Files
olivv-cs-ComfyUI-FunPack/app/shell/settings_window.js
T
digital-garbage 739a720adc app, core: Settings matches v4 -- retractable rail, fixed frame, real About
Three things the unified Settings window was still missing next to v4's
own, all found by looking at it next to v4 rather than in isolation:

- The sidebar is a 52px icon rail now, expanding to a full list on
  hover/focus and overlaying the content instead of a splitPane's
  permanently-docked two-pane shape -- v4's own sidebar, not an
  approximation of it. Fixing this surfaced two real CSS bugs along the
  way: filterList's own min-width:220px silently defeated the collapse
  (clipping the search box to "Sear" instead of showing an icon), and
  .cx-filter-list's unset overflow-x (computed to auto once overflow-y
  was set) put a horizontal scrollbar under it. Centering the icon in
  the collapsed row needed the label's flex-basis AND the row's own gap
  to both go to zero -- an invisible label still claims its share of
  both otherwise, and the icon renders off-centre.
- The modal now has a real height (v4's own 660px) rather than sizing to
  whichever section is showing, so switching between a short section
  (About) and a long one (Node packs) doesn't visibly resize the whole
  window. That exposed a THIRD latent bug: .cx-modal-body never had
  flex:1 1 auto, invisible until a modal had a real height to fill --
  every prior modal shrink-wrapped its content, so head+body+foot always
  summed to exactly the card's height by accident. Fixed at the shared
  rule, not just for Settings.
- About now shows what v4's does: commit and dirty state, a codename
  when the checkout has one, and the machine's real hardware/software
  facts (chip, memory, GPU or MPS, disk, OS, ComfyUI/Python/Torch/
  attention-backend versions, hostname) -- a new core/sysinfo.py
  (ported from v4's own, individually guarded so a missing probe never
  takes the panel down) behind a new GET /funpack/api/system, injected
  into settings_window.js the same way every other network call here is.

Also: a nav row's tone (the coloured icon chip) is now validated against
its closed set the same way button.js already validates its own tone --
an unknown one used to render silently wrong instead of failing loudly.
2026-09-07 13:47:18 +03:00

223 lines
9.9 KiB
JavaScript
Raw 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.
// One home for everything that is a PREFERENCE about the app or the pipeline,
// rather than an edit to the project on screen -- replacing five separate
// menu items (Models and pipeline, Updates, Node packs, Log, Temp files) that
// nothing tied together and nothing let you search.
//
// Every section is MOUNTED here, in place, beside the same always-visible
// nav list -- picking "Node packs" does not leave this window any more than
// clicking a different System Settings pane leaves System Settings. Each of
// pipeline_window.js/updates.js/packs.js/logwindow.js/tempfiles.js already
// had its content built separately from its own modal chrome (a `body`
// region.stack, footer buttons set on the modal handle); their `mount()`
// exports are that same content, taking `setFooter`/`close` from whoever is
// hosting them instead of owning a modal directly. Their `open()` exports
// are unchanged for anyone opening one standalone.
import { composer } from "../composer/composer.js";
/**
* createSettingsWindow({ sections, gitStatus, systemInfo }) -> { open, close }
*
* `sections`: [{ id, title, subtitle, keywords, icon,
* mount({ setFooter, close }) -> handle }].
* `mount()` returns a composer element handle (its own `destroy()` is the
* cleanup, called when another section is picked or the window closes).
* `setFooter`/`close` act on this window's own modal chrome -- a section
* does not know or care whether it is standing alone or hosted here.
*
* `gitStatus`/`systemInfo` are injected, not imported, for the same reason
* every other shell window takes its network calls as props: testable
* without a server, and unable to reach for one that was not handed to it.
*
* `current` lives HERE, per call, not at module scope: a module-level
* singleton would let a second independent settings window (there is only
* ever one in this app today, but nothing enforced that) silently redraw
* the FIRST one's modal instead of opening its own.
*/
export function createSettingsWindow({ sections = [], gitStatus, systemInfo } = {}) {
const all = [aboutSection(gitStatus, systemInfo), ...sections];
let current = null;
function open(id) {
if (current) { current.show(id); return current; }
let activeCleanup = null;
const contentStack = composer.region.stack({ gap: "sm", fill: true });
function showSection(pick) {
const spec = all.find((s) => s.id === pick) || all[0];
if (!spec) return;
if (activeCleanup) { activeCleanup.destroy?.(); activeCleanup = null; }
// Cleared before mounting: a section that sets no footer of its own
// (About) must not go on showing the PREVIOUS section's buttons.
modal.setFooter({});
const content = spec.mount({
setFooter: (f) => modal.setFooter(f),
close: () => modal.close("done"),
});
activeCleanup = content;
contentStack.set([
composer.header.md({ text: spec.title }),
spec.subtitle ? composer.hint.default({ text: spec.subtitle }) : null,
content,
]);
nav.setValue(spec.id);
}
const nav = composer.filterList.md({
// No hint line here -- an icon and a name, the way v4's own sidebar
// reads. What a section IS goes in its own head once picked, not
// repeated under every row whether picked or not.
items: all.map((s) => ({
id: s.id, label: s.title, icon: s.icon, keywords: s.keywords,
tone: s.tone || "neutral",
})),
value: id,
onChange: showSection,
placeholder: "Search settings",
});
// A whole pane, not the compact dropdown-style list this element is
// usually a small part of.
nav.node.classList.add("cx-filter-full");
contentStack.node.classList.add("cx-settings-content");
// A retractable rail, not a resizable split: v4's own sidebar is a 52px
// icon strip that expands to a full list on hover/focus and OVERLAYS the
// content rather than pushing it over -- .cx-settings-body/.cx-settings-rail
// do the positioning; splitPane's drag handle would be the wrong affordance
// for something that is not meant to stay open.
const body = composer.region.stack({ gap: "none", fill: true, children: [nav, contentStack] });
body.node.classList.add("cx-settings-body");
nav.node.classList.add("cx-settings-rail");
const modal = composer.modal.generic({
title: "Settings", size: "xl", body,
// Mirrors pipeline_window.js's own standalone modal: a half-finished
// edit (Models and pipeline's draft, most concretely) must not vanish
// because a click landed on the backdrop. This window has no way to
// ask "does the section on screen have unsaved work" per section, so
// it holds the door for all of them, the same conservative call
// pipeline_window.js already made for itself -- the X button and every
// section's own "Close" still work.
closeOnOutside: false,
onClose: () => {
if (activeCleanup) { activeCleanup.destroy?.(); activeCleanup = null; }
if (current === handle) current = null;
},
});
// A modal sizes to its content by default, which is right for a dialogue
// and wrong here: switching from About (a few centred rows) to Node packs
// (a long list) visibly grew and shrank the whole window every click.
// System Settings does not do that -- one fixed frame, and content that
// does not fit scrolls inside it, which .cx-modal-body already does.
modal.node.classList.add("cx-settings-modal");
const handle = { node: modal.node, close: () => modal.close(), show: showSection };
current = handle;
showSection(id || (all[0] && all[0].id));
return handle;
}
function close() {
if (current) current.close();
}
return { open, close };
}
const GB = (n) => (n == null ? null : `${n} GB`);
/**
* Version, branch, and the machine ComfyUI runs on -- not the browser's,
* which on a rental is a different box entirely and not the one worth
* describing. Two independent fetches (git status, host facts), each
* redrawing on its own arrival: one being slow or absent must not hold the
* other back, and a fact this Mac genuinely has (Version) should not sit
* behind a fact a CPU-only box never will (a GPU).
*/
function aboutSection(gitStatus, systemInfo) {
return {
id: "about", title: "About FunPack",
subtitle: "Version, branch, and the machine this runs on.",
keywords: "about version commit branch system cpu gpu memory disk python torch host hardware",
icon: "◉", tone: "neutral",
mount() {
const mark = composer.text.lg({ text: "◉" });
// xl, not lg: v4's own About name is 28px/bold (.cx-h-xl here), not
// 22px/semibold -- the difference between a title and a wordmark.
const name = composer.header.xl({ text: "FunPack" });
const codename = composer.text.sm({ text: "" });
const sub = composer.text.sm({ text: "" });
const facts = composer.region.stack({ gap: "md" });
const copyright = composer.hint.default({ text: "© 2025–2026 DigitalGarbage" });
const stack = composer.region.stack({ gap: "md" });
stack.node.classList.add("cx-about");
let git = null;
let sys = null;
const row = (label, value) => composer.settingsRow.default({
label, control: composer.text.sm({ text: value || "—" }) });
const group = (label, rows) => [composer.label.section({ text: label }),
composer.region.stack({ gap: "none", children: rows })];
function render() {
codename.node.hidden = !(git && git.codename);
if (git && git.codename) codename.setText(`“${git.codename}”`);
sub.setText("Cutting Room");
const identity = composer.region.stack({ gap: "none", children: [
row("Version", git && (git.version || "unknown")),
row("Commit", git && git.ok ? git.commit + (git.dirty ? " (local changes)" : "") : null),
row("Branch", git && git.ok ? git.branch : null),
] });
const groups = [];
if (sys) {
const cpu = sys.cpu || {};
const cores = cpu.cores && cpu.threads && cpu.cores !== cpu.threads
? `${cpu.cores}C/${cpu.threads}T` : (cpu.threads ? `${cpu.threads}C` : null);
const mem = sys.memory || {};
const gpus = sys.gpus || [];
groups.push(...group("Hardware", [
row("Chip", [cpu.name, cores].filter(Boolean).join(" · ") || cpu.arch),
row("Memory", mem.available_gb != null && mem.total_gb != null
? `${mem.available_gb} GB free of ${mem.total_gb} GB` : GB(mem.total_gb)),
...(gpus.length
? gpus.map((g, i) => row(gpus.length > 1 ? `Graphics ${i}` : "Graphics",
[g.name, GB(g.vram_gb), g.capability].filter(Boolean).join(" · ")))
: [row("Graphics", sys.mps ? "Apple GPU (MPS)" : "CPU only (no CUDA device)")]),
row("Storage", sys.disk && sys.disk.free_gb != null && sys.disk.total_gb != null
? `${sys.disk.free_gb} GB available of ${sys.disk.total_gb} GB` : GB(sys.disk && sys.disk.total_gb)),
]));
const t = sys.torch || {};
groups.push(...group("Software", [
row("System", sys.os),
row("ComfyUI", sys.comfyui),
row("Python", sys.python),
row("Torch", [t.version, t.cuda ? `CUDA ${t.cuda}` : null].filter(Boolean).join(" · ")),
...(t.attention ? [row("Attention", t.attention)] : []),
...(sys.host ? [row("Host", sys.host)] : []),
]));
}
facts.set([identity, ...groups]);
}
render();
stack.set([mark, name, codename, sub, facts, copyright]);
if (gitStatus) {
gitStatus().then((s) => { git = s; render(); }).catch(() => {
git = { ok: false }; render();
});
}
if (systemInfo) {
systemInfo().then((s) => { sys = s; render(); }).catch(() => {});
}
return stack;
},
};
}