// SPDX-FileCopyrightText: 2025-2026 Kforge Labs // 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 's `value`. The action values are bare lowercase strings // because they're only ever compared against the action , [1] is the // immediate children of [0]'s value, etc. Each child select includes a // "(save in )" 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