Files
Rohit MahtoandCommandCodeBot e43313b9f4 feat: password-protect file manager and terminal (0.6.1)
- New password_auth module: random token generated on first run, encrypted
  at rest under ~/.champdev (Fernet, cryptography dep), verify + 12h signed
  session cookie. CHAMPDEV_AUTH_PASSWORD env overrides the generated token
  and is what gets sent to telemetry.
- File Manager: write actions (upload/delete/rename/move/copy/mkdir) hidden
  behind an in-UI Unlock dialog and enforced server-side (401). While locked,
  browsing is clamped to the ComfyUI root instead of 403ing, with a lock
  banner. Unlock reveals write actions and allows browsing above the root.
- Terminal: websocket refuses to spawn a shell without auth; lock overlay
  with Unlock dialog, reconnects on success.
- Telemetry: token included in the ingest payload (server column exists).
- Frontend: shared champdev_auth.js in-UI password modal (window.prompt was
  silently blocked in embedded contexts).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-23 19:51:42 +05:30

713 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { unlockWithDialog } from "./champdev_auth.js";
const API = "/champdev/fm";
const STYLE_ID = "champ-fm-style";
const PAGE = 20; // rows (and thumbnails) materialized per "page"
function toast(severity, summary, detail) {
try {
app.extensionManager.toast.add({ severity, summary, detail, life: 4000 });
} catch (e) {
if (severity === "error") window.alert(`${summary}${detail ? ": " + detail : ""}`);
}
}
async function jget(path, params) {
const qs = new URLSearchParams(params).toString();
const r = await api.fetchApi(`${API}${path}?${qs}`);
return r.json();
}
async function jpost(path, body) {
const r = await api.fetchApi(`${API}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return r.json();
}
function fileUrl(path, download) {
const qs = new URLSearchParams({ path });
if (download) qs.set("download", "1");
return api.apiURL(`${API}/file?${qs.toString()}`);
}
function thumbUrl(path) {
return api.apiURL(`${API}/thumbnail?${new URLSearchParams({ path, size: "96" })}`);
}
function fmtSize(n) {
if (n == null) return "—";
if (n === 0) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < u.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
function fmtDate(ts) {
return ts ? new Date(ts * 1000).toLocaleString() : "—";
}
function el(tag, attrs = {}, ...children) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") e.className = v;
else if (k === "style") Object.assign(e.style, v);
else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v);
else e.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
e.append(c.nodeType ? c : document.createTextNode(c));
}
return e;
}
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const s = el("style", { id: STYLE_ID });
s.textContent = `
.champ-fm{display:flex;flex-direction:column;width:100%;height:100%;
background:#1e1e1e;color:#ddd;font-size:12px;border-radius:6px;overflow:hidden}
.champ-fm:focus{outline:none}
.champ-fm button{background:#3a3a3a;color:#ddd;border:1px solid #555;border-radius:4px;
padding:3px 7px;cursor:pointer;font-size:12px}
.champ-fm button:hover{background:#484848}
.champ-fm button:disabled{opacity:.4;cursor:default}
.champ-fm button.danger{color:#ff7a7a;border-color:#7a3a3a}
.champ-fm-bar{display:flex;gap:4px;align-items:center;padding:5px;border-bottom:1px solid #333;flex-wrap:wrap}
.champ-fm-path{flex:1;min-width:80px;background:#111;color:#ddd;border:1px solid #444;
border-radius:4px;padding:3px 6px}
.champ-fm-filter{background:#111;color:#ddd;border:1px solid #444;border-radius:4px;padding:2px 6px}
.champ-fm-body{flex:1;display:flex;min-height:0}
.champ-fm-list{flex:1;overflow:auto;min-height:0}
.champ-fm-list table{width:100%;border-collapse:collapse}
.champ-fm-list th{position:sticky;top:0;background:#262626;text-align:left;
padding:4px 8px;cursor:pointer;border-bottom:1px solid #333;font-weight:500}
.champ-fm-list td{padding:3px 8px;border-bottom:1px solid #2a2a2a;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
.champ-fm-row{cursor:pointer}
.champ-fm-row:hover{background:#2c2c2c}
.champ-fm-row.sel{background:#33415a}
.champ-fm-name{display:flex;align-items:center;gap:6px;max-width:240px}
.champ-fm-name img{width:20px;height:20px;object-fit:cover;border-radius:3px;background:#333}
.champ-fm-more{padding:8px;text-align:center;color:#888;cursor:pointer;border-bottom:1px solid #2a2a2a}
.champ-fm-more:hover{color:#ddd}
.champ-fm-divider{width:6px;flex-shrink:0;cursor:col-resize;background:#2a2a2a}
.champ-fm-divider:hover{background:#3a6ea5}
.champ-fm-side{border-left:1px solid #333;padding:8px;overflow:auto;flex-shrink:0;
display:flex;flex-direction:column;box-sizing:border-box}
.champ-fm-side .ph{width:100%;flex:1;min-height:240px;background:#111;border-radius:4px;display:flex;
align-items:center;justify-content:center;color:#666;font-size:48px;overflow:hidden}
.champ-fm-side .ph.clk{cursor:zoom-in}
.champ-fm-side .ph img{max-width:100%;max-height:100%;object-fit:contain}
.champ-fm-side .ph video{max-width:100%;max-height:100%}
.champ-fm-side .ph audio{width:100%}
.champ-fm-side dl{margin:8px 0 0;font-size:11px;color:#aaa;line-height:1.5}
.champ-fm-side dt{color:#888}
.champ-fm-side dd{margin:0 0 5px;color:#ddd;word-break:break-all}
.champ-fm-empty{padding:16px;color:#777;text-align:center}
.champ-fm-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:10000;
display:flex;align-items:center;justify-content:center}
.champ-fm-overlay img,.champ-fm-overlay video{max-width:90vw;max-height:90vh}
.champ-fm-overlay .close{position:absolute;top:16px;right:24px;font-size:28px;color:#fff;cursor:pointer}
.champ-fm-overlay .cap{position:absolute;bottom:18px;left:0;right:0;text-align:center;
color:#eee;font-size:13px;text-shadow:0 1px 3px #000;pointer-events:none}
.champ-fm-overlay .nav{position:absolute;top:50%;transform:translateY(-50%);font-size:46px;
color:#fff;cursor:pointer;user-select:none;padding:0 18px;opacity:.6}
.champ-fm-overlay .nav:hover{opacity:1}
.champ-fm-overlay .nav.prev{left:8px}
.champ-fm-overlay .nav.next{right:8px}
.champ-fm.drag{outline:2px dashed #5a8;outline-offset:-6px}
`;
document.head.append(s);
}
const ICONS = { folder: "📁", image: "🖼", video: "🎬", audio: "🎵", text: "📄", other: "📦" };
const isPreviewable = (e) => e.kind === "image" || e.kind === "video" || e.kind === "audio";
// Fullscreen viewer. `list` is the current visible entries; ←/→ cycle through the
// previewable members of that list (wrapping). Falls back to a single item.
function openViewer(entry, list) {
if (!isPreviewable(entry)) {
window.open(fileUrl(entry.path), "_blank");
return;
}
let media = (list && list.length ? list : [entry]).filter(isPreviewable);
let idx = media.findIndex((e) => e.path === entry.path);
if (idx < 0) {
media = [entry];
idx = 0;
}
const overlay = el("div", { class: "champ-fm-overlay" });
const cap = el("div", { class: "cap" });
const prev = el("div", { class: "nav prev", title: "Previous (←)" }, "‹");
const next = el("div", { class: "nav next", title: "Next (→)" }, "›");
let mediaEl = null;
const draw = () => {
const cur = media[idx];
const url = fileUrl(cur.path);
let m;
if (cur.kind === "image") m = el("img", { src: url });
else if (cur.kind === "video") m = el("video", { src: url, controls: "", autoplay: "" });
else m = el("audio", { src: url, controls: "", autoplay: "" });
if (mediaEl) mediaEl.replaceWith(m);
else overlay.insertBefore(m, cap);
mediaEl = m;
cap.textContent = media.length > 1 ? `${cur.name} · ${idx + 1} / ${media.length}` : cur.name;
};
const step = (d) => {
idx = (idx + d + media.length) % media.length;
draw();
};
const close = () => {
overlay.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e) => {
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") { step(1); e.preventDefault(); }
else if (e.key === "ArrowLeft") { step(-1); e.preventDefault(); }
};
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
prev.addEventListener("click", (e) => { e.stopPropagation(); step(-1); });
next.addEventListener("click", (e) => { e.stopPropagation(); step(1); });
document.addEventListener("keydown", onKey);
overlay.append(cap, el("div", { class: "close", onclick: close }, "✕"));
if (media.length > 1) overlay.append(prev, next);
document.body.append(overlay);
draw();
}
function createFileManager(initialPath, showHidden) {
const state = {
cwd: initialPath || "",
parent: null,
entries: [],
view: [], // current filtered+sorted list (full, not windowed)
selected: new Set(),
focused: null,
showHidden: !!showHidden,
sort: "name",
sortDir: "asc",
filter: "",
shown: PAGE, // how many of `view` are currently rendered
sideWidth: 320,
unlocked: false, // write actions + browsing above ComfyUI need the password
};
const root = el("div", { class: "champ-fm" });
root.tabIndex = 0;
// --- toolbar ---
const upBtn = el("button", { title: "Up one level" }, "⬆");
const pathInput = el("input", { class: "champ-fm-path", type: "text", placeholder: "/path/to/folder" });
const refreshBtn = el("button", { title: "Refresh" }, "⟳");
const hiddenChk = el("input", { type: "checkbox", title: "Show hidden" });
hiddenChk.checked = state.showHidden;
root.append(
el("div", { class: "champ-fm-bar" }, upBtn, pathInput, refreshBtn,
el("label", { style: { display: "flex", alignItems: "center", gap: "3px" } }, hiddenChk, "hidden"))
);
// --- action bar ---
const actBar = el("div", { class: "champ-fm-bar" });
const actions = {};
const addAction = (key, label, title, handler, danger) => {
const b = el("button", danger ? { title, class: "danger" } : { title }, label);
b.addEventListener("click", handler);
actions[key] = b;
actBar.append(b);
};
root.append(actBar);
// --- password unlock ---
// Write actions (upload/download/rename/move/copy/folder/delete) are hidden
// while locked; the backend also rejects them with 401.
const WRITE_ACTIONS = ["upload", "download", "rename", "move", "copy", "mkdir", "del"];
const unlockBtn = el("button", { title: "Unlock write actions and browsing outside ComfyUI" }, "🔓 Unlock");
const lockBtn = el("button", { title: "Lock (hide write actions)" }, "🔒");
lockBtn.style.display = "none";
actBar.append(unlockBtn, lockBtn);
async function refreshAuth() {
try {
const r = await api.fetchApi("/champdev/auth/status");
const res = await r.json();
state.unlocked = !!res.authed;
} catch (e) {
state.unlocked = false;
}
updateActionState();
}
async function unlock() {
const ok = await unlockWithDialog("Password (from the telemetry dashboard):");
if (!ok) return;
state.unlocked = true;
toast("success", "Unlocked", "Write actions enabled");
updateActionState();
load(); // re-browse in case we were clamped above the ComfyUI root
}
function lock() {
state.unlocked = false;
updateActionState();
}
unlockBtn.addEventListener("click", unlock);
lockBtn.addEventListener("click", lock);
// --- body: list + resizable divider + side pane ---
const listWrap = el("div", { class: "champ-fm-list" });
const side = el("div", { class: "champ-fm-side" });
side.style.width = state.sideWidth + "px";
const divider = el("div", { class: "champ-fm-divider", title: "Drag to resize preview" });
root.append(el("div", { class: "champ-fm-body" }, listWrap, divider, side));
divider.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startW = side.offsetWidth;
const onMove = (ev) => {
const w = Math.max(180, Math.min(720, startW + (startX - ev.clientX)));
side.style.width = w + "px";
state.sideWidth = w;
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
// auto-load more rows as the list scrolls near the bottom
listWrap.addEventListener("scroll", () => {
if (listWrap.scrollTop + listWrap.clientHeight >= listWrap.scrollHeight - 48) loadMore();
});
// ---- data ----
async function load(path) {
const target = path != null ? path : state.cwd;
const res = await jget("/list", { path: target, show_hidden: state.showHidden, sort: state.sort });
if (res.error) {
if (res.error === "unlock to browse outside ComfyUI") {
toast("warn", "Locked", "Unlock to browse outside ComfyUI");
} else {
toast("error", "Cannot open folder", res.error);
}
return;
}
state.cwd = res.cwd;
state.parent = res.parent;
state.entries = res.entries;
state.selected.clear();
state.focused = null;
state.shown = PAGE;
listWrap.scrollTop = 0;
render();
// While locked, the server pins us inside the ComfyUI root — surface that.
if (res.clamped && !state.unlocked) {
side.replaceChildren();
side.append(el("div", { style: { color: "#e8c15a", fontSize: "12px", lineHeight: 1.5 } },
"🔒 Locked to the ComfyUI folder. Unlock to browse above it."));
}
}
function loadMore() {
if (state.shown < state.view.length) {
state.shown += PAGE;
render();
}
}
function selectedEntries() {
return state.entries.filter((e) => state.selected.has(e.path));
}
function updateActionState() {
const sel = selectedEntries();
const one = sel.length === 1;
const any = sel.length >= 1;
actions.preview.disabled = !(one && sel[0].kind !== "folder" && sel[0].kind !== "other");
actions.download.disabled = !(one && !sel[0].is_dir);
actions.rename.disabled = !one;
actions.move.disabled = !any;
actions.copy.disabled = !any;
actions.del.disabled = !any;
actions.props.disabled = !one;
// Hide write actions until unlocked; the backend enforces this too.
for (const k of WRITE_ACTIONS) actions[k].style.display = state.unlocked ? "" : "none";
unlockBtn.style.display = state.unlocked ? "none" : "";
lockBtn.style.display = state.unlocked ? "" : "none";
// read-only actions stay visible while locked
actions.preview.style.display = actions.props.style.display = "";
}
function showProps(entry) {
side.replaceChildren();
if (!entry) {
side.append(el("div", { style: { color: "#777" } }, "No selection"));
return;
}
const ph = el("div", { class: "ph" });
if (entry.kind === "image") {
const img = el("img", { src: fileUrl(entry.path) });
img.addEventListener("error", () => ph.replaceChildren(document.createTextNode(ICONS.image)));
ph.append(img);
ph.classList.add("clk");
ph.addEventListener("click", () => openViewer(entry, state.view));
} else if (entry.kind === "video") {
ph.append(el("video", { src: fileUrl(entry.path), controls: "" }));
ph.classList.add("clk");
ph.addEventListener("click", (ev) => { if (ev.target === ph) openViewer(entry, state.view); });
} else if (entry.kind === "audio") {
ph.append(el("audio", { src: fileUrl(entry.path), controls: "" }));
} else {
ph.append(document.createTextNode(ICONS[entry.kind] || ICONS.other));
}
const dl = el("dl");
const add = (k, v) => dl.append(el("dt", {}, k), el("dd", {}, v));
add("Name", entry.name);
add("Type", entry.is_dir ? "folder" : entry.kind + (entry.ext ? " (" + entry.ext + ")" : ""));
if (!entry.is_dir) add("Size", fmtSize(entry.size));
add("Modified", fmtDate(entry.mtime));
add("Path", entry.path);
side.append(ph, dl);
if (entry.kind === "image" && !entry.is_dir) {
jget("/properties", { path: entry.path }).then((p) => {
if (p.width && dl.isConnected) add("Dimensions", `${p.width} × ${p.height}`);
});
}
}
function selectOnly(entry) {
state.selected.clear();
state.selected.add(entry.path);
state.focused = entry;
}
function rowFor(entry) {
const nameCell = el("div", { class: "champ-fm-name" });
if (entry.kind === "image") {
const thumb = el("img", { src: thumbUrl(entry.path), loading: "lazy" });
thumb.addEventListener("error", () => thumb.replaceWith(el("span", {}, ICONS.image)));
nameCell.append(thumb);
} else {
nameCell.append(el("span", {}, ICONS[entry.kind] || ICONS.other));
}
nameCell.append(el("span", { style: { overflow: "hidden", textOverflow: "ellipsis" } }, entry.name));
const tr = el("tr", { class: "champ-fm-row" + (state.selected.has(entry.path) ? " sel" : "") },
el("td", {}, nameCell),
el("td", {}, entry.is_dir ? "—" : fmtSize(entry.size)),
el("td", {}, entry.is_dir ? "folder" : entry.kind),
el("td", {}, fmtDate(entry.mtime)));
tr.addEventListener("click", (ev) => {
if (ev.ctrlKey || ev.metaKey) {
if (state.selected.has(entry.path)) state.selected.delete(entry.path);
else state.selected.add(entry.path);
state.focused = entry;
} else {
selectOnly(entry);
}
root.focus({ preventScroll: true }); // so ↑/↓ work after a click
render();
});
tr.addEventListener("dblclick", () => {
if (entry.is_dir) load(entry.path);
else if (isPreviewable(entry)) openViewer(entry, state.view);
else window.open(fileUrl(entry.path), "_blank");
});
return tr;
}
function visibleEntries() {
let items = state.entries.slice();
if (state.filter) {
const f = state.filter.toLowerCase();
items = items.filter((e) => e.name.toLowerCase().includes(f));
}
const dir = state.sortDir === "desc" ? -1 : 1;
items.sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
let av, bv;
if (state.sort === "size") { av = a.size; bv = b.size; }
else if (state.sort === "mtime") { av = a.mtime; bv = b.mtime; }
else if (state.sort === "kind") { av = a.kind; bv = b.kind; }
else { av = a.name.toLowerCase(); bv = b.name.toLowerCase(); }
return av < bv ? -dir : av > bv ? dir : 0;
});
return items;
}
function render() {
pathInput.value = state.cwd;
upBtn.disabled = !state.parent;
const items = visibleEntries();
state.view = items;
const windowed = items.slice(0, state.shown);
const header = (label, key) =>
el("th", { onclick: () => {
if (state.sort === key) state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
else { state.sort = key; state.sortDir = "asc"; }
state.shown = PAGE;
render();
} }, label + (state.sort === key ? (state.sortDir === "asc" ? " ▲" : " ▼") : ""));
const tbody = el("tbody");
for (const e of windowed) tbody.append(rowFor(e));
listWrap.replaceChildren(
el("table", {},
el("thead", {}, el("tr", {},
header("Name", "name"), header("Size", "size"),
header("Type", "kind"), header("Modified", "mtime"))),
tbody)
);
if (!items.length) {
listWrap.append(el("div", { class: "champ-fm-empty" }, "Empty folder"));
} else if (items.length > windowed.length) {
const remaining = items.length - windowed.length;
listWrap.append(el("div", { class: "champ-fm-more", onclick: loadMore },
`Showing ${windowed.length} of ${items.length} — click or scroll for ${Math.min(PAGE, remaining)} more`));
}
showProps(state.focused);
updateActionState();
}
// ---- keyboard navigation (list has focus) ----
root.addEventListener("keydown", (e) => {
if (document.querySelector(".champ-fm-overlay")) return; // viewer owns the keys
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; // typing in a field
const nav = e.key === "ArrowDown" || e.key === "ArrowUp";
if (!nav && e.key !== "Enter") return;
const items = state.view;
if (!items.length) return;
let i = state.focused ? items.findIndex((x) => x.path === state.focused.path) : -1;
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
const cur = items[i] || items[0];
if (!cur) return;
if (cur.is_dir) load(cur.path);
else openViewer(cur, items);
return;
}
if (i < 0) i = e.key === "ArrowDown" ? 0 : items.length - 1;
else i = e.key === "ArrowDown" ? Math.min(items.length - 1, i + 1) : Math.max(0, i - 1);
selectOnly(items[i]);
if (i >= state.shown) state.shown = Math.ceil((i + 1) / PAGE) * PAGE;
e.preventDefault();
e.stopPropagation();
render();
const selRow = listWrap.querySelector(".champ-fm-row.sel");
if (selRow) selRow.scrollIntoView({ block: "nearest" });
});
// ---- uploads ----
async function uploadFiles(fileList) {
if (!fileList || !fileList.length) return;
if (!state.unlocked) {
toast("warn", "Locked", "Unlock to upload files");
return;
}
const fd = new FormData();
fd.append("dest", state.cwd); // dest MUST be first (multipart is read in order)
for (const f of fileList) fd.append("files", f, f.name);
const r = await api.fetchApi(`${API}/upload`, { method: "POST", body: fd });
const res = await r.json();
if (res.error) toast("error", "Upload failed", res.error);
else toast("success", "Uploaded", `${res.results.length} file(s)`);
load();
}
// ---- action handlers ----
addAction("preview", "👁 Preview", "Preview", () => {
const sel = selectedEntries();
if (sel.length === 1) openViewer(sel[0], state.view);
});
addAction("upload", "⬆ Upload", "Upload files here", () => {
const inp = el("input", { type: "file", multiple: "" });
inp.addEventListener("change", () => uploadFiles(inp.files));
inp.click();
});
addAction("download", "⬇ Download", "Download", () => {
const sel = selectedEntries();
if (sel.length === 1 && !sel[0].is_dir) {
const a = el("a", { href: fileUrl(sel[0].path, true), download: sel[0].name });
document.body.append(a);
a.click();
a.remove();
}
});
addAction("rename", "✎ Rename", "Rename", async () => {
const sel = selectedEntries();
if (sel.length !== 1) return;
const name = window.prompt("New name:", sel[0].name);
if (!name || name === sel[0].name) return;
const res = await jpost("/rename", { path: sel[0].path, new_name: name });
if (res.error) toast("error", "Rename failed", res.error);
load();
});
addAction("move", "↪ Move", "Move to…", async () => {
const sel = selectedEntries();
if (!sel.length) return;
const dest = window.prompt("Move to folder:", state.cwd);
if (!dest) return;
const res = await jpost("/move", { paths: sel.map((e) => e.path), dest, copy: false });
reportResults("Move", res);
load();
});
addAction("copy", "⧉ Copy", "Copy to…", async () => {
const sel = selectedEntries();
if (!sel.length) return;
const dest = window.prompt("Copy to folder:", state.cwd);
if (!dest) return;
const res = await jpost("/move", { paths: sel.map((e) => e.path), dest, copy: true });
reportResults("Copy", res);
load();
});
addAction("mkdir", "+ Folder", "New folder", async () => {
const name = window.prompt("New folder name:");
if (!name) return;
const res = await jpost("/mkdir", { path: state.cwd, name });
if (res.error) toast("error", "Create folder failed", res.error);
load();
});
addAction("del", "🗑 Delete", "Delete", async () => {
const sel = selectedEntries();
if (!sel.length) return;
if (!window.confirm(`Delete ${sel.length} item(s)? This cannot be undone.`)) return;
const res = await jpost("/delete", { paths: sel.map((e) => e.path) });
reportResults("Delete", res);
load();
}, true);
addAction("props", "ℹ Properties", "Properties", () => {
const sel = selectedEntries();
if (sel.length === 1) {
state.focused = sel[0];
showProps(sel[0]);
}
});
function reportResults(label, res) {
if (res.error) {
toast("error", `${label} failed`, res.error);
return;
}
const fails = (res.results || []).filter((r) => !r.ok);
if (fails.length) toast("warn", `${label}: ${fails.length} failed`, fails[0].error);
else toast("success", label, "Done");
}
// ---- wire toolbar ----
upBtn.addEventListener("click", () => state.parent && load(state.parent));
refreshBtn.addEventListener("click", () => load());
pathInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") load(pathInput.value.trim());
});
hiddenChk.addEventListener("change", () => {
state.showHidden = hiddenChk.checked;
load();
});
// filter box lives in the action bar
const filterInput = el("input", { class: "champ-fm-filter", type: "text", placeholder: "filter…" });
filterInput.addEventListener("input", () => {
state.filter = filterInput.value;
state.shown = PAGE;
listWrap.scrollTop = 0;
render();
});
actBar.append(filterInput);
// ---- drag & drop upload ----
root.addEventListener("dragover", (e) => {
e.preventDefault();
root.classList.add("drag");
});
root.addEventListener("dragleave", (e) => {
if (e.target === root) root.classList.remove("drag");
});
root.addEventListener("drop", (e) => {
e.preventDefault();
root.classList.remove("drag");
if (e.dataTransfer?.files?.length) uploadFiles(e.dataTransfer.files);
});
return { root, load, refreshAuth };
}
app.registerExtension({
name: "champdev.filemanager",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "ChampdevFM") return;
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onCreated?.apply(this, arguments);
ensureStyle();
const startWidget = this.widgets?.find((w) => w.name === "start_path");
const hiddenWidget = this.widgets?.find((w) => w.name === "show_hidden");
const fm = createFileManager(startWidget?.value || "", hiddenWidget?.value);
const fmWidget = this.addDOMWidget("champ_fm", "div", fm.root, { serialize: false, hideOnZoom: false });
this.size = [640, 520];
// A ComfyUI DOM widget is sized to its content, so the file <table> would
// grow the node unbounded and the inner list could never scroll. Pin the
// element to an EXPLICIT pixel height derived from the node's height (not
// its content) so it clips/scrolls internally and the node stays fixed.
// Keep it in sync on resize (drag, and relayout via ResizeObserver). ~86px
// is reserved for the node title + start_path / show_hidden widgets above.
const node = this;
const RESERVED = 86;
const fmHeight = () => Math.max(160, Math.round((node.size?.[1] || 520) - RESERVED));
const syncHeight = () => { fm.root.style.height = fmHeight() + "px"; };
fmWidget.computeSize = function (width) { return [width, fmHeight()]; };
const prevResize = this.onResize;
this.onResize = function () { prevResize?.apply(this, arguments); syncHeight(); };
syncHeight();
requestAnimationFrame(() => {
syncHeight();
const host = fm.root.parentElement;
if (host && window.ResizeObserver) new ResizeObserver(syncHeight).observe(host);
});
fm.load(startWidget?.value || "");
fm.refreshAuth();
if (startWidget) {
const cb = startWidget.callback;
startWidget.callback = function () {
cb?.apply(this, arguments);
fm.load(startWidget.value);
};
}
return r;
};
},
});