// SPDX-FileCopyrightText: 2025-2026 Kforge Labs // SPDX-License-Identifier: GPL-3.0-only // ============================================================================= // Sidebar tree — section-registry engine, data gathering, DOM row factories, // folder builder, context menus for workflow/directory rows, and the panel // renderer (search, action bars, tree mount, event subscriptions). // // `pathStates` is module-private — folder expansion state survives across // re-renders triggered by saves, picks-changed events, or storage events. // Stale path keys are pruned every render against the set of paths actually // emitted, so renames and deletes don't leak entries. // ============================================================================= import { REPOS, ROOT_GROUP_LABEL, WORKFLOWS_GROUP_LABEL, STORAGE_KEY, WORKFLOWS_FALLBACK_KEY, PICKS_CHANGED_EVENT, WORKFLOWS_CHANGED_EVENT, SNAPSHOT_STATUS_CHANGED_EVENT, GROUP_MODE_KEY, GROUP_MODE_DEFAULT, MODULE_TAG, GUIDE_URL, GITHUB_REPO_URL, ensureStyle, toast, compareNames, } from "./constants.js"; import { formatLocalStamp } from "./format_time.js"; import { loadUserPicks, removeFromMyPicks, notifyPicksChanged, addToMyPicks, loadAutoPullHidden, hideAutoPullType, setPicksRenderSource, clearPicksRenderSource, } from "./picks_store.js"; import { persistMutation, listDirectoryNames, dirOf, addDirectory, renameDirectory, deleteDirectory, saveWorkflowEntry, copyWorkflowIntoStore, copyWorkflowIntoLiveStore, copyFolderIntoStore, copyFolderIntoLiveStore, archiveWorkflow, unarchiveWorkflow, renameWorkflow, deleteWorkflow, moveWorkflow, moveDirectory, clearArchive, cleanUpArchive, getArchiveCleanupPlan, getArchiveDisplayInfo, pathsEqual, getWorkflowGraph, getWorkflowTags, workflowHasTag, PUBLISHED_TAG, isWorkflowModule, addTag, removeTag, getAllWorkflowsForExport, setWorkflowsRenderSource, clearWorkflowsRenderSource, } from "./workflows_store.js"; import { diffPicks, diffWorkflows, getWorkflowEntryFromStore } from "./snapshot_diff.js"; import { checkForUpdate, renderUpdateFooter } from "./update_check.js"; import { serializeFullCanvas, serializeSelection, canvasIsNonEmpty, captureWorkflowApiPrompt, loadWorkflowOntoCanvas, insertWorkflowOntoCanvas, insertNode, getSelectedNodeTypes, getSelectedNodeCount, getCanvasNodeCount, dropPlaceholdersForPacks, } from "./canvas_io.js"; import { discoverMissingPacks } from "./installer.js"; import { showInputModal, showConfirmModal, showSaveWorkflowModal, showContextMenu, showTagsModal, showPublishSetupModal, showInstallMissingModal, showSaveSnapshotDialog, showLoadSnapshotDialog, } from "./modals.js"; import { attachHoverPreview, teardownPreview } from "./node_preview.js"; import { publishSavedWorkflowSetup, revealPublishedSetupFolder } from "./published_setups.js"; import { sanitizeName, gatherSnapshot, applySnapshot, listPresets, readPreset, writePreset, presetExists, deletePreset, getLibraryInfo, saveSettings, browseDirectories, createBrowseDirectory, getCurrentPresetName, setCurrentPresetName, exportStarterPreset, writePreLoadAutosave, markStateSaved, markStateAutosaved, getSnapshotStatus, listAutosaves, revealPresetFolder, } from "./snapshot.js"; import { resolveFolderExpanded } from "./tree_expansion.js"; // ============================================================================= // Built-in section IDs. These are also pathState key prefixes — every // `pathStates` entry begins with one of these strings followed by `/`. The // engine handles the prefixing for paths emitted via `ctx.folder({path: …})`, // but `renderPanel`'s save flow has to construct pin keys directly, so the // constants are exported here (module-private) instead of duplicated as // literals. // ============================================================================= const SECTION_ID_NODES = "nodes"; const SECTION_ID_WORKFLOWS = "workflows"; const SECTION_ID_TAGS = "tags"; // When true, the Workflows section is pruned to only `published`-tagged // workflows (folders kept for the ones that remain, all force-expanded) so // the maintainer can see every published setup in its origin structure at a // glance. Toggled by the "P" Tools button; read by gatherDirsAt + renderTree. let publishedOnly = false; const SVG_NS = "http://www.w3.org/2000/svg"; const TOOLBAR_ICONS = { loadSnapshot: { kind: "letter", text: "L" }, saveSnapshot: { kind: "letter", text: "S" }, compareSnapshot: { kind: "letter", text: "A/B" }, help: { kind: "letter", text: "H" }, publishedFilter: { kind: "letter", text: "P" }, exportStarter: { kind: "letter", text: "E" }, installMissing: { kind: "letter", text: "I" }, dropMissing: { kind: "svg", shapes: [ ["path", { d: "M10 10 5 5" }], ["path", { d: "M5 5v5" }], ["path", { d: "M5 5h5" }], ["path", { d: "m14 10 5-5" }], ["path", { d: "M19 5v5" }], ["path", { d: "M19 5h-5" }], ["path", { d: "m10 14-5 5" }], ["path", { d: "M5 19v-5" }], ["path", { d: "M5 19h5" }], ["path", { d: "m14 14 5 5" }], ["path", { d: "M19 19v-5" }], ["path", { d: "M19 19h-5" }], ["circle", { cx: "12", cy: "12", r: "1.2" }], ], }, repoMode: { kind: "stair" }, categoryMode: { kind: "list" }, saveWorkflow: { kind: "square" }, }; // ============================================================================= // Folder expansion state — Map; truthy = expanded. // Path keys are auto-prefixed by the engine with the owning section's id, so // section authors only write segment-relative paths. // // `pathStates` is the LIVE panel's state. In Compare mode the snapshot panel // renders with its OWN `comparePathStates` (swapped in via withComparePathStates // for the duration of its render) so the two panels don't share keys: renderTree // prunes the active map to the rendering panel's paths, so without separate maps // a copy's re-render of one panel would prune (collapse) the other's folders // (#197). `let` so the active map can be swapped. let pathStates = new Map(); const comparePathStates = new Map(); // Paths flagged by `pinExpanded` to be force-opened on the next render. The // set is consumed each render: kept entries are written to pathStates and the // set is cleared. Used by save flows that mutate state and want the // destination folder open after the resulting re-render. const pinnedPaths = new Set(); // Section registry. Sections render in declaration order, separated by // dividers. Built-in sections register at module load (bottom of this file). const SECTIONS = []; // ============================================================================= // Engine — public surface (3 functions) // ============================================================================= /** * @typedef {Object} FolderOpts * @property {string} name * @property {number} [count] * @property {string} [iconKind] "favorites" | "workflows" * | "archive" | "folder". * @property {string} path Segment-relative; engine * prefixes with section.id. * @property {boolean} [startExpanded=false] * @property {boolean} [forceExpandedWhenFiltered=true] * @property {(e: MouseEvent) => void} [onContextMenu] * @property {(sub: SectionCtx) => void} build Populate the sub-folder. * * @typedef {Object} SectionCtx * @property {*} data Result returned by `gather`. * Top-level ctx only; nested * builds close over their * own data via the outer * `build` scope. * @property {string} query Lowercased+trimmed search. * @property {boolean} isFiltered True when query is non-empty. * @property {(opts: FolderOpts) => void} folder Append a sub-folder. * @property {(opts: { row: HTMLElement }) => void} leaf * Append a leaf row. * * @typedef {Object} SectionSpec * @property {string} id Stable; used as the * pathState root for * this section. * @property {string} label Group folder header. * @property {string} [iconKind] Same enum as FolderOpts. * @property {string} [emptyMessage] Shown when the section * gathers nothing AND no * search is active. * @property {(query: string) => { total: number }} gather * Returns a result with at * least `total`. Other * fields are opaque to * the engine; the section * reads them via * `ctx.data` in `build`. * @property {(ctx: SectionCtx) => void} build Called when total > 0; * uses `ctx.folder` and * `ctx.leaf` to populate * the section tree. */ /** * Register a tree section. * * @param {SectionSpec} spec */ function addSection(spec) { SECTIONS.push(spec); } /** * Mark paths as expanded for the next render. The paths must include the * section-id prefix (e.g. "workflows", "workflows/MyDir"). Called from save * flows so the destination folder is open after the resulting re-render. * * @param {string[]} paths */ function pinExpanded(paths) { for (const p of paths) pinnedPaths.add(p); } /** * Spotlight effect for newly-saved picks: collapse every folder under the * Nodes section, then pin the just-added node's pack-folder + subcategory- * folder open. Designed as a pedagogical aid — when the user adds a node * via the toolbar `+` or the right-click "Add to Kforge Labs", the sidebar * snaps to focus on exactly where that node lives in the pack hierarchy, * helping newcomers internalize which pack/category each node belongs to. * * Caller is responsible for triggering a re-render afterwards (typically * via `notifyPicksChanged()`); this function only mutates `pathStates` + * `pinnedPaths`. * * Multi-add (caller passes several types): every pack/subcategory hit by * the additions is pinned open. So adding 5 nodes from 3 packs lights up * all 3 pack folders simultaneously rather than just the last. * * Idempotent / safe: types not in the LiteGraph registry are silently * skipped (no-op). Empty input → no-op (no collapse, no pin). * * @param {string[]} typeNames */ export function spotlightAddedPicks(typeNames) { if (!Array.isArray(typeNames) || typeNames.length === 0) return; const pathsToExpand = new Set(); for (const t of typeNames) { const loc = findPackPathForType(t); if (!loc) continue; pathsToExpand.add(`${SECTION_ID_NODES}/${loc.packLabel}`); pathsToExpand.add(`${SECTION_ID_NODES}/${loc.packLabel}/${loc.sub}`); } if (pathsToExpand.size === 0) return; // Collapse every existing Nodes-section entry (the section root + every // pack/subcategory). The render-time fallback (`startExpanded`) keeps // the section header itself open since `addSection` registers it with // `startExpanded: true`; pack folders default to closed, which is what // we want for everything except the spotlighted location. for (const key of [...pathStates.keys()]) { if (key === SECTION_ID_NODES || key.startsWith(SECTION_ID_NODES + "/")) { pathStates.delete(key); } } pinExpanded([...pathsToExpand]); } /** * Single render entry point. Idempotent — safe to call from search input, * picks-changed, workflows-changed, or storage events. * * @param {{ treeEl: HTMLElement, query: string }} opts */ function renderTree({ treeEl, query }) { // Tear down any active hover preview before detaching rows. `innerHTML // = ""` removes leaf rows without firing `mouseleave`, so a card that // was up at the moment of re-render would otherwise leak — its anchor // gone, the card's `pointer-events: none` blocks user dismissal, and // it'd persist until the next successful hover. The preview module // owns a singleton card; one call clears whatever's live. teardownPreview(); treeEl.innerHTML = ""; const q = (query || "").trim().toLowerCase(); const isFiltered = q.length > 0; const validPaths = new Set(); // Track which sections actually rendered this pass. Phase 3 only prunes // keys whose section rendered — sections gated empty by a search filter // keep their pathStates entries so clearing the filter restores user // expansions. const renderedSectionIds = new Set(); // Phase 1: gather every section. const gathered = SECTIONS.map(section => ({ section, result: section.gather(q), })); // Phase 2: render. Cross-section empty under a non-trivial search emits a // single placeholder; otherwise iterate sections with dividers between // non-empty ones. Phase 3 ALWAYS runs after this, so pins always apply. const anyVisible = gathered.some(({ result }) => result.total > 0); if (!anyVisible && isFiltered) { const empty = document.createElement("div"); empty.className = "koolook-empty"; empty.textContent = "No nodes or workflows match your search."; treeEl.appendChild(empty); } else { let appended = 0; for (const { section, result } of gathered) { if (result.total === 0) { // Per-section empty placeholder only when not filtered. Under // filter, the cross-section "no matches" message handles it. if (!isFiltered && section.emptyMessage) { if (appended > 0) appendDivider(treeEl); const el = document.createElement("div"); el.className = "koolook-empty"; el.textContent = section.emptyMessage; treeEl.appendChild(el); appended += 1; } continue; } if (appended > 0) appendDivider(treeEl); renderedSectionIds.add(section.id); const sectionPath = section.id; validPaths.add(sectionPath); // The Published filter force-expands the Workflows section (and its // subfolders) so every remaining published setup is visible at a // glance — without flipping the section's `isFiltered` flatten // behavior or expanding the Nodes/Tags sections. const sectionExpand = isFiltered || (publishedOnly && section.id === SECTION_ID_WORKFLOWS); const sectionFolder = buildFolder({ name: section.label, count: result.total, iconKind: section.iconKind, startExpanded: true, path: sectionPath, forceExpanded: sectionExpand, // Optional per-section right-click on the section header row. // `makeFolderRow` no-ops if undefined, so non-opting sections // (Nodes, Tags) keep today's behavior. onContextMenu: section.rootContextMenu, childrenBuilder: (children) => { const ctx = makeSectionCtx(children, sectionPath, sectionExpand, validPaths); ctx.data = result; ctx.query = q; ctx.isFiltered = isFiltered; section.build(ctx); }, }); treeEl.appendChild(sectionFolder); appended += 1; } } // Phase 3: prune pathStates + apply pins. Always runs so pins never leak // across renders. Prune is scoped to keys whose owning section actually // rendered — keys belonging to a section that was gated empty by the // current filter survive untouched, so the user's expansions return when // they clear the filter. const pinned = new Set(pinnedPaths); pinnedPaths.clear(); for (const key of [...pathStates.keys()]) { const slashIdx = key.indexOf("/"); const sectionId = slashIdx === -1 ? key : key.slice(0, slashIdx); if (!renderedSectionIds.has(sectionId)) continue; if (!validPaths.has(key) && !pinned.has(key)) { pathStates.delete(key); } } for (const p of pinned) pathStates.set(p, true); } // Section ctx — handed to `section.build(ctx)`. Each `ctx.folder({...})` call // recurses with a fresh sub-ctx whose path prefix is extended, so nested // folders only need to provide segment-relative paths. function makeSectionCtx(parentEl, prefix, isFiltered, validPaths) { return { folder({ name, count, iconKind, path, startExpanded = false, forceExpandedWhenFiltered = true, onContextMenu, build, draggablePayload, dropTarget, }) { const fullPath = prefix ? `${prefix}/${path}` : path; validPaths.add(fullPath); const folder = buildFolder({ name, count, iconKind, startExpanded, path: fullPath, forceExpanded: isFiltered, forceExpandedWhenFiltered, onContextMenu, draggablePayload, dropTarget, childrenBuilder: (children) => { const sub = makeSectionCtx(children, fullPath, isFiltered, validPaths); build(sub); }, }); parentEl.appendChild(folder); }, leaf({ row }) { parentEl.appendChild(row); }, }; } function appendDivider(treeEl) { const d = document.createElement("div"); d.className = "koolook-tree-divider"; treeEl.appendChild(d); } // ============================================================================= // Drag-and-drop primitives. Workflow leaf rows + directory folder rows are // draggable; directory folder rows + the synthetic Archive folder accept // drops. The MIME type "application/x-koolook-row" ensures we only react to // our own rows (not files / external drags). // // Drop semantics dispatched by handleDndDrop: // workflow → directory → moveWorkflow (no-op if same dir) // workflow → Archive → archive (move to dest dir first if cross-dir) // directory → directory → moveDirectory (cycle-checked in store) // ============================================================================= const DND_MIME = "application/x-koolook-row"; function decorateDraggable(row, payload) { row.draggable = true; row.addEventListener("dragstart", (e) => { e.stopPropagation(); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData(DND_MIME, JSON.stringify(payload)); }); } function decorateDropTarget(row, target) { row.addEventListener("dragover", (e) => { if (!Array.from(e.dataTransfer.types).includes(DND_MIME)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "move"; row.classList.add("koolook-drop-target"); }); row.addEventListener("dragleave", () => { row.classList.remove("koolook-drop-target"); }); row.addEventListener("drop", (e) => { row.classList.remove("koolook-drop-target"); if (!Array.from(e.dataTransfer.types).includes(DND_MIME)) return; e.preventDefault(); e.stopPropagation(); const raw = e.dataTransfer.getData(DND_MIME); if (!raw) return; let payload; try { payload = JSON.parse(raw); } catch { return; } handleDndDrop(payload, target); }); } function handleDndDrop(payload, target) { if (!payload || !target) return; if (payload.type === "workflow") { const srcPath = payload.path; const wfName = payload.name; if (target.kind === "dir") { // No-op when dropped onto its own directory. if (pathsEqual(srcPath, target.path)) return; persistMutation({ mutate: () => moveWorkflow(srcPath, wfName, target.path), onSuccess: () => toast(`Moved "${wfName}" to ${target.path.join(" / ")}.`), onNoOp: () => toast(`Could not move (name conflict in destination?).`), }); return; } if (target.kind === "archive") { // Drop on Archive folder of dir target.path → archive in that // dir. If the workflow lives in a different dir, move it first. const sameDir = pathsEqual(srcPath, target.path); persistMutation({ mutate: () => { if (!sameDir) { if (!moveWorkflow(srcPath, wfName, target.path)) return false; } return archiveWorkflow(target.path, wfName); }, onSuccess: () => { const where = sameDir ? "" : ` in ${target.path.join(" / ")}`; toast(`Archived "${wfName}"${where}.`); }, onNoOp: () => toast(`Could not archive (name conflict or workflow missing).`), }); return; } return; } if (payload.type === "directory") { if (target.kind !== "dir") return; // dirs don't drop onto Archive const srcParentPath = payload.parentPath; const dirName = payload.name; // Same-parent drop is a no-op. if (pathsEqual(srcParentPath, target.path)) return; // Self-drop (drop a dir onto its own row) is also a no-op. The // same-parent guard above misses this for root-level dirs because // the dir's own row carries `target.path = [...srcParentPath, name]`, // which doesn't equal `srcParentPath`. Without this check the // cycle guard in `moveDirectory` would still reject the move, but // the user would see a misleading "cycle, name collision, or // invalid target" toast for what's just a no-op gesture. if (pathsEqual([...srcParentPath, dirName], target.path)) return; persistMutation({ mutate: () => moveDirectory(srcParentPath, dirName, target.path), onSuccess: () => { const where = target.path.length === 0 ? "(root)" : target.path.join(" / "); toast(`Moved directory "${dirName}" into ${where}.`); }, onNoOp: () => toast(`Could not move directory (cycle, name collision, or invalid target).`), }); } } // ============================================================================= // Data gathering — nodes // ============================================================================= function subcategoryFor(category, categoryRoot) { if (!category) return "(uncategorized)"; if (category === categoryRoot) return "(root)"; if (categoryRoot && category.startsWith(categoryRoot + "/")) { return category.slice(categoryRoot.length + 1); } // Fallback: the node's category neither equals the repo's `categoryRoot` // nor lives under it. Reachable only via a logic error — typically a // `REPOS` entry whose `categoryRoot` no longer matches the upstream // node's actual category prefix (upstream renamed; repo config is // stale). Surface the misconfig in the console so it's debuggable // instead of producing silently mis-grouped sidebar entries. console.warn( `[Koolook] subcategoryFor fallback fired: category="${category}" did not match ` + `categoryRoot="${categoryRoot}". The repo config may need updating to track an ` + `upstream category rename.` ); const parts = category.split("/"); return parts.length > 1 ? parts.slice(1).join("/") : category; } // Given a node ID, compute the (packLabel, sub) pair the gather code would // place it under in the sidebar. Mirrors the precedence in // `gatherNodesByRepo` + `gatherUserPickPacks`: registered REPOS first // (`select="all"` matches by category prefix; `select=Array` matches by // explicit ID), then the user-picks fallback derives the pack label from // the first segment of the node's category. // // Returns `{packLabel, sub}` on success, `null` if the node isn't in the // LiteGraph registry (caller should skip silently — the node won't render // in the sidebar either way). Used by `spotlightAddedPicks` to know which // folder to expand after a save. // True if this type would be auto-pulled by a REPOS{select: "all"} entry // — i.e. its category prefix matches a repo's `categoryRoot`, OR it's // listed in a `select: [...]` array. Used by the `×` handler to decide // whether removing-from-picks is sufficient (no — registry-driven re-pull // would resurface it on the next render) or whether the type also needs to // be added to the auto-pull hidden list. function isAutoPulled(typeName) { const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; const nc = registry[typeName]; if (!nc) return false; const cat = nc.category || ""; for (const repo of REPOS) { if (repo.select === "all") { const root = repo.categoryRoot || ""; if (root && (cat === root || cat.startsWith(root + "/"))) return true; } else if (Array.isArray(repo.select) && repo.select.includes(typeName)) { return true; } } return false; } function findPackPathForType(typeName) { const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; const nc = registry[typeName]; if (!nc) return null; const cat = (nc && nc.category) || ""; for (const repo of REPOS) { if (repo.select === "all") { const root = repo.categoryRoot || ""; if (root && (cat === root || cat.startsWith(root + "/"))) { return { packLabel: repo.label, sub: subcategoryFor(cat, root) }; } } else if (Array.isArray(repo.select)) { if (repo.select.includes(typeName)) { return { packLabel: repo.label, sub: subcategoryFor(cat, repo.categoryRoot || "") }; } } } const packLabel = cat.split("/")[0] || "(uncategorized)"; return { packLabel, sub: subcategoryFor(cat, packLabel) }; } function matchesQuery(display, type, q) { if (!q) return true; return display.toLowerCase().includes(q) || type.toLowerCase().includes(q); } function gatherNodesByRepo(query) { const q = (query || "").trim().toLowerCase(); const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; const hidden = loadAutoPullHidden(); const out = []; for (const repo of REPOS) { let candidateIds; if (repo.select === "all") { const root = repo.categoryRoot || ""; candidateIds = Object.entries(registry) .filter(([type, nc]) => { if (hidden.has(type)) return false; const cat = (nc && nc.category) || ""; return root && (cat === root || cat.startsWith(root + "/")); }) .map(([type]) => type); } else if (Array.isArray(repo.select)) { candidateIds = repo.select.filter(t => registry[t] !== undefined && !hidden.has(t)); } else { candidateIds = []; } const subcats = new Map(); let total = 0; for (const type of candidateIds) { const nc = registry[type]; const display = (nc && nc.title) || type; if (repo.excludePatterns && repo.excludePatterns.some(re => re.test(display))) continue; if (!matchesQuery(display, type, q)) continue; const sub = subcategoryFor((nc && nc.category) || "", repo.categoryRoot); if (!subcats.has(sub)) subcats.set(sub, []); subcats.get(sub).push({ type, display }); total += 1; } const categories = [...subcats.entries()] .map(([name, nodes]) => ({ name, nodes: nodes.sort((a, b) => compareNames(a.display, b.display)), })) .sort((a, b) => compareNames(a.name, b.name)); out.push({ label: repo.label, categories, total }); } return out; } function gatherUserPickPacks(query) { const q = (query || "").trim().toLowerCase(); const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; const picks = loadUserPicks(); const autoCategoryRoots = new Set( REPOS.filter(r => r.select === "all" && r.categoryRoot).map(r => r.categoryRoot) ); const byPack = new Map(); for (const type of picks) { const nc = registry[type]; if (!nc) continue; const cat = nc.category || ""; const packLabel = cat.split("/")[0] || "(uncategorized)"; if (autoCategoryRoots.has(packLabel)) continue; const display = nc.title || type; if (!matchesQuery(display, type, q)) continue; const sub = subcategoryFor(cat, packLabel); if (!byPack.has(packLabel)) byPack.set(packLabel, new Map()); const subcatMap = byPack.get(packLabel); if (!subcatMap.has(sub)) subcatMap.set(sub, []); subcatMap.get(sub).push({ type, display }); } const result = []; for (const [packLabel, subcatMap] of byPack.entries()) { const categories = [...subcatMap.entries()] .map(([name, nodes]) => ({ name, nodes: nodes.sort((a, b) => compareNames(a.display, b.display)), })) .sort((a, b) => compareNames(a.name, b.name)); const total = categories.reduce((acc, c) => acc + c.nodes.length, 0); result.push({ label: packLabel, categories, total, isUserPicks: true }); } result.sort((a, b) => compareNames(a.label, b.label)); return result; } // ============================================================================= // Data gathering — theme-first mode // // Groups every user pick by a single semantic theme — independent of which // pack the node ships from. Picks-only (no REPOS-driven auto-pulled nodes) // because the user's mental model is "show me my image-related favorites in // one place," not "show me every image-related node ComfyUI knows about." // // Theme extraction (`extractTheme`): // 1. Read the node's CATEGORY string from its registered class. // 2. Look up the source pack via `findPackPathForType` (the same // precedence the toolbar `+` button uses for spotlight). // 3. If the FIRST CATEGORY segment matches the pack label (after // canonical-key normalization — case + whitespace + underscore + // hyphen folded), strip it. The next segment is the theme. // 4. Otherwise the first segment is already the theme. // 5. Canonicalize the theme key for cross-pack merging so // `KJNodes/image/...`, `Koolook/Image/...`, and `image/...` all // land in the same bucket regardless of casing. // // Output shape (flat — no nested folders within a theme): // Map, // for label resolution // nodes: [{ type, display, isUserPick, unresolved? }], // }> // ============================================================================= // Canonical key for a single category-path segment. Lowercase + strip // internal whitespace/underscores/hyphens so `style_model`, `StyleModel`, // and `style model` all collapse to `stylemodel`. Segments are not // stemmed (singular vs plural keep distinct) — see issue #73 for the // deliberate "no Levenshtein" tradeoff. function canonicalSegment(s) { return String(s).toLowerCase().replace(/[\s_\-]+/g, ""); } // Resolves a folder's display label from the count map of original // casings seen. Picks the most common; ties → first-seen. Map iteration // is insertion order, which mirrors the gather walk order — stable // across renders for a given registry state. Empty maps are only valid // for synthetic buckets where the caller pre-seeds the label. function pickDisplayLabel(displayCounts) { let best = null; let bestCount = -1; for (const [name, count] of displayCounts) { if (count > bestCount) { best = name; bestCount = count; } } return best; } // ============================================================================= // Theme classifier — broad semantic buckets via keyword substring match. // // The strict canonical-segment approach (`image` ≠ `images` ≠ `Image-Save`) // produces too many tiny buckets — "image", "images", "HQ-Image-Save", // "rgthree/Image Comparer", "upscaling/Upscale Image By" all fragment apart // even though the user mentally lumps them all under "Image." // // This classifier folds anything that mentions an image-related word // (image / img / pixel / color / upscale / resize / etc.) into a single // "Image" bucket regardless of which pack or subcategory the node lives // under. First classifier whose pattern matches the search corpus wins. // // Corpus = pack name + full category path + node display title + node ID, // lower-cased. Searching all four fields catches: // • "HQ-Image-Save" (pack name has "image") // • "rgthree/Image Comparer" (display title has "image") // • "EasyAIPipeline" (type ID has "pipeline") // // Keyword choices favor breadth — adjust this list when a future pick // lands in the wrong bucket. Patterns deeper in the list act as fallbacks // for picks the broader earlier patterns missed. // ============================================================================= const THEME_CLASSIFIERS = [ { key: "image", label: "Image", patterns: [ /\bimg\b/i, /image/i, /pixel/i, /color/i, /\bocio\b/i, /\bexr\b/i, /\bhdr\b/i, /upscal/i, /resize/i, /comparer/i, /\btile\b/i, /frame/i, /thumbnail/i, ], }, { key: "video", label: "Video", patterns: [/video/i, /\bwan\b/i, /hunyuan/i, /cogvideo/i, /\bltx\b/i, /motion/i, /sequence/i], }, { key: "mask", label: "Mask", patterns: [/\bmask/i, /\balpha\b/i, /segment/i, /matte/i, /rmbg/i], }, { key: "audio", label: "Audio", patterns: [/audio/i, /\bwav\b/i, /\bsound\b/i], }, { key: "model", label: "Model", patterns: [ /model/i, /checkpoint/i, /\blora\b/i, /\bvae\b/i, /\bclip\b/i, /controlnet/i, /ipadapter/i, /diffusion/i, /\bunet\b/i, ], }, { key: "sampler", label: "Sampler", patterns: [/sampler/i, /scheduler/i, /\bnoise\b/i, /\bsigma\b/i], }, { key: "conditioning", label: "Conditioning", patterns: [/condition/i, /\bembed/i, /tokeniz/i], }, { key: "text", label: "Text & Prompt", patterns: [/\btext\b/i, /prompt/i, /\bstring\b/i, /caption/i], }, { key: "math", label: "Math & Logic", patterns: [ /\bmath\b/i, /\blogic\b/i, /\bint\b/i, /\bfloat\b/i, /\bbool/i, /switch/i, /\bcompare\b/i, /numeric/i, ], }, { key: "pipeline", label: "Pipeline & Batch", patterns: [/pipeline/i, /workflow/i, /\bbatch\b/i, /\bcache\b/i], }, { key: "io", label: "I/O", patterns: [/\bload\b/i, /\bsave\b/i, /\bfile\b/i, /\bpath\b/i, /import/i, /export/i], }, { key: "camera", label: "Camera", patterns: [/camera/i, /\blens\b/i, /\bpose\b/i, /\btrack/i], }, { key: "utility", label: "Utility", // `\bget\b` / `\bset\b` are kept short on purpose — they're the // last-resort net for trivial getter/setter nodes that didn't hit // any earlier classifier. Do NOT generalize them to `/get/` or // `/set/` (no boundaries) without re-walking the upstream pattern // list — un-anchored variants would fish picks out of node names // like "AssetGet…" that should land in their own theme. patterns: [/\butil/i, /helper/i, /\btool\b/i, /\bget\b/i, /\bset\b/i, /reroute/i], }, ]; // Returns `{key, label}` of the first classifier whose patterns match the // corpus, or `null` if no classifier matched. Walked in order — broader // upstream classifiers preempt downstream ones. "Image Comparer (rgthree)" // lands in Image because the corpus contains the word "image", which the // Image classifier's `/image/i` pattern matches; ordering would also have // protected it (Math is below Image), but the substring hit comes first. function classifyTheme(corpus) { for (const cls of THEME_CLASSIFIERS) { for (const pat of cls.patterns) { if (pat.test(corpus)) return { key: cls.key, label: cls.label }; } } return null; } // Per-pick: classify into one of the broad themes above, falling back to // the pack-prefix-stripped first CATEGORY segment when no classifier // matched. Returns one of: // { kind: "unresolved" } — pack not loaded, no registry entry // { kind: "uncategorized" } — pack loaded but CATEGORY empty/missing // { kind: "theme", themeRaw, themeCanonical } // // The fallback path keeps long-tail picks (a niche pack with no recognizable // keywords) in their original-segment bucket rather than collapsing every // unrecognized thing into one "(other)" pile. function extractTheme(typeName, registry) { const nc = registry[typeName]; if (!nc) return { kind: "unresolved" }; const cat = nc.category || ""; const display = nc.title || typeName; const loc = findPackPathForType(typeName); const packLabel = loc?.packLabel || ""; // Search corpus combines every signal we have about this node so a // keyword in any of them — pack name, category subtree, display title, // raw type ID — counts. No `.toLowerCase()` needed: every classifier // pattern carries the `/i` flag. const corpus = `${packLabel} ${cat} ${display} ${typeName}`; const classified = classifyTheme(corpus); if (classified) { return { kind: "theme", themeRaw: classified.label, themeCanonical: classified.key, }; } // Fallback: original pack-prefix-stripped first-segment behavior, so a // niche pick from "MyPack/specialFoo/SomeNode" lands in "specialFoo" // rather than dumping everything unmatched into "(other)". const segs = cat.split("/").map(s => s.trim()).filter(Boolean); if (segs.length === 0) return { kind: "uncategorized" }; let themeIdx = 0; if (loc && loc.packLabel && segs.length > 1) { if (canonicalSegment(loc.packLabel) === canonicalSegment(segs[0])) { themeIdx = 1; } } const themeRaw = segs[themeIdx]; if (!themeRaw) return { kind: "uncategorized" }; return { kind: "theme", themeRaw, themeCanonical: canonicalSegment(themeRaw), }; } function gatherNodesByTheme(query) { const q = (query || "").trim().toLowerCase(); const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; const userPicks = loadUserPicks(); // Population mirror of repo mode: auto-pulled REPOS nodes plus user // picks for packs that aren't auto-pulled. Without this seeding, theme // mode silently drops every node the maintainer has registered as // always-on via REPOS{select: "all"} (on a stock install: the entire // Koolook pack), even though those same nodes do appear in repo mode. // Hidden auto-pulls (×'d by the user) are filtered out so the user's // visible-favorites set is consistent across both modes. const autoCategoryRoots = new Set( REPOS.filter(r => r.select === "all" && r.categoryRoot).map(r => r.categoryRoot) ); const hidden = loadAutoPullHidden(); const seen = new Set(); const seeds = []; for (const repo of REPOS) { if (repo.select === "all") { const root = repo.categoryRoot || ""; if (!root) continue; for (const [type, nc] of Object.entries(registry)) { if (seen.has(type) || hidden.has(type)) continue; const cat = (nc && nc.category) || ""; if (cat !== root && !cat.startsWith(root + "/")) continue; const display = (nc && nc.title) || type; if (repo.excludePatterns && repo.excludePatterns.some(re => re.test(display))) continue; seen.add(type); seeds.push({ type }); } } else if (Array.isArray(repo.select)) { for (const type of repo.select) { if (seen.has(type) || hidden.has(type) || registry[type] === undefined) continue; seen.add(type); seeds.push({ type }); } } } for (const type of userPicks) { if (seen.has(type)) continue; // Skip picks already covered by an auto-pulled REPO (would double- // count with the auto-pull pass above). A type that's been hidden // from auto-pull AND explicitly user-picked still gets surfaced // here, since the auto-pull pass skipped it. const nc = registry[type]; if (nc && !hidden.has(type)) { const packLabel = (nc.category || "").split("/")[0] || ""; if (autoCategoryRoots.has(packLabel)) continue; } seen.add(type); seeds.push({ type }); } // Map, nodes: [...] }> const themes = new Map(); const ensureBucket = (key, seedLabel) => { if (!themes.has(key)) { themes.set(key, { displayLabels: seedLabel ? new Map([[seedLabel, 1]]) : new Map(), nodes: [], }); } return themes.get(key); }; for (const { type } of seeds) { const result = extractTheme(type, registry); if (result.kind === "unresolved") { // Pack not loaded — surface the pick under (unresolved) so the // user can see what they picked even before installing the pack. // Display falls back to the bare type since `nc.title` is unreachable. if (!matchesQuery(type, type, q)) continue; const bucket = ensureBucket("(unresolved)", "(unresolved)"); bucket.nodes.push({ type, display: type, unresolved: true }); continue; } // `nc` is guaranteed non-null here: extractTheme() above already // bailed with `kind: "unresolved"` for any type missing from the // registry, and we caught that branch before reaching this line. const nc = registry[type]; const display = nc.title || type; if (!matchesQuery(display, type, q)) continue; if (result.kind === "uncategorized") { const bucket = ensureBucket("(uncategorized)", "(uncategorized)"); bucket.nodes.push({ type, display }); continue; } const bucket = ensureBucket(result.themeCanonical); bucket.displayLabels.set( result.themeRaw, (bucket.displayLabels.get(result.themeRaw) || 0) + 1, ); bucket.nodes.push({ type, display }); } return themes; } // Total-leaf count across every theme bucket. Drives the section header's // count badge so the user sees "N picks" regardless of theme distribution. function countNodesInThemes(themes) { let c = 0; for (const bucket of themes.values()) c += bucket.nodes.length; return c; } // ============================================================================= // Group mode — read/write the user's chosen mode for the Nodes section. // Two values: "repo" (default) and "category". Anything else falls back to // the default so a stray localStorage value can't lock the panel into a // broken state. // ============================================================================= function loadGroupMode() { try { const v = localStorage.getItem(GROUP_MODE_KEY); return v === "category" || v === "repo" ? v : GROUP_MODE_DEFAULT; } catch { return GROUP_MODE_DEFAULT; } } function saveGroupMode(mode) { if (mode !== "repo" && mode !== "category") return; try { localStorage.setItem(GROUP_MODE_KEY, mode); } catch { /* quota / disabled */ } } // ============================================================================= // Data gathering — workflows (recursive) // // Each output entry has shape: // { name, path, active, archived, subdirs } // where `path` is the full string[] address and `subdirs` is the recursive // list of child entries with the same shape. Empty subtrees (no matching // active, archived, or subdirs) are dropped, so a search-narrowed render // only includes paths that produced at least one visible row. // ============================================================================= function gatherWorkflows(query) { const q = (query || "").trim().toLowerCase(); const stats = { total: 0 }; const directories = gatherDirsAt([], q, stats); return { directories, total: stats.total }; } function gatherDirsAt(parentPath, q, stats) { const out = []; for (const dirName of listDirectoryNames(parentPath)) { const dirPath = [...parentPath, dirName]; const dir = dirOf(dirPath); if (!dir) continue; const matches = (n) => !q || n.toLowerCase().includes(q) || dirName.toLowerCase().includes(q); const allNames = Object.keys(dir.workflows || {}); const active = []; const archived = []; for (const n of allNames) { if (!matches(n)) continue; // Published filter: keep only `published`-tagged workflows, and // only their active version (old archived versions stay hidden). if (publishedOnly && !workflowHasTag(dirPath, n, PUBLISHED_TAG)) continue; if (dir.workflows[n] && dir.workflows[n].archived) { if (publishedOnly) continue; archived.push(n); } else { active.push(n); } } active.sort(compareNames); archived.sort((a, b) => { const aInfo = getArchiveDisplayInfo(dirPath, a); const bInfo = getArchiveDisplayInfo(dirPath, b); const byTime = bInfo.timestampMs - aInfo.timestampMs; return byTime || compareNames(a, b); }); const subdirs = gatherDirsAt(dirPath, q, stats); // Under an active search, drop entries that contributed nothing // (no matched workflows of their own, no matched descendants) so // the filtered tree only shows paths leading to a hit. With NO // search, keep every directory that exists — including freshly- // created empty subdirs — so the tree mirrors the actual store. if ((q || publishedOnly) && active.length === 0 && archived.length === 0 && subdirs.length === 0) continue; out.push({ name: dirName, path: dirPath, active, archived, subdirs }); stats.total += active.length + archived.length; } return out; } // Recursive count of every workflow (active + archived) under a gathered // directory entry, including all descendants. Used for the dir's header // count so a parent dir with subdirs but no direct workflows doesn't show // "0" while clearly hosting things underneath. function countWorkflowsInGatheredDir(dir) { let count = dir.active.length + dir.archived.length; for (const sub of dir.subdirs) count += countWorkflowsInGatheredDir(sub); return count; } // Recursive counts on a raw DirNode (not the gathered shape) for delete- // confirmation messages. Returns { workflows, subdirs } totals. function countDescendantsOfRawDir(dir) { if (!dir) return { workflows: 0, subdirs: 0 }; let workflows = Object.keys(dir.workflows || {}).length; let subdirs = 0; for (const child of Object.values(dir.directories || {})) { const sub = countDescendantsOfRawDir(child); workflows += sub.workflows; subdirs += 1 + sub.subdirs; } return { workflows, subdirs }; } // ============================================================================= // Data gathering — tags (DFS walk over the workflow tree). // // Each output entry has shape: // { name, entries: [{ wfName, path }, ...] } // `path` is the full directory string[] address of the workflow's home dir. // Archived workflows are skipped here so the Tags section reflects the // active set only — their tags are still persisted on the entry, so a // restore (via the Archive folder's right-click) brings them back into // the section. Tag grouping is case-sensitive: "AI" and "ai" produce // separate tag folders (the dedupe in `normalizeDirNode` is also // case-sensitive). The search filter is case-insensitive substring match // against tag name OR workflow name — typing a tag narrows to that group, // typing a workflow name keeps every tag the workflow carries. // ============================================================================= function gatherTags(query) { const q = (query || "").trim().toLowerCase(); const tagMap = new Map(); const walk = (parentPath) => { for (const dirName of listDirectoryNames(parentPath)) { const dirPath = [...parentPath, dirName]; const dir = dirOf(dirPath); if (!dir) continue; for (const [wfName, wf] of Object.entries(dir.workflows || {})) { if (wf.archived === true) continue; const tags = Array.isArray(wf.tags) ? wf.tags : []; if (tags.length === 0) continue; for (const tag of tags) { if (q && !tag.toLowerCase().includes(q) && !wfName.toLowerCase().includes(q)) continue; if (!tagMap.has(tag)) tagMap.set(tag, []); tagMap.get(tag).push({ wfName, path: dirPath }); } } walk(dirPath); } }; walk([]); const tags = [...tagMap.entries()] .map(([name, entries]) => ({ name, entries: entries.sort((a, b) => compareNames(a.wfName, b.wfName)), })) .sort((a, b) => compareNames(a.name, b.name)); const total = tags.reduce((acc, t) => acc + t.entries.length, 0); return { tags, total }; } // ============================================================================= // DOM helpers // ============================================================================= function makeIconElement(icon) { if (icon && icon.kind === "letter") { const el = document.createElement("span"); el.className = "koolook-letter-icon"; el.textContent = icon.text; el.setAttribute("aria-hidden", "true"); return el; } if (icon && icon.kind === "square") { const el = document.createElement("span"); el.className = "koolook-filled-square-icon"; el.setAttribute("aria-hidden", "true"); return el; } if (icon && icon.kind === "stair") { const el = document.createElement("span"); el.className = "koolook-stair-icon"; el.setAttribute("aria-hidden", "true"); for (let i = 0; i < 3; i += 1) { el.appendChild(document.createElement("span")); } return el; } if (icon && icon.kind === "list") { const el = document.createElement("span"); el.className = "koolook-list-icon"; el.setAttribute("aria-hidden", "true"); for (let i = 0; i < 3; i += 1) { el.appendChild(document.createElement("span")); } return el; } if (icon && icon.kind === "svg") { const svg = document.createElementNS(SVG_NS, "svg"); svg.classList.add("koolook-inline-svg-icon"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("aria-hidden", "true"); for (const [tag, attrs] of icon.shapes || []) { const shape = document.createElementNS(SVG_NS, tag); for (const [key, value] of Object.entries(attrs)) { shape.setAttribute(key, value); } svg.appendChild(shape); } return svg; } const el = document.createElement("span"); el.className = typeof icon === "string" ? icon : (icon?.iconClass || ""); el.setAttribute("aria-hidden", "true"); return el; } // Toolbar button factory for icon-only action buttons. Icons are passed as // either PrimeIcon class strings or static local glyph descriptors. function makeToolbarButton({ iconClass, icon, title, onClick }) { const btn = document.createElement("button"); btn.className = "koolook-add-btn koolook-icon-btn"; btn.appendChild(makeIconElement(icon || iconClass)); btn.title = title; btn.setAttribute("aria-label", title); btn.addEventListener("click", onClick); return btn; } function makeFolderRow({ name, count, iconKind, onToggle, onContextMenu, draggablePayload, dropTarget }) { const row = document.createElement("div"); row.className = "koolook-row"; const chevron = document.createElement("span"); chevron.className = "koolook-chevron"; chevron.textContent = "▾"; row.appendChild(chevron); const icon = document.createElement("span"); if (iconKind === "favorites") { icon.className = "pi pi-star koolook-pin-icon"; } else if (iconKind === "workflows") { icon.className = "pi pi-th-large koolook-workflows-icon"; } else if (iconKind === "archive") { icon.className = "pi pi-box koolook-archive-icon"; } else { icon.className = "pi pi-folder koolook-folder-icon"; } row.appendChild(icon); const nameEl = document.createElement("span"); nameEl.className = "koolook-name"; nameEl.textContent = name; row.appendChild(nameEl); if (count != null) { const cnt = document.createElement("span"); cnt.className = "koolook-count"; cnt.textContent = String(count); row.appendChild(cnt); } row.addEventListener("click", onToggle); if (onContextMenu) row.addEventListener("contextmenu", onContextMenu); if (draggablePayload) decorateDraggable(row, draggablePayload); if (dropTarget) decorateDropTarget(row, dropTarget); return { row, chevron }; } function makeNodeLeafRow({ display, type, removable, onClick, unresolved, breadcrumb, packBadge }) { const row = document.createElement("div"); row.className = "koolook-row koolook-leaf"; if (unresolved) row.classList.add("koolook-leaf-unresolved"); // Stable identity for Compare-mode tinting (#181). Invisible otherwise. if (type) row.dataset.koolookNodeType = type; // Breadcrumb-equipped rows under search-flatten mode also surface the // origin path in the row tooltip (`type` is still appended) so a hover // confirms exactly which folder the leaf would live under in the tree. row.title = breadcrumb ? `${breadcrumb} › ${type}` : type; const chevron = document.createElement("span"); chevron.className = "koolook-chevron"; row.appendChild(chevron); const dot = document.createElement("span"); dot.className = "koolook-leaf-dot"; row.appendChild(dot); // The name cell holds an optional dim-grey breadcrumb prefix (set under // search-flatten mode) followed by the node's display name. We avoid // innerHTML and build text nodes instead — display strings come from // `nc.title` and breadcrumbs from category labels, both of which can in // principle contain user-controlled HTML if a custom-node author // included markup in `CATEGORY` or class attributes. const nameEl = document.createElement("span"); nameEl.className = "koolook-name"; if (breadcrumb) { const crumb = document.createElement("span"); crumb.className = "koolook-leaf-crumb"; crumb.textContent = `${breadcrumb} › `; nameEl.appendChild(crumb); nameEl.appendChild(document.createTextNode(display)); } else { nameEl.textContent = display; } row.appendChild(nameEl); // Right-side pack badge — used in theme mode as a quiet "where did // this come from" memory aid. Repo mode and search-flatten skip it // (origin is already conveyed by tree nesting / breadcrumb prefix). if (packBadge) { const badge = document.createElement("span"); badge.className = "koolook-pack-badge"; badge.textContent = packBadge; row.appendChild(badge); } if (removable) { const rm = document.createElement("span"); rm.className = "koolook-remove"; rm.textContent = "×"; rm.title = "Remove from favorites"; rm.addEventListener("click", (e) => { e.stopPropagation(); // Two paths a row can land in favorites: explicit user pick // (drop it from the picks list) or REPOS{select: "all"} auto- // pull (add it to the auto-pull hidden set so the next render // doesn't re-pull it). A type can in principle be both at // once — running both ops is idempotent and covers the union. removeFromMyPicks(type); if (isAutoPulled(type)) hideAutoPullType(type); notifyPicksChanged(); }); row.appendChild(rm); } row.addEventListener("click", onClick); // Hover preview — every leaf gets one. The card looks up the live // node class on hover (lazy), so leaves whose pack isn't loaded // still get a "Pack not loaded" stub instead of silently no-oping. attachHoverPreview(row, type); return row; } function makeWorkflowLeafRow({ name, dirName, wfPath, onClick, onContextMenu, draggablePayload, isModule = false, isPublished = false, secondaryText = "", }) { const row = document.createElement("div"); row.className = "koolook-row koolook-leaf"; // Stable identity for Compare-mode tinting (#181). Invisible otherwise. if (wfPath) row.dataset.koolookWfPath = wfPath; // Module-tagged entries get a distinct hover tooltip so the green icon // isn't the only signal that a left-click does something different. The // `pi pi-plus-circle` class swap is what actually makes them look like // "drop in" affordances rather than "open" affordances. const titleSuffix = secondaryText ? ` — ${secondaryText}` : ""; row.title = isModule ? `${dirName} / ${name}${titleSuffix} — module (left-click to insert into canvas)` : `${dirName} / ${name}${titleSuffix}`; const chevron = document.createElement("span"); chevron.className = "koolook-chevron"; row.appendChild(chevron); const icon = document.createElement("span"); icon.className = isModule ? "pi pi-plus-circle koolook-module-icon" : "pi pi-file koolook-leaf-icon"; row.appendChild(icon); if (secondaryText) { const stack = document.createElement("span"); stack.className = "koolook-name-stack"; const nameEl = document.createElement("span"); nameEl.className = "koolook-name"; nameEl.textContent = name; stack.appendChild(nameEl); const metaEl = document.createElement("span"); metaEl.className = "koolook-row-meta"; metaEl.textContent = secondaryText; stack.appendChild(metaEl); row.appendChild(stack); } else { const nameEl = document.createElement("span"); nameEl.className = "koolook-name"; nameEl.textContent = name; row.appendChild(nameEl); } // Quiet "this workflow is published as a setup" marker (issue #227). Driven // by the `published` tag so it survives reloads and matches the Tools "P" // filter + the Tags-section pool. if (isPublished) { const badge = document.createElement("span"); badge.className = "koolook-published-badge"; badge.textContent = "published"; badge.title = "Published as a callable setup"; row.appendChild(badge); } row.addEventListener("click", onClick); if (onContextMenu) row.addEventListener("contextmenu", onContextMenu); if (draggablePayload) decorateDraggable(row, draggablePayload); return row; } // Resolve module-ness from the workflow's stored tags. Computed at render // time (cheap — small array `.includes` per row) so we don't have to widen // the gather pipeline's row shape just to carry one boolean. Returns false // for archived entries: archived workflows are old versions; clicking them // in the Archive folder should still mean "load to review", not "splice an // outdated cluster into my live canvas". The right-click menu on archived // rows still exposes both Insert and Load if the user really wants to // insert an archived version. function isModuleWorkflow(dirPath, wfName) { return isWorkflowModule(dirPath, wfName); } function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, startExpanded = true, path, forceExpanded = false, forceExpandedWhenFiltered = true, draggablePayload, dropTarget, }) { // `path` is required — every section/sub-section calling site routes // through `makeSectionCtx`, which always supplies a non-empty // section-prefixed string. The assertion below converts a "future // caller forgot to pass path" regression into a loud failure at the // boundary; without it, a stray `undefined` would silently land in // `pathStates.set(undefined, …)` and pollute one shared key across // every misconfigured folder. The pathStates branch further down // assumes a string key. if (typeof path !== "string" || !path) { throw new Error("buildFolder: `path` must be a non-empty string"); } // Bind the active expansion map at BUILD time. In Compare mode the snapshot // panel renders under `comparePathStates` (swapped in by withComparePathStates), // but `onToggle` fires later — at click time, after the swap has restored the // live map. Capturing the map here means a toggle writes back to the same map // the folder was rendered from, so the two panels' expansion never crosses // (#197). Reads below use it too, for symmetry. const stateMap = pathStates; // Resolution order: // 1. forceExpanded (e.g. search active) opens regular folders, but not // Archive folders. A crowded Archive should stay summarized under // search unless the user has explicitly opened or pinned it. // 2. pinnedPaths (a save/spotlight flow has flagged this for force-open // on the current render) — Phase 3 writes pins into pathStates AFTER // Phase 2 builds DOM, so without a direct pinnedPaths read here the // pin would only take effect on the *next* render. The spotlight // effect for newly-added picks depends on pinned paths expanding // on the immediate render. // 3. pathStates (user has previously toggled this folder) // 4. startExpanded (the natural default for this folder) const initiallyExpanded = resolveFolderExpanded({ forceExpanded, forceExpandedWhenFiltered, iconKind, isPinned: pinnedPaths.has(path), hasStoredState: stateMap.has(path), storedState: stateMap.get(path), startExpanded, }); // Track the live expanded state in a closure variable instead of // round-tripping through `wrapper.dataset.expanded`. The DOM dataset // forces every value to a string, which led to the slightly awkward // `!== "false"` read; closure-locality keeps the toggle a plain boolean // and removes one source of truth that wasn't the canonical one // (`pathStates` is the canonical store; the DOM dataset just mirrored // it for reads). let isExpanded = initiallyExpanded; const wrapper = document.createElement("div"); const children = document.createElement("div"); children.className = "koolook-children"; if (!isExpanded) children.style.display = "none"; const { row, chevron } = makeFolderRow({ name, count, iconKind, onContextMenu, draggablePayload, dropTarget, onToggle: () => { isExpanded = !isExpanded; chevron.textContent = isExpanded ? "▾" : "▸"; children.style.display = isExpanded ? "" : "none"; stateMap.set(path, isExpanded); }, }); if (!initiallyExpanded) chevron.textContent = "▸"; // Stable identity for the Compare-mode folder pull-in (#197): the read-only // guard reads this to offer "Copy folder … (with contents)" on workflow // folders. Invisible otherwise. The tree path carries the section prefix, // so the handler can tell a workflows folder from a node/tag folder. row.dataset.koolookFolderPath = path; wrapper.appendChild(row); wrapper.appendChild(children); childrenBuilder(children); return wrapper; } // ============================================================================= // Context-menu wiring for workflow rows // ============================================================================= function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) { // "+ New directory…" / "+ New subdirectory under …" both create a fresh // directory and move the workflow into it as one atomic mutation. If // the move couldn't land we manually undo the dir add so we don't leak // an empty orphan; in practice that branch is unreachable (the new dir // is empty so the move can't collide), but the explicit cleanup keeps a // future regression from silently leaving stray directories behind. function moveToNewDirAction(parentPath, parentLabel) { showInputModal({ title: parentLabel ? `New subdirectory under "${parentLabel}"` : "New top-level directory", label: "Name", placeholder: "e.g. drafts", confirmLabel: "Create & Move", onSubmit: (name) => persistMutation({ mutate: () => { if (!addDirectory(parentPath, name)) return false; const target = [...parentPath, name]; if (!moveWorkflow(dirPath, wfName, target)) { deleteDirectory(parentPath, name); return false; } return target; }, onSuccess: (target) => toast(`Moved "${wfName}" to ${target.join(" / ")}.`), onNoOp: () => toast(`Could not create — name in use, empty, or "Archive" reserved.`), }), }); } const newDirItem = { label: "+ New directory…", action: () => moveToNewDirAction([], null), }; const newSubdirItem = { label: `+ New subdirectory under "${dirPath.join(" / ")}"…`, action: () => moveToNewDirAction(dirPath, dirPath.join(" / ")), }; const archiveItem = isArchived ? { label: "Restore from archive", action: () => persistMutation({ mutate: () => unarchiveWorkflow(dirPath, wfName), onSuccess: () => toast(`Restored "${wfName}".`), }), } : { label: "Move to archive", action: () => persistMutation({ mutate: () => archiveWorkflow(dirPath, wfName), onSuccess: () => toast(`Archived "${wfName}".`), }), }; const duplicateItem = { label: "Duplicate…", action: () => { const sourceGraph = getWorkflowGraph(dirPath, wfName); if (!sourceGraph) { toast("Could not duplicate — workflow not found."); return; } // Capture tags at modal-open time so a same-name duplicate (which // archives the original via `saveWorkflowEntry`) still inherits // the source's categorization onto the new active entry. const sourceTags = getWorkflowTags(dirPath, wfName) || []; const sourceIsModule = isWorkflowModule(dirPath, wfName); showInputModal({ title: "Duplicate workflow", label: "New name", defaultValue: `${wfName} (copy)`, confirmLabel: "Duplicate", onSubmit: (newName) => persistMutation({ mutate: () => { // Re-validate at submit-time. A concurrent action // (another tab, drag-and-drop) could have deleted // the source between menu-open and submit. Without // this guard, `saveWorkflowEntry` would call // `ensureDirectoryAtPath` and silently re-create // the parent directory with a clone of the // captured graph — effectively undoing someone // else's delete with no feedback. if (getWorkflowGraph(dirPath, wfName) === null) return false; const cloned = JSON.parse(JSON.stringify(sourceGraph)); const result = saveWorkflowEntry(dirPath, newName, cloned, { module: sourceIsModule }); if (!result) return false; // Route tag inheritance through the public `addTag` // mutator instead of writing `wf.tags` directly, so // the Duplicate path stays inside the documented // store API. The whole sequence rides on the same // persistMutation snapshot — commit failure rolls // back the save AND the tag adds together. for (const t of sourceTags) addTag(dirPath, newName, t); if (sourceIsModule) addTag(dirPath, newName, MODULE_TAG); return result; }, onSuccess: (result) => { if (result && result.archivedAs) { toast(`Duplicated to "${newName}" — previous "${newName}" archived as "${result.archivedAs}".`); } else { toast(`Duplicated to "${newName}".`); } }, onNoOp: () => toast(`Could not duplicate — "${wfName}" no longer exists.`), }), }); }, }; const updateFromCanvasItem = { label: "Update from selection or canvas", action: () => { if (!canvasIsNonEmpty()) { toast("Canvas is empty."); return; } const selectedCount = getSelectedNodeCount(); const totalNodeCount = getCanvasNodeCount(); const allNodesSelected = selectedCount > 0 && selectedCount === totalNodeCount; let graph = null; let sourceLabel = "canvas"; if (selectedCount > 0 && !allNodesSelected) { const selectionResult = serializeSelection(); if (selectionResult.kind === "stale") { toast("Selected node(s) no longer exist."); return; } if (selectionResult.kind !== "ok") { toast("Selection unavailable. Re-select nodes and try again."); return; } if (!selectionResult.graph || !Array.isArray(selectionResult.graph.nodes) || selectionResult.graph.nodes.length === 0) { toast("Failed to serialize selection. See console."); return; } graph = selectionResult.graph; sourceLabel = "selection"; } else { graph = serializeFullCanvas(); if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) { toast("Failed to serialize canvas. See console."); return; } } const sourceTags = getWorkflowTags(dirPath, wfName) || []; const sourceIsModule = isWorkflowModule(dirPath, wfName); persistMutation({ mutate: () => { if (getWorkflowGraph(dirPath, wfName) === null) return false; const result = saveWorkflowEntry(dirPath, wfName, graph, { module: sourceIsModule }); if (!result) return false; for (const t of sourceTags) addTag(dirPath, wfName, t); if (sourceIsModule) addTag(dirPath, wfName, MODULE_TAG); return result; }, onSuccess: (result) => { if (result && result.archivedAs) { toast(`Updated "${wfName}" from ${sourceLabel} (previous version archived as "${result.archivedAs}").`); } else { toast(`Updated "${wfName}" from ${sourceLabel}.`); } }, onNoOp: () => toast(`Could not update — "${wfName}" no longer exists.`), }); }, }; const tagsItem = { label: "Tags…", action: () => { showTagsModal({ wfName, // Pass the raw `null` through so the modal can render an // explicit gone-state when the workflow is deleted/moved/ // renamed by a concurrent action. Collapsing to `[]` would // silently morph "workflow gone" into "no tags yet" and // accept doomed Add inputs. getCurrentTags: () => getWorkflowTags(dirPath, wfName), onAddTag: (tag, onDone) => persistMutation({ mutate: () => addTag(dirPath, wfName, tag), onSuccess: () => { onDone(); toast(`Tagged "${wfName}" with "${tag}".`); }, onNoOp: () => toast(`Could not add — empty or already tagged.`), }), onRemoveTag: (tag, onDone) => persistMutation({ mutate: () => removeTag(dirPath, wfName, tag), onSuccess: () => { onDone(); toast(`Removed tag "${tag}" from "${wfName}".`); }, onNoOp: () => toast(`Tag "${tag}" was not present.`), }), }); }, }; const publishSetupItem = { label: "Publish setup…", action: () => { const visualGraph = getWorkflowGraph(dirPath, wfName); showPublishSetupModal({ wfName, dirPath, currentTags: getWorkflowTags(dirPath, wfName) || [], visualGraph, revealPublishedSetupFolder, // Return the publish result so the modal can render the success // card (where it saved + Open folder / Copy path). The card is // the user-facing confirmation now, replacing the old toast. onPublish: async ({ metadata, inputContract, outputContract }) => { const result = await publishSavedWorkflowSetup({ dirPath, wfName, visualGraph, captureApiPrompt: captureWorkflowApiPrompt, metadata, inputContract, outputContract, }); // Mark the source workflow published so it carries the // badge, joins the Tags "published" pool, and surfaces under // the Tools "P" filter. Awaited so the success card reflects // a committed tag; a persist failure surfaces through // persistMutation's own critical toast. addTag no-ops if the // tag is already present (e.g. re-publishing an update). await persistMutation({ mutate: () => addTag(dirPath, wfName, PUBLISHED_TAG), onSuccess: () => {}, onNoOp: () => { // No-op means already-tagged (republish — fine) OR // the source workflow vanished mid-publish. Only the // latter leaves it untagged, so warn just for that: // the setup published, but won't be discoverable. if (!workflowHasTag(dirPath, wfName, PUBLISHED_TAG)) { toast(`Published, but couldn't tag "${wfName}" as published — it may have moved.`); } }, }); return result; }, }); }, }; showContextMenu(event, [ { label: "Load", action: () => loadWorkflowOntoCanvas(dirPath, wfName), }, { // Non-destructive sibling of Load — splices the saved cluster into // whatever's already on canvas (placed at the viewport center, // inserted nodes selected so the user can immediately drag). // Aborts cleanly if any referenced node type isn't installed. label: "Insert into canvas", action: () => insertWorkflowOntoCanvas(dirPath, wfName), }, ...(!isArchived ? [updateFromCanvasItem] : []), { label: "Rename…", action: () => { showInputModal({ title: "Rename workflow", label: "New name", defaultValue: wfName, confirmLabel: "Rename", onSubmit: (newName) => persistMutation({ mutate: () => renameWorkflow(dirPath, wfName, newName), onSuccess: () => toast(`Renamed to "${newName}".`), onNoOp: () => toast(`Rename failed (name in use?).`), }), }); }, }, duplicateItem, tagsItem, ...(!isArchived ? [publishSetupItem] : []), archiveItem, // New-folder shortcuts — existing-folder moves stay on drag-and-drop // so large libraries don't make this context menu taller than the // viewport. null, newDirItem, newSubdirItem, null, { label: "Delete", danger: true, action: () => { showConfirmModal({ title: "Delete workflow?", message: `"${wfName}" will be removed. This cannot be undone.`, confirmLabel: "Delete", danger: true, onConfirm: () => persistMutation({ mutate: () => deleteWorkflow(dirPath, wfName), onSuccess: () => toast(`Deleted "${wfName}".`), }), }); }, }, ]); } // Right-click on the synthetic Archive folder. The Archive folder isn't a // real DirNode — it's rendered for any dir with `archived: true` workflows // at this level. Bulk cleanup keeps representative recent entries; the // destructive all-delete escape hatch remains available below it. function archiveFolderContextMenu(event, dirPath, archivedCount) { const dirDisplay = dirPath.join(" / "); const noun = `${archivedCount} archived workflow${archivedCount === 1 ? "" : "s"}`; const cleanupPlan = getArchiveCleanupPlan(dirPath); showContextMenu(event, [ { label: "Clean up archive", action: () => { if (!cleanupPlan || cleanupPlan.deleteCount === 0) { toast(`Archive in "${dirDisplay}" already looks tidy.`); return; } showConfirmModal({ title: "Clean up archive?", message: archiveCleanupConfirmMessage(cleanupPlan), confirmLabel: "Clean up archive", danger: true, onConfirm: () => persistMutation({ mutate: () => cleanUpArchive(dirPath, cleanupPlan), onSuccess: (result) => toast( `Cleaned archive in "${dirDisplay}": kept ${result.keepCount}, deleted ${result.deleteCount}.` ), onNoOp: () => toast(`Archive in "${dirDisplay}" already looks tidy.`), }), }); }, }, { label: `Delete archive (${archivedCount})`, danger: true, action: () => { showConfirmModal({ title: "Delete archived workflows?", message: `${noun} in "${dirDisplay}" will be permanently deleted. Active workflows in this directory are not affected.`, confirmLabel: "Delete archive", danger: true, onConfirm: () => persistMutation({ mutate: () => clearArchive(dirPath), onSuccess: (result) => toast( `Deleted ${result.count} archived workflow${result.count === 1 ? "" : "s"} in "${dirDisplay}".` ), }), }); }, }, ]); } function archiveCleanupConfirmMessage(cleanupPlan) { const list = document.createElement("ul"); list.className = "koolook-confirm-list"; const items = [ "Clean up this Archive folder.", "Keep latest from 5 minutes, 1 hour, and 1 day.", "Apply separately to each setup name.", `Delete ${cleanupPlan.deleteCount} older duplicate${cleanupPlan.deleteCount === 1 ? "" : "s"}.`, "Active workflows stay untouched.", ]; for (const text of items) { const item = document.createElement("li"); item.textContent = text; list.appendChild(item); } return list; } // Right-click on the "Workflows" section header — the row labeled "Workflows" // at the top of the Workflows tree. Hosts top-level directory creation, which // used to live as a 📂 toolbar button in Row 3 alongside the save buttons. // Moved here so the Workflows action bar contains only save actions and tree- // structure operations live where tree-structure operations already live — // on right-click of the row representing the structure being mutated. Mirrors // the per-folder right-click menu's "Create subdirectory…" pattern, just one // level up. function workflowsRootContextMenu(event) { showContextMenu(event, [ { label: "Create directory…", action: () => { showInputModal({ title: "Create directory", label: "Directory name", placeholder: "e.g. Inpainting", confirmLabel: "Create", onSubmit: (name) => persistMutation({ // Top-level directory creation. Subdirectories are // created via right-click → "Create subdirectory…" // on an existing folder row. mutate: () => addDirectory([], name), onSuccess: () => toast(`Directory "${name}" created.`), onNoOp: () => toast(`Directory "${name}" already exists.`), }), }); }, }, ]); } function directoryRowContextMenu(event, dirPath) { const dir = dirOf(dirPath); const dirName = dirPath[dirPath.length - 1]; const parentPath = dirPath.slice(0, -1); const displayPath = dirPath.join(" / "); const counts = countDescendantsOfRawDir(dir); const isEmpty = counts.workflows === 0 && counts.subdirs === 0; showContextMenu(event, [ { label: "Create subdirectory…", action: () => { showInputModal({ title: `Create subdirectory under "${displayPath}"`, label: "Name", placeholder: "e.g. Type-A", confirmLabel: "Create", onSubmit: (name) => persistMutation({ mutate: () => addDirectory(dirPath, name), onSuccess: () => toast(`Created "${name}" in ${displayPath}.`), onNoOp: () => toast(`Could not create — name in use, empty, or "Archive" reserved.`), }), }); }, }, { label: "Rename directory…", action: () => { showInputModal({ title: "Rename directory", label: "New name", defaultValue: dirName, confirmLabel: "Rename", onSubmit: (newName) => persistMutation({ mutate: () => renameDirectory(parentPath, dirName, newName), onSuccess: () => toast(`Renamed to "${newName}".`), onNoOp: () => toast(`Rename failed — name in use or "Archive" reserved.`), }), }); }, }, null, { label: "Delete directory", danger: true, action: () => { if (isEmpty) { showConfirmModal({ title: "Delete empty directory?", message: `"${displayPath}" will be removed.`, confirmLabel: "Delete", danger: true, onConfirm: () => persistMutation({ mutate: () => deleteDirectory(parentPath, dirName), onSuccess: () => toast(`Deleted directory "${displayPath}".`), }), }); } else { const parts = []; if (counts.workflows > 0) parts.push(`${counts.workflows} workflow${counts.workflows === 1 ? "" : "s"}`); if (counts.subdirs > 0) parts.push(`${counts.subdirs} subdirector${counts.subdirs === 1 ? "y" : "ies"}`); showConfirmModal({ title: "Delete non-empty directory?", message: `"${displayPath}" contains ${parts.join(" and ")}. They will be permanently deleted.`, confirmLabel: "Delete all", danger: true, onConfirm: () => persistMutation({ mutate: () => deleteDirectory(parentPath, dirName), onSuccess: () => toast(`Deleted "${displayPath}" and its contents.`), }), }); } }, }, ]); } // ============================================================================= // Built-in section descriptors. Order here = render order in the panel. // ============================================================================= addSection({ id: SECTION_ID_NODES, label: ROOT_GROUP_LABEL, iconKind: "favorites", emptyMessage: "No curated nodes yet. Click + above (with a canvas node selected) or right-click a node on the canvas → Add to Kforge Labs.", gather(q) { const mode = loadGroupMode(); if (mode === "category") { const themes = gatherNodesByTheme(q); return { mode, themes, total: countNodesInThemes(themes) }; } const auto = gatherNodesByRepo(q); const user = gatherUserPickPacks(q); const packs = [...auto, ...user] .filter(p => p.total > 0) .sort((a, b) => compareNames(a.label, b.label)); const total = packs.reduce((acc, p) => acc + p.total, 0); return { mode: "repo", packs, total }; }, build(ctx) { // Search-flatten branch: under any non-empty query the Nodes section // collapses to a flat list of matching leaves with breadcrumb // prefixes, regardless of group mode. The tree's spatial signal is // already mostly lost once a query has narrowed the set, so a flat // sorted list with breadcrumb prefixes scans faster than a forest // of forced-open folder spines. Workflows / Tags sections retain // their existing tree-under-filter behavior. if (ctx.isFiltered) { emitNodesFlatSearchResults(ctx); return; } if (ctx.data.mode === "category") { emitThemeChildren(ctx, ctx.data.themes); return; } for (const pack of ctx.data.packs) { ctx.folder({ name: pack.label, count: pack.total, path: pack.label, build: (packCtx) => { for (const cat of pack.categories) { packCtx.folder({ name: cat.name, count: cat.nodes.length, path: cat.name, build: (catCtx) => { for (const n of cat.nodes) { catCtx.leaf({ row: makeNodeLeafRow({ display: n.display, type: n.type, removable: true, onClick: () => insertNode(n.type), }), }); } }, }); } }, }); } }, }); // Flat-list emitters for the Nodes section under search. Both group modes // produce the same `{ display, type, breadcrumb, removable, unresolved? }` // shape so the leaf factory call is uniform downstream. Sorted by display // name (case-insensitive); breadcrumb is a `›`-joined path of the folder // labels the leaf would live under in the un-filtered tree, so the user // retains the spatial-origin signal. function emitNodesFlatSearchResults(ctx) { const items = []; if (ctx.data.mode === "category") { collectFlatFromThemes(ctx.data.themes, items); } else { collectFlatFromRepoMode(ctx.data.packs, items); } items.sort((a, b) => compareNames(a.display, b.display)); for (const item of items) { ctx.leaf({ row: makeNodeLeafRow({ display: item.display, type: item.type, breadcrumb: item.breadcrumb, unresolved: !!item.unresolved, removable: true, onClick: () => insertNode(item.type), }), }); } } function collectFlatFromThemes(themes, out) { for (const bucket of themes.values()) { // Theme label = the most common original casing seen across the // picks that landed in this bucket. Falls back to the canonical key // for synthetic buckets where `displayLabels` was pre-seeded with // the bucket name itself. const label = pickDisplayLabel(bucket.displayLabels); for (const n of bucket.nodes) { out.push({ display: n.display, type: n.type, unresolved: !!n.unresolved, removable: !!n.isUserPick, breadcrumb: label || null, }); } } } function collectFlatFromRepoMode(packs, out) { for (const pack of packs) { for (const cat of pack.categories) { // Collapse the subcategory out of the breadcrumb when it adds no // info: either the synthetic "(root)" label (`subcategoryFor`'s // marker for nodes whose category equals the repo's // `categoryRoot`) or a duplicate of the pack label itself // (happens for user picks under "(uncategorized)" where both // labels collapse to the same synthetic token). const subRedundant = cat.name === "(root)" || cat.name === pack.label; const crumbs = subRedundant ? [pack.label] : [pack.label, cat.name]; const breadcrumb = crumbs.join(" › "); for (const n of cat.nodes) { out.push({ display: n.display, type: n.type, removable: !!pack.isUserPicks, breadcrumb, }); } } } } // Emits one folder per theme bucket under `parentCtx`. Each theme folder // holds a flat sorted list of its picks — no further nesting. Folder // `path` uses the canonical key (not the resolved display label) so the // user's expansion state in `pathStates` survives a future upstream // casing change in the most-common label for that theme. function emitThemeChildren(parentCtx, themes) { const themeEntries = [...themes.entries()] .map(([canonical, bucket]) => ({ canonical, bucket, label: pickDisplayLabel(bucket.displayLabels) || canonical, })) .sort((a, b) => compareNames(a.label, b.label)); for (const { canonical, bucket, label } of themeEntries) { parentCtx.folder({ name: label, count: bucket.nodes.length, path: canonical, build: (sub) => { const sortedNodes = [...bucket.nodes].sort( (a, b) => compareNames(a.display, b.display), ); for (const n of sortedNodes) { // Pack badge — only for real pack labels. Skip // unresolved (origin IS the unknown) and synthetic // (uncategorized)/(unresolved) parens-prefixed names. let badge = null; if (!n.unresolved) { const loc = findPackPathForType(n.type); if (loc && loc.packLabel && !loc.packLabel.startsWith("(")) { badge = loc.packLabel; } } sub.leaf({ row: makeNodeLeafRow({ display: n.display, type: n.type, removable: true, unresolved: !!n.unresolved, packBadge: badge, onClick: () => insertNode(n.type), }), }); } }, }); } } addSection({ id: SECTION_ID_WORKFLOWS, label: WORKFLOWS_GROUP_LABEL, iconKind: "workflows", rootContextMenu: workflowsRootContextMenu, // No emptyMessage — when the workflow store is empty and no search is // active, we silently hide the section. Saving a workflow uses the save // modal's "+ New directory…" entry to seed the first directory; once the // section is non-empty, right-click on the "Workflows" header creates // additional top-level dirs (see `workflowsRootContextMenu`). gather(q) { return gatherWorkflows(q); }, build(ctx) { for (const dir of ctx.data.directories) renderGatheredDir(ctx, dir); }, }); // Tags section — flat list of tag folders, each containing the workflows // carrying that tag. Workflows that share a tag appear under the tag folder // regardless of which directory they live in. Click loads the workflow from // its real path; the right-click menu operates on the workflow at its // canonical location, so renames/moves originating here update the same // underlying entry the Workflows section shows. addSection({ id: SECTION_ID_TAGS, label: "Tags", iconKind: "folder", // No emptyMessage — until any workflow carries a tag, the section stays // silent rather than nag the user about an empty feature. gather(q) { return gatherTags(q); }, build(ctx) { for (const tag of ctx.data.tags) { ctx.folder({ name: tag.name, count: tag.entries.length, path: tag.name, build: (tagCtx) => { for (const entry of tag.entries) { // Module-ness is per-workflow, not per-tag-folder — // a workflow tagged `module` is still a module when // it shows up under any other tag it carries. const moduleEntry = isModuleWorkflow(entry.path, entry.wfName); tagCtx.leaf({ row: makeWorkflowLeafRow({ name: entry.wfName, dirName: entry.path.join(" / "), wfPath: [...entry.path, entry.wfName].join("/"), onClick: () => moduleEntry ? insertWorkflowOntoCanvas(entry.path, entry.wfName) : loadWorkflowOntoCanvas(entry.path, entry.wfName), onContextMenu: (e) => workflowRowContextMenu(e, entry.path, entry.wfName, false), isModule: moduleEntry, }), }); } }, }); } }, }); // Render one gathered directory entry (and its descendants) under // `parentCtx`. Layout per directory: subdirectories first (so their own // trees nest under the parent), then the synthetic Archive folder if any // archived workflows exist at this level, then active workflows last so // the freshest version sits closest to the bottom of the directory. function renderGatheredDir(parentCtx, dir) { const dirPath = dir.path; const parentPath = dirPath.slice(0, -1); const dirDisplay = dirPath.join(" / "); const totalInTree = countWorkflowsInGatheredDir(dir); parentCtx.folder({ name: dir.name, count: totalInTree, path: dir.name, onContextMenu: (e) => directoryRowContextMenu(e, dirPath), // Directory rows are draggable (drag to nest under another dir) and // also drop targets (drop a workflow or another dir onto them). draggablePayload: { type: "directory", parentPath, name: dir.name }, dropTarget: { kind: "dir", path: dirPath }, build: (dirCtx) => { for (const sub of dir.subdirs) renderGatheredDir(dirCtx, sub); if (dir.archived.length > 0) { dirCtx.folder({ name: "Archive", count: dir.archived.length, iconKind: "archive", path: "Archive", onContextMenu: (e) => archiveFolderContextMenu(e, dirPath, dir.archived.length), // Archive folders accept workflow drops only (drop = archive // in this directory; cross-dir drops move + archive). dropTarget: { kind: "archive", path: dirPath }, build: (archCtx) => { for (const wfName of dir.archived) { const displayInfo = getArchiveDisplayInfo(dirPath, wfName); archCtx.leaf({ row: makeWorkflowLeafRow({ name: displayInfo.label, dirName: dirDisplay, wfPath: [...dirPath, wfName].join("/"), onClick: () => loadWorkflowOntoCanvas(dirPath, wfName), onContextMenu: (e) => workflowRowContextMenu(e, dirPath, wfName, true), draggablePayload: { type: "workflow", path: dirPath, name: wfName }, secondaryText: displayInfo.meta, }), }); } }, }); } for (const wfName of dir.active) { const moduleEntry = isModuleWorkflow(dirPath, wfName); dirCtx.leaf({ row: makeWorkflowLeafRow({ name: wfName, dirName: dirDisplay, wfPath: [...dirPath, wfName].join("/"), // Module convention: left-click inserts; non-module // entries left-click loads (replaces canvas, with // confirm modal as before). Both actions are always // available via the right-click menu regardless. onClick: () => moduleEntry ? insertWorkflowOntoCanvas(dirPath, wfName) : loadWorkflowOntoCanvas(dirPath, wfName), onContextMenu: (e) => workflowRowContextMenu(e, dirPath, wfName, false), draggablePayload: { type: "workflow", path: dirPath, name: wfName }, isModule: moduleEntry, isPublished: workflowHasTag(dirPath, wfName, PUBLISHED_TAG), }), }); } }, }); } // ============================================================================= // Panel rendering // ============================================================================= // ============================================================================= // Compare mode (#181) — host + helpers. // // `renderSidebar` is the panel entry point bound by koolook_sidebar.js. With // compare off it renders the single live `renderPanel`, exactly as before. // With compare on it lays out two columns — the live working copy and a // read-only render of a chosen snapshot — plus a bottom "Compare mode" status, // then tints the comparison column from the snapshot diff. The existing // `renderPanel` + CSS are reused verbatim; only the data source differs. // ============================================================================= let compareSnapshot = null; // null = compare off; else the chosen snapshot object let compareMeta = null; // { fileName, displayName } of the chosen snapshot let sidebarContainer = null; // remembered so a toggle can re-render in place let liveListeners = null; // AbortController for the live panel's window listeners // Which compare panel is the editable DESTINATION (#197). "A" = the live // working kit (left, default); "B" = the loaded snapshot (right). The swap // toggle flips it; the inactive side becomes the read-only SOURCE and carries // the read-only guard + the pull-in context menu. Reset to "A" on every // enter/exit so a re-entered Compare always starts from the 80% case (B->A). let activeSide = "A"; // Diff-filter chips (#197). A Set of enabled statuses ("new" | "diff"). When // non-empty, the snapshot (B) panel shows only matching items — their folders // force-expanded, everything else hidden — so the user can review just what's // new / modified, then clear the chips to collapse back. Reset on enter/exit. let compareFilter = new Set(); // The snapshot (B) panel is a working COPY of a file. A->B copies edit it // IN MEMORY (no per-copy disk write — autosaves must stay a pure safety net, // never an A->B write target). `compareDirty` tracks pending edits so the // footer shows "Not saved" + a Save button; the user saves explicitly to a // NAMED file via saveCompareSnapshot. Reset on enter / exit / save. (#197) let compareDirty = false; // In-flight guard for the explicit Save so rapid Save clicks can't fire // overlapping writePreset POSTs (last-writer-wins). let compareSaving = false; // Run `fn` synchronously with the render path pointed at `snapshot`'s data // instead of live state. Read-only: always clears the overrides afterwards // (even if `fn` throws) so later live renders see live data. Must wrap only // synchronous render work — never an await. function withSnapshotSource(snapshot, fn) { setPicksRenderSource(snapshot && Array.isArray(snapshot.picks) ? snapshot.picks : []); setWorkflowsRenderSource(snapshot && snapshot.workflows ? snapshot.workflows : { directories: {} }); try { return fn(); } finally { clearPicksRenderSource(); clearWorkflowsRenderSource(); } } // Run `fn` (a synchronous tree render) with the snapshot panel's own expansion // map active, so its folder state never collides with the live panel's. Must // wrap only synchronous render work; always restores the live map afterwards. function withComparePathStates(fn) { const saved = pathStates; pathStates = comparePathStates; try { return fn(); } finally { pathStates = saved; } } export function renderSidebar(container) { sidebarContainer = container; // Drop the previous mount's live-panel window listeners before re-rendering // (toggling Compare re-runs renderPanel for the live panel). if (liveListeners) liveListeners.abort(); liveListeners = new AbortController(); const signal = liveListeners.signal; if (!compareSnapshot) { container.classList.remove("koolook-compare-host"); renderPanel(container, { onToggleCompare: enterCompareMode, signal }); return; } container.innerHTML = ""; container.classList.remove("koolook-sidebar"); container.classList.add("koolook-compare-host"); const snapName = compareSnapName(); // clean name — never the long autosave path const liveIsActive = activeSide === "A"; const split = document.createElement("div"); split.className = "koolook-compare-split"; // Each column wraps a panel host (the rendered sidebar) plus a SOURCE/TARGET // footer stripe, so the orientation reads at the bottom of each half. const leftCol = makeCompareColumn(); const rightCol = makeCompareColumn(); split.appendChild(leftCol.col); split.appendChild(rightCol.col); container.appendChild(split); // Exactly one side is the editable TARGET (`activeSide`); the other is the // read-only SOURCE and carries the guard + pull-in. The single pull-in is // handed to whichever side is the source; its copy handlers derive their // direction (B->A into the live kit, or A->B written back into the snapshot // file) from `activeSide` (#197). const pullIn = makePullIn(); renderPanel(leftCol.host, { onToggleCompare: exitCompareMode, signal, readOnly: !liveIsActive, pullIn: liveIsActive ? null : pullIn, showUpdateFooter: false, }); renderPanel(rightCol.host, { compare: true, snapshot: compareSnapshot, onToggleCompare: exitCompareMode, signal, compareName: snapName, readOnly: true, pullIn: liveIsActive ? pullIn : null, showUpdateFooter: false, }); // Diff/tint/filter follow the SOURCE (the read-only side you copy FROM): // "new" = in the source, not the target — the copy candidates — so the // highlight lands where you'd grab them. Default (target=A) → source=B; // after a swap (target=B) → source=A. Live reads here are live (the panel // renders cleared the snapshot override in its finally). const livePicks = loadUserPicks(); const liveStore = getAllWorkflowsForExport(); const snapPicks = Array.isArray(compareSnapshot.picks) ? compareSnapshot.picks : []; const snapStore = compareSnapshot.workflows && typeof compareSnapshot.workflows === "object" ? compareSnapshot.workflows : { directories: {} }; const sourceHost = liveIsActive ? rightCol.host : leftCol.host; const sourcePicks = liveIsActive ? snapPicks : livePicks; const sourceStore = liveIsActive ? snapStore : liveStore; const targetPicks = liveIsActive ? livePicks : snapPicks; const targetStore = liveIsActive ? liveStore : snapStore; const counts = applyCompareTint(sourceHost, targetPicks, targetStore, sourcePicks, sourceStore); applyCompareFilter(sourceHost); // Stripe 1 — SOURCE/TARGET orientation only (one line per column). (liveIsActive ? leftCol : rightCol).col.classList.add("koolook-compare-active"); labelColumnFoot(leftCol.foot, { sideLetter: "A", role: liveIsActive ? "target" : "source", name: "your kit" }); labelColumnFoot(rightCol.foot, { sideLetter: "B", role: liveIsActive ? "source" : "target", name: snapName }); // Stripe 2 — the dedicated SAVE stripe (save state + instructions + button). container.appendChild(buildSaveStripe()); container.appendChild(buildCompareBar(counts)); } // A compare column: a flex-column holding the rendered panel (`host`) and a // SOURCE/TARGET footer stripe (`foot`). function makeCompareColumn() { const col = document.createElement("div"); col.className = "koolook-compare-col"; const host = document.createElement("div"); host.className = "koolook-compare-panelhost"; const foot = document.createElement("div"); foot.className = "koolook-compare-colfoot"; col.appendChild(host); col.appendChild(foot); return { col, host, foot }; } // Stripe 1: the SOURCE/TARGET footer for one column — orientation only (one // line). Save state lives in its own stripe (buildSaveStripe), not here. function labelColumnFoot(footEl, { sideLetter, role, name }) { footEl.className = `koolook-compare-colfoot koolook-foot-${role}`; footEl.replaceChildren(); const roleEl = document.createElement("span"); roleEl.className = "koolook-foot-role"; roleEl.textContent = role === "target" ? "TARGET" : "SOURCE"; const nameEl = document.createElement("span"); nameEl.className = "koolook-foot-name"; nameEl.textContent = `${sideLetter} · ${name}`; footEl.appendChild(roleEl); footEl.appendChild(nameEl); } // True when the loaded snapshot is an autosave shadow (rotating safety file), // not a user-named snapshot — A->B edits must Save to a NAMED file, not back // into the autosave. function isAutosaveTarget() { const dir = compareMeta && compareMeta.dir ? compareMeta.dir : ""; return dir.endsWith("_autosave"); } // A clean display name for the snapshot — never the long autosave path. For an // autosave shadow (`Foo_autosave/...`) show the named parent + an "autosave" // marker so the user knows it's the recovery copy, not the named file. function compareSnapName() { const dir = compareMeta && compareMeta.dir ? compareMeta.dir : ""; if (dir === "_unsaved_autosave") return "unsaved recovery · autosave"; if (dir.endsWith("_autosave")) return `${dir.slice(0, -"_autosave".length)} · autosave`; return (compareMeta && compareMeta.fileName) ? compareMeta.fileName : "snapshot"; } // Stripe 2: the dedicated SAVE stripe — all the save state + instructions for // the snapshot side, separate from the SOURCE/TARGET footers. Your kit auto- // saves; the snapshot's A->B edits live in memory until you Save them to a // NAMED file. Shows the unsaved state + a Save button whenever the snapshot is // dirty (regardless of which side is the target, so it survives a swap). function buildSaveStripe() { const stripe = document.createElement("div"); stripe.className = "koolook-compare-savebar"; const msg = document.createElement("span"); msg.className = "koolook-savebar-msg"; if (compareDirty) { msg.classList.add("koolook-savebar-unsaved"); msg.textContent = isAutosaveTarget() ? "● Snapshot has unsaved edits — it's an autosave, so Save writes a named file." : `● Snapshot "${compareSnapName()}" has unsaved edits — not saved to disk yet.`; } else { msg.textContent = "Your kit auto-saves · the snapshot is a read-only file; edits Save to a named file."; } stripe.appendChild(msg); if (compareDirty) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "koolook-savebar-btn"; btn.textContent = isAutosaveTarget() ? "Save as file…" : "Save to file"; btn.title = "Save the merged snapshot to a named file"; btn.addEventListener("click", saveCompareSnapshot); stripe.appendChild(btn); } return stripe; } // The bottom bar: the green/red diff legend (doubling as filter chips) plus the // A<->B swap toggle. function buildCompareBar({ newCount, diffCount }) { const bar = document.createElement("div"); bar.className = "koolook-compare-status"; bar.appendChild(makeFilterChip( "new", "new", newCount, "In the SOURCE side, not the target — copy candidates. Click to show just these.", )); bar.appendChild(makeFilterChip( "diff", "modified", diffCount, "In both, but the graph differs. Click to show just these.", )); const swapBtn = document.createElement("button"); swapBtn.type = "button"; swapBtn.className = "koolook-compare-swap"; swapBtn.textContent = "⇄ Swap A↔B"; swapBtn.title = "Flip source/target — which side you copy into"; swapBtn.addEventListener("click", swapCompareSides); bar.appendChild(swapBtn); return bar; } // A legend swatch that doubles as a filter toggle. `status` is "new" | "diff"; // enabling it filters the SOURCE panel to matching items. function makeFilterChip(status, label, count, tip) { const chip = document.createElement("button"); chip.type = "button"; chip.className = "koolook-cmp-chip"; if (compareFilter.has(status)) chip.classList.add("koolook-cmp-chip-on"); chip.title = tip; const dot = document.createElement("span"); dot.className = `koolook-cmp-dot koolook-cmp-dot-${status}`; chip.appendChild(dot); chip.appendChild(document.createTextNode(`${count} ${label}`)); chip.addEventListener("click", () => toggleCompareFilter(status)); return chip; } function toggleCompareFilter(status) { if (compareFilter.has(status)) compareFilter.delete(status); else compareFilter.add(status); rerenderSidebar(); } // Post-render diff filter for the SOURCE panel. With chips enabled, hide // every leaf whose status isn't selected, hide folders with no matching // descendant, and force-expand the folders that do — leaving a tree of just the // new/modified items. A no-op when no chip is active (normal full view). Relies // on the tinted classes applyCompareTint added, and on the nested-DOM tree // (collapsed children are present, just display:none). function applyCompareFilter(panelEl) { if (!panelEl || compareFilter.size === 0) return; const sels = []; if (compareFilter.has("new")) sels.push(".koolook-cmp-new"); if (compareFilter.has("diff")) sels.push(".koolook-cmp-diff"); const matchSel = sels.join(","); if (!matchSel) return; panelEl.classList.add("koolook-cmp-filtering"); for (const leaf of panelEl.querySelectorAll(".koolook-leaf")) { leaf.style.display = leaf.matches(matchSel) ? "" : "none"; } // querySelector ignores display, so an ancestor still detects a match in a // (separately shown) descendant regardless of processing order. for (const childrenEl of panelEl.querySelectorAll(".koolook-children")) { const wrapper = childrenEl.parentElement; if (childrenEl.querySelector(matchSel)) { childrenEl.style.display = ""; // force-expand if (wrapper) wrapper.style.display = ""; } else if (wrapper) { wrapper.style.display = "none"; // hide the whole folder } } } function rerenderSidebar() { if (sidebarContainer) renderSidebar(sidebarContainer); } // Enter Compare mode: reuse the existing Load dialog in read-only "choose" // mode (`onChoose`) so picking a preset hands back its parsed snapshot WITHOUT // applying it. Never calls applySnapshot — the non-destructive guarantee. function enterCompareMode() { showLoadSnapshotDialog({ listPresets, readPreset, deletePreset, applySnapshot, setCurrentPresetName, getCurrentPresetName, getLibraryInfo, saveSettings, browseDirectories, createBrowseDirectory, writePreLoadAutosave, markStateSaved, markStateAutosaved, listAutosaves, revealPresetFolder, onToast: toast, onChoose: (snap, meta) => { compareSnapshot = snap; compareMeta = meta || null; activeSide = "A"; // always (re-)enter in the B->A direction compareFilter.clear(); // The published filter is a Workflows-browsing affordance; a // Compare view diffs full snapshots, so clear it on entry (the // rebuilt "P" button then re-seeds un-highlighted). Otherwise both // compare columns would render filtered with no toggle to undo it. publishedOnly = false; comparePathStates.clear(); // fresh snapshot starts with default expansion compareDirty = false; // freshly loaded — no pending edits rerenderSidebar(); }, }); } function exitCompareMode() { const doExit = () => { compareSnapshot = null; compareMeta = null; activeSide = "A"; compareFilter.clear(); comparePathStates.clear(); compareDirty = false; rerenderSidebar(); }; // Don't silently lose in-memory A->B merges the user hasn't saved. if (compareDirty) { showConfirmModal({ title: "Discard unsaved snapshot edits?", message: "You copied items into the snapshot but haven't saved them to a file yet. Exit Compare and discard those edits?", confirmLabel: "Discard", danger: true, onConfirm: doExit, }); return; } doExit(); } // Flip which side is the editable destination, then re-render so the read-only // guard + pull-in move to the new source side (#197). function swapCompareSides() { activeSide = activeSide === "A" ? "B" : "A"; rerenderSidebar(); } // The pull-in for whichever side is the read-only SOURCE. The copy is // path-preserving (it lands at the same folder path on the target, creating // missing folders / merging into existing ones — see copyWorkflowIntoStore) and // directional: TARGET = the active side. B->A writes the LIVE kit; A->B writes // back into the snapshot FILE on disk. Reads the source only — never // applySnapshot, never aliases a graph (the engine deep-clones). function makePullIn() { const targetIsLive = activeSide === "A"; return { destLabel: activeSide, // "A" or "B" — the target copyNode: (type) => (targetIsLive ? copyNodeToLive(type) : copyNodeToSnapshot(type)), copyWorkflow: (wfPath) => (targetIsLive ? copyWorkflowToLive(wfPath) : copyWorkflowToSnapshot(wfPath)), copyFolder: (dirSegs, label) => (targetIsLive ? copyFolderToLive(dirSegs, label) : copyFolderToSnapshot(dirSegs, label)), }; } // Summarize a bulk folder copy: "3 added, 1 kept-both, 2 already there". function folderCopyToast(dest, label, s) { if (!s || s.total === 0) return `"${label}" has no workflows to copy to ${dest}.`; if (s.added === 0 && s.keptBoth === 0) { return `"${label}": all ${s.skipped} already in ${dest} — nothing copied.`; } const parts = []; if (s.added) parts.push(`${s.added} added`); if (s.keptBoth) parts.push(`${s.keptBoth} kept-both`); if (s.skipped) parts.push(`${s.skipped} already there`); return `Copied folder "${label}" → ${dest}: ${parts.join(", ")}.`; } function splitWfPath(wfPath) { const segs = String(wfPath).split("/"); const wfName = segs.pop(); return { dirSegs: segs, wfName }; } // Pin the destination path open so the live panel's re-render (off // WORKFLOWS_CHANGED_EVENT) lands with the copied item visible, not buried in a // collapsed folder. Mirrors the Save-workflow flow's pinning. function pinDestPath(dirSegs) { const pinKeys = [SECTION_ID_WORKFLOWS]; let cur = SECTION_ID_WORKFLOWS; for (const seg of dirSegs) { cur = `${cur}/${seg}`; pinKeys.push(cur); } pinExpanded(pinKeys); } // A->B equivalent of pinDestPath: open the destination dir chain in the // SNAPSHOT panel (its own comparePathStates map) so the copied item is visible // after the rerender. Opens only the dir chain — never the synthetic "Archive" // child (it has its own collapsed default and isn't in this chain). function openCompareDestPath(dirSegs) { comparePathStates.set(SECTION_ID_WORKFLOWS, true); let cur = SECTION_ID_WORKFLOWS; for (const seg of dirSegs) { cur = `${cur}/${seg}`; comparePathStates.set(cur, true); } } function copyToast(dest, wfName, res) { if (res && res.status === "kept-both") { return `Copied into ${dest} as "${res.finalName}" — kept both (a different "${wfName}" was already there).`; } return `Copied "${wfName}" into ${dest}.`; } // ---- B -> A: copy from the snapshot into the live working kit ---- function copyNodeToLive(type) { const result = addToMyPicks(type); if (result === "added") { notifyPicksChanged(); toast(`Copied "${type}" into A's favorites.`); } else if (result === "duplicate") { toast(`"${type}" is already in A's favorites.`); } else { toast(`Could not copy "${type}" — favorites write failed.`); } } function copyWorkflowToLive(wfPath) { const entry = getWorkflowEntryFromStore( compareSnapshot && compareSnapshot.workflows ? compareSnapshot.workflows : null, wfPath, ); if (!entry || !entry.graph) { toast(`Could not read "${wfPath}" from the snapshot.`); return; } const { dirSegs, wfName } = splitWfPath(wfPath); const tags = Array.isArray(entry.tags) ? entry.tags : []; const sourceLabel = compareMeta && compareMeta.displayName ? compareMeta.displayName : "snapshot"; let res = null; persistMutation({ mutate: () => { res = copyWorkflowIntoLiveStore(dirSegs, wfName, entry.graph, { tags, module: entry.module === true, sourceLabel, }); if (res.status === "skipped") return false; pinDestPath(dirSegs); return res; }, onSuccess: () => toast(copyToast("A", wfName, res)), onNoOp: () => toast(`"${wfName}" is already in A at that path.`), persistFailedMessage: `Copy failed — could not write "${wfName}" to A. See console.`, }); } // ---- A -> B: edit the in-memory snapshot working copy (no disk write) ---- // The snapshot panel is a working COPY of a file; A->B copies mutate it in // memory and mark it dirty. Persisting is an explicit, user-initiated Save to a // NAMED file (saveCompareSnapshot) — never a per-copy write, never an autosave. function copyNodeToSnapshot(type) { const picks = Array.isArray(compareSnapshot.picks) ? compareSnapshot.picks : []; if (picks.includes(type)) { toast(`"${type}" is already in the snapshot.`); return; } compareSnapshot.picks = [...picks, type]; compareDirty = true; rerenderSidebar(); toast(`Added "${type}" to the snapshot — unsaved (use Save in the footer to keep it).`); } function copyWorkflowToSnapshot(wfPath) { const entry = getWorkflowEntryFromStore(getAllWorkflowsForExport(), wfPath); if (!entry || !entry.graph) { toast(`Could not read "${wfPath}" from your kit.`); return; } const { dirSegs, wfName } = splitWfPath(wfPath); const tags = Array.isArray(entry.tags) ? entry.tags : []; if (!compareSnapshot.workflows || typeof compareSnapshot.workflows !== "object") { compareSnapshot.workflows = { directories: {} }; } const res = copyWorkflowIntoStore(compareSnapshot.workflows, dirSegs, wfName, entry.graph, { tags, module: entry.module === true, sourceLabel: "your kit", }); if (res.status === "skipped") { toast(`"${wfName}" is already in the snapshot at that path.`); return; } compareDirty = true; openCompareDestPath(dirSegs); rerenderSidebar(); toast(`${copyToast("B", wfName, res)} Unsaved — Save to keep it.`); } // Derive the named snapshot this working copy should Save to: the named file it // was loaded from, or — when loaded from an autosave shadow (`Foo_autosave/`) — // the named snapshot `Foo` it shadows. Empty when loaded from `_unsaved_autosave` // (no named parent) so the user types a fresh name. function compareDefaultSaveName() { const dir = compareMeta && compareMeta.dir ? compareMeta.dir : ""; if (dir === "_unsaved_autosave") return ""; if (dir.endsWith("_autosave")) return dir.slice(0, -"_autosave".length); return compareMeta && compareMeta.fileName ? compareMeta.fileName : ""; } // Explicit Save of the in-memory snapshot edits to a NAMED file (never an // autosave). Pre-fills the name with the shadowed / loaded name so the user // confirms exactly where it lands, writes to the default library dir, and clears // the dirty flag. Independent of the LIVE save tracker — this writes a snapshot // file; it doesn't change what your live session tracks. function saveCompareSnapshot() { if (!compareSnapshot) return; const snapshotRef = compareSnapshot; showInputModal({ title: "Save snapshot", label: "Save merged snapshot as", defaultValue: compareDefaultSaveName(), placeholder: "snapshot name", confirmLabel: "Save", onSubmit: async (rawName) => { // Sanitize like every other writePreset caller (clean toast on bad // input instead of an opaque server 400). const name = sanitizeName(rawName); if (!name) { toast("Snapshot name is empty after stripping unsafe characters."); return; } const doWrite = async () => { if (compareSaving) { toast("A snapshot save is already in progress."); return; } compareSaving = true; try { await writePreset(name, snapshotRef); // default library dir — never an autosave subdir } catch (e) { console.error("[Koolook] snapshot save failed:", e); toast(`Could not save snapshot "${name}": ${e.message}`); return; } finally { compareSaving = false; } // If the user exited / loaded a different snapshot during the // write, the dirty flag + meta now belong to something else. if (compareSnapshot !== snapshotRef) { toast(`Saved snapshot "${name}".`); return; } compareMeta = { fileName: name, displayName: name }; // now tracks the named file compareDirty = false; rerenderSidebar(); toast(`Saved snapshot "${name}" to disk.`); }; // Confirm the destination before writing — `writePreset` ends in an // atomic os.replace, so an accepted default name could otherwise // silently overwrite a DIFFERENT same-named snapshot. Mirrors the // live Save flow's existence guard (modals.js promptForName). const exists = await presetExists(name); if (exists === null) { toast("Cannot reach the preset library to verify the name. Save canceled — check the library path or your connection."); return; } if (exists === true) { showConfirmModal({ title: "Overwrite existing snapshot?", message: `A snapshot named "${name}" already exists. Overwrite it?`, confirmLabel: "Overwrite", danger: true, onConfirm: doWrite, }); return; } await doWrite(); }, }); } // ---- Bulk folder copy (the merge case) ---- function copyFolderToLive(dirSegs, label) { const sourceStore = compareSnapshot && compareSnapshot.workflows ? compareSnapshot.workflows : { directories: {} }; const sourceLabel = compareMeta && compareMeta.displayName ? compareMeta.displayName : "snapshot"; let summary = null; persistMutation({ mutate: () => { summary = copyFolderIntoLiveStore(sourceStore, dirSegs, { sourceLabel }); if (summary.added === 0 && summary.keptBoth === 0) return false; // nothing new landed pinDestPath(dirSegs); return summary; }, onSuccess: () => toast(folderCopyToast("A", label, summary)), onNoOp: () => toast(folderCopyToast("A", label, summary)), persistFailedMessage: `Folder copy failed — could not write "${label}" to A. See console.`, }); } function copyFolderToSnapshot(dirSegs, label) { const sourceStore = getAllWorkflowsForExport(); if (!compareSnapshot.workflows || typeof compareSnapshot.workflows !== "object") { compareSnapshot.workflows = { directories: {} }; } const summary = copyFolderIntoStore(compareSnapshot.workflows, sourceStore, dirSegs, { sourceLabel: "your kit" }); if (summary.added === 0 && summary.keptBoth === 0) { toast(folderCopyToast("B", label, summary)); // nothing new — no edit return; } compareDirty = true; openCompareDestPath(dirSegs); rerenderSidebar(); toast(`${folderCopyToast("B", label, summary)} Unsaved — Save to keep it.`); } // Tint the SOURCE column from the diff and return the counts for the legend. // The diff is SOURCE-relative-to-TARGET — green/new = present in the source but // NOT the target (a copy candidate you'd grab from this side); red/diff = in // both, graph differs (ignoring savedAt). Direction-aware, so it follows the // swap: the highlight always lands on the side you copy FROM, marking what's new // THERE. Text-only (CSS classes on existing rows). Runs after the column // rendered with the store overrides cleared, so live reads are live. function applyCompareTint(panelEl, targetPicks, targetStore, sourcePicks, sourceStore) { const newPicks = new Set(diffPicks(targetPicks, sourcePicks).onlyComparison); let newCount = newPicks.size; let diffCount = 0; const wfStatus = diffWorkflows(targetStore, sourceStore); for (const key of Object.keys(wfStatus)) { if (wfStatus[key] === "new") newCount += 1; else if (wfStatus[key] === "diff") diffCount += 1; } if (panelEl) { for (const row of panelEl.querySelectorAll("[data-koolook-node-type]")) { if (newPicks.has(row.dataset.koolookNodeType)) row.classList.add("koolook-cmp-new"); } for (const row of panelEl.querySelectorAll("[data-koolook-wf-path]")) { const status = wfStatus[row.dataset.koolookWfPath]; if (status === "new") row.classList.add("koolook-cmp-new"); else if (status === "diff") row.classList.add("koolook-cmp-diff"); } } return { newCount, diffCount }; } export function renderPanel(container, options = {}) { const { compare = false, snapshot = null, onToggleCompare = null, signal = null, compareName = "", readOnly = compare, pullIn = null, showUpdateFooter = true } = options; // Compare mode (#181): when `compare` is set, every tree (re-)render in // this panel reads from the loaded `snapshot` via the read-only store // overrides, and the live-change listeners at the bottom are skipped so // the snapshot panel stays static. compare=false is byte-identical to the // pre-#181 single live panel. // // `readOnly` (#197) is orthogonal: it applies the capture-phase guard and // the optional `pullIn` copy menu. It defaults to `compare` (so existing // callers are unchanged), but the compare host sets it per active side so // the swap can move the guard onto the live panel without changing its // data source. const withSource = compare ? (fn) => withSnapshotSource(snapshot, () => withComparePathStates(fn)) : (fn) => fn(); // `signal` (from renderSidebar's AbortController) lets a re-render drop the // previous mount's window listeners, so toggling Compare never leaks a set. const listenerOpts = signal ? { signal } : undefined; ensureStyle(); container.innerHTML = ""; container.classList.add("koolook-sidebar"); if (readOnly) { container.classList.add("koolook-compare-panel"); // Read-only comparison panel (#181): the same interactive renderPanel is // reused (chrome stays visually identical), but the read-only SOURCE side // must never mutate or load live state. Capture-phase guards allow // navigation only — folder expand/collapse, search, and the A/B exit // button — and neutralize every mutating/loading affordance: leaf // load/insert clicks, pick ×/+, the snapshot + tools buttons, the // grouping mode toggle (which writes the shared global GROUP_MODE_KEY), // context menus, and drag — dragstart on the source PLUS dragover/drop so // a drag begun in the live panel can't land on (and mutate via) this // column. Buttons stay visible but inert. // // The ONE affordance allowed through (#197): when a `pullIn` is wired, // right-clicking a node or workflow row opens a single "Copy to " // item. Direction is set by the swap; everything else stays blocked. const NAV_ALLOW = ".koolook-search, [data-koolook-compare-exit], .koolook-row:not(.koolook-leaf)"; container.addEventListener("click", (e) => { const t = e.target; if (t instanceof Element && t.closest(NAV_ALLOW)) return; e.preventDefault(); e.stopPropagation(); }, true); container.addEventListener("contextmenu", (e) => { // Always suppress the native menu and stop the row's own (live- // mutating) context-menu handler from firing. Then, if a pull-in is // wired, open the copy menu for the pull-able row under the cursor. e.preventDefault(); e.stopPropagation(); if (!pullIn) return; const t = e.target instanceof Element ? e.target : null; if (!t) return; const nodeRow = t.closest("[data-koolook-node-type]"); if (nodeRow) { showContextMenu(e, [{ label: `Copy to ${pullIn.destLabel}`, action: () => pullIn.copyNode(nodeRow.dataset.koolookNodeType), }]); return; } const wfRow = t.closest("[data-koolook-wf-path]"); if (wfRow) { showContextMenu(e, [{ label: `Copy to ${pullIn.destLabel}`, action: () => pullIn.copyWorkflow(wfRow.dataset.koolookWfPath), }]); return; } // Folder pull-in (#197): bulk-copy a whole workflow folder (the merge // case). Workflow folders only (node/tag folders aren't real store // dirs); the synthetic "Archive" folder is skipped. The Workflows // section root copies the entire tree. const dirRow = t.closest("[data-koolook-folder-path]"); if (dirRow) { const fp = dirRow.dataset.koolookFolderPath || ""; const isWfFolder = fp === SECTION_ID_WORKFLOWS || fp.startsWith(`${SECTION_ID_WORKFLOWS}/`); const segs = fp === SECTION_ID_WORKFLOWS ? [] : fp.slice(SECTION_ID_WORKFLOWS.length + 1).split("/"); if (isWfFolder && segs[segs.length - 1] !== "Archive") { const label = segs.length ? segs[segs.length - 1] : "all workflows"; showContextMenu(e, [{ label: `Copy folder "${label}" → ${pullIn.destLabel} (with contents)`, action: () => pullIn.copyFolder(segs, label), }]); } } }, true); container.addEventListener("dragstart", (e) => { e.preventDefault(); e.stopPropagation(); }, true); container.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); }, true); container.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); }, true); } // Forward declarations — `tree` and `search` are created several rows // below, but the Nodes-row mode toggle (built mid-renderPanel) needs to // re-render the tree from its click handler. JS closures bind by // reference, so the handler resolves both at click time, by which point // the assignments below have run. Without these `let` placeholders the // toggle would have to be appended out-of-order. let tree; let search; const rerenderTree = () => { if (tree) withSource(() => renderTree({ treeEl: tree, query: search ? search.value : "" })); }; // Three small wrappers over the snapshot dialogs. Each opens directly // — no tabbed parent modal — so the user goes from toolbar click to // the right form in one step. Capability functions are passed in // explicitly to keep `modals.js` UI-only (no `snapshot.js` import // crossing the layer). const openSaveDialog = () => showSaveSnapshotDialog({ getCurrentPresetName, setCurrentPresetName, presetExists, writePreset, gatherSnapshot, sanitizeName, getLibraryInfo, saveSettings, browseDirectories, createBrowseDirectory, revealPresetFolder, markStateSaved, onToast: toast, }); const openLoadDialog = () => showLoadSnapshotDialog({ listPresets, readPreset, deletePreset, applySnapshot, setCurrentPresetName, getCurrentPresetName, getLibraryInfo, saveSettings, browseDirectories, createBrowseDirectory, writePreLoadAutosave, markStateSaved, markStateAutosaved, listAutosaves, revealPresetFolder, onToast: toast, }); // ---- Row 0: Snapshot library action bar (top-level kit ops) ---- // Mirrors the Nodes/Workflows action-row pattern but lives above the // search field — these are infrequent meta-actions (save/load the // whole Kforge Labs state as a named preset) and shouldn't be // mixed into the per-section toolbars. const snapshotRow = document.createElement("div"); snapshotRow.className = "koolook-actions-row"; // Snapshot status indicator (replaces the static "Snapshot" label). // Layout: [● · ] where: // • the dot's color encodes status (saved=green, autosaved=blue, // unsaved=orange, none=grey) // • the name is the user's last-loaded/saved preset, ellipsised if // long; "(no snapshot)" italic when nothing is tracked // • the state is a small textual qualifier ("saved" / "auto-saved" / // "unsaved") // Tooltip on the whole row carries the long-form explanation including // the latest auto-save timestamp when relevant. const snapshotStatus = document.createElement("div"); snapshotStatus.className = "koolook-snap-status"; const snapshotDot = document.createElement("span"); snapshotDot.className = "koolook-snap-status-dot koolook-snap-status-none"; snapshotStatus.appendChild(snapshotDot); const snapshotName = document.createElement("span"); snapshotName.className = "koolook-snap-status-name"; snapshotStatus.appendChild(snapshotName); const snapshotState = document.createElement("span"); snapshotState.className = "koolook-snap-status-state"; snapshotStatus.appendChild(snapshotState); snapshotRow.appendChild(snapshotStatus); let snapshotLibraryPath = ""; let snapshotStatusRefreshSeq = 0; function formatLocalTime(iso) { if (!iso) return "not saved yet"; const d = new Date(iso); if (isNaN(d.getTime())) return iso; return formatLocalStamp(d); } function snapshotTooltip(status) { const stamp = status.state === "autosaved" ? status.lastAutosaveAt : status.lastNamedSaveAt; const location = snapshotLibraryPath || "loading..."; // Drifted (#161): the named snapshot file on disk diverged from the // live state when the session started. Tooltip carries the recovery // instructions so the user understands what the pill colour means // and how to clear it without hunting through docs. if (status.state === "drifted") { // Use ``driftDetectedAt`` (session-noticed-at), NOT // ``lastNamedSaveAt`` — the latter comes from a localStorage // baseline that may have been captured by a prior session // whose live state was already corrupt, and surfacing it // here as "Last named save" would mislead the user about // when the divergence actually started. return ( `Tracked snapshot "${status.name || "?"}" diverges from live state.\n` + `Periodic auto-saves are being redirected to _unsaved_autosave/\n` + `to protect the named snapshot's recovery folder.\n` + `\n` + `To resolve: Load the tracked snapshot (discards live changes), or\n` + `Save / Quick Save (overwrites the tracked snapshot with live state).\n` + `\n` + `Drift detected: ${formatLocalTime(status.driftDetectedAt)}\n` + `Location: ${location}` ); } return `Date: ${formatLocalTime(stamp)}\nLocation: ${location}`; } function refreshSnapshotStatus() { const seq = ++snapshotStatusRefreshSeq; const status = getSnapshotStatus(); snapshotDot.className = "koolook-snap-status-dot koolook-snap-status-" + status.state; if (status.name) { snapshotName.classList.remove("koolook-snap-status-name-empty"); snapshotName.textContent = status.name; } else { snapshotName.classList.add("koolook-snap-status-name-empty"); snapshotName.textContent = "(no snapshot)"; } let stateText = ""; switch (status.state) { case "saved": stateText = "· saved"; break; case "autosaved": stateText = "· auto-saved"; break; case "unsaved": stateText = "· unsaved"; break; case "drifted": stateText = "· drifted (reload?)"; break; case "none": default: stateText = ""; break; } snapshotState.textContent = stateText; snapshotStatus.title = snapshotTooltip(status); getLibraryInfo().then((info) => { if (seq !== snapshotStatusRefreshSeq) return; snapshotLibraryPath = info && typeof info.path === "string" ? info.path : ""; snapshotStatus.title = snapshotTooltip(status); }).catch(() => { if (seq !== snapshotStatusRefreshSeq) return; snapshotLibraryPath = ""; snapshotStatus.title = snapshotTooltip(status); }); } if (compare) { // The comparison panel identifies the snapshot it is showing — its own // name + a static "comparing" state — rather than echoing the live // working-copy status. It never refreshes off live status events. snapshotDot.className = "koolook-snap-status-dot koolook-snap-status-comparing"; snapshotName.classList.remove("koolook-snap-status-name-empty"); snapshotName.textContent = compareName || "(snapshot)"; snapshotState.textContent = "· comparing"; snapshotStatus.title = compareName ? `Comparing: ${compareName}` : "Comparing"; } else { refreshSnapshotStatus(); window.addEventListener(PICKS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts); window.addEventListener(WORKFLOWS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts); window.addEventListener(SNAPSHOT_STATUS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts); } snapshotRow.appendChild(makeToolbarButton({ icon: TOOLBAR_ICONS.loadSnapshot, title: "Load a saved snapshot (replaces current state)", onClick: openLoadDialog, })); // Quick Save — single-click overwrite of the currently-tracked preset // with no dialog. Disabled when no preset is tracked (then the regular // Save button is the entry point because it prompts for a new name). // Distinct icon from Save so the two are scannable: Save = cloud-upload, // Quick Save = floppy-disk save icon. const quickSaveBtn = makeToolbarButton({ iconClass: "pi pi-save", title: "Quick Save — overwrite the currently loaded preset (no dialog)", onClick: async () => { const current = getCurrentPresetName(); if (!current) { toast("No preset loaded — use Save to name a new one."); return; } try { const snap = gatherSnapshot(current); await writePreset(current, snap); markStateSaved(); toast(`Quick-saved "${current}".`); } catch (e) { console.error("[Koolook] quick save failed:", e); toast(`Could not Quick Save: ${e.message}`); } }, }); function refreshQuickSaveDisabled() { quickSaveBtn.disabled = !getCurrentPresetName(); } refreshQuickSaveDisabled(); window.addEventListener(SNAPSHOT_STATUS_CHANGED_EVENT, refreshQuickSaveDisabled, listenerOpts); snapshotRow.appendChild(quickSaveBtn); snapshotRow.appendChild(makeToolbarButton({ icon: TOOLBAR_ICONS.saveSnapshot, title: "Save current state — overwrites the last-loaded preset, or prompts for a name", onClick: openSaveDialog, })); // A/B — Compare mode toggle (#181). Same letter-button style as L/S; the // host (`renderSidebar`) supplies the toggle handler (enter on the live // panel, exit on the comparison panels). if (onToggleCompare) { const compareBtn = makeToolbarButton({ icon: TOOLBAR_ICONS.compareSnapshot, title: compare ? "Exit Compare mode" : "Compare mode — open another snapshot side by side", onClick: onToggleCompare, }); // Exempt from the read-only comparison-panel click guard so the user // can always toggle back out of Compare mode. compareBtn.dataset.koolookCompareExit = "1"; snapshotRow.appendChild(compareBtn); } // Settings cog removed in #137: library-path change now lives inside the // Save dialog ("Save to...") and the Load dialog ("Load from..."), both // backed by the same folder picker. The cog is gone; the snapshot row // is now: status indicator | Load | Quick Save | Save. container.appendChild(snapshotRow); // ---- Row 0b: Tools (admin/advanced — not for daily use) ---- // Sits between the Snapshot row and the search field. Holds power-user // utilities a typical user shouldn't trigger by accident: exporting the // curated_defaults.json (maintainer flow), the Manager-driven install of // missing packs (only useful on a fresh install or after wiping nodes), // and the canvas-handoff fallback for `security_level=normal` users // (drops placeholder nodes so Manager's own "Install Missing Custom // Nodes" UI takes over). Keeping these out of the per-section toolbars // declutters the everyday Add/Save flow. const dividerAfterSnapshot = document.createElement("div"); dividerAfterSnapshot.className = "koolook-tree-divider"; container.appendChild(dividerAfterSnapshot); const toolsRow = document.createElement("div"); toolsRow.className = "koolook-actions-row"; const toolsLabel = document.createElement("span"); toolsLabel.className = "koolook-actions-label"; toolsLabel.textContent = "Tools"; toolsRow.appendChild(toolsLabel); toolsRow.appendChild(makeToolbarButton({ icon: TOOLBAR_ICONS.exportStarter, title: "Export current state as starter_preset.json (copies snapshot JSON to clipboard — paste into web/starter_preset.json to ship as the next release's starter)", onClick: exportStarterPreset, })); // "Install missing for picks" — always enabled. The modal itself decides // whether anything actually needs installing (after probing Manager and // walking picks against the live LiteGraph registry), so leaving the // button enabled lets the user trigger the discovery flow whenever they // want a status check, including the "everything's already installed" // confirmation. Click handlers don't read picks here — the modal pulls // a fresh `loadUserPicks()` snapshot at open-time. toolsRow.appendChild(makeToolbarButton({ icon: TOOLBAR_ICONS.installMissing, title: "Install missing custom nodes for current picks (via ComfyUI-Manager)", onClick: () => showInstallMissingModal({ picks: loadUserPicks() }), })); // "Drop missing onto canvas" — the security_level=normal escape hatch. // Discovery runs the same detect+map+resolve pipeline as the install // modal, then for each missing pack we instantiate one placeholder node // (the pack's first mapped node ID) on a fresh canvas. ComfyUI renders // unknown types as red error nodes, which Manager's "Install Missing // Custom Nodes" feature scans for — so the user gets a one-click // handoff to Manager's UI-driven install path that doesn't go through // /customnode/install/git_url's security gate. toolsRow.appendChild(makeToolbarButton({ icon: TOOLBAR_ICONS.dropMissing, title: "Drop placeholders for missing packs onto canvas — then use Manager's \"Install Missing Custom Nodes\" (works at security_level=normal)", onClick: async () => { const picks = loadUserPicks(); if (!picks || picks.length === 0) { toast("No picks to check. Add some favorites first."); return; } const discovery = await discoverMissingPacks(picks); if (!discovery.ok) { if (discovery.reason === "manager-unreachable") { toast("ComfyUI-Manager isn't reachable — can't resolve picks to packs."); } else { toast(`Could not load Manager's mapping database: ${discovery.error?.message || discovery.reason}.`); } return; } const byUrl = discovery.result.willInstall.byUrl; if (byUrl.size === 0) { toast("Nothing missing — every pick is installed (or unmapped)."); return; } await dropPlaceholdersForPacks(byUrl); }, })); toolsRow.appendChild(makeToolbarButton({ icon: TOOLBAR_ICONS.help, title: "Open the Kforge Labs visual guide", onClick: () => { const opened = window.open(GUIDE_URL, "_blank", "noopener,noreferrer"); if (!opened) { toast("Could not open the guide. Allow pop-ups for this page, then try again."); } }, })); // "P" — Published filter toggle (issue #227). Prunes the Workflows tree to // only workflows tagged `published`, force-expanded, so every published // setup is visible in its origin structure. Toggle off restores the full // tree. The active state is mirrored on the button so it reads as "on". const publishedFilterBtn = makeToolbarButton({ icon: TOOLBAR_ICONS.publishedFilter, title: "Show only published setups (toggle)", onClick: () => { publishedOnly = !publishedOnly; publishedFilterBtn.classList.toggle("koolook-icon-btn-active", publishedOnly); rerenderTree(); }, }); // Seed the highlight from the persisted module flag: the panel is rebuilt // on Compare enter/exit, swap, etc., and `publishedOnly` survives those, so // without this the button would render un-highlighted while the tree is // still filtered — and the next click would invert the real state. publishedFilterBtn.classList.toggle("koolook-icon-btn-active", publishedOnly); toolsRow.appendChild(publishedFilterBtn); container.appendChild(toolsRow); const dividerBeforeSearch = document.createElement("div"); dividerBeforeSearch.className = "koolook-tree-divider"; container.appendChild(dividerBeforeSearch); // ---- Row 1: Search ---- const searchRow = document.createElement("div"); searchRow.className = "koolook-search-row"; const searchWrap = document.createElement("div"); searchWrap.className = "koolook-search-wrap"; const searchIcon = document.createElement("span"); searchIcon.className = "pi pi-search koolook-search-icon"; searchWrap.appendChild(searchIcon); search = document.createElement("input"); search.type = "search"; search.className = "koolook-search"; search.placeholder = "Search nodes & workflows..."; searchWrap.appendChild(search); searchRow.appendChild(searchWrap); container.appendChild(searchRow); // Divider between the search field and the Nodes action bar — gives the // search field its own visual zone instead of jamming it against the // first action row beneath it. const dividerAfterSearch = document.createElement("div"); dividerAfterSearch.className = "koolook-tree-divider"; container.appendChild(dividerAfterSearch); // ---- Row 2: Nodes action bar ---- const nodesRow = document.createElement("div"); nodesRow.className = "koolook-actions-row"; const nodesLabel = document.createElement("span"); nodesLabel.className = "koolook-actions-label"; nodesLabel.textContent = "Nodes"; nodesRow.appendChild(nodesLabel); // Segmented mode toggle: [📦 by-pack] [🌐 by-category]. The active mode // is highlighted; click switches and re-renders. The choice persists in // localStorage (`GROUP_MODE_KEY`) and survives reloads. We listen for // storage events further down so a second tab updates in lockstep. const modeToggle = document.createElement("div"); modeToggle.className = "koolook-mode-toggle"; function makeModeBtn(modeId, icon, title) { const btn = document.createElement("button"); btn.className = "koolook-mode-toggle-btn"; btn.title = title; btn.setAttribute("aria-label", title); btn.dataset.mode = modeId; btn.appendChild(makeIconElement(icon)); btn.addEventListener("click", () => { if (loadGroupMode() === modeId) return; saveGroupMode(modeId); refreshModeToggle(); rerenderTree(); }); return btn; } const repoModeBtn = makeModeBtn( "repo", TOOLBAR_ICONS.repoMode, "Group by pack — each repo's nodes under their original category subtree.", ); const categoryModeBtn = makeModeBtn( "category", TOOLBAR_ICONS.categoryMode, "Group by theme — picks regrouped by category theme (e.g. all image nodes together) regardless of source pack.", ); function refreshModeToggle() { const m = loadGroupMode(); repoModeBtn.classList.toggle("koolook-mode-active", m === "repo"); categoryModeBtn.classList.toggle("koolook-mode-active", m === "category"); } refreshModeToggle(); modeToggle.appendChild(repoModeBtn); modeToggle.appendChild(categoryModeBtn); nodesRow.appendChild(modeToggle); const addBtn = document.createElement("button"); // Same height as the icon-bar buttons (toolbar pi-* glyphs) — uses an // explicit `pi pi-plus` icon at the same font-size rather than a 16px "+" // text glyph that sat taller than the rest of the row. The dusty-green // accent class flags it as the primary action in the Nodes row. addBtn.className = "koolook-add-btn koolook-icon-btn koolook-add-btn-green"; const addBtnIcon = document.createElement("i"); addBtnIcon.className = "pi pi-plus"; addBtnIcon.setAttribute("aria-hidden", "true"); addBtn.appendChild(addBtnIcon); addBtn.title = "Add the selected canvas node(s) to favorites"; addBtn.setAttribute("aria-label", "Add the selected canvas node(s) to favorites"); addBtn.addEventListener("click", () => { const types = getSelectedNodeTypes(); if (types.length === 0) { toast("Select a node on the canvas first."); return; } let added = 0; let duplicates = 0; let failed = 0; const successfulTypes = []; for (const t of types) { const status = addToMyPicks(t); if (status === "added") added += 1; else if (status === "duplicate") duplicates += 1; else failed += 1; // Spotlight even on duplicates — the user clicked + with this node // selected; reminding them where it lives is the whole feature. if (status !== "failed") successfulTypes.push(t); } spotlightAddedPicks(successfulTypes); if (added > 0) { const noun = added === 1 ? "node" : "nodes"; toast(`Added ${added} ${noun} to favorites.`); notifyPicksChanged(); } else if (duplicates > 0 && failed === 0) { toast("Already in favorites."); // Re-render anyway so the spotlight effect lands even on pure- // duplicate clicks. notifyPicksChanged is the agreed-upon trigger // for any sidebar refresh, even when picks didn't actually change. notifyPicksChanged(); } if (failed > 0) { toast(`Failed to save ${failed} pick${failed === 1 ? "" : "s"}. See console.`); } }); nodesRow.appendChild(addBtn); // Daily-use Nodes row keeps only the Add (+) button — Export and // Install-missing have been promoted up to the dedicated Tools row above // the search field (they're admin/advanced operations the typical user // shouldn't trigger from the everyday toolbar). container.appendChild(nodesRow); // Divider between the two action bars const dividerBetweenBars = document.createElement("div"); dividerBetweenBars.className = "koolook-tree-divider"; container.appendChild(dividerBetweenBars); // ---- Row 3: Workflows action bar ---- const wfRow = document.createElement("div"); wfRow.className = "koolook-actions-row"; const wfLabel = document.createElement("span"); wfLabel.className = "koolook-actions-label"; wfLabel.textContent = "Workflows"; wfRow.appendChild(wfLabel); // Top-level directory creation lives on right-click of the "Workflows" // section header (see `workflowsRootContextMenu`). The action bar below // is reserved for save actions only — keeping it homogeneous so beginners // aren't asked to mentally separate "save the canvas" from "make a // folder" in the same toolbar. // Shared save handler for both buttons. Uses persistMutation so the cache // mutation rolls back automatically if both /userdata and the localStorage // fallback rejected the write — the user isn't told a save succeeded when // nothing was persisted, and the in-memory cache stays consistent with disk. // // `asModule` rides the same persistMutation so a commit failure rolls // back BOTH the entry write and the tag add. Doing the addTag in a // separate persistMutation would race: the save could land while the // tag rejected, leaving a "module" entry the user thinks is tagged // but isn't. const saveAndToast = async (graph, name, dirPath, asModule = false) => { // Pin the workflows section + every ancestor of the destination dir // so the resulting re-render leaves the whole path open. Pins are // consumed on the next render. const pinKeys = [SECTION_ID_WORKFLOWS]; let cur = SECTION_ID_WORKFLOWS; for (const seg of dirPath) { cur = `${cur}/${seg}`; pinKeys.push(cur); } pinExpanded(pinKeys); const dirDisplay = dirPath.join(" / "); await persistMutation({ mutate: () => { const result = saveWorkflowEntry(dirPath, name, graph, { module: asModule }); if (!result) return false; if (asModule) addTag(dirPath, name, MODULE_TAG); return result; }, onSuccess: (result) => { const moduleSuffix = asModule ? " as module" : ""; if (result.archivedAs) { toast(`Saved "${name}" in ${dirDisplay}${moduleSuffix}. Previous version moved to Archive.`); } else { toast(`Saved "${name}" in ${dirDisplay}${moduleSuffix}.`); } }, persistFailedMessage: `Save failed — could not write "${name}". See console.`, }); }; wfRow.appendChild(makeToolbarButton({ icon: TOOLBAR_ICONS.saveWorkflow, title: "Save entire canvas as a workflow", onClick: () => { // Check emptiness first so a serialize() throw doesn't get // misreported as "Canvas is empty" — distinct toasts for distinct // failure modes. if (!canvasIsNonEmpty()) { toast("Canvas is empty."); return; } const graph = serializeFullCanvas(); if (!graph || !graph.nodes || graph.nodes.length === 0) { toast("Failed to serialize canvas. See console."); return; } showSaveWorkflowModal({ titleSuffix: "entire canvas", // Whole-canvas saves default to NOT being modules — the // typical "save my whole workflow" intent is to keep the // load-replaces-canvas semantics. The checkbox is still // visible so a power user can opt in. defaultModule: false, onSave: ({ name, dirPath, asModule }) => saveAndToast(graph, name, dirPath, asModule), }); }, })); wfRow.appendChild(makeToolbarButton({ iconClass: "pi pi-objects-column", title: "Save current selection as a workflow", onClick: () => { const result = serializeSelection(); if (result.kind === "empty") { toast("Select one or more nodes on the canvas first."); return; } if (result.kind === "stale") { // Selection set is non-empty but every id in it points at a // deleted node — typically after an undo or a delete-then- // change-tab sequence. Generic "select one or more" was // misleading because the user *had* selected something; the // canvas just doesn't have it anymore. toast("Selected node(s) no longer exist. Click a node on the canvas to re-select."); return; } const { graph } = result; showSaveWorkflowModal({ titleSuffix: `${graph.nodes.length} selected node${graph.nodes.length === 1 ? "" : "s"}`, // Selection saves are the canonical "module" candidate — // small clusters the user wants to splice into other // workflows. Pre-check the box; the user can untick if // they'd rather have replace-on-load semantics. defaultModule: true, onSave: ({ name, dirPath, asModule }) => saveAndToast(graph, name, dirPath, asModule), }); }, })); container.appendChild(wfRow); // Divider between the workflows action bar and the list below const dividerBeforeTree = document.createElement("div"); dividerBeforeTree.className = "koolook-tree-divider"; container.appendChild(dividerBeforeTree); // ---- Tree (scrollable) ---- tree = document.createElement("div"); tree.className = "koolook-tree"; container.appendChild(tree); withSource(() => renderTree({ treeEl: tree, query: "" })); const updateFooter = document.createElement("div"); updateFooter.className = "koolook-update-footer"; container.appendChild(updateFooter); if (showUpdateFooter) { checkForUpdate().then((update) => renderUpdateFooter(updateFooter, update)); } // ---- GitHub discovery nudge ---- // A discreet line directly below the update footer. Always shown in the // live panel (gated on the same `showUpdateFooter` flag so the read-only // compare columns stay chrome-free). Ko-fi/funding deliberately lives in // the GitHub repo only — this surface stays limited to the discovery ask. if (showUpdateFooter) { const starFooter = document.createElement("div"); starFooter.className = "koolook-star-footer"; const starGlyph = document.createElement("span"); starGlyph.className = "koolook-star-glyph"; starGlyph.textContent = "★"; starGlyph.setAttribute("aria-hidden", "true"); const starText = document.createElement("span"); starText.textContent = "Help creators find us on "; const starLink = document.createElement("a"); starLink.href = GITHUB_REPO_URL; starLink.target = "_blank"; starLink.rel = "noopener noreferrer"; starLink.textContent = "GitHub"; starLink.title = "Help creators find Kforge Labs on GitHub"; starText.append(starLink); starFooter.append(starGlyph, starText); container.appendChild(starFooter); } // ---- Build tag (dev-sync verification) ---- // `scripts/sync_to_dev.py` writes `web/_dev_build.json` post-sync so the // maintainer can eyeball whether the live ComfyUI is running the latest // synced code. Absent on registry installs and plain git checkouts — // silently renders nothing. Cache-bust query param so a re-sync's new // mtime reaches the browser without a hard refresh. const buildTag = document.createElement("div"); buildTag.className = "koolook-build-tag"; container.appendChild(buildTag); fetch(new URL("../_dev_build.json", import.meta.url).href + `?t=${Date.now()}`) .then(r => (r.ok ? r.json() : null)) .then(info => { if (!info) return; // Layout, top-to-bottom: // dev ·