Files
olivv-cs-ComfyUI-FunPack/app/shell/pipeline_window.js
T
digital-garbage e6e4dfa882 app: mount every Settings section in place instead of deep-linking out
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.
2026-09-07 01:06:02 +03:00

621 lines
23 KiB
JavaScript

// The pipeline, opened up and edited.
//
// One wide modal, a card per group, and a group that opens into a node list on
// the left with that node's parameters on the right, saved a group at a time.
//
// Two kinds of edit, and they behave differently on purpose:
//
// * A VALUE is drafted. Changing steps from 20 to 30 cannot make a graph
// illegal, so it waits in the draft until Save, and Cancel puts it back.
// * A STRUCTURE change -- adding a node, removing one, pointing a slot at a
// different node, moving it to another group -- goes to the server as it
// happens, because only the server can say whether the result still wires up,
// and a refusal shown ten edits later is a refusal nobody can act on.
//
// Nothing here composes a graph, and nothing here makes an element. Both would
// be a second answer to a question that already has one -- what runs, and what a
// control looks like.
import { composer } from "../composer/composer.js";
import { rendererFor, rendererNameFor, SELF_LABELLING } from "../composer/panel.js";
import { settingFor, label as humanLabel, whyNotEditable } from "./widgets.js";
const UNGROUPED = "Other";
// The value of a list nobody has chosen from. Not "" -- an empty string is a
// value a combo could legitimately offer.
const NOT_SET = "\u0000not-set";
/** Groups in the order they first appear, plus any the user has just made. */
export function groupsOf(slots, extra = []) {
const order = [];
const byGroup = new Map();
const put = (name) => {
if (!byGroup.has(name)) { byGroup.set(name, []); order.push(name); }
return byGroup.get(name);
};
for (const slot of slots) put(slot.group || UNGROUPED).push(slot);
for (const name of extra) put(name);
return { order, byGroup };
}
const isLink = (value) => Array.isArray(value) && value.length === 2
&& typeof value[0] === "string" && typeof value[1] === "number";
const countOf = (n) => (n === 0 ? "empty" : `${n} node${n === 1 ? "" : "s"}`);
// The one that is open, if one is.
//
// There is a single pipeline, so there is a single window onto it. Without this
// the button opened another one on every press, and two windows over the same
// pipeline are two drafts of it: whichever was saved last wins, and the other
// was edited against a pipeline that had already moved.
let current = null;
/**
* mount({ load, describe, check, search, onApply, setFooter, close }) -> handle
*
* Every server call is injected. The window is then testable without a server
* and -- more to the point -- cannot reach for one that is not there.
*
* `setFooter`/`close` are the two things a modal gives this that a container
* does not have for free -- see tempfiles.js's mount() for the general shape.
* This one additionally exposes its live state (slots/editing/selected/
* pending) and a few structural actions, because `open()` below and the
* jsdom suite both drive it without reading the DOM back; `open()` forwards
* every one of these rather than wrapping a second copy of this state.
*/
export function mount({ load, describe, check, search, onApply,
setFooter = () => {}, close = () => {} } = {}) {
let slots = [];
const nodes = new Map(); // class name -> description | null
const extraGroups = []; // made in this window, no slot in them yet
let draft = null; // slot id -> { input: value }, while editing
let editing = null; // group name
let selected = null; // slot id
let notes = { refused: [], incomplete: [] };
const body = composer.region.stack({ gap: "md", label: "Pipeline", fill: true });
// ---------------------------------------------------------------- server
async function refresh() {
const answer = await load();
slots = answer.slots;
notes = { refused: [], incomplete: answer.incomplete || [] };
await learn(slots.map((slot) => slot.node));
}
/** Descriptions for classes we have not seen. Asked once per class. */
async function learn(classes) {
const unknown = [...new Set(classes)].filter((c) => c && !nodes.has(c));
if (!unknown.length) return;
const described = await describe(unknown);
// Every class asked about gets an entry, `null` included: without it an
// absent node is asked for again on every redraw, and reads on screen as a
// node still being looked up rather than one that is not installed.
for (const name of unknown) nodes.set(name, described[name] ?? null);
}
// Which edit is the current one. Two structural edits can be in flight at
// once -- moving a node carries the whole slot list and a remove carries one
// id, so the second can easily answer first -- and the older answer was built
// from the pipeline as it stood BEFORE the newer edit. Taking it put the
// removed node back with no refusal and nothing said. The picker's search
// already guards this; the path that decides what gets QUEUED did not.
let edits = 0;
/** Send the pipeline as it now stands and keep what the server says of it. */
async function commit(next, action = {}) {
const mine = ++edits;
let answer;
try {
answer = await check({ slots: next, ...action });
} catch (err) {
if (mine !== edits) return false;
notes = { refused: [err.message], incomplete: notes.incomplete };
draw();
return false;
}
// A newer edit has been sent since. Its answer is the one that describes
// the pipeline; this one describes a pipeline that no longer exists.
if (mine !== edits) return false;
// A refused edit did not happen, so what is on screen must stay what it
// was: showing the refusal beside the change it refused is how someone
// comes to believe an edit landed when it did not.
if (answer.refused.length) {
notes = { refused: answer.refused, incomplete: notes.incomplete };
draw();
return false;
}
forgetDraftsFor(slots, answer.slots);
slots = answer.slots;
notes = { refused: [], incomplete: answer.incomplete };
await learn(slots.map((slot) => slot.node));
if (onApply) onApply(slots);
draw();
return true;
}
/**
* Drop edits that were for a node this slot no longer holds.
*
* A draft is keyed by slot id, and a slot's id survives being pointed at a
* different node -- so an unsaved `steps` for one sampler stayed attached
* when the slot was changed to another. Most samplers declare `steps`, and
* the server only refuses an input the new node does NOT declare, so a
* same-named one is written on the next Save with nothing anywhere saying it
* was never chosen for that node.
*
* Keyed on the node CHANGING, not on the slots having come back from the
* server: saving goes through this same path, and a rule that fired on every
* answer would throw the edit away in the act of saving it.
*/
function forgetDraftsFor(before, after) {
if (!draft || !draft.size) return;
const was = new Map(before.map((slot) => [slot.id, slot.node]));
const now = new Map(after.map((slot) => [slot.id, slot.node]));
for (const id of [...draft.keys()]) {
if (!now.has(id) || now.get(id) !== was.get(id)) draft.delete(id);
}
}
// ----------------------------------------------------------------- draw
function draw() {
if (editing === null) {
body.set(index());
// Nothing to save from the index: the cards are a way in, not an edit.
setFooter({});
return;
}
body.set(group(editing));
setFooter(footer());
}
/**
* What to say, and where.
*
* A refusal is about the edit just attempted, so it belongs wherever that
* edit was made. What is still unfilled is a property of the WHOLE pipeline,
* and repeating all of it above every group put three warnings about the
* loaders over the sampler's settings, where none of them were about anything
* on screen. Inside a group the unfilled control says so itself -- it reads
* "not set" -- which is the same information in the place it can be acted on.
*/
function messages({ everything = false } = {}) {
return [
...notes.refused.map((text) => composer.banner.danger({ text })),
// Not an error: a fresh install has no model picked, and colouring that
// red says something went wrong when nothing has yet.
...(everything ? notes.incomplete.map((text) => composer.banner.warn({ text })) : []),
];
}
// ---- the index: one card per group
function index() {
const { order, byGroup } = groupsOf(slots, extraGroups);
return [
...messages({ everything: true }),
composer.gallery.cards({
items: order.map((name) => ({
id: name, label: name, hint: countOf(byGroup.get(name).length),
})),
onActivate: (item) => enter(item.id),
}),
composer.toolbar.default({ label: "Groups", items: [newGroupButton()] }),
];
}
function newGroupButton() {
return composer.button.md({
label: "New group",
onClick: async () => {
const picked = await composer.modal.prompt({
title: "New group",
label: "Name",
placeholder: "Upscaling",
validate: (name) => {
if (!name.trim()) return "A group needs a name.";
if (groupsOf(slots, extraGroups).byGroup.has(name.trim())) {
return "There is already a group called that.";
}
return null;
},
}).result;
if (picked === null) return;
// Held only for as long as this window is open. A group with no node in
// it is not part of the pipeline, so there is nothing to save it on --
// said in the card rather than left to be discovered after a reload.
extraGroups.push(picked.trim());
draw();
},
});
}
// ---- one group: nodes on the left, the selected node's parameters right
function enter(name) {
editing = name;
draft = new Map();
selected = (groupsOf(slots, extraGroups).byGroup.get(name) || [])[0]?.id ?? null;
draw();
}
function leave() {
editing = null;
draft = null;
selected = null;
draw();
}
function group(name) {
const mine = groupsOf(slots, extraGroups).byGroup.get(name) || [];
if (selected && !mine.some((slot) => slot.id === selected)) selected = mine[0]?.id ?? null;
const left = composer.region.stack({ gap: "sm", label: "Nodes", children: [
composer.filterList.md({
items: mine.map((slot) => ({ id: slot.id, label: slot.id, hint: titleOf(slot.node) })),
value: selected,
placeholder: "Find a node",
empty: "No nodes in this group yet",
onChange: (id) => { selected = id; draw(); },
}),
composer.toolbar.default({ label: "Nodes", items: [
composer.button.sm({ label: "Add node", onClick: () => pickNode(name) }),
composer.button.sm({
label: "Remove", tone: "ghost", disabled: !selected,
onClick: () => remove(selected),
}),
] }),
] });
const right = composer.region.stack({ gap: "sm", label: "Parameters",
children: selected ? parameters(slotBy(selected)) : [
composer.emptyState.default({
icon: "◇", title: "Nothing selected",
hint: "Pick a node on the left, or add one.",
}),
] });
return [
composer.toolbar.default({ label: "Group", items: [
composer.button.sm({ label: "← All groups", tone: "ghost", onClick: leave }),
composer.text.sm({ text: name }),
] }),
...messages(),
composer.splitPane.h({ panes: [left, right], size: 32, label: "Nodes and parameters" }),
];
}
/**
* Save and Cancel, in the modal's own footer rather than in the body.
*
* The footer is the one row outside the scrolling body. A bar built inside it
* sticks to the bottom of the SCROLLER, which is above the card's own edge by
* the body's padding -- so a strip of the settings showed through underneath
* the buttons and scrolled as the settings scrolled.
*/
function footer() {
const pending = draft ? draft.size : 0;
return {
note: pending
? `${pending} node${pending === 1 ? "" : "s"} edited — not saved yet`
: "No changes to save",
actions: [
composer.button.lg({ label: "Cancel", tone: "neutral", onClick: leave }),
composer.button.lg({
label: "Save", tone: "primary",
onClick: async () => { if (await saveDraft()) leave(); },
}),
],
};
}
const slotBy = (id) => slots.find((slot) => slot.id === id) || null;
const titleOf = (className) => (nodes.get(className) || {}).title || className;
// ---- the parameters of one node
function parameters(slot) {
if (!slot) return [];
const described = nodes.get(slot.node);
const rows = [
composer.settingsRow.default({
label: titleOf(slot.node),
hint: slot.node,
control: composer.button.sm({
label: "Change node",
onClick: () => pickNode(slot.group || UNGROUPED, slot.id),
}),
}),
composer.settingsRow.default({
label: "Group",
hint: "Which card this node appears under.",
control: composer.select.md({
options: groupsOf(slots, extraGroups).order.map((g) => ({ value: g, label: g })),
value: slot.group || UNGROUPED,
onChange: (to) => moveTo(slot.id, to),
}),
}),
];
if (!described) {
// The slot names a node this install does not have. Said here, where its
// parameters would be: an empty pane reads as "no settings".
rows.push(composer.banner.danger({
text: `${slot.node} is not installed here, so there is nothing to edit and the `
+ "pipeline will not run until this slot points somewhere else.",
}));
return rows;
}
for (const widget of described.widgets) {
let setting = settingFor(widget);
if (setting && unset(slot, widget)) setting = asUnset(setting);
if (!setting) {
rows.push(composer.settingsRow.default({
label: humanLabel(widget.name), hint: whyNotEditable(widget),
}));
continue;
}
const current = valueOf(slot, widget.name, setting.default);
const control = rendererFor(setting)(setting, current,
(next) => { if (next !== NOT_SET) edit(slot.id, widget.name, next); });
// A checkbox row draws its own label and hint. Wrapping it in a settings
// row printed both of them twice, one above the other.
rows.push(SELF_LABELLING.has(rendererNameFor(setting))
? control
: composer.settingsRow.default({
label: setting.label, hint: setting.hint, control,
}));
}
for (const socket of described.sockets) {
const wired = (slot.inputs || {})[socket.name];
rows.push(composer.settingsRow.default({
label: humanLabel(socket.name),
// Said outright, because there is no control here to look for. Wiring
// is not something this window does yet, and an input reading only
// "nothing is wired to it" reads as a thing the user failed to find.
hint: isLink(wired)
? `fed by ${wired[0]}`
: `${socket.type} — nothing feeds it, and this window cannot wire it yet`,
}));
}
return rows;
}
/** Has this input been given a value -- by the pipeline, or in this draft? */
function unset(slot, widget) {
const edited = draft && draft.get(slot.id);
if (edited && Object.prototype.hasOwnProperty.call(edited, widget.name)) return false;
return (slot.inputs || {})[widget.name] === undefined;
}
/**
* A choice that has not been made, shown as one.
*
* A select rendered with the first choice looks like a value that was picked:
* the pipeline says "model_name is not filled" while the box beside it reads
* `ltx2_3_video_diffusion_fp8.safetensors`, and picking that same entry fires
* no change event, so the one click that looks like the fix does nothing.
* Only for lists -- a text box showing "" is already showing what it holds.
*/
function asUnset(setting) {
if (setting.type !== "enum") return setting;
return {
...setting,
default: NOT_SET,
options: [{ value: NOT_SET, label: "— not set —" }, ...setting.options],
};
}
/** The drafted value if this node has been edited, else what the slot holds. */
function valueOf(slot, name, fallback) {
const edited = draft && draft.get(slot.id);
if (edited && Object.prototype.hasOwnProperty.call(edited, name)) return edited[name];
const held = (slot.inputs || {})[name];
// A wired input is not a value: rendering the link as text would put
// "model,0" in a box and offer to save it as one.
if (held === undefined || isLink(held)) return fallback;
return held;
}
function edit(slotId, name, value) {
draft.set(slotId, { ...(draft.get(slotId) || {}), [name]: value });
// The footer, not the body. Redrawing the body would rebuild the control
// that fired this and take the cursor out of it mid-edit; leaving the
// footer alone left it reading "No changes to save" over an edit that had
// just been made.
setFooter(footer());
}
async function saveDraft() {
if (!draft.size) return true;
const next = slots.map((slot) => (draft.has(slot.id)
? { ...slot, inputs: { ...(slot.inputs || {}), ...draft.get(slot.id) } }
: slot));
const ok = await commit(next);
if (ok) draft = new Map();
return ok;
}
// ---- structure
async function moveTo(slotId, to) {
await commit(slots.map((slot) => (slot.id === slotId ? { ...slot, group: to } : slot)));
}
async function remove(slotId) {
await commit(slots, { action: "remove", slot: slotId });
}
/** Add a node to `group`, or -- with `replacing` -- point that slot elsewhere. */
function pickNode(group, replacing = null) {
const results = composer.region.stack({ gap: "sm", label: "Results" });
let latest = 0;
let typing = null;
async function run(query) {
const mine = ++latest;
let answer;
try {
answer = await search(query);
} catch (err) {
results.set([composer.inlineError.default({ text: err.message })]);
return;
}
// An older search finishing after a newer one would overwrite the newer
// results with an answer to a question nobody is asking any more.
if (mine !== latest) return;
results.set([
composer.filterList.md({
items: answer.nodes.map((n) => ({
id: n.node, label: n.title, hint: n.category || n.node,
})),
placeholder: "Filter these results",
empty: "Nothing matches",
onChange: (className) => { picker.close("picked"); chose(className); },
}),
answer.total > answer.nodes.length
? composer.hint.default({
text: `Showing ${answer.nodes.length} of ${answer.total} — `
+ "type to narrow it down.",
})
: null,
].filter(Boolean));
}
// As you type, not on Enter: a picker that answers only when committed to
// makes you guess the whole name before it says whether it exists.
// Debounced, because otherwise every keystroke is a request.
const box = composer.search.md({
placeholder: "Search installed nodes",
label: "Node",
onInput: (query) => { clearTimeout(typing); typing = setTimeout(() => run(query), 150); },
onCommit: (query) => { clearTimeout(typing); run(query); },
});
const picker = composer.modal.stacked({
title: replacing ? `Change ${replacing}` : `Add a node to ${group}`,
size: "md",
body: composer.region.stack({ gap: "sm", children: [box, results] }),
// A pending search must not land on a closed picker, and a search whose
// answer is thrown away is a request nobody needed.
onClose: () => { clearTimeout(typing); latest += 1; },
});
run("");
async function chose(className) {
await learn([className]);
if (replacing) {
await commit(slots, { action: "replace", slot: replacing, node: className });
return;
}
// No inputs: what feeds a new node is a wiring decision, and guessing one
// is how a node ends up quietly reading the wrong thing.
const id = freeId(className);
if (await commit([...slots, { id, group, node: className, inputs: {} }])) {
selected = id;
draw();
}
}
}
/** A slot id nobody is using, derived from the node's name. */
function freeId(className) {
const base = className.replace(/[^A-Za-z0-9]+/g, "_").toLowerCase();
const taken = new Set(slots.map((slot) => slot.id));
if (!taken.has(base)) return base;
for (let n = 2; ; n += 1) if (!taken.has(`${base}_${n}`)) return `${base}_${n}`;
}
// --------------------------------------------------------------- startup
body.set([composer.spinner.md({ label: "Reading the pipeline" })]);
const ready = refresh().then(draw).catch((err) => {
body.set([composer.emptyState.default({
icon: "▲",
title: "The pipeline could not be read",
hint: err.message,
})]);
});
return {
node: body.node,
destroy: () => body.destroy(),
ready,
// The window's own view of things, so a test does not have to infer state
// by reading the DOM back.
get slots() { return slots; },
get editing() { return editing; },
get selected() { return selected; },
get pending() { return draft ? draft.size : 0; },
enter,
leave,
_replace: (slotId, className) =>
commit(slots, { action: "replace", slot: slotId, node: className }),
_moveTo: moveTo,
_remove: remove,
};
}
/**
* open({ load, describe, check, search, onApply }) -> handle
*
* Opening while one is already open returns THAT one and focuses it, rather
* than stacking a second over it.
*/
export function open({ load, describe, check, search, onApply } = {}) {
if (current) {
current.focus();
return current;
}
let window_ = null;
const content = mount({
load, describe, check, search, onApply,
setFooter: (f) => window_ && window_.setFooter(f),
close: () => window_ && window_.close("done"),
});
window_ = composer.modal.generic({
title: "Models and pipeline",
subtitle: "What the run is made of.",
size: "xl",
body: content,
// A half-finished group edit must not vanish because a click landed on the
// backdrop: the draft is the only copy of it.
closeOnOutside: false,
// However it was closed -- the button, Escape, destroy -- the next press
// opens a fresh one rather than finding this one and refusing.
onClose: () => { if (current === handle) current = null; },
});
const handle = {
node: window_.node,
close: (reason) => window_.close(reason),
focus: () => window_.node.focus ? window_.node.focus() : undefined,
ready: content.ready,
get slots() { return content.slots; },
get editing() { return content.editing; },
get selected() { return content.selected; },
get pending() { return content.pending; },
enter: content.enter,
leave: content.leave,
_replace: content._replace,
_moveTo: content._moveTo,
_remove: content._remove,
};
current = handle;
return handle;
}
/** For tests, which open and close many windows in one process. */
export function _reset() {
if (current) current.close("reset");
current = null;
}