Workspace: private folder per account, owner-managed shared folders, Save asks where, Files tab

This commit is contained in:
Doudou
2026-09-26 02:01:50 +00:00
parent 4b6c588806
commit e3becf8a7f
9 changed files with 854 additions and 40 deletions
+2
View File
@@ -8,6 +8,7 @@ import { RoomSync } from "./sync.js";
import { Presence } from "./presence.js";
import { RigSharePanel } from "./panel.js";
import { STYLES } from "./styles.js";
import { installSaveDialog } from "./save-dialog.js";
const TAB_ID = "rigshare";
const ICON_CLASS = "rigshare-tab-icon";
@@ -241,6 +242,7 @@ app.registerExtension({
installQueueGuard(client);
installManagerGuard(client);
installComfyAccountGuard(client);
installSaveDialog(app, api, client);
presence.install();
app.extensionManager.registerSidebarTab({
+148 -25
View File
@@ -7,7 +7,8 @@ import { ServerWidget } from "./server-widget.js";
const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
const VIEWS = [
{ id: "workflows", icon: "pi-share-alt", label: "Workflows" },
{ 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" },
@@ -182,6 +183,7 @@ export class RigSharePanel {
}
const views = {
workflows: () => this.workflowsView(),
files: () => this.filesView(),
people: () => this.peopleView(),
chat: () => [this.chatView],
server: () => this.serverView(),
@@ -269,49 +271,57 @@ export class RigSharePanel {
}
accessEditor() {
const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…"));
const key = this.sync.targetKey;
Promise.all([
this.run(() => this.client.request("GET", `/rigshare/api/room/acl?key=${encodeURIComponent(key)}`)),
this.run(() => this.client.request("GET", "/rigshare/api/people")),
]).then(([acl, people]) => {
return this.accessEditorFor({
load: () => this.client.request("GET", `/rigshare/api/room/acl?key=${encodeURIComponent(key)}`),
save: (restricted, members) => this.sync.setAccess(restricted, members),
everyoneHint: "Anyone with an account can open it, with their usual permissions",
close: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); },
draftKey: "accessDraft",
});
}
/** Access editor shared by live workflows and shared folders. */
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]) => {
if (!acl || !people) return;
const draft = this.accessDraft ??= { restricted: acl.restricted, members: { ...acl.members } };
const draft = this[draftKey] ??= { restricted: acl.restricted, members: { ...acl.members } };
const render = () => {
const fixed = people.filter((p) => p.username === acl.owner || p.admin);
const choosable = people.filter((p) => p.username !== acl.owner && !p.admin);
const fixedRows = fixed.map((p) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`)),
h("span", { class: "rs-chip" }, p.username === acl.owner ? "Owner · always" : "Admin · always")));
const rows = choosable
.map((p) => {
const select = h("select", {
class: "rs-input rs-select", disabled: !draft.restricted,
onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; },
}, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) =>
h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label)));
return h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`),
p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")),
select);
});
const rows = choosable.map((p) => {
const select = h("select", {
class: "rs-input rs-select", disabled: !draft.restricted,
onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; },
}, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) =>
h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label)));
return h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`),
p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")),
select);
});
const name = `rs-access-${draftKey}`;
const radio = (value, label, hint) => h("label", { class: "rs-check rs-block" },
h("input", { type: "radio", name: "rs-access", checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }),
h("input", { type: "radio", name, checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }),
h("span", {}, label, h("div", { class: "rs-muted rs-small" }, hint)));
box.replaceChildren(...[
radio(false, "Everyone", "Anyone with an account can open it, with their usual permissions"),
radio(false, "Everyone", everyoneHint),
radio(true, "Only people I choose", "The owner and admins always have access"),
draft.restricted ? h("div", { class: "rs-access-list" }, ...fixedRows, ...rows,
rows.length ? null : h("p", { class: "rs-muted rs-small" },
"Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open this workflow.")) : null,
"Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open it.")) : null,
h("div", { class: "rs-actions" },
h("button", {
class: "rs-btn rs-btn-primary", onclick: async () => {
const ok = await this.run(() => this.sync.setAccess(draft.restricted, draft.restricted ? draft.members : {}), "Access updated");
if (ok !== undefined) { this.accessOpen = false; this.accessDraft = null; this.renderBody(); }
const ok = await this.run(() => save(draft.restricted, draft.restricted ? draft.members : {}), "Access updated");
if (ok !== undefined) { this[draftKey] = null; close(); }
},
}, "Save"),
h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); } }, "Cancel")),
h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this[draftKey] = null; close(); } }, "Cancel")),
].filter(Boolean));
};
render();
@@ -381,6 +391,119 @@ 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() {
+157
View File
@@ -0,0 +1,157 @@
// "Save" asks where: My files, a shared folder, or Common.
//
// 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";
const LAST_FOLDER = "rigshare.lastSaveFolder";
function cleanName(name) {
return (name || "").trim().replace(/\.json$/i, "").replace(/[\\/]+/g, "-").slice(0, 120);
}
export function installSaveDialog(app, api, client) {
const patch = () => {
const wf = app.extensionManager?.workflow?.activeWorkflow;
if (!wf) return false;
let proto = Object.getPrototypeOf(wf);
while (proto && !Object.prototype.hasOwnProperty.call(proto, "promptSave")) proto = Object.getPrototypeOf(proto);
if (!proto) return false;
if (proto.__rigshare) return true;
const original = proto.promptSave;
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);
} catch (e) {
console.error("[RigShare] save dialog", e);
return original.apply(this, args);
}
};
proto.__rigshare = true;
return true;
};
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");
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 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);
});
if (!result) return null;
const targetDir = result.folder ? `workflows/${result.folder}` : "workflows";
if (targetDir === directory) return result.name;
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`);
}
async function saveCopyElsewhere(app, api, client, wf, path) {
const store = app.extensionManager.workflow;
await store.syncWorkflows?.();
if (store.getWorkflowByPath?.(path)) {
const dialog = app.extensionManager?.dialog;
const ok = dialog?.confirm
? await dialog.confirm({ title: "Overwrite?", message: `${path.slice("workflows/".length)} already exists. Replace it?` })
: window.confirm(`${path} already exists. Replace it?`);
if (!ok) return null;
}
const state = wf.activeState ?? app.rootGraph?.serialize?.() ?? app.graph.serialize();
await api.storeUserData(path, JSON.stringify(state), { overwrite: true, full_info: true, throwOnError: true });
await store.syncWorkflows?.();
const copy = store.getWorkflowByPath?.(path);
if (copy) {
if (!copy.isLoaded) await copy.load();
await app.loadGraphData(copy.activeState, true, true, copy);
}
client.emit("toast", { severity: "success", summary: "RigShare", detail: `Saved as ${path.slice("workflows/".length)}` });
return null;
}
+18
View File
@@ -170,6 +170,24 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-gpu-name .rs-muted { font-weight: 400; font-size: 11px; }
.rs-queue-line { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 6px 8px; border-radius: 6px; background: var(--rs-bg2); }
/* files */
.rs-folder-block + .rs-folder-block { border-top: 1px solid var(--rs-soft); }
.rs-folder-files { margin-left: 26px; }
.rs-file .rs-select { max-width: 110px; }
/* 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);
border-radius: 12px; padding: 18px 18px 14px; box-shadow: 0 20px 60px rgb(0 0 0 / .45); display: flex; flex-direction: column; gap: 6px; }
.rs-modal-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 650; margin-bottom: 6px; }
.rs-modal-actions { justify-content: flex-end; margin-top: 10px; }
.rs-save-list { display: flex; flex-direction: column; gap: 4px; max-height: 260px; overflow-y: auto; margin-bottom: 6px; }
.rs-save-option { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid var(--rs-border); border-radius: 8px; cursor: pointer; }
.rs-save-option.on { border-color: var(--rs-accent); background: color-mix(in srgb, var(--rs-accent) 12%, transparent); }
.rs-save-option input { margin: 0; }
.rs-error { color: #f87171; font-size: 12px; }
.rs-error:empty { display: none; }
/* floating server monitor */
.rs-float { position: fixed; z-index: 1000; width: 240px; background: var(--rs-bg); border: 1px solid var(--rs-border);
border-radius: 10px; box-shadow: 0 10px 30px rgb(0 0 0 / .35); font-size: 11px; user-select: none; }
+23
View File
@@ -541,6 +541,29 @@ export class RoomSync extends EventTarget {
};
}
/** Open a saved workflow file (like ComfyUI's workflow browser does). */
async openPath(path) {
const store = this.store;
await store.syncWorkflows?.();
const wf = store.getWorkflowByPath?.(path);
if (!wf) throw new Error("That workflow no longer exists");
if (wf === store.activeWorkflow) return;
if (!wf.isLoaded) await wf.load();
await this.app.loadGraphData(wf.activeState, true, true, wf);
}
/** Move a saved workflow to another folder (ComfyUI's own rename). */
async movePath(path, target) {
const store = this.store;
await store.syncWorkflows?.();
const wf = store.getWorkflowByPath?.(path);
if (!wf) throw new Error("That workflow no longer exists");
if (store.getWorkflowByPath?.(target)) throw new Error("A workflow with that name already exists there");
if (store.renameWorkflow) await store.renameWorkflow(wf, target);
else await wf.rename(target);
await store.syncWorkflows?.();
}
/** Find an already-open tab for a room. */
findTab(room) {
for (const wf of this.store?.openWorkflows || []) {