Files
malkuthro-ComfyUI-Koolook/web/sidebar/tree.js
T
malkuthroandClaude Opus 4.7 a609e75d40 refactor(web): tree pipeline as section-registry with two-tier API (#52)
* refactor(web): tree pipeline as section-registry with two-tier API

Implements issue #50. Behavior-preserving — same DOM, same path-state
keys, same render cadence, same context menus. The new engine deepens
the tree module: a 3-function public surface hides the section
iteration, divider insertion, forceExpanded threading, path prefixing,
empty-state placeholders, and pathStates lifecycle.

Public surface (3 functions, all module-private — only renderPanel is
exported):
  - addSection(spec)        — register a section descriptor
  - renderTree({treeEl,query}) — single render entry point
  - pinExpanded(paths)      — pin folders open for the next render

Internals:
  - SECTIONS array (declaration order = render order)
  - pinnedPaths Set (consumed every render)
  - makeSectionCtx → builds ctx.folder({...}) / ctx.leaf({row}) with
    automatic path prefixing (sections write segment-relative paths;
    engine produces ${section.id}/<seg>/...)
  - validPaths Set populated as folders render; pathStates pruned at
    end of each render against validPaths ∪ pinnedPaths — fixes the
    unbounded-growth bug from renames/deletes leaving stale keys

Built-in sections:
  - "nodes": curated-nodes section, merges gatherNodesByRepo +
    gatherUserPickPacks into one displayed group with auto-pack and
    user-pick subgroups (same UX as before)
  - "workflows": directories with Archive sub-folders and right-click
    context menus

Save flow:
  - saveAndToast now calls pinExpanded(["workflows", "workflows/${dir}"])
    before persistMutation; the persist's notifyWorkflowsChanged fires
    a re-render that consumes the pin and opens the destination folder

Adding a new section is now ~12 lines (simple flat list) or ~30 lines
(advanced tree with row interactions) — the engine handles cross-cutting
concerns. Foundation for tags / recents / pinned / templates features.

Closes #50.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): scope pathStates prune to rendered sections + always apply pins

Two HIGH issues from the multi-agent review on PR #52:

1) Pruning was over-broad. When section A is gated empty by a search
   filter, its (legitimate) pathStates keys were getting nuked because
   they weren't in `validPaths` — clearing the search reverted the
   user's prior expansions to startExpanded defaults. Fix: track which
   sections actually rendered this pass (`renderedSectionIds`); prune
   only keys whose owning section rendered.

2) `pinnedPaths` could leak across renders when the cross-section
   search-empty branch took an early return without consuming the pin
   set. A pin from one save would later be applied to an unrelated
   render. Fix: drop the early return; phase 3 (prune + pin apply) now
   always runs.

Also tightens code-quality LOW findings:
- `let pinnedPaths` -> `const pinnedPaths` + `.clear()` after snapshot
  via `new Set(pinnedPaths)`.
