Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e943f3e60 | ||
|
|
f4131ac6f5 | ||
|
|
d3cef4a6ca | ||
|
|
9f70cf9205 | ||
|
|
e533e64970 | ||
|
|
d6ef325ee1 | ||
|
|
c1b2bf0d5e | ||
|
|
aadedc9f03 | ||
|
|
45aebb0776 | ||
|
|
435acb0914 | ||
|
|
51c16fa93e | ||
|
|
6833141e58 |
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## 1.2.1 (2026-09-26)
|
||||
|
||||
### Changed
|
||||
|
||||
- App mode: the **RigShare** and **Files** buttons open their tabs in ComfyUI's docked side panel, like Assets and Apps, instead of a floating drawer
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching between the App view and the graph with the RigShare or Files panel open left the panel blank
|
||||
|
||||
## 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, 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
|
||||
- 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)
|
||||
|
||||
Workflows now live in folders, like a shared drive, and sharing follows them.
|
||||
|
||||
@@ -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
|
||||
@@ -115,16 +116,3 @@ I run one ComfyUI box for several people. [ComfyUI-Nexus](https://github.com/dax
|
||||
- it hid menus with CSS and monkeypatched litegraph internals that no longer exist
|
||||
- anyone could become admin with a shared password typed into chat
|
||||
- everybody worked on one canvas, and opening ComfyUI wiped your own workflow
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] Login page, JWT sessions, first-run admin setup
|
||||
- [x] Per-user API keys
|
||||
- [x] Live co-editing of every shared workflow, per-workflow snapshots
|
||||
- [x] Cursors, selections, follow mode
|
||||
- [x] Chat with badge, blink and sound, whole history with scroll back
|
||||
- [x] Server monitor with floating widget
|
||||
- [x] Conflict-free node ids when two people add nodes at the same instant
|
||||
- [x] Private folder per account, shared folders, Save asks where
|
||||
- [x] Folder access lists (share with specific people)
|
||||
- [x] Publish to the Comfy registry
|
||||
|
||||
@@ -29,15 +29,19 @@ RigShare organises ComfyUI's workflows folder like a shared drive. Where a workf
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
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 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*.
|
||||
|
||||
Hover a row for its actions: **Rename**, **Move to…**, **Delete**, and on a shared folder you own, 🔒 **Who can open it**. The folder button creates a folder. In the Shared tab it creates a shared folder that you own.
|
||||
Hover a row for its actions: **Rename**, **Move to…**, **Delete**, and on a shared folder you own, 🔒 **Who can open it**. Inside a shared folder you own, the same 🔒 sits next to the new-folder button. The folder button creates a folder. In the Shared tab it creates a shared folder that you own.
|
||||
|
||||
## Saving
|
||||
|
||||
<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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 142 KiB |
@@ -27,12 +27,13 @@ Admins change these under **Admin → Server settings**. They are stored in `rig
|
||||
| `guest_permissions` | `{"edit": false, "queue": false}` | What guests may do |
|
||||
| `broadcast_execution` | `true` | Send progress and previews to everyone, not only whoever queued |
|
||||
| `hide_comfy_account` | `false` | Hide ComfyUI's Comfy.org sign-in button and dialog |
|
||||
| `hide_comfy_file_tabs` | `true` | Hide ComfyUI's own Workflows and Apps sidebar tabs; RigShare's Files tab replaces them and their shortcuts open it |
|
||||
| `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 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
|
||||
|
||||
@@ -29,6 +29,8 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw
|
||||
- Two people editing the same node at once: the last change wins.
|
||||
- Two people adding or connecting nodes at the same moment both keep their work. If both picked the same node or link id, the server renumbers the later one and quietly resyncs that person. Links and groups merge one by one, so a connection made by one person never erases another's.
|
||||
|
||||
**App mode.** Each person chooses for themselves whether a workflow shows as the node graph or as an App. Switching doesn't change anyone else's view, and live edits keep it. The Live tab and People show which view each person is in, using ComfyUI's Graph and App icons. Inputs changed in the App view sync like any other widget value. The app's layout, meaning its chosen inputs and outputs, belongs to the workflow and syncs. Cursors aren't shown in the App view. ComfyUI's App view shows only its own sidebar tabs, so RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open in the same docked side panel as ComfyUI's own tabs.
|
||||
|
||||
Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back.
|
||||
|
||||
## When a workflow stops being live
|
||||
@@ -40,7 +42,7 @@ Viewers (no *Edit* permission) see a read-only canvas. Anything they change loca
|
||||
|
||||
## Who can open it
|
||||
|
||||
Access follows the folder, not the workflow. To keep a workflow to some people, put it in a shared folder and restrict the folder: in the Files tab (🔒 on the folder), or with **Folder access** under *This tab* when the workflow on screen is in a folder you own. Choose *Only people I choose* and give each person **Can view** or **Can edit**.
|
||||
Access follows the folder, not the workflow. To keep a workflow to some people, put it in a shared folder and restrict the folder in the Files tab (🔒 on the folder). *This tab* shows whether the workflow on screen is in a restricted folder. Choose *Only people I choose* and give each person **Can view** or **Can edit**.
|
||||
|
||||
- Workflows at the top level of Shared (not in a folder) can be opened by everyone with an account, with their usual permissions.
|
||||
- The folder's owner (whoever created it) and admins always have access.
|
||||
@@ -54,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,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.1"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from aiohttp import web, WSMsgType
|
||||
from .chatlog import ChatLog
|
||||
from .graphdoc import apply_patch, remap_conflicts
|
||||
from .stats import collect_stats
|
||||
from .workspace import Workspace
|
||||
from .workspace import Workspace, has_app
|
||||
|
||||
log = logging.getLogger("ComfyUI-RigShare")
|
||||
|
||||
@@ -76,12 +76,13 @@ class Client:
|
||||
self.joined = time.time()
|
||||
self.idle = False
|
||||
self.room = None
|
||||
self.view = "graph" # "graph" (node editor) or "app" (ComfyUI's App view)
|
||||
|
||||
def public(self):
|
||||
return {"id": self.id, "kind": self.kind, "key": self.key if self.kind == "user" else None,
|
||||
"guest_id": self.key if self.kind == "guest" else None,
|
||||
"name": self.name, "color": self.color, "perms": self.perms,
|
||||
"joined": int(self.joined), "idle": self.idle, "room": self.room}
|
||||
"joined": int(self.joined), "idle": self.idle, "room": self.room, "view": self.view}
|
||||
|
||||
|
||||
class Hub:
|
||||
@@ -283,7 +284,8 @@ class Hub:
|
||||
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 []),
|
||||
"restricted": self.folder_restricted(self.folder_of(room))})
|
||||
"restricted": self.folder_restricted(self.folder_of(room)),
|
||||
"app": room.key.endswith(".app.json") or has_app(room.doc)})
|
||||
out.sort(key=lambda r: (-len(r["members"]), -r["updated"]))
|
||||
return out
|
||||
|
||||
@@ -318,7 +320,7 @@ class Hub:
|
||||
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.name = room.key.rsplit("/", 1)[-1].removesuffix(".json").removesuffix(".app")
|
||||
room.dirty = True
|
||||
self.rooms[room.key] = room
|
||||
moved.append(room.key)
|
||||
@@ -373,6 +375,7 @@ class Hub:
|
||||
cfg = self.store.config
|
||||
return {"name": cfg.get("server_name"), "allow_guests": cfg.get("allow_guests"),
|
||||
"hide_comfy_account": bool(cfg.get("hide_comfy_account")),
|
||||
"hide_comfy_file_tabs": bool(cfg.get("hide_comfy_file_tabs", True)),
|
||||
"hide_api_templates": bool(cfg.get("hide_api_templates"))}
|
||||
|
||||
async def broadcast_server(self):
|
||||
@@ -503,6 +506,12 @@ class Hub:
|
||||
await self.broadcast_presence()
|
||||
await self.system_chat(f"{old} is now known as {name}")
|
||||
|
||||
elif kind == "view":
|
||||
view = data.get("view")
|
||||
if view in ("graph", "app") and view != client.view:
|
||||
client.view = view
|
||||
await self.broadcast_presence()
|
||||
|
||||
elif kind == "idle":
|
||||
client.idle = bool(data.get("idle"))
|
||||
await self.broadcast_presence()
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -47,6 +47,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
# Hide ComfyUI's own Comfy.org account sign-in (top-bar button and dialog).
|
||||
"hide_comfy_account": False,
|
||||
# ComfyUI's own Workflows and Apps sidebar tabs duplicate RigShare's Files tab.
|
||||
"hide_comfy_file_tabs": True,
|
||||
# Remove templates that need paid API nodes from the template browser.
|
||||
"hide_api_templates": False,
|
||||
# Rooms nobody opened for this many days are forgotten.
|
||||
@@ -442,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,
|
||||
@@ -452,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)
|
||||
@@ -468,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:
|
||||
|
||||
@@ -11,6 +11,7 @@ Paths handled here are relative to the *userdata* root, e.g.
|
||||
every path is fully URL-decoded and normalised before it is checked.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
@@ -40,6 +41,13 @@ def norm(path):
|
||||
return posixpath.normpath("/" + path).lstrip("/") if path else ""
|
||||
|
||||
|
||||
def has_app(doc):
|
||||
"""A workflow with an App set up (ComfyUI App mode: chosen inputs or outputs)."""
|
||||
data = (doc or {}).get("extra") if isinstance(doc, dict) else None
|
||||
linear = (data or {}).get("linearData") if isinstance(data, dict) else None
|
||||
return isinstance(linear, dict) and bool(linear.get("inputs") or linear.get("outputs"))
|
||||
|
||||
|
||||
def min_role(a, b):
|
||||
return a if ROLE_RANK[a] <= ROLE_RANK[b] else b
|
||||
|
||||
@@ -51,6 +59,7 @@ class Workspace:
|
||||
self.folders = store._read("folders.json", {}) # "shared/<name>" -> {owner, acl, created}
|
||||
os.makedirs(os.path.join(root, "users"), exist_ok=True)
|
||||
os.makedirs(os.path.join(root, "shared"), exist_ok=True)
|
||||
self._app_cache = {} # absolute path -> (mtime, size, has an App)
|
||||
|
||||
def save(self):
|
||||
self.store._write("folders.json", self.folders)
|
||||
@@ -191,10 +200,29 @@ class Workspace:
|
||||
def _abs(self, rel):
|
||||
return os.path.join(self.root, *[p for p in rel.split("/") if p])
|
||||
|
||||
def _file_has_app(self, full):
|
||||
"""Read a workflow file only when it changed since the last check."""
|
||||
try:
|
||||
st = os.stat(full)
|
||||
except OSError:
|
||||
return False
|
||||
cached = self._app_cache.get(full)
|
||||
if cached and cached[0] == st.st_mtime and cached[1] == st.st_size:
|
||||
return cached[2]
|
||||
try:
|
||||
with open(full, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
found = '"linearData"' in text and has_app(json.loads(text))
|
||||
except (OSError, ValueError):
|
||||
found = False
|
||||
self._app_cache[full] = (st.st_mtime, st.st_size, found)
|
||||
return found
|
||||
|
||||
def _entry(self, rel, name, is_dir, kind, key, perms):
|
||||
full = self._abs(rel)
|
||||
is_app = not is_dir and name.endswith(".app.json") # saved as an App (ComfyUI App mode)
|
||||
entry = {"type": "folder" if is_dir else "file", "path": rel,
|
||||
"name": name if is_dir else name[:-5],
|
||||
"name": name if is_dir else name[:-9] if is_app else name[:-5],
|
||||
"modified": int(os.path.getmtime(full)) if os.path.exists(full) else 0,
|
||||
"role": self.role(rel, kind, key, perms)}
|
||||
area, folder = self.area(rel)
|
||||
@@ -202,6 +230,8 @@ class Workspace:
|
||||
meta = self.folders.get(folder) or {}
|
||||
entry.update(owner=meta.get("owner"), restricted=bool(meta.get("acl")),
|
||||
manage=self.can_manage(folder, kind, key, perms))
|
||||
if is_app or (not is_dir and self._file_has_app(full)):
|
||||
entry["app"] = True
|
||||
entry["deletable"] = self.can_delete(rel, kind, key, perms)
|
||||
return entry
|
||||
|
||||
@@ -249,7 +279,15 @@ class Workspace:
|
||||
entries.append(self._entry(child, name, os.path.isdir(full), kind, key, perms))
|
||||
can_mkdir = role == "edit" and rel not in ("", "users", "shared")
|
||||
entries.sort(key=lambda e: (e["type"] != "folder", e["name"].lower()))
|
||||
return {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir}
|
||||
out = {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir}
|
||||
# Inside a shared folder: which one, and whether this person manages its access.
|
||||
area, folder = self.area(rel) if rel != "@shared" else (None, None)
|
||||
if area == "shared":
|
||||
meta = self.folders.get(folder) or {}
|
||||
out["shared_folder"] = {"path": folder, "name": folder.split("/", 1)[1], "owner": meta.get("owner"),
|
||||
"restricted": bool(meta.get("acl")),
|
||||
"manage": self.can_manage(folder, kind, key, perms)}
|
||||
return out
|
||||
|
||||
def make_dir(self, parent, name, kind, key, perms):
|
||||
"""New folder. In the Shared root it becomes a shared folder you own."""
|
||||
|
||||
@@ -324,3 +324,119 @@ def test_chat_history_pages(run):
|
||||
assert "hello 0" in texts and seqs == list(range(len(seqs))), "no gaps, no repeats"
|
||||
assert (await session.get(rig.url("/rigshare/api/chat?before=x"))).status in (400, 401)
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_apps_and_comfy_file_tabs(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
h = rig.http
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/demo.app.json')}"), data=b"{}", headers=alice)
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/plain.json')}"), data=b"{}", headers=alice)
|
||||
listing = await (await h.get(rig.url("/rigshare/api/workspace/list?path=users/alice"), headers=alice)).json()
|
||||
entries = {e["name"]: e for e in listing["entries"]}
|
||||
assert entries["demo"].get("app") is True and "app" not in entries["plain"], "apps named without .app"
|
||||
|
||||
sock = await rig.ws("alice")
|
||||
key = "file:workflows/shared/demo.app.json"
|
||||
await sock.send({"type": "join", "room": key, "name": "demo", "doc": BASE_DOC})
|
||||
await sock.until("room")
|
||||
presence = [m for m in await sock.drain() if m["type"] == "presence"][-1]
|
||||
assert presence["rooms"][0]["app"] is True
|
||||
|
||||
# ComfyUI's own Workflows/Apps tabs are hidden unless an admin turns that off.
|
||||
assert rig.hub.server_info()["hide_comfy_file_tabs"] is True
|
||||
await h.patch(rig.url("/rigshare/api/config"), json={"hide_comfy_file_tabs": False}, headers=admin)
|
||||
assert (await sock.until("server"))["server"]["hide_comfy_file_tabs"] is False
|
||||
await sock.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_view_mode_in_presence(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
await rig.add_user("alice", edit=True)
|
||||
alice, boss = await rig.ws("alice"), await rig.ws("boss")
|
||||
await boss.drain()
|
||||
await alice.send({"type": "view", "view": "app"})
|
||||
presence = await boss.until("presence")
|
||||
assert {u["key"]: u["view"] for u in presence["users"]} == {"alice": "app", "boss": "graph"}
|
||||
await alice.send({"type": "view", "view": "nonsense"})
|
||||
await asyncio.sleep(0.2)
|
||||
assert next(c for c in rig.hub.clients.values() if c.key == "alice").view == "app", "unknown views are ignored"
|
||||
await alice.close(); await boss.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_listing_inside_a_shared_folder(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
bob = await rig.add_user("bob", edit=True)
|
||||
h = rig.http
|
||||
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "FaceSwap"}, headers=alice)
|
||||
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "shared/FaceSwap", "name": "old"}, headers=alice)
|
||||
|
||||
async def here(user, path):
|
||||
r = await h.get(rig.url(f"/rigshare/api/workspace/list?path={path}"), headers=user)
|
||||
return (await r.json()).get("shared_folder")
|
||||
|
||||
# The folder you are in, even deeper down, and whether you manage its access.
|
||||
assert await here(alice, "shared/FaceSwap/old") == {"path": "shared/FaceSwap", "name": "FaceSwap", "owner": "alice",
|
||||
"restricted": False, "manage": True}
|
||||
assert (await here(bob, "shared/FaceSwap"))["manage"] is False
|
||||
assert await here(alice, "users/alice") is None
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_workflows_with_an_app_set_up(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
h = rig.http
|
||||
with_app = json.dumps({"nodes": [], "extra": {"linearData": {"inputs": [[3, "seed"]], "outputs": [9]}}}).encode()
|
||||
empty_app = json.dumps({"nodes": [], "extra": {"linearData": {"inputs": [], "outputs": []}}}).encode()
|
||||
for name, body in (("faceswap.json", with_app), ("empty.json", empty_app), ("plain.json", b"{}")):
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/' + name)}"), data=body, headers=alice)
|
||||
|
||||
async def apps():
|
||||
listing = await (await h.get(rig.url("/rigshare/api/workspace/list?path=users/alice"), headers=alice)).json()
|
||||
return {e["name"] for e in listing["entries"] if e.get("app")}
|
||||
assert await apps() == {"faceswap"}, "only a workflow whose App has inputs or outputs"
|
||||
# Edited on disk: the cached answer follows the file.
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/faceswap.json')}"), data=b'{"nodes": []}', headers=alice)
|
||||
assert await apps() == set()
|
||||
|
||||
sock = await rig.ws("alice")
|
||||
await sock.send({"type": "join", "room": "file:workflows/shared/f.json", "name": "f", "doc": json.loads(with_app)})
|
||||
await sock.until("room")
|
||||
presence = [m for m in await sock.drain() if m["type"] == "presence"][-1]
|
||||
assert presence["rooms"][0]["app"] is True
|
||||
await sock.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// RigShare in ComfyUI's App mode.
|
||||
//
|
||||
// The App view's sidebar only draws buttons for a fixed set of tabs (assets
|
||||
// and apps), but its docked side panel shows whichever sidebar tab is active.
|
||||
// So RigShare adds its own buttons to that sidebar, copied from ComfyUI's so
|
||||
// they look the same, and they open the regular RigShare and Files tabs in the
|
||||
// docked panel, exactly like ComfyUI's own tabs. If the sidebar can't be
|
||||
// found, a small floating dock offers the same buttons.
|
||||
|
||||
import { h } from "./ui.js";
|
||||
|
||||
const RAIL = 'nav[data-testid="side-toolbar"]';
|
||||
|
||||
export class AppDock {
|
||||
/**
|
||||
* views: { id: { icon, title, tabId } }, tabId being a registered sidebar tab.
|
||||
*/
|
||||
constructor({ app, sync, views }) {
|
||||
this.app = app;
|
||||
this.sync = sync;
|
||||
this.views = views;
|
||||
this.badges = {};
|
||||
this.railButtons = {};
|
||||
this.dockButtons = {};
|
||||
this.dock = h("div", { class: "rs-dock rs-panel-vars" }, ...Object.entries(views).map(([id, v]) => {
|
||||
const badge = h("span", { class: "rs-dock-badge" });
|
||||
const btn = h("button", {
|
||||
class: "rs-dock-btn", title: v.title, "aria-label": v.title,
|
||||
onclick: () => this.toggle(id),
|
||||
}, h("i", { class: `pi ${v.icon}` }), badge);
|
||||
this.dockButtons[id] = { btn, badge };
|
||||
return btn;
|
||||
}));
|
||||
this.dock.style.display = "none";
|
||||
document.body.append(this.dock);
|
||||
setInterval(() => this.update(), 300);
|
||||
}
|
||||
|
||||
get activeTabId() {
|
||||
return this.app.extensionManager?.sidebarTab?.activeSidebarTabId ?? null;
|
||||
}
|
||||
|
||||
/** Open (or close) the tab in ComfyUI's docked side panel, like its own buttons do. */
|
||||
toggle(id) {
|
||||
this.app.extensionManager?.command?.execute(`Workspace.ToggleSidebarTab.${this.views[id].tabId}`);
|
||||
setTimeout(() => this.refreshButtons(), 30);
|
||||
}
|
||||
|
||||
update() {
|
||||
const active = this.sync.inAppMode;
|
||||
const rail = active ? document.querySelector(RAIL) : null;
|
||||
if (rail) this.addToRail(rail);
|
||||
else this.removeFromRail();
|
||||
this.dock.style.display = active && !rail ? "" : "none";
|
||||
this.refreshButtons();
|
||||
}
|
||||
|
||||
/** Copy one of ComfyUI's sidebar buttons for each view, placed after its own tabs. */
|
||||
addToRail(rail) {
|
||||
const ours = Object.values(this.railButtons);
|
||||
if (ours.length && ours.every((b) => rail.contains(b.btn))) return;
|
||||
const tabs = [...rail.querySelectorAll('[data-testid$="-tab-button"]')].filter((b) => !b.dataset.rigshare);
|
||||
const template = tabs.at(-1);
|
||||
if (!template) return;
|
||||
this.removeFromRail();
|
||||
let after = template;
|
||||
for (const [id, view] of Object.entries(this.views)) {
|
||||
const btn = template.cloneNode(true);
|
||||
btn.removeAttribute("data-testid");
|
||||
btn.dataset.rigshare = id;
|
||||
btn.classList.remove("side-bar-button-selected");
|
||||
btn.setAttribute("aria-label", view.title);
|
||||
btn.title = view.title;
|
||||
const icon = btn.querySelector(".side-bar-button-icon");
|
||||
if (icon) {
|
||||
const mine = document.createElement("i");
|
||||
mine.className = `pi ${view.icon} side-bar-button-icon`;
|
||||
mine.style.fontSize = "18px";
|
||||
icon.replaceWith(mine);
|
||||
}
|
||||
btn.querySelector(".sidebar-icon-badge")?.remove();
|
||||
const label = [...btn.querySelectorAll("span")].find((s) => s.textContent.trim());
|
||||
if (label) label.textContent = view.title;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "sidebar-icon-badge rs-rail-badge";
|
||||
const holder = btn.querySelector(".side-bar-button-icon")?.parentElement ?? btn;
|
||||
holder.style.position = "relative"; // the badge sits on the icon's corner
|
||||
holder.append(badge);
|
||||
btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.toggle(id); });
|
||||
after.after(btn);
|
||||
after = btn;
|
||||
this.railButtons[id] = { btn, badge };
|
||||
}
|
||||
this.refreshButtons();
|
||||
}
|
||||
|
||||
removeFromRail() {
|
||||
for (const b of Object.values(this.railButtons)) b.btn.remove();
|
||||
this.railButtons = {};
|
||||
}
|
||||
|
||||
refreshButtons() {
|
||||
const open = this.activeTabId;
|
||||
for (const [id, b] of Object.entries(this.railButtons)) {
|
||||
b.btn.classList.toggle("side-bar-button-selected", this.views[id].tabId === open);
|
||||
this.paintBadge(b.badge, this.badges[id]);
|
||||
}
|
||||
for (const [id, b] of Object.entries(this.dockButtons)) {
|
||||
b.btn.classList.toggle("active", this.views[id].tabId === open);
|
||||
this.paintBadge(b.badge, this.badges[id]);
|
||||
}
|
||||
}
|
||||
|
||||
paintBadge(el, text) {
|
||||
el.textContent = text || "";
|
||||
el.style.display = text ? "block" : "none";
|
||||
}
|
||||
|
||||
setBadge(id, text) {
|
||||
this.badges[id] = text || null;
|
||||
this.refreshButtons();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
// One component serves the Files tab ("browse"), the "Open from RigShare"
|
||||
// dialog ("open"), the Save dialog ("save") and the "Move to…" picker ("pick").
|
||||
|
||||
import { h, timeAgo, storage } from "./ui.js";
|
||||
import { h, timeAgo, storage, APP_ICON } from "./ui.js";
|
||||
|
||||
const SHARED = "@shared";
|
||||
const ALL = "";
|
||||
@@ -161,7 +161,14 @@ export class FilesBrowser {
|
||||
h("button", { class: `rs-fb-crumb ${i === trail.length - 1 ? "current" : ""}`, onclick: () => this.go(c.path) },
|
||||
c.icon ? h("i", { class: `pi ${c.icon}` }) : null, i === 0 && c.icon ? null : c.label),
|
||||
]).filter(Boolean));
|
||||
const shared = this.listing?.shared_folder;
|
||||
this.actions.replaceChildren(...[
|
||||
// Inside a shared folder: its access list, like the 🔒 on its row one level up.
|
||||
shared?.manage && this.mode === "browse" && this.accessEditor ? h("button", {
|
||||
class: `rs-btn rs-btn-icon ${this.accessFor === shared.path ? "active" : ""}`,
|
||||
title: `Who can open "${shared.name}"${shared.restricted ? " (restricted)" : ""}`,
|
||||
onclick: () => { this.accessFor = this.accessFor === shared.path ? null : shared.path; this.render(); },
|
||||
}, h("i", { class: `pi ${shared.restricted ? "pi-lock" : "pi-lock-open"}` })) : null,
|
||||
this.listing?.can_mkdir && this.mode !== "open" ? h("button", {
|
||||
class: "rs-btn rs-btn-icon", title: this.path === SHARED ? "New shared folder" : "New folder",
|
||||
onclick: () => this.newFolder(),
|
||||
@@ -173,6 +180,14 @@ export class FilesBrowser {
|
||||
|
||||
renderList() {
|
||||
const entries = (this.listing?.entries || []).filter((e) => !this.filter || e.name.toLowerCase().includes(this.filter));
|
||||
// The access editor of the shared folder we are in shows above its content.
|
||||
const here = this.listing?.shared_folder;
|
||||
const top = here && this.accessFor === here.path && this.accessEditor
|
||||
? this.accessEditor(here.path, () => { this.accessFor = null; this.reload(); }) : null;
|
||||
if (top) {
|
||||
this.list.replaceChildren(top, ...entries.map((e) => this.row(e)));
|
||||
return;
|
||||
}
|
||||
if (!entries.length) {
|
||||
this.list.replaceChildren(h("div", { class: "rs-fb-empty" },
|
||||
this.filter ? "Nothing matches." : this.path === `users/${this.me}` ? "Nothing here yet. Save a workflow and choose My files." : "This folder is empty."));
|
||||
@@ -191,8 +206,8 @@ export class FilesBrowser {
|
||||
const owner = e.owner ? this.client.users.find((u) => u.key === e.owner)?.name ?? `@${e.owner}` : null;
|
||||
const meta = isFolder
|
||||
? [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";
|
||||
: [e.app ? "App" : null, timeAgo(e.modified * 1000), e.role === "view" ? "view only" : null].filter(Boolean).join(" · ");
|
||||
const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : e.app ? "app" : "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;
|
||||
@@ -207,7 +222,7 @@ export class FilesBrowser {
|
||||
if (this.mode !== "pick") this.onOpenFile?.(`workflows/${e.path}`);
|
||||
},
|
||||
},
|
||||
h("i", { class: `pi ${icon} rs-fb-icon ${isFolder ? "folder" : ""}` }),
|
||||
h("i", { class: `${icon === "app" ? `${APP_ICON} rs-comfy-icon` : `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,
|
||||
live ? h("span", { class: "rs-live rs-inline-icon", title: `Live · ${live.members.length} open` }, h("span", { class: "rs-live-dot" })) : null),
|
||||
@@ -234,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);
|
||||
@@ -316,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),
|
||||
@@ -329,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,8 +11,35 @@ import { STYLES } from "./styles.js";
|
||||
import { installSaveDialog } from "./save-dialog.js";
|
||||
import { FilesBrowser, openFromWorkspace } from "./files.js";
|
||||
import { h } from "./ui.js";
|
||||
import { AppDock } from "./app-dock.js";
|
||||
|
||||
const TAB_ID = "rigshare";
|
||||
const FILES_TAB_ID = "rigshare-files";
|
||||
|
||||
/** Remembers the side panel elements each custom tab was rendered into and keeps
|
||||
* the tab's content in whichever of them is on screen. */
|
||||
class SlotTracker {
|
||||
constructor(app, tabs) {
|
||||
this.app = app;
|
||||
this.tabs = tabs;
|
||||
this.slots = Object.fromEntries(Object.keys(tabs).map((id) => [id, new Set()]));
|
||||
setInterval(() => this.check(), 300);
|
||||
}
|
||||
|
||||
add(id, el) {
|
||||
this.slots[id]?.add(el);
|
||||
}
|
||||
|
||||
check() {
|
||||
const active = this.app.extensionManager?.sidebarTab?.activeSidebarTabId;
|
||||
for (const [id, set] of Object.entries(this.slots)) {
|
||||
for (const el of set) if (!el.isConnected) set.delete(el);
|
||||
if (id !== active) continue;
|
||||
const visible = [...set].find((el) => el.getClientRects().length > 0);
|
||||
if (visible && !this.tabs[id].has(visible)) this.tabs[id].fill(visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
const ICON_CLASS = "rigshare-tab-icon";
|
||||
|
||||
function setting(id, fallback) {
|
||||
@@ -93,6 +120,45 @@ function installComfyAccountGuard(client) {
|
||||
setTimeout(apply, 2000);
|
||||
}
|
||||
|
||||
// ComfyUI's Workflows and Apps sidebar tabs browse the same files as RigShare's
|
||||
// Files tab, without its folders. The server enforces permissions either way;
|
||||
// hiding them avoids two file browsers and buttons the server would refuse.
|
||||
const COMFY_FILE_TABS = ["workflows", "apps"];
|
||||
function installFileTabsGuard(client) {
|
||||
const removed = new Map();
|
||||
const manager = () => app.extensionManager;
|
||||
const tabs = () => manager()?.getSidebarTabs?.() ?? manager()?.sidebarTab?.sidebarTabs ?? [];
|
||||
const hidden = () => client.server?.hide_comfy_file_tabs !== false;
|
||||
const openFiles = () => manager()?.command?.execute("Workspace.ToggleSidebarTab.rigshare-files");
|
||||
const apply = () => {
|
||||
if (!client.server) return; // wait for the server's settings
|
||||
const hide = hidden();
|
||||
for (const id of COMFY_FILE_TABS) {
|
||||
const tab = tabs().find((t) => t.id === id);
|
||||
// Open workflow tabs can be shown in the Workflows sidebar tab: keep it then.
|
||||
const needed = id === "workflows" && setting("Comfy.Workflow.WorkflowTabsPosition", "Topbar") === "Sidebar";
|
||||
if (hide && tab && !needed) {
|
||||
removed.set(id, tab);
|
||||
(manager().unregisterSidebarTab ?? manager().sidebarTab.unregisterSidebarTab)(id);
|
||||
} else if ((!hide || needed) && !tab && removed.has(id)) {
|
||||
manager().registerSidebarTab(removed.get(id));
|
||||
removed.delete(id);
|
||||
}
|
||||
// Their keyboard shortcuts open Files instead.
|
||||
const cmd = (manager()?.command?.commands ?? []).find((c) => c.id === `Workspace.ToggleSidebarTab.${id}`);
|
||||
if (cmd && !cmd.__rigshare) {
|
||||
const original = cmd.function;
|
||||
cmd.function = (...args) => (removed.has(id) ? openFiles() : original(...args));
|
||||
cmd.__rigshare = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
client.on("server", apply);
|
||||
// ComfyUI registers its own tabs around the time extensions load.
|
||||
let tries = 0;
|
||||
const timer = setInterval(() => { apply(); if (++tries > 15) clearInterval(timer); }, 1000);
|
||||
}
|
||||
|
||||
function installQueueGuard(client) {
|
||||
const original = api.queuePrompt.bind(api);
|
||||
api.queuePrompt = async (...args) => {
|
||||
@@ -235,9 +301,22 @@ app.registerExtension({
|
||||
installQueueGuard(client);
|
||||
installManagerGuard(client);
|
||||
installComfyAccountGuard(client);
|
||||
installFileTabsGuard(client);
|
||||
installSaveDialog(app, api, client, sync);
|
||||
presence.install();
|
||||
|
||||
// ComfyUI keeps a separate side panel for the graph and the App view and
|
||||
// shows the old one again without asking the tab to render: follow the
|
||||
// visible one, or the panel comes back blank after switching views.
|
||||
let filesPanelRef = () => null; // set once the Files panel is built below
|
||||
const slots = new SlotTracker(app, {
|
||||
[TAB_ID]: { has: (el) => el.contains(panel.root), fill: (el) => panel.mount(el) },
|
||||
[FILES_TAB_ID]: {
|
||||
has: (el) => !filesPanelRef() || el.contains(filesPanelRef()),
|
||||
fill: (el) => { if (el.contains(panel.root)) panel.unmount(); el.replaceChildren(filesPanelRef()); },
|
||||
},
|
||||
});
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: TAB_ID,
|
||||
icon: `pi pi-users ${ICON_CLASS}`,
|
||||
@@ -246,7 +325,7 @@ app.registerExtension({
|
||||
label: "RigShare",
|
||||
type: "custom",
|
||||
iconBadge: null,
|
||||
render: (el) => panel.mount(el),
|
||||
render: (el) => { slots.add(TAB_ID, el); panel.mount(el); },
|
||||
destroy: () => panel.unmount(),
|
||||
});
|
||||
|
||||
@@ -271,15 +350,17 @@ app.registerExtension({
|
||||
onclick: () => app.extensionManager.command.execute("Comfy.OpenWorkflow"),
|
||||
}, h("i", { class: "pi pi-upload" }), "From computer")),
|
||||
h("div", { class: "rs-body" }, filesBrowser.el));
|
||||
filesPanelRef = () => filesPanel;
|
||||
let filesLoaded = false;
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "rigshare-files",
|
||||
id: FILES_TAB_ID,
|
||||
icon: "pi pi-folder-open",
|
||||
title: "Files",
|
||||
tooltip: "RigShare files: My files and Shared",
|
||||
label: "Files",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
slots.add(FILES_TAB_ID, el);
|
||||
el.classList.add("rs-host");
|
||||
// Same slot element as the RigShare tab when switching between them
|
||||
// (ComfyUI only calls destroy on unmount): take it over cleanly.
|
||||
@@ -290,11 +371,21 @@ app.registerExtension({
|
||||
});
|
||||
client.on("self", () => { if (filesPanel.isConnected) filesBrowser.reload(); });
|
||||
|
||||
// ComfyUI's App view draws buttons only for its own tabs: add ours to its sidebar.
|
||||
const dock = new AppDock({
|
||||
app, sync,
|
||||
views: {
|
||||
[TAB_ID]: { icon: "pi-users", title: "RigShare", tabId: TAB_ID },
|
||||
files: { icon: "pi-folder-open", title: "Files", tabId: FILES_TAB_ID },
|
||||
},
|
||||
});
|
||||
|
||||
// The registered tab object is wrapped by Vue; mutating it through the
|
||||
// store keeps the unread badge reactive.
|
||||
panel.onBadge = (badge) => {
|
||||
const tab = app.extensionManager.getSidebarTabs?.().find((t) => t.id === TAB_ID);
|
||||
if (tab) tab.iconBadge = badge;
|
||||
dock.setBadge(TAB_ID, badge);
|
||||
setBlink(!!badge);
|
||||
};
|
||||
panel.onNotify = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Sidebar panel: tabs for workflows, people, chat, server and admin, plus an
|
||||
// account view (profile, password, API keys) behind the avatar button.
|
||||
|
||||
import { h, initials, timeAgo, clock, roleChips, meter, storage } from "./ui.js";
|
||||
import { h, initials, timeAgo, clock, roleChips, meter, storage, viewIcon, APP_ICON } from "./ui.js";
|
||||
import { ServerWidget } from "./server-widget.js";
|
||||
import { pickFolder } from "./files.js";
|
||||
import { isPrivatePath } from "./sync.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;
|
||||
@@ -247,9 +255,8 @@ export class RigSharePanel {
|
||||
s.live
|
||||
? h("span", { class: "rs-live" }, h("span", { class: "rs-live-dot" }), `Live · rev ${s.version}${last}`)
|
||||
: h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"),
|
||||
h("span", { class: "rs-avatars" }, ...here.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))));
|
||||
h("span", { class: "rs-avatars" }, ...here.map((u) => this.viewAvatar(u)))));
|
||||
body.push(this.accessLine());
|
||||
if (this.accessOpen && s.room?.manage && s.room.folder) body.push(this.accessEditor(s.room.folder));
|
||||
if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access."));
|
||||
if (s.canEdit) {
|
||||
row.append(h("button", {
|
||||
@@ -278,7 +285,7 @@ export class RigSharePanel {
|
||||
await this.run(() => this.sync.movePath(wf.path, dest ? `workflows/${dest}/${base}` : `workflows/${base}`), "Moved: it is live now");
|
||||
}
|
||||
|
||||
/** Who can open the workflow on screen: that is decided by its folder. */
|
||||
/** Who can open the workflow on screen: decided by its folder, managed in the Files tab. */
|
||||
accessLine() {
|
||||
const info = this.sync.room;
|
||||
if (!info) return null;
|
||||
@@ -286,24 +293,10 @@ export class RigSharePanel {
|
||||
return h("div", { class: "rs-access" },
|
||||
h("i", { class: `pi ${info.restricted ? "pi-lock" : "pi-globe"}` }),
|
||||
h("span", { class: "rs-grow" }, info.restricted ? `Only people with access to "${folder}"` : "Everyone can open it",
|
||||
folder && !info.restricted ? h("span", { class: "rs-muted" }, ` · in "${folder}"`) : null),
|
||||
info.manage ? h("button", {
|
||||
class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`, title: `Who can open the folder "${folder}"`,
|
||||
onclick: () => { this.accessOpen = !this.accessOpen; this.accessDraft = null; this.renderBody(); },
|
||||
}, "Folder access") : null);
|
||||
folder && !info.restricted ? h("span", { class: "rs-muted" }, ` · in "${folder}"`) : null));
|
||||
}
|
||||
|
||||
accessEditor(folder) {
|
||||
return this.accessEditorFor({
|
||||
load: () => this.client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(folder)}`),
|
||||
save: (restricted, members) => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: folder, restricted, members }),
|
||||
everyoneHint: "Anyone with an account can open its workflows, with their usual permissions",
|
||||
close: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); },
|
||||
draftKey: "accessDraft",
|
||||
});
|
||||
}
|
||||
|
||||
/** Access editor for a shared folder (Files tab and the live workflow's folder). */
|
||||
/** Access editor for a shared folder (used by the Files tab). */
|
||||
accessEditorFor({ load, save, everyoneHint, close, draftKey }) {
|
||||
const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…"));
|
||||
Promise.all([this.run(load), this.run(() => this.client.request("GET", "/rigshare/api/people"))]).then(([acl, people]) => {
|
||||
@@ -363,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();
|
||||
},
|
||||
@@ -380,7 +373,16 @@ 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. */
|
||||
viewAvatar(u) {
|
||||
const app = u.view === "app";
|
||||
return h("span", {
|
||||
class: `rs-avatar rs-avatar-xs rs-view-avatar ${app ? "rs-view-app" : ""}`, style: `background:${u.color}`,
|
||||
title: `${u.name} · ${app ? "App view" : "editing the graph"}`,
|
||||
}, initials(u.name), h("span", { class: "rs-view-mark" }, viewIcon(u.view)));
|
||||
}
|
||||
|
||||
roomsSection() {
|
||||
@@ -392,11 +394,11 @@ export class RigSharePanel {
|
||||
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 pi-file rs-row-icon" }),
|
||||
h("i", { class: `${r.app ? `${APP_ICON} rs-comfy-icon` : "pi pi-file"} rs-row-icon`, title: r.app ? "App" : null }),
|
||||
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),
|
||||
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))))),
|
||||
h("div", { class: "rs-avatars" }, ...members.map((u) => this.viewAvatar(u)))),
|
||||
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)),
|
||||
@@ -432,7 +434,9 @@ export class RigSharePanel {
|
||||
h("div", { class: "rs-name rs-ellipsis" }, u.name, self ? h("span", { class: "rs-muted" }, " (you)") : null,
|
||||
u.idle ? h("i", { class: "pi pi-moon rs-muted rs-inline-icon", title: "Away" }) : null),
|
||||
h("div", { class: "rs-chips" }, ...(toggles.length ? toggles : roleChips(u.perms))),
|
||||
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"))),
|
||||
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"),
|
||||
where ? h("span", { class: `rs-chip rs-view-chip ${u.view === "app" ? "rs-view-app" : ""}` },
|
||||
viewIcon(u.view), u.view === "app" ? "App" : "Graph") : null)),
|
||||
self ? null : h("button", {
|
||||
class: `rs-btn rs-btn-icon ${following ? "rs-btn-primary" : ""}`,
|
||||
title: following ? "Stop following" : `Follow ${u.name} (jump to their tab and view)`,
|
||||
@@ -777,6 +781,7 @@ export class RigSharePanel {
|
||||
broadcast: box(cfg.broadcast_execution),
|
||||
protect: box(cfg.api_protection?.enabled),
|
||||
hideAccount: box(cfg.hide_comfy_account),
|
||||
hideFileTabs: box(cfg.hide_comfy_file_tabs ?? true),
|
||||
hideApiTemplates: box(cfg.hide_api_templates),
|
||||
trusted: text((cfg.api_protection?.trusted_ips || []).join(", "), "127.0.0.1, 172.17.0.0/16"),
|
||||
interval: h("input", { class: "rs-input rs-num", type: "number", min: 0, value: Math.round((cfg.snapshot_interval_sec ?? 300) / 60) }),
|
||||
@@ -794,6 +799,7 @@ export class RigSharePanel {
|
||||
allow_guests: f.guests.checked,
|
||||
broadcast_execution: f.broadcast.checked,
|
||||
hide_comfy_account: f.hideAccount.checked,
|
||||
hide_comfy_file_tabs: f.hideFileTabs.checked,
|
||||
hide_api_templates: f.hideApiTemplates.checked,
|
||||
api_protection: { enabled: f.protect.checked, trusted_ips: f.trusted.value.split(/[\s,]+/).filter(Boolean) },
|
||||
snapshot_interval_sec: Math.max(0, +f.interval.value || 0) * 60,
|
||||
@@ -808,6 +814,7 @@ export class RigSharePanel {
|
||||
line(f.broadcast, "Share execution progress", "Everyone sees progress and previews, not only whoever queued"),
|
||||
line(f.protect, "Enforce permissions on the ComfyUI API", "Blocks queueing, interrupting and uploads for users without permission"),
|
||||
line(f.hideAccount, "Hide the Comfy.org account login", "Removes ComfyUI's own sign-in button and dialog (used for paid API nodes)"),
|
||||
line(f.hideFileTabs, "Hide ComfyUI's Workflows and Apps tabs", "RigShare's Files tab replaces them. Their shortcuts open Files instead"),
|
||||
line(f.hideApiTemplates, "Hide API templates", "Removes templates that need paid API nodes from the template browser"),
|
||||
h("label", { class: "rs-label" }, "Trusted IPs / networks (skip login and checks)"), f.trusted,
|
||||
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Auto snapshot every (min, 0 = off)"), f.interval),
|
||||
|
||||
@@ -71,7 +71,7 @@ export class Presence extends EventTarget {
|
||||
sendState() {
|
||||
if (!this.client.online || !this.app.canvas) return;
|
||||
let msg;
|
||||
if (!this.sync.live || document.hidden) {
|
||||
if (!this.sync.live || document.hidden || this.sync.inAppMode) {
|
||||
msg = { type: "cursor", x: null, y: null };
|
||||
} else {
|
||||
const [x, y] = this.app.canvas.graph_mouse ?? [0, 0];
|
||||
@@ -104,7 +104,7 @@ export class Presence extends EventTarget {
|
||||
}
|
||||
|
||||
applyViewport(vp, graphId) {
|
||||
if (graphId !== this.currentGraphId()) return;
|
||||
if (graphId !== this.currentGraphId() || this.sync.inAppMode) return;
|
||||
const canvas = this.app.canvas;
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
canvas.ds.scale = vp.scale;
|
||||
@@ -123,7 +123,7 @@ export class Presence extends EventTarget {
|
||||
|
||||
draw(ctx) {
|
||||
const visible = new Set();
|
||||
if (!this.sync.live || !this.showCursors) return this.pruneCursorEls(visible);
|
||||
if (!this.sync.live || !this.showCursors || this.sync.inAppMode) return this.pruneCursorEls(visible);
|
||||
const canvas = this.app.canvas;
|
||||
const graph = canvas.graph;
|
||||
const scale = canvas.ds.scale;
|
||||
|
||||
@@ -9,8 +9,9 @@ import { FilesBrowser, modal } from "./files.js";
|
||||
|
||||
const LAST_FOLDER = "rigshare.lastSaveFolder";
|
||||
|
||||
// ComfyUI adds ".json", or ".app.json" for a workflow saved as an App: names go back without either.
|
||||
function cleanName(name) {
|
||||
return (name || "").trim().replace(/\.json$/i, "").replace(/[\\/]+/g, "-").slice(0, 120);
|
||||
return (name || "").trim().replace(/(\.app)?\.json$/i, "").replace(/\.app$/i, "").replace(/[\\/]+/g, "-").slice(0, 120);
|
||||
}
|
||||
|
||||
export function installSaveDialog(app, api, client, sync) {
|
||||
@@ -75,7 +76,8 @@ export async function chooseLocation(app, api, client, sync, wf) {
|
||||
if (directory === "workflows") return `${result.folder}/${result.name}`;
|
||||
// "Save As" from a file that lives in another folder: write the copy
|
||||
// ourselves, open it, and tell ComfyUI there is nothing left to do.
|
||||
return await saveCopyElsewhere(app, api, client, wf, `${targetDir}/${result.name}.json`);
|
||||
const isApp = wf.initialMode === "app" || /\.app(\.json)?$/i.test(wf.filename || "");
|
||||
return await saveCopyElsewhere(app, api, client, wf, `${targetDir}/${result.name}${isApp ? ".app" : ""}.json`);
|
||||
}
|
||||
|
||||
async function saveCopyElsewhere(app, api, client, wf, path) {
|
||||
|
||||
@@ -94,7 +94,7 @@ code { font-family: ui-monospace, monospace; font-size: 11px; background: var(--
|
||||
.rs-avatar-sm { width: 28px; height: 28px; font-size: 10.5px; }
|
||||
.rs-avatar-lg { width: 44px; height: 44px; font-size: 15px; }
|
||||
.rs-avatar-xs { width: 20px; height: 20px; font-size: 8.5px; }
|
||||
.rs-avatars { display: inline-flex; gap: 3px; flex-wrap: wrap; margin-top: 3px; }
|
||||
.rs-avatars { display: inline-flex; gap: 7px; flex-wrap: wrap; margin-top: 3px; }
|
||||
.rs-name { font-weight: 600; }
|
||||
.rs-me { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.rs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
||||
@@ -204,7 +204,7 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-fb-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 7px 8px; border: 0; background: none;
|
||||
color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.rs-fb-main:disabled { cursor: default; }
|
||||
.rs-fb-icon { font-size: 16px; color: var(--rs-muted); width: 18px; text-align: center; flex: none; }
|
||||
.rs-fb-icon { font-size: 13px; color: var(--rs-muted); width: 15px; text-align: center; flex: none; }
|
||||
.rs-fb-icon.folder { color: #f5b942; }
|
||||
.rs-fb-tools-row { display: none; gap: 3px; padding-right: 6px; }
|
||||
.rs-fb-row:hover .rs-fb-tools-row, .rs-fb-row:focus-within .rs-fb-tools-row { display: flex; }
|
||||
@@ -244,6 +244,25 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-mini-queue { color: var(--rs-muted); text-align: center; margin-top: 2px; }
|
||||
|
||||
/* remote cursors (DOM layer above canvas content) */
|
||||
.rs-dock { position: fixed; left: 64px; bottom: 16px; z-index: 1100; display: flex; gap: 4px; padding: 4px;
|
||||
background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 12px; box-shadow: 0 6px 24px rgb(0 0 0 / .35); }
|
||||
.rs-dock-btn { position: relative; width: 36px; height: 36px; display: grid; place-items: center; border: 0; border-radius: 8px;
|
||||
background: none; color: var(--rs-muted); cursor: pointer; }
|
||||
.rs-dock-btn .pi { font-size: 16px; }
|
||||
.rs-dock-btn:hover { color: var(--rs-fg); background: var(--rs-bg2); }
|
||||
.rs-dock-btn.active { color: var(--rs-accent-fg); background: var(--rs-accent); }
|
||||
.rs-dock-badge { display: none; position: absolute; top: 1px; right: 1px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
|
||||
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; }
|
||||
.rs-rail-badge { position: absolute; top: -4px; right: -6px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
|
||||
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; text-align: center; }
|
||||
.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: #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); }
|
||||
.rs-cursor-layer { position: fixed; inset: 0; pointer-events: none; z-index: 900; overflow: hidden; }
|
||||
.rs-cursor { position: absolute; left: 0; top: 0; will-change: transform; transition: transform 70ms linear; }
|
||||
.rs-cursor svg { display: block; filter: drop-shadow(0 1px 1px rgb(0 0 0 / .4)); }
|
||||
|
||||
@@ -120,7 +120,7 @@ export class RoomSync extends EventTarget {
|
||||
this.inFlight = 0; // our patches not yet acknowledged
|
||||
this.crossed = false; // a remote patch arrived while ours were in flight
|
||||
|
||||
client.on("welcome", () => { this.targetKey = undefined; this.tick(); });
|
||||
client.on("welcome", () => { this.targetKey = undefined; this.sentView = undefined; this.tick(); });
|
||||
client.on("status", (s) => { if (s !== "online") { this.ready = false; this.emitState(); } });
|
||||
client.on("room", (msg) => this.onRoom(msg));
|
||||
client.on("room_info", (msg) => {
|
||||
@@ -222,6 +222,13 @@ export class RoomSync extends EventTarget {
|
||||
return this.app.rootGraph ?? this.app.graph;
|
||||
}
|
||||
|
||||
/** The tab on screen shows ComfyUI's App view (no canvas), not the node graph. */
|
||||
get inAppMode() {
|
||||
const wf = this.store?.activeWorkflow;
|
||||
const mode = wf?.activeMode ?? wf?.initialMode ?? "graph";
|
||||
return mode === "app" || mode === "builder:arrange";
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return stripView(JSON.parse(JSON.stringify(this.graph().serialize())));
|
||||
}
|
||||
@@ -229,7 +236,7 @@ export class RoomSync extends EventTarget {
|
||||
// ----- which room does a tab belong to --------------------------------
|
||||
|
||||
tabName(wf) {
|
||||
return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/\.json$/, "");
|
||||
return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/(\.app)?\.json$/, "");
|
||||
}
|
||||
|
||||
/** Why a tab is not live: "unsaved", "private", or null when it is shared. */
|
||||
@@ -254,6 +261,9 @@ export class RoomSync extends EventTarget {
|
||||
|
||||
tick() {
|
||||
if (!this.client.online || !this.store) return;
|
||||
// Tell the others whether this tab shows the node graph or the App view.
|
||||
const view = this.inAppMode ? "app" : "graph";
|
||||
if (view !== this.sentView && this.client.send({ type: "view", view })) this.sentView = view;
|
||||
const { wf, room } = this.activeRoom();
|
||||
const key = room?.key ?? null;
|
||||
if (key !== this.targetKey || (key && wf !== this.targetWf)) {
|
||||
@@ -317,6 +327,8 @@ export class RoomSync extends EventTarget {
|
||||
const doc = structuredClone(this.doc);
|
||||
const ds = this.app.canvas?.ds;
|
||||
if (keepView && ds) doc.extra = { ...(doc.extra || {}), ds: { scale: ds.scale, offset: [...ds.offset] } };
|
||||
// Graph or App view is each person's own choice: a reload must not switch it.
|
||||
const mode = wf.activeMode;
|
||||
this.applying++;
|
||||
this.ownLoad++;
|
||||
try {
|
||||
@@ -324,6 +336,7 @@ export class RoomSync extends EventTarget {
|
||||
} finally {
|
||||
this.ownLoad--;
|
||||
this.applying--;
|
||||
if (mode && wf.activeMode !== mode) wf.activeMode = mode;
|
||||
}
|
||||
this.settle();
|
||||
this.updateReadOnly();
|
||||
|
||||
@@ -62,3 +62,11 @@ export const storage = {
|
||||
} catch { /* private mode */ }
|
||||
},
|
||||
};
|
||||
|
||||
// ComfyUI's own icons for its Graph / App switch, so RigShare matches what people see there.
|
||||
export const GRAPH_ICON = "icon-[comfy--workflow]";
|
||||
export const APP_ICON = "icon-[lucide--panels-top-left]";
|
||||
|
||||
export function viewIcon(view, cls = "") {
|
||||
return h("i", { class: `${view === "app" ? APP_ICON : GRAPH_ICON} rs-comfy-icon ${cls}` });
|
||||
}
|
||||
|
||||