Sharing follows folders: workflows outside private folders are live while open

Replaces Share this tab / Stop sharing and the auto-share settings. The Live
list shows workflows someone has open, the Files tab marks live files,
private tabs offer Move to Shared, and deleting or moving a live file into a
private folder closes its room on the server.
This commit is contained in:
Edouard Durand
2026-09-26 02:42:07 +00:00
parent 7a5385c46a
commit e1d103564b
13 changed files with 214 additions and 239 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## Unreleased
### Changed
- Sharing follows folders: every saved workflow outside a private folder is live while it is open, with nothing to share by hand. Unsaved tabs and files in *My files* stay private
- 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
### Removed
- *Share this tab*, *Stop sharing*, the automatic-sharing settings and the admin 🗑 for idle rooms
## 1.0.0
First release of RigShare, a rewrite of [ComfyUI-Nexus](https://github.com/daxcay/ComfyUI-Nexus) 1.0.2 for the current ComfyUI frontend.
+1 -1
View File
@@ -90,7 +90,7 @@ Behind a reverse proxy, turn on WebSocket support, or nothing will be live. See
| | |
|---|---|
| [Installing](docs/install.md) | Custom node, Docker/compose, first-run setup, upgrading from Nexus |
| [Sharing workflows](docs/sharing.md) | Rooms, saved vs unsaved tabs, stop sharing, snapshots, follow |
| [Sharing workflows](docs/sharing.md) | Live workflows by folder, access lists, snapshots, follow |
| [Accounts and permissions](docs/accounts.md) | Edit, Queue, Admin, renaming, what viewers can do |
| [API access](docs/api.md) | API keys, websockets, trusted IPs, other apps such as Open WebUI |
| [Settings](docs/settings.md) | Panel settings, ComfyUI settings, every key in `config.json` |
+1 -1
View File
@@ -8,7 +8,7 @@
| **Edit** | Change shared workflows, save or rename workflow files, take and restore snapshots, upload files |
| **Queue** | Queue prompts, interrupt, free memory |
| **Manager** | Open ComfyUI Manager: install, update or remove custom nodes and models, snapshots, restart |
| **Admin** | Everything, plus deleting workflow files, removing shared workflows, accounts and server settings |
| **Admin** | Everything, plus deleting workflow files, accounts and server settings |
Queueing, uploads, and saving or deleting workflow files are checked by the server too, when *Enforce permissions on the ComfyUI API* is on (the default). A viewer cannot get around it with a script.
+2 -4
View File
@@ -6,14 +6,12 @@ In ComfyUI's settings dialog, under **RigShare**. They apply to your browser onl
| Setting | Default |
|---|---|
| Share saved workflows automatically | on |
| Share unsaved tabs automatically once edited | on |
| Show other people's cursors and selections | on |
| Show names next to cursors | on |
| Play a sound for new chat messages | on |
| Show a notification for new chat messages | off |
Commands, bindable under *Keybindings*: **RigShare: Open chat** and **RigShare: Share this tab**.
Commands, bindable under *Keybindings*: **RigShare: Open chat** and **Open from RigShare…** (Ctrl+Alt+O).
The **Server** tab's *Pop out* button toggles the floating monitor. Drag it by its title; its position is remembered.
@@ -32,7 +30,7 @@ Admins change these under **Admin → Server settings**. They are stored in `rig
| `hide_api_templates` | `false` | Remove templates that need paid API nodes from the template browser |
| `api_protection.enabled` | `true` | Server-side checks on queue, interrupt, free and upload calls |
| `api_protection.trusted_ips` | `["127.0.0.1", "::1"]` | Addresses or networks that skip login and checks |
| `room_expiry_days` | `14` | Forget shared workflows nobody opened for this long |
| `room_expiry_days` | `14` | Forget the live state of workflows nobody opened for this long |
| `snapshot_interval_sec` | `300` | Automatic snapshot interval while editing; `0` turns it off |
| `snapshot_keep` | `30` | Automatic snapshots kept per workflow; named ones are always kept |
| `chat_history` | `200` | Chat messages kept |
+14 -18
View File
@@ -1,22 +1,23 @@
# Sharing workflows
Every shared workflow is a *room* on the server. The server keeps the current version of each room and relays edits between everyone who has it open.
Sharing follows folders, like a shared drive. There is nothing to share by hand: where a workflow is saved decides who works on it with you.
## Which tabs are shared
## Which tabs are live
| Tab | Shared? |
| Tab | Live? |
|---|---|
| A saved workflow file | Yes, automatically. Everyone who opens the same file lands in the same room. |
| An unsaved tab | As soon as you edit it, or when you click **Share this tab**. Untouched tabs, such as the default workflow or tabs restored from your last visit, stay private. |
| A workflow saved in **Shared** or at the top level of the workflows folder | Yes, while it is open. Everyone who opens the same file lands in the same room and edits it together. |
| A workflow in **My files** (your private folder) | No. Only you (and admins) can open it. |
| An unsaved tab | No. Save it outside your private folder to work on it with others. |
| A snapshot opened as a copy | No, it is a private copy. |
Both automatic behaviours can be turned off in ComfyUI's settings under *RigShare*.
To take a private workflow live, move it out of *My files*: **Move to Shared…** in the panel, **Move to…** in the Files tab, or save it somewhere shared.
Only the tab on screen is live. Switch to another tab and you leave the room; switch back and you rejoin. If the room changed meanwhile, you get the current version.
Only the tab on screen is live. Switch to another tab and you leave the room; switch back and you rejoin. If the workflow changed meanwhile, you get the current version.
## The panel
**Workflows** shows the tab on screen: whether it is live, who else is in it, and buttons for **Snapshot**, **History** and **Stop sharing**. Below that is the list of shared workflows, with the people in each one. Open any of them with the arrow.
**Live** shows the tab on screen: whether it is live, who else is in it, and buttons for **Snapshot** and **History**. For a private or unsaved tab it says why it is not live. Below that is the list of **live workflows**: those someone has open right now, with their folder and the people in each one. Open any of them with the arrow. The Files tab marks live files with a green dot.
**People** lists who is online and which workflow they are on. The eye button *follows* someone: it jumps to their tab and keeps your view on theirs until you click or scroll the canvas.
@@ -30,20 +31,15 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw
Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back.
## Stop sharing
## When a workflow stops being live
**Stop sharing** takes the tab out of its room and keeps working on a private copy.
- For an **admin**, if nobody else is in the room, it is also removed from the shared list, and a *Before unshare* snapshot is kept in its history.
- Otherwise, including for everyone who is not an admin, you just leave. It stays shared for the others.
Only admins can remove a shared workflow. Workflows nobody has open show a 🗑 button for them in the list. For a saved file, stopping sharing only applies to you. Anyone else who opens the file shares it again, unless they turned off automatic sharing.
Rooms nobody opens for 14 days are forgotten (`room_expiry_days`).
- 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.
- 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
By default everyone with an account can open a shared workflow, with their usual permissions. The **Access** button under *This tab* lets its owner, whoever shared it first, or any admin choose *Only people I choose* and give each person **Can view** or **Can edit**.
By default everyone who can open its folder can open a live workflow, with their usual permissions. The **Access** button under *This tab* lets its owner (whoever opened it first) or any admin choose *Only people I choose* and give each person **Can view** or **Can edit**.
- The owner and admins always have access.
- A role can only narrow someone's account permissions: *Can edit* does nothing for an account without *Edit*.
+42 -45
View File
@@ -1,9 +1,9 @@
"""Real-time hub: presence, chat, cursors, server stats and shared rooms.
Every workflow tab that is shared maps to a *room* (a saved workflow file is
the room ``file:<path>``; a shared unsaved tab is ``tmp:<id>``). The server
holds the authoritative document of each room and relays patches between the
browsers that currently have that room on screen.
Sharing follows folders: every saved workflow outside a private folder is a
*room* (``file:<path>``) while it is open. The server holds the authoritative
document of each room and relays patches between the browsers that currently
have it on screen. Unsaved tabs and private files never get a room.
Clients identify once (session token or guest id); from then on the server
stamps relayed messages with the sender identity and checks permissions.
@@ -21,6 +21,7 @@ from aiohttp import web, WSMsgType
from .graphdoc import apply_patch, remap_conflicts
from .stats import collect_stats
from .workspace import Workspace
log = logging.getLogger("ComfyUI-RigShare")
@@ -37,6 +38,13 @@ def clean_text(text, limit):
ROLES = ("view", "edit")
def is_live_key(key):
"""Only saved workflows outside a private folder can be live."""
if not isinstance(key, str) or not key.startswith("file:workflows/"):
return False
return Workspace.area(key[len("file:workflows/"):])[0] not in ("private", "users-root")
class Room:
def __init__(self, key, name, kind, doc, version=0, updated=None, owner=None, acl=None):
self.key = key
@@ -91,6 +99,10 @@ class Hub:
self.clients = {}
self.rooms = {}
for data in store.load_rooms():
if not is_live_key(data.get("key")):
# Unsaved-tab rooms and private files from older versions.
store.delete_room(data.get("key", ""))
continue
try:
self.rooms[data["key"]] = Room(data["key"], data.get("name") or data["key"], data.get("kind", "file"),
data.get("doc"), data.get("version", 0), data.get("updated"),
@@ -225,23 +237,22 @@ class Hub:
except Exception:
pass
def drop_if_unused(self, key):
"""Forget an unsaved-tab room nobody edited once its last member leaves."""
room = self.rooms.get(key)
if room and room.kind == "tmp" and room.version == 0 and not self.members(key):
self.rooms.pop(key, None)
async def drop_rooms(self, prefix, by, label="Before delete"):
"""The file (or folder) is gone or went private: forget its rooms. Snapshots are kept."""
dropped = False
for key in list(self.rooms):
if key != prefix and not key.startswith(prefix + "/"):
continue
room = self.rooms.pop(key)
self.store.delete_room(key)
def remove_room(self, key, by):
"""Stop sharing a workflow nobody has open. Snapshots are kept."""
room = self.rooms.get(key)
if room is None or self.members(key):
return False
if room.doc is not None and room.version > 0:
self.snapshot(room, by, "Before unshare")
self.rooms.pop(key, None)
self.store.delete_room(key)
return True
if room.doc is not None and room.version > 0:
self.snapshot(room, by, label)
for client in self.members(key):
client.room = None
await self.send(client, {"type": "room_closed", "room": key, "name": room.name})
dropped = True
if dropped:
await self.broadcast_presence()
def members(self, key):
return [c for c in self.clients.values() if c.kind and c.room == key]
@@ -285,13 +296,13 @@ class Hub:
"restricted": bool(room.acl), "owner": room.owner}
def room_list(self, client=None):
cutoff = time.time() - 7 * 86400
"""Live workflows: the rooms someone has open right now."""
out = []
for room in self.rooms.values():
if client is not None and self.client_access(room, client) is None:
continue
members = [c.id for c in self.members(room.key)]
if members or room.updated > cutoff:
if members:
out.append({"key": room.key, "name": room.name, "kind": room.kind, "version": room.version,
"updated": int(room.updated), "members": members,
"nodes": len((room.doc or {}).get("nodes") or []),
@@ -339,12 +350,14 @@ class Hub:
if key == old_prefix or key.startswith(old_prefix + "/"):
room = self.rooms.pop(key)
self.store.delete_room(key)
for client in self.members(key):
client.room = None
room.key = new_prefix + key[len(old_prefix):]
if not is_live_key(room.key):
continue # moved into a private folder: no longer live
room.name = room.key.rsplit("/", 1)[-1].removesuffix(".json")
room.dirty = True
self.rooms[room.key] = room
for client in self.members(key):
client.room = None
moved.append(room.key)
return moved
@@ -422,8 +435,6 @@ class Hub:
log.error(f"[RigShare] websocket error: {e}")
finally:
self.clients.pop(client.id, None)
if client.room:
self.drop_if_unused(client.room)
if client.kind:
await self.broadcast({"type": "leave", "id": client.id})
await self.broadcast_presence()
@@ -462,13 +473,16 @@ class Hub:
previous = client.room
if not key:
client.room = None
elif not is_live_key(key):
client.room = None
await self.send(client, {"type": "room_denied", "room": key, "name": clean_text(data.get("name"), 120) or key,
"reason": "Only saved workflows outside a private folder are live."})
else:
room = self.rooms.get(key)
created = False
if room is None:
doc = data.get("doc") if isinstance(data.get("doc"), dict) else {}
room_kind = "file" if key.startswith("file:") else "tmp"
room = Room(key, clean_text(data.get("name"), 120) or key, room_kind, doc,
room = Room(key, clean_text(data.get("name"), 120) or key, "file", doc,
owner=client.key if client.kind == "user" else None)
room.dirty = True
self.rooms[key] = room
@@ -485,25 +499,8 @@ class Hub:
if previous != client.room:
if previous:
await self.broadcast({"type": "leave", "id": client.id}, room=previous)
self.drop_if_unused(previous)
await self.broadcast_presence()
elif kind == "unshare":
# Leave the room; an admin also removes it from the shared list if nobody else is in it.
key = data.get("room")
room = self.rooms.get(key)
if room is None:
return
if client.room == key:
client.room = None
await self.broadcast({"type": "leave", "id": client.id}, room=key)
others = self.members(key)
# Only admins remove a workflow from the shared list; others just leave.
removed = not others and client.perms.get("admin") and self.remove_room(key, client.name)
await self.send(client, {"type": "unshared", "room": key, "name": room.name, "removed": removed,
"others": [c.name for c in others]})
await self.broadcast_presence()
elif kind == "cursor":
if not client.room:
return
+9 -16
View File
@@ -408,8 +408,12 @@ def setup(server, store, hub):
@routes.post("/rigshare/api/workspace/delete-folder")
async def ws_delete_dir(request):
body = await request.json()
err, _ = ws_call(lambda: ws().delete_dir(body.get("path", ""), *who(request)))
return err or web.json_response({"ok": True})
path = norm(body.get("path", ""))
err, _ = ws_call(lambda: ws().delete_dir(path, *who(request)))
if err:
return err
await hub.drop_rooms("file:workflows/" + path, display(request))
return web.json_response({"ok": True})
@routes.get("/rigshare/api/workspace/folder")
async def folder_acl(request):
@@ -519,20 +523,6 @@ def setup(server, store, hub):
f"{'everyone' if acl is None else ', '.join(f'{u}={r}' for u, r in acl['members'].items()) or 'owner only'}")
return web.json_response({"ok": True, "owner": room.owner, "restricted": bool(acl)})
@routes.delete("/rigshare/api/room")
async def delete_room(request):
require_admin(request)
key = request.query.get("key", "")
room = hub.rooms.get(key)
if not room:
return error("No such room", 404)
if hub.members(key):
names = ", ".join(c.name for c in hub.members(key))
return error(f"Still open by {names}", 409)
hub.remove_room(key, display(request))
await hub.broadcast_presence()
return web.json_response({"ok": True})
@routes.get("/rigshare/api/room")
async def get_room(request):
room = hub.rooms.get(request.query.get("key", ""))
@@ -748,6 +738,9 @@ def setup(server, store, hub):
# 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()
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.
if (store.config.get("hide_api_templates") and request.method == "GET"
and path.startswith("/templates/index") and path.endswith(".json")):
+16 -2
View File
@@ -7,7 +7,7 @@ globalThis.window ??= new EventTarget();
const nativeSetInterval = globalThis.setInterval;
globalThis.setInterval = (...args) => { const t = nativeSetInterval(...args); t.unref?.(); return t; };
globalThis.performance ??= { now: () => Date.now() };
const { RoomSync, snapshotOf, diffSnapshots } = await import("../../web/rigshare/sync.js");
const { RoomSync, snapshotOf, diffSnapshots, isPrivatePath } = await import("../../web/rigshare/sync.js");
const { applyPatch, reconcile } = await import("../../web/rigshare/graphdoc.js");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -63,7 +63,6 @@ test("opening a file never overwrites the shared workflow of the previous tab",
};
const api = new EventTarget();
const sync = new RoomSync(app, api, client);
sync.overrides[tabA.path] = true;
sync.tick();
client.emit("room", { room: `file:${tabA.path}`, version: 1, doc: graphA, role: "edit", manage: true });
await sleep(400);
@@ -77,3 +76,18 @@ test("opening a file never overwrites the shared workflow of the previous tab",
const leaked = sent.filter((m) => m.type === "graph" && m.room === `file:${tabA.path}`);
assert.equal(leaked.length, 0, "the previous tab's room must not receive the new graph");
});
test("live follows folders: saved files outside private folders only", () => {
const store = { activeWorkflow: null, openWorkflows: [] };
const client = Object.assign(new EventTarget(), { online: false, perms: {}, rooms: [], users: [], on() {}, send() { return true; } });
const app = { extensionManager: { workflow: store }, loadGraphData: async () => {} };
const sync = new RoomSync(app, new EventTarget(), client);
const tab = (path, isTemporary = false) => ({ path, filename: path.split("/").pop(), isTemporary });
assert.equal(sync.roomFor(tab("workflows/flow.json"))?.key, "file:workflows/flow.json");
assert.equal(sync.roomFor(tab("workflows/shared/Team/a.json"))?.key, "file:workflows/shared/Team/a.json");
assert.equal(sync.roomFor(tab("workflows/users/bob/a.json")), null);
assert.equal(sync.privacyOf(tab("workflows/users/bob/a.json")), "private");
assert.equal(sync.roomFor(tab("workflows/Unsaved Workflow.json", true)), null);
assert.equal(sync.privacyOf(tab("workflows/Unsaved Workflow.json", true)), "unsaved");
assert.ok(isPrivatePath("workflows/users") && !isPrivatePath("workflows/usersx/a.json"));
});
+46
View File
@@ -197,3 +197,49 @@ def test_folders_over_comfy_api(run):
await h.patch(rig.url("/rigshare/api/users/alice"), json={"username": "alicia"}, headers=H["boss"])
assert os.path.exists(os.path.join(rig.user_root, "workflows", "users", "alicia", "keep.json"))
run(scenario())
def test_live_follows_folders(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
for name in ("alice", "bob"):
await rig.add_user(name, edit=True)
alice, bob = await rig.ws("alice"), await rig.ws("bob")
# Your own private folder and unsaved tabs never become live.
for key in ("file:workflows/users/alice/mine.json", "tmp:abc"):
await alice.send({"type": "join", "room": key, "name": "x", "doc": BASE_DOC})
assert (await alice.until("room_denied"))["room"] == key
assert not rig.hub.rooms
# Anything else is live while open, and only open ones are listed.
key = "file:workflows/shared/team.json"
await alice.send({"type": "join", "room": key, "name": "team", "doc": BASE_DOC})
await alice.until("room")
presence = [m for m in await bob.drain() if m["type"] == "presence"][-1]
assert [r["key"] for r in presence["rooms"]] == [key]
await alice.send({"type": "join", "room": None})
presence = [m for m in await bob.drain() if m["type"] == "presence"][-1]
assert presence["rooms"] == [], "closed workflows leave the live list"
assert key in rig.hub.rooms, "its state is kept for the next person who opens it"
# Moving it into a private folder takes it out of live sharing.
path = "workflows/shared/team.json"
await rig.http.post(rig.url(f"/api/userdata/{enc(path)}"), data=b"{}", headers=rig.headers["alice"])
await bob.send({"type": "join", "room": key, "name": "team"})
await bob.until("room")
r = await rig.http.post(rig.url(f"/api/userdata/{enc(path)}/move/{enc('workflows/users/alice/team.json')}"), headers=rig.headers["boss"])
assert r.status == 200
assert not rig.hub.rooms
# Deleting a live file closes it for whoever has it open.
key2, path2 = "file:workflows/gone.json", "workflows/gone.json"
await rig.http.post(rig.url(f"/api/userdata/{enc(path2)}"), data=b"{}", headers=rig.headers["alice"])
await bob.send({"type": "join", "room": key2, "name": "gone", "doc": BASE_DOC})
await bob.until("room")
assert (await rig.http.delete(rig.url(f"/api/userdata/{enc(path2)}"), headers=rig.headers["boss"])).status == 204
assert (await bob.until("room_closed"))["room"] == key2
assert key2 not in rig.hub.rooms
await alice.close(); await bob.close()
run(scenario())
+6 -1
View File
@@ -31,6 +31,8 @@ export class FilesBrowser {
h("div", { class: "rs-fb-bar" }, this.crumbs, this.actions),
h("div", { class: "rs-fb-tools" }, this.search),
this.list);
// The sidebar browser lives as long as the page: keep its live dots current.
if (mode === "browse") client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
}
get me() {
@@ -163,6 +165,8 @@ export class FilesBrowser {
? [e.owner !== undefined && owner ? `owner ${owner}` : null, e.role === "view" ? "view only" : null].filter(Boolean).join(" · ")
: [timeAgo(e.modified * 1000), e.role === "view" ? "view only" : null].filter(Boolean).join(" · ");
const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : "pi-file";
// Files outside private folders are live while open: show who is in them.
const live = isFolder ? null : this.client.rooms?.find((r) => r.key === `file:workflows/${e.path}`);
const disabled = this.mode === "pick" && !isFolder;
const tools = this.mode === "browse" ? this.rowTools(e) : [];
return h("div", { class: `rs-fb-row ${isFolder ? "folder" : "file"} ${disabled ? "disabled" : ""}` },
@@ -177,7 +181,8 @@ export class FilesBrowser {
},
h("i", { class: `pi ${icon} rs-fb-icon ${isFolder ? "folder" : ""}` }),
h("span", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis", title: e.name }, e.name),
h("div", { class: "rs-name rs-ellipsis", title: e.name }, e.name,
live ? h("span", { class: "rs-live rs-inline-icon", title: `Live · ${live.members.length} open` }, h("span", { class: "rs-live-dot" })) : null),
meta ? h("div", { class: "rs-muted rs-small rs-ellipsis" }, meta) : null)),
tools.length ? h("div", { class: "rs-fb-tools-row" }, ...tools) : null);
}
-24
View File
@@ -154,22 +154,6 @@ app.registerExtension({
name: "Comfy.RigShare",
settings: [
{
id: "RigShare.ShareSaved",
category: ["RigShare", "Sharing", "ShareSaved"],
name: "Share saved workflows automatically (everyone opening the same file edits it together)",
type: "boolean",
defaultValue: false,
onChange: (v) => { if (rig) rig.sync.autoShareFiles = v; },
},
{
id: "RigShare.ShareUnsaved",
category: ["RigShare", "Sharing", "ShareUnsaved"],
name: "Share unsaved tabs automatically once they contain nodes",
type: "boolean",
defaultValue: false,
onChange: (v) => { if (rig) rig.sync.autoShareUnsaved = v; },
},
{
id: "RigShare.ShowCursors",
category: ["RigShare", "Presence", "ShowCursors"],
@@ -228,12 +212,6 @@ app.registerExtension({
rig?.panel.focusChat();
},
},
{
id: "RigShare.ShareTab",
label: "RigShare: Share this tab",
icon: "pi pi-share-alt",
function: () => rig?.sync.shareCurrent(),
},
],
async setup() {
@@ -248,8 +226,6 @@ app.registerExtension({
rig = { client, sync, presence, panel };
window.rigshare = rig; // handy for debugging from the console
sync.autoShareFiles = setting("RigShare.ShareSaved", false);
sync.autoShareUnsaved = setting("RigShare.ShareUnsaved", false);
presence.showCursors = setting("RigShare.ShowCursors", true);
presence.showNames = setting("RigShare.ShowCursorNames", true);
panel.chatToasts = setting("RigShare.ChatToasts", false);
+30 -21
View File
@@ -3,6 +3,8 @@
import { h, initials, timeAgo, clock, roleChips, meter, storage } from "./ui.js";
import { ServerWidget } from "./server-widget.js";
import { pickFolder } from "./files.js";
import { isPrivatePath } from "./sync.js";
const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
@@ -212,16 +214,23 @@ export class RigSharePanel {
const wf = s.store?.activeWorkflow;
if (!wf) return this.section("pi-file", "This tab", h("p", { class: "rs-muted" }, "No tab open."));
const room = s.target;
const privacy = s.privacyOf(wf);
const head = h("div", { class: "rs-tab-head" },
h("div", { class: "rs-name" }, s.tabName(wf)),
wf.isTemporary ? h("span", { class: "rs-chip" }, "unsaved") : null);
privacy ? h("span", { class: "rs-chip" }, privacy) : null);
const row = h("div", { class: "rs-actions" });
const body = [head];
if (!room) {
body.push(h("p", { class: "rs-muted" }, wf.isTemporary
? "Private until you edit it or share it."
: "Private — not synced with others."));
row.append(h("button", { class: "rs-btn rs-btn-primary", onclick: () => s.shareCurrent() }, h("i", { class: "pi pi-share-alt" }), "Share this tab"));
body.push(h("p", { class: "rs-muted" }, privacy === "unsaved"
? "Not live. Save it outside your private folder to work on it with others."
: privacy === "private"
? "In your private folder: only you see it. Move it out to work on it with others."
: "Not live: this tab is a private copy (no access, or the file was deleted)."));
if (privacy === "private" && c.perms.edit) {
row.append(h("button", {
class: "rs-btn rs-btn-primary", onclick: () => this.moveOutOfPrivate(wf),
}, h("i", { class: "pi pi-share-alt" }), "Move to Shared…"));
}
} else {
const here = c.users.filter((u) => u.room === room.key);
const last = s.lastEditor && Date.now() - s.lastEditor.at < 60000 ? ` · ${s.lastEditor.name} editing` : "";
@@ -245,14 +254,21 @@ export class RigSharePanel {
}
row.append(
h("button", { class: `rs-btn ${this.historyOpen ? "active" : ""}`, onclick: () => { this.historyOpen = !this.historyOpen; this.renderBody(); if (this.historyOpen) this.loadHistory(); } },
h("i", { class: "pi pi-history" }), "History"),
h("button", { class: "rs-btn rs-btn-ghost", title: c.perms.admin ? "Stop sharing this tab (removed from the list if nobody else is in it)" : "Leave: stop syncing this tab and keep a private copy", onclick: () => s.unshareCurrent() },
h("i", { class: "pi pi-eye-slash" }), "Stop sharing"));
h("i", { class: "pi pi-history" }), "History"));
}
this.historyEl ??= h("div", { class: "rs-history" });
return this.section("pi-file", "This tab", ...body, row, room && this.historyOpen ? this.historyEl : null);
}
/** Move a private workflow into a shared location, where it goes live. */
async moveOutOfPrivate(wf) {
const dest = await pickFolder({ app: this.app, client: this.client, sync: this.sync, title: `Move "${this.sync.tabName(wf)}" to…`, start: "@shared" });
if (dest === null || dest === undefined) return;
if (isPrivatePath(`workflows/${dest}/`)) return this.client.emit("toast", { severity: "warn", summary: "RigShare", detail: "Pick a folder outside a private folder." });
const base = wf.path.split("/").pop();
await this.run(() => this.sync.movePath(wf.path, dest ? `workflows/${dest}/${base}` : `workflows/${base}`), "Moved: it is live now");
}
accessLine() {
const s = this.sync;
const info = s.room;
@@ -366,27 +382,20 @@ export class RigSharePanel {
const rows = rooms.map((r) => {
const here = r.key === this.sync.targetKey;
const members = r.members.map((id) => byId.get(id)).filter(Boolean);
const folder = r.key.replace(/^file:workflows\//, "").split("/").slice(0, -1).join("/");
return h("div", { class: `rs-row-item rs-room ${here ? "here" : ""}`, title: r.key.replace(/^file:workflows\//, "") },
h("i", { class: `pi ${r.kind === "file" ? "pi-file" : "pi-file-edit"} rs-row-icon` }),
h("i", { class: "pi pi-file rs-row-icon" }),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, r.name, r.restricted ? h("i", { class: "pi pi-lock rs-inline-icon rs-muted", title: "Restricted" }) : null),
members.length
? h("div", { class: "rs-avatars" }, ...members.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))
: h("div", { class: "rs-muted rs-small" }, `${timeAgo(r.updated * 1000)} · ${r.nodes} nodes`)),
!here && !members.length && c.perms.admin ? h("button", {
class: "rs-btn rs-btn-icon rs-danger", title: "Stop sharing (remove from this list)",
onclick: async () => {
if (!(await this.confirm("Stop sharing", `Remove "${r.name}" from the shared workflows? A snapshot is kept in its history.`))) return;
await this.run(() => c.request("DELETE", `/rigshare/api/room?key=${encodeURIComponent(r.key)}`), `"${r.name}" is no longer shared`);
},
}, h("i", { class: "pi pi-trash" })) : null,
h("div", { class: "rs-muted rs-small rs-ellipsis" }, folder ? folder.replace(/^shared(\/|$)/, "Shared$1") : "Workflows", ` · ${r.nodes} nodes`),
h("div", { class: "rs-avatars" }, ...members.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))),
here ? h("span", { class: "rs-chip rs-chip-edit" }, "open") : h("button", {
class: "rs-btn rs-btn-icon", title: "Open",
onclick: () => this.run(() => this.sync.openRoom(r)),
}, h("i", { class: "pi pi-arrow-right" })));
});
return this.section("pi-share-alt", "Shared workflows",
...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nothing shared yet. Open a saved workflow or edit a tab.")]));
return this.section("pi-share-alt", "Live workflows",
...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nobody has a shared workflow open. Any workflow outside a private folder is live while it is open.")]));
}
// ----- people ---------------------------------------------------------
+34 -106
View File
@@ -1,9 +1,9 @@
// Live sync of workflow tabs between browsers.
//
// Every shared tab maps to a room on the server: a saved workflow is the room
// "file:<path>" (everyone opening the same file lands in the same room), an
// unsaved tab joins a room only after "Share this tab" ("tmp:<id>", stored in
// the workflow's extra so it survives reloads and travels with the doc).
// Sharing follows folders: a saved workflow outside your private folder
// (workflows/users/<name>/) is live as soon as it is open, in the room
// "file:<path>" (everyone opening the same file lands in the same room).
// Unsaved tabs and files in a private folder are never synced.
//
// Only the tab on screen is live. Switching tabs leaves the old room and joins
// the new one; the server copy is authoritative when a room already exists.
@@ -15,8 +15,6 @@
// anything structural (added/removed nodes, links, groups, subgraphs) reloads
// the tab from the room document, keeping the local viewport.
const LS_OVERRIDES = "rigshare.shareOverrides";
import { applyPatch, itemId } from "./graphdoc.js";
export { applyPatch };
@@ -82,6 +80,11 @@ export function diffSnapshots(base, snap) {
return Object.keys(patch).length ? patch : null;
}
/** Files in someone's private folder (workflows/users/<name>/...) are never live. */
export function isPrivatePath(path) {
return /^workflows\/users(\/|$)/.test(path || "");
}
function stripView(wf) {
if (wf?.extra) {
delete wf.extra.ds;
@@ -95,14 +98,9 @@ export class RoomSync extends EventTarget {
this.app = app;
this.api = api;
this.client = client;
this.autoShareFiles = false;
this.autoShareUnsaved = false;
this.loading = 0; // a graph load (ours or ComfyUI's) is in progress
this.ownLoad = 0; // the load in progress was started by RigShare
this.installLoadGuard();
this.optedOut = new WeakSet(); // unsaved tabs the user chose to keep private
this.seen = new WeakMap(); // unsaved tab -> nodes when first seen
this.overrides = this.loadOverrides();
this.target = null; // room info of the tab on screen, or null
this.targetWf = null; // that tab's workflow object
@@ -137,7 +135,18 @@ export class RoomSync extends EventTarget {
this.ready = false;
this.room = null;
}
client.emit("toast", { severity: "warn", summary: "RigShare", detail: `You don't have access to "${msg.name}". Your tab stays as a private copy.` });
client.emit("toast", { severity: "warn", summary: "RigShare", detail: msg.reason || `You don't have access to "${msg.name}". Your tab stays as a private copy.` });
this.updateReadOnly();
this.emitState();
});
client.on("room_closed", (msg) => {
// The file was deleted: the tab stays open as a private copy.
this.denied.add(msg.room);
if (msg.room === this.targetKey) {
this.ready = false;
this.room = null;
}
client.emit("toast", { severity: "info", summary: "RigShare", detail: `"${msg.name}" was deleted. Your tab stays as a private copy.` });
this.updateReadOnly();
this.emitState();
});
@@ -160,14 +169,6 @@ export class RoomSync extends EventTarget {
this.emitState();
});
client.on("self", () => this.updateReadOnly());
client.on("unshared", (msg) => {
const detail = msg.removed
? `"${msg.name}" is no longer shared.`
: msg.others.length
? `You left "${msg.name}". Still shared with ${msg.others.join(", ")}.`
: `You left "${msg.name}".`;
client.emit("toast", { severity: "info", summary: "RigShare", detail });
});
api.addEventListener("graphChanged", () => this.checkLocal());
window.addEventListener("pointerdown", (e) => {
@@ -214,33 +215,25 @@ export class RoomSync extends EventTarget {
// ----- which room does a tab belong to --------------------------------
loadOverrides() {
try { return JSON.parse(localStorage.getItem(LS_OVERRIDES)) || {}; } catch { return {}; }
}
saveOverrides() {
try { localStorage.setItem(LS_OVERRIDES, JSON.stringify(this.overrides)); } catch { /* ignore */ }
}
tabName(wf) {
return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/\.json$/, "");
}
roomFor(wf, extra) {
/** Why a tab is not live: "unsaved", "private", or null when it is shared. */
privacyOf(wf) {
if (!wf) return null;
if (!wf.isTemporary) {
const shared = this.overrides[wf.path] ?? this.autoShareFiles;
return shared ? { key: `file:${wf.path}`, name: this.tabName(wf), kind: "file" } : null;
}
extra ??= (wf.changeTracker?.activeState ?? wf.activeState)?.extra;
const id = extra?.rigshare?.room;
return id ? { key: id, name: this.tabName(wf), kind: "tmp" } : null;
if (wf.isTemporary || !wf.path?.startsWith("workflows/")) return "unsaved";
return isPrivatePath(wf.path) ? "private" : null;
}
roomFor(wf) {
if (!wf || this.privacyOf(wf)) return null;
return { key: `file:${wf.path}`, name: this.tabName(wf), kind: "file" };
}
activeRoom() {
const wf = this.store?.activeWorkflow;
// For the tab on screen, read the live graph (share flag may be fresh).
const room = this.roomFor(wf, this.graph()?.extra);
const room = this.roomFor(wf);
return { wf, room: room && this.denied.has(room.key) ? null : room };
}
@@ -254,7 +247,6 @@ export class RoomSync extends EventTarget {
tick() {
if (!this.client.online || !this.store) return;
this.maybeAutoShare();
const { wf, room } = this.activeRoom();
const key = room?.key ?? null;
if (key !== this.targetKey || (key && wf !== this.targetWf)) {
@@ -441,69 +433,11 @@ export class RoomSync extends EventTarget {
} else if (!lock && this.readOnlyForced) {
canvas.read_only = false;
this.readOnlyForced = false;
this.room = null; // {role, manage, restricted, owner} of the joined room
this.denied = new Set(); // rooms we were refused; not retried until access changes
this.inFlight = 0; // our patches not yet acknowledged
this.crossed = false; // a remote patch arrived while ours were in flight
}
}
// ----- actions --------------------------------------------------------
/** Start sharing the tab on screen. */
shareCurrent() {
const wf = this.store?.activeWorkflow;
if (!wf) return;
if (wf.isTemporary) {
const graph = this.graph();
graph.extra ??= {};
graph.extra.rigshare = { room: `tmp:${crypto.randomUUID?.() ?? Date.now().toString(36)}` };
wf.changeTracker?.checkState();
} else {
this.overrides[wf.path] = true;
this.saveOverrides();
}
this.tick();
}
/**
* Unsaved tabs join a room on their own once someone edits them. Tabs that
* are merely open (the default workflow, restored tabs) stay private so the
* shared list is not flooded with untouched copies.
*/
maybeAutoShare() {
const wf = this.store.activeWorkflow;
if (!this.autoShareUnsaved || !wf?.isTemporary || this.optedOut.has(wf)) return;
const graph = this.graph();
if (!graph || graph.extra?.rigshare?.room) return;
const sig = JSON.stringify(graph.serialize().nodes);
const seen = this.seen.get(wf);
const now = performance.now();
// Keep re-baselining while the tab is still loading/settling.
if (!seen || now - seen.at < 3000) {
this.seen.set(wf, { sig, at: seen?.at ?? now });
if (seen) seen.sig = sig;
return;
}
if (sig !== seen.sig && graph.nodes?.length) this.shareCurrent();
}
/** Stop sharing the tab on screen (keeps a private copy). */
unshareCurrent() {
const wf = this.store?.activeWorkflow;
if (!wf) return;
if (this.targetKey) this.client.send({ type: "unshare", room: this.targetKey });
if (wf.isTemporary) {
this.optedOut.add(wf);
delete this.graph().extra?.rigshare;
wf.changeTracker?.checkState();
} else {
this.overrides[wf.path] = false;
this.saveOverrides();
}
this.tick();
}
/**
* ComfyUI replaces the graph first and only then switches the active tab
* (opening a file, a template, drag-and-drop, switching tabs). Without a
@@ -573,18 +507,14 @@ export class RoomSync extends EventTarget {
return null;
}
/** Open a room from the list (switching to its tab if already open). */
/** Open a live workflow from the list (switching to its tab if already open). */
async openRoom(room) {
const store = this.store;
let wf = this.findTab(room);
if (wf && wf === store.activeWorkflow) return;
const data = await this.client.request("GET", `/rigshare/api/room?key=${encodeURIComponent(room.key)}`);
if (!wf && room.kind === "file") {
if (!wf) {
const path = room.key.slice(5);
if (this.overrides[path] === false) {
delete this.overrides[path];
this.saveOverrides();
}
await store.syncWorkflows?.();
wf = store.getWorkflowByPath?.(path) ?? null;
if (wf && !wf.isLoaded) await wf.load();
@@ -597,10 +527,8 @@ export class RoomSync extends EventTarget {
this.ownLoad--;
}
} else {
// Unsaved shared tab (or a file that no longer exists): open a
// temporary tab carrying the room id.
// The file no longer exists: open its last live state as an unsaved copy.
const doc = structuredClone(data.doc || {});
doc.extra = { ...(doc.extra || {}), rigshare: { room: room.key } };
this.ownLoad++;
try {
await this.app.loadGraphData(doc, true, true, `${room.name}.json`);