vidfom's picture
Upload folder using huggingface_hub (part 8)
0c85e62 verified
Raw
History Blame Contribute Delete
118 kB
// 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, formatLibraryPathBreadcrumb, toast } from "./constants.js";
import { formatLocalStamp } from "./format_time.js";
import { listDirectoryNames, dirOf } from "./workflows_store.js";
import { inferSetupSurface } from "./published_surface.js";
import { buildPublishSuccessView } from "./published_setups.js";
import {
detectManager,
loadMappings,
resolvePicksToInstall,
queueInstallGitUrl,
startQueue,
pollUntilDone,
reboot,
} from "./installer.js";
function makeModalShell({ title, titleTooltip, 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;
// Optional hover-tooltip for explanatory context. Per
// ``docs/maintainers/conventions.md``: header sections stay clean
// (title only, no descriptive subtitle); use this tooltip for the
// one-sentence "what does this dialog do" gloss instead. Functional
// info rows (library-path indicator, etc.) live in the body, not
// the header — they are not the same thing as a description.
if (titleTooltip) titleEl.title = titleTooltip;
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, titleEl, 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. Renders above
// the message so callers can surface context (e.g. the library path
// breadcrumb on the Save-as-new overwrite-confirm path) before the
// user scans the action question. The redesigned Save / Load dialogs
// surface that breadcrumb inline in their own body, but other callers
// still benefit from the slot.
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";
if (message && typeof message === "object" && typeof message.nodeType === "number") {
msg.appendChild(message);
} else {
msg.textContent = message;
}
body.appendChild(msg);
let overlay;
// `onCancel` fires once for EVERY non-OK dismissal: Cancel button,
// Escape, AND overlay-click. Promise-wrapping callers (recovery
// toast's "Discard offline copy", `dropPlaceholdersForPacks` from
// issue #84) need every dismissal path to settle the Promise — an
// earlier cut wired the Cancel button only, so Esc / click-outside
// still leaked. The `settled` flag keeps `onCancel` idempotent so
// the Cancel-button path doesn't double-fire when overlay teardown
// re-enters via the wrapped `overlay.remove`. Optional; existing
// callers that don't pass `onCancel` keep working unchanged.
let settled = false;
const settleCancel = () => {
if (settled) return;
settled = true;
if (typeof onCancel !== "function") return;
try { onCancel(); }
catch (e) { console.error("[Koolook] confirm modal onCancel failed:", e); }
};
const cancel = makeModalButton({
label: cancelLabel || "Cancel",
onClick: () => { settleCancel(); overlay.remove(); },
});
const ok = makeModalButton({
label: confirmLabel || "OK",
primary: !danger,
danger,
// Mark settled BEFORE removing the overlay so the wrapped
// `overlay.remove` below doesn't fire `onCancel` on the OK path.
onClick: () => { settled = true; overlay.remove(); onConfirm(); },
});
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
// Escape and overlay-click route through `makeModalShell.close()`,
// which calls `overlay.remove()` (the listener-cleanup override) but
// is unaware of `onCancel`. Re-wrap to settle the cancel callback
// before the underlying teardown runs.
const baseRemove = overlay.remove.bind(overlay);
overlay.remove = () => {
settleCancel();
baseRemove();
};
}
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);
}
function slugForSetupId(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function makeLabeledInput(body, label, value, placeholder = "") {
body.appendChild(modalLabel(label));
const input = document.createElement("input");
input.className = "koolook-modal-input";
input.value = value || "";
input.placeholder = placeholder;
body.appendChild(input);
return input;
}
function makeLabeledTextarea(body, label, value) {
body.appendChild(modalLabel(label));
const input = document.createElement("textarea");
input.className = "koolook-modal-textarea";
input.value = value;
body.appendChild(input);
return input;
}
function appendSurfaceLine(section, label, value) {
const line = document.createElement("div");
line.textContent = `${label}: ${value || "missing"}`;
section.appendChild(line);
}
function fieldList(fields) {
if (!Array.isArray(fields) || !fields.length) return "";
return fields
.map(field => field?.label || field?.key)
.filter(Boolean)
.join(", ");
}
function appendSurfaceSection(body, visualGraph, dirPath = [], wfName = "") {
const surface = inferSetupSurface(visualGraph);
const section = document.createElement("div");
section.className = "koolook-publish-surface koolook-publish-wide";
const title = document.createElement("div");
title.className = "koolook-publish-surface-title";
title.textContent = "Inferred app surface";
section.appendChild(title);
appendSurfaceLine(section, "Source breadcrumbs", [...(dirPath || []), wfName].filter(Boolean).join(" / "));
appendSurfaceLine(
section,
"Koolook Input",
surface.sourceInputs.flatMap(item => item.nodes.map(node => node.title)).join(", ")
);
appendSurfaceLine(
section,
"Koolook Output",
surface.outputs.flatMap(item => item.nodes.map(node => node.title)).join(", ")
);
appendSurfaceLine(section, "Mode switch", surface.app?.switch?.label || "");
appendSurfaceLine(section, "Source fields", fieldList(surface.app?.inputs));
appendSurfaceLine(section, "Output controls", fieldList(surface.app?.outputs));
appendSurfaceLine(section, "Result fields", fieldList(surface.app?.results));
body.appendChild(section);
}
export function showPublishSetupModal({ wfName, dirPath, currentTags = [], visualGraph = null, onPublish, revealPublishedSetupFolder }) {
const body = document.createElement("div");
body.className = "koolook-publish-grid";
const setupId = makeLabeledInput(body, "Setup id", slugForSetupId(wfName), "director-demo");
const title = makeLabeledInput(body, "Title", wfName, "Director demo");
const descriptionWrap = document.createElement("div");
descriptionWrap.className = "koolook-publish-wide";
const description = makeLabeledInput(descriptionWrap, "Description", "", "What this setup is for");
body.appendChild(descriptionWrap);
const category = makeLabeledInput(body, "Category", "", "Video");
const tags = makeLabeledInput(
body,
"Tags",
Array.isArray(currentTags) ? currentTags.join(", ") : "",
"video, director"
);
const sourceWrap = document.createElement("div");
sourceWrap.className = "koolook-publish-wide";
const source = makeLabeledInput(sourceWrap, "Source workflow", [...(dirPath || []), wfName].join("/"));
source.disabled = true;
body.appendChild(sourceWrap);
const previewWrap = document.createElement("div");
previewWrap.className = "koolook-publish-wide";
const previewImage = makeLabeledInput(previewWrap, "Preview/card reference", "", "optional image or card URL");
body.appendChild(previewWrap);
appendSurfaceSection(body, visualGraph, dirPath, wfName);
const advanced = document.createElement("details");
advanced.className = "koolook-publish-advanced koolook-publish-wide";
const advancedSummary = document.createElement("summary");
advancedSummary.textContent = "Advanced contract JSON";
advanced.appendChild(advancedSummary);
const inputWrap = document.createElement("div");
inputWrap.className = "koolook-publish-wide";
const inputContract = makeLabeledTextarea(
inputWrap,
"Input contract JSON",
JSON.stringify({ inputs: [] }, null, 2)
);
advanced.appendChild(inputWrap);
const outputWrap = document.createElement("div");
outputWrap.className = "koolook-publish-wide";
const outputContract = makeLabeledTextarea(
outputWrap,
"Output contract JSON",
JSON.stringify({ outputs: [] }, null, 2)
);
advanced.appendChild(outputWrap);
body.appendChild(advanced);
const msg = document.createElement("div");
msg.className = "koolook-publish-message koolook-publish-wide";
body.appendChild(msg);
let overlay;
const cancelBtn = makeModalButton({ label: "Cancel", onClick: () => overlay.remove() });
const publishBtn = makeModalButton({
label: "Publish setup",
primary: true,
onClick: async () => {
msg.textContent = "";
let parsedInput;
let parsedOutput;
try {
parsedInput = JSON.parse(inputContract.value || "{}");
parsedOutput = JSON.parse(outputContract.value || "{}");
} catch (e) {
msg.textContent = `Contract JSON is invalid: ${e.message}`;
return;
}
publishBtn.disabled = true;
publishBtn.textContent = "Publishing...";
try {
const result = await onPublish({
metadata: {
id: setupId.value,
title: title.value,
description: description.value,
category: category.value,
tags: tags.value,
previewImage: previewImage.value,
},
inputContract: parsedInput,
outputContract: parsedOutput,
});
overlay.remove();
// Replace the silent close with an explicit confirmation that
// shows where the setup landed (issue #227). Falls back to a
// bare confirmation if a caller's onPublish returns nothing.
showPublishSuccessModal({
view: buildPublishSuccessView(result),
revealPublishedSetupFolder,
});
} catch (e) {
msg.textContent = e.message || "Publish failed.";
publishBtn.disabled = false;
publishBtn.textContent = "Publish setup";
}
},
});
let modal;
({ overlay, modal } = makeModalShell({
title: "Publish setup",
titleTooltip: "Publish a saved sidebar workflow into the callable setup registry.",
body,
actions: [cancelBtn, publishBtn],
}));
modal.classList.add("koolook-publish-modal");
setupId.focus();
}
// Folder name one level up from a file path — e.g. the "koolook-published-
// setups" directory that contains setups.json. Splits on both separators so
// Windows and POSIX paths behave the same (mirrors pathLeaf).
function pathParentLeaf(path) {
const parts = String(path || "").split(/[\\/]+/).filter(Boolean);
return parts.length >= 2 ? parts[parts.length - 2] : pathLeaf(path);
}
// Post-publish confirmation (issue #227). Reuses the snapshot Save dialog's
// "Saved to … Open folder ↗" library-row language so the publish flow gets
// the same visible destination + open/copy affordances. Open folder and Copy
// path appear only when the server returned a storage path; a draft publish
// without one still gets an explicit confirmation instead of vanishing.
export function showPublishSuccessModal({ view, revealPublishedSetupFolder }) {
const safeView = (view && typeof view === "object") ? view : {};
const body = document.createElement("div");
body.className = "koolook-publish-grid";
const headline = document.createElement("div");
headline.className = "koolook-publish-surface-title";
headline.textContent = safeView.title
? `Published “${safeView.title}”`
: "Setup published";
body.appendChild(headline);
if (safeView.storagePath) {
const libRow = document.createElement("div");
libRow.className = "koolook-snap-lib-row koolook-publish-wide";
const libRowInfo = document.createElement("div");
libRowInfo.className = "koolook-snap-lib-row-info";
const libRowTop = document.createElement("div");
libRowTop.className = "koolook-snap-lib-row-top";
const libLabel = document.createElement("span");
libLabel.className = "koolook-snap-lib-label";
libLabel.textContent = "Saved to";
libRowTop.appendChild(libLabel);
if (safeView.canOpenFolder && typeof revealPublishedSetupFolder === "function") {
let revealInFlight = false;
const openFolderLink = document.createElement("a");
openFolderLink.className = "koolook-snap-open-folder-link";
openFolderLink.href = "#";
openFolderLink.textContent = "Open folder ↗";
openFolderLink.title = "Open the published-setups folder in your file manager";
openFolderLink.addEventListener("click", async (e) => {
e.preventDefault();
if (revealInFlight) return;
revealInFlight = true;
try {
const r = await revealPublishedSetupFolder();
toast(`Opened: ${r.path}`);
} catch (err) {
console.error("[Koolook] reveal failed:", err);
toast(`Could not open folder: ${err.message}`);
} finally {
revealInFlight = false;
}
});
libRowTop.appendChild(openFolderLink);
}
libRowInfo.appendChild(libRowTop);
const libName = document.createElement("div");
libName.className = "koolook-settings-folder-name";
libName.textContent = pathParentLeaf(safeView.storagePath);
libRowInfo.appendChild(libName);
const libPath = document.createElement("div");
libPath.className = "koolook-settings-folder-path";
libPath.textContent = safeView.storagePath;
libPath.title = safeView.storagePath;
libRowInfo.appendChild(libPath);
libRow.appendChild(libRowInfo);
body.appendChild(libRow);
}
const meta = document.createElement("div");
meta.className = "koolook-publish-surface koolook-publish-wide";
if (safeView.setupId) appendSurfaceLine(meta, "Setup id", safeView.setupId);
if (safeView.sourcePath) appendSurfaceLine(meta, "Source workflow", safeView.sourcePath);
if (safeView.validationStatus) appendSurfaceLine(meta, "Validation", safeView.validationStatus);
if (meta.childNodes.length) body.appendChild(meta);
let overlay;
const actions = [];
if (safeView.canCopyPath) {
actions.push(makeModalButton({
label: "Copy path",
onClick: async () => {
try {
await navigator.clipboard.writeText(safeView.storagePath);
toast("Copied registry path.");
} catch (err) {
toast(`Could not copy path: ${err.message}`);
}
},
}));
}
actions.push(makeModalButton({
label: "Close",
primary: true,
onClick: () => overlay.remove(),
}));
let modal;
({ overlay, modal } = makeModalShell({
title: "Setup published",
titleTooltip: "Where this setup was saved in the callable setup registry.",
body,
actions,
}));
modal.classList.add("koolook-publish-modal");
}
// =============================================================================
// 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.pickCount} pick${p.pickCount === 1 ? "" : "s"}`);
parts.push(`${p.workflowCount} workflow${p.workflowCount === 1 ? "" : "s"}`);
if (typeof p.mtime === "number" && isFinite(p.mtime)) {
const stamp = formatLocalStamp(new Date(p.mtime * 1000));
if (stamp) parts.push(p.kind ? stamp : `saved ${stamp}`);
} else if (p.exportedAt) {
const stamp = formatLocalStamp(new Date(p.exportedAt));
if (stamp) parts.push(p.kind ? stamp : `exported ${stamp}`);
}
return parts.join(" · ");
}
function pathLeaf(path) {
const parts = String(path || "").split(/[\\/]+/).filter(Boolean);
return parts.length ? parts[parts.length - 1] : String(path || "");
}
// =============================================================================
// Save flow — redesigned per issue #137, mockup section 2.
//
// One unified dialog (was three: three-way overwrite / input prompt / confirm).
// Layout:
//
// [ Save snapshot ]
// [ Saved to Open folder ↗ ]
// [ <leaf folder> ]
// [ <full path> ]
// [ ]
// [ Save current state over "<name>"? ]
// [ ]
// [ [ Save to… ] [ Cancel ] [ Save as new… ] [Save]]
//
// The Save button cycles through four states per mockup section 5:
// Default ("Save") → In progress ("Saving…", disabled) → Done ("Saved",
// subtle disabled). When the user changes the library via Save to…, the
// dialog enters Dirty state — Save label flips to "Save to new folder"
// so the click reads as "you're about to write into a different place."
//
// 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,
saveSettings,
browseDirectories,
createBrowseDirectory,
revealPresetFolder,
markStateSaved,
onToast,
}) {
const toast = onToast || (() => {});
const current = getCurrentPresetName();
// Internal state: original library path (set after the first
// getLibraryInfo() resolution) and current library path (mutated when
// the user picks a different folder via Save to…). The two diverge
// → Dirty state → primary label becomes "Save to new folder".
let originalLibraryPath = "";
let currentLibraryPath = "";
let libraryRevealInFlight = false;
const body = document.createElement("div");
// -----------------------------------------------------------------
// Library-row info block (top of body). Pure information — the
// mockup explicitly puts every action in the bottom command bar.
// Async-populated so the dialog opens immediately without waiting
// on a server round-trip; the path appears in place when ready.
// -----------------------------------------------------------------
const libRow = document.createElement("div");
libRow.className = "koolook-snap-lib-row";
const libRowInfo = document.createElement("div");
libRowInfo.className = "koolook-snap-lib-row-info";
const libRowTop = document.createElement("div");
libRowTop.className = "koolook-snap-lib-row-top";
const libLabel = document.createElement("span");
libLabel.className = "koolook-snap-lib-label";
libLabel.textContent = "Saved to";
libRowTop.appendChild(libLabel);
const openFolderLink = document.createElement("a");
openFolderLink.className = "koolook-snap-open-folder-link";
openFolderLink.href = "#";
openFolderLink.textContent = "Open folder ↗";
openFolderLink.title = "Open the snapshot library folder in your file manager";
openFolderLink.addEventListener("click", async (e) => {
e.preventDefault();
if (typeof revealPresetFolder !== "function") return;
if (libraryRevealInFlight) return;
libraryRevealInFlight = true;
try {
const r = await revealPresetFolder();
toast(`Opened: ${r.path}`);
} catch (err) {
console.error("[Koolook] reveal failed:", err);
toast(`Could not open library folder: ${err.message}`);
} finally {
libraryRevealInFlight = false;
}
});
libRowTop.appendChild(openFolderLink);
libRowInfo.appendChild(libRowTop);
const libName = document.createElement("div");
libName.className = "koolook-settings-folder-name";
libName.textContent = "Library folder: (loading…)";
libRowInfo.appendChild(libName);
const libPath = document.createElement("div");
libPath.className = "koolook-settings-folder-path";
libRowInfo.appendChild(libPath);
libRow.appendChild(libRowInfo);
body.appendChild(libRow);
function renderLibRow(path) {
currentLibraryPath = path || "";
const leaf = currentLibraryPath ? pathLeaf(currentLibraryPath) : "(unavailable)";
libName.textContent = leaf;
libPath.textContent = currentLibraryPath || "Path unavailable";
libRow.title = currentLibraryPath || "";
updatePrimaryLabel();
}
if (typeof getLibraryInfo === "function") {
getLibraryInfo().then((info) => {
const path = (info && typeof info.path === "string") ? info.path : "";
originalLibraryPath = path;
renderLibRow(path);
}).catch(() => {
libName.textContent = "Library path unavailable";
libPath.textContent = "";
});
}
// -----------------------------------------------------------------
// Message line — "Save current state over '<name>'?" if there's a
// tracked preset, or "Save current state as a new preset" if not.
// -----------------------------------------------------------------
const msg = document.createElement("p");
msg.className = "koolook-modal-message koolook-snap-save-message";
msg.textContent = current
? `Save current state over "${current}"?`
: "Save current state as a new preset.";
body.appendChild(msg);
// -----------------------------------------------------------------
// Primary "Save" button — four-state per mockup section 5. The
// helper centralises label / class / disabled so the dialog only
// toggles state names, never DOM attributes directly.
// -----------------------------------------------------------------
let primaryState = current ? "default" : "default-new";
const primaryBtn = document.createElement("button");
primaryBtn.className = "koolook-modal-btn";
function isDirty() {
return Boolean(
originalLibraryPath &&
currentLibraryPath &&
originalLibraryPath !== currentLibraryPath
);
}
function updatePrimaryLabel() {
// ``dirty`` only meaningfully changes behaviour when there's a tracked
// preset to overwrite — otherwise the primary action is "ask for a
// name and write" regardless of the destination folder. Without this
// guard, opening Save with no tracked preset, then changing the
// folder via Save to…, would enter the "dirty" branch with
// ``current === null`` and the primary click would call
// ``doOverwrite(null)`` → ``sanitizeName(null)`` instead of routing
// to the Save-as-new name prompt.
if (!current) {
primaryState = "default-new";
} else {
primaryState = isDirty() ? "dirty" : "default";
}
renderPrimary();
}
function renderPrimary() {
primaryBtn.classList.remove(
"koolook-modal-btn-primary",
"koolook-snap-save-in-progress",
"koolook-snap-save-done",
);
switch (primaryState) {
case "default":
primaryBtn.textContent = "Save";
primaryBtn.classList.add("koolook-modal-btn-primary");
primaryBtn.disabled = false;
primaryBtn.title = `Overwrite "${current}" with the current state.`;
break;
case "default-new":
// No tracked preset → Save needs a name → routes to Save as new.
primaryBtn.textContent = "Save";
primaryBtn.classList.add("koolook-modal-btn-primary");
primaryBtn.disabled = false;
primaryBtn.title = "Choose a name and write the snapshot.";
break;
case "dirty":
primaryBtn.textContent = "Save to new folder";
primaryBtn.classList.add("koolook-modal-btn-primary");
primaryBtn.disabled = false;
primaryBtn.title = `Write into ${currentLibraryPath}.`;
break;
case "in-progress":
primaryBtn.textContent = "Saving…";
primaryBtn.classList.add(
"koolook-modal-btn-primary",
"koolook-snap-save-in-progress",
);
primaryBtn.disabled = true;
primaryBtn.title = "";
break;
case "done":
primaryBtn.textContent = "Saved";
primaryBtn.classList.add("koolook-snap-save-done");
primaryBtn.disabled = true;
primaryBtn.title = "";
break;
}
}
renderPrimary();
// -----------------------------------------------------------------
// Footer buttons.
// -----------------------------------------------------------------
const saveToBtn = makeModalButton({
label: "Save to…",
onClick: () => openSaveTo(),
});
const cancelBtn = makeModalButton({
label: "Cancel",
onClick: () => close(),
});
const saveAsNewBtn = makeModalButton({
label: "Save as new…",
onClick: () => {
promptForName(current ? `${current} (copy)` : `preset ${todayStamp()}`);
},
});
primaryBtn.addEventListener("click", async () => {
if (primaryBtn.disabled) return;
if (primaryState === "default-new") {
promptForName(`preset ${todayStamp()}`);
return;
}
// "default" or "dirty" — write over the tracked preset (dirty
// means the library path changed; the preset *name* is the same).
await doOverwrite(current);
});
const spacer = document.createElement("span");
spacer.className = "koolook-folder-picker-spacer";
let overlay;
const close = () => overlay.remove();
// -----------------------------------------------------------------
// Save to… → folder picker. On Use this folder, persist via
// saveSettings() and update the in-dialog state. The original path
// is kept (in `originalLibraryPath`) so the dirty indicator stays
// accurate even after navigating multiple times.
// -----------------------------------------------------------------
function openSaveTo() {
if (typeof browseDirectories !== "function") {
toast("Folder picker unavailable in this session.");
return;
}
if (saveToBtn.disabled) return;
saveToBtn.disabled = true;
setTimeout(() => { saveToBtn.disabled = false; }, 0);
showFolderPicker({
title: "Save snapshots to",
titleTooltip: "Pick the library folder this Save will write into.",
initialPath: currentLibraryPath,
browseDirectories,
createBrowseDirectory,
onUseFolder: async (chosen) => {
if (typeof saveSettings !== "function") {
renderLibRow(chosen);
return;
}
try {
const r = await saveSettings(chosen);
renderLibRow(r.savedLibraryPath || chosen);
toast(`Snapshot library set to: ${currentLibraryPath}.`);
} catch (err) {
console.error("[Koolook] saveSettings failed:", err);
toast(`Could not save library path: ${err.message}`);
}
},
});
}
// -----------------------------------------------------------------
// Overwrite the tracked preset directly. The mockup intentionally
// skips a second "are you sure?" — the dialog already says the
// destination preset name and (if dirty) the folder change, so the
// click is informed.
// -----------------------------------------------------------------
async function doOverwrite(name) {
primaryState = "in-progress";
renderPrimary();
try {
const sanitized = sanitizeName(name);
const snap = gatherSnapshot(sanitized);
await writePreset(sanitized, snap);
setCurrentPresetName(sanitized);
if (typeof markStateSaved === "function") markStateSaved();
primaryState = "done";
renderPrimary();
toast(`Saved "${sanitized}".`);
// Brief "Saved" pause for legibility, then close — per the
// four-state pattern, the user sees the confirmation tag
// before the dialog disappears.
setTimeout(close, 600);
} catch (e) {
console.error("[Koolook] preset save failed:", e);
toast(`Could not save preset: ${e.message}`);
updatePrimaryLabel();
}
}
// -----------------------------------------------------------------
// Save as new… → name prompt → existence check → write. The
// existence-check guard lives here (not in `doOverwrite`) because
// overwrite intent is explicit; rename intent is not.
// -----------------------------------------------------------------
function promptForName(defaultName) {
showInputModal({
title: "Save snapshot",
label: "Snapshot name",
defaultValue: defaultName,
placeholder: "e.g. Wan video kit",
confirmLabel: "Save",
onSubmit: async (typed) => {
const sanitized = sanitizeName(typed);
if (!sanitized) {
toast("Snapshot name is empty after stripping unsafe characters.");
return;
}
const exists = await presetExists(sanitized);
if (exists === null) {
toast(
"Cannot reach the preset library to verify name. " +
"Save canceled — check the library path 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,
onConfirm: () => writeAsNew(sanitized),
});
return;
}
writeAsNew(sanitized);
},
});
}
async function writeAsNew(name) {
primaryState = "in-progress";
renderPrimary();
try {
const snap = gatherSnapshot(name);
await writePreset(name, snap);
setCurrentPresetName(name);
if (typeof markStateSaved === "function") markStateSaved();
primaryState = "done";
renderPrimary();
toast(`Saved "${name}".`);
setTimeout(close, 600);
} catch (e) {
console.error("[Koolook] preset save failed:", e);
toast(`Could not save preset: ${e.message}`);
updatePrimaryLabel();
}
}
({ overlay } = makeModalShell({
title: "Save snapshot",
titleTooltip: "Write the current sidebar state to a preset file.",
body,
actions: [saveToBtn, spacer, cancelBtn, saveAsNewBtn, primaryBtn],
}));
}
// =============================================================================
// Load flow — redesigned per issue #137, mockup section 3.
//
// Single dialog. Layout:
//
// [ Load snapshot ]
// [ Loaded from Open folder ↗ ]
// [ <leaf folder> ]
// [ <full path> ]
// [ ]
// [ <preset row> [×] ]
// [ <preset row> [×] ]
// [ ↓ when a preset has a newer autosave, clicking it expands ]
// [ exactly ONE scoped recovery row beneath that preset: ]
// [ [Pre-load badge] May 12 14:47 · 11 picks · 6 wf ]
// [ ]
// [ [ Load from… ] [ Close ] ]
//
// Click flow:
// * Preset row, no newer autosave → load directly (with pre-load backup).
// * Preset row WITH a newer autosave → scope the recovery section to that
// preset alone, render one row (newest of periodic / pre_load_*),
// insert it directly below the clicked preset. The user then chooses:
// click the named row again to load named, or click the recovery row
// to load the autosave.
// * Recovery row → restore that autosave (with pre-load backup).
//
// Delete (×) is inline: clicking × outlines the target row red and the
// command-bar Close button transforms to "Yes (delete)". A second click on
// Yes commits; Escape cancels and reverts.
//
// Load from… opens the folder picker (commit 2). On Use this folder, the
// dialog persists the path via saveSettings() and refreshes the preset
// listing.
//
// Load success → updates the tracker so a subsequent Save defaults to
// overwriting that preset.
// =============================================================================
export function showLoadSnapshotDialog({
listPresets,
readPreset,
deletePreset,
applySnapshot,
setCurrentPresetName,
getCurrentPresetName,
getLibraryInfo,
saveSettings,
browseDirectories,
createBrowseDirectory,
writePreLoadAutosave,
markStateSaved,
markStateAutosaved,
listAutosaves,
revealPresetFolder,
onToast,
// Compare mode (#181): when provided, selecting a preset (or an autosave)
// calls onChoose(snapshot, meta) and closes — instead of the destructive
// applySnapshot load. Default undefined keeps the normal Load flow intact.
onChoose,
}) {
const toast = onToast || (() => {});
const body = document.createElement("div");
let libraryPath = "";
// ---- Library row (mockup section 3, "Loaded from") ----
// Pure information row at the top. Label + Open folder link sit on the
// top edge so a long absolute path can never cover the link area.
const libRow = document.createElement("div");
libRow.className = "koolook-snap-lib-row";
const libRowInfo = document.createElement("div");
libRowInfo.className = "koolook-snap-lib-row-info";
const libRowTop = document.createElement("div");
libRowTop.className = "koolook-snap-lib-row-top";
const libLabel = document.createElement("span");
libLabel.className = "koolook-snap-lib-label";
libLabel.textContent = "Loaded from";
libRowTop.appendChild(libLabel);
const openFolderLink = document.createElement("a");
openFolderLink.className = "koolook-snap-open-folder-link";
openFolderLink.href = "#";
openFolderLink.textContent = "Open folder ↗";
openFolderLink.title = "Open the snapshot library folder in your file manager";
openFolderLink.addEventListener("click", async (e) => {
e.preventDefault();
if (typeof revealPresetFolder !== "function") return;
if (libraryRevealInFlight) return;
libraryRevealInFlight = true;
try {
const r = await revealPresetFolder();
toast(`Opened: ${r.path}`);
} catch (err) {
console.error("[Koolook] reveal failed:", err);
toast(`Could not open library folder: ${err.message}`);
} finally {
libraryRevealInFlight = false;
}
});
libRowTop.appendChild(openFolderLink);
libRowInfo.appendChild(libRowTop);
const libName = document.createElement("div");
libName.className = "koolook-settings-folder-name";
libName.textContent = "Library folder: (loading…)";
libRowInfo.appendChild(libName);
const libPath = document.createElement("div");
libPath.className = "koolook-settings-folder-path";
libRowInfo.appendChild(libPath);
libRow.appendChild(libRowInfo);
body.appendChild(libRow);
const listWrap = document.createElement("div");
body.appendChild(listWrap);
const recoverySection = document.createElement("div");
recoverySection.className = "koolook-recovery-section";
const recoverySummary = document.createElement("div");
recoverySummary.className = "koolook-recovery-summary";
recoverySummary.textContent = "▸ Recovery auto-saves";
recoverySummary.classList.add("koolook-recovery-summary-passive");
recoverySection.appendChild(recoverySummary);
const recoveryContent = document.createElement("div");
recoveryContent.className = "koolook-recovery-list";
recoveryContent.hidden = true;
recoverySection.appendChild(recoveryContent);
recoverySummary.addEventListener("click", () => {
if (scopedRecovery) {
clearScopedRecovery();
setDialogTitle("Load snapshot");
applyCloseButtonState();
}
});
body.appendChild(recoverySection);
let overlay;
let titleEl;
const close = () => overlay.remove();
// Per-dialog state. ``pendingDelete`` holds the target whose × was
// clicked and the row element currently outlined red; the command
// bar swaps to a "Confirm delete?" prompt until the user confirms or
// Escape cancels. ``scopedRecovery`` holds the bottom recovery
// section state for one preset at a time (mockup section 3).
let pendingDelete = null;
let scopedRecovery = null;
let selectedNamed = null;
let selectedRecovery = null;
let recoveryRequestId = 0;
let libraryRevealInFlight = false;
function renderEmpty(text) {
const el = document.createElement("div");
el.className = "koolook-snapshot-empty";
el.textContent = text;
return el;
}
async function refresh() {
cancelDelete();
listWrap.innerHTML = "";
listWrap.appendChild(renderEmpty("Loading…"));
let previews;
try { previews = await listPresets(); }
catch (e) { previews = []; }
listWrap.innerHTML = "";
clearScopedRecovery();
clearLoadSelection();
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", () => onPresetClick(p, row));
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(); armDelete(p, row); });
actions.appendChild(delBtn);
row.appendChild(actions);
list.appendChild(row);
}
listWrap.appendChild(list);
}
// Action helpers. Backup-chain logic is identical to the pre-#137
// version — what changes is the *routing* into these helpers (no
// more "Replace current state?" confirm modal for the unambiguous
// case, and no YES/NO choice modal at all). ``close()`` here refers
// to the OUTER Load Snapshot dialog's overlay.
async function doNamedLoad(preview) {
// Compare mode: read-only — hand the parsed snapshot back and close.
// No pre-load auto-save, no applySnapshot, no tracker change.
if (onChoose) {
try {
const snap = await readPreset(preview.fileName);
close();
onChoose(snap, { fileName: preview.fileName, displayName: preview.displayName });
} catch (e) {
console.error("[Koolook] preset read (compare) failed:", e);
toast(`Could not read "${preview.displayName}": ${e.message}`);
}
return;
}
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}`;
// Compare mode: read-only — hand the parsed snapshot back and close.
if (onChoose) {
try {
const snap = await readPreset(item.fileName, { dir: item.dir });
close();
// Pass `dir` so a Compare A->B write-back targets the same
// autosave subfolder the snapshot was read from (#197).
onChoose(snap, { fileName: item.fileName, displayName: tooltipName, dir: item.dir });
} catch (e) {
console.error("[Koolook] autosave read (compare) failed:", e);
toast(`Could not read "${tooltipName}": ${e.message}`);
}
return;
}
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}`);
}
}
// ---- Click routing ----
// Row clicks select; footer buttons commit. A newer recovery opens
// the bottom section and changes the dialog title/button pair, but
// there is no double-click-to-load convention.
function onPresetClick(preview, rowEl) {
// Cancel any pending delete-confirm on a different row.
if (pendingDelete && pendingDelete.preview.fileName !== preview.fileName) {
cancelDelete();
}
if (selectedNamed?.preview.fileName === preview.fileName && !pendingDelete) return;
clearLoadSelection();
selectedNamed = { preview, rowEl };
rowEl.classList.add("koolook-snapshot-row-selected");
clearScopedRecovery();
if (typeof preview.latestAutosaveMtime === "number" &&
typeof preview.mtime === "number" &&
preview.latestAutosaveMtime > preview.mtime) {
openScopedRecovery(preview);
setDialogTitle("Auto-save is newer than the saved version");
applyCloseButtonState();
return;
}
setDialogTitle("Load snapshot");
applyCloseButtonState();
}
async function openScopedRecovery(preview) {
const requestId = ++recoveryRequestId;
const expectedFileName = preview.fileName;
scopedRecovery = { parentPreview: preview, item: null, rowEl: null, loading: true };
recoverySummary.textContent = "▾ Recovery auto-saves";
recoverySummary.classList.remove("koolook-recovery-summary-passive");
recoveryContent.hidden = false;
recoveryContent.innerHTML = "";
recoveryContent.appendChild(renderEmpty("Loading recovery…"));
let items = [];
if (typeof listAutosaves === "function") {
try {
items = await listAutosaves();
} catch (e) {
items = [];
toast("Could not load recovery list; saved snapshot is still available.");
}
}
if (requestId !== recoveryRequestId ||
selectedNamed?.preview.fileName !== expectedFileName) {
return;
}
// Filter to this preset's autosave subdir AND pick the single
// freshest entry by mtime. ``periodic.json`` and the rotated
// ``pre_load_*.json`` files all live in
// ``<preset>_autosave/`` — pick whichever was written most
// recently regardless of kind.
const subdir = `${preview.fileName.replace(/\.json$/i, "")}_autosave`;
const scoped = items
.filter((item) => item.dir === subdir)
.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
recoveryContent.innerHTML = "";
if (scoped.length === 0) {
clearScopedRecovery();
setDialogTitle("Load snapshot");
applyCloseButtonState();
return;
}
const newest = scoped[0];
const group = document.createElement("div");
group.className = "koolook-recovery-group";
const groupHead = document.createElement("div");
groupHead.className = "koolook-recovery-group-head";
const groupTitle = document.createElement("div");
groupTitle.className = "koolook-recovery-group-title";
groupTitle.textContent = subdir;
groupHead.appendChild(groupTitle);
const groupOpen = document.createElement("a");
groupOpen.className = "koolook-snap-open-folder-link";
groupOpen.href = "#";
groupOpen.textContent = "Open folder ↗";
groupOpen.title = "Open this recovery folder in your file manager";
let groupRevealInFlight = false;
groupOpen.addEventListener("click", async (e) => {
e.preventDefault();
if (typeof revealPresetFolder !== "function") return;
if (groupRevealInFlight) return;
groupRevealInFlight = true;
try {
const r = await revealPresetFolder({ dir: subdir });
toast(`Opened: ${r.path}`);
} catch (err) {
console.error("[Koolook] reveal recovery folder failed:", err);
toast(`Could not open recovery folder: ${err.message}`);
} finally {
groupRevealInFlight = false;
}
});
groupHead.appendChild(groupOpen);
group.appendChild(groupHead);
const groupPath = document.createElement("div");
groupPath.className = "koolook-recovery-group-path";
const fullGroupPath = libraryPath ? `${libraryPath}/${subdir}/` : subdir;
groupPath.textContent = formatLibraryPathBreadcrumb(fullGroupPath, 56);
groupPath.title = fullGroupPath;
group.appendChild(groupPath);
const row = document.createElement("div");
row.className = "koolook-recovery-row";
const info = document.createElement("div");
info.className = "koolook-recovery-row-info";
info.title = "Click to load this auto-save instead of the named preset.";
const kindBadge = document.createElement("span");
kindBadge.className = "koolook-recovery-kind koolook-recovery-kind-" + newest.kind;
kindBadge.textContent = newest.kind === "pre_load" ? "Pre-load" :
newest.kind === "periodic" ? "Periodic" : "Other";
info.appendChild(kindBadge);
const meta = document.createElement("div");
meta.className = "koolook-recovery-row-meta";
meta.textContent = formatPreviewMeta(newest);
info.appendChild(meta);
info.addEventListener("click", () => {
if (pendingDelete) {
cancelDelete();
}
if (selectedNamed) {
selectedNamed.rowEl.classList.remove("koolook-snapshot-row-selected");
selectedNamed = null;
}
if (selectedRecovery) selectedRecovery.rowEl.classList.remove("koolook-recovery-row-selected");
selectedRecovery = { item: newest, rowEl: row };
row.classList.add("koolook-recovery-row-selected");
applyCloseButtonState();
});
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 this recovery auto-save";
delBtn.addEventListener("click", (e) => {
e.stopPropagation();
armDelete(newest, row, { dir: newest.dir });
});
actions.appendChild(delBtn);
row.appendChild(actions);
group.appendChild(row);
recoveryContent.appendChild(group);
scopedRecovery = { parentPreview: preview, item: newest, rowEl: row };
applyCloseButtonState();
}
function clearScopedRecovery() {
recoveryRequestId += 1;
recoverySummary.textContent = "▸ Recovery auto-saves";
recoverySummary.classList.add("koolook-recovery-summary-passive");
recoveryContent.hidden = true;
recoveryContent.innerHTML = "";
scopedRecovery = null;
if (selectedRecovery) {
selectedRecovery.rowEl.classList.remove("koolook-recovery-row-selected");
selectedRecovery = null;
}
}
function clearLoadSelection() {
if (selectedNamed) selectedNamed.rowEl.classList.remove("koolook-snapshot-row-selected");
if (selectedRecovery) selectedRecovery.rowEl.classList.remove("koolook-recovery-row-selected");
selectedNamed = null;
selectedRecovery = null;
}
function setDialogTitle(text) {
if (titleEl) titleEl.textContent = text;
}
// ---- Inline delete confirm ----
// Mockup-locked: × outlines the row red, Close transforms to
// "Yes (delete)". A second confirm click commits; Escape cancels.
function armDelete(preview, rowEl, { dir = "" } = {}) {
if (pendingDelete) cancelDelete();
clearLoadSelection();
if (!dir) clearScopedRecovery();
rowEl.classList.add("koolook-snapshot-row-pending-delete");
pendingDelete = { preview, rowEl, dir };
setDialogTitle("Load snapshot");
applyCloseButtonState();
}
function cancelDelete() {
if (!pendingDelete) return;
pendingDelete.rowEl.classList.remove("koolook-snapshot-row-pending-delete");
pendingDelete = null;
applyCloseButtonState();
}
async function commitDelete() {
const target = pendingDelete;
if (!target) return;
try {
await deletePreset(target.preview.fileName, { dir: target.dir });
if (typeof getCurrentPresetName === "function" &&
!target.dir &&
getCurrentPresetName() === target.preview.fileName) {
setCurrentPresetName(null);
}
toast(`Deleted "${target.preview.displayName}".`);
pendingDelete = null;
applyCloseButtonState();
await refresh();
} catch (e) {
console.error("[Koolook] preset delete failed:", e);
toast(`Could not delete "${target.preview.displayName}": ${e.message}`);
cancelDelete();
}
}
// Footer buttons.
const loadFromBtn = makeModalButton({
label: "Load from…",
onClick: () => openLoadFrom(),
});
const confirmText = document.createElement("span");
confirmText.className = "koolook-delete-confirm-text";
confirmText.hidden = true;
const loadSavedBtn = makeModalButton({
label: "NO - load saved",
onClick: () => {
if (!scopedRecovery) return;
doNamedLoad(scopedRecovery.parentPreview);
},
});
loadSavedBtn.hidden = true;
const loadLatestBtn = makeModalButton({
label: "YES - load latest",
primary: true,
onClick: () => {
if (!scopedRecovery || !scopedRecovery.item) return;
doAutosaveRestore(selectedRecovery?.item || scopedRecovery.item);
},
});
loadLatestBtn.hidden = true;
const closeBtn = makeModalButton({
label: "Close",
onClick: () => {
if (pendingDelete) {
commitDelete();
return;
}
if (selectedNamed) {
doNamedLoad(selectedNamed.preview);
return;
}
close();
},
});
function applyCloseButtonState() {
if (pendingDelete) {
loadFromBtn.hidden = true;
loadSavedBtn.hidden = true;
loadLatestBtn.hidden = true;
confirmText.hidden = false;
confirmText.textContent = `Confirm delete "${pendingDelete.preview.displayName}"?`;
closeBtn.hidden = false;
closeBtn.textContent = "Yes";
closeBtn.classList.add("koolook-modal-btn-danger");
closeBtn.classList.remove("koolook-modal-btn-primary");
closeBtn.title = "Confirm deletion. Press Esc to cancel.";
} else if (scopedRecovery && scopedRecovery.item) {
loadFromBtn.hidden = true;
confirmText.hidden = true;
confirmText.textContent = "";
loadSavedBtn.hidden = false;
loadLatestBtn.hidden = false;
closeBtn.hidden = true;
closeBtn.classList.remove("koolook-modal-btn-danger", "koolook-modal-btn-primary");
} else if (scopedRecovery && scopedRecovery.loading) {
loadFromBtn.hidden = true;
confirmText.hidden = true;
confirmText.textContent = "";
loadSavedBtn.hidden = true;
loadLatestBtn.hidden = true;
closeBtn.hidden = false;
closeBtn.textContent = "Close";
closeBtn.classList.remove("koolook-modal-btn-danger", "koolook-modal-btn-primary");
closeBtn.title = "";
} else if (selectedNamed) {
loadFromBtn.hidden = false;
confirmText.hidden = true;
confirmText.textContent = "";
loadSavedBtn.hidden = true;
loadLatestBtn.hidden = true;
closeBtn.hidden = false;
closeBtn.textContent = "Load";
closeBtn.classList.add("koolook-modal-btn-primary");
closeBtn.classList.remove("koolook-modal-btn-danger");
closeBtn.title = `Load "${selectedNamed.preview.displayName}".`;
} else {
loadFromBtn.hidden = false;
confirmText.hidden = true;
confirmText.textContent = "";
loadSavedBtn.hidden = true;
loadLatestBtn.hidden = true;
closeBtn.hidden = false;
closeBtn.textContent = "Close";
closeBtn.classList.remove("koolook-modal-btn-danger", "koolook-modal-btn-primary");
closeBtn.title = "";
}
}
function openLoadFrom() {
if (typeof browseDirectories !== "function") {
toast("Folder picker unavailable in this session.");
return;
}
if (loadFromBtn.disabled) return;
loadFromBtn.disabled = true;
setTimeout(() => { loadFromBtn.disabled = false; }, 0);
showFolderPicker({
title: "Load snapshots from",
titleTooltip: "Switch the snapshot library this Load is reading from.",
initialPath: libraryPath,
browseDirectories,
createBrowseDirectory,
onUseFolder: async (chosen) => {
if (typeof saveSettings === "function") {
try { await saveSettings(chosen); }
catch (err) {
console.error("[Koolook] saveSettings failed:", err);
toast(`Could not save library path: ${err.message}`);
return;
}
}
libraryPath = chosen;
renderLibRow(chosen);
refresh();
toast(`Loading snapshots from: ${chosen}.`);
},
});
}
function renderLibRow(path) {
libraryPath = path || "";
const leaf = libraryPath ? pathLeaf(libraryPath) : "(unavailable)";
libName.textContent = leaf;
libPath.textContent = libraryPath || "Path unavailable";
libRow.title = libraryPath || "";
}
const spacer = document.createElement("span");
spacer.className = "koolook-folder-picker-spacer";
({ overlay, titleEl } = makeModalShell({
title: "Load snapshot",
titleTooltip: "Restore the sidebar state from a preset file.",
body,
actions: [loadFromBtn, confirmText, spacer, loadSavedBtn, loadLatestBtn, closeBtn],
}));
// Escape-to-cancel for inline delete. The modal shell already wires
// Escape to overlay close; intercept BEFORE that fires so a pending
// delete just unwinds the row state without dismissing the whole
// dialog. ``capture: true`` runs this listener before the shell's.
// Listener is captured by name so we can tear it down on close —
// ``document.addEventListener`` does not auto-clean up when the
// dialog overlay is removed, and repeated open/close cycles would
// otherwise stack one stale handler per session. Tie the cleanup to
// ``overlay.remove`` (the canonical close path that every dismissal
// route already goes through, thanks to the shell's wrapping).
const escapeForDelete = (e) => {
if (e.key !== "Escape") return;
if (!pendingDelete) return;
e.stopPropagation();
cancelDelete();
};
document.addEventListener("keydown", escapeForDelete, { capture: true });
const overlayRemoveBeforeCleanup = overlay.remove.bind(overlay);
overlay.remove = () => {
document.removeEventListener("keydown", escapeForDelete, { capture: true });
overlayRemoveBeforeCleanup();
};
refresh();
if (typeof getLibraryInfo === "function") {
getLibraryInfo().then((info) => {
if (info && typeof info.path === "string") renderLibRow(info.path);
else { libName.textContent = "Library path unavailable"; libPath.textContent = ""; }
}).catch(() => {
libName.textContent = "Library path unavailable";
libPath.textContent = "";
});
} else {
libName.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.
// =============================================================================
// =============================================================================
// Folder-browse picker — issue #137, mockup §6 (navigate-into model).
//
// Replaces the legacy ``openBrowseDialog`` (a select-driven "pick a sibling"
// browser scoped to the Settings dialog) with a top-level reusable picker
// driven by a path input. The path input is the source of truth: typing a
// path + Enter navigates, clicking a folder row drills in (and updates the
// input), ↑ Up climbs one level. Files are shown greyed below the
// directories as a "yes, this is the folder I expected" affordance — they
// do NOT count toward the selection and clicking them is inert.
//
// Visual spec: docs/designs/snapshot-dialogs.html §6.
// Visual harness: docs/designs/_harness/folder-picker.html.
// =============================================================================
export function showFolderPicker({
title,
titleTooltip,
initialPath,
browseDirectories,
createBrowseDirectory,
onUseFolder,
onCancel,
}) {
const body = document.createElement("div");
body.className = "koolook-folder-picker";
// Toolbar row: ↑ Up + path input. The toolbar is rebuilt in place when
// the New folder… flow opens (a transient name-input replaces the
// path-input UI), and restored on Create / Cancel.
const toolbar = document.createElement("div");
toolbar.className = "koolook-folder-picker-toolbar";
const upBtn = makeModalButton({
label: "↑ Up",
onClick: () => { if (currentParent) navigate(currentParent); },
});
upBtn.classList.add("koolook-folder-picker-up");
const pathInput = document.createElement("input");
pathInput.className = "koolook-modal-input koolook-folder-picker-path";
pathInput.value = initialPath || "";
pathInput.spellcheck = false;
pathInput.title = "Type or paste a path and press Enter, or click a subfolder below.";
pathInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const target = pathInput.value.trim();
if (target) navigate(target);
}
});
toolbar.appendChild(upBtn);
toolbar.appendChild(pathInput);
body.appendChild(toolbar);
// List body.
const list = document.createElement("div");
list.className = "koolook-folder-picker-list";
body.appendChild(list);
let currentPath = initialPath || "";
let currentParent = "";
let overlay;
const close = () => overlay.remove();
function renderState(text, isError) {
list.innerHTML = "";
const el = document.createElement("div");
el.className = isError
? "koolook-folder-picker-empty koolook-folder-picker-error"
: "koolook-folder-picker-empty";
el.textContent = text;
list.appendChild(el);
}
function revealPathEnd() {
// Even with `direction: rtl` the caret/scroll position needs a nudge
// after programmatic value changes so the END of the path is on-
// screen. Defer to the next tick so layout has settled.
setTimeout(() => { pathInput.scrollLeft = pathInput.scrollWidth; }, 0);
}
function renderListing(data) {
list.innerHTML = "";
const dirs = data.dirs || [];
const files = data.files || [];
if (!dirs.length && !files.length) {
renderState("Folder is empty.");
return;
}
for (const d of dirs) {
const row = document.createElement("button");
row.type = "button";
row.className = "koolook-folder-picker-row";
row.title = `Open ${d.name}`;
const icon = document.createElement("span");
icon.className = "koolook-folder-picker-icon";
icon.textContent = "📁";
row.appendChild(icon);
const name = document.createElement("span");
name.className = "koolook-folder-picker-name";
name.textContent = d.name;
row.appendChild(name);
row.addEventListener("click", () => navigate(d.path));
list.appendChild(row);
}
for (const f of files) {
const row = document.createElement("div");
row.className = "koolook-folder-picker-row koolook-folder-picker-row-file";
row.title = "Files are shown for context only — Use this folder commits the folder, not a file.";
const icon = document.createElement("span");
icon.className = "koolook-folder-picker-icon";
icon.textContent = "📄";
row.appendChild(icon);
const name = document.createElement("span");
name.className = "koolook-folder-picker-name";
name.textContent = f.name;
row.appendChild(name);
list.appendChild(row);
}
}
async function navigate(target) {
renderState("Loading…");
useBtn.disabled = true;
upBtn.disabled = true;
newFolderBtn.disabled = true;
let data;
try {
// ``{files: true}`` is the opt-in for the files-for-context
// affordance — the listing endpoint returns greyed file
// entries below the directory rows so the user can verify
// "yes, this is the folder I expected" before committing.
data = await browseDirectories(target, { files: true });
} catch (e) {
renderState(e.message || String(e), true);
// Keep the input value as-typed so the user can fix it and retry.
useBtn.disabled = true;
upBtn.disabled = !currentParent;
newFolderBtn.disabled = false;
return;
}
currentPath = data.path;
currentParent = data.parentPath || "";
pathInput.value = data.path;
revealPathEnd();
useBtn.disabled = false;
upBtn.disabled = !currentParent;
newFolderBtn.disabled = typeof createBrowseDirectory !== "function";
renderListing(data);
}
// Footer buttons.
const newFolderBtn = makeModalButton({
label: "New folder…",
onClick: () => openNewFolderInput(),
});
const cancelBtn = makeModalButton({
label: "Cancel",
onClick: () => {
close();
if (onCancel) onCancel();
},
});
const useBtn = makeModalButton({
label: "Use this folder",
primary: true,
onClick: () => {
const chosen = pathInput.value.trim();
if (!chosen) return;
close();
if (onUseFolder) onUseFolder(chosen);
},
});
// Inline new-folder input — swaps toolbar contents transiently. Cleaner
// than another modal-on-modal: the picker stays open, the body still
// shows the destination's existing children, and the user has visual
// continuity about *where* the new folder is being created.
function openNewFolderInput() {
if (typeof createBrowseDirectory !== "function") return;
// Snapshot the parent at the moment New folder… is clicked, so a
// late navigation doesn't redirect the create call. (Defensive —
// we disable nav while the input is open, but the assignment makes
// the contract explicit.)
const parentForCreate = currentPath;
toolbar.innerHTML = "";
toolbar.classList.add("koolook-folder-picker-toolbar-newfolder");
const label = document.createElement("span");
label.className = "koolook-folder-picker-newfolder-label";
// Show only the leaf folder name to keep the toolbar height stable;
// the full path is visible on hover via the ``title`` attribute and
// was on screen in the path input the user just dismissed.
label.textContent = `New folder in ${pathLeaf(parentForCreate)}:`;
label.title = parentForCreate;
toolbar.appendChild(label);
const nameInput = document.createElement("input");
nameInput.className = "koolook-modal-input koolook-folder-picker-newfolder-input";
nameInput.placeholder = "untitled";
nameInput.spellcheck = false;
toolbar.appendChild(nameInput);
function restoreToolbar() {
toolbar.classList.remove("koolook-folder-picker-toolbar-newfolder");
toolbar.innerHTML = "";
toolbar.appendChild(upBtn);
toolbar.appendChild(pathInput);
revealPathEnd();
}
const createBtn = makeModalButton({
label: "Create",
primary: true,
onClick: async () => {
const name = nameInput.value.trim();
if (!name) { nameInput.focus(); return; }
createBtn.disabled = true;
cancelInline.disabled = true;
try {
const r = await createBrowseDirectory(parentForCreate, name);
restoreToolbar();
// Navigate INTO the newly created folder so the user can
// immediately commit or drill further.
await navigate(r.path || `${parentForCreate}/${name}`);
} catch (e) {
renderState(e.message || String(e), true);
createBtn.disabled = false;
cancelInline.disabled = false;
}
},
});
const cancelInline = makeModalButton({
label: "Cancel",
onClick: () => restoreToolbar(),
});
toolbar.appendChild(createBtn);
toolbar.appendChild(cancelInline);
nameInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); createBtn.click(); }
if (e.key === "Escape") { e.preventDefault(); restoreToolbar(); }
});
setTimeout(() => nameInput.focus(), 0);
}
const spacer = document.createElement("span");
spacer.className = "koolook-folder-picker-spacer";
({ overlay } = makeModalShell({
title,
titleTooltip,
body,
actions: [newFolderBtn, spacer, cancelBtn, useBtn],
}));
// Initial load — kick off with whatever path the caller supplied (or
// the empty string, which the server resolves to the configured
// library dir).
navigate(initialPath || "");
}
// =============================================================================
// 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);
}