Files

420 lines
21 KiB
JavaScript

// Drive-style file browser for the RigShare workspace.
//
// [My files] users/<you>/… only you (and admins)
// [Shared] shared folders + the common top level
// [All] the whole workflows folder, 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, APP_ICON } from "./ui.js";
const SHARED = "@shared";
const ALL = "";
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 = null;
this.tab = null;
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.tabsEl = h("div", { class: "rs-fb-tabs", role: "tablist" });
this.list = h("div", { class: "rs-fb-list" });
this.el = h("div", { class: `rs-fb rs-fb-${mode}` },
this.tabsEl,
h("div", { class: "rs-fb-bar" }, this.crumbs, this.actions),
h("div", { class: "rs-fb-tools" }, this.search),
this.list);
// The sidebar browser lives as long as the page: keep its live dots current.
if (mode === "browse") {
client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
// Someone (maybe you, from the Save dialog) saved, moved or deleted a workflow: reload.
client.on("files_changed", () => {
clearTimeout(this.changedTimer);
this.changedTimer = setTimeout(() => { if (this.el.isConnected && !this.accessFor) this.reload(); }, 250);
});
}
this.setStart(start);
}
get me() {
return this.client.self?.key;
}
/** The folder tabs: each is a root the browser can't go above. */
get tabs() {
return [
this.me ? { id: "mine", label: "My files", icon: "pi-user", root: `users/${this.me}`, hint: "Only you" } : null,
{ id: "shared", label: "Shared", icon: "pi-users", root: SHARED, hint: "Shared folders and common workflows" },
this.client.perms.admin ? { id: "all", label: "All", icon: "pi-shield", root: ALL, hint: "The whole workflows folder, everyone's files (admins)" } : null,
].filter(Boolean);
}
/** Which tab a path belongs to when opened directly (not through a tab). */
tabFor(path) {
const ids = this.tabs.map((t) => t.id);
if (this.me && (path === `users/${this.me}` || path?.startsWith(`users/${this.me}/`))) return "mine";
if (path === ALL || path === "users" || path?.startsWith("users/")) return ids.includes("all") ? "all" : ids[0];
return "shared";
}
setStart(start) {
if (start === undefined || start === null || start === "@home") start = this.me ? `users/${this.me}` : SHARED;
this.tab = this.tabFor(start);
const allowed = this.tab === "all" || this.tab === "shared" || start.startsWith(`users/${this.me}`);
this.path = allowed && !(this.tab === "shared" && (start === ALL || start.startsWith("users"))) ? start : this.currentTab.root;
}
get currentTab() {
return this.tabs.find((t) => t.id === this.tab) ?? this.tabs[0];
}
async switchTab(id) {
const tab = this.tabs.find((t) => t.id === id);
if (!tab) return;
this.tab = id;
await this.go(tab.root);
}
/** 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 === "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() {
// Tabs depend on who is signed in (admin, username): keep the current one valid.
if (!this.tabs.some((t) => t.id === this.tab)) {
this.tab = this.tabs[0].id;
this.path = this.tabs[0].root;
}
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);
this.listing = null;
const root = this.currentTab.root;
if (this.path !== root) return this.go(root);
}
this.render();
this.onChange?.(this);
}
// ----- breadcrumbs ------------------------------------------------------
/** Breadcrumbs from the current tab's root down to the current folder. */
trail() {
const tab = this.currentTab;
const out = [{ label: tab.label, path: tab.root }];
const p = this.path;
if (p === tab.root) return out;
const parts = p.split("/");
// Number of leading path parts the tab root stands for.
let skip = 0;
if (tab.id === "mine") skip = 2;
else if (tab.id === "shared" && parts[0] === "shared") skip = 1;
parts.slice(skip).forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 1 + skip).join("/") }));
return out;
}
/** Human label of the current folder, e.g. "My files / renders". */
get label() {
return this.trail().map((c) => c.label).join(" / ");
}
render() {
this.tabsEl.replaceChildren(...this.tabs.map((t) => h("button", {
class: `rs-fb-tab ${t.id === this.tab ? "active" : ""}`, role: "tab", title: t.hint,
"aria-selected": String(t.id === this.tab),
onclick: () => { if (t.id !== this.tab || this.path !== t.root) this.switchTab(t.id); },
}, h("i", { class: `pi ${t.icon}` }), h("span", {}, t.label))));
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));
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(),
}, 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.renderList();
}
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."));
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) {
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(" · ")
: [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;
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: `${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),
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 = "") {
// 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);
}
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;
// Its open tabs close first (ComfyUI would otherwise keep them as orphans).
const path = `workflows/${e.path}`;
const closing = this.sync.tabsUnder(path).map((wf) => `file:${wf.path}`);
await this.sync.closeTabsUnder(path);
let done;
if (e.type === "folder") {
done = await this.run(() => this.client.request("POST", "/rigshare/api/workspace/delete-folder", { path: e.path }), "Deleted");
} else {
done = 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");
}
if (!done) for (const key of closing) this.sync.denied.delete(key); // still there: reopening it rejoins
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);
};
// 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),
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 };
}
/** A yes/no question in RigShare's own modal. */
export async function confirmModal(title, message, confirmLabel = "OK", danger = false) {
const ok = h("button", { class: `rs-btn ${danger ? "rs-btn-danger" : "rs-btn-primary"}` }, confirmLabel);
const m = modal(title, "pi-question-circle", h("p", { class: "rs-confirm-text" }, message),
[h("span", { class: "rs-grow" }), h("button", { class: "rs-btn rs-btn-ghost", onclick: () => m.close(false) }, "Cancel"), ok]);
ok.onclick = () => m.close(true);
setTimeout(() => ok.focus(), 30);
return !!(await m.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 });
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") ?? undefined,
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;
}