feat(sidebar): Modules — splice a saved cluster into the live canvas

Tag any saved workflow with the literal tag `module` and the Kforge
Labs sidebar starts treating it as a reusable building block: a green
`pi pi-plus-circle` icon replaces the file glyph, left-click
**inserts** the cluster at the viewport center (with internal links
re-created and the new nodes left selected), and a distinct hover
tooltip surfaces the changed semantics. Designed for the
`EXR-output stack` / `depth-only render` / `Wan 2.2 prompt
module` pattern: small clusters the maintainer wants to drop into
many workflows without rebuilding them by hand.

What changed:

1. New non-destructive primitive `insertWorkflowOntoCanvas` in
   `web/sidebar/canvas_io.js`. Pre-flights every referenced node type
   against `LiteGraph.registered_node_types` and aborts cleanly with a
   toast naming the missing types — a partial insert with stub nodes
   is worse than no insert. Then deep-clones the saved graph, calls
   `node.configure(...)` per node with `node.id = -1` so
   `app.graph.add()` allocates fresh ids that can never collide with
   the live canvas, and finally walks the saved `links[]` to
   recreate internal connections via `originNode.connect(...)` (which
   auto-allocates fresh link ids — no manual link remap needed). Bbox
   placement reuses the existing CSS-pixel correction so the cluster
   lands at the visible viewport center on HiDPI displays.

2. Right-click on any workflow row now offers `Insert into canvas`
   alongside the existing `Load` — Load remains available for every
   row regardless of tag, so a module can still be loaded as a full
   session if the user wants.

