Files: Drive-style sidebar tab (My files / Shared), Open from RigShare, folder browser in Save, folder operations

This commit is contained in:
Doudou
2026-09-26 02:10:39 +00:00
parent e3becf8a7f
commit 82b12c360d
7 changed files with 637 additions and 204 deletions
+49
View File
@@ -362,6 +362,55 @@ def setup(server, store, hub):
return error("Not logged in", 401)
return web.json_response(ws().tree(*who(request)))
def ws_call(fn):
try:
return None, fn()
except PermissionError as e:
return error(str(e), 403), None
except FileNotFoundError as e:
return error(str(e), 404), None
except ValueError as e:
return error(str(e)), None
@routes.get("/rigshare/api/workspace/list")
async def ws_list(request):
if not current_user(request):
return error("Not logged in", 401)
err, result = ws_call(lambda: ws().list_dir(request.query.get("path", ""), *who(request)))
return err or web.json_response(result)
@routes.post("/rigshare/api/workspace/mkdir")
async def ws_mkdir(request):
body = await request.json()
err, path = ws_call(lambda: ws().make_dir(body.get("parent", ""), body.get("name"), *who(request)))
return err or web.json_response({"path": path})
@routes.post("/rigshare/api/workspace/rename-folder")
async def ws_rename_dir(request):
body = await request.json()
old = norm(body.get("path"))
err, new = ws_call(lambda: ws().rename_dir(old, body.get("name"), *who(request)))
if err:
return err
hub.rekey_rooms("file:workflows/" + old, "file:workflows/" + new)
return web.json_response({"path": new})
@routes.post("/rigshare/api/workspace/move-folder")
async def ws_move_dir(request):
body = await request.json()
old = norm(body.get("path"))
err, new = ws_call(lambda: ws().move_dir(old, body.get("dest", ""), *who(request)))
if err:
return err
hub.rekey_rooms("file:workflows/" + old, "file:workflows/" + new)
return web.json_response({"path": new})
@routes.post("/rigshare/api/workspace/delete-folder")
async def ws_delete_dir(request):
body = await request.json()
err, _ = ws_call(lambda: ws().delete_dir(body.get("path", ""), *who(request)))
return err or web.json_response({"ok": True})
@routes.get("/rigshare/api/workspace/folder")
async def folder_acl(request):
folder = norm(request.query.get("path"))
+139
View File
@@ -186,6 +186,145 @@ class Workspace:
"common": {"files": self._files(""), "role": "edit" if can_edit else "view"},
"can_create": can_edit}
# ----- browsing (Files tab) -------------------------------------------
def _abs(self, rel):
return os.path.join(self.root, *[p for p in rel.split("/") if p])
def _entry(self, rel, name, is_dir, kind, key, perms):
full = self._abs(rel)
entry = {"type": "folder" if is_dir else "file", "path": rel,
"name": name if is_dir 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)
if is_dir and area == "shared" and rel == folder:
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))
entry["deletable"] = self.can_delete(rel, kind, key, perms)
return entry
def list_dir(self, rel, kind, key, perms):
"""One folder's content. "@shared" is the Shared root: the shared folders
plus the common top level. "users" (admins) lists everyone's folders.
Raises PermissionError / FileNotFoundError."""
can_edit = bool(perms.get("edit") or perms.get("admin"))
entries = []
if rel == "@shared":
shared_dir = self._abs("shared")
for name in (os.listdir(shared_dir) if os.path.isdir(shared_dir) else []):
path = "shared/" + name
if os.path.isdir(self._abs(path)) and self.folder_role(path, kind, key, perms):
entries.append(self._entry(path, name, True, kind, key, perms))
for name in os.listdir(self.root):
if name in ("users", "shared") or name.startswith("."):
continue
full = os.path.join(self.root, name)
if os.path.isdir(full) or name.endswith(".json"):
entries.append(self._entry(name, name, os.path.isdir(full), kind, key, perms))
role = "edit" if can_edit else "view"
can_mkdir = can_edit and kind == "user"
else:
rel = norm(rel)
if rel == "users" and not perms.get("admin"):
raise PermissionError("You do not have access to this folder")
role = self.role(rel, kind, key, perms)
if role is None:
raise PermissionError("You do not have access to this folder")
if kind == "user" and rel == "users/" + str(key):
self.ensure_private(key)
base = self._abs(rel)
if not os.path.isdir(base):
raise FileNotFoundError("This folder no longer exists")
for name in os.listdir(base):
if name.startswith("."):
continue
child = rel + "/" + name if rel else name
full = os.path.join(base, name)
if not (os.path.isdir(full) or name.endswith(".json")):
continue
if self.role(child, kind, key, perms) is None:
continue
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}
def make_dir(self, parent, name, kind, key, perms):
"""New folder. In the Shared root it becomes a shared folder you own."""
name = (name or "").strip()
if not FOLDER_NAME_RE.match(name) or name in (".", ".."):
raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )")
if parent == "@shared":
if kind != "user" or not (perms.get("edit") or perms.get("admin")):
raise PermissionError("You need the Edit permission to create shared folders")
return self.create_folder(name, key)
parent = norm(parent)
if parent in ("", "users", "shared") or self.role(parent, kind, key, perms) != "edit":
raise PermissionError("You cannot create a folder here")
path = parent + "/" + name
if os.path.exists(self._abs(path)):
raise ValueError("A folder with that name already exists")
os.makedirs(self._abs(path))
return path
def _top_level(self, rel):
area, folder = self.area(rel)
return (area == "shared" and rel == folder) or (area == "private" and rel.count("/") < 2) \
or rel in ("users", "shared")
def rename_dir(self, rel, name, kind, key, perms):
rel = norm(rel)
area, folder = self.area(rel)
if area == "shared" and rel == folder:
if not self.can_manage(folder, kind, key, perms):
raise PermissionError("Only the folder's owner or an admin can rename it")
return self.rename_folder(folder, name)
name = (name or "").strip()
if not FOLDER_NAME_RE.match(name) or name in (".", ".."):
raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )")
parent = posixpath.dirname(rel)
if self._top_level(rel) or not self.can_delete(rel, kind, key, perms) \
or self.role(parent, kind, key, perms) != "edit":
raise PermissionError("You cannot rename this folder")
new = parent + "/" + name if parent else name
if os.path.exists(self._abs(new)):
raise ValueError("A folder with that name already exists")
os.rename(self._abs(rel), self._abs(new))
return new
def move_dir(self, rel, dest_parent, kind, key, perms):
rel, dest_parent = norm(rel), ("" if dest_parent == "@shared" else norm(dest_parent))
if self._top_level(rel):
raise PermissionError("Top-level folders cannot be moved")
if dest_parent == rel or dest_parent.startswith(rel + "/"):
raise ValueError("A folder cannot be moved into itself")
if dest_parent in ("users", "shared") or not self.can_delete(rel, kind, key, perms) \
or self.role(dest_parent, kind, key, perms) != "edit":
raise PermissionError("You cannot move this folder there")
new = dest_parent + "/" + posixpath.basename(rel) if dest_parent else posixpath.basename(rel)
if os.path.exists(self._abs(new)):
raise ValueError("A folder with that name already exists there")
os.rename(self._abs(rel), self._abs(new))
return new
def delete_dir(self, rel, kind, key, perms):
rel = norm(rel)
area, folder = self.area(rel)
if area == "private" and rel.count("/") < 2 or rel in ("", "users", "shared"):
raise PermissionError("This folder cannot be deleted")
if not self.can_delete(rel, kind, key, perms):
raise PermissionError("Only the folder's owner or an admin can delete it")
has_files = any(files for _, _, files in os.walk(self._abs(rel)))
own_private = area == "private" and kind == "user" and folder == key
if has_files and not own_private:
raise ValueError("Move or delete the workflows inside it first")
shutil.rmtree(self._abs(rel), ignore_errors=True)
if area == "shared" and rel == folder:
self.folders.pop(folder, None)
self.save()
# ----- changes ------------------------------------------------------
def ensure_private(self, username):
+327
View File
@@ -0,0 +1,327 @@
// Drive-style file browser for the RigShare workspace.
//
// Home ─┬─ My files users/<you>/… only you (and admins)
// ├─ Shared shared folders + the common top level
// └─ Everyone's files (admins)
//
// 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";
const HOME = "@home";
const SHARED = "@shared";
export class FilesBrowser {
constructor({ app, client, sync, mode = "browse", start, onOpenFile, onPickFile, onChange, accessEditor }) {
Object.assign(this, { app, client, sync, mode, onOpenFile, onPickFile, onChange, accessEditor });
this.path = start || HOME;
this.listing = null;
this.filter = "";
this.accessFor = null;
this.crumbs = h("div", { class: "rs-fb-crumbs" });
this.search = h("input", {
class: "rs-input rs-fb-search", placeholder: "Filter", type: "search",
oninput: () => { this.filter = this.search.value.trim().toLowerCase(); this.renderList(); },
onkeydown: (e) => e.stopPropagation(),
});
this.actions = h("div", { class: "rs-fb-actions" });
this.list = h("div", { class: "rs-fb-list" });
this.el = h("div", { class: `rs-fb rs-fb-${mode}` },
h("div", { class: "rs-fb-bar" }, this.crumbs, this.actions),
h("div", { class: "rs-fb-tools" }, this.search),
this.list);
}
get me() {
return this.client.self?.key;
}
/** Folder path usable for saving ("" is the common top level), or null. */
get saveFolder() {
if (!this.listing || this.listing.role !== "edit") return null;
if (this.path === HOME || this.path === "users") return null;
return this.path === SHARED ? "" : this.path;
}
toast(severity, detail) {
this.client.emit("toast", { severity, summary: "RigShare", detail });
}
async run(fn, ok) {
try {
const r = await fn();
if (ok) this.toast("success", ok);
return r ?? true;
} catch (e) {
this.toast("error", e.message || String(e));
return undefined;
}
}
async go(path) {
this.path = path;
this.filter = "";
this.search.value = "";
this.accessFor = null;
await this.reload();
}
async reload() {
if (this.path === HOME) {
const entries = [];
if (this.me) entries.push({ type: "home", path: `users/${this.me}`, name: "My files", hint: "Only you", icon: "pi-user" });
entries.push({ type: "home", path: SHARED, name: "Shared", hint: "Shared folders and common workflows", icon: "pi-users" });
if (this.client.perms.admin) entries.push({ type: "home", path: "users", name: "Everyone's files", hint: "Private folders of every account (admins)", icon: "pi-shield" });
this.listing = { path: HOME, role: "view", entries, can_mkdir: false };
} else {
this.list.replaceChildren(h("div", { class: "rs-fb-empty" }, h("i", { class: "pi pi-spin pi-spinner" })));
try {
this.listing = await this.client.request("GET", `/rigshare/api/workspace/list?path=${encodeURIComponent(this.path)}`);
} catch (e) {
this.toast("error", e.message);
if (this.path !== HOME) return this.go(HOME);
}
}
this.render();
this.onChange?.(this);
}
// ----- breadcrumbs ------------------------------------------------------
trail() {
const out = [{ label: "Home", path: HOME, icon: "pi-home" }];
const p = this.path;
if (p === HOME) return out;
const parts = p.split("/");
if (p === SHARED) return [...out, { label: "Shared", path: SHARED }];
if (parts[0] === "users") {
if (parts[1] === this.me) {
out.push({ label: "My files", path: `users/${this.me}` });
parts.slice(2).forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 3).join("/") }));
} else {
out.push({ label: "Everyone's files", path: "users" });
parts.slice(1).forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 2).join("/") }));
}
return out;
}
out.push({ label: "Shared", path: SHARED });
const rest = parts[0] === "shared" ? parts.slice(1) : parts;
const offset = parts[0] === "shared" ? 1 : 0;
rest.forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 1 + offset).join("/") }));
return out;
}
/** Human label of the current folder, e.g. "My files / renders". */
get label() {
return this.trail().slice(1).map((c) => c.label).join(" / ") || "Home";
}
render() {
const trail = this.trail();
this.crumbs.replaceChildren(...trail.flatMap((c, i) => [
i ? h("i", { class: "pi pi-angle-right rs-fb-sep" }) : null,
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));
this.actions.replaceChildren(...[
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(),
}, h("i", { class: "pi pi-folder-plus" })) : null,
h("button", { class: "rs-btn rs-btn-icon", title: "Refresh", onclick: () => this.reload() }, h("i", { class: "pi pi-refresh" })),
].filter(Boolean));
this.el.querySelector(".rs-fb-tools").style.display = this.path === HOME ? "none" : "";
this.renderList();
}
renderList() {
const entries = (this.listing?.entries || []).filter((e) => !this.filter || e.name.toLowerCase().includes(this.filter));
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."));
return;
}
const rows = [];
for (const e of entries) {
rows.push(this.row(e));
if (this.accessFor === e.path && this.accessEditor) rows.push(this.accessEditor(e.path, () => { this.accessFor = null; this.reload(); }));
}
this.list.replaceChildren(...rows);
}
row(e) {
if (e.type === "home") {
return h("button", { class: "rs-fb-row rs-fb-home", onclick: () => this.go(e.path) },
h("i", { class: `pi ${e.icon} rs-fb-icon` }),
h("span", { class: "rs-grow rs-min0" }, h("div", { class: "rs-name" }, e.name), h("div", { class: "rs-muted rs-small" }, e.hint)),
h("i", { class: "pi pi-angle-right rs-muted" }));
}
const isFolder = e.type === "folder";
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";
const disabled = this.mode === "pick" && !isFolder;
const tools = this.mode === "browse" ? this.rowTools(e) : [];
return h("div", { class: `rs-fb-row ${isFolder ? "folder" : "file"} ${disabled ? "disabled" : ""}` },
h("button", {
class: "rs-fb-main", disabled,
ondblclick: () => { if (!isFolder && this.mode === "save") this.onOpenFile?.(e.path); },
onclick: () => {
if (isFolder) return this.go(e.path);
if (this.mode === "save") return this.onPickFile?.(e);
if (this.mode !== "pick") this.onOpenFile?.(`workflows/${e.path}`);
},
},
h("i", { class: `pi ${icon} rs-fb-icon ${isFolder ? "folder" : ""}` }),
h("span", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis", title: e.name }, e.name),
meta ? h("div", { class: "rs-muted rs-small rs-ellipsis" }, meta) : null)),
tools.length ? h("div", { class: "rs-fb-tools-row" }, ...tools) : null);
}
rowTools(e) {
const isFolder = e.type === "folder";
const topShared = isFolder && e.manage !== undefined;
const tools = [];
if (topShared && e.manage) {
tools.push(h("button", { class: "rs-btn rs-btn-icon rs-btn-sm", title: "Who can open it", onclick: () => {
this.accessFor = this.accessFor === e.path ? null : e.path; this.renderList();
} }, h("i", { class: "pi pi-lock" })));
}
const canRename = isFolder ? (topShared ? e.manage : e.deletable) : e.role === "edit";
if (canRename) tools.push(h("button", { class: "rs-btn rs-btn-icon rs-btn-sm", title: "Rename", onclick: () => this.rename(e) }, h("i", { class: "pi pi-pencil" })));
if (e.deletable && !topShared) tools.push(h("button", { class: "rs-btn rs-btn-icon rs-btn-sm", title: "Move to…", onclick: () => this.move(e) }, h("i", { class: "pi pi-arrow-right-arrow-left" })));
if (e.deletable) tools.push(h("button", { class: "rs-btn rs-btn-icon rs-btn-sm rs-danger", title: "Delete", onclick: () => this.remove(e) }, h("i", { class: "pi pi-trash" })));
return tools;
}
// ----- actions ----------------------------------------------------------
async ask(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);
}
async confirm(title, message) {
const dialog = this.app.extensionManager?.dialog;
if (dialog?.confirm) return !!(await dialog.confirm({ title, message }));
return window.confirm(message);
}
async refreshComfy() {
await this.app.extensionManager?.workflow?.syncWorkflows?.();
}
async newFolder() {
const name = await this.ask(this.path === SHARED ? "New shared folder" : "New folder", "Folder name");
if (!name) return;
const res = await this.run(() => this.client.request("POST", "/rigshare/api/workspace/mkdir", { parent: this.path, name }));
if (res) this.reload();
}
async rename(e) {
const name = await this.ask("Rename", `New name for "${e.name}"`, e.name);
if (!name || name === e.name) return;
if (e.type === "folder") {
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/rename-folder", { path: e.path, name }), "Renamed");
await this.refreshComfy();
} else {
const dir = e.path.includes("/") ? e.path.slice(0, e.path.lastIndexOf("/") + 1) : "";
const clean = name.replace(/\.json$/i, "").replace(/[\\/]+/g, "-");
await this.run(() => this.sync.movePath(`workflows/${e.path}`, `workflows/${dir}${clean}.json`), "Renamed");
}
this.reload();
}
async move(e) {
const dest = await pickFolder({ app: this.app, client: this.client, sync: this.sync, title: `Move "${e.name}" to…`, start: this.path });
if (dest === null || dest === undefined) return;
if (e.type === "folder") {
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/move-folder", { path: e.path, dest }), "Moved");
await this.refreshComfy();
} else {
const base = e.path.split("/").pop();
await this.run(() => this.sync.movePath(`workflows/${e.path}`, dest ? `workflows/${dest}/${base}` : `workflows/${base}`), "Moved");
}
this.reload();
}
async remove(e) {
const what = e.type === "folder" ? `the folder "${e.name}"` : `"${e.name}"`;
if (!(await this.confirm("Delete", `Delete ${what}? This cannot be undone.`))) return;
if (e.type === "folder") {
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/delete-folder", { path: e.path }), "Deleted");
} else {
await this.run(async () => {
const store = this.app.extensionManager.workflow;
await store.syncWorkflows?.();
const wf = store.getWorkflowByPath?.(`workflows/${e.path}`);
if (wf && store.deleteWorkflow) await store.deleteWorkflow(wf);
else {
const { api } = await import("../../../scripts/api.js");
const res = await api.deleteUserData(`workflows/${e.path}`);
if (res.status !== 204) throw new Error(`Could not delete (${res.status})`);
}
}, "Deleted");
}
await this.refreshComfy();
this.reload();
}
}
// ----- modal helpers ----------------------------------------------------------
export function modal(title, icon, body, footer) {
let resolveClose;
const closed = new Promise((r) => { resolveClose = r; });
const close = (value) => {
backdrop.remove();
document.removeEventListener("keydown", onKey, true);
resolveClose(value);
};
const onKey = (e) => { if (e.key === "Escape" && backdrop.isConnected) { 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),
h("button", { class: "rs-btn rs-btn-icon rs-btn-ghost", title: "Close", onclick: () => close(null) }, h("i", { class: "pi pi-times" }))),
body,
footer ? h("div", { class: "rs-modal-footer" }, footer) : null));
backdrop.addEventListener("keydown", (e) => e.stopPropagation());
document.body.append(backdrop);
document.addEventListener("keydown", onKey, true);
return { close, 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: start && start !== "@home" ? start : `users/${client.self?.key}` });
const choose = h("button", { class: "rs-btn rs-btn-primary" }, "Move here");
const where = h("span", { class: "rs-muted rs-small rs-grow rs-ellipsis" });
browser.onChange = () => {
choose.disabled = browser.saveFolder === null;
where.textContent = browser.saveFolder === null ? "Open a folder you can edit" : browser.label;
};
const m = modal(title, "pi-arrow-right-arrow-left", browser.el, [where, choose]);
choose.onclick = () => m.close(browser.saveFolder);
await browser.reload();
return m.closed;
}
/** "Open from RigShare": browse and open a workflow. */
export async function openFromWorkspace({ app, client, sync }) {
const browser = new FilesBrowser({
app, client, sync, mode: "open", start: storage.get("rigshare.lastOpenFolder") || "@home",
onOpenFile: async (path) => {
storage.set("rigshare.lastOpenFolder", browser.path);
m.close(true);
try { await sync.openPath(path); } catch (e) { client.emit("toast", { severity: "error", summary: "RigShare", detail: e.message }); }
},
});
const m = modal("Open from RigShare", "pi-folder-open", browser.el, null);
await browser.reload();
return m.closed;
}
+55 -1
View File
@@ -9,6 +9,8 @@ import { Presence } from "./presence.js";
import { RigSharePanel } from "./panel.js";
import { STYLES } from "./styles.js";
import { installSaveDialog } from "./save-dialog.js";
import { FilesBrowser, openFromWorkspace } from "./files.js";
import { h } from "./ui.js";
const TAB_ID = "rigshare";
const ICON_CLASS = "rigshare-tab-icon";
@@ -201,7 +203,22 @@ app.registerExtension({
},
],
menuCommands: [
{ path: ["Workflow"], commands: ["RigShare.OpenFromWorkspace"] },
],
keybindings: [
{ combo: { key: "o", ctrl: true, alt: true }, commandId: "RigShare.OpenFromWorkspace" },
],
commands: [
{
id: "RigShare.OpenFromWorkspace",
label: "Open from RigShare…",
menubarLabel: "Open from RigShare…",
icon: "pi pi-folder-open",
function: () => rig && openFromWorkspace(rig),
},
{
id: "RigShare.OpenChat",
label: "RigShare: Open chat",
@@ -242,7 +259,7 @@ app.registerExtension({
installQueueGuard(client);
installManagerGuard(client);
installComfyAccountGuard(client);
installSaveDialog(app, api, client);
installSaveDialog(app, api, client, sync);
presence.install();
app.extensionManager.registerSidebarTab({
@@ -257,6 +274,43 @@ app.registerExtension({
destroy: () => panel.unmount(),
});
// Files: its own sidebar tab, Drive-style.
const filesBrowser = new FilesBrowser({
app, client, sync, mode: "browse",
onOpenFile: (path) => sync.openPath(path).catch((e) => client.emit("toast", { severity: "error", summary: "RigShare", detail: e.message })),
accessEditor: (folder, close) => panel.accessEditorFor({
load: () => client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(folder)}`),
save: (restricted, members) => 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, draftKey: "folderDraft",
}),
});
const filesPanel = h("div", { class: "rs-panel rs-files-panel" },
h("div", { class: "rs-header" },
h("div", { class: "rs-title" }, h("i", { class: "pi pi-folder-open rs-files-logo" }),
h("div", { class: "rs-title-text" }, h("div", { class: "rs-title-name" }, "Files"),
h("div", { class: "rs-status" }, "Your folders and shared ones"))),
h("button", {
class: "rs-btn", title: "Open a workflow file from this computer",
onclick: () => app.extensionManager.command.execute("Comfy.OpenWorkflow"),
}, h("i", { class: "pi pi-upload" }), "From computer")),
h("div", { class: "rs-body" }, filesBrowser.el));
let filesLoaded = false;
app.extensionManager.registerSidebarTab({
id: "rigshare-files",
icon: "pi pi-folder-open",
title: "Files",
tooltip: "RigShare files: My files and Shared",
label: "Files",
type: "custom",
render: (el) => {
el.classList.add("rs-host");
el.append(filesPanel);
if (!filesLoaded || client.online) { filesLoaded = true; filesBrowser.reload(); }
},
});
client.on("self", () => { if (filesPanel.isConnected) filesBrowser.reload(); });
// The registered tab object is wrapped by Vue; mutating it through the
// store keeps the unread badge reactive.
panel.onBadge = (badge) => {
+1 -116
View File
@@ -8,7 +8,6 @@ const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
const VIEWS = [
{ id: "workflows", icon: "pi-share-alt", label: "Live" },
{ id: "files", icon: "pi-folder", label: "Files" },
{ id: "people", icon: "pi-users", label: "People" },
{ id: "chat", icon: "pi-comments", label: "Chat" },
{ id: "server", icon: "pi-server", label: "Server" },
@@ -183,14 +182,13 @@ export class RigSharePanel {
}
const views = {
workflows: () => this.workflowsView(),
files: () => this.filesView(),
people: () => this.peopleView(),
chat: () => [this.chatView],
server: () => this.serverView(),
admin: () => this.adminView(),
account: () => this.accountView(),
};
if (this.view === "admin" && !c.perms.admin) this.view = "workflows";
if ((this.view === "admin" && !c.perms.admin) || !VIEWS.some((v) => v.id === this.view) && this.view !== "account") this.view = "workflows";
const scroll = this.body.scrollTop;
this.body.replaceChildren(...(views[this.view] ?? views.workflows)().filter(Boolean));
this.body.scrollTop = scroll;
@@ -391,119 +389,6 @@ export class RigSharePanel {
...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nothing shared yet. Open a saved workflow or edit a tab.")]));
}
// ----- files ----------------------------------------------------------
filesView() {
this.filesEl ??= h("div", {});
this.loadFiles();
return [this.filesEl];
}
async loadFiles() {
const tree = await this.run(() => this.client.request("GET", "/rigshare/api/workspace"));
if (!tree) return;
this.tree = tree;
const destinations = [
tree.private ? { folder: tree.private.path, label: "My files" } : null,
...tree.shared.filter((f) => f.role === "edit").map((f) => ({ folder: f.path, label: f.name })),
tree.common.role === "edit" ? { folder: "", label: "Common" } : null,
].filter(Boolean);
const fileRow = (file, folder, canMoveOut) => h("div", { class: "rs-row-item rs-file" },
h("i", { class: "pi pi-file rs-row-icon" }),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis", title: file.path.slice("workflows/".length) }, file.name.replace(/\.json$/, "")),
h("div", { class: "rs-muted rs-small" }, timeAgo(file.modified * 1000))),
canMoveOut && destinations.length > 1 ? h("select", {
class: "rs-input rs-select", title: "Move to another folder",
onchange: async (e) => {
const dest = e.target.value;
e.target.value = "__";
if (dest === "__") return;
const base = file.path.split("/").pop();
const target = dest ? `workflows/${dest}/${base}` : `workflows/${base}`;
await this.run(() => this.sync.movePath(file.path, target), "Moved");
this.loadFiles();
},
}, h("option", { value: "__" }, "Move to…"), ...destinations.filter((d) => d.folder !== folder).map((d) =>
h("option", { value: d.folder }, d.label))) : null,
h("button", { class: "rs-btn rs-btn-icon", title: "Open", onclick: () => this.run(() => this.sync.openPath(file.path)) },
h("i", { class: "pi pi-arrow-right" })));
const empty = (text) => h("p", { class: "rs-muted rs-small" }, text);
const sections = [];
if (tree.private) {
sections.push(this.section("pi-user", "My files",
h("p", { class: "rs-muted rs-small" }, "Only you (and admins) can see these."),
...(tree.private.files.length ? tree.private.files.map((f) => fileRow(f, tree.private.path, true)) : [empty("Nothing here yet. Save a workflow and choose My files.")])));
}
const folderBlocks = tree.shared.map((f) => {
const open = this.openFolders?.has(f.path);
const owner = f.owner ? this.client.users.find((u) => u.key === f.owner)?.name ?? `@${f.owner}` : null;
const head = h("div", { class: "rs-row-item rs-folder" },
h("button", { class: "rs-btn rs-btn-icon rs-btn-ghost", title: open ? "Collapse" : "Expand", onclick: () => {
this.openFolders ??= new Set();
open ? this.openFolders.delete(f.path) : this.openFolders.add(f.path);
this.loadFiles();
} }, h("i", { class: `pi ${open ? "pi-chevron-down" : "pi-chevron-right"}` })),
h("i", { class: `pi ${f.restricted ? "pi-lock" : "pi-folder"} rs-row-icon` }),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, f.name),
h("div", { class: "rs-muted rs-small" }, `${f.files.length} workflow${f.files.length === 1 ? "" : "s"}${owner ? ` · owner ${owner}` : ""}${f.role === "view" ? " · view only" : ""}`)),
f.manage ? h("button", { class: `rs-btn rs-btn-sm ${this.folderAccess === f.path ? "active" : ""}`, onclick: () => {
this.folderAccess = this.folderAccess === f.path ? null : f.path;
this.folderDraft = null;
this.loadFiles();
} }, "Access") : null,
f.manage ? h("button", { class: "rs-btn rs-btn-icon", title: "Rename folder", onclick: async () => {
const name = await this.prompt("Rename folder", `New name for "${f.name}"`, f.name);
if (!name || name === f.name) return;
await this.run(() => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: f.path, name }), "Folder renamed");
this.loadFiles();
} }, h("i", { class: "pi pi-pencil" })) : null,
f.manage ? h("button", { class: "rs-btn rs-btn-icon rs-danger", title: "Delete folder (must be empty)", onclick: async () => {
if (!(await this.confirm("Delete folder", `Delete the empty folder "${f.name}"?`))) return;
await this.run(() => this.client.request("DELETE", `/rigshare/api/workspace/folders?path=${encodeURIComponent(f.path)}`), "Folder deleted");
this.loadFiles();
} }, h("i", { class: "pi pi-trash" })) : null);
const parts = [head];
if (this.folderAccess === f.path) {
parts.push(this.accessEditorFor({
load: async () => {
const t = await this.client.request("GET", "/rigshare/api/workspace");
const folder = t.shared.find((x) => x.path === f.path);
const members = await this.client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(f.path)}`);
return { owner: folder?.owner, restricted: !!folder?.restricted, members: members.members || {} };
},
save: (restricted, members) => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: f.path, restricted, members }),
everyoneHint: "Anyone with an account can open its workflows, with their usual permissions",
close: () => { this.folderAccess = null; this.loadFiles(); },
draftKey: "folderDraft",
}));
}
if (open) {
parts.push(h("div", { class: "rs-folder-files" },
...(f.files.length ? f.files.map((file) => fileRow(file, f.path, f.manage)) : [empty("Empty folder.")])));
}
return h("div", { class: "rs-folder-block" }, ...parts);
});
const newFolderBtn = tree.can_create ? h("button", { class: "rs-btn", onclick: async () => {
const name = await this.prompt("New shared folder", "Folder name");
if (!name) return;
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/folders", { name }), "Folder created");
this.loadFiles();
} }, h("i", { class: "pi pi-folder-plus" }), "New shared folder") : null;
sections.push(this.section("pi-folder", "Shared folders",
...(folderBlocks.length ? folderBlocks : [empty("No shared folders yet.")]),
newFolderBtn ? h("div", { class: "rs-actions" }, newFolderBtn) : null));
sections.push(this.section("pi-globe", "Common",
h("p", { class: "rs-muted rs-small" }, "Top-level workflows, visible to everyone."),
...(tree.common.files.length ? tree.common.files.map((f) => fileRow(f, "", this.client.perms.admin)) : [empty("No workflows at the top level.")])));
this.filesEl.replaceChildren(...sections);
}
// ----- people ---------------------------------------------------------
peopleView() {
+31 -87
View File
@@ -1,10 +1,11 @@
// "Save" asks where: My files, a shared folder, or Common.
// "Save" asks where, with the same folder browser as the Files tab.
//
// ComfyUI's save flow asks the workflow for a file name (promptSave) and saves
// to "<workflow directory>/<name>.json". Returning "<folder>/<name>" makes it
// save straight into the chosen folder, with ComfyUI's own overwrite checks.
import { h, storage } from "./ui.js";
import { FilesBrowser, modal } from "./files.js";
const LAST_FOLDER = "rigshare.lastSaveFolder";
@@ -12,7 +13,7 @@ function cleanName(name) {
return (name || "").trim().replace(/\.json$/i, "").replace(/[\\/]+/g, "-").slice(0, 120);
}
export function installSaveDialog(app, api, client) {
export function installSaveDialog(app, api, client, sync) {
const patch = () => {
const wf = app.extensionManager?.workflow?.activeWorkflow;
if (!wf) return false;
@@ -24,7 +25,7 @@ export function installSaveDialog(app, api, client) {
proto.promptSave = async function (...args) {
if (!client.online || !client.self || !this.path?.startsWith("workflows/")) return original.apply(this, args);
try {
return await chooseLocation(app, api, client, this);
return await chooseLocation(app, api, client, sync, this);
} catch (e) {
console.error("[RigShare] save dialog", e);
return original.apply(this, args);
@@ -36,95 +37,38 @@ export function installSaveDialog(app, api, client) {
const timer = setInterval(() => { if (patch()) clearInterval(timer); }, 1000);
}
export async function chooseLocation(app, api, client, wf) {
let tree = await client.request("GET", "/rigshare/api/workspace");
export async function chooseLocation(app, api, client, sync, wf) {
const directory = wf.directory || "workflows";
const options = () => {
const out = [];
if (tree.private) out.push({ folder: tree.private.path, label: "My files", hint: "Only you", icon: "pi-user" });
for (const f of tree.shared) {
if (f.role === "edit") out.push({ folder: f.path, label: f.name, hint: f.restricted ? "Shared with chosen people" : "Shared with everyone", icon: f.restricted ? "pi-lock" : "pi-folder" });
}
if (tree.common?.role === "edit") out.push({ folder: "", label: "Common", hint: "Top level, visible to everyone", icon: "pi-globe" });
return out;
};
const current = directory === "workflows" ? null : directory.slice("workflows/".length);
const pick = (opts) => {
const remembered = storage.get(LAST_FOLDER);
return opts.find((o) => o.folder === current)?.folder
?? opts.find((o) => o.folder === remembered)?.folder
?? opts[0]?.folder;
};
const start = current ?? storage.get(LAST_FOLDER) ?? `users/${client.self?.key}`;
const result = await new Promise((resolve) => {
let opts = options();
let selected = pick(opts);
const name = h("input", { class: "rs-input", value: cleanName(wf.filename) || "Untitled", maxLength: 120 });
const list = h("div", { class: "rs-save-list" });
const newFolder = h("input", { class: "rs-input", placeholder: "New shared folder name", maxLength: 64 });
const error = h("div", { class: "rs-error" });
const renderList = () => {
list.replaceChildren(...opts.map((o) => h("label", { class: `rs-save-option ${o.folder === selected ? "on" : ""}` },
h("input", { type: "radio", name: "rs-save-folder", checked: o.folder === selected, onchange: () => { selected = o.folder; renderList(); } }),
h("i", { class: `pi ${o.icon}` }),
h("span", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, o.label),
h("div", { class: "rs-muted rs-small" }, o.hint)))));
};
const close = (value) => {
backdrop.remove();
document.removeEventListener("keydown", onKey, true);
resolve(value);
};
const submit = () => {
const n = cleanName(name.value);
if (!n) { error.textContent = "Enter a name"; return; }
if (selected === undefined) { error.textContent = "Choose where to save it"; return; }
storage.set(LAST_FOLDER, selected);
close({ name: n, folder: selected });
};
const onKey = (e) => {
if (!backdrop.isConnected) return;
if (e.key === "Escape") { e.stopPropagation(); close(null); }
if (e.key === "Enter" && document.activeElement !== newFolder) { e.preventDefault(); e.stopPropagation(); submit(); }
};
const canCreate = tree.can_create;
const createRow = canCreate ? h("div", { class: "rs-row" }, newFolder,
h("button", {
class: "rs-btn", onclick: async () => {
error.textContent = "";
try {
const res = await client.request("POST", "/rigshare/api/workspace/folders", { name: newFolder.value });
tree = await client.request("GET", "/rigshare/api/workspace");
opts = options();
selected = res.path;
newFolder.value = "";
renderList();
} catch (e) { error.textContent = e.message; }
},
}, h("i", { class: "pi pi-folder-plus" }), "Create")) : 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", role: "dialog", "aria-label": "Save workflow" },
h("div", { class: "rs-modal-title" }, h("i", { class: "pi pi-save" }), "Save workflow"),
h("label", { class: "rs-label" }, "Name"), name,
h("label", { class: "rs-label" }, "Save in"), list,
createRow, error,
h("div", { class: "rs-actions rs-modal-actions" },
h("button", { class: "rs-btn rs-btn-ghost", onclick: () => close(null) }, "Cancel"),
h("button", { class: "rs-btn rs-btn-primary", onclick: submit }, "Save"))));
backdrop.addEventListener("keydown", (e) => e.stopPropagation());
renderList();
document.body.append(backdrop);
document.addEventListener("keydown", onKey, true);
setTimeout(() => { name.focus(); name.select(); }, 30);
const name = h("input", { class: "rs-input rs-save-name", value: cleanName(wf.filename) || "Untitled", maxLength: 120,
onkeydown: (e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); submit(); } } });
const where = h("span", { class: "rs-muted rs-small rs-grow rs-ellipsis" });
const save = h("button", { class: "rs-btn rs-btn-primary" }, h("i", { class: "pi pi-save" }), "Save here");
const browser = new FilesBrowser({
app, client, sync, mode: "save", start: start === "" ? "@shared" : start,
onPickFile: (entry) => { name.value = entry.name; name.focus(); },
onChange: () => {
save.disabled = browser.saveFolder === null;
where.textContent = browser.saveFolder === null
? (browser.path === "@home" ? "Choose My files or Shared" : "You cannot save in this folder")
: `Save in ${browser.label}`;
},
});
const body = h("div", { class: "rs-save-body" }, h("label", { class: "rs-label" }, "Name"), name, h("label", { class: "rs-label" }, "Location"), browser.el);
const m = modal("Save workflow", "pi-save", body, [where, h("button", { class: "rs-btn rs-btn-ghost", onclick: () => m.close(null) }, "Cancel"), save]);
function submit() {
const n = cleanName(name.value);
if (!n || browser.saveFolder === null) return;
storage.set(LAST_FOLDER, browser.saveFolder);
m.close({ name: n, folder: browser.saveFolder });
}
save.onclick = submit;
await browser.reload();
setTimeout(() => { name.focus(); name.select(); }, 30);
const result = await m.closed;
if (!result) return null;
const targetDir = result.folder ? `workflows/${result.folder}` : "workflows";
if (targetDir === directory) return result.name;
+35
View File
@@ -175,6 +175,41 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-folder-files { margin-left: 26px; }
.rs-file .rs-select { max-width: 110px; }
/* file browser */
.rs-files-logo { width: 28px; height: 28px; border-radius: 7px; display: grid; place-items: center; font-size: 15px;
background: linear-gradient(135deg, #3b82f6, #a855f7); color: #fff; flex: none; }
.rs-fb { display: flex; flex-direction: column; min-height: 0; }
.rs-fb-bar { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.rs-fb-crumbs { flex: 1; min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; }
.rs-fb-crumb { border: 0; background: none; color: var(--rs-muted); font: inherit; font-size: 12.5px; padding: 3px 5px; border-radius: 5px; cursor: pointer; }
.rs-fb-crumb:hover { color: var(--rs-fg); background: var(--rs-bg2); }
.rs-fb-crumb.current { color: var(--rs-fg); font-weight: 650; }
.rs-fb-sep { font-size: 10px; color: var(--rs-muted); }
.rs-fb-actions { display: flex; gap: 4px; }
.rs-fb-tools { margin-bottom: 6px; }
.rs-fb-search { padding: 5px 8px; font-size: 12px; }
.rs-fb-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 60px; }
.rs-fb-row { display: flex; align-items: center; gap: 6px; border-radius: 7px; position: relative; }
.rs-fb-row:hover { background: color-mix(in srgb, var(--rs-bg2) 70%, transparent); }
.rs-fb-row.disabled { opacity: .45; }
.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.folder { color: #f5b942; }
.rs-fb-home { width: 100%; padding: 12px 10px; border: 1px solid var(--rs-border); background: color-mix(in srgb, var(--rs-bg2) 45%, transparent);
color: inherit; font: inherit; text-align: left; cursor: pointer; margin-bottom: 6px; }
.rs-fb-home:hover { border-color: var(--rs-accent); }
.rs-fb-home .rs-fb-icon { font-size: 20px; color: var(--rs-accent); width: 24px; }
.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; }
.rs-fb-empty { color: var(--rs-muted); font-size: 12px; text-align: center; padding: 24px 8px; }
.rs-modal-wide { max-width: 560px; }
.rs-modal-wide .rs-fb-list { max-height: 46vh; }
.rs-modal-footer { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rs-border); }
.rs-save-body { display: flex; flex-direction: column; gap: 6px; }
.rs-save-body .rs-fb { border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px; }
/* save dialog */
.rs-modal-backdrop { position: fixed; inset: 0; z-index: 3000; background: rgb(0 0 0 / .55); display: grid; place-items: center; padding: 16px; color: var(--rs-fg); font-size: 13px; }
.rs-modal { width: 100%; max-width: 440px; max-height: 90vh; overflow-y: auto; background: var(--rs-bg); border: 1px solid var(--rs-border);