diff --git a/CHANGELOG.md b/CHANGELOG.md index 9850757..0ac4010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - The **Live** list shows the workflows someone has open right now, with their folder and who is in them; the Files tab marks live files - A private workflow's panel offers **Move to Shared…** to take it live - Deleting a live file closes it for the people who have it open (a snapshot is kept); moving one into a private folder takes it out of live sharing +- Renaming or moving a file or folder re-points everyone's open tabs to the new path, so nobody recreates it under the old name. People who can't open the new location are told nothing about it and keep a private copy ### Removed diff --git a/docs/sharing.md b/docs/sharing.md index 6da455a..10d2666 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -34,7 +34,8 @@ Viewers (no *Edit* permission) see a read-only canvas. Anything they change loca ## When a workflow stops being live - When everyone closes it, it leaves the live list. The server keeps its latest state, so the next person to open it picks up where the others left off, saved or not. That state is forgotten after 14 days without anyone opening it (`room_expiry_days`). -- Moving it into a private folder takes it out of live sharing straight away. +- Renaming or moving it (or its folder) keeps it live: everyone who has it open follows it to the new name, and its history and access list go with it. +- Moving it into a private folder takes it out of live sharing straight away. Its owner's tab follows it; everyone else keeps a private copy. - Deleting it (admins, or the owner of its shared folder) closes it for the people who have it open. Their tabs stay as private copies, and a *Before delete* snapshot is kept. ## Who can open it diff --git a/rigshare_core/hub.py b/rigshare_core/hub.py index 4181212..954ad6d 100644 --- a/rigshare_core/hub.py +++ b/rigshare_core/hub.py @@ -361,6 +361,40 @@ class Hub: moved.append(room.key) return moved + def path_access(self, path, client, key=None, room=None): + """May ``client`` open the workflows path (file or folder)? ``room`` adds its access list.""" + key = client.key if key is None else key + if room is not None and self._room_access(room, client.kind, key, client.perms) is None: + return False + rel = Workspace.workflows_rel(path) + if rel is None or not self.workspace: + return True + return self.workspace.role(rel, client.kind, key, client.perms) is not None + + def who_sees(self, path): + """Connected clients that may open ``path``; call before moving it.""" + room = self.rooms.get("file:" + path) + return [c for c in self.clients.values() if c.kind and self.path_access(path, c, room=room)] + + async def path_moved(self, old, new, renamed=None, seen_by=None): + """A workflow file or folder moved (userdata paths, e.g. ``workflows/a.json``). + + Its rooms follow it, and everyone who could see the old location + (``seen_by``, taken before the move changed any folder settings) is told + where it went so their open tabs follow too, or that it went somewhere + they cannot open. ``renamed`` maps usernames changed by the same action. + """ + room = self.rooms.get("file:" + old) + notes = [] + for client in self.who_sees(old) if seen_by is None else seen_by: + key = (renamed or {}).get(client.key, client.key) if client.kind == "user" else client.key + notes.append((client, self.path_access(new, client, key=key, room=room))) + self.rekey_rooms("file:" + old, "file:" + new) + for client, follows in notes: + await self.send(client, {"type": "path_moved", "from": old, "to": new} if follows + else {"type": "path_closed", "from": old}) + await self.broadcast_presence() + async def refresh_rooms_access(self): """Folder access changed: remove people from rooms they can no longer open.""" for client in list(self.clients.values()): diff --git a/rigshare_core/routes.py b/rigshare_core/routes.py index 90188a8..6f6571c 100644 --- a/rigshare_core/routes.py +++ b/rigshare_core/routes.py @@ -294,7 +294,8 @@ def setup(server, store, hub): hub.rename_member(username, user["username"]) if hub.workspace: hub.workspace.rename_user(username, user["username"]) - hub.rekey_rooms(f"file:workflows/users/{username}", f"file:workflows/users/{user['username']}") + await hub.path_moved(f"workflows/users/{username}", f"workflows/users/{user['username']}", + renamed={username: user["username"]}) log.info(f"[RigShare] {admin['username']} renamed @{username} to @{user['username']}") await hub.refresh_identities() else: @@ -389,20 +390,22 @@ def setup(server, store, hub): async def ws_rename_dir(request): body = await request.json() old = norm(body.get("path")) + seen_by = hub.who_sees("workflows/" + old) err, new = ws_call(lambda: ws().rename_dir(old, body.get("name"), *who(request))) if err: return err - hub.rekey_rooms("file:workflows/" + old, "file:workflows/" + new) + await hub.path_moved("workflows/" + old, "workflows/" + new, seen_by=seen_by) return web.json_response({"path": new}) @routes.post("/rigshare/api/workspace/move-folder") async def ws_move_dir(request): body = await request.json() old = norm(body.get("path")) + seen_by = hub.who_sees("workflows/" + old) err, new = ws_call(lambda: ws().move_dir(old, body.get("dest", ""), *who(request))) if err: return err - hub.rekey_rooms("file:workflows/" + old, "file:workflows/" + new) + await hub.path_moved("workflows/" + old, "workflows/" + new, seen_by=seen_by) return web.json_response({"path": new}) @routes.post("/rigshare/api/workspace/delete-folder") @@ -458,8 +461,9 @@ def setup(server, store, hub): kind, key, _ = who(request) ws().set_acl(folder, acl, fallback_owner=key if kind == "user" else None) if body.get("name"): + seen_by = hub.who_sees(f"workflows/{folder}") new_folder = ws().rename_folder(folder, body["name"]) - hub.rekey_rooms(f"file:workflows/{folder}", f"file:workflows/{new_folder}") + await hub.path_moved(f"workflows/{folder}", f"workflows/{new_folder}", seen_by=seen_by) folder = new_folder except ValueError as e: return error(str(e)) @@ -737,8 +741,7 @@ def setup(server, store, hub): response = filter_listing(request, response, userdata[1]) # A file moved on disk: its live room follows it. if userdata and userdata[0] == "move" and getattr(response, "status", 0) == 200: - hub.rekey_rooms("file:" + userdata[1], "file:" + userdata[2]) - await hub.broadcast_presence() + await hub.path_moved(userdata[1], userdata[2]) if userdata and userdata[0] == "delete" and getattr(response, "status", 0) in (200, 204): await hub.drop_rooms("file:" + userdata[1], display(request)) # Hide API templates: rewrite the template index ComfyUI serves. diff --git a/tests/js/sync.test.mjs b/tests/js/sync.test.mjs index 1d0a2ff..fd5ecb5 100644 --- a/tests/js/sync.test.mjs +++ b/tests/js/sync.test.mjs @@ -91,3 +91,40 @@ test("live follows folders: saved files outside private folders only", () => { assert.equal(sync.privacyOf(tab("workflows/Unsaved Workflow.json", true)), "unsaved"); assert.ok(isPrivatePath("workflows/users") && !isPrivatePath("workflows/usersx/a.json")); }); + +test("a file renamed by someone else re-points the open tab and rejoins its room", async () => { + class Wf { + constructor(path) { this.updatePath(path); this.isTemporary = false; } + updatePath(path) { this.path = path; this.filename = path.split("/").pop(); } + async rename() { throw new Error("the file already moved: must not call the server"); } + } + const wf = new Wf("workflows/shared/a.json"); + const other = new Wf("workflows/other.json"); + const store = { + activeWorkflow: wf, openWorkflows: [wf, other], + async renameWorkflow(w, path) { await w.rename(path); }, // like ComfyUI's store + }; + const sent = []; + const client = Object.assign(new EventTarget(), { + online: true, perms: { edit: true }, rooms: [], users: [], + on(type, fn) { this.addEventListener(type, (e) => fn(e.detail)); }, + emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); }, + send(m) { sent.push(m); return true; }, + }); + const app = { extensionManager: { workflow: store }, canvas: null, loadGraphData: async () => {}, + rootGraph: { serialize: () => ({ nodes: [], links: [], extra: {} }), extra: {}, getNodeById: () => null } }; + const sync = new RoomSync(app, new EventTarget(), client); + sync.tick(); + assert.equal(sent.at(-1).room, "file:workflows/shared/a.json"); + + client.emit("path_moved", { from: "workflows/shared", to: "workflows/shared2" }); + await sync.busy; + assert.equal(wf.path, "workflows/shared2/a.json"); + assert.equal(other.path, "workflows/other.json"); + assert.ok(!Object.hasOwn(wf, "rename"), "the real rename is restored"); + assert.equal(sent.at(-1).room, "file:workflows/shared2/a.json"); + + client.emit("path_closed", { from: "workflows/shared2/a.json" }); + sync.tick(); + assert.equal(sent.at(-1).room, null, "moved out of reach: the tab leaves the room"); +}); diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 581ed38..0344c1b 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -243,3 +243,47 @@ def test_live_follows_folders(run): assert key2 not in rig.hub.rooms await alice.close(); await bob.close() run(scenario()) + + +def test_moves_follow_open_tabs(run): + async def scenario(): + async with rig_server() as rig: + await rig.setup_admin() + for name in ("alice", "bob", "carol", "dave"): + await rig.add_user(name, edit=True) + H, h = rig.headers, rig.http + socks = {n: await rig.ws(n) for n in ("alice", "bob", "carol", "dave")} + + async def moves(name): + return [m for m in await socks[name].drain() if m["type"] in ("path_moved", "path_closed")] + + # A live file renamed: everyone who could open it is told, and its room follows. + await h.post(rig.url(f"/api/userdata/{enc('workflows/a.json')}"), data=b"{}", headers=H["alice"]) + await socks["bob"].send({"type": "join", "room": "file:workflows/a.json", "name": "a", "doc": BASE_DOC}) + await socks["bob"].until("room") + for sock in socks.values(): + await sock.drain() + r = await h.post(rig.url(f"/api/userdata/{enc('workflows/a.json')}/move/{enc('workflows/b.json')}"), headers=H["alice"]) + assert r.status == 200 + assert await moves("bob") == [{"type": "path_moved", "from": "workflows/a.json", "to": "workflows/b.json"}] + assert "file:workflows/b.json" in rig.hub.rooms and "file:workflows/a.json" not in rig.hub.rooms + + # Into a private folder: the owner follows it, the others keep a private copy. + r = await h.post(rig.url(f"/api/userdata/{enc('workflows/b.json')}/move/{enc('workflows/users/alice/b.json')}"), headers=H["boss"]) + assert r.status == 200 + assert (await moves("alice"))[0]["type"] == "path_moved" + assert await moves("bob") == [{"type": "path_closed", "from": "workflows/b.json"}] + assert not rig.hub.rooms + + # A restricted shared folder renamed: people without access learn nothing. + await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Team"}, headers=H["alice"]) + await h.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "view"}}, headers=H["alice"]) + for sock in socks.values(): + await sock.drain() + r = await h.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "name": "Crew"}, headers=H["alice"]) + assert r.status == 200 + assert await moves("carol") == [{"type": "path_moved", "from": "workflows/shared/Team", "to": "workflows/shared/Crew"}] + assert await moves("dave") == [] + for sock in socks.values(): + await sock.close() + run(scenario()) diff --git a/web/rigshare/sync.js b/web/rigshare/sync.js index 9c2f32c..993c18d 100644 --- a/web/rigshare/sync.js +++ b/web/rigshare/sync.js @@ -150,6 +150,19 @@ export class RoomSync extends EventTarget { this.updateReadOnly(); this.emitState(); }); + client.on("path_moved", (msg) => this.enqueue(() => this.followMove(msg.from, msg.to))); + client.on("path_closed", (msg) => { + // Moved somewhere we cannot open: matching tabs stay as private copies. + const tabs = this.tabsUnder(msg.from); + for (const wf of tabs) this.denied.add(`file:${wf.path}`); + if (tabs.includes(this.targetWf)) { + this.ready = false; + this.room = null; + } + if (tabs.length) client.emit("toast", { severity: "info", summary: "RigShare", detail: `"${this.tabName(tabs[0])}" was moved somewhere you can't open. Your tab stays as a private copy.` }); + this.updateReadOnly(); + this.emitState(); + }); client.on("presence", () => { // Access granted again: the room shows up in our list. for (const key of this.denied) if (client.rooms.some((r) => r.key === key)) this.denied.delete(key); @@ -498,6 +511,45 @@ export class RoomSync extends EventTarget { await store.syncWorkflows?.(); } + /** Open saved tabs at ``path`` or inside it (a folder). */ + tabsUnder(path) { + return (this.store?.openWorkflows || []).filter((wf) => + !wf.isTemporary && (wf.path === path || wf.path?.startsWith(path + "/"))); + } + + /** + * Someone renamed or moved a file (or folder) we have open: point our tabs + * at the new path. The file already moved on the server, so ComfyUI's own + * rename runs with the server call skipped; it still updates the tab list, + * drafts, thumbnails and bookmarks. + */ + async followMove(from, to) { + const store = this.store; + let moved = false; + for (const wf of this.tabsUnder(from)) { + const target = to + wf.path.slice(from.length); + if (target === wf.path) continue; + moved = true; + this.denied.delete(`file:${target}`); + if (store?.renameWorkflow && typeof wf.updatePath === "function") { + wf.rename = async (path) => { wf.updatePath(path); return wf; }; + try { + await store.renameWorkflow(wf, target); + } finally { + delete wf.rename; + } + } else if (typeof wf.updatePath === "function") { + wf.updatePath(target); + } else { + wf.path = target; + } + } + if (!moved) return; + // Rejoin under the new name (or leave, if it went private). + this.targetKey = undefined; + this.tick(); + } + /** Find an already-open tab for a room. */ findTab(room) { for (const wf of this.store?.openWorkflows || []) {