3. Convention-driven module classification in
   `web/sidebar/constants.js` (`MODULE_TAG = module`). Picked a
   tag instead of a new `isModule` schema field so existing saved
   entries can be promoted to modules with one right-click → Tags add,
   no migration needed, and the existing Tags section groups them for
   free. Tree rendering looks up the tag at row-render time
   (`isModuleWorkflow` helper) and switches both the leaf icon
   (green `pi pi-plus-circle`) and the left-click handler (Insert
   instead of Load). Active rows in the Workflows section AND every
   row under the Tags section honor the flip; archived rows stay on
   Load (they're old versions, `review` not `splice`).

4. `showSaveWorkflowModal` gains a `Save as module` checkbox. The
   selection-save toolbar button passes `defaultModule: true` (the
   typical `module` candidate is a small cluster the user just
   selected); the whole-canvas save passes `defaultModule: false`.
   The tag is added in the SAME `persistMutation` as the entry write
   (folded into the `mutate` callback in `saveAndToast`), so a
   commit failure rolls back BOTH the save and the tag together — no
   `module`-but-untagged ghosts.

Docs: new `Modules` section in
`docs/maintainers/workflows-sidebar.md` covering the convention, the
checkbox flow, per-surface behavior, and how to promote an existing
saved workflow to a module without re-saving.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
malkuthro
2026-05-05 21:32:06 +02:00
co-authored by Cursor
parent ced6492257
commit ed6f3dad02
6 changed files with 412 additions and 14 deletions
+25
View File
@@ -7,6 +7,31 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Added
- **Modules — splice a saved cluster into your live canvas instead of
replacing it.** Tag any saved workflow with the literal tag `module`
and the Kforge Labs sidebar starts treating it as a reusable building
block: a green `pi pi-plus-circle` icon replaces the file glyph,
left-click **inserts** the cluster at the viewport center (with
internal links re-created and the new nodes left selected), and a
distinct hover tooltip surfaces the changed semantics. The
selection-save modal now ships a **`Save as module`** checkbox
(pre-checked for selection saves; unchecked for whole-canvas saves),
which adds the `module` tag inside the same `persistMutation` as the
entry write so a commit failure rolls back both. The right-click
menu on every workflow row gains an explicit **Insert into canvas**
entry alongside the existing Load — Load still works on every row,
module-tagged or not. New primitive `insertWorkflowOntoCanvas` in
`web/sidebar/canvas_io.js` pre-flights every referenced node type
against `LiteGraph.registered_node_types` and aborts cleanly when
any are missing (a partial insert with stub nodes is worse than no
insert), then deep-clones the saved graph, lets `app.graph.add()`
allocate fresh node ids so nothing collides with the live canvas,
and recreates internal connections via `originNode.connect(...)` so
link ids are auto-allocated too — no manual link remap needed.
Designed for setups like an EXR-output stack, a depth-only render
cluster, or a Wan 2.2 prompt module the user wants to drop into many
workflows. Full reference in
[`docs/maintainers/workflows-sidebar.md`](docs/maintainers/workflows-sidebar.md#modules--splice-a-saved-cluster-into-your-live-canvas).
- **Drag-link-release menu hoists curated picks to the top.** When the user
drags a connection out of a node socket and releases on empty canvas,
ComfyUI's compatibility-filtered suggestion list now sorts items in the
+38 -1
View File
@@ -29,9 +29,46 @@ The archive trigger is a name collision with any existing active entry,
regardless of which action was selected. The Action dropdown is UX
guidance; the underlying logic flows from the resulting `(name, dir)` pair.
## Modules — splice a saved cluster into your live canvas
A saved workflow tagged with the literal tag **`module`** behaves like a
reusable building block: clicking it **inserts** its nodes into the existing
canvas (placed at the viewport center, freshly id-remapped, internal links
re-created, the inserted nodes left selected) instead of replacing the
graph the way a normal Load does.
Typical use cases this is designed for:
- **EXR output stack** — a Save EXR + path-builder + format selector cluster
the user wants to drop into any render-time workflow.
- **Depth-only render** — a depth-map node + preview wired up, ready to be
spliced into a wider pipeline.
- **Wan 2.2 prompt module** — the `EasyWan22Prompt` node + a CLIP encoder
+ any conditioning glue, saved once, dropped into ten different
workflows.
How it works:
| Surface | Behavior |
|---|---|
| **Save modal** (selection save) | Includes a **`Save as module`** checkbox, **pre-checked** for selection saves. Whole-canvas saves get the same checkbox but **unchecked** by default. The tag is added in the same atomic `persistMutation` as the save — a commit failure rolls both back. |
| **Workflows section** | Module-tagged entries get a green **`pi pi-plus-circle`** icon and a distinct hover tooltip. **Left-click inserts** instead of loading. Right-click still offers **both** `Load` and `Insert into canvas` — `Load` stays available for every row regardless of tag. |
| **Tags section** | Same flip — a workflow tagged `module` is treated as a module no matter which tag folder it shows up under. |
| **Archive folder** | Archived module entries still left-click to **Load** (they're old versions, treated as "review" rather than "splice"). Right-click still has both Load and Insert. |
Implementation pieces:
- The literal tag string lives in [`web/sidebar/constants.js`](../../web/sidebar/constants.js) as `MODULE_TAG`.
- The non-destructive insert primitive is `insertWorkflowOntoCanvas(dirPath, wfName)` in [`web/sidebar/canvas_io.js`](../../web/sidebar/canvas_io.js). It pre-flights every referenced node type against `LiteGraph.registered_node_types`, aborts cleanly with a toast when any are missing (a partial insert with stub nodes is worse than no insert), then deep-clones the saved graph, calls `node.configure(...)` per node with `node.id = -1` so `app.graph.add()` allocates fresh ids that can't collide with the live canvas, and finally walks the saved `links[]` to recreate internal connections via `originNode.connect(...)` (which auto-allocates fresh link ids — no manual link remap needed).
- Bbox-of-cluster placement uses CSS pixels (`clientWidth`/`clientHeight`), not the HiDPI backing buffer — same correction as the existing `placeAtCanvasCenter` helper.
To turn an **existing** saved workflow into a module: right-click → **Tags…**
→ add `module`. (No re-save needed; the row re-renders on the next
`workflows-changed` event.) To un-module: remove the `module` tag the same way.
## Right-click menus
- **Workflow row**: Load / Rename / `→ <other path>` (lists every other directory in the tree) / Move to archive (or Restore from archive if already archived) / Delete.
- **Workflow row**: Load / **Insert into canvas** / Rename / `→ <other path>` (lists every other directory in the tree) / Move to archive (or Restore from archive if already archived) / Delete.
- **Directory row** (any depth): **Create subdirectory…** / Rename / Delete (with confirm if non-empty — the message names workflow + subdirectory counts).
- **Archive folder** (synthetic — appears only when a directory has archived workflows): **Delete archive (N)** — removes every archived workflow in this directory in one go (active workflows in the same directory are untouched).
+226
View File
@@ -325,6 +325,232 @@ export function insertNode(typeName) {
app.canvas.setDirty(true, true);
}
// =============================================================================
// Workflow insertion (non-destructive — ADDS nodes from a saved workflow into
// the live canvas instead of replacing it). Sibling to `loadWorkflowOntoCanvas`
// above; the existing function rebuilds the entire graph via
// `app.loadGraphData`, which is the right call for "load this workflow as my
// session", but the wrong one for "drop this preset cluster next to my
// existing nodes". This primitive is the basis for the Modules feature: a
// saved selection (typically tagged `module`) that the user wants to splice
// into whatever they're already building.
//
// Mechanics — kept tight because the failure modes are subtle:
//
// 1. **Pre-flight missing types.** A saved cluster can reference custom
// nodes the user hasn't installed. `app.loadGraphData` masks this by
// rendering red error nodes; for an insert that would silently splice
// broken stubs into the user's working graph. We check
// `LiteGraph.registered_node_types` up-front and abort with a toast if
// anything is missing — partial inserts are worse than no insert.
//
// 2. **Let the live graph allocate ids.** The saved graph's node ids and
// link ids almost certainly collide with what's already on canvas (both
// counters reset per saved workflow). Easiest correct fix: deep-clone
// the saved graph, then for each cloned node call `node.configure(...)`
// (which restores widget values + properties from the JSON) but force
// `node.id = -1` before `app.graph.add(node)` so LiteGraph hands out a
// fresh id from `last_node_id + 1`. Build an `oldId → newId` map as we
// go so links can be reconstructed afterwards.
//
// 3. **Recreate links via `node.connect`.** Walking `clone.links` and
// calling `originNode.connect(originSlot, targetNode, targetSlot)`
// sidesteps link-id remapping entirely — `connect` allocates a fresh
// link id from `app.graph.last_link_id + 1`. Cross-boundary references
// (links pointing at nodes that weren't part of the saved selection)
// are already nulled by `serializeSelection`, so the typical case is a
// clean closed sub-graph.
//
// 4. **Place the cluster at the viewport center.** Compute the bbox of
// the cloned nodes (in their saved coords), then translate every node
// by `(viewportCenter - bboxCenter)`. Reuses the same HiDPI-correct
// math as `placeAtCanvasCenter` (CSS pixels via `clientWidth`, not the
// backing-store buffer). If the canvas isn't laid out yet, we fall
// through and place at original coords — same defensive posture as
// `placeAtCanvasCenter`.
//
// 5. **Select the inserted cluster** so the user can immediately drag it
// somewhere else, delete it, or hit Ctrl+C to duplicate. Selection API
// varies across LiteGraph forks (`selectNodes` vs. `selectNode(_, true)`
// for additive); we try both and swallow failures because selection is
// a nice-to-have, not load-critical.
//
// Returns `{ ok: true, count }` on success, `{ ok: false, reason }` on any
// abort path so callers can branch (the right-click menu just toasts; a
// future drag-onto-canvas path might want to surface a different message).
// =============================================================================
function placeBboxAtCanvasCenter(nodes) {
if (!nodes || nodes.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of nodes) {
const w = (n.size && n.size[0]) || 200;
const h = (n.size && n.size[1]) || 100;
const x = (n.pos && n.pos[0]) || 0;
const y = (n.pos && n.pos[1]) || 0;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
}
if (!isFinite(minX) || !isFinite(minY)) return;
const bboxCx = (minX + maxX) / 2;
const bboxCy = (minY + maxY) / 2;
let dx = 0, dy = 0;
try {
const canvas = app.canvas;
const ds = canvas.ds;
const cssWidth = canvas.canvas.clientWidth || canvas.canvas.width;
const cssHeight = canvas.canvas.clientHeight || canvas.canvas.height;
const cx = -ds.offset[0] + cssWidth / (2 * ds.scale);
const cy = -ds.offset[1] + cssHeight / (2 * ds.scale);
dx = cx - bboxCx;
dy = cy - bboxCy;
} catch (e) {
// Canvas not ready — leave nodes at their saved coords. This matches
// `placeAtCanvasCenter`'s posture: don't throw on a layout race.
}
if (dx === 0 && dy === 0) return;
for (const n of nodes) {
if (!n.pos) n.pos = [0, 0];
n.pos[0] += dx;
n.pos[1] += dy;
}
}
export async function insertWorkflowOntoCanvas(dirPath, wfName) {
const sourceGraph = getWorkflowGraph(dirPath, wfName);
if (!sourceGraph) {
toast(`Workflow not found: ${wfName}`);
return { ok: false, reason: "not-found" };
}
if (typeof LiteGraph === "undefined") {
toast(`Cannot insert "${wfName}" — LiteGraph not available.`);
return { ok: false, reason: "no-litegraph" };
}
// Deep clone — `node.configure` and `placeBboxAtCanvasCenter` both
// mutate. We never want to touch the in-memory store copy.
const clone = JSON.parse(JSON.stringify(sourceGraph));
const nodesRaw = Array.isArray(clone.nodes) ? clone.nodes : [];
if (nodesRaw.length === 0) {
toast(`"${wfName}" has no nodes to insert.`);
return { ok: false, reason: "empty" };
}
// Pre-flight: every node type must be registered. Aborting here is the
// entire reason this is a separate primitive from `loadWorkflowOntoCanvas`
// — a partial insert with stub nodes silently corrupts the user's graph.
const registered = LiteGraph.registered_node_types || {};
const missingTypes = [];
const seenMissing = new Set();
for (const n of nodesRaw) {
const t = n && n.type;
if (!t) continue;
if (!registered[t] && !seenMissing.has(t)) {
seenMissing.add(t);
missingTypes.push(t);
}
}
if (missingTypes.length > 0) {
const sample = missingTypes.slice(0, 3).join(", ");
const more = missingTypes.length > 3 ? ` (+${missingTypes.length - 3} more)` : "";
toast(
`Cannot insert "${wfName}" — missing node type${missingTypes.length === 1 ? "" : "s"}: ` +
`${sample}${more}. Install the required pack(s) first.`,
4500
);
console.warn(`[Koolook] insert "${wfName}" aborted; missing types:`, missingTypes);
return { ok: false, reason: "missing-types", missingTypes };
}
// Translate the cluster to the viewport center BEFORE creating live
// nodes. `placeBboxAtCanvasCenter` writes into `n.pos` on the cloned
// entries, which `node.configure(...)` then reads.
placeBboxAtCanvasCenter(nodesRaw);
// Insert nodes; let the live graph allocate ids so nothing collides
// with `app.graph.last_node_id`.
const oldToNew = new Map();
const inserted = [];
for (const oldNode of nodesRaw) {
const node = LiteGraph.createNode(oldNode.type);
if (!node) {
// Pre-flight should have caught this; log if it happens anyway
// (a registered type whose factory returned null is a LiteGraph
// bug or a misregistered pack — we'd rather skip than crash the
// whole insert at this point).
console.warn(`[Koolook] LiteGraph.createNode("${oldNode.type}") returned null after pre-flight passed`);
continue;
}
try {
node.configure(oldNode);
} catch (e) {
console.warn(`[Koolook] node.configure failed for "${oldNode.type}":`, e);
}
// configure() restored `node.id` from the saved data — that's the
// OLD id. Force re-allocation so we don't collide with the live
// graph (or with another inserted node from the same cluster).
const oldId = oldNode.id;
node.id = -1;
app.graph.add(node);
if (oldId != null) oldToNew.set(oldId, node.id);
inserted.push(node);
}
// Recreate internal links. `serializeSelection` already nulled out
// cross-boundary references, but defensively skip any link whose
// endpoint we don't have in the remap (covers hand-edited /userdata
// files and old saves from before that pruning landed).
const linksRaw = Array.isArray(clone.links) ? clone.links : [];
let connectedLinks = 0;
for (const link of linksRaw) {
if (!Array.isArray(link) || link.length < 5) continue;
const oldOrigin = link[1];
const originSlot = link[2];
const oldTarget = link[3];
const targetSlot = link[4];
const newOrigin = oldToNew.get(oldOrigin);
const newTarget = oldToNew.get(oldTarget);
if (newOrigin == null || newTarget == null) continue;
const originNode = app.graph.getNodeById(newOrigin);
const targetNode = app.graph.getNodeById(newTarget);
if (!originNode || !targetNode) continue;
try {
const result = originNode.connect(originSlot, targetNode, targetSlot);
if (result) connectedLinks += 1;
} catch (e) {
console.warn(
`[Koolook] link reconnect failed: ${originNode.type}[${originSlot}] → ` +
`${targetNode.type}[${targetSlot}]:`, e
);
}
}
// Selection — best-effort, never block on it.
try {
if (typeof app.canvas.selectNodes === "function") {
app.canvas.selectNodes(inserted);
} else if (typeof app.canvas.selectNode === "function") {
// Older LiteGraph: additive single-select per node.
for (let i = 0; i < inserted.length; i += 1) {
app.canvas.selectNode(inserted[i], i > 0);
}
}
} catch (e) {
// Don't surface — the nodes are on canvas, selection is just polish.
}
app.canvas.setDirty(true, true);
const noun = inserted.length === 1 ? "node" : "nodes";
const linkSuffix = connectedLinks > 0
? ` (${connectedLinks} link${connectedLinks === 1 ? "" : "s"})`
: "";
toast(`Inserted "${wfName}" — ${inserted.length} ${noun}${linkSuffix}.`);
return { ok: true, count: inserted.length, links: connectedLinks };
}
export function getSelectedNodeTypes() {
try {
const sel = (app.canvas && app.canvas.selected_nodes) || {};
+17
View File
@@ -58,6 +58,20 @@ 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;
// Convention-driven "module" classification — a saved workflow tagged with
// this literal string is treated as a reusable building block (insert into
// the existing canvas) instead of a full session (replace the canvas).
//
// Why a tag instead of a dedicated `isModule` field on the entry? Three
// reasons: (1) zero schema migration; (2) the Tags section already groups
// `module`-tagged entries for free; (3) re-tagging an existing saved entry
// turns it into a module without any data shape change.
//
// Comparison is case-sensitive to match `addTag` / `removeTag` semantics in
// `workflows_store.js`. The literal lives here so future renames (or a
// "modules also includes `preset`" rule) only need to touch one constant.
export const MODULE_TAG = "module";
// =============================================================================
// Styles
// =============================================================================
@@ -90,6 +104,7 @@ export function ensureStyle() {
.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-module-icon { color: #7be08a; opacity: 0.95; 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; }
@@ -109,6 +124,8 @@ export function ensureStyle() {
.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-checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 12px; opacity: 0.85; cursor: pointer; user-select: none; }
.koolook-modal-checkbox-row input[type="checkbox"] { cursor: pointer; }
.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); }
+26 -2
View File
@@ -149,7 +149,7 @@ export function showConfirmModal({ title, message, confirmLabel, cancelLabel, da
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
}
export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, onSave }) {
export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, defaultModule = false, onSave }) {
const body = document.createElement("div");
// ---- Directory (cascading picker) ----
@@ -353,6 +353,26 @@ export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, on
nameInput.placeholder = "My workflow";
body.appendChild(nameInput);
// ---- "Save as module" checkbox ----
// Tagging the saved entry with the literal `module` tag flips the
// sidebar's left-click default from Load to Insert, and re-icons the
// row green. The actual `addTag` call lives in the caller's `onSave`
// handler so it can ride the same persistMutation snapshot as the
// entry write itself — a commit failure rolls back BOTH the save and
// the tag in one go. Visually, the checkbox sits below the name field
// (the last thing the user touches before clicking Save) so the
// intent decision is co-located with the destination decision.
const moduleRow = document.createElement("label");
moduleRow.className = "koolook-modal-checkbox-row";
const moduleCheckbox = document.createElement("input");
moduleCheckbox.type = "checkbox";
moduleCheckbox.checked = !!defaultModule;
moduleRow.appendChild(moduleCheckbox);
const moduleText = document.createElement("span");
moduleText.textContent = "Save as module (left-click inserts into canvas instead of replacing)";
moduleRow.appendChild(moduleText);
body.appendChild(moduleRow);
// ---- Wiring ----
// Candidates for "Base on existing": collect every active workflow at
// the destination dir AND walk up its ancestors. So if the user picks
@@ -516,7 +536,11 @@ export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, on
// match the codebase-wide convention (`dirPath` for arrays of
// segments, `dirName` for single-segment strings, `dir` for resolved
// DirNode objects).
await onSave({ name, dirPath });
// `asModule` is the "Save as module" checkbox state. Caller is
// expected to fold an `addTag(..., MODULE_TAG)` into the same
// persistMutation as the save when truthy, so a commit failure
// rolls back both the entry write and the tag together.
await onSave({ name, dirPath, asModule: moduleCheckbox.checked });
};
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
+80 -11
View File
@@ -18,6 +18,7 @@ import {
WORKFLOWS_CHANGED_EVENT,
GROUP_MODE_KEY,
GROUP_MODE_DEFAULT,
MODULE_TAG,
ensureStyle,
toast,
compareNames,
@@ -55,6 +56,7 @@ import {
serializeSelection,
canvasIsNonEmpty,
loadWorkflowOntoCanvas,
insertWorkflowOntoCanvas,
insertNode,
getSelectedNodeTypes,
dropPlaceholdersForPacks,
@@ -1061,17 +1063,25 @@ function makeNodeLeafRow({ display, type, removable, onClick, packBadge, unresol
return row;
}
function makeWorkflowLeafRow({ name, dirName, onClick, onContextMenu, draggablePayload }) {
function makeWorkflowLeafRow({ name, dirName, onClick, onContextMenu, draggablePayload, isModule = false }) {
const row = document.createElement("div");
row.className = "koolook-row koolook-leaf";
row.title = `${dirName} / ${name}`;
// Module-tagged entries get a distinct hover tooltip so the green icon
// isn't the only signal that a left-click does something different. The
// `pi pi-plus-circle` class swap is what actually makes them look like
// "drop in" affordances rather than "open" affordances.
row.title = isModule
? `${dirName} / ${name} — module (left-click to insert into canvas)`
: `${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";
icon.className = isModule
? "pi pi-plus-circle koolook-module-icon"
: "pi pi-file koolook-leaf-icon";
row.appendChild(icon);
const nameEl = document.createElement("span");
@@ -1085,6 +1095,20 @@ function makeWorkflowLeafRow({ name, dirName, onClick, onContextMenu, draggableP
return row;
}
// Resolve module-ness from the workflow's stored tags. Computed at render
// time (cheap — small array `.includes` per row) so we don't have to widen
// the gather pipeline's row shape just to carry one boolean. Returns false
// for archived entries: archived workflows are old versions; clicking them
// in the Archive folder should still mean "load to review", not "splice an
// outdated cluster into my live canvas". The right-click menu on archived
// rows still exposes both Insert and Load if the user really wants to
// insert an archived version.
function isModuleWorkflow(dirPath, wfName) {
const tags = getWorkflowTags(dirPath, wfName);
if (!Array.isArray(tags)) return false;
return tags.includes(MODULE_TAG);
}
function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, startExpanded = true, path, forceExpanded = false, draggablePayload, dropTarget }) {
// `path` is required — every section/sub-section calling site routes
// through `makeSectionCtx`, which always supplies a non-empty
@@ -1310,6 +1334,14 @@ function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) {
label: "Load",
action: () => loadWorkflowOntoCanvas(dirPath, wfName),
},
{
// Non-destructive sibling of Load — splices the saved cluster into
// whatever's already on canvas (placed at the viewport center,
// inserted nodes selected so the user can immediately drag).
// Aborts cleanly if any referenced node type isn't installed.
label: "Insert into canvas",
action: () => insertWorkflowOntoCanvas(dirPath, wfName),
},
{
label: "Rename…",
action: () => {
@@ -1685,12 +1717,19 @@ addSection({
path: tag.name,
build: (tagCtx) => {
for (const entry of tag.entries) {
// Module-ness is per-workflow, not per-tag-folder —
// a workflow tagged `module` is still a module when
// it shows up under any other tag it carries.
const moduleEntry = isModuleWorkflow(entry.path, entry.wfName);
tagCtx.leaf({
row: makeWorkflowLeafRow({
name: entry.wfName,
dirName: entry.path.join(" / "),
onClick: () => loadWorkflowOntoCanvas(entry.path, entry.wfName),
onClick: () => moduleEntry
? insertWorkflowOntoCanvas(entry.path, entry.wfName)
: loadWorkflowOntoCanvas(entry.path, entry.wfName),
onContextMenu: (e) => workflowRowContextMenu(e, entry.path, entry.wfName, false),
isModule: moduleEntry,
}),
});
}
@@ -1747,13 +1786,21 @@ function renderGatheredDir(parentCtx, dir) {
});
}
for (const wfName of dir.active) {
const moduleEntry = isModuleWorkflow(dirPath, wfName);
dirCtx.leaf({
row: makeWorkflowLeafRow({
name: wfName,
dirName: dirDisplay,
onClick: () => loadWorkflowOntoCanvas(dirPath, wfName),
// Module convention: left-click inserts; non-module
// entries left-click loads (replaces canvas, with
// confirm modal as before). Both actions are always
// available via the right-click menu regardless.
onClick: () => moduleEntry
? insertWorkflowOntoCanvas(dirPath, wfName)
: loadWorkflowOntoCanvas(dirPath, wfName),
onContextMenu: (e) => workflowRowContextMenu(e, dirPath, wfName, false),
draggablePayload: { type: "workflow", path: dirPath, name: wfName },
isModule: moduleEntry,
}),
});
}
@@ -2085,7 +2132,13 @@ export function renderPanel(container) {
// 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, dirPath) => {
//
// `asModule` rides the same persistMutation so a commit failure rolls
// back BOTH the entry write and the tag add. Doing the addTag in a
// separate persistMutation would race: the save could land while the
// tag rejected, leaving a "module" entry the user thinks is tagged
// but isn't.
const saveAndToast = async (graph, name, dirPath, asModule = false) => {
// Pin the workflows section + every ancestor of the destination dir
// so the resulting re-render leaves the whole path open. Pins are
// consumed on the next render.
@@ -2098,12 +2151,18 @@ export function renderPanel(container) {
pinExpanded(pinKeys);
const dirDisplay = dirPath.join(" / ");
await persistMutation({
mutate: () => saveWorkflowEntry(dirPath, name, graph),
mutate: () => {
const result = saveWorkflowEntry(dirPath, name, graph);
if (!result) return false;
if (asModule) addTag(dirPath, name, MODULE_TAG);
return result;
},
onSuccess: (result) => {
const moduleSuffix = asModule ? " as module" : "";
if (result.archivedAs) {
toast(`Saved "${name}" in ${dirDisplay}. Previous version moved to Archive.`);
toast(`Saved "${name}" in ${dirDisplay}${moduleSuffix}. Previous version moved to Archive.`);
} else {
toast(`Saved "${name}" in ${dirDisplay}.`);
toast(`Saved "${name}" in ${dirDisplay}${moduleSuffix}.`);
}
},
persistFailedMessage: `Save failed — could not write "${name}". See console.`,
@@ -2128,7 +2187,12 @@ export function renderPanel(container) {
}
showSaveWorkflowModal({
titleSuffix: "entire canvas",
onSave: ({ name, dirPath }) => saveAndToast(graph, name, dirPath),
// Whole-canvas saves default to NOT being modules — the
// typical "save my whole workflow" intent is to keep the
// load-replaces-canvas semantics. The checkbox is still
// visible so a power user can opt in.
defaultModule: false,
onSave: ({ name, dirPath, asModule }) => saveAndToast(graph, name, dirPath, asModule),
});
},
}));
@@ -2154,7 +2218,12 @@ export function renderPanel(container) {
const { graph } = result;
showSaveWorkflowModal({
titleSuffix: `${graph.nodes.length} selected node${graph.nodes.length === 1 ? "" : "s"}`,
onSave: ({ name, dirPath }) => saveAndToast(graph, name, dirPath),
// Selection saves are the canonical "module" candidate —
// small clusters the user wants to splice into other
// workflows. Pre-check the box; the user can untick if
// they'd rather have replace-on-load semantics.
defaultModule: true,
onSave: ({ name, dirPath, asModule }) => saveAndToast(graph, name, dirPath, asModule),
});
},
}));