Picking Node packs, Updates, the log or Temp files used to close the
Settings window and open that feature's own separate modal, with no way
back except reopening Settings from the menu bar -- not what v4 does,
and not what "unified" is supposed to mean. Each of those five files
(plus pipeline_window.js) now exports mount({ setFooter, close }), the
same content its standalone open() already built, taking the modal
chrome's footer and close from whoever hosts it instead of owning a
modal directly. open() is unchanged for anyone opening one alone --
pipeline_window.test.js and every Playwright spec for these five prove
it, unedited, still passing.
Also fixes two findings from the adversarial pass on the earlier
deep-link version: filterList's search only matched a section's visible
label/hint, so a section findable only by a keyword (e.g. "loaders" for
"Models and pipeline") silently vanished from the filtered list; and
createSettingsWindow's "one at a time" state lived at module scope, so
a second independent instance would have redrawn the first one's modal
instead of opening its own. Both now have regression tests.
174 lines
6.4 KiB
JavaScript
174 lines
6.4 KiB
JavaScript
// Node packs: install, update, remove.
|
|
//
|
|
// A stand-in for ComfyUI-Manager's three operations, so somebody adding the one
|
|
// pack a workflow needs is not sent to another UI to do it. There is no
|
|
// catalogue and no curation -- the URL is theirs -- which keeps this honest
|
|
// about what it is: git, in a directory.
|
|
//
|
|
// Everything here can take minutes. Nothing pretends otherwise: the row that is
|
|
// working says so, and the rest of the list stays usable.
|
|
|
|
import { composer } from "../composer/composer.js";
|
|
|
|
const BASE = "/funpack/api/packs";
|
|
|
|
async function ask(method, path = "", body) {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const payload = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(payload.detail || `HTTP ${res.status}`);
|
|
return payload;
|
|
}
|
|
|
|
/** What a pack's git state says, in words rather than two numbers. */
|
|
function saySince(pack, checked) {
|
|
if (!pack.git) return "not a git checkout — update and remove are not offered";
|
|
const news = checked && checked[pack.name];
|
|
if (!news) return `${pack.branch || "?"} · ${pack.commit || ""}`.trim();
|
|
if (!news.checked) return `${pack.branch || "?"} · could not check: ${news.reason}`;
|
|
if (news.behind) return `${news.behind} update${news.behind === 1 ? "" : "s"} available`;
|
|
return `${pack.branch || "?"} · up to date`;
|
|
}
|
|
|
|
/** mount({ setFooter, close }) -> a composer handle, no modal chrome. See
|
|
* tempfiles.js's mount() for why these two are the only things asked for. */
|
|
export function mount({ setFooter = () => {}, close = () => {} } = {}) {
|
|
let listing = null;
|
|
let checked = null;
|
|
let busy = null; // the pack currently being worked on, by name
|
|
// What went wrong, held rather than passed: every redraw would otherwise wipe
|
|
// it, and the one after a failure arrives immediately -- the message appeared
|
|
// and vanished in the same frame.
|
|
let problem = null;
|
|
|
|
const body = composer.region.stack({ gap: "sm", label: "Node packs" });
|
|
|
|
const run = async (name, work) => {
|
|
busy = name;
|
|
problem = null;
|
|
draw();
|
|
try {
|
|
await work();
|
|
// Stale news, not just a stale listing: an update that just succeeded must
|
|
// not go on claiming there is one available until "Check for updates" is
|
|
// pressed again.
|
|
if (checked) delete checked[name];
|
|
await load();
|
|
} catch (err) {
|
|
problem = err.message;
|
|
} finally {
|
|
busy = null;
|
|
draw();
|
|
}
|
|
};
|
|
|
|
function rows() {
|
|
const out = [];
|
|
if (problem) out.push(composer.banner.danger({ text: problem }));
|
|
|
|
const url = composer.input.md({ label: "Git URL",
|
|
placeholder: "https://github.com/owner/pack" });
|
|
out.push(composer.settingsRow.default({
|
|
label: "Install a pack",
|
|
hint: "Anything git can clone. ComfyUI has to be restarted before a new pack "
|
|
+ "loads — Settings ▸ Updates can do that.",
|
|
control: composer.button.md({
|
|
label: busy === "*" ? "Installing…" : "Install",
|
|
disabled: Boolean(busy),
|
|
onClick: () => {
|
|
const value = String(url.value || "").trim();
|
|
// Said here rather than sent: an empty URL is not a server's problem.
|
|
if (!value) { problem = "Paste a git URL first."; draw(); return; }
|
|
run("*", () => ask("POST", "/install", { url: value }));
|
|
},
|
|
}),
|
|
}));
|
|
out.push(composer.field.default({ label: "Git URL", control: url }));
|
|
|
|
if (!listing) {
|
|
out.push(composer.hint.default({ text: "Reading custom_nodes…" }));
|
|
return out;
|
|
}
|
|
|
|
out.push(composer.label.section({ text: `${listing.nodes.length} installed` }));
|
|
for (const pack of listing.nodes) {
|
|
const working = busy === pack.name;
|
|
out.push(composer.settingsRow.default({
|
|
label: pack.name + (pack.is_funpack ? " — this is FunPack" : ""),
|
|
hint: working ? "Working…" : saySince(pack, checked),
|
|
// Buttons, not a button GROUP: these are two actions, not a choice
|
|
// between two states. A radio group also leaves one of them looking
|
|
// chosen afterwards, which for "Remove" is an alarming thing to look at.
|
|
control: composer.toolbar.default({
|
|
label: pack.name,
|
|
items: [
|
|
...(pack.git && !pack.is_funpack ? [composer.button.sm({
|
|
label: "Update", disabled: Boolean(busy),
|
|
onClick: () => run(pack.name, () => ask("POST", "/update", { name: pack.name })),
|
|
})] : []),
|
|
...(pack.is_funpack ? [] : [composer.button.sm({
|
|
label: "Remove", tone: "danger", disabled: Boolean(busy),
|
|
onClick: () => run(pack.name, () => ask("POST", "/remove", { name: pack.name })),
|
|
})]),
|
|
],
|
|
}),
|
|
}));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function draw() {
|
|
body.set(rows());
|
|
setFooter({
|
|
note: listing ? listing.root : "",
|
|
actions: [
|
|
composer.button.md({ label: "Check for updates", disabled: Boolean(busy),
|
|
onClick: () => check() }),
|
|
composer.button.md({ label: "Close", tone: "primary", onClick: () => close() }),
|
|
],
|
|
});
|
|
}
|
|
|
|
async function load() {
|
|
try { listing = await ask("GET"); }
|
|
catch (err) { listing = null; problem = err.message; }
|
|
draw();
|
|
}
|
|
|
|
async function check() {
|
|
// Fetching every pack is network-bound and slow; the button says what it is
|
|
// doing rather than looking broken for twenty seconds.
|
|
busy = "*check*";
|
|
draw();
|
|
problem = null;
|
|
// `checked`, which is the key the server sends. It said `nodes` here for a
|
|
// while: the request succeeded, the JSON was valid, and every pack reported
|
|
// nothing to update no matter how far behind it was.
|
|
try { checked = (await ask("POST", "/check")).checked || null; }
|
|
catch (err) { problem = err.message; }
|
|
finally { busy = null; draw(); }
|
|
}
|
|
|
|
load();
|
|
return body;
|
|
}
|
|
|
|
export function open() {
|
|
let window_ = null;
|
|
const body = mount({
|
|
setFooter: (f) => window_ && window_.setFooter(f),
|
|
close: () => window_ && window_.close("done"),
|
|
});
|
|
window_ = composer.modal.generic({
|
|
title: "Node packs",
|
|
subtitle: "Install, update and remove ComfyUI custom nodes.",
|
|
size: "lg",
|
|
body,
|
|
onClose: () => { window_ = null; },
|
|
});
|
|
return window_;
|
|
}
|