// 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, toast } from "./constants.js"; import { formatLocalStamp } from "./format_time.js"; import { listDirectoryNames, dirOf } from "./workflows_store.js"; import { detectManager, loadMappings, resolvePicksToInstall, queueInstallGitUrl, startQueue, pollUntilDone, reboot, } from "./installer.js"; function makeModalShell({ title, body, actions }) { const overlay = document.createElement("div"); overlay.className = "koolook-modal-overlay"; const modal = document.createElement("div"); modal.className = "koolook-modal"; const titleEl = document.createElement("div"); titleEl.className = "koolook-modal-title"; titleEl.textContent = title; modal.appendChild(titleEl); if (body) modal.appendChild(body); const actionsEl = document.createElement("div"); actionsEl.className = "koolook-modal-actions"; for (const action of actions) actionsEl.appendChild(action); modal.appendChild(actionsEl); // Centralized close so every dismissal path (Escape, overlay click, Cancel // button, OK button calling overlay.remove()) tears down the document-level // keydown listener too. Without this, every modal opened leaks one listener. let escHandler = null; const close = () => { if (escHandler) { document.removeEventListener("keydown", escHandler); escHandler = null; } if (overlay.parentNode) overlay.remove(); }; // Make overlay.remove() route through close so existing call sites that // do `overlay.remove()` continue to work (and clean up the listener too). const origRemove = overlay.remove.bind(overlay); overlay.remove = () => { if (escHandler) { document.removeEventListener("keydown", escHandler); escHandler = null; } origRemove(); }; overlay.appendChild(modal); // Click-to-dismiss with drag-out-of-input protection. A single click // fires `mousedown` then `mouseup` then `click` — and `click.target` // is the deepest common ancestor of mousedown's and mouseup's targets. // So if you drag-select text inside an input and release in the // overlay's dark area, click fires with target=overlay and the modal // would otherwise close mid-edit. Track whether the gesture STARTED // on the overlay too; only dismiss if both ends did. let mouseDownOnOverlay = false; overlay.addEventListener("mousedown", (e) => { mouseDownOnOverlay = (e.target === overlay); }); overlay.addEventListener("click", (e) => { if (e.target === overlay && mouseDownOnOverlay) close(); mouseDownOnOverlay = false; }); escHandler = (e) => { if (e.key === "Escape") close(); }; document.addEventListener("keydown", escHandler); document.body.appendChild(overlay); return { overlay, modal, close }; } function makeModalButton({ label, primary, danger, onClick }) { const btn = document.createElement("button"); btn.className = "koolook-modal-btn"; if (primary) btn.classList.add("koolook-modal-btn-primary"); if (danger) btn.classList.add("koolook-modal-btn-danger"); btn.textContent = label; btn.addEventListener("click", onClick); return btn; } // Tiny factory for the small-caps label rows above modal inputs/selects. // Replaces the four-line `createElement` + `className` + `textContent` + // `appendChild` block that appears 7× across the modal helpers below. function modalLabel(text) { const lbl = document.createElement("label"); lbl.className = "koolook-modal-label"; lbl.textContent = text; return lbl; } export function showInputModal({ title, label, defaultValue, placeholder, confirmLabel, onSubmit, subtitle }) { const body = document.createElement("div"); // Optional subtitle — string (rendered in the standard pathline style) or // a pre-built HTMLElement (caller controls styling AND can mutate after // the modal is open, e.g. to fill in an asynchronously-fetched library // path). Sits between title and label so the user sees context first. if (subtitle) { if (typeof subtitle === "string") { const sub = document.createElement("div"); sub.className = "koolook-modal-pathline"; sub.textContent = subtitle; body.appendChild(sub); } else { body.appendChild(subtitle); } } body.appendChild(modalLabel(label || "Name")); const input = document.createElement("input"); input.className = "koolook-modal-input"; input.value = defaultValue || ""; input.placeholder = placeholder || ""; body.appendChild(input); let overlay; const submit = () => { const v = input.value.trim(); if (!v) { input.focus(); return; } overlay.remove(); onSubmit(v); }; input.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); const cancel = makeModalButton({ label: "Cancel", onClick: () => overlay.remove() }); const ok = makeModalButton({ label: confirmLabel || "OK", primary: true, onClick: submit }); ({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] })); setTimeout(() => { input.focus(); input.select(); }, 0); } export function showConfirmModal({ title, message, confirmLabel, cancelLabel, danger, onConfirm, onCancel, subtitle }) { const body = document.createElement("div"); // Optional subtitle — same contract as showInputModal / showThreeWayDialog. // Used by the Save-as-new overwrite-confirm path to surface the library // breadcrumb so the user keeps "where am I writing to" context across // the multi-modal Save flow (input modal → existence check → confirm). if (subtitle) { if (typeof subtitle === "string") { const sub = document.createElement("div"); sub.className = "koolook-modal-pathline"; sub.textContent = subtitle; body.appendChild(sub); } else { body.appendChild(subtitle); } } const msg = document.createElement("div"); msg.className = "koolook-modal-message"; msg.textContent = message; body.appendChild(msg); let overlay; // `onCancel` lets callers that wrap this in a Promise actually settle // when the user cancels — without it, the recovery toast's "Discard // offline copy" Promise stayed pending forever (button stuck in // "Discarding…"), and similarly `dropPlaceholdersForPacks` (issue // #84 part 2) leaked a pending Promise on every cancel. Optional; // existing callers that ignore cancel keep working unchanged. const cancel = makeModalButton({ label: cancelLabel || "Cancel", onClick: () => { overlay.remove(); if (typeof onCancel === "function") onCancel(); }, }); const ok = makeModalButton({ label: confirmLabel || "OK", primary: !danger, danger, onClick: () => { overlay.remove(); onConfirm(); }, }); ({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] })); } export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, defaultModule = false, onSave }) { const body = document.createElement("div"); // ---- Directory (cascading picker) ---- // Each cascade level is a '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