RigShare 1.2.0

Snapshot on every save (Saved at <time>), New folder prompt above the Save
dialog, lighter Graph marker, changelog, docs, README and screenshots.
This commit is contained in:
Edouard Durand
2026-09-26 06:26:47 +00:00
parent e533e64970
commit 0c1f37b39b
20 changed files with 114 additions and 21 deletions
+19 -5
View File
@@ -1,14 +1,28 @@
# Changelog
## Unreleased
## 1.2.0 (2026-09-26)
ComfyUI's App mode works with RigShare, and every save keeps a snapshot.
### Added
- App mode: live workflows work in ComfyUI's App view. Each person keeps their own graph or App view through live edits, and App inputs sync. Cursors are off in the App view. Workflows saved as Apps (`.app.json`) show with an app icon in Files and in the Live list, and Save keeps them Apps
- In the App view, where ComfyUI shows only its own sidebar tabs, RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open the panels in a drawer beside it
- **App mode**: live workflows work in ComfyUI's App view. Each person keeps their own graph or App view through live edits, App inputs sync, and the app's layout (chosen inputs and outputs) syncs with the workflow. Cursors are off in the App view
- In the App view, where ComfyUI shows only its own sidebar tabs, **RigShare** and **Files** buttons join that sidebar, with the unread chat badge, and open the panels in a drawer beside it
- The Live tab and People show whether each person is in the node graph or the App view, with ComfyUI's own Graph and App icons
- Folder access is managed only in the Files tab. *This tab* in the Live tab still says whether the folder is restricted. Inside a shared folder you own, its 🔒 sits next to the new-folder button
- ComfyUI's own Workflows and Apps sidebar tabs are hidden, since Files replaces them, and their shortcuts open Files. Admin setting `hide_comfy_file_tabs`, on by default
- Workflows with an App set up, and workflows saved as Apps (`.app.json`), show ComfyUI's App icon in Files and the Live list. Save keeps an App an App
- **A snapshot on every save**, named *Saved at* and the time (in each viewer's time zone). They rotate like automatic snapshots (`snapshot_keep`)
- Inside a shared folder you own, its 🔒 access button sits next to the new-folder button
### Changed
- ComfyUI's own Workflows and Apps sidebar tabs are hidden, since Files replaces them, and their shortcuts open Files. Admin setting `hide_comfy_file_tabs`, on by default. The server enforces file permissions either way
- Folder access is managed only in the Files tab. *This tab* in the Live tab still says whether the folder is restricted
- Smaller icons in folder lists
### Fixed
- In the Save and Move dialogs, **New folder** asked for the name in a prompt hidden behind the dialog
- Snapshots taken in the same second could be rotated out of order
## 1.1.0 (2026-09-26)
+2 -1
View File
@@ -26,7 +26,8 @@ A custom node that turns a single ComfyUI server into a shared workspace. Everyo
- shows who is online and which workflow they are on, plus live cursors and selections
- lets you follow someone, jumping to their tab and view
- keeps the whole chat history, loading older messages as you scroll up, with an unread badge, a blinking icon and a sound
- keeps per-workflow history: snapshots every few minutes, named snapshots, one-click restore
- keeps per-workflow history: a snapshot on every save and every few minutes, named snapshots, one-click restore
- works in ComfyUI's App mode: everyone picks their own graph or App view, App inputs sync live, and you can see who is in which view
- gives each account permissions to edit, queue, use ComfyUI Manager or administer, enforced by the server and not just hidden in the UI
- keeps simultaneous edits apart: two people adding or connecting nodes at the same moment both keep their work
- issues personal API keys, so scripts and other apps can call ComfyUI with their owner's permissions
+2 -2
View File
@@ -31,7 +31,7 @@ RigShare organises ComfyUI's workflows folder like a shared drive. Where a workf
ComfyUI's own **Workflows** and **Apps** tabs are hidden, since Files replaces them, and their keyboard shortcuts open Files. Admins can bring them back under *Admin → Server settings*. Hiding them isn't what protects the files, though: the server applies the same rules whichever browser is used. If you show ComfyUI's open workflow tabs in the sidebar (*Comfy.Workflow.WorkflowTabsPosition*), the Workflows tab stays, because it lists them.
Workflows saved as **Apps** (ComfyUI's App mode, `.app.json`) show with an app icon. Opening one opens it as an app.
Workflows with an App set up (ComfyUI's App mode), or saved as Apps (`.app.json`), show ComfyUI's App icon. Opening an App opens it in the App view.
Click a workflow to open it. The Files tab marks live workflows with a green dot. **From computer** opens a workflow file from your machine, like ComfyUI's own *Open*.
@@ -41,7 +41,7 @@ Hover a row for its actions: **Rename**, **Move to…**, **Delete**, and on a sh
<p align="center"><img src="images/save.png" width="720" alt="Save dialog"></p>
**Save** (Ctrl+S) on a new workflow, and **Save As**, open a dialog with a name and the same three tabs. It starts in My files, or in the folder you last saved to. Pick any folder you can edit and click **Save here**. Clicking an existing workflow reuses its name, and ComfyUI asks before overwriting.
**Save** (Ctrl+S) on a new workflow, and **Save As**, open a dialog with a name and the same three tabs. It starts in My files, or in the folder you last saved to. Pick any folder you can edit and click **Save here**. Clicking an existing workflow reuses its name, and ComfyUI asks before overwriting. Every save also keeps a snapshot in the workflow's history.
## Opening
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 142 KiB

+1 -1
View File
@@ -33,7 +33,7 @@ Admins change these under **Admin → Server settings**. They are stored in `rig
| `api_protection.trusted_ips` | `["127.0.0.1", "::1"]` | Addresses or networks that skip login and checks |
| `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 |
| `snapshot_keep` | `30` | Automatic snapshots kept per workflow, and as many on-save ones; named ones are always kept |
| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup |
## Environment variables
+1
View File
@@ -56,6 +56,7 @@ Saved workflow files still show up by name in ComfyUI's workflow browser; openin
## Snapshots
- Automatic every 5 minutes while a workflow is being edited (`snapshot_interval_sec`). The last 30 are kept (`snapshot_keep`).
- On every save, by anyone, of any workflow: named *Saved at* and the time, shown in your own time zone. The last 30 are kept too.
- **Snapshot** saves a named one, which is never rotated out.
- **History** lists them. ↗ opens a private copy; ⟲ restores it for everyone, after first saving the current state as a snapshot.
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-rigshare"
description = "Multi-user collaboration for ComfyUI: accounts and login, live-shared workflows, cursors, chat, snapshots and a server monitor."
version = "1.1.0"
version = "1.2.0"
license = { file = "LICENSE" }
dependencies = []
+19
View File
@@ -609,6 +609,20 @@ def setup(server, store, hub):
action = {"GET": "get", "HEAD": "get", "POST": "save", "DELETE": "delete"}.get(request.method)
return (action, src, None) if action else None
def snapshot_saved_file(request, path, body):
if not path.endswith(".json") or Workspace.workflows_rel(path) is None:
return
try:
doc = json.loads(body)
except ValueError:
return
if not isinstance(doc, dict) or "nodes" not in doc:
return
name = path.rsplit("/", 1)[-1].removesuffix(".json").removesuffix(".app")
# The panel shows the time in each viewer's own time zone; the label is for API clients.
label = "Saved at " + time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime())
store.add_snapshot(doc, display(request), "file:" + path, name, label, kind="save")
def check_userdata(request, action, src, dest):
"""Folder rules (private spaces, restricted shared folders)."""
kind, key, perms = who(request)
@@ -705,7 +719,12 @@ def setup(server, store, hub):
"details": "", "extra_info": {}}, "node_errors": {}},
status=403)
break
if userdata and userdata[0] == "save":
await request.read() # keep the body for the snapshot below (aiohttp caches it)
response = await handler(request)
# Every save of a workflow keeps a snapshot, named after when it was saved.
if userdata and userdata[0] == "save" and getattr(response, "status", 0) == 200:
snapshot_saved_file(request, userdata[1], await request.read())
# Workflow listings only show what this person may open.
if userdata and userdata[0] == "list" and not trusted(request.remote or "") and hub.workspace:
response = filter_listing(request, response, userdata[1])
+12 -5
View File
@@ -444,8 +444,11 @@ class Store:
items.append({"id": name[:-5], **meta})
return items
def add_snapshot(self, doc, author, room, room_name, label=None):
snap_id = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
def add_snapshot(self, doc, author, room, room_name, label=None, kind=None):
"""``kind`` "save": taken when the file was saved; those rotate like automatic ones."""
# Sorted by name, newest first: include microseconds so saves in the same second keep their order.
now = time.time()
snap_id = time.strftime("%Y%m%d-%H%M%S", time.localtime(now)) + f"-{int(now % 1 * 1e6):06d}-" + secrets.token_hex(2)
meta = {
"time": int(time.time()),
"author": author,
@@ -454,6 +457,8 @@ class Store:
"room_name": room_name,
"nodes": len(doc.get("nodes", [])) if isinstance(doc, dict) else 0,
}
if kind:
meta["kind"] = kind
with open(os.path.join(self.snapshot_dir, snap_id + ".json"), "w", encoding="utf-8") as f:
json.dump({"meta": meta, "doc": doc}, f)
self._prune_snapshots(room)
@@ -470,9 +475,11 @@ class Store:
def _prune_snapshots(self, room):
keep = max(1, int(self.config.get("snapshot_keep", 30)))
# Labelled (manual) snapshots are kept; only automatic ones rotate.
auto = [s for s in self.list_snapshots(room) if not s.get("label")]
for snap in auto[keep:]:
# Named (manual) snapshots are kept; automatic and on-save ones rotate, each on its own.
snaps = self.list_snapshots(room)
auto = [s for s in snaps if not s.get("label") and not s.get("kind")]
saves = [s for s in snaps if s.get("kind") == "save"]
for snap in auto[keep:] + saves[keep:]:
try:
os.remove(os.path.join(self.snapshot_dir, snap["id"] + ".json"))
except OSError:
+22
View File
@@ -418,3 +418,25 @@ def test_workflows_with_an_app_set_up(run):
assert presence["rooms"][0]["app"] is True
await sock.close()
run(scenario())
def test_every_save_keeps_a_snapshot(run):
async def scenario():
async with rig_server({"snapshot_keep": 2}) as rig:
await rig.setup_admin()
alice = await rig.add_user("alice", edit=True)
h = rig.http
path = "workflows/shared/flow.json"
for n in range(3):
doc = json.dumps({"nodes": [{"id": i} for i in range(n + 1)], "links": []}).encode()
assert (await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=doc, headers=alice)).status == 200
await h.post(rig.url(f"/api/userdata/{enc('workflows/notes.json')}"), data=b'{"not": "a workflow"}', headers=alice)
snaps = await (await h.get(rig.url(f"/rigshare/api/snapshots?room={enc('file:' + path)}"), headers=alice)).json()
assert [s["nodes"] for s in snaps] == [3, 2], "newest first, rotated to snapshot_keep"
assert all(s["kind"] == "save" and s["label"].startswith("Saved at ") and s["author"] == "alice" for s in snaps)
assert not rig.store.list_snapshots("file:workflows/notes.json"), "only workflows"
# A save that is refused keeps nothing.
viewer = await rig.add_user("viewer")
await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=b'{"nodes": []}', headers=viewer)
assert len(rig.store.list_snapshots("file:" + path)) == 2
run(scenario())
+22 -1
View File
@@ -249,6 +249,8 @@ export class FilesBrowser {
// ----- actions ----------------------------------------------------------
async ask(title, message, value = "") {
// Inside the Save / Move / Open dialogs ComfyUI's own prompt would open underneath.
if (this.mode !== "browse") return await promptModal(title, message, value);
const dialog = this.app.extensionManager?.dialog;
if (dialog?.prompt) return await dialog.prompt({ title, message, defaultValue: value });
return window.prompt(message, value);
@@ -331,7 +333,13 @@ export function modal(title, icon, body, footer) {
document.removeEventListener("keydown", onKey, true);
resolveClose(value);
};
const onKey = (e) => { if (e.key === "Escape" && backdrop.isConnected) { e.stopPropagation(); close(null); } };
// Stacked modals (a prompt over the Save dialog): Escape closes only the top one.
const onKey = (e) => {
if (e.key !== "Escape" || !backdrop.isConnected) return;
if ([...document.querySelectorAll(".rs-modal-backdrop")].at(-1) !== backdrop) return;
e.stopPropagation();
close(null);
};
const backdrop = h("div", { class: "rs-modal-backdrop rs-panel-vars", onmousedown: (e) => { if (e.target === backdrop) close(null); } },
h("div", { class: "rs-modal rs-modal-wide", role: "dialog", "aria-label": title },
h("div", { class: "rs-modal-title" }, h("i", { class: `pi ${icon}` }), h("span", { class: "rs-grow" }, title),
@@ -344,6 +352,19 @@ export function modal(title, icon, body, footer) {
return { close, closed };
}
/** A text prompt in RigShare's own modal, so it stacks above another RigShare modal. */
export async function promptModal(title, message, value = "") {
const input = h("input", { class: "rs-input", value, maxLength: 120 });
const ok = h("button", { class: "rs-btn rs-btn-primary" }, "OK");
const m = modal(title, "pi-pencil", h("div", { class: "rs-save-body" }, h("label", { class: "rs-label" }, message), input),
[h("span", { class: "rs-grow" }), h("button", { class: "rs-btn rs-btn-ghost", onclick: () => m.close(null) }, "Cancel"), ok]);
const submit = () => m.close(input.value);
ok.onclick = submit;
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); submit(); } });
setTimeout(() => { input.focus(); input.select(); }, 30);
return await m.closed;
}
/** Folder picker; resolves to a folder path ("" = common top level) or null. */
export async function pickFolder({ app, client, sync, title, start }) {
const browser = new FilesBrowser({ app, client, sync, mode: "pick", start });
+11 -3
View File
@@ -16,6 +16,14 @@ const VIEWS = [
{ id: "admin", icon: "pi-shield", label: "Admin", admin: true },
];
/** A snapshot's name; on-save ones show the save time in the viewer's own time zone. */
function snapLabel(snap) {
if (snap.kind === "save") {
return `Saved at ${new Date(snap.time * 1000).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`;
}
return snap.label || "Auto";
}
export class RigSharePanel {
constructor(app, client, sync, presence) {
this.app = app;
@@ -348,12 +356,12 @@ export class RigSharePanel {
}
this.historyEl.replaceChildren(...snaps.map((snap) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-ellipsis" }, snap.label || "Auto", h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
h("div", { class: "rs-ellipsis" }, snapLabel(snap), h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
h("div", { class: "rs-muted rs-small" }, `${timeAgo(snap.time * 1000)} · ${snap.author}`)),
h("button", { class: "rs-btn rs-btn-icon", title: "Open a private copy", onclick: () => this.openSnapshot(snap) }, h("i", { class: "pi pi-external-link" })),
this.sync.canEdit ? h("button", {
class: "rs-btn rs-btn-icon", title: "Restore for everyone", onclick: async () => {
if (!(await this.confirm("Restore snapshot", `Restore "${snap.label || "Auto"}" for everyone in this workflow?`))) return;
if (!(await this.confirm("Restore snapshot", `Restore "${snapLabel(snap)}" for everyone in this workflow?`))) return;
await this.run(() => this.client.request("POST", `/rigshare/api/snapshots/${snap.id}/restore`), "Snapshot restored");
this.loadHistory();
},
@@ -365,7 +373,7 @@ export class RigSharePanel {
if (!data) return;
const doc = structuredClone(data.doc);
if (doc.extra) delete doc.extra.rigshare; // a private copy, not the live room
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snap.label || snap.id}).json`);
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snapLabel(snap).replace(/[\/:]/g, "-")}).json`);
}
/** Small avatar marked with how that person views the workflow: node graph or App. */
+2 -2
View File
@@ -264,8 +264,8 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-view-avatar { position: relative; overflow: visible; }
.rs-comfy-icon { display: inline-block; width: 1em; height: 1em; flex: none; }
.rs-view-mark { position: absolute; right: -6px; bottom: -5px; font-size: 8px; width: 13px; height: 13px; display: grid; place-items: center;
border-radius: 50%; background: var(--rs-bg); color: var(--rs-muted); border: 1px solid var(--rs-border); }
.rs-view-app .rs-view-mark { color: var(--rs-accent-fg); background: var(--rs-accent); border-color: var(--rs-accent); }
border-radius: 50%; background: #71717a; color: #fff; border: 1.5px solid var(--rs-bg); }
.rs-view-app .rs-view-mark { color: var(--rs-accent-fg); background: var(--rs-accent); }
.rs-view-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 6px; font-size: 10.5px; padding: 0 6px; }
.rs-view-chip .rs-comfy-icon { font-size: 11px; }
.rs-view-chip.rs-view-app { color: var(--rs-accent); border-color: var(--rs-accent); }