Files
malkuthro-ComfyUI-Koolook/web/sidebar/modals.js
T

2222 lines
92 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.
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
// SPDX-License-Identifier: GPL-3.0-only
// =============================================================================
// Modal + context-menu DOM. The shell takes care of the Escape-key / overlay-
// click / Cancel / OK dismissal paths and ties the document-level keydown
// listener to the overlay's lifetime so opening many modals doesn't leak.
// =============================================================================
import { compareNames, criticalToast, toast } from "./constants.js";
import { formatLocalStamp } from "./format_time.js";
import { listDirectoryNames, dirOf } from "./workflows_store.js";
import {
detectManager,
loadMappings,
resolvePicksToInstall,
queueInstallGitUrl,
startQueue,
pollUntilDone,
reboot,
} from "./installer.js";
function makeModalShell({ title, body, actions }) {
const overlay = document.createElement("div");
overlay.className = "koolook-modal-overlay";
const modal = document.createElement("div");
modal.className = "koolook-modal";
const titleEl = document.createElement("div");
titleEl.className = "koolook-modal-title";
titleEl.textContent = title;
modal.appendChild(titleEl);
if (body) modal.appendChild(body);
const actionsEl = document.createElement("div");
actionsEl.className = "koolook-modal-actions";
for (const action of actions) actionsEl.appendChild(action);
modal.appendChild(actionsEl);
// Centralized close so every dismissal path (Escape, overlay click, Cancel
// button, OK button calling overlay.remove()) tears down the document-level
// keydown listener too. Without this, every modal opened leaks one listener.
let escHandler = null;
const close = () => {
if (escHandler) {
document.removeEventListener("keydown", escHandler);
escHandler = null;
}
if (overlay.parentNode) overlay.remove();
};
// Make overlay.remove() route through close so existing call sites that
// do `overlay.remove()` continue to work (and clean up the listener too).
const origRemove = overlay.remove.bind(overlay);
overlay.remove = () => {
if (escHandler) {
document.removeEventListener("keydown", escHandler);
escHandler = null;
}
origRemove();
};
overlay.appendChild(modal);
// Click-to-dismiss with drag-out-of-input protection. A single click
// fires `mousedown` then `mouseup` then `click` — and `click.target`
// is the deepest common ancestor of mousedown's and mouseup's targets.
// So if you drag-select text inside an input and release in the
// overlay's dark area, click fires with target=overlay and the modal
// would otherwise close mid-edit. Track whether the gesture STARTED
// on the overlay too; only dismiss if both ends did.
let mouseDownOnOverlay = false;
overlay.addEventListener("mousedown", (e) => {
mouseDownOnOverlay = (e.target === overlay);
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay && mouseDownOnOverlay) close();
mouseDownOnOverlay = false;
});
escHandler = (e) => { if (e.key === "Escape") close(); };
document.addEventListener("keydown", escHandler);
document.body.appendChild(overlay);
return { overlay, modal, close };
}
function makeModalButton({ label, primary, danger, onClick }) {
const btn = document.createElement("button");
btn.className = "koolook-modal-btn";
if (primary) btn.classList.add("koolook-modal-btn-primary");
if (danger) btn.classList.add("koolook-modal-btn-danger");
btn.textContent = label;
btn.addEventListener("click", onClick);
return btn;
}
// Tiny factory for the small-caps label rows above modal inputs/selects.
// Replaces the four-line `createElement` + `className` + `textContent` +
// `appendChild` block that appears 7× across the modal helpers below.
function modalLabel(text) {
const lbl = document.createElement("label");
lbl.className = "koolook-modal-label";
lbl.textContent = text;
return lbl;
}
export function showInputModal({ title, label, defaultValue, placeholder, confirmLabel, onSubmit, subtitle }) {
const body = document.createElement("div");
// Optional subtitle — string (rendered in the standard pathline style) or
// a pre-built HTMLElement (caller controls styling AND can mutate after
// the modal is open, e.g. to fill in an asynchronously-fetched library
// path). Sits between title and label so the user sees context first.
if (subtitle) {
if (typeof subtitle === "string") {
const sub = document.createElement("div");
sub.className = "koolook-modal-pathline";
sub.textContent = subtitle;
body.appendChild(sub);
} else {
body.appendChild(subtitle);
}
}
body.appendChild(modalLabel(label || "Name"));
const input = document.createElement("input");
input.className = "koolook-modal-input";
input.value = defaultValue || "";
input.placeholder = placeholder || "";
body.appendChild(input);
let overlay;
const submit = () => {
const v = input.value.trim();
if (!v) {
input.focus();
return;
}
overlay.remove();
onSubmit(v);
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit();
});
const cancel = makeModalButton({ label: "Cancel", onClick: () => overlay.remove() });
const ok = makeModalButton({ label: confirmLabel || "OK", primary: true, onClick: submit });
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
setTimeout(() => { input.focus(); input.select(); }, 0);
}
export function showConfirmModal({ title, message, confirmLabel, cancelLabel, danger, onConfirm, onCancel, subtitle }) {
const body = document.createElement("div");
// Optional subtitle — same contract as showInputModal / showThreeWayDialog.
// Used by the Save-as-new overwrite-confirm path to surface the library
// breadcrumb so the user keeps "where am I writing to" context across
// the multi-modal Save flow (input modal → existence check → confirm).
if (subtitle) {
if (typeof subtitle === "string") {
const sub = document.createElement("div");
sub.className = "koolook-modal-pathline";
sub.textContent = subtitle;
body.appendChild(sub);
} else {
body.appendChild(subtitle);
}
}
const msg = document.createElement("div");
msg.className = "koolook-modal-message";
msg.textContent = message;
body.appendChild(msg);
let overlay;
// `onCancel` lets callers that wrap this in a Promise actually settle
// when the user cancels — without it, the recovery toast's "Discard
// offline copy" Promise stayed pending forever (button stuck in
// "Discarding…"), and similarly `dropPlaceholdersForPacks` (issue
// #84 part 2) leaked a pending Promise on every cancel. Optional;
// existing callers that ignore cancel keep working unchanged.
const cancel = makeModalButton({
label: cancelLabel || "Cancel",
onClick: () => {
overlay.remove();
if (typeof onCancel === "function") onCancel();
},
});
const ok = makeModalButton({
label: confirmLabel || "OK",
primary: !danger,
danger,
onClick: () => { overlay.remove(); onConfirm(); },
});
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
}
export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, defaultModule = false, onSave }) {
const body = document.createElement("div");
// ---- Directory (cascading picker) ----
// Each cascade level is a <select> showing the immediate children at
// that depth. Sub-level selects start with a "(save in <path>)"
// sentinel so the user can stop drilling at any level. Subdirectories
// are created via right-click; "+ New directory…" at the top level
// creates a NEW top-level directory only.
//
// The leading double-underscores on cascade sentinels (`__new__`,
// `__here__`) are deliberate: real directory names cannot start with
// double-underscore in any current store, so the sentinel can never
// collide with a user-created directory's name when stored as a
// <select>'s `value`. The action values are bare lowercase strings
// because they're only ever compared against the action <select>'s
// own `value`, which we control.
const NEW_TOP = "__new__";
const SAVE_HERE = "__here__";
const ACTION_NEW = "new";
const ACTION_USE_EXISTING = "use_existing";
const ACTION_MODIFY_EXISTING = "modify_existing";
const topNames = listDirectoryNames([]);
body.appendChild(modalLabel("Directory"));
const cascadeContainer = document.createElement("div");
body.appendChild(cascadeContainer);
const newDirInput = document.createElement("input");
newDirInput.className = "koolook-modal-input";
newDirInput.placeholder = "New directory name";
newDirInput.style.marginTop = "6px";
newDirInput.style.display = "none";
body.appendChild(newDirInput);
// The cascade: cascadeSelects[0] is the top-level <select>, [1] is the
// immediate children of [0]'s value, etc. Each child select includes a
// "(save in <path>)" option so drilling can stop at any depth.
const cascadeSelects = [];
function buildTopSelect() {
const sel = document.createElement("select");
sel.className = "koolook-modal-select";
for (const name of topNames) {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
if (Array.isArray(defaultDir) && defaultDir[0] === name) opt.selected = true;
sel.appendChild(opt);
}
const newOpt = document.createElement("option");
newOpt.value = NEW_TOP;
newOpt.textContent = "+ New directory…";
if (topNames.length === 0) newOpt.selected = true;
sel.appendChild(newOpt);
sel.addEventListener("change", () => onCascadeChange(0));
return sel;
}
function buildChildSelect(level, parentPath) {
// Returns a <select> for the children of `parentPath`, or null if
// that directory has no children (in which case no further select
// is shown — save lands at parentPath).
const children = listDirectoryNames(parentPath);
if (children.length === 0) return null;
const sel = document.createElement("select");
sel.className = "koolook-modal-select";
sel.style.marginTop = "6px";
const hereOpt = document.createElement("option");
hereOpt.value = SAVE_HERE;
hereOpt.textContent = `(save in "${parentPath.join(" / ")}")`;
sel.appendChild(hereOpt);
let preselected = false;
for (const name of children) {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
if (Array.isArray(defaultDir) && defaultDir[level] === name) {
opt.selected = true;
preselected = true;
}
sel.appendChild(opt);
}
// If no descendant of defaultDir matched at this level, default to
// "(save here)" so the user doesn't fall accidentally deeper than
// they intended.
if (!preselected) sel.value = SAVE_HERE;
sel.addEventListener("change", () => onCascadeChange(level));
return sel;
}
function onCascadeChange(changedLevel) {
// Tear down every cascade level deeper than the one that changed.
while (cascadeSelects.length > changedLevel + 1) {
const old = cascadeSelects.pop();
old.remove();
}
const top = cascadeSelects[0];
if (top.value === NEW_TOP) {
newDirInput.style.display = "";
// Tear down child selects too — they don't apply to a brand-new
// top-level directory.
while (cascadeSelects.length > 1) {
const old = cascadeSelects.pop();
old.remove();
}
applyState();
return;
}
newDirInput.style.display = "none";
// Walk the kept selects to compute the current path. Stop at
// SAVE_HERE — anything below it has been torn down already.
const path = [top.value];
for (let i = 1; i < cascadeSelects.length; i += 1) {
const v = cascadeSelects[i].value;
if (v === SAVE_HERE) {
applyState();
return;
}
path.push(v);
}
// Try to extend the cascade one level deeper if children exist.
const deeper = buildChildSelect(path.length, path);
if (deeper) {
cascadeContainer.appendChild(deeper);
cascadeSelects.push(deeper);
// If the new level pre-selected a real child (not "save here"),
// recurse to build the level after it. This lets `defaultDir`
// navigate the cascade without user clicks.
if (deeper.value !== SAVE_HERE) {
onCascadeChange(cascadeSelects.length - 1);
return;
}
}
applyState();
}
function getSelectedPath() {
if (cascadeSelects.length === 0) return null;
const top = cascadeSelects[0].value;
if (top === NEW_TOP) {
const t = newDirInput.value.trim();
return t ? [t] : null;
}
const path = [top];
for (let i = 1; i < cascadeSelects.length; i += 1) {
const v = cascadeSelects[i].value;
if (v === SAVE_HERE) break;
path.push(v);
}
return path;
}
// Mount the top-level select and let onCascadeChange drive the rest.
const topSelect = buildTopSelect();
cascadeContainer.appendChild(topSelect);
cascadeSelects.push(topSelect);
// If no top-level dirs exist, the only option is "+ New directory…"
// and the new-dir input should already be visible.
if (topNames.length === 0) newDirInput.style.display = "";
// ---- Base on existing (only shown when the chosen directory has workflows) ----
const baseLbl = modalLabel("Base on existing");
body.appendChild(baseLbl);
const baseSelect = document.createElement("select");
baseSelect.className = "koolook-modal-select";
body.appendChild(baseSelect);
// ---- Action ----
const actionLbl = modalLabel("Action");
body.appendChild(actionLbl);
const actionSelect = document.createElement("select");
actionSelect.className = "koolook-modal-select";
[
{ value: ACTION_NEW, label: "New name" },
{ value: ACTION_USE_EXISTING, label: "Use existing name (archive previous)" },
{ value: ACTION_MODIFY_EXISTING, label: "Modify existing name" },
].forEach(a => {
const opt = document.createElement("option");
opt.value = a.value;
opt.textContent = a.label;
actionSelect.appendChild(opt);
});
body.appendChild(actionSelect);
// ---- Workflow name (only shown when needed) ----
const nameLbl = modalLabel("Workflow name");
body.appendChild(nameLbl);
const nameInput = document.createElement("input");
nameInput.className = "koolook-modal-input";
nameInput.value = defaultName || "";
nameInput.placeholder = "My workflow";
body.appendChild(nameInput);
// ---- "Save as module" checkbox ----
// Tagging the saved entry with the literal `module` tag flips the
// sidebar's left-click default from Load to Insert, and re-icons the
// row green. The actual `addTag` call lives in the caller's `onSave`
// handler so it can ride the same persistMutation snapshot as the
// entry write itself — a commit failure rolls back BOTH the save and
// the tag in one go. Visually, the checkbox sits below the name field
// (the last thing the user touches before clicking Save) so the
// intent decision is co-located with the destination decision.
const moduleRow = document.createElement("label");
moduleRow.className = "koolook-modal-checkbox-row";
const moduleCheckbox = document.createElement("input");
moduleCheckbox.type = "checkbox";
moduleCheckbox.checked = !!defaultModule;
moduleRow.appendChild(moduleCheckbox);
const moduleText = document.createElement("span");
moduleText.textContent = "Save as module (left-click inserts into canvas instead of replacing)";
moduleRow.appendChild(moduleText);
body.appendChild(moduleRow);
// ---- Wiring ----
// Candidates for "Base on existing": collect every active workflow at
// the destination dir AND walk up its ancestors. So if the user picks
// an empty subdir like "UP-scale / seedvr2", the parent's workflows
// remain available to base on (instead of "Use existing name" /
// "Modify existing name" being disabled because the leaf dir is
// empty). De-duplication is by name, deepest-first wins (closest to
// the destination is the most relevant if names collide).
//
// The `value` of each option stays the bare name (the existing submit
// path uses `baseSelect.value` directly to populate the workflow
// name). `textContent` adds a " · in <path>" suffix when the
// candidate is from an ancestor, so the user knows which directory
// they're basing on.
function getCandidatesForBase() {
const path = getSelectedPath();
if (!path || path.length === 0) return [];
// For "+ New directory…" the destination doesn't exist yet — no
// base candidates. (Ancestors don't apply: the new dir is at root.)
if (cascadeSelects[0].value === NEW_TOP) return [];
const seen = new Set();
const out = [];
for (let i = path.length; i >= 1; i -= 1) {
const ancestorPath = path.slice(0, i);
const dir = dirOf(ancestorPath);
if (!dir || !dir.workflows) continue;
const names = Object.keys(dir.workflows)
.filter(n => !dir.workflows[n].archived)
.sort(compareNames);
for (const name of names) {
if (seen.has(name)) continue;
seen.add(name);
out.push({ name, fromPath: ancestorPath, isCurrent: i === path.length });
}
}
return out;
}
function rebuildBaseOptions(candidates) {
const previous = baseSelect.value;
baseSelect.innerHTML = "";
for (const c of candidates) {
const opt = document.createElement("option");
opt.value = c.name;
opt.textContent = c.isCurrent
? c.name
: `${c.name} · in ${c.fromPath.join(" / ")}`;
baseSelect.appendChild(opt);
}
if (candidates.some(c => c.name === previous)) baseSelect.value = previous;
}
function applyState({ refocusName = false } = {}) {
// newDirInput visibility is managed by onCascadeChange — it's tied
// to whether the top select is on "+ New directory…", not to
// anything applyState recomputes here.
const dirIsNew = cascadeSelects[0]?.value === NEW_TOP;
const candidates = dirIsNew ? [] : getCandidatesForBase();
const hasBase = candidates.length > 0;
// Base on existing — visible whenever the destination dir OR any of
// its ancestors has active workflows. (Empty leaf dirs no longer
// disable the existing-name actions when the parent has things to
// base on.)
if (hasBase) {
baseLbl.style.display = "";
baseSelect.style.display = "";
rebuildBaseOptions(candidates);
} else {
baseLbl.style.display = "none";
baseSelect.style.display = "none";
}
// Action — hide the whole dropdown when there's nothing to base on.
// "Use existing" and "Modify existing" are meaningless without a
// base, and disabled <option> elements render subtly across browsers
// (the user just sees a "New name" they can't change). With no base,
// the only useful action is a fresh save by name — the Workflow Name
// field below already covers that case, so the dropdown is just
// noise. With a base, restore the full action picker.
if (hasBase) {
actionLbl.style.display = "";
actionSelect.style.display = "";
for (const opt of actionSelect.options) {
if (opt.value === ACTION_USE_EXISTING || opt.value === ACTION_MODIFY_EXISTING) {
opt.disabled = false;
}
}
} else {
actionLbl.style.display = "none";
actionSelect.style.display = "none";
// Pin the underlying value to "new" so submit takes the Workflow
// Name path.
if (actionSelect.value !== ACTION_NEW) actionSelect.value = ACTION_NEW;
}
// Workflow name — visible only when needed.
const action = actionSelect.value;
if (action === ACTION_USE_EXISTING) {
// Name comes from the base; field is irrelevant.
nameLbl.style.display = "none";
nameInput.style.display = "none";
} else {
nameLbl.style.display = "";
nameInput.style.display = "";
if (action === ACTION_MODIFY_EXISTING && baseSelect.value) {
nameInput.value = baseSelect.value;
nameInput.readOnly = false;
if (refocusName) {
setTimeout(() => {
nameInput.focus();
const len = nameInput.value.length;
nameInput.setSelectionRange(len, len);
}, 0);
}
} else {
// "new" — leave the value alone so the user's typing isn't clobbered.
nameInput.readOnly = false;
}
}
}
actionSelect.addEventListener("change", () => applyState({ refocusName: true }));
baseSelect.addEventListener("change", () => {
if (actionSelect.value === ACTION_MODIFY_EXISTING) {
nameInput.value = baseSelect.value;
}
});
// Drive the initial cascade — onCascadeChange recurses to honor
// `defaultDir` if any deeper levels need pre-selection. Then
// applyState() runs to compute the rest of the modal.
onCascadeChange(0);
applyState();
if (cascadeSelects[0].value === NEW_TOP) newDirInput.focus();
let overlay;
const submit = async () => {
// `dir` is a path array drawn from the cascade. "+ New directory…"
// yields a new top-level (single-segment) path once the user types
// a name; existing-dir selections yield the deepest path they
// drilled to before stopping at "(save in <path>)".
const dirPath = getSelectedPath();
if (!dirPath || dirPath.length === 0) {
// Top is "__new__" with empty input — prompt for a name.
if (cascadeSelects[0].value === NEW_TOP) newDirInput.focus();
return;
}
const action = actionSelect.value;
let name;
if (action === ACTION_USE_EXISTING) {
name = (baseSelect.value || "").trim();
if (!name) { actionSelect.value = ACTION_NEW; applyState(); nameInput.focus(); return; }
} else {
name = nameInput.value.trim();
if (!name) { nameInput.focus(); return; }
}
overlay.remove();
// `dirPath` is a string[] of segment names (`["UP-scale", "Type-A"]`).
// Renamed from the historical `dir` field on this contract so callers
// match the codebase-wide convention (`dirPath` for arrays of
// segments, `dirName` for single-segment strings, `dir` for resolved
// DirNode objects).
// `asModule` is the "Save as module" checkbox state. Caller is
// expected to fold an `addTag(..., MODULE_TAG)` into the same
// persistMutation as the save when truthy, so a commit failure
// rolls back both the entry write and the tag together.
await onSave({ name, dirPath, asModule: moduleCheckbox.checked });
};
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
newDirInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
const cancel = makeModalButton({ label: "Cancel", onClick: () => overlay.remove() });
const ok = makeModalButton({ label: "Save", primary: true, onClick: submit });
({ overlay } = makeModalShell({
title: titleSuffix ? `Save workflow — ${titleSuffix}` : "Save workflow",
body,
actions: [cancel, ok],
}));
setTimeout(() => {
if (nameInput.style.display !== "none") {
nameInput.focus();
nameInput.select();
}
}, 0);
}
// =============================================================================
// Tags modal — adds/removes tags one at a time, each via its own
// persistMutation (passed in by the caller). The modal itself stays open until
// the user clicks Close, so several edits can be made in a row. Chip rendering
// reads from `getCurrentTags` after every successful mutation, so the live
// state matches what the caller persisted.
//
// `getCurrentTags()` returns `null` when the underlying workflow no longer
// exists (deleted/moved/renamed by a concurrent action). We surface this as
// an explicit "gone" state — empty placeholder + disabled add input — so the
// user doesn't keep typing tags into a modal that can't persist them.
// =============================================================================
export function showTagsModal({ wfName, getCurrentTags, onAddTag, onRemoveTag }) {
const body = document.createElement("div");
body.appendChild(modalLabel("Current tags"));
const chipsContainer = document.createElement("div");
chipsContainer.className = "koolook-tags-chips";
body.appendChild(chipsContainer);
let input;
let addBtn;
function renderChips() {
chipsContainer.innerHTML = "";
const tags = getCurrentTags();
if (tags === null) {
// Workflow disappeared between menu-open and now. Stay open so
// the user sees the explicit reason instead of a generic "no
// tags" placeholder, and disable the add path so a second click
// can't fire a doomed persistMutation.
const empty = document.createElement("span");
empty.className = "koolook-tags-empty";
empty.textContent = "Workflow no longer exists.";
chipsContainer.appendChild(empty);
if (input) {
input.disabled = true;
input.placeholder = "(workflow gone)";
}
if (addBtn) addBtn.disabled = true;
return;
}
if (tags.length === 0) {
const empty = document.createElement("span");
empty.className = "koolook-tags-empty";
empty.textContent = "No tags yet.";
chipsContainer.appendChild(empty);
return;
}
for (const tag of tags) {
const chip = document.createElement("span");
chip.className = "koolook-tag-chip";
const name = document.createElement("span");
name.textContent = tag;
chip.appendChild(name);
const x = document.createElement("span");
x.className = "koolook-tag-chip-x";
x.textContent = "×";
x.title = `Remove "${tag}"`;
x.addEventListener("click", () => {
onRemoveTag(tag, () => renderChips());
});
chip.appendChild(x);
chipsContainer.appendChild(chip);
}
}
body.appendChild(modalLabel("Add tag"));
const addRow = document.createElement("div");
addRow.className = "koolook-tag-add-row";
body.appendChild(addRow);
input = document.createElement("input");
input.className = "koolook-modal-input";
input.placeholder = "tag name";
addRow.appendChild(input);
const submit = () => {
if (input.disabled) return;
const v = input.value.trim();
if (!v) { input.focus(); return; }
onAddTag(v, () => {
input.value = "";
renderChips();
input.focus();
});
};
input.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
addBtn = makeModalButton({ label: "Add", primary: true, onClick: submit });
addRow.appendChild(addBtn);
// Now that input + addBtn exist, run the initial render so the
// gone-state branch can disable them if the workflow is already missing.
renderChips();
let overlay;
const close = makeModalButton({ label: "Close", onClick: () => overlay.remove() });
({ overlay } = makeModalShell({
title: `Tags for "${wfName}"`,
body,
actions: [close],
}));
setTimeout(() => { if (!input.disabled) input.focus(); }, 0);
}
// =============================================================================
// Install missing nodes modal — three phases driven by ComfyUI-Manager's HTTP
// API.
//
// 1. Discovery — probe Manager, fetch the node-id → git-url mapping,
// bucket picks into already-installed / will-install /
// unresolved.
// 2. Confirm — show the counts + per-bucket detail; user clicks Install
// or Copy URL list.
// 3. Progress — POST /customnode/install/git_url for each unique URL,
// POST /manager/queue/start, then poll /manager/queue/status
// while updating a progress bar.
// 4. Result — summarize successes/failures and offer Reboot.
//
// We don't render the unresolved list as a hard-fail block — installs can
// still proceed for the resolved subset, and the unresolved IDs surface
// inside a collapsible <details> so the user can act on them separately.
// =============================================================================
export function showInstallMissingModal({ picks }) {
const body = document.createElement("div");
const message = document.createElement("div");
message.className = "koolook-modal-message";
message.textContent = "Checking ComfyUI-Manager…";
body.appendChild(message);
const stats = document.createElement("div");
body.appendChild(stats);
const progress = document.createElement("div");
progress.className = "koolook-install-progress";
progress.style.display = "none";
const progressBar = document.createElement("div");
progressBar.className = "koolook-install-progress-bar";
progress.appendChild(progressBar);
body.appendChild(progress);
// Track the polling abort so the Cancel button (or modal dismiss) stops
// pumping `pollUntilDone` even if installs are still chugging on the
// server side. We can't *cancel* an install — Manager doesn't expose a
// per-task cancel — so this just disengages our UI loop, not the work.
const pollAbort = { aborted: false };
let overlay;
const closeBtn = makeModalButton({
label: "Cancel",
onClick: () => { pollAbort.aborted = true; overlay.remove(); },
});
const copyBtn = makeModalButton({ label: "Copy URL list", onClick: () => {} });
copyBtn.style.display = "none";
const installBtn = makeModalButton({ label: "Install via Manager", primary: true, onClick: () => {} });
installBtn.style.display = "none";
const rebootBtn = makeModalButton({ label: "Reboot now", primary: true, onClick: () => {} });
rebootBtn.style.display = "none";
({ overlay } = makeModalShell({
title: "Install missing nodes",
body,
actions: [closeBtn, copyBtn, installBtn, rebootBtn],
}));
function setStatLines(lines) {
stats.innerHTML = "";
for (const line of lines) {
const div = document.createElement("div");
div.className = "koolook-install-stat-line";
if (line.fail) div.classList.add("koolook-install-stat-fail");
div.textContent = line.text;
stats.appendChild(div);
}
}
function appendUnresolvedDetail(unresolvedIds) {
if (unresolvedIds.length === 0) return;
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.className = "koolook-install-unresolved-summary";
summary.textContent = `Show unresolved (${unresolvedIds.length})`;
details.appendChild(summary);
const list = document.createElement("div");
list.className = "koolook-install-unresolved";
list.textContent = unresolvedIds.join(", ");
details.appendChild(list);
stats.appendChild(details);
}
// ---- Phase 1: Discovery ----
(async () => {
const managerOk = await detectManager();
if (!managerOk) {
message.textContent = "ComfyUI-Manager isn't reachable. Install it via the official ComfyUI installer, or run `comfy node install <pack>` from the CLI for each missing pack.";
return;
}
let mappings;
try {
mappings = await loadMappings();
} catch (e) {
console.warn("[Koolook] loadMappings failed:", e);
message.textContent = `Could not load Manager's mapping database (${e.message}). In Manager → click "Update DB" or restart ComfyUI, then retry.`;
return;
}
const result = resolvePicksToInstall(picks, mappings.urlByNodeId);
const urlCount = result.willInstall.byUrl.size;
const willInstallNodeCount = [...result.willInstall.byUrl.values()]
.reduce((acc, ids) => acc + ids.length, 0);
message.textContent = "";
const lines = [
{ text: `Already installed: ${result.alreadyInstalled.length} pick${result.alreadyInstalled.length === 1 ? "" : "s"}` },
{ text: `Will install: ${urlCount} pack${urlCount === 1 ? "" : "s"} (${willInstallNodeCount} pick${willInstallNodeCount === 1 ? "" : "s"})` },
];
if (result.unresolved.length > 0) {
lines.push({ text: `Unresolved (no mapping found): ${result.unresolved.length}` });
}
setStatLines(lines);
appendUnresolvedDetail(result.unresolved);
if (urlCount === 0) {
message.textContent = result.alreadyInstalled.length === picks.length && picks.length > 0
? "Every pick is already installed."
: "Nothing to install.";
closeBtn.textContent = "Close";
return;
}
// ---- Phase 2: Confirm ----
const urls = [...result.willInstall.byUrl.keys()];
copyBtn.style.display = "";
installBtn.style.display = "";
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(urls.join("\n") + "\n");
toast(`Copied ${urls.length} git URL${urls.length === 1 ? "" : "s"} to clipboard.`);
} catch (e) {
console.warn("[Koolook] clipboard write failed:", e);
toast("Clipboard write failed — see console for the URL list.");
console.log("[Koolook] git URLs:\n" + urls.join("\n"));
}
});
installBtn.addEventListener("click", async () => {
// Lock buttons so a double-click can't double-queue.
installBtn.disabled = true;
copyBtn.disabled = true;
// ---- Phase 3: Progress ----
progress.style.display = "";
stats.style.display = "none";
message.textContent = "Queueing installs…";
const queueResults = [];
for (const url of urls) {
if (pollAbort.aborted) break;
const r = await queueInstallGitUrl(url);
queueResults.push({ url, ...r });
}
const queuedOk = queueResults.filter(r => r.ok).length;
if (queuedOk === 0) {
progress.style.display = "none";
stats.style.display = "";
message.textContent = "No installs were accepted.";
setStatLines(queueResults.map(r => ({
text: `${r.url} — ${r.message}`,
fail: true,
})));
closeBtn.textContent = "Close";
installBtn.style.display = "none";
copyBtn.style.display = "none";
return;
}
await startQueue();
const finalStatus = await pollUntilDone({
onTick: (s) => {
const pct = s.total_count > 0 ? (s.done_count / s.total_count) * 100 : 0;
progressBar.style.width = `${pct}%`;
message.textContent = `Installing… ${s.done_count} of ${s.total_count}`;
},
signal: pollAbort,
});
// ---- Phase 4: Result ----
progress.style.display = "none";
stats.style.display = "";
installBtn.style.display = "none";
copyBtn.style.display = "none";
const resultLines = [];
if (finalStatus) {
resultLines.push({
text: `Installed ${finalStatus.done_count} of ${finalStatus.total_count} pack${finalStatus.total_count === 1 ? "" : "s"}.`,
});
} else {
resultLines.push({ text: "Stopped polling — installs may still complete in the background." });
}
for (const r of queueResults.filter(r => !r.ok)) {
resultLines.push({ text: `Failed to queue: ${r.url} — ${r.message}`, fail: true });
}
if (queuedOk > 0) {
resultLines.push({ text: "Restart ComfyUI to load the newly installed nodes." });
}
message.textContent = "";
setStatLines(resultLines);
appendUnresolvedDetail(result.unresolved);
closeBtn.textContent = "Close";
if (queuedOk > 0) {
rebootBtn.style.display = "";
rebootBtn.addEventListener("click", async () => {
rebootBtn.disabled = true;
await reboot();
overlay.remove();
toast("Reboot requested. Reload the page in a few seconds.");
});
}
});
})();
}
// =============================================================================
// Snapshot / preset library — three separate dialogs (Save / Load / Settings),
// one per toolbar button. No tabs, no shared state across them.
//
// Capability functions are passed in by the caller instead of imported here,
// so this file stays UI-only and the snapshot logic stays in its own module
// (`./snapshot.js`).
// =============================================================================
// Local helper: today's date in YYYY-MM-DD form for the default name.
function todayStamp() {
return new Date().toISOString().slice(0, 10);
}
// Local helper: render a preset's metadata line (used by the Load dialog
// for both regular snapshot rows AND recovery auto-save rows). Prefers
// `mtime` (Unix epoch seconds from the server-side stat) over the
// snapshot's self-reported `exportedAt` because (1) mtime survives
// out-of-band file copies in Finder while exportedAt is frozen at
// gather-time, and (2) "saved <local time>" is what the user actually
// wants to see for "which row is newest". Falls back to `exportedAt`
// for legacy rows whose listing endpoint didn't carry mtime.
function formatPreviewMeta(p) {
const parts = [];
parts.push(`${p.workflowCount} workflow${p.workflowCount === 1 ? "" : "s"}`);
parts.push(`${p.pickCount} pick${p.pickCount === 1 ? "" : "s"}`);
if (typeof p.mtime === "number" && isFinite(p.mtime)) {
const stamp = formatLocalStamp(new Date(p.mtime * 1000));
if (stamp) parts.push(`saved ${stamp}`);
} else if (p.exportedAt) {
const stamp = formatLocalStamp(new Date(p.exportedAt));
if (stamp) parts.push(`exported ${stamp}`);
}
return parts.join(" · ");
}
function pathLeaf(path) {
const parts = String(path || "").split(/[\\/]+/).filter(Boolean);
return parts.length ? parts[parts.length - 1] : String(path || "");
}
function renderLibraryLocation(el, path, { label = "Library folder", title } = {}) {
el.innerHTML = "";
const folder = document.createElement("div");
folder.className = "koolook-settings-folder-name";
// `title` override skips the `<label>: <leaf>` formatting so callers
// (autosave group headers) can render just the subdir name without
// the "Library folder:" prefix while still reusing the same CSS
// pair (`folder-name` + `folder-path`) for visual consistency.
folder.textContent = title ?? `${label}: ${path ? pathLeaf(path) : "(unavailable)"}`;
el.appendChild(folder);
const full = document.createElement("div");
full.className = "koolook-settings-folder-path";
full.textContent = path || "Path unavailable";
el.appendChild(full);
el.title = path || "";
}
// Local helper: a 3-button confirm modal (Save / Save as new / Cancel) for
// the Save flow when there's a current preset to overwrite. `showConfirmModal`
// is 2-button only; we'd need to bend it. Cleaner to assemble directly with
// `makeModalShell`.
function showThreeWayDialog({ title, message, primaryLabel, secondaryLabel, cancelLabel, onPrimary, onSecondary, subtitle }) {
const body = document.createElement("div");
// Optional subtitle — same contract as showInputModal. Sits above the
// message so library path / context is visible before the user scans
// the action question.
if (subtitle) {
if (typeof subtitle === "string") {
const sub = document.createElement("div");
sub.className = "koolook-modal-pathline";
sub.textContent = subtitle;
body.appendChild(sub);
} else {
body.appendChild(subtitle);
}
}
const msg = document.createElement("div");
msg.className = "koolook-modal-message";
msg.textContent = message;
body.appendChild(msg);
let overlay;
const close = () => overlay.remove();
const cancel = makeModalButton({
label: cancelLabel || "Cancel",
onClick: close,
});
const secondary = makeModalButton({
label: secondaryLabel,
onClick: () => { close(); onSecondary(); },
});
const primary = makeModalButton({
label: primaryLabel,
primary: true,
onClick: () => { close(); onPrimary(); },
});
({ overlay } = makeModalShell({
title,
body,
actions: [cancel, secondary, primary],
}));
}
// =============================================================================
// Save flow.
//
// One click semantics:
// - If a current preset is tracked → confirm "Save over <name>?" with
// three options (overwrite / rename / cancel).
// - If not → straight to a name prompt with `preset YYYY-MM-DD` default.
//
// Save success → updates the tracker so subsequent Save clicks default
// to overwrite-the-same-one.
// =============================================================================
export function showSaveSnapshotDialog({
getCurrentPresetName,
setCurrentPresetName,
presetExists,
writePreset,
gatherSnapshot,
sanitizeName,
getLibraryInfo,
markStateSaved,
onToast,
}) {
const toast = onToast || (() => {});
// Build a reactive library-path subtitle element. Both the input modal
// (Save as new…) and the three-way overwrite dialog reuse the same node
// so the user sees consistent context regardless of which path they
// entered the Save flow through. Async-populated to avoid blocking the
// modal open on a server round-trip — the user sees "(loading…)" briefly,
// then the resolved path.
function buildPathSubtitle() {
const el = document.createElement("div");
el.className = "koolook-modal-pathline";
el.textContent = "Library: (loading…)";
if (typeof getLibraryInfo === "function") {
getLibraryInfo().then((info) => {
if (info && typeof info.path === "string" && info.path) {
el.className = "koolook-load-library-location";
renderLibraryLocation(el, info.path, { label: "Library folder" });
} else {
el.textContent = "Library path unavailable.";
}
}).catch(() => {
el.textContent = "Library path unavailable.";
});
} else {
el.textContent = "";
}
return el;
}
async function doSave(name, { confirmOverwrite }) {
const sanitized = sanitizeName(name);
if (!sanitized) {
toast("Snapshot name is empty after stripping unsafe characters.");
return;
}
// For Save-as-new only: check existence + prompt to overwrite.
// The "overwrite current" path skipped this prompt by design
// (the user already confirmed via the three-way dialog).
//
// `presetExists` is now tri-state: true / false / null. The
// null case means the existence check itself failed (network
// down, server error) — refuse to silently write, since we
// can't tell whether we'd be clobbering an existing preset.
if (confirmOverwrite) {
const exists = await presetExists(sanitized);
if (exists === null) {
toast(
"Cannot reach the preset library to verify name. " +
"Save canceled — check Settings or your connection."
);
return;
}
if (exists === true) {
showConfirmModal({
title: "Overwrite existing preset?",
message: `A snapshot named "${sanitized}" already exists. Overwrite it?`,
confirmLabel: "Overwrite",
danger: true,
// Carry the library breadcrumb across the input → exists →
// confirm chain so the user knows which library they're
// overwriting in (matters when Settings → libraryPath has
// been changed mid-session, or facility users juggle
// multiple shared libraries).
subtitle: buildPathSubtitle(),
onConfirm: () => writeAndToast(sanitized),
});
return;
}
}
writeAndToast(sanitized);
}
async function writeAndToast(name) {
try {
const snap = gatherSnapshot(name);
await writePreset(name, snap);
setCurrentPresetName(name);
// Baseline the saved-state fingerprint — flips the sidebar
// status indicator from "unsaved" to "saved" without waiting
// for an unrelated mutation event to refresh it.
if (typeof markStateSaved === "function") markStateSaved();
toast(`Saved "${name}".`);
} catch (e) {
console.error("[Koolook] preset save failed:", e);
toast(`Could not save preset: ${e.message}`);
}
}
function promptForName(defaultName) {
showInputModal({
title: "Save snapshot",
label: "Snapshot name",
defaultValue: defaultName,
placeholder: "e.g. Wan video kit",
confirmLabel: "Save",
subtitle: buildPathSubtitle(),
onSubmit: (typed) => doSave(typed, { confirmOverwrite: true }),
});
}
const current = getCurrentPresetName();
if (current) {
showThreeWayDialog({
title: "Save snapshot",
message: `Save current state over "${current}"?`,
primaryLabel: "Save",
secondaryLabel: "Save as new…",
cancelLabel: "Cancel",
subtitle: buildPathSubtitle(),
onPrimary: () => doSave(current, { confirmOverwrite: false }),
onSecondary: () => promptForName(`${current} (copy)`),
});
} else {
promptForName(`preset ${todayStamp()}`);
}
}
// =============================================================================
// Load flow.
//
// Single dialog with the preset list. Each row click loads (with confirm),
// each row's × button deletes (with confirm). Top of the dialog shows the
// current library path so the user can see where they're loading from.
//
// Load success → updates the tracker so a subsequent Save defaults to
// overwriting that preset.
// =============================================================================
export function showLoadSnapshotDialog({
listPresets,
readPreset,
deletePreset,
applySnapshot,
setCurrentPresetName,
getCurrentPresetName,
getLibraryInfo,
writePreLoadAutosave,
markStateSaved,
markStateAutosaved,
listAutosaves,
revealPresetFolder,
onToast,
}) {
const toast = onToast || (() => {});
const body = document.createElement("div");
// Captured from `getLibraryInfo()` resolution below — the recovery
// section uses it to render full subdir paths (`<library>/<subdir>`)
// under each group header, matching the library section's title-plus-
// path layout. Empty string is the not-yet-resolved sentinel; group
// headers fall back to no subtitle in that case.
let libraryPath = "";
// Header row: library title + path (rendered by `renderLibraryLocation`
// for visual parity with the recovery group headers below) on the
// left, `📂 Open` button on the right. Same flex layout (`flex-start`
// alignment + `space-between` justification) as group headers so both
// sections feel like one consistent UI.
const pathRow = document.createElement("div");
pathRow.className = "koolook-load-library-row";
pathRow.style.display = "flex";
pathRow.style.alignItems = "flex-start";
pathRow.style.justifyContent = "space-between";
pathRow.style.gap = "8px";
const pathLine = document.createElement("div");
pathLine.className = "koolook-load-library-location";
pathLine.style.flex = "1";
pathLine.style.minWidth = "0";
pathLine.textContent = "Library: (loading…)";
pathRow.appendChild(pathLine);
if (typeof revealPresetFolder === "function") {
const openLibraryBtn = document.createElement("button");
// `koolook-snapshot-row-btn` matches the size + styling of the
// group-header Open button in the recovery section, so both
// surfaces look like the same control instead of one large
// (`koolook-modal-button`) and one small (row-btn) variant.
openLibraryBtn.className = "koolook-snapshot-row-btn";
openLibraryBtn.textContent = "📂 Open";
openLibraryBtn.title = "Open the snapshot library folder in your file manager";
openLibraryBtn.style.flex = "0 0 auto";
openLibraryBtn.addEventListener("click", async () => {
try {
const r = await revealPresetFolder();
toast(`Opened: ${r.path}`);
} catch (e) {
console.error("[Koolook] reveal failed:", e);
toast(`Could not open library folder: ${e.message}`);
}
});
pathRow.appendChild(openLibraryBtn);
}
body.appendChild(pathRow);
const listWrap = document.createElement("div");
body.appendChild(listWrap);
let overlay;
const close = () => overlay.remove();
function renderEmpty(text) {
const el = document.createElement("div");
el.className = "koolook-snapshot-empty";
el.textContent = text;
return el;
}
async function refresh() {
listWrap.innerHTML = "";
listWrap.appendChild(renderEmpty("Loading…"));
let previews;
try { previews = await listPresets(); }
catch (e) { previews = []; }
listWrap.innerHTML = "";
if (previews.length === 0) {
listWrap.appendChild(renderEmpty("No presets in this library yet. Use Save to create one."));
return;
}
const list = document.createElement("div");
list.className = "koolook-snapshot-list";
for (const p of previews) {
const row = document.createElement("div");
row.className = "koolook-snapshot-row";
const info = document.createElement("div");
info.className = "koolook-snapshot-row-info";
info.title = `Click to load "${p.displayName}"`;
const name = document.createElement("div");
name.className = "koolook-snapshot-row-name";
name.textContent = p.displayName;
info.appendChild(name);
const meta = document.createElement("div");
meta.className = "koolook-snapshot-row-meta";
meta.textContent = formatPreviewMeta(p);
info.appendChild(meta);
info.addEventListener("click", () => promptApply(p));
row.appendChild(info);
const actions = document.createElement("div");
actions.className = "koolook-snapshot-row-actions";
const delBtn = document.createElement("button");
delBtn.className = "koolook-snapshot-row-btn koolook-snapshot-row-btn-danger";
delBtn.textContent = "×";
delBtn.title = `Delete "${p.displayName}"`;
delBtn.addEventListener("click", (e) => { e.stopPropagation(); promptDelete(p); });
actions.appendChild(delBtn);
row.appendChild(actions);
list.appendChild(row);
}
listWrap.appendChild(list);
}
// Action helpers. Extracted from the in-line `onConfirm` bodies so
// both the standard load flow AND the autosave-newer YES/NO choice
// modal can dispatch the same pre-load-autosave + applySnapshot +
// tracker-rebase + partial-failure logic without duplicating ~30
// lines per call site. `close()` here refers to the OUTER Load
// Snapshot dialog's overlay — closures over `close` from the
// enclosing `showLoadSnapshotDialog` scope.
async function doNamedLoad(preview) {
try {
// Defensive auto-save BEFORE any destructive write — see
// `writePreLoadAutosave` rationale in snapshot.js. If the
// backup fails, abort the Load entirely; landing in a
// "Load destroyed my state with no backup" world is
// exactly what this whole code path exists to prevent.
let backupName = null;
if (typeof writePreLoadAutosave === "function") {
try {
backupName = await writePreLoadAutosave(preview.displayName);
} catch (e) {
console.error("[Koolook] pre-load auto-save failed:", e);
close();
criticalToast(
`Could not write pre-load auto-save: ${e.message}. ` +
`Load aborted to protect your current state. Fix ` +
`the library access issue (Settings → Library path) ` +
`and retry.`
);
return;
}
}
const snap = await readPreset(preview.fileName);
const { picksOk, workflowsOk } = await applySnapshot(snap);
close();
// Gate the tracker on FULL success only. A partial-apply
// (one of picks / workflows persisted, the other didn't)
// leaves on-disk and in-memory state inconsistent — if
// we tracked the preset name anyway, the next Save would
// default to "Save over '<name>'?" and overwrite the
// saved preset with the corrupted half-state. Clear the
// tracker on partial failure so the next Save forces a
// fresh name prompt.
if (picksOk && workflowsOk) {
setCurrentPresetName(preview.fileName);
// Baseline the saved-state fingerprint — current
// state IS the loaded preset, so the indicator
// should read "saved" until the next mutation.
if (typeof markStateSaved === "function") markStateSaved();
const backupSuffix = backupName ? ` (backup: ${backupName})` : "";
toast(`Loaded preset "${preview.displayName}"${backupSuffix}.`);
} else if (picksOk || workflowsOk) {
setCurrentPresetName(null);
toast(
`Loaded "${preview.displayName}" — PARTIAL: ` +
`picks ${picksOk ? "OK" : "FAIL"}, ` +
`workflows ${workflowsOk ? "OK" : "FAIL"}. ` +
`Reload to recover prior state. Tracker cleared.`
);
} else {
setCurrentPresetName(null);
toast(`Loaded "${preview.displayName}" in memory but persist failed. Reload to recover.`);
}
} catch (e) {
console.error("[Koolook] preset load failed:", e);
toast(`Could not load "${preview.displayName}": ${e.message}`);
}
}
async function doAutosaveRestore(item) {
const tooltipName = `${item.dir}/${item.fileName}`;
try {
let backupName = null;
if (typeof writePreLoadAutosave === "function") {
try {
backupName = await writePreLoadAutosave(`Pre-recovery (${tooltipName})`);
} catch (e) {
console.error("[Koolook] pre-recovery autosave failed:", e);
close();
criticalToast(
`Could not write pre-recovery auto-save: ${e.message}. ` +
`Restore aborted to protect your current state.`
);
return;
}
}
const snap = await readPreset(item.fileName, { dir: item.dir });
const { picksOk, workflowsOk } = await applySnapshot(snap);
close();
// Derive the original named preset from the subfolder so
// the post-restore tracker reads naturally — `Foo_autosave`
// → "Foo", `_unsaved_autosave` → null (no tracked preset).
let restoredName = null;
if (item.dir !== "_unsaved_autosave" && item.dir.endsWith("_autosave")) {
restoredName = item.dir.slice(0, -"_autosave".length);
}
if (picksOk && workflowsOk) {
setCurrentPresetName(restoredName);
// Baseline as "auto-saved", NOT "saved". The named file
// on disk is still the OLDER deliberate save — only
// periodic.json matches what's now in memory. Calling
// `markStateSaved()` here would mis-claim the named save
// is up to date and blur the deliberate-save model
// (named saves only change on Save / Quick Save). The
// dot turns blue, prompting Quick Save when the user
// wants to commit the restored state to the named file.
if (typeof markStateAutosaved === "function") markStateAutosaved();
const backupSuffix = backupName ? ` (backup: ${backupName})` : "";
toast(
`Restored auto-save${restoredName ? ` of "${restoredName}"` : ""}` +
` — Quick Save to commit to the named file${backupSuffix}.`
);
} else if (picksOk || workflowsOk) {
setCurrentPresetName(null);
toast(
`Restored "${tooltipName}" — PARTIAL: ` +
`picks ${picksOk ? "OK" : "FAIL"}, ` +
`workflows ${workflowsOk ? "OK" : "FAIL"}.`
);
} else {
setCurrentPresetName(null);
toast(`Restored "${tooltipName}" in memory but persist failed.`);
}
} catch (e) {
console.error("[Koolook] autosave restore failed:", e);
toast(`Could not restore "${tooltipName}": ${e.message}`);
}
}
function promptApply(preview) {
// When the corresponding `<base>_autosave/periodic.json` is
// strictly newer than the named save, route to the YES/NO
// choice modal so the user can pick which version to load
// without navigating the Recovery disclosure or having to
// mentally reason about which timestamp is newer. The standard
// single-button confirm is reserved for rows where there's no
// newer auto-save (the unambiguous case).
if (typeof preview.latestAutosaveMtime === "number" &&
typeof preview.mtime === "number" &&
preview.latestAutosaveMtime > preview.mtime) {
promptApplyChoice(preview);
return;
}
showConfirmModal({
title: "Replace current state?",
message:
`Loading "${preview.displayName}" will replace your current ` +
`picks and workflows with the snapshot's contents (` +
`${formatPreviewMeta(preview)}). A pre-load auto-save of your ` +
`current state will be written first as a recovery point.`,
confirmLabel: "Load preset",
danger: true,
onConfirm: () => doNamedLoad(preview),
});
}
// YES/NO choice modal — appears when the row's auto-save is newer
// than its named save. Built directly via `makeModalShell` (not
// `showConfirmModal`) because we need ESC / overlay click to mean
// "cancel both" rather than "fire one of the actions": showConfirm
// would bind one of the two paths to its cancel handler, which
// also runs on Escape. Two affirmative buttons, one neutral
// dismissal — this layout requires the direct shell.
function promptApplyChoice(preview) {
const namedStamp = formatLocalStamp(new Date(preview.mtime * 1000));
const autoStamp = formatLocalStamp(new Date(preview.latestAutosaveMtime * 1000));
const body = document.createElement("div");
const intro = document.createElement("div");
intro.className = "koolook-modal-message";
intro.textContent =
`Loading "${preview.displayName}" will replace your current ` +
`picks and workflows. A pre-load auto-save of your current ` +
`state will be written first as a recovery point.`;
body.appendChild(intro);
const compareHead = document.createElement("div");
compareHead.className = "koolook-modal-message";
compareHead.style.marginTop = "10px";
compareHead.textContent = "Auto-save is NEWER than the saved version:";
body.appendChild(compareHead);
const compareList = document.createElement("ul");
compareList.style.margin = "4px 0 0 18px";
compareList.style.padding = "0";
compareList.style.fontSize = "12px";
const liNamed = document.createElement("li");
liNamed.textContent = `Saved version — ${namedStamp}`;
const liAuto = document.createElement("li");
liAuto.textContent = `Auto-save — ${autoStamp}`;
compareList.appendChild(liNamed);
compareList.appendChild(liAuto);
body.appendChild(compareList);
const question = document.createElement("div");
question.className = "koolook-modal-message";
question.style.marginTop = "10px";
question.textContent =
"Do you want to load the auto-saved version? " +
"(NO loads the saved version. Press Esc to cancel.)";
body.appendChild(question);
let shell;
const noBtn = makeModalButton({
label: "NO",
onClick: () => { shell.close(); doNamedLoad(preview); },
});
const yesBtn = makeModalButton({
label: "YES — load auto-save",
primary: true,
onClick: () => {
shell.close();
doAutosaveRestore({
dir: `${preview.fileName}_autosave`,
fileName: "periodic",
});
},
});
shell = makeModalShell({
title: "Replace current state?",
body,
actions: [noBtn, yesBtn],
});
}
function promptDelete(preview) {
showConfirmModal({
title: "Delete preset?",
message: `"${preview.displayName}" will be removed from the library. This cannot be undone.`,
confirmLabel: "Delete",
danger: true,
onConfirm: async () => {
try {
await deletePreset(preview.fileName);
// If the deleted preset was the one tracked as
// "currently loaded," clear the tracker. Otherwise
// the next Save would offer to "Save over '<deleted>'"
// and silently re-create the file.
if (typeof getCurrentPresetName === "function" &&
getCurrentPresetName() === preview.fileName) {
setCurrentPresetName(null);
}
toast(`Deleted "${preview.displayName}".`);
await refresh();
} catch (e) {
console.error("[Koolook] preset delete failed:", e);
toast(`Could not delete "${preview.displayName}": ${e.message}`);
}
},
});
}
// ---- Recovery auto-saves section (collapsible) ----
// Closes the gap from the v2 layout: pre-load and periodic autosaves
// live in `<preset>_autosave/` subfolders, deliberately hidden from the
// main Load list to keep that list clean. But "hidden" can't mean
// "unreachable" — the whole point of pre-load autosave is recovery.
// This section is the explicit recovery path: lazy-loaded on first
// expand, grouped by subdir, click-to-restore (with the same pre-load
// protection the regular Load flow uses).
const recoverySection = document.createElement("details");
recoverySection.className = "koolook-recovery-section";
const recoverySummary = document.createElement("summary");
recoverySummary.className = "koolook-recovery-summary";
// Native `<details>` already renders a disclosure triangle on
// `<summary>`. Don't add a second arrow character here — the previous
// `▸ / ▾` text duplicated the native marker and the user saw two
// arrows side-by-side.
recoverySummary.textContent = "Recovery auto-saves";
recoverySection.appendChild(recoverySummary);
const recoveryList = document.createElement("div");
recoveryList.className = "koolook-recovery-list";
recoverySection.appendChild(recoveryList);
function promptDeleteAutosave(item) {
showConfirmModal({
title: "Delete recovery auto-save?",
message: `"${item.dir}/${item.fileName}" will be removed. ` +
`If this is the only remaining recovery point for that ` +
`preset, you won't be able to restore from it later.`,
confirmLabel: "Delete",
danger: true,
onConfirm: async () => {
try {
await deletePreset(item.fileName, { dir: item.dir });
toast(`Deleted "${item.dir}/${item.fileName}".`);
await refreshRecovery();
} catch (e) {
console.error("[Koolook] autosave delete failed:", e);
toast(`Could not delete: ${e.message}`);
}
},
});
}
function promptApplyAutosave(item) {
const tooltipName = `${item.dir}/${item.fileName}`;
showConfirmModal({
title: "Restore from auto-save?",
message:
`Restoring "${tooltipName}" will replace your current ` +
`picks and workflows with the auto-save's contents (` +
`${formatPreviewMeta(item)}). A pre-load auto-save of ` +
`your current state will be written first as a recovery ` +
`point — this restore is itself reversible.`,
confirmLabel: "Restore",
danger: true,
onConfirm: () => doAutosaveRestore(item),
});
}
let recoveryLoaded = false;
async function refreshRecovery() {
recoveryList.innerHTML = "";
recoveryList.appendChild(renderEmpty("Loading…"));
let entries = [];
if (typeof listAutosaves === "function") {
try { entries = await listAutosaves(); } catch (e) { entries = []; }
}
recoveryList.innerHTML = "";
if (entries.length === 0) {
recoveryList.appendChild(renderEmpty("No recovery auto-saves yet."));
return;
}
// Group by subdir for readability. Map iteration preserves first-
// seen order, which from the server is alphabetical-by-subdir —
// matches expectations.
const groups = new Map();
for (const p of entries) {
if (!groups.has(p.dir)) groups.set(p.dir, []);
groups.get(p.dir).push(p);
}
for (const [subdir, items] of groups) {
const groupEl = document.createElement("div");
groupEl.className = "koolook-recovery-group";
// Subfolder header: title (subdir name) + path subtitle on the
// left, `📂 Open` button on the right. Mirrors the library
// section's layout exactly — same flex options, same
// `renderLibraryLocation` rendering pair (`folder-name` +
// `folder-path` CSS), same Open button class. Visually the
// two surfaces should be indistinguishable apart from the
// contents.
const groupHeader = document.createElement("div");
groupHeader.className = "koolook-recovery-group-header";
groupHeader.style.display = "flex";
groupHeader.style.alignItems = "flex-start";
groupHeader.style.justifyContent = "space-between";
groupHeader.style.gap = "8px";
const headerInfo = document.createElement("div");
headerInfo.style.flex = "1";
headerInfo.style.minWidth = "0";
const fullSubdirPath = libraryPath ? `${libraryPath}/${subdir}` : "";
renderLibraryLocation(headerInfo, fullSubdirPath, { title: subdir });
groupHeader.appendChild(headerInfo);
if (typeof revealPresetFolder === "function") {
const openBtn = document.createElement("button");
openBtn.className = "koolook-snapshot-row-btn";
openBtn.textContent = "📂 Open";
openBtn.title = `Open "${subdir}/" in your file manager`;
openBtn.style.flex = "0 0 auto";
openBtn.addEventListener("click", async (e) => {
e.stopPropagation();
try {
const r = await revealPresetFolder({ dir: subdir });
toast(`Opened: ${r.path}`);
} catch (err) {
console.error("[Koolook] reveal failed:", err);
toast(`Could not open "${subdir}/": ${err.message}`);
}
});
groupHeader.appendChild(openBtn);
}
groupEl.appendChild(groupHeader);
// Wrap rows in the same `koolook-snapshot-list` container the
// library section uses so each group gets the same bordered-
// box treatment around its rows. Without this, the recovery
// rows render flat (no border, no rounded corners) and
// visually diverge from the Koolook_v03 row even though the
// row contents are identical.
const rowsList = document.createElement("div");
rowsList.className = "koolook-snapshot-list";
for (const item of items) {
const row = document.createElement("div");
row.className = "koolook-snapshot-row";
const info = document.createElement("div");
info.className = "koolook-snapshot-row-info";
info.title = `Click to restore "${item.dir}/${item.fileName}"`;
const name = document.createElement("div");
name.className = "koolook-snapshot-row-name";
const kindBadge = document.createElement("span");
kindBadge.className = "koolook-recovery-kind koolook-recovery-kind-" + item.kind;
kindBadge.textContent = item.kind === "pre_load" ? "Pre-load" :
item.kind === "periodic" ? "Periodic" : "Other";
name.appendChild(kindBadge);
// Show the bare filename (`pre_load_2026-05-07T14-32-…`,
// `periodic`) instead of the snapshot's self-reported
// displayName, which for autosaves is a redundant
// `Periodic auto-save · <subdir> · <iso>` string that
// pushes the timestamp off-screen on narrow modals.
name.appendChild(document.createTextNode(item.fileName));
info.appendChild(name);
const meta = document.createElement("div");
meta.className = "koolook-snapshot-row-meta";
// mtime is when the file was actually written — strictly
// more relevant for "which autosave is freshest" than the
// snapshot's self-reported `exportedAt` (the two match for
// autosaves anyway, but mtime is what survives an out-of-
// band file copy in Finder).
meta.textContent = formatPreviewMeta(item);
info.appendChild(meta);
info.addEventListener("click", () => promptApplyAutosave(item));
row.appendChild(info);
const actions = document.createElement("div");
actions.className = "koolook-snapshot-row-actions";
const delBtn = document.createElement("button");
delBtn.className = "koolook-snapshot-row-btn koolook-snapshot-row-btn-danger";
delBtn.textContent = "×";
delBtn.title = `Delete "${item.dir}/${item.fileName}"`;
delBtn.addEventListener("click", (e) => { e.stopPropagation(); promptDeleteAutosave(item); });
actions.appendChild(delBtn);
row.appendChild(actions);
rowsList.appendChild(row);
}
groupEl.appendChild(rowsList);
recoveryList.appendChild(groupEl);
}
}
// Lazy-load on first expand; the autosave listing is N+1 reads (one
// per file to validate it's a snapshot and extract the displayName)
// and we don't want to pay that cost when the user is just looking
// for a regular preset.
recoverySection.addEventListener("toggle", () => {
if (!recoverySection.open) return;
if (recoveryLoaded) return;
recoveryLoaded = true;
refreshRecovery();
});
body.appendChild(recoverySection);
const closeBtn = makeModalButton({ label: "Close", onClick: close });
({ overlay } = makeModalShell({
title: "Load snapshot",
body,
actions: [closeBtn],
}));
refresh();
if (typeof getLibraryInfo === "function") {
getLibraryInfo().then((info) => {
if (info) {
renderLibraryLocation(pathLine, info.path, { label: "Library folder" });
// Stash for the recovery section's group-header path
// subtitle. If recovery is already loaded (user expanded
// before this resolved), refresh so the path appears.
libraryPath = info.path || "";
if (recoveryLoaded) refreshRecovery();
} else {
pathLine.textContent = "Library path unavailable.";
}
}).catch(() => { pathLine.textContent = "Library path unavailable."; });
} else {
pathLine.textContent = "";
}
}
// =============================================================================
// Settings flow.
//
// Single dialog with one editable field (library path). Two action buttons:
// Save (writes the path to the server-side settings file) and Reset to
// default (clears the saved path; server falls back to env var or built-in
// default). A read-only line shows the currently-resolved path + source so
// the user can see what's actually in effect right now.
// =============================================================================
export function showSnapshotSettingsDialog({
getSettings,
saveSettings,
getCurrentPresetName,
setCurrentPresetName,
gatherSnapshot,
writePreset,
markStateSaved,
browseDirectories,
createBrowseDirectory,
onToast,
}) {
const toast = onToast || (() => {});
const body = document.createElement("div");
body.appendChild(modalLabel("Library path"));
const inputRow = document.createElement("div");
inputRow.className = "koolook-path-input-row";
const input = document.createElement("input");
input.className = "koolook-modal-input";
input.placeholder = "/absolute/path/to/library (leave empty to use env-var or default)";
input.disabled = true;
inputRow.appendChild(input);
const browseBtn = makeModalButton({
label: "Browse...",
onClick: () => openBrowseDialog(input.value.trim() || ""),
});
browseBtn.disabled = true;
inputRow.appendChild(browseBtn);
body.appendChild(inputRow);
const hint = document.createElement("div");
hint.style.cssText = "font-size: 11px; opacity: 0.55; margin: 8px 0 12px; line-height: 1.5; word-break: break-all;";
hint.textContent = "Loading current settings…";
body.appendChild(hint);
let overlay;
let savedLibraryPath = "";
const close = () => overlay.remove();
function renderSelectedFolder(el, path) {
el.innerHTML = "";
if (!path) {
el.textContent = "No folder selected";
return;
}
const label = document.createElement("span");
label.textContent = "Selected folder: ";
const name = document.createElement("strong");
name.className = "koolook-browse-selected-name";
name.textContent = pathLeaf(path);
const full = document.createElement("div");
full.className = "koolook-browse-selected-path";
full.textContent = path;
el.appendChild(label);
el.appendChild(name);
el.appendChild(full);
}
function renderPathSummary({ saved }) {
const rawPath = input.value.trim();
const folder = rawPath ? pathLeaf(rawPath) : "(default)";
const label = saved ? "Saved folder" : "Selected folder";
hint.innerHTML = "";
const name = document.createElement("div");
name.className = "koolook-settings-folder-name";
name.textContent = `${label}: ${folder}`;
hint.appendChild(name);
const path = document.createElement("div");
path.className = "koolook-settings-folder-path";
path.textContent = rawPath || "Using env var / default preset folder";
hint.appendChild(path);
if (!saved) {
const note = document.createElement("div");
note.className = "koolook-settings-save-note";
note.textContent = "Will save the current snapshot here.";
hint.appendChild(note);
}
}
function revealPathEnd() {
setTimeout(() => {
input.scrollLeft = input.scrollWidth;
}, 0);
}
function updateSaveState() {
if (!save) return;
const dirty = input.value.trim() !== savedLibraryPath;
save.disabled = !dirty;
save.textContent = dirty ? "Save folder + snapshot" : "Saved";
save.classList.toggle("koolook-modal-btn-primary", dirty);
save.title = dirty
? "Save the folder and current snapshot"
: "Snapshot folder is saved";
renderPathSummary({ saved: !dirty });
}
async function refresh() {
let settings;
try { settings = await getSettings(); }
catch (e) { settings = null; }
if (!settings) {
hint.textContent =
"Settings endpoint unavailable — server-side routes not registered. " +
"Restart ComfyUI to fix.";
return;
}
savedLibraryPath = settings.savedLibraryPath || "";
input.value = savedLibraryPath;
revealPathEnd();
// Native tooltip surfaces the full path on hover — the input is only
// ~440px wide so long paths get truncated by horizontal scroll.
// The user can still drag-select to view, but the tooltip avoids
// making them do so.
input.title = settings.savedLibraryPath || "";
input.disabled = false;
let sourceLabel;
if (settings.source === "settings") sourceLabel = "from this Settings panel";
else if (settings.source === "env") sourceLabel = `from ${settings.envVar} env var`;
else sourceLabel = "built-in default";
browseBtn.disabled = typeof browseDirectories !== "function";
updateSaveState();
hint.title = `Currently in effect: ${settings.resolvedPath} (${sourceLabel})`;
}
async function doSave(rawValue) {
if (rawValue === savedLibraryPath) {
updateSaveState();
return;
}
try {
const result = await saveSettings(rawValue);
const sourceLabel = result.source === "settings"
? "from this Settings panel"
: (result.source === "env" ? "from env var" : "built-in default");
savedLibraryPath = result.savedLibraryPath || "";
input.value = savedLibraryPath;
input.title = savedLibraryPath;
revealPathEnd();
hint.title = `Currently in effect: ${result.resolvedPath} (${sourceLabel})`;
const currentName = typeof getCurrentPresetName === "function"
? getCurrentPresetName()
: null;
if (rawValue && currentName && typeof writePreset === "function" &&
typeof gatherSnapshot === "function") {
try {
await writePreset(currentName, gatherSnapshot(currentName));
if (typeof setCurrentPresetName === "function") {
setCurrentPresetName(currentName);
}
if (typeof markStateSaved === "function") markStateSaved();
toast(`Saved "${currentName}" to ${pathLeaf(savedLibraryPath)}.`);
} catch (copyErr) {
console.error("[Koolook] settings path saved but snapshot copy failed:", copyErr);
toast(
`Library folder saved, but "${currentName}" was not copied there: ` +
`${copyErr.message}. Use Save or Quick Save to retry.`
);
}
updateSaveState();
return;
}
toast(rawValue
? "Library folder saved."
: "Override cleared — using env var / default."
);
updateSaveState();
} catch (e) {
console.error("[Koolook] settings save failed:", e);
toast(`Could not save settings: ${e.message}`);
}
}
function openBrowseDialog(startPath) {
if (typeof browseDirectories !== "function") return;
const pickerBody = document.createElement("div");
const current = document.createElement("div");
current.className = "koolook-browse-current";
current.textContent = "Loading...";
pickerBody.appendChild(current);
const navRow = document.createElement("div");
navRow.className = "koolook-browse-nav";
const upBtn = makeModalButton({ label: "Up", onClick: () => {} });
const driveLabel = document.createElement("span");
driveLabel.className = "koolook-browse-drive-label";
driveLabel.textContent = "Location";
const rootsSelect = document.createElement("select");
rootsSelect.className = "koolook-modal-select";
navRow.appendChild(upBtn);
navRow.appendChild(driveLabel);
navRow.appendChild(rootsSelect);
pickerBody.appendChild(navRow);
const list = document.createElement("div");
list.className = "koolook-browse-list";
pickerBody.appendChild(list);
let selectedPath = startPath;
let overlay;
const closePicker = () => overlay.remove();
async function render(path) {
choose.disabled = true;
list.innerHTML = "";
const loading = document.createElement("div");
loading.className = "koolook-snapshot-empty";
loading.textContent = "Loading...";
list.appendChild(loading);
let data;
try {
data = await browseDirectories(path);
} catch (e) {
list.innerHTML = "";
const err = document.createElement("div");
err.className = "koolook-snapshot-empty";
err.textContent = e.message;
list.appendChild(err);
return;
}
selectedPath = data.path || "";
choose.disabled = !selectedPath;
renderSelectedFolder(current, selectedPath);
current.title = selectedPath;
upBtn.disabled = !data.parentPath;
upBtn.onclick = () => { if (data.parentPath) render(data.parentPath); };
rootsSelect.innerHTML = "";
for (const root of data.roots || []) {
const opt = document.createElement("option");
opt.value = root.path;
const isCurrentRoot = selectedPath &&
selectedPath.toLowerCase().startsWith(root.path.toLowerCase());
opt.textContent = isCurrentRoot ? selectedPath : root.name;
if (isCurrentRoot) {
opt.selected = true;
}
rootsSelect.appendChild(opt);
}
rootsSelect.onchange = () => render(rootsSelect.value);
list.innerHTML = "";
const dirs = Array.isArray(data.dirs) ? data.dirs : [];
if (dirs.length === 0) {
const empty = document.createElement("div");
empty.className = "koolook-snapshot-empty";
empty.textContent = "No folders inside selected folder.";
list.appendChild(empty);
return;
}
const childLabel = document.createElement("div");
childLabel.className = "koolook-browse-list-label";
childLabel.textContent = "Folders inside selected folder";
list.appendChild(childLabel);
for (const dir of dirs) {
const row = document.createElement("button");
row.type = "button";
row.className = "koolook-browse-row";
row.textContent = dir.name;
row.title = dir.path;
row.addEventListener("click", () => render(dir.path));
list.appendChild(row);
}
}
const cancel = makeModalButton({ label: "Cancel", onClick: closePicker });
const newFolder = makeModalButton({
label: "New folder...",
onClick: () => {
if (typeof createBrowseDirectory !== "function") return;
showInputModal({
title: "New snapshot folder",
label: "Folder name",
defaultValue: "",
placeholder: "e.g. koolook-presets3",
confirmLabel: "Create",
subtitle: `Inside: ${selectedPath}`,
onSubmit: async (name) => {
const trimmed = name.trim();
if (!trimmed) {
toast("Folder name is empty.");
return;
}
try {
const result = await createBrowseDirectory(selectedPath, trimmed);
await render(result.path);
toast(`Created folder "${pathLeaf(result.path)}".`);
} catch (e) {
console.error("[Koolook] create folder failed:", e);
toast(e.message);
}
},
});
},
});
const choose = makeModalButton({
label: "Use this folder",
primary: true,
onClick: () => {
input.value = selectedPath || "";
input.title = selectedPath || "";
revealPathEnd();
updateSaveState();
closePicker();
},
});
({ overlay } = makeModalShell({
title: "Browse snapshot folder",
body: pickerBody,
actions: [cancel, newFolder, choose],
}));
render(startPath);
}
const cancel = makeModalButton({ label: "Close", onClick: close });
const reset = makeModalButton({
label: "Reset to default",
onClick: () => doSave(""),
});
const save = makeModalButton({
label: "Save",
onClick: () => doSave(input.value.trim()),
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && input.value.trim() !== savedLibraryPath) {
doSave(input.value.trim());
}
});
input.addEventListener("input", updateSaveState);
({ overlay } = makeModalShell({
title: "Snapshot library settings",
body,
actions: [cancel, reset, save],
}));
updateSaveState();
refresh();
}
// =============================================================================
// Context menu helper
// =============================================================================
let activeContextMenuCleanup = null;
function closeActiveContextMenu() {
if (activeContextMenuCleanup) {
activeContextMenuCleanup();
activeContextMenuCleanup = null;
}
}
export function showContextMenu(event, items) {
event.preventDefault();
event.stopPropagation();
closeActiveContextMenu();
const menu = document.createElement("div");
menu.className = "koolook-context-menu";
let closeOnClick = null;
const cleanup = () => {
menu.remove();
if (closeOnClick) {
document.removeEventListener("click", closeOnClick);
document.removeEventListener("contextmenu", closeOnClick);
closeOnClick = null;
}
if (activeContextMenuCleanup === cleanup) activeContextMenuCleanup = null;
};
activeContextMenuCleanup = cleanup;
for (const item of items) {
if (!item) {
const sep = document.createElement("div");
sep.className = "koolook-context-sep";
menu.appendChild(sep);
continue;
}
const m = document.createElement("div");
m.className = "koolook-context-item";
if (item.danger) m.classList.add("koolook-context-danger");
m.textContent = item.label;
if (item.disabled) {
m.style.opacity = "0.4";
m.style.cursor = "not-allowed";
} else {
m.addEventListener("click", () => {
cleanup();
item.action();
});
}
menu.appendChild(m);
}
menu.style.left = `${event.clientX}px`;
menu.style.top = `${event.clientY}px`;
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = `${event.clientX - rect.width}px`;
if (rect.bottom > window.innerHeight) menu.style.top = `${event.clientY - rect.height}px`;
setTimeout(() => {
closeOnClick = (ev) => {
if (!menu.contains(ev.target)) {
cleanup();
}
};
document.addEventListener("click", closeOnClick);
document.addEventListener("contextmenu", closeOnClick);
}, 0);
}