diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77484a8..3543ee0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/README.md b/README.md
index 2e0dcd9..a11bccd 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/files.md b/docs/files.md
index a80d055..b6169b3 100644
--- a/docs/files.md
+++ b/docs/files.md
@@ -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

-**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
diff --git a/docs/images/access.png b/docs/images/access.png
index 98f60b2..9d6d4c1 100644
Binary files a/docs/images/access.png and b/docs/images/access.png differ
diff --git a/docs/images/admin.png b/docs/images/admin.png
index 35d5035..a250535 100644
Binary files a/docs/images/admin.png and b/docs/images/admin.png differ
diff --git a/docs/images/chat.png b/docs/images/chat.png
index 86657dd..482133b 100644
Binary files a/docs/images/chat.png and b/docs/images/chat.png differ
diff --git a/docs/images/files-all.png b/docs/images/files-all.png
index de1ebeb..266c602 100644
Binary files a/docs/images/files-all.png and b/docs/images/files-all.png differ
diff --git a/docs/images/files-shared.png b/docs/images/files-shared.png
index 19ef68d..6a35a9b 100644
Binary files a/docs/images/files-shared.png and b/docs/images/files-shared.png differ
diff --git a/docs/images/files.png b/docs/images/files.png
index 16355e1..e97eeb6 100644
Binary files a/docs/images/files.png and b/docs/images/files.png differ
diff --git a/docs/images/history.png b/docs/images/history.png
index 78812e7..9945401 100644
Binary files a/docs/images/history.png and b/docs/images/history.png differ
diff --git a/docs/images/save.png b/docs/images/save.png
index 3c83ff2..84e5a79 100644
Binary files a/docs/images/save.png and b/docs/images/save.png differ
diff --git a/docs/settings.md b/docs/settings.md
index 8ad93aa..43c3c44 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -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
diff --git a/docs/sharing.md b/docs/sharing.md
index 9196277..9c7a786 100644
--- a/docs/sharing.md
+++ b/docs/sharing.md
@@ -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.
diff --git a/pyproject.toml b/pyproject.toml
index cb1e1e9..933cd44 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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 = []
diff --git a/rigshare_core/routes.py b/rigshare_core/routes.py
index 4ee29ce..23eb79d 100644
--- a/rigshare_core/routes.py
+++ b/rigshare_core/routes.py
@@ -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])
diff --git a/rigshare_core/store.py b/rigshare_core/store.py
index cd44a18..74cf399 100644
--- a/rigshare_core/store.py
+++ b/rigshare_core/store.py
@@ -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:
diff --git a/tests/python/test_server.py b/tests/python/test_server.py
index bcc92ea..5f4c2ac 100644
--- a/tests/python/test_server.py
+++ b/tests/python/test_server.py
@@ -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())
diff --git a/web/rigshare/files.js b/web/rigshare/files.js
index b3eadeb..dcfae9a 100644
--- a/web/rigshare/files.js
+++ b/web/rigshare/files.js
@@ -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 });
diff --git a/web/rigshare/panel.js b/web/rigshare/panel.js
index 5729d7f..70944bc 100644
--- a/web/rigshare/panel.js
+++ b/web/rigshare/panel.js
@@ -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. */
diff --git a/web/rigshare/styles.js b/web/rigshare/styles.js
index 492ed20..922165f 100644
--- a/web/rigshare/styles.js
+++ b/web/rigshare/styles.js
@@ -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); }