vidfom's picture
Upload folder using huggingface_hub (part 8)
0c85e62 verified
Raw
History Blame Contribute Delete
173 kB
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
// 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<path, boolean>; 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<canonicalThemeKey, {
// displayLabels: Map<originalCasing, count>, // 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<canonicalThemeKey, { displayLabels: Map<rawLabel, count>, 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 <active>"
// 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: [● <preset name> · <state>] 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 <SHA> · <time> ← line 1: identifier + timestamp
// <scope> ← line 2: human-readable purpose
// SHA gets its own span (13px monospace) — the at-a-glance
// identifier when comparing chat history with the live footer.
// Scope renders as a block in proportional italic so it reads
// as prose, not as part of the build identifier.
buildTag.textContent = "";
buildTag.appendChild(document.createTextNode("dev "));
if (info.commit) {
const shaEl = document.createElement("span");
shaEl.className = "koolook-build-sha";
shaEl.textContent = info.commit;
buildTag.appendChild(shaEl);
}
if (info.synced_at) {
buildTag.appendChild(document.createTextNode(` · ${info.synced_at}`));
}
if (info.scope) {
const scopeEl = document.createElement("span");
scopeEl.className = "koolook-build-scope";
scopeEl.textContent = info.scope;
buildTag.appendChild(scopeEl);
}
})
.catch(() => { /* not in a dev install — leave empty */ });
// ---- Search wiring ----
let debounce = null;
search.addEventListener("input", (e) => {
const q = e.target.value;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => withSource(() => renderTree({ treeEl: tree, query: q })), 60);
});
// ---- Event subscriptions for live re-rendering ----
// Skipped in compare mode: a comparison panel shows a static snapshot, so
// it must not re-render off live picks/workflows/storage changes (which
// would read live state instead of the snapshot) or leak window listeners
// across compare toggles.
if (!compare) {
window.addEventListener(PICKS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }), listenerOpts);
window.addEventListener(WORKFLOWS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }), listenerOpts);
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY || e.key === WORKFLOWS_FALLBACK_KEY || e.key === GROUP_MODE_KEY) {
renderTree({ treeEl: tree, query: search.value });
}
}, listenerOpts);
}
}