| |
| |
|
|
| |
| |
| |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| let escHandler = null; |
| const close = () => { |
| if (escHandler) { |
| document.removeEventListener("keydown", escHandler); |
| escHandler = null; |
| } |
| if (overlay.parentNode) overlay.remove(); |
| }; |
| |
| |
| const origRemove = overlay.remove.bind(overlay); |
| overlay.remove = () => { |
| if (escHandler) { |
| document.removeEventListener("keydown", escHandler); |
| escHandler = null; |
| } |
| origRemove(); |
| }; |
|
|
| overlay.appendChild(modal); |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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"); |
|
|
| |
| |
| |
| |
| 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"); |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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, |
| |
| |
| onClick: () => { settled = true; overlay.remove(); onConfirm(); }, |
| }); |
| ({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] })); |
|
|
| |
| |
| |
| |
| const baseRemove = overlay.remove.bind(overlay); |
| overlay.remove = () => { |
| settleCancel(); |
| baseRemove(); |
| }; |
| } |
|
|
| export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, defaultModule = false, onSave }) { |
| const body = document.createElement("div"); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| 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) { |
| |
| |
| |
| 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 (!preselected) sel.value = SAVE_HERE; |
| sel.addEventListener("change", () => onCascadeChange(level)); |
| return sel; |
| } |
|
|
| function onCascadeChange(changedLevel) { |
| |
| while (cascadeSelects.length > changedLevel + 1) { |
| const old = cascadeSelects.pop(); |
| old.remove(); |
| } |
|
|
| const top = cascadeSelects[0]; |
| if (top.value === NEW_TOP) { |
| newDirInput.style.display = ""; |
| |
| |
| while (cascadeSelects.length > 1) { |
| const old = cascadeSelects.pop(); |
| old.remove(); |
| } |
| applyState(); |
| return; |
| } |
| newDirInput.style.display = "none"; |
|
|
| |
| |
| 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); |
| } |
|
|
| |
| const deeper = buildChildSelect(path.length, path); |
| if (deeper) { |
| cascadeContainer.appendChild(deeper); |
| cascadeSelects.push(deeper); |
| |
| |
| |
| 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; |
| } |
|
|
| |
| const topSelect = buildTopSelect(); |
| cascadeContainer.appendChild(topSelect); |
| cascadeSelects.push(topSelect); |
| |
| |
| if (topNames.length === 0) newDirInput.style.display = ""; |
|
|
| |
| const baseLbl = modalLabel("Base on existing"); |
| body.appendChild(baseLbl); |
|
|
| const baseSelect = document.createElement("select"); |
| baseSelect.className = "koolook-modal-select"; |
| body.appendChild(baseSelect); |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function getCandidatesForBase() { |
| const path = getSelectedPath(); |
| if (!path || path.length === 0) return []; |
| |
| |
| 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 } = {}) { |
| |
| |
| |
| const dirIsNew = cascadeSelects[0]?.value === NEW_TOP; |
| const candidates = dirIsNew ? [] : getCandidatesForBase(); |
| const hasBase = candidates.length > 0; |
|
|
| |
| |
| |
| |
| if (hasBase) { |
| baseLbl.style.display = ""; |
| baseSelect.style.display = ""; |
| rebuildBaseOptions(candidates); |
| } else { |
| baseLbl.style.display = "none"; |
| baseSelect.style.display = "none"; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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"; |
| |
| |
| if (actionSelect.value !== ACTION_NEW) actionSelect.value = ACTION_NEW; |
| } |
|
|
| |
| const action = actionSelect.value; |
| if (action === ACTION_USE_EXISTING) { |
| |
| 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 { |
| |
| nameInput.readOnly = false; |
| } |
| } |
| } |
|
|
| actionSelect.addEventListener("change", () => applyState({ refocusName: true })); |
| baseSelect.addEventListener("change", () => { |
| if (actionSelect.value === ACTION_MODIFY_EXISTING) { |
| nameInput.value = baseSelect.value; |
| } |
| }); |
|
|
| |
| |
| |
| onCascadeChange(0); |
| applyState(); |
| if (cascadeSelects[0].value === NEW_TOP) newDirInput.focus(); |
|
|
| let overlay; |
| const submit = async () => { |
| |
| |
| |
| |
| const dirPath = getSelectedPath(); |
| if (!dirPath || dirPath.length === 0) { |
| |
| 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(); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| 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(); |
| |
| |
| |
| 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(); |
| } |
|
|
| |
| |
| |
| function pathParentLeaf(path) { |
| const parts = String(path || "").split(/[\\/]+/).filter(Boolean); |
| return parts.length >= 2 ? parts[parts.length - 2] : pathLeaf(path); |
| } |
|
|
| |
| |
| |
| |
| |
| 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"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| (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; |
| } |
|
|
| |
| 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 () => { |
| |
| installBtn.disabled = true; |
| copyBtn.disabled = true; |
|
|
| |
| 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, |
| }); |
|
|
| |
| 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."); |
| }); |
| } |
| }); |
| })(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| function todayStamp() { |
| return new Date().toISOString().slice(0, 10); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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 || ""); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function showSaveSnapshotDialog({ |
| getCurrentPresetName, |
| setCurrentPresetName, |
| presetExists, |
| writePreset, |
| gatherSnapshot, |
| sanitizeName, |
| getLibraryInfo, |
| saveSettings, |
| browseDirectories, |
| createBrowseDirectory, |
| revealPresetFolder, |
| markStateSaved, |
| onToast, |
| }) { |
| const toast = onToast || (() => {}); |
| const current = getCurrentPresetName(); |
|
|
| |
| |
| |
| |
| let originalLibraryPath = ""; |
| let currentLibraryPath = ""; |
| let libraryRevealInFlight = false; |
|
|
| const body = document.createElement("div"); |
|
|
| |
| |
| |
| |
| |
| |
| 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 = ""; |
| }); |
| } |
|
|
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| |
| |
| 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() { |
| |
| |
| |
| |
| |
| |
| |
| |
| 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": |
| |
| 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(); |
|
|
| |
| |
| |
| 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; |
| } |
| |
| |
| await doOverwrite(current); |
| }); |
|
|
| const spacer = document.createElement("span"); |
| spacer.className = "koolook-folder-picker-spacer"; |
|
|
| let overlay; |
| const close = () => overlay.remove(); |
|
|
| |
| |
| |
| |
| |
| |
| 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}`); |
| } |
| }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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}".`); |
| |
| |
| |
| setTimeout(close, 600); |
| } catch (e) { |
| console.error("[Koolook] preset save failed:", e); |
| toast(`Could not save preset: ${e.message}`); |
| updatePrimaryLabel(); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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], |
| })); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 = ""; |
|
|
| |
| |
| |
| 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(); |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| async function doNamedLoad(preview) { |
| |
| |
| 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 { |
| |
| |
| |
| |
| |
| 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(); |
| |
| |
| |
| |
| |
| |
| |
| |
| if (picksOk && workflowsOk) { |
| setCurrentPresetName(preview.fileName); |
| |
| |
| |
| 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}`; |
| |
| if (onChoose) { |
| try { |
| const snap = await readPreset(item.fileName, { dir: item.dir }); |
| close(); |
| |
| |
| 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(); |
| |
| |
| |
| let restoredName = null; |
| if (item.dir !== "_unsaved_autosave" && item.dir.endsWith("_autosave")) { |
| restoredName = item.dir.slice(0, -"_autosave".length); |
| } |
| if (picksOk && workflowsOk) { |
| setCurrentPresetName(restoredName); |
| |
| |
| |
| |
| |
| |
| |
| |
| if (typeof markStateAutosaved === "function") markStateAutosaved(); |
| const backupSuffix = backupName ? ` (backup: ${backupName})` : ""; |
| toast( |
| `Restored auto-save${restoredName ? ` of "${restoredName}"` : ""}` + |
| ` — Quick Save to commit to the named file${backupSuffix}.` |
| ); |
| } else if (picksOk || workflowsOk) { |
| setCurrentPresetName(null); |
| toast( |
| `Restored "${tooltipName}" — PARTIAL: ` + |
| `picks ${picksOk ? "OK" : "FAIL"}, ` + |
| `workflows ${workflowsOk ? "OK" : "FAIL"}.` |
| ); |
| } else { |
| setCurrentPresetName(null); |
| toast(`Restored "${tooltipName}" in memory but persist failed.`); |
| } |
| } catch (e) { |
| console.error("[Koolook] autosave restore failed:", e); |
| toast(`Could not restore "${tooltipName}": ${e.message}`); |
| } |
| } |
|
|
| |
| |
| |
| |
| function onPresetClick(preview, rowEl) { |
| |
| 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; |
| } |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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(); |
| } |
| } |
|
|
| |
| 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], |
| })); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 = ""; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function showFolderPicker({ |
| title, |
| titleTooltip, |
| initialPath, |
| browseDirectories, |
| createBrowseDirectory, |
| onUseFolder, |
| onCancel, |
| }) { |
| const body = document.createElement("div"); |
| body.className = "koolook-folder-picker"; |
|
|
| |
| |
| |
| 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); |
|
|
| |
| 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() { |
| |
| |
| |
| 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 { |
| |
| |
| |
| |
| data = await browseDirectories(target, { files: true }); |
| } catch (e) { |
| renderState(e.message || String(e), true); |
| |
| 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); |
| } |
|
|
| |
| 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); |
| }, |
| }); |
|
|
| |
| |
| |
| |
| function openNewFolderInput() { |
| if (typeof createBrowseDirectory !== "function") return; |
| |
| |
| |
| |
| 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"; |
| |
| |
| |
| 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(); |
| |
| |
| 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], |
| })); |
|
|
| |
| |
| |
| navigate(initialPath || ""); |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|