diff --git a/rigshare_core/hub.py b/rigshare_core/hub.py index c7838e7..0059775 100644 --- a/rigshare_core/hub.py +++ b/rigshare_core/hub.py @@ -21,7 +21,7 @@ from aiohttp import web, WSMsgType from .chatlog import ChatLog from .graphdoc import apply_patch, remap_conflicts from .stats import collect_stats -from .workspace import Workspace +from .workspace import Workspace, has_app log = logging.getLogger("ComfyUI-RigShare") @@ -285,7 +285,7 @@ class Hub: "updated": int(room.updated), "members": members, "nodes": len((room.doc or {}).get("nodes") or []), "restricted": self.folder_restricted(self.folder_of(room)), - "app": room.key.endswith(".app.json")}) + "app": room.key.endswith(".app.json") or has_app(room.doc)}) out.sort(key=lambda r: (-len(r["members"]), -r["updated"])) return out diff --git a/rigshare_core/workspace.py b/rigshare_core/workspace.py index 9ec7945..7a8b04b 100644 --- a/rigshare_core/workspace.py +++ b/rigshare_core/workspace.py @@ -11,6 +11,7 @@ Paths handled here are relative to the *userdata* root, e.g. every path is fully URL-decoded and normalised before it is checked. """ +import json import os import posixpath import re @@ -40,6 +41,13 @@ def norm(path): return posixpath.normpath("/" + path).lstrip("/") if path else "" +def has_app(doc): + """A workflow with an App set up (ComfyUI App mode: chosen inputs or outputs).""" + data = (doc or {}).get("extra") if isinstance(doc, dict) else None + linear = (data or {}).get("linearData") if isinstance(data, dict) else None + return isinstance(linear, dict) and bool(linear.get("inputs") or linear.get("outputs")) + + def min_role(a, b): return a if ROLE_RANK[a] <= ROLE_RANK[b] else b @@ -51,6 +59,7 @@ class Workspace: self.folders = store._read("folders.json", {}) # "shared/" -> {owner, acl, created} os.makedirs(os.path.join(root, "users"), exist_ok=True) os.makedirs(os.path.join(root, "shared"), exist_ok=True) + self._app_cache = {} # absolute path -> (mtime, size, has an App) def save(self): self.store._write("folders.json", self.folders) @@ -191,6 +200,24 @@ class Workspace: def _abs(self, rel): return os.path.join(self.root, *[p for p in rel.split("/") if p]) + def _file_has_app(self, full): + """Read a workflow file only when it changed since the last check.""" + try: + st = os.stat(full) + except OSError: + return False + cached = self._app_cache.get(full) + if cached and cached[0] == st.st_mtime and cached[1] == st.st_size: + return cached[2] + try: + with open(full, "r", encoding="utf-8") as f: + text = f.read() + found = '"linearData"' in text and has_app(json.loads(text)) + except (OSError, ValueError): + found = False + self._app_cache[full] = (st.st_mtime, st.st_size, found) + return found + def _entry(self, rel, name, is_dir, kind, key, perms): full = self._abs(rel) is_app = not is_dir and name.endswith(".app.json") # saved as an App (ComfyUI App mode) @@ -203,7 +230,7 @@ class Workspace: meta = self.folders.get(folder) or {} entry.update(owner=meta.get("owner"), restricted=bool(meta.get("acl")), manage=self.can_manage(folder, kind, key, perms)) - if is_app: + if is_app or (not is_dir and self._file_has_app(full)): entry["app"] = True entry["deletable"] = self.can_delete(rel, kind, key, perms) return entry diff --git a/tests/python/test_server.py b/tests/python/test_server.py index cdc82dd..bcc92ea 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -390,3 +390,31 @@ def test_listing_inside_a_shared_folder(run): assert (await here(bob, "shared/FaceSwap"))["manage"] is False assert await here(alice, "users/alice") is None run(scenario()) + + +def test_workflows_with_an_app_set_up(run): + async def scenario(): + async with rig_server() as rig: + await rig.setup_admin() + alice = await rig.add_user("alice", edit=True) + h = rig.http + with_app = json.dumps({"nodes": [], "extra": {"linearData": {"inputs": [[3, "seed"]], "outputs": [9]}}}).encode() + empty_app = json.dumps({"nodes": [], "extra": {"linearData": {"inputs": [], "outputs": []}}}).encode() + for name, body in (("faceswap.json", with_app), ("empty.json", empty_app), ("plain.json", b"{}")): + await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/' + name)}"), data=body, headers=alice) + + async def apps(): + listing = await (await h.get(rig.url("/rigshare/api/workspace/list?path=users/alice"), headers=alice)).json() + return {e["name"] for e in listing["entries"] if e.get("app")} + assert await apps() == {"faceswap"}, "only a workflow whose App has inputs or outputs" + # Edited on disk: the cached answer follows the file. + await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/faceswap.json')}"), data=b'{"nodes": []}', headers=alice) + assert await apps() == set() + + sock = await rig.ws("alice") + await sock.send({"type": "join", "room": "file:workflows/shared/f.json", "name": "f", "doc": json.loads(with_app)}) + await sock.until("room") + presence = [m for m in await sock.drain() if m["type"] == "presence"][-1] + assert presence["rooms"][0]["app"] is True + await sock.close() + run(scenario()) diff --git a/web/rigshare/styles.js b/web/rigshare/styles.js index 17296c9..492ed20 100644 --- a/web/rigshare/styles.js +++ b/web/rigshare/styles.js @@ -204,7 +204,7 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; } .rs-fb-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 7px 8px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; } .rs-fb-main:disabled { cursor: default; } -.rs-fb-icon { font-size: 16px; color: var(--rs-muted); width: 18px; text-align: center; flex: none; } +.rs-fb-icon { font-size: 13px; color: var(--rs-muted); width: 15px; text-align: center; flex: none; } .rs-fb-icon.folder { color: #f5b942; } .rs-fb-tools-row { display: none; gap: 3px; padding-right: 6px; } .rs-fb-row:hover .rs-fb-tools-row, .rs-fb-row:focus-within .rs-fb-tools-row { display: flex; }