- Add `@typedef SectionCtx` and `FolderOpts` JSDoc blocks (referenced
  by `SectionSpec.build` but missing in the initial commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:12:44 +02:00

988 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// 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,
ensureStyle,
toast,
compareNames,
} from "./constants.js";
import {
loadUserPicks,
removeFromMyPicks,
notifyPicksChanged,
addToMyPicks,
exportPicks,
} from "./picks_store.js";
import {
persistMutation,
listDirectoryNames,
dirOf,
addDirectory,
renameDirectory,
deleteDirectory,
saveWorkflowEntry,
archiveWorkflow,
unarchiveWorkflow,
renameWorkflow,
deleteWorkflow,
moveWorkflow,
} from "./workflows_store.js";
import {
serializeFullCanvas,
serializeSelection,
canvasIsNonEmpty,
loadWorkflowOntoCanvas,
insertNode,
getSelectedNodeTypes,
} from "./canvas_io.js";
import {
showInputModal,
showConfirmModal,
showSaveWorkflowModal,
showContextMenu,
} from "./modals.js";
// =============================================================================
// 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.
// =============================================================================
const pathStates = 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 {(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);
}
/**
* 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 }) {
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);
const sectionFolder = buildFolder({
name: section.label,
count: result.total,
iconKind: section.iconKind,
startExpanded: true,
path: sectionPath,
forceExpanded: isFiltered,
childrenBuilder: (children) => {
const ctx = makeSectionCtx(children, sectionPath, isFiltered, 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, onContextMenu, build }) {
const fullPath = prefix ? `${prefix}/${path}` : path;
validPaths.add(fullPath);
const folder = buildFolder({
name, count, iconKind, startExpanded,
path: fullPath,
forceExpanded: isFiltered,
onContextMenu,
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);
}
// =============================================================================
// 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);
}
const parts = category.split("/");
return parts.length > 1 ? parts.slice(1).join("/") : category;
}
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 out = [];
for (const repo of REPOS) {
let candidateIds;
if (repo.select === "all") {
const root = repo.categoryRoot || "";
candidateIds = Object.entries(registry)
.filter(([, nc]) => {
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);
} 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 — workflows
// =============================================================================
function gatherWorkflows(query) {
const q = (query || "").trim().toLowerCase();
const out = [];
let total = 0;
for (const dirName of listDirectoryNames()) {
const dir = dirOf(dirName);
const allNames = Object.keys(dir.workflows || {});
const matches = (n) => !q || n.toLowerCase().includes(q) || dirName.toLowerCase().includes(q);
const active = [];
const archived = [];
for (const n of allNames) {
if (!matches(n)) continue;
if (dir.workflows[n] && dir.workflows[n].archived) archived.push(n);
else active.push(n);
}
active.sort(compareNames);
archived.sort(compareNames);
if (active.length === 0 && archived.length === 0) continue;
out.push({ name: dirName, active, archived });
total += active.length + archived.length;
}
return { directories: out, total };
}
// =============================================================================
// DOM helpers
// =============================================================================
function makeFolderRow({ name, count, iconKind, onToggle, onContextMenu }) {
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);
return { row, chevron };
}
function makeNodeLeafRow({ display, type, removable, onClick }) {
const row = document.createElement("div");
row.className = "koolook-row koolook-leaf";
row.title = 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);
const nameEl = document.createElement("span");
nameEl.className = "koolook-name";
nameEl.textContent = display;
row.appendChild(nameEl);
if (removable) {
const rm = document.createElement("span");
rm.className = "koolook-remove";
rm.textContent = "×";
rm.title = "Remove from favorites";
rm.addEventListener("click", (e) => {
e.stopPropagation();
removeFromMyPicks(type);
notifyPicksChanged();
});
row.appendChild(rm);
}
row.addEventListener("click", onClick);
return row;
}
function makeWorkflowLeafRow({ name, dirName, onClick, onContextMenu }) {
const row = document.createElement("div");
row.className = "koolook-row koolook-leaf";
row.title = `${dirName} / ${name}`;
const chevron = document.createElement("span");
chevron.className = "koolook-chevron";
row.appendChild(chevron);
const icon = document.createElement("span");
icon.className = "pi pi-file koolook-leaf-icon";
row.appendChild(icon);
const nameEl = document.createElement("span");
nameEl.className = "koolook-name";
nameEl.textContent = name;
row.appendChild(nameEl);
row.addEventListener("click", onClick);
if (onContextMenu) row.addEventListener("contextmenu", onContextMenu);
return row;
}
function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, startExpanded = true, path = null, forceExpanded = false }) {
// Resolution order:
// 1. forceExpanded (e.g. search active) — overrides everything
// 2. pathStates (user has previously toggled this folder)
// 3. startExpanded (the natural default for this folder)
let initiallyExpanded;
if (forceExpanded) {
initiallyExpanded = true;
} else if (path && pathStates.has(path)) {
initiallyExpanded = pathStates.get(path);
} else {
initiallyExpanded = startExpanded;
}
const wrapper = document.createElement("div");
wrapper.dataset.expanded = initiallyExpanded ? "true" : "false";
const children = document.createElement("div");
children.className = "koolook-children";
if (!initiallyExpanded) children.style.display = "none";
const { row, chevron } = makeFolderRow({
name,
count,
iconKind,
onContextMenu,
onToggle: () => {
const expanded = wrapper.dataset.expanded !== "false";
const next = !expanded;
wrapper.dataset.expanded = next ? "true" : "false";
chevron.textContent = next ? "▾" : "▸";
children.style.display = next ? "" : "none";
if (path) pathStates.set(path, next);
},
});
if (!initiallyExpanded) chevron.textContent = "▸";
wrapper.appendChild(row);
wrapper.appendChild(children);
childrenBuilder(children);
return wrapper;
}
// =============================================================================
// Context-menu wiring for workflow rows
// =============================================================================
function workflowRowContextMenu(event, dirName, wfName, isArchived = false) {
const dirNames = listDirectoryNames();
const moveItems = dirNames
.filter(d => d !== dirName)
.map(d => ({
label: `→ ${d}`,
action: () => persistMutation({
mutate: () => moveWorkflow(dirName, wfName, d),
onSuccess: () => toast(`Moved "${wfName}" to ${d}.`),
onNoOp: () => toast(`Could not move (name conflict?).`),
}),
}));
const archiveItem = isArchived
? {
label: "Restore from archive",
action: () => persistMutation({
mutate: () => unarchiveWorkflow(dirName, wfName),
onSuccess: () => toast(`Restored "${wfName}".`),
}),
}
: {
label: "Move to archive",
action: () => persistMutation({
mutate: () => archiveWorkflow(dirName, wfName),
onSuccess: () => toast(`Archived "${wfName}".`),
}),
};
showContextMenu(event, [
{
label: "Load",
action: () => loadWorkflowOntoCanvas(dirName, wfName),
},
{
label: "Rename…",
action: () => {
showInputModal({
title: "Rename workflow",
label: "New name",
defaultValue: wfName,
confirmLabel: "Rename",
onSubmit: (newName) => persistMutation({
mutate: () => renameWorkflow(dirName, wfName, newName),
onSuccess: () => toast(`Renamed to "${newName}".`),
onNoOp: () => toast(`Rename failed (name in use?).`),
}),
});
},
},
archiveItem,
...(moveItems.length > 0 ? [null, ...moveItems] : []),
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(dirName, wfName),
onSuccess: () => toast(`Deleted "${wfName}".`),
}),
});
},
},
]);
}
function directoryRowContextMenu(event, dirName) {
const dir = dirOf(dirName);
const isEmpty = !dir || Object.keys(dir.workflows || {}).length === 0;
showContextMenu(event, [
{
label: "Rename directory…",
action: () => {
showInputModal({
title: "Rename directory",
label: "New name",
defaultValue: dirName,
confirmLabel: "Rename",
onSubmit: (newName) => persistMutation({
mutate: () => renameDirectory(dirName, newName),
onSuccess: () => toast(`Renamed to "${newName}".`),
onNoOp: () => toast(`Rename failed (name in use?).`),
}),
});
},
},
null,
{
label: "Delete directory",
danger: true,
action: () => {
if (isEmpty) {
showConfirmModal({
title: "Delete empty directory?",
message: `"${dirName}" will be removed.`,
confirmLabel: "Delete",
danger: true,
onConfirm: () => persistMutation({
mutate: () => deleteDirectory(dirName),
onSuccess: () => toast(`Deleted directory "${dirName}".`),
}),
});
} else {
const count = Object.keys(dir.workflows).length;
showConfirmModal({
title: "Delete non-empty directory?",
message: `"${dirName}" contains ${count} workflow${count === 1 ? "" : "s"}. They will be permanently deleted.`,
confirmLabel: "Delete all",
danger: true,
onConfirm: () => persistMutation({
mutate: () => deleteDirectory(dirName),
onSuccess: () => toast(`Deleted "${dirName}" and its ${count} workflow${count === 1 ? "" : "s"}.`),
}),
});
}
},
},
]);
}
// =============================================================================
// Built-in section descriptors. Order here = render order in the panel.
// =============================================================================
addSection({
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 Curated Sidebar.",
gather(q) {
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 { packs, total };
},
build(ctx) {
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: !!pack.isUserPicks,
onClick: () => insertNode(n.type),
}),
});
}
},
});
}
},
});
}
},
});
addSection({
id: "workflows",
label: WORKFLOWS_GROUP_LABEL,
iconKind: "workflows",
// No emptyMessage — when the workflow store is empty and no search is
// active, we silently hide the section. The toolbar above still lets the
// user create a directory or save a workflow.
gather(q) {
return gatherWorkflows(q);
},
build(ctx) {
for (const dir of ctx.data.directories) {
const totalInDir = dir.active.length + dir.archived.length;
ctx.folder({
name: dir.name,
count: totalInDir,
path: dir.name,
onContextMenu: (e) => directoryRowContextMenu(e, dir.name),
build: (dirCtx) => {
// Archive first, then active — so the latest (active)
// workflow is closest to the bottom of the directory.
if (dir.archived.length > 0) {
dirCtx.folder({
name: "Archive",
count: dir.archived.length,
iconKind: "archive",
path: "Archive",
build: (archCtx) => {
for (const wfName of dir.archived) {
archCtx.leaf({
row: makeWorkflowLeafRow({
name: wfName,
dirName: dir.name,
onClick: () => loadWorkflowOntoCanvas(dir.name, wfName),
onContextMenu: (e) => workflowRowContextMenu(e, dir.name, wfName, true),
}),
});
}
},
});
}
for (const wfName of dir.active) {
dirCtx.leaf({
row: makeWorkflowLeafRow({
name: wfName,
dirName: dir.name,
onClick: () => loadWorkflowOntoCanvas(dir.name, wfName),
onContextMenu: (e) => workflowRowContextMenu(e, dir.name, wfName, false),
}),
});
}
},
});
}
},
});
// =============================================================================
// Panel rendering
// =============================================================================
export function renderPanel(container) {
ensureStyle();
container.innerHTML = "";
container.classList.add("koolook-sidebar");
// ---- 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);
const 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);
// ---- 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);
const addBtn = document.createElement("button");
addBtn.className = "koolook-add-btn";
addBtn.textContent = "+";
addBtn.title = "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;
for (const t of types) {
const status = addToMyPicks(t);
if (status === "added") added += 1;
else if (status === "duplicate") duplicates += 1;
else failed += 1;
}
if (added > 0) {
const noun = added === 1 ? "node" : "nodes";
toast(`Added ${added} ${noun} to favorites.`);
notifyPicksChanged();
}
if (failed > 0) {
toast(`Failed to save ${failed} pick${failed === 1 ? "" : "s"}. See console.`);
} else if (added === 0 && duplicates > 0) {
toast("Already in favorites.");
}
});
nodesRow.appendChild(addBtn);
const exportBtn = document.createElement("button");
exportBtn.className = "koolook-add-btn koolook-export-btn";
exportBtn.innerHTML = '<span class="pi pi-download"></span>';
exportBtn.title = "Export current picks as curated_defaults.json (copies JSON to clipboard)";
exportBtn.addEventListener("click", exportPicks);
nodesRow.appendChild(exportBtn);
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);
const newDirBtn = document.createElement("button");
newDirBtn.className = "koolook-add-btn koolook-icon-btn";
newDirBtn.innerHTML = '<span class="pi pi-folder-open"></span>';
newDirBtn.title = "Create new workflow directory";
newDirBtn.addEventListener("click", () => {
showInputModal({
title: "Create directory",
label: "Directory name",
placeholder: "e.g. Inpainting",
confirmLabel: "Create",
onSubmit: (name) => persistMutation({
mutate: () => addDirectory(name),
onSuccess: () => toast(`Directory "${name}" created.`),
onNoOp: () => toast(`Directory "${name}" already exists.`),
}),
});
});
wfRow.appendChild(newDirBtn);
// 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.
const saveAndToast = async (graph, name, dir) => {
// Pin the workflows section + destination dir so the resulting
// re-render leaves them open. Pins are consumed on next render.
pinExpanded(["workflows", `workflows/${dir}`]);
await persistMutation({
mutate: () => saveWorkflowEntry(dir, name, graph),
onSuccess: (result) => {
if (result.archivedAs) {
toast(`Saved "${name}" in ${dir}. Previous version moved to Archive.`);
} else {
toast(`Saved "${name}" in ${dir}.`);
}
},
persistFailedMessage: `Save failed — could not write "${name}". See console.`,
});
};
const saveCanvasBtn = document.createElement("button");
saveCanvasBtn.className = "koolook-add-btn koolook-icon-btn";
saveCanvasBtn.innerHTML = '<span class="pi pi-save"></span>';
saveCanvasBtn.title = "Save entire canvas as a workflow";
saveCanvasBtn.addEventListener("click", () => {
// 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",
onSave: ({ name, dir }) => saveAndToast(graph, name, dir),
});
});
wfRow.appendChild(saveCanvasBtn);
const saveSelectionBtn = document.createElement("button");
saveSelectionBtn.className = "koolook-add-btn koolook-icon-btn";
saveSelectionBtn.innerHTML = '<span class="pi pi-objects-column"></span>';
saveSelectionBtn.title = "Save current selection as a workflow";
saveSelectionBtn.addEventListener("click", () => {
const graph = serializeSelection();
if (!graph) {
toast("Select one or more nodes on the canvas first.");
return;
}
showSaveWorkflowModal({
titleSuffix: `${graph.nodes.length} selected node${graph.nodes.length === 1 ? "" : "s"}`,
onSave: ({ name, dir }) => saveAndToast(graph, name, dir),
});
});
wfRow.appendChild(saveSelectionBtn);
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) ----
const tree = document.createElement("div");
tree.className = "koolook-tree";
container.appendChild(tree);
renderTree({ treeEl: tree, query: "" });
// ---- Search wiring ----
let debounce = null;
search.addEventListener("input", (e) => {
const q = e.target.value;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => renderTree({ treeEl: tree, query: q }), 60);
});
// ---- Event subscriptions for live re-rendering ----
window.addEventListener(PICKS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }));
window.addEventListener(WORKFLOWS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }));
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY || e.key === WORKFLOWS_FALLBACK_KEY) {
renderTree({ treeEl: tree, query: search.value });
}
});
}