refactor(web): split koolook_sidebar.js into 7 sidebar modules (#51)

* refactor(web): split koolook_sidebar.js into 7 sidebar modules

Pure mechanical split — zero behavior change. Implements the design from
issue #49 (common-case layout with patchCanvasMenu extracted).

Old: web/koolook_sidebar.js (1973 lines, monolithic)
New: web/koolook_sidebar.js (48-line entry) + 7 modules in web/sidebar/

  - constants.js (130) — REPOS, TAB_*, storage keys, event names, URLs,
    ensureStyle, toast, compareNames
  - picks_store.js (117) — localStorage user picks: load/save/add (tri-state)
    /remove/notify/seed/export
  - workflows_store.js (347) — workflowsCache + persistMutation + snapshotCache
    + commit + persist*/load*/seed* + in-memory ops; cache is module-private
  - canvas_io.js (192) — serialize*/canvasIsNonEmpty/loadWorkflowOntoCanvas
    /insertNode/getSelectedNodeTypes
  - modals.js (384) — makeModalShell + showInputModal/showConfirmModal
    /showSaveWorkflowModal/showContextMenu
  - canvas_menu.js (45) — patchCanvasMenu (idempotent LGraphCanvas patch)
  - tree.js (825) — pathStates (module-private) + gatherers + DOM row
    factories + buildFolder + workflow/dir context menus + rebuildTree
    + renderPanel

Encapsulation invariants verified:
  - workflowsCache: zero references outside workflows_store.js
  - pathStates: zero references outside tree.js
  - All 8 files pass node --check
  - CSS string byte-identical to original
  - URL constants resolve to same targets after the extra `..` adjustment

Documentation updated: dev-iteration-loop.md and workflows-sidebar.md
now point pathStates references at web/sidebar/tree.js. Other doc
references to koolook_sidebar.js as the entry remain accurate.

Closes #49.

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

* refactor(web): drop unreachable defensive guard in gatherWorkflows

Review feedback on #51: the `if (!dir) continue;` after `dirOf(dirName)`
is unreachable — `listDirectoryNames()` enumerates the same module-private
`workflowsCache.directories` keys that `dirOf()` reads, synchronously,
within a single event-loop turn. Removing the guard restores strict
"byte-identical move" semantics for the file-split refactor.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-04 17:47:20 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent cc6bc47866
commit e03523faf9
10 changed files with 2063 additions and 1949 deletions
+3 -3
View File
@@ -6,7 +6,7 @@ round-trip.
## The loop
1. Edit `web/koolook_sidebar.js` (or related JSON) in the worktree.
1. Edit `web/koolook_sidebar.js` or any module under `web/sidebar/` (or related JSON) in the worktree.
2. `python scripts/sync_to_dev.py` — copies runtime files into your live
ComfyUI `custom_nodes/<koolook>/`. Reads `KOLOOK_COMFYUI_DEV_PATH` from `.env`.
3. Restart ComfyUI; verify visually.
@@ -52,11 +52,11 @@ deleting `/userdata/koolook_workflows.json`, or wiping browser data does.
- **Defensive fallbacks** when calling a newer ComfyUI API surface —
multiple property paths inside try/catch so older frontends still work.
- **Persist folder expansion state across re-renders** so saving doesn't
collapse the user's view (the `pathStates` Map in `koolook_sidebar.js`).
collapse the user's view (the `pathStates` Map in `web/sidebar/tree.js`).
## Files this loop touches most
- [`web/koolook_sidebar.js`](../../web/koolook_sidebar.js) — sidebar implementation
- [`web/koolook_sidebar.js`](../../web/koolook_sidebar.js) — sidebar entry (boots the extension; module bodies live in [`web/sidebar/`](../../web/sidebar/))
- [`web/curated_defaults.json`](../../web/curated_defaults.json) — node defaults
- [`web/workflow_defaults.json`](../../web/workflow_defaults.json) — workflow defaults
- [`scripts/sync_to_dev.py`](../../scripts/sync_to_dev.py) — dev sync helper
+1 -1
View File
@@ -77,7 +77,7 @@ location.reload();
- **Selection saves are partial graphs.** Only the selected nodes + links between them survive; links into/out of non-selected nodes are nulled out, so the loaded workflow never has dangling references.
- **Workflow tab name comes from `app.loadGraphData(graph, true, true, name, {})`'s 4th arg.** The tab flips from "Unsaved Workflow (N)" to the saved name, and Ctrl+S pre-fills with that name. (Verified against `Comfy-Org/ComfyUI_frontend` `src/scripts/app.ts`.)
- **Folder expansion state persists across re-renders** (the `pathStates` Map in `koolook_sidebar.js`). Saving never collapses the directory you were viewing.
- **Folder expansion state persists across re-renders** (the `pathStates` Map in `web/sidebar/tree.js`). Saving never collapses the directory you were viewing.
- **Archive sub-folder is rendered *above* active workflows** in each directory, so the latest active workflow sits closest to the bottom — easy to spot.
- **No auto-pruning of archives.** Re-saving the same name many times leaves many timestamped archive entries. Right-click → Delete to remove individual ones when no longer useful.
- **Same-pattern distribution as `curated_defaults.json`** — see [`curated-sidebar.md`](curated-sidebar.md) for the seeding semantics that also apply here.
+20 -1945
View File
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
// =============================================================================
// Canvas IO — anything that touches `app.graph` or `app.canvas` directly.
// Selection serialization prunes cross-boundary links so a saved selection
// reloads cleanly. `loadWorkflowOntoCanvas` snapshots the prior canvas and
// restores it if the load throws partway, so a corrupt workflow can't strand
// the user with a half-loaded graph.
// =============================================================================
import { app } from "../../../../scripts/app.js";
import { toast } from "./constants.js";
import { getWorkflowGraph } from "./workflows_store.js";
import { showConfirmModal } from "./modals.js";
export function serializeFullCanvas() {
try {
return app.graph.serialize();
} catch (e) {
console.warn("[Koolook] graph.serialize() failed:", e);
return null;
}
}
function getSelectedNodeIds() {
try {
const sel = (app.canvas && app.canvas.selected_nodes) || {};
return new Set(
Object.values(sel)
.filter(n => n && n.id != null)
.map(n => n.id)
);
} catch (e) {
return new Set();
}
}
export function serializeSelection() {
const selectedIds = getSelectedNodeIds();
if (selectedIds.size === 0) return null;
const full = serializeFullCanvas();
if (!full) return null;
const internalLinks = (full.links || []).filter(link =>
Array.isArray(link) && selectedIds.has(link[1]) && selectedIds.has(link[3])
);
const internalLinkIds = new Set(internalLinks.map(l => l[0]));
const nodes = (full.nodes || [])
.filter(n => selectedIds.has(n.id))
.map(n => {
const clone = JSON.parse(JSON.stringify(n));
if (Array.isArray(clone.inputs)) {
for (const inp of clone.inputs) {
if (inp && inp.link != null && !internalLinkIds.has(inp.link)) {
inp.link = null;
}
}
}
if (Array.isArray(clone.outputs)) {
for (const out of clone.outputs) {
if (out && Array.isArray(out.links)) {
out.links = out.links.filter(l => internalLinkIds.has(l));
if (out.links.length === 0) out.links = null;
}
}
}
return clone;
});
if (nodes.length === 0) return null;
return {
last_node_id: full.last_node_id,
last_link_id: full.last_link_id,
nodes,
links: internalLinks,
groups: [],
config: full.config || {},
extra: full.extra || {},
version: full.version || 0.4,
};
}
export function canvasIsNonEmpty() {
try {
return app.graph && app.graph._nodes && app.graph._nodes.length > 0;
} catch (e) {
return false;
}
}
export async function loadWorkflowOntoCanvas(dirName, wfName) {
const graph = getWorkflowGraph(dirName, wfName);
if (!graph) {
toast(`Workflow not found: ${wfName}`);
return;
}
const apply = async () => {
// Snapshot the current canvas before loading. If loadGraphData throws
// partway (missing node type, malformed payload), we restore the
// user's prior canvas instead of leaving them with neither workflow.
let previousGraph = null;
try {
if (canvasIsNonEmpty()) previousGraph = app.graph.serialize();
} catch (e) {
previousGraph = null;
}
try {
// Passing `wfName` as the 4th arg makes loadGraphData create a
// temporary workflow tab titled with that name (instead of
// "Unsaved Workflow (N)"). Frontend reference: ComfyUI_frontend
// src/scripts/app.ts (loadGraphData) →
// workflowService.afterLoadNewGraph → createNewTemporary, which
// builds path `workflows/<wfName>.json` and binds it to the tab.
await app.loadGraphData(graph, true, true, wfName, {});
// Defensive fallback for frontends that didn't honor the 4th arg.
try {
const wf = app.extensionManager?.workflow?.activeWorkflow;
if (wf && wf.isTemporary && typeof wf.rename === "function" && wf.filename !== wfName) {
await wf.rename(`workflows/${wfName}.json`);
}
} catch (e) {
console.warn("[Koolook] workflow rename fallback failed:", e);
}
toast(`Loaded "${wfName}".`);
} catch (e) {
console.error("[Koolook] loadGraphData failed:", e);
if (previousGraph) {
try {
await app.loadGraphData(previousGraph);
toast(`Failed to load "${wfName}". Canvas restored. See console.`);
return;
} catch (restoreErr) {
console.error("[Koolook] canvas restore failed:", restoreErr);
toast(`Failed to load "${wfName}". Canvas may be partial. See console.`);
return;
}
}
toast(`Failed to load "${wfName}". See console.`);
}
};
if (canvasIsNonEmpty()) {
showConfirmModal({
title: "Replace current workflow?",
message: `Loading "${wfName}" will replace what's currently on the canvas. This cannot be undone with Ctrl+Z in some cases.`,
confirmLabel: "Load anyway",
danger: true,
onConfirm: apply,
});
} else {
apply();
}
}
// =============================================================================
// Node insertion
// =============================================================================
function placeAtCanvasCenter(node) {
try {
const canvas = app.canvas;
const ds = canvas.ds;
const cx = -ds.offset[0] + canvas.canvas.width / (2 * ds.scale);
const cy = -ds.offset[1] + canvas.canvas.height / (2 * ds.scale);
node.pos = [cx - node.size[0] / 2, cy - node.size[1] / 2];
} catch (e) {
// Default position is fine if the canvas isn't ready yet.
}
}
export function insertNode(typeName) {
if (typeof LiteGraph === "undefined") return;
const node = LiteGraph.createNode(typeName);
if (!node) {
console.warn(`[Koolook] could not create node: ${typeName}`);
return;
}
app.graph.add(node);
placeAtCanvasCenter(node);
app.canvas.setDirty(true, true);
}
export function getSelectedNodeTypes() {
try {
const sel = (app.canvas && app.canvas.selected_nodes) || {};
return Object.values(sel)
.filter(n => n && n.type)
.map(n => n.type);
} catch (e) {
return [];
}
}
+45
View File
@@ -0,0 +1,45 @@
// =============================================================================
// Right-click context menu on canvas nodes — adds to favorites.
//
// Patches `LGraphCanvas.prototype.getNodeMenuOptions` (wrap-not-replace) so the
// "Add to Curated Sidebar" item appears on every node's right-click menu —
// including nodes from other packs. The patch is idempotent so a soft refresh
// doesn't stack duplicate entries.
// =============================================================================
import { toast } from "./constants.js";
import { addToMyPicks, notifyPicksChanged } from "./picks_store.js";
export function patchCanvasMenu() {
const C = (typeof LGraphCanvas !== "undefined") ? LGraphCanvas : null;
if (!C || !C.prototype || !C.prototype.getNodeMenuOptions) {
console.warn("[Koolook] LGraphCanvas.getNodeMenuOptions not available; right-click menu skipped.");
return;
}
// Idempotency: this is a global prototype mutation that affects every
// pack's right-click menu. If the extension re-loads (HMR, soft refresh,
// multiple registerExtension calls) we'd otherwise stack duplicate
// "Add to Curated Sidebar" entries on every node.
if (C.prototype.__koolookCuratedPatched) return;
C.prototype.__koolookCuratedPatched = true;
const orig = C.prototype.getNodeMenuOptions;
C.prototype.getNodeMenuOptions = function (node) {
const options = orig.apply(this, arguments);
options.push(null);
options.push({
content: "Add to Curated Sidebar",
callback: () => {
if (!node || !node.type) return;
const status = addToMyPicks(node.type);
if (status === "added") {
toast(`Added "${node.title || node.type}" to favorites.`);
notifyPicksChanged();
} else if (status === "duplicate") {
toast("Already in favorites.");
} else {
toast(`Failed to save "${node.title || node.type}". See console.`);
}
},
});
return options;
};
}
+130
View File
@@ -0,0 +1,130 @@
// =============================================================================
// Configuration — static "always shown" entries.
//
// You normally don't need to edit this. To grow the panel from inside
// ComfyUI, use either:
// • the "+" button at the top of the sidebar (with a canvas node selected), or
// • right-click a canvas node → "Add to Curated Sidebar".
//
// Those additions are stored in the browser's localStorage and surface as
// the "My Picks" group at the top of the tree.
// =============================================================================
export const REPOS = [
{
label: "Koolook",
categoryRoot: "Koolook",
select: "all",
excludePatterns: [/\bdeprecated\b/i],
},
];
export const TAB_ID = "koolook.curatedNodes";
export const TAB_TITLE = "Curated Nodes";
export const TAB_TOOLTIP = "Curated selection of ComfyUI nodes";
export const TAB_ICON = "pi pi-star";
export const ROOT_GROUP_LABEL = "Nodes (favorites)";
export const WORKFLOWS_GROUP_LABEL = "Workflows";
// Defaults JSON files live in web/ alongside the entry; this module is one
// level deeper at web/sidebar/, so the relative URL needs the extra `..`.
export const STORAGE_KEY = "koolook.curated.userPicks.v1";
export const SEEDED_KEY = "koolook.curated.seeded.v1";
export const PICKS_CHANGED_EVENT = "koolook-picks-changed";
export const DEFAULTS_URL = new URL("../curated_defaults.json", import.meta.url).href;
export const WORKFLOWS_USERDATA_PATH = "koolook_workflows.json";
export const WORKFLOWS_FALLBACK_KEY = "koolook.workflows.fallback.v1";
export const WORKFLOWS_SEEDED_KEY = "koolook.workflows.seeded.v1";
export const WORKFLOWS_CHANGED_EVENT = "koolook-workflows-changed";
export const WORKFLOWS_DEFAULTS_URL = new URL("../workflow_defaults.json", import.meta.url).href;
// =============================================================================
// Styles
// =============================================================================
const STYLE_ID = "koolook-sidebar-style";
export function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const s = document.createElement("style");
s.id = STYLE_ID;
s.textContent = `
.koolook-sidebar { display: flex; flex-direction: column; height: 100%; font-size: 13px; user-select: none; }
.koolook-search-row { margin: 6px; flex-shrink: 0; }
.koolook-actions-row { display: flex; align-items: center; gap: 4px; padding: 2px 6px; flex-shrink: 0; }
.koolook-actions-label { font-size: 10px; opacity: 0.55; text-transform: uppercase; letter-spacing: 0.08em; flex: 1; font-weight: 600; }
.koolook-tree-divider { margin: 8px 8px; border-top: 1px solid rgba(255,255,255,0.08); flex-shrink: 0; }
.koolook-search-wrap { position: relative; width: 100%; }
.koolook-search-icon { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); opacity: 0.55; font-size: 11px; pointer-events: none; }
.koolook-search { width: 100%; padding: 5px 8px 5px 26px; box-sizing: border-box; background: var(--comfy-input-bg, rgba(0,0,0,0.25)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; color: var(--input-text, inherit); font-size: 12px; outline: none; }
.koolook-search:focus { border-color: var(--p-primary-color, rgba(100,150,255,0.5)); }
.koolook-add-btn { padding: 0 12px; background: var(--comfy-input-bg, rgba(0,0,0,0.25)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; cursor: pointer; color: var(--input-text, inherit); font-size: 16px; line-height: 1; flex-shrink: 0; }
.koolook-add-btn:hover { background: rgba(255,255,255,0.1); }
.koolook-add-btn:active { background: rgba(255,255,255,0.15); }
.koolook-export-btn { padding: 0 10px; font-size: 12px; }
.koolook-icon-btn { padding: 0 10px; font-size: 12px; }
.koolook-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.koolook-tree { flex: 1; overflow-y: auto; padding: 0 4px 8px; }
.koolook-row { display: flex; align-items: center; padding: 3px 6px; cursor: pointer; gap: 6px; border-radius: 3px; line-height: 1.3; }
.koolook-row:hover { background: var(--comfy-input-bg, rgba(255,255,255,0.06)); }
.koolook-chevron { width: 10px; display: inline-block; opacity: 0.7; text-align: center; font-size: 10px; flex-shrink: 0; }
.koolook-folder-icon { opacity: 0.85; flex-shrink: 0; }
.koolook-pin-icon { color: #ffb84d; opacity: 0.95; flex-shrink: 0; }
.koolook-workflows-icon { color: #6db4ff; opacity: 0.95; flex-shrink: 0; }
.koolook-archive-icon { opacity: 0.55; flex-shrink: 0; }
.koolook-leaf-icon { opacity: 0.7; flex-shrink: 0; font-size: 11px; }
.koolook-leaf-dot { width: 6px; height: 6px; margin: 0 2px; border-radius: 50%; background: rgba(255,255,255,0.45); flex-shrink: 0; }
.koolook-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.koolook-count { background: rgba(255,255,255,0.08); padding: 1px 7px; border-radius: 8px; font-size: 11px; opacity: 0.75; }
.koolook-children { padding-left: 14px; }
.koolook-empty { padding: 14px 8px; opacity: 0.65; font-size: 12px; line-height: 1.4; }
.koolook-leaf .koolook-chevron { visibility: hidden; }
.koolook-remove { padding: 0 4px; margin-left: 2px; opacity: 0; font-size: 14px; line-height: 1; cursor: pointer; flex-shrink: 0; }
.koolook-row:hover .koolook-remove { opacity: 0.5; }
.koolook-remove:hover { opacity: 1 !important; color: #ff7777; }
.koolook-section-divider { border-top: 1px solid var(--border-color, rgba(255,255,255,0.08)); margin: 8px 4px 0; }
.koolook-toast { position: fixed; bottom: 30px; right: 30px; background: rgba(40,40,40,0.95); color: #fff; padding: 8px 14px; border-radius: 4px; font-size: 12px; z-index: 9999; transition: opacity 0.3s; box-shadow: 0 2px 8px rgba(0,0,0,0.4); pointer-events: none; max-width: 360px; }
.koolook-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 9998; display: flex; align-items: center; justify-content: center; }
.koolook-modal { background: var(--comfy-menu-bg, #2a2a2a); border: 1px solid var(--border-color, rgba(255,255,255,0.15)); border-radius: 6px; padding: 16px 18px; min-width: 320px; max-width: 440px; box-shadow: 0 6px 24px rgba(0,0,0,0.55); color: var(--input-text, inherit); }
.koolook-modal-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
.koolook-modal-message { font-size: 12px; opacity: 0.85; margin-bottom: 14px; line-height: 1.45; }
.koolook-modal-label { font-size: 11px; opacity: 0.7; margin: 6px 0 4px; display: block; text-transform: uppercase; letter-spacing: 0.04em; }
.koolook-modal-input, .koolook-modal-select { width: 100%; padding: 6px 8px; background: var(--comfy-input-bg, rgba(0,0,0,0.3)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; color: inherit; font-size: 13px; box-sizing: border-box; outline: none; }
.koolook-modal-input:focus, .koolook-modal-select:focus { border-color: var(--p-primary-color, rgba(100,150,255,0.5)); }
.koolook-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.koolook-modal-btn { padding: 6px 14px; background: var(--comfy-input-bg, rgba(0,0,0,0.3)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; color: inherit; cursor: pointer; font-size: 12px; }
.koolook-modal-btn:hover { background: rgba(255,255,255,0.1); }
.koolook-modal-btn-primary { background: rgba(80,140,235,0.35); border-color: rgba(80,140,235,0.55); }
.koolook-modal-btn-primary:hover { background: rgba(80,140,235,0.5); }
.koolook-modal-btn-danger { background: rgba(220,80,80,0.25); border-color: rgba(220,80,80,0.5); }
.koolook-modal-btn-danger:hover { background: rgba(220,80,80,0.4); }
.koolook-context-menu { position: fixed; background: var(--comfy-menu-bg, #2a2a2a); border: 1px solid var(--border-color, rgba(255,255,255,0.15)); border-radius: 4px; padding: 4px 0; min-width: 160px; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 12px; }
.koolook-context-item { padding: 5px 12px; cursor: pointer; }
.koolook-context-item:hover { background: rgba(255,255,255,0.08); }
.koolook-context-danger { color: #ff8888; }
.koolook-context-sep { height: 1px; background: rgba(255,255,255,0.1); margin: 4px 0; }
.koolook-context-submenu-arrow { float: right; opacity: 0.5; }
`;
document.head.appendChild(s);
}
// =============================================================================
// Toast helper
// =============================================================================
export function toast(msg, duration = 2200) {
const t = document.createElement("div");
t.className = "koolook-toast";
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => {
t.style.opacity = "0";
setTimeout(() => t.remove(), 300);
}, duration);
}
// =============================================================================
// Sort helper — human-alphabetical (case-insensitive). Use everywhere we sort
// names, labels, or directory entries so the order in the sidebar matches what
// a user would expect from an A→Z list.
// =============================================================================
export function compareNames(a, b) {
return String(a).localeCompare(String(b), undefined, { sensitivity: "base" });
}
+384
View File
@@ -0,0 +1,384 @@
// =============================================================================
// Modal + context-menu DOM. The shell takes care of the Escape-key / overlay-
// click / Cancel / OK dismissal paths and ties the document-level keydown
// listener to the overlay's lifetime so opening many modals doesn't leak.
// =============================================================================
import { compareNames } from "./constants.js";
import { listDirectoryNames, dirOf } from "./workflows_store.js";
function makeModalShell({ title, body, actions }) {
const overlay = document.createElement("div");
overlay.className = "koolook-modal-overlay";
const modal = document.createElement("div");
modal.className = "koolook-modal";
const titleEl = document.createElement("div");
titleEl.className = "koolook-modal-title";
titleEl.textContent = title;
modal.appendChild(titleEl);
if (body) modal.appendChild(body);
const actionsEl = document.createElement("div");
actionsEl.className = "koolook-modal-actions";
for (const action of actions) actionsEl.appendChild(action);
modal.appendChild(actionsEl);
// Centralized close so every dismissal path (Escape, overlay click, Cancel
// button, OK button calling overlay.remove()) tears down the document-level
// keydown listener too. Without this, every modal opened leaks one listener.
let escHandler = null;
const close = () => {
if (escHandler) {
document.removeEventListener("keydown", escHandler);
escHandler = null;
}
if (overlay.parentNode) overlay.remove();
};
// Make overlay.remove() route through close so existing call sites that
// do `overlay.remove()` continue to work (and clean up the listener too).
const origRemove = overlay.remove.bind(overlay);
overlay.remove = () => {
if (escHandler) {
document.removeEventListener("keydown", escHandler);
escHandler = null;
}
origRemove();
};
overlay.appendChild(modal);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
escHandler = (e) => { if (e.key === "Escape") close(); };
document.addEventListener("keydown", escHandler);
document.body.appendChild(overlay);
return { overlay, modal, close };
}
function makeModalButton({ label, primary, danger, onClick }) {
const btn = document.createElement("button");
btn.className = "koolook-modal-btn";
if (primary) btn.classList.add("koolook-modal-btn-primary");
if (danger) btn.classList.add("koolook-modal-btn-danger");
btn.textContent = label;
btn.addEventListener("click", onClick);
return btn;
}
export function showInputModal({ title, label, defaultValue, placeholder, confirmLabel, onSubmit }) {
const body = document.createElement("div");
const lbl = document.createElement("label");
lbl.className = "koolook-modal-label";
lbl.textContent = label || "Name";
body.appendChild(lbl);
const input = document.createElement("input");
input.className = "koolook-modal-input";
input.value = defaultValue || "";
input.placeholder = placeholder || "";
body.appendChild(input);
let overlay;
const submit = () => {
const v = input.value.trim();
if (!v) {
input.focus();
return;
}
overlay.remove();
onSubmit(v);
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit();
});
const cancel = makeModalButton({ label: "Cancel", onClick: () => overlay.remove() });
const ok = makeModalButton({ label: confirmLabel || "OK", primary: true, onClick: submit });
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
setTimeout(() => { input.focus(); input.select(); }, 0);
}
export function showConfirmModal({ title, message, confirmLabel, cancelLabel, danger, onConfirm }) {
const body = document.createElement("div");
const msg = document.createElement("div");
msg.className = "koolook-modal-message";
msg.textContent = message;
body.appendChild(msg);
let overlay;
const cancel = makeModalButton({ label: cancelLabel || "Cancel", onClick: () => overlay.remove() });
const ok = makeModalButton({
label: confirmLabel || "OK",
primary: !danger,
danger,
onClick: () => { overlay.remove(); onConfirm(); },
});
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
}
export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, onSave }) {
const body = document.createElement("div");
// ---- Directory ----
const dirLbl = document.createElement("label");
dirLbl.className = "koolook-modal-label";
dirLbl.textContent = "Directory";
body.appendChild(dirLbl);
const dirNames = listDirectoryNames();
const dirSelect = document.createElement("select");
dirSelect.className = "koolook-modal-select";
if (dirNames.length === 0) {
const opt = document.createElement("option");
opt.value = "__new__";
opt.textContent = "+ New directory…";
dirSelect.appendChild(opt);
} else {
for (const d of dirNames) {
const opt = document.createElement("option");
opt.value = d;
opt.textContent = d;
if (d === defaultDir) opt.selected = true;
dirSelect.appendChild(opt);
}
const newOpt = document.createElement("option");
newOpt.value = "__new__";
newOpt.textContent = "+ New directory…";
dirSelect.appendChild(newOpt);
}
body.appendChild(dirSelect);
const newDirInput = document.createElement("input");
newDirInput.className = "koolook-modal-input";
newDirInput.placeholder = "New directory name";
newDirInput.style.marginTop = "6px";
newDirInput.style.display = dirNames.length === 0 ? "" : "none";
body.appendChild(newDirInput);
// ---- Base on existing (only shown when the chosen directory has workflows) ----
const baseLbl = document.createElement("label");
baseLbl.className = "koolook-modal-label";
baseLbl.textContent = "Base on existing";
body.appendChild(baseLbl);
const baseSelect = document.createElement("select");
baseSelect.className = "koolook-modal-select";
body.appendChild(baseSelect);
// ---- Action ----
const actionLbl = document.createElement("label");
actionLbl.className = "koolook-modal-label";
actionLbl.textContent = "Action";
body.appendChild(actionLbl);
const actionSelect = document.createElement("select");
actionSelect.className = "koolook-modal-select";
[
{ value: "new", label: "New name" },
{ value: "use_existing", label: "Use existing name (archive previous)" },
{ value: "modify_existing", label: "Modify existing name" },
].forEach(a => {
const opt = document.createElement("option");
opt.value = a.value;
opt.textContent = a.label;
actionSelect.appendChild(opt);
});
body.appendChild(actionSelect);
// ---- Workflow name (only shown when needed) ----
const nameLbl = document.createElement("label");
nameLbl.className = "koolook-modal-label";
nameLbl.textContent = "Workflow name";
body.appendChild(nameLbl);
const nameInput = document.createElement("input");
nameInput.className = "koolook-modal-input";
nameInput.value = defaultName || "";
nameInput.placeholder = "My workflow";
body.appendChild(nameInput);
// ---- Wiring ----
function getActiveWorkflowsInCurrentDir() {
const v = dirSelect.value;
if (v === "__new__") return [];
const dir = dirOf(v);
if (!dir) return [];
return Object.keys(dir.workflows)
.filter(n => !dir.workflows[n].archived)
.sort(compareNames);
}
function rebuildBaseOptions(names) {
const previous = baseSelect.value;
baseSelect.innerHTML = "";
for (const n of names) {
const opt = document.createElement("option");
opt.value = n;
opt.textContent = n;
baseSelect.appendChild(opt);
}
if (names.includes(previous)) baseSelect.value = previous;
}
function applyState({ refocusName = false } = {}) {
const dirIsNew = dirSelect.value === "__new__";
newDirInput.style.display = dirIsNew ? "" : "none";
const activeNames = dirIsNew ? [] : getActiveWorkflowsInCurrentDir();
const hasBase = activeNames.length > 0;
// Base on existing — visible only when the chosen directory has active workflows.
if (hasBase) {
baseLbl.style.display = "";
baseSelect.style.display = "";
rebuildBaseOptions(activeNames);
} else {
baseLbl.style.display = "none";
baseSelect.style.display = "none";
}
// Action options — disable "Use existing" / "Modify existing" when no base is available.
for (const opt of actionSelect.options) {
if (opt.value === "use_existing" || opt.value === "modify_existing") {
opt.disabled = !hasBase;
}
}
if (!hasBase && actionSelect.value !== "new") {
actionSelect.value = "new";
}
// Workflow name — visible only when needed.
const action = actionSelect.value;
if (action === "use_existing") {
// Name comes from the base; field is irrelevant.
nameLbl.style.display = "none";
nameInput.style.display = "none";
} else {
nameLbl.style.display = "";
nameInput.style.display = "";
if (action === "modify_existing" && baseSelect.value) {
nameInput.value = baseSelect.value;
nameInput.readOnly = false;
if (refocusName) {
setTimeout(() => {
nameInput.focus();
const len = nameInput.value.length;
nameInput.setSelectionRange(len, len);
}, 0);
}
} else {
// "new" — leave the value alone so the user's typing isn't clobbered.
nameInput.readOnly = false;
}
}
}
dirSelect.addEventListener("change", () => {
if (dirSelect.value === "__new__") newDirInput.focus();
applyState();
});
actionSelect.addEventListener("change", () => applyState({ refocusName: true }));
baseSelect.addEventListener("change", () => {
if (actionSelect.value === "modify_existing") {
nameInput.value = baseSelect.value;
}
});
applyState();
let overlay;
const submit = async () => {
let dir = dirSelect.value;
if (dir === "__new__") {
dir = newDirInput.value.trim();
if (!dir) { newDirInput.focus(); return; }
}
const action = actionSelect.value;
let name;
if (action === "use_existing") {
name = (baseSelect.value || "").trim();
if (!name) { actionSelect.value = "new"; applyState(); nameInput.focus(); return; }
} else {
name = nameInput.value.trim();
if (!name) { nameInput.focus(); return; }
}
overlay.remove();
await onSave({ name, dir });
};
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
newDirInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
const cancel = makeModalButton({ label: "Cancel", onClick: () => overlay.remove() });
const ok = makeModalButton({ label: "Save", primary: true, onClick: submit });
({ overlay } = makeModalShell({
title: titleSuffix ? `Save workflow — ${titleSuffix}` : "Save workflow",
body,
actions: [cancel, ok],
}));
setTimeout(() => {
if (nameInput.style.display !== "none") {
nameInput.focus();
nameInput.select();
}
}, 0);
}
// =============================================================================
// Context menu helper
// =============================================================================
export function showContextMenu(event, items) {
event.preventDefault();
event.stopPropagation();
const menu = document.createElement("div");
menu.className = "koolook-context-menu";
for (const item of items) {
if (!item) {
const sep = document.createElement("div");
sep.className = "koolook-context-sep";
menu.appendChild(sep);
continue;
}
const m = document.createElement("div");
m.className = "koolook-context-item";
if (item.danger) m.classList.add("koolook-context-danger");
m.textContent = item.label;
if (item.disabled) {
m.style.opacity = "0.4";
m.style.cursor = "not-allowed";
} else {
m.addEventListener("click", () => {
menu.remove();
item.action();
});
}
menu.appendChild(m);
}
menu.style.left = `${event.clientX}px`;
menu.style.top = `${event.clientY}px`;
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = `${event.clientX - rect.width}px`;
if (rect.bottom > window.innerHeight) menu.style.top = `${event.clientY - rect.height}px`;
setTimeout(() => {
const closeOnClick = (ev) => {
if (!menu.contains(ev.target)) {
menu.remove();
document.removeEventListener("click", closeOnClick);
document.removeEventListener("contextmenu", closeOnClick);
}
};
document.addEventListener("click", closeOnClick);
document.addEventListener("contextmenu", closeOnClick);
}, 0);
}
+117
View File
@@ -0,0 +1,117 @@
// =============================================================================
// User picks (localStorage, browser-local persistence)
// =============================================================================
import {
STORAGE_KEY,
SEEDED_KEY,
PICKS_CHANGED_EVENT,
DEFAULTS_URL,
toast,
} from "./constants.js";
export function loadUserPicks() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(x => typeof x === "string") : [];
} catch (e) {
// Surface corruption so the user has a chance to spot it in the console
// before the next saveUserPicks() overwrites the bad blob with a fresh list.
console.warn("[Koolook] failed to parse user picks; returning empty list:", e);
return [];
}
}
function saveUserPicks(picks) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(picks));
return true;
} catch (e) {
console.warn("[Koolook] failed to save picks:", e);
return false;
}
}
// Tri-state: "added" | "duplicate" | "failed". Callers need to distinguish
// "save failed" from "already in list" so the user isn't told a write
// succeeded when localStorage rejected it (quota / private mode).
export function addToMyPicks(typeName) {
if (!typeName) return "failed";
const picks = loadUserPicks();
if (picks.includes(typeName)) return "duplicate";
picks.push(typeName);
return saveUserPicks(picks) ? "added" : "failed";
}
export function removeFromMyPicks(typeName) {
saveUserPicks(loadUserPicks().filter(p => p !== typeName));
}
export function notifyPicksChanged() {
window.dispatchEvent(new CustomEvent(PICKS_CHANGED_EVENT));
}
export async function seedDefaultsIfNeeded() {
if (localStorage.getItem(SEEDED_KEY)) return;
if (loadUserPicks().length > 0) {
localStorage.setItem(SEEDED_KEY, "1");
return;
}
try {
const resp = await fetch(DEFAULTS_URL);
if (!resp.ok) {
localStorage.setItem(SEEDED_KEY, "1");
return;
}
const data = await resp.json();
const picks = (data && Array.isArray(data.picks))
? data.picks.filter(p => typeof p === "string")
: [];
if (picks.length > 0) {
// Only mark seeded if the save actually landed — otherwise the next
// page load retries instead of being permanently blocked by the flag.
if (!saveUserPicks(picks)) {
console.warn("[Koolook] seed save failed; will retry on next load");
return;
}
}
localStorage.setItem(SEEDED_KEY, "1");
console.log(`[Koolook] seeded ${picks.length} default pick(s)`);
} catch (e) {
console.warn("[Koolook] failed to load curated_defaults.json:", e);
localStorage.setItem(SEEDED_KEY, "1");
}
}
function picksAsDistributionJSON() {
const picks = [...loadUserPicks()].sort();
return JSON.stringify({ picks }, null, 2) + "\n";
}
export async function exportPicks() {
const picks = loadUserPicks();
if (picks.length === 0) {
toast("No picks to export yet.");
return;
}
const json = picksAsDistributionJSON();
const noun = picks.length === 1 ? "pick" : "picks";
try {
await navigator.clipboard.writeText(json);
toast(`Copied ${picks.length} ${noun} to clipboard. Paste into web/curated_defaults.json.`);
return;
} catch (e) {
console.warn("[Koolook] clipboard write failed, falling back to download:", e);
}
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "curated_defaults.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast(`Downloaded ${picks.length} ${noun} as curated_defaults.json. Replace the file in web/ with it.`);
}
+824
View File
@@ -0,0 +1,824 @@
// =============================================================================
// Sidebar tree — data gathering, DOM row factories, folder builder, context
// menus for workflow/directory rows, the tree-rebuild dispatcher, 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.
// =============================================================================
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 — persisted across re-renders so saving (or any
// other action that triggers a tree rebuild) doesn't collapse what the user
// was looking at. Map<path, boolean>; truthy = expanded.
// =============================================================================
const pathStates = new Map();
// =============================================================================
// 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"}.`),
}),
});
}
},
},
]);
}
// =============================================================================
// Tree rendering
// =============================================================================
function rebuildTree(treeEl, query) {
treeEl.innerHTML = "";
const isFiltered = (query || "").trim().length > 0;
// ------ Gather both sections first so we know whether to render a divider ------
const autoPacks = gatherNodesByRepo(query);
const userPickPacks = gatherUserPickPacks(query);
const allPacks = [...autoPacks, ...userPickPacks]
.filter(p => p.total > 0)
.sort((a, b) => compareNames(a.label, b.label));
const nodesTotal = allPacks.reduce((acc, p) => acc + p.total, 0);
const showNodes = nodesTotal > 0;
const wf = gatherWorkflows(query);
const showWorkflows = wf.directories.length > 0;
// Search returned nothing across both sections.
if (!showNodes && !showWorkflows && isFiltered) {
const empty = document.createElement("div");
empty.className = "koolook-empty";
empty.textContent = "No nodes or workflows match your search.";
treeEl.appendChild(empty);
return;
}
// ------ Nodes section ------
if (showNodes) {
const nodesGroup = buildFolder({
name: ROOT_GROUP_LABEL,
count: nodesTotal,
iconKind: "favorites",
startExpanded: true,
path: "nodes",
forceExpanded: isFiltered,
childrenBuilder: (rootChildren) => {
for (const pack of allPacks) {
const packFolder = buildFolder({
name: pack.label,
count: pack.total,
startExpanded: false,
path: `nodes/${pack.label}`,
forceExpanded: isFiltered,
childrenBuilder: (packChildren) => {
for (const cat of pack.categories) {
const catFolder = buildFolder({
name: cat.name,
count: cat.nodes.length,
startExpanded: false,
path: `nodes/${pack.label}/${cat.name}`,
forceExpanded: isFiltered,
childrenBuilder: (catChildren) => {
for (const n of cat.nodes) {
catChildren.appendChild(makeNodeLeafRow({
display: n.display,
type: n.type,
removable: !!pack.isUserPicks,
onClick: () => insertNode(n.type),
}));
}
},
});
packChildren.appendChild(catFolder);
}
},
});
rootChildren.appendChild(packFolder);
}
},
});
treeEl.appendChild(nodesGroup);
} else if (!isFiltered) {
const empty = document.createElement("div");
empty.className = "koolook-empty";
empty.textContent = "No curated nodes yet. Click + above (with a canvas node selected) or right-click a node on the canvas → Add to Curated Sidebar.";
treeEl.appendChild(empty);
}
// ------ Divider between sections ------
if (showNodes && showWorkflows) {
const divider = document.createElement("div");
divider.className = "koolook-tree-divider";
treeEl.appendChild(divider);
}
// ------ Workflows section ------
if (showWorkflows) {
const workflowsGroup = buildFolder({
name: WORKFLOWS_GROUP_LABEL,
count: wf.total,
iconKind: "workflows",
startExpanded: true,
path: "workflows",
forceExpanded: isFiltered,
childrenBuilder: (wfChildren) => {
for (const dir of wf.directories) {
const totalInDir = dir.active.length + dir.archived.length;
const dirFolder = buildFolder({
name: dir.name,
count: totalInDir,
startExpanded: false,
path: `workflows/${dir.name}`,
forceExpanded: isFiltered,
onContextMenu: (e) => directoryRowContextMenu(e, dir.name),
childrenBuilder: (dirChildren) => {
// Archive first, then active — so the latest (active)
// workflow is closest to the bottom of the directory.
if (dir.archived.length > 0) {
const archiveFolder = buildFolder({
name: "Archive",
count: dir.archived.length,
iconKind: "archive",
startExpanded: false,
path: `workflows/${dir.name}/Archive`,
forceExpanded: isFiltered,
childrenBuilder: (archiveChildren) => {
for (const wfName of dir.archived) {
archiveChildren.appendChild(makeWorkflowLeafRow({
name: wfName,
dirName: dir.name,
onClick: () => loadWorkflowOntoCanvas(dir.name, wfName),
onContextMenu: (e) => workflowRowContextMenu(e, dir.name, wfName, true),
}));
}
},
});
dirChildren.appendChild(archiveFolder);
}
for (const wfName of dir.active) {
dirChildren.appendChild(makeWorkflowLeafRow({
name: wfName,
dirName: dir.name,
onClick: () => loadWorkflowOntoCanvas(dir.name, wfName),
onContextMenu: (e) => workflowRowContextMenu(e, dir.name, wfName, false),
}));
}
},
});
wfChildren.appendChild(dirFolder);
}
},
});
treeEl.appendChild(workflowsGroup);
}
// (When workflows is empty and not filtered, we silently hide the section;
// the toolbar above still lets the user create a directory or save a workflow.)
}
// =============================================================================
// 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) => {
pathStates.set("workflows", true);
pathStates.set(`workflows/${dir}`, true);
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);
rebuildTree(tree, "");
// ---- Search wiring ----
let debounce = null;
search.addEventListener("input", (e) => {
const q = e.target.value;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => rebuildTree(tree, q), 60);
});
// ---- Event subscriptions for live re-rendering ----
window.addEventListener(PICKS_CHANGED_EVENT, () => rebuildTree(tree, search.value));
window.addEventListener(WORKFLOWS_CHANGED_EVENT, () => rebuildTree(tree, search.value));
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY || e.key === WORKFLOWS_FALLBACK_KEY) {
rebuildTree(tree, search.value);
}
});
}
+347
View File
@@ -0,0 +1,347 @@
// =============================================================================
// Workflows storage (ComfyUI /userdata API with localStorage fallback) +
// transaction layer + in-memory mutation operations.
//
// `workflowsCache` is module-private. Outside callers go through the exported
// helpers — direct access would otherwise hold a stale reference after the
// cache rebinds during seed/recovery.
// =============================================================================
import {
WORKFLOWS_USERDATA_PATH,
WORKFLOWS_FALLBACK_KEY,
WORKFLOWS_SEEDED_KEY,
WORKFLOWS_CHANGED_EVENT,
WORKFLOWS_DEFAULTS_URL,
compareNames,
toast,
} from "./constants.js";
let workflowsCache = { directories: {} };
function notifyWorkflowsChanged() {
window.dispatchEvent(new CustomEvent(WORKFLOWS_CHANGED_EVENT));
}
function normalizeWorkflowsStore(data) {
if (!data || typeof data !== "object") return { directories: {} };
const dirs = data.directories;
if (!dirs || typeof dirs !== "object") return { directories: {} };
const out = {};
let dropped = 0;
for (const [name, dir] of Object.entries(dirs)) {
if (!dir || typeof dir !== "object") continue;
const wfs = dir.workflows && typeof dir.workflows === "object" ? dir.workflows : {};
const cleanedWfs = {};
for (const [wfName, wf] of Object.entries(wfs)) {
// Drop entries that can't be loaded (missing/non-object graph).
// Coerce `archived` to a strict boolean so a stray "false" string
// can't accidentally flag an entry as archived.
if (!wf || typeof wf !== "object" || !wf.graph || typeof wf.graph !== "object") {
dropped += 1;
continue;
}
cleanedWfs[wfName] = { ...wf, archived: wf.archived === true };
}
out[name] = { workflows: cleanedWfs };
}
if (dropped > 0) {
console.warn(`[Koolook] dropped ${dropped} malformed workflow entr(y/ies) during normalize`);
}
return { directories: out };
}
// Sentinel for "server reachable but file content is unparseable".
// Distinct from `undefined` (server unreachable) and `null` (file missing)
// so callers can refuse to auto-seed on top of a corrupt-but-existing file.
const SERVER_FILE_CORRUPT = Symbol("workflows-server-corrupt");
async function fetchWorkflowsFromServer() {
let resp;
try {
resp = await fetch(`/userdata/${WORKFLOWS_USERDATA_PATH}`);
} catch (e) {
console.warn("[Koolook] /userdata read failed (network):", e);
return undefined;
}
if (resp.status === 404) return null;
if (!resp.ok) {
console.warn(`[Koolook] /userdata read returned HTTP ${resp.status}`);
return undefined;
}
const text = await resp.text();
if (!text || !text.trim()) return null;
try {
return JSON.parse(text);
} catch (e) {
console.error("[Koolook] /userdata workflow file is unreadable; refusing to auto-recover:", e);
return SERVER_FILE_CORRUPT;
}
}
// Returns "server" | "fallback" | false. Callers that care about durability
// (specifically the seeders) should only treat "server" as a success — a
// "fallback"-only write is per-browser and won't reach future page loads if
// /userdata becomes reachable again.
async function persistWorkflowsToServer(store) {
const json = JSON.stringify(store, null, 2);
try {
const resp = await fetch(`/userdata/${WORKFLOWS_USERDATA_PATH}?overwrite=true`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: json,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return "server";
} catch (e) {
console.warn("[Koolook] /userdata write failed, using localStorage fallback:", e);
try {
localStorage.setItem(WORKFLOWS_FALLBACK_KEY, json);
return "fallback";
} catch (e2) {
console.error("[Koolook] both /userdata and localStorage write failed:", e2);
return false;
}
}
}
// Returns { corrupt: false } on normal load (cache populated), or
// { corrupt: true } when /userdata reachable but file content is unparseable —
// the caller (setup) must skip seeding so we don't clobber the corrupt-but-
// recoverable file with stock defaults.
export async function loadWorkflowsStore() {
const fromServer = await fetchWorkflowsFromServer();
if (fromServer === SERVER_FILE_CORRUPT) {
toast("Workflow file on /userdata is unreadable. Refusing to auto-recover; check console.");
workflowsCache = { directories: {} };
return { corrupt: true };
}
if (fromServer === null) {
// Server reachable, file just doesn't exist yet — return empty.
workflowsCache = { directories: {} };
return { corrupt: false };
}
if (fromServer === undefined) {
// Server unreachable — try localStorage fallback.
try {
const raw = localStorage.getItem(WORKFLOWS_FALLBACK_KEY);
if (raw) {
workflowsCache = normalizeWorkflowsStore(JSON.parse(raw));
return { corrupt: false };
}
} catch (e) {
console.warn("[Koolook] failed to parse localStorage workflows fallback:", e);
}
workflowsCache = { directories: {} };
return { corrupt: false };
}
workflowsCache = normalizeWorkflowsStore(fromServer);
// Reconciliation: /userdata loaded successfully but a stale fallback blob
// from an earlier outage still exists. We don't auto-merge (risk of
// clobbering) but we surface it visibly so the user knows where to recover.
if (localStorage.getItem(WORKFLOWS_FALLBACK_KEY)) {
console.warn(
`[Koolook] /userdata loaded, but a stale localStorage fallback exists ` +
`at "${WORKFLOWS_FALLBACK_KEY}". If workflows you saved during a previous ` +
`outage are missing, recover from there before clearing.`
);
toast(`Old offline workflow data found in localStorage["${WORKFLOWS_FALLBACK_KEY}"]. See console to recover.`, 4500);
}
return { corrupt: false };
}
async function commit() {
// Both "server" and "fallback" are user-visible successes — the panel
// shows the change either way. Only `false` (both backends rejected)
// indicates a real loss requiring rollback at the call site.
const result = await persistWorkflowsToServer(workflowsCache);
if (result) notifyWorkflowsChanged();
return result !== false;
}
// Snapshot the cache so callers can roll back a mutation when commit() fails.
// Pairs with a `restore()` function that puts the cache back and re-renders.
function snapshotCache() {
const snap = JSON.stringify(workflowsCache);
return () => {
try {
workflowsCache = JSON.parse(snap);
notifyWorkflowsChanged();
} catch (e) {
console.error("[Koolook] cache rollback failed:", e);
}
};
}
// Mutate-then-commit with automatic rollback on persist failure. Mutation
// returning `false` is treated as a no-op (e.g. name collision) and bypasses
// commit. `onSuccess(result)` and `onNoOp()` callbacks run their own toasts.
export async function persistMutation({ mutate, onSuccess, onNoOp, persistFailedMessage }) {
const restore = snapshotCache();
const result = mutate();
if (result === false) {
if (onNoOp) onNoOp();
return false;
}
if (await commit()) {
if (onSuccess) onSuccess(result);
return true;
}
restore();
toast(persistFailedMessage || "Save failed — change reverted. See console.");
return false;
}
export async function seedWorkflowDefaultsIfNeeded() {
if (localStorage.getItem(WORKFLOWS_SEEDED_KEY)) return;
// If existing data is non-empty, respect it and mark seeded.
const dirNames = Object.keys(workflowsCache.directories || {});
if (dirNames.length > 0) {
localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1");
return;
}
try {
const resp = await fetch(WORKFLOWS_DEFAULTS_URL);
if (!resp.ok) {
localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1");
return;
}
const data = await resp.json();
const normalized = normalizeWorkflowsStore(data);
const seedDirCount = Object.keys(normalized.directories).length;
if (seedDirCount > 0) {
workflowsCache = normalized;
// Only mark seeded if the persist *reached the server* — a
// "fallback"-only write is per-browser, and once /userdata
// becomes reachable on a later load the empty server file
// would otherwise trump the locally-seeded data.
const persistResult = await persistWorkflowsToServer(workflowsCache);
if (persistResult !== "server") {
console.warn(
`[Koolook] seed persist landed in ${persistResult || "neither"}; ` +
`not marking seeded so we retry against /userdata next load.`
);
return;
}
}
localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1");
console.log(`[Koolook] seeded ${seedDirCount} default workflow director(y/ies)`);
} catch (e) {
console.warn("[Koolook] failed to load workflow_defaults.json:", e);
localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1");
}
}
// =============================================================================
// Workflow operations (in-memory; pair every mutation with persistMutation())
// =============================================================================
export function listDirectoryNames() {
return Object.keys(workflowsCache.directories || {}).sort(compareNames);
}
export function dirOf(name) {
return workflowsCache.directories[name];
}
function ensureDirectory(name) {
if (!workflowsCache.directories[name]) {
workflowsCache.directories[name] = { workflows: {} };
}
return workflowsCache.directories[name];
}
export function addDirectory(name) {
name = (name || "").trim();
if (!name) return false;
if (workflowsCache.directories[name]) return false; // already exists
workflowsCache.directories[name] = { workflows: {} };
return true;
}
export function renameDirectory(oldName, newName) {
newName = (newName || "").trim();
if (!newName || newName === oldName) return false;
if (!workflowsCache.directories[oldName]) return false;
if (workflowsCache.directories[newName]) return false;
workflowsCache.directories[newName] = workflowsCache.directories[oldName];
delete workflowsCache.directories[oldName];
return true;
}
export function deleteDirectory(name) {
if (!workflowsCache.directories[name]) return false;
delete workflowsCache.directories[name];
return true;
}
export function saveWorkflowEntry(dirName, wfName, graphData) {
const dir = ensureDirectory(dirName);
let archivedAs = null;
const existing = dir.workflows[wfName];
if (existing) {
// Same-name save: move the existing version into the directory's
// Archive (timestamp-suffixed) before overwriting.
const ts = new Date().toISOString().slice(0, 19).replace("T", " ");
let archiveName = `${wfName} (archived ${ts})`;
let n = 1;
while (dir.workflows[archiveName]) {
n += 1;
archiveName = `${wfName} (archived ${ts}) #${n}`;
}
dir.workflows[archiveName] = { ...existing, archived: true };
archivedAs = archiveName;
}
dir.workflows[wfName] = {
savedAt: new Date().toISOString(),
graph: graphData,
};
return { archivedAs };
}
export function archiveWorkflow(dirName, wfName) {
const dir = dirOf(dirName);
if (!dir || !dir.workflows[wfName]) return false;
dir.workflows[wfName].archived = true;
return true;
}
export function unarchiveWorkflow(dirName, wfName) {
const dir = dirOf(dirName);
if (!dir || !dir.workflows[wfName]) return false;
delete dir.workflows[wfName].archived;
return true;
}
export function renameWorkflow(dirName, oldWfName, newWfName) {
newWfName = (newWfName || "").trim();
if (!newWfName || newWfName === oldWfName) return false;
const dir = dirOf(dirName);
if (!dir || !dir.workflows[oldWfName]) return false;
if (dir.workflows[newWfName]) return false;
dir.workflows[newWfName] = dir.workflows[oldWfName];
delete dir.workflows[oldWfName];
return true;
}
export function deleteWorkflow(dirName, wfName) {
const dir = dirOf(dirName);
if (!dir || !dir.workflows[wfName]) return false;
delete dir.workflows[wfName];
return true;
}
export function moveWorkflow(srcDir, wfName, dstDir) {
if (srcDir === dstDir) return false;
const src = dirOf(srcDir);
if (!src || !src.workflows[wfName]) return false;
const dst = ensureDirectory(dstDir);
if (dst.workflows[wfName]) return false; // name collision in destination
dst.workflows[wfName] = src.workflows[wfName];
delete src.workflows[wfName];
return true;
}
export function getWorkflowGraph(dirName, wfName) {
const dir = dirOf(dirName);
if (!dir || !dir.workflows[wfName]) return null;
return dir.workflows[wfName].graph || null;
}