feat(sidebar): right-click menu pack — Duplicate, Tags, + New directory move (#59)
* feat(sidebar): right-click menu pack — Duplicate, Tags, + New directory move Three sibling sidebar slices bundled because they all extend the same workflow-row right-click menu in `web/sidebar/tree.js` and share a small schema/store surface in `workflows_store.js`. Refactor cleanup is held back for a separate PR (issue #47) so this diff stays a "no behavior change to existing flows" feature add. Changes ------- - Right-click "Duplicate…" → name modal with `<name> (copy)` default; deep-clones the source graph into a new entry in the same directory via `saveWorkflowEntry`. The duplicate inherits the source's tags so the user's categorization carries over. Same-name duplicates fall through to the existing archive-on-collision behavior. Closes #58. - Right-click "Tags…" → chip-row modal with add/remove. Each edit fires its own `persistMutation` so changes survive a mid-edit close. New `showTagsModal` in `modals.js` mirrors the existing `showInputModal` / `showConfirmModal` shape (escape-key teardown, overlay-click dismissal centralized in `makeModalShell`). - New Tags sidebar section between Workflows. Flat list of tag folders; each tag holds the workflows carrying it (sorted A→Z). Click loads the workflow from its real path. Archived workflows are filtered out of the section so the active view stays clean — their tags persist on the entry, so a restore brings them back. Search matches tag name OR workflow name. - Right-click "+ New directory…" / "+ New subdirectory under <path>…" in the Move-to flow. Both create a fresh directory and move the workflow into it as one atomic mutation; if the move can't land, the new directory is rolled back so the cache never leaks an empty orphan. Closes #57. Schema additions (back-compatible) ---------------------------------- - Workflow entries gain optional `tags: string[]`. `normalizeDirNode` trims, drops empties, and dedupes case-sensitively, so old entries without a tags field load as `tags: []` and the rest of the code can assume the field always exists. Pre-v0.3 stores load fine. - New exports in `workflows_store.js`: `getWorkflowTags`, `addTag`, `removeTag`. `getWorkflowGraph` is now also exported so the duplicate flow can deep-clone without reaching into the cache directly. Why one PR for three issues --------------------------- All three slices touch `workflowRowContextMenu` in `tree.js` and the mutator API in `workflows_store.js`. A coherent UX-feature scope ("next batch of workflow menu enhancements") reviews more cheaply as one diff than three near-identical PRs. Issue #47 (refactor / tidiness pass) stays separate because mixing refactor and feature in the same diff defeats the point of the split — that work is queued for a follow-up PR. Testing ------- - `python tools/preflight_release.py --skip manager-meta -v` PASS (static / dispatch / workflows fixtures, all 3 stages). - `node --input-type=module --check` passes on each modified JS file. - Browser smoke test: pending — needs a ComfyUI session for the user to verify the three menu actions and the Tags section against a real workflow store. The CI gates are Python-only and don't catch JS regressions in the sidebar by themselves. Related ------- - Closes #57 (move-to: + New directory…) - Closes #58 (Duplicate) - Implements the right-click + section parts of #56 (Tags); the issue body's `addTag` / `removeTag` / `listAllTags()` ops landed under slightly different names (`addTag`, `removeTag`, `gatherTags`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sidebar): address review findings on PR #59 Self-review pass against the validated agent findings. Five fixes total across the three issues' surface, holding refactor/file-size concerns for follow-up #47 widening. Fixes ----- - **HIGH** `normalizeDirNode` now rejects non-string `tags` elements outright instead of `String(raw).trim()` coercing them. `null` / `undefined` / `true` / `123` no longer silently surface as the literal tag strings `"null"` / `"undefined"` / etc. — `stats.dropped` increments instead so the existing console warning catches malformed migrations. Closes review HIGH-1. - **HIGH** Tags modal now distinguishes "no tags yet" from "workflow no longer exists." The `getCurrentTags` adapter no longer collapses `null` to `[]`, and `renderChips` renders an explicit gone-state with disabled add input + descriptive placeholder. Prevents users typing into a doomed modal when a concurrent action (another tab, drag-and-drop) deletes the workflow under them. Closes review HIGH-3. - **MEDIUM** Duplicate flow re-validates source existence at submit-time inside the `mutate` callback. Without it, a concurrent delete of the source between menu-open and submit would let `saveWorkflowEntry`'s `ensureDirectoryAtPath` silently re-create the parent directory and paste a clone of the captured graph — effectively undoing someone else's delete. Now returns `false` on missing source, surfacing "Could not duplicate — <name> no longer exists." Closes review MEDIUM-6. - **MEDIUM** Duplicate flow now routes inherited tags through the public `addTag` mutator instead of writing `dir.workflows[newName].tags` directly. Keeps the tree.js UI layer outside the store's internal shape. The whole sequence still rides on one `persistMutation` snapshot — commit failure rolls back save + tag adds together. Closes review MEDIUM-5. - **LOW** `gatherTags` comment block clarifies that grouping is case-sensitive but search filter is case-insensitive substring. The comment previously said "Comparison is case-sensitive" which was technically true for grouping but misleading on its own. Skipped with rationale ---------------------- - **HIGH-2 modal cache desync on persist failure** — agent analysis was wrong on trace. `renderChips` reads from cache, X-click does not optimistically remove the chip from DOM, and `restore()` reverts cache to pre-mutation. DOM and cache stay consistent across every commit-failure path. Cross-tab updates aren't reflected, but that's a project-wide cache-coherency concern, not specific to this PR. - **HIGH-4 tree.js size** — real architectural concern but follow-up only. Track on #47 widening (or a sibling extraction issue). Adding extraction in this PR would mix refactor with feature. - **MEDIUM-7 tag API asymmetry** — agent claimed `addTag`'s parameter reassignment differed from neighbors, but verified the existing convention IS parameter reassignment (`addDirectory`, `renameDirectory`, `renameWorkflow`, `moveDirectory` all do `name = (name || "").trim();`). The defensive-copy difference between `getWorkflowTags` and `getWorkflowGraph` is intentional. - **MEDIUM-8 top-level "Archive" name** — pre-existing semantic shared by toolbar Create-directory and save-modal "+ New directory." Restricting only the right-click new-dir entry would be inconsistent. Filing as a separate cross-cutting issue is the cleaner path. - **LOW magic strings / modals.js size** — both queued for #47. Validation ---------- - `python tools/preflight_release.py --skip manager-meta -v` PASS. - `node --input-type=module --check` on all three modified JS files — clean. 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:
co-authored by
Claude Opus 4.7
parent
8c1e6e3d78
commit
bebc6f618d
@@ -32,6 +32,29 @@ The format is inspired by Keep a Changelog and SemVer.
|
||||
on #47.
|
||||
|
||||
### Added
|
||||
- **Right-click "Duplicate…"** on any workflow row in the sidebar tree.
|
||||
Opens a name modal pre-filled with `<name> (copy)`; saving deep-clones
|
||||
the source graph into a new entry in the same directory. The duplicate
|
||||
inherits the source's tags so the user's categorization carries over.
|
||||
Same-name duplicates fall through to the existing archive-on-collision
|
||||
behavior in `saveWorkflowEntry`. (Closes #58.)
|
||||
- **Right-click "Tags…"** on any workflow row. Opens a chip-style modal
|
||||
to view, add, and remove the workflow's tags one at a time. Each
|
||||
edit fires its own `persistMutation` so changes survive a mid-edit
|
||||
close. (Part of #56.)
|
||||
- **Tags sidebar section.** A new section between Workflows lists every
|
||||
tag in use across the active workflow tree. Each tag becomes a folder
|
||||
whose entries are the tagged workflows (sorted A→Z); click loads the
|
||||
workflow from its real directory. Archived workflows are filtered
|
||||
out of the section so the active view stays clean — their tags are
|
||||
still preserved on the entry, so a restore from the Archive folder
|
||||
brings them back. Search matches tag name OR workflow name. (Part
|
||||
of #56.)
|
||||
- **Right-click "+ New directory…" / "+ New subdirectory under <path>…"**
|
||||
in the workflow row's Move-to flow. Both create a fresh directory and
|
||||
move the workflow into it as one atomic mutation; if the move can't
|
||||
land, the new directory is rolled back so the cache never leaks an
|
||||
empty orphan. (Closes #57.)
|
||||
- **Recursive subdirectories under workflow directories.** Right-click any
|
||||
directory in the Workflows tree → "Create subdirectory…" to nest folders
|
||||
to arbitrary depth (e.g. `UP-scale / Type-A / Sharp`). Each nested
|
||||
@@ -95,6 +118,18 @@ The format is inspired by Keep a Changelog and SemVer.
|
||||
- Reserved-name check: subdirectory names cannot be `Archive`
|
||||
(case-insensitive) at any non-root level — collides with the synthetic
|
||||
Archive folder rendered for archived workflows.
|
||||
- **Workflow entries gain optional `tags: string[]`.** `normalizeDirNode`
|
||||
trims, drops empties, and dedupes case-sensitively, so old entries
|
||||
without a tags field load as `tags: []` and the rest of the code can
|
||||
assume the field always exists. New mutators in `workflows_store.js`:
|
||||
`getWorkflowTags(path, wfName)`, `addTag(path, wfName, tag)`,
|
||||
`removeTag(path, wfName, tag)`. `getWorkflowGraph(path, wfName)` is
|
||||
now also exported so the duplicate flow can deep-clone without
|
||||
reaching into the cache directly.
|
||||
- **`showTagsModal` in `modals.js`** — chip-row UI with add/remove
|
||||
callbacks, mirrors the existing `showInputModal` / `showConfirmModal`
|
||||
surface so the modal shell, escape-key teardown, and overlay-click
|
||||
dismissal stay centralized.
|
||||
|
||||
### Documentation
|
||||
- Closed out issue #28 (de-vendor upstream code under `upscaler_FIX/`
|
||||
|
||||
@@ -107,6 +107,13 @@ export function ensureStyle() {
|
||||
.koolook-row[draggable="true"] { cursor: grab; }
|
||||
.koolook-row[draggable="true"]:active { cursor: grabbing; }
|
||||
.koolook-drop-target { background: rgba(80,140,235,0.18) !important; outline: 2px solid rgba(80,140,235,0.6); outline-offset: -2px; }
|
||||
.koolook-tags-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 4px; min-height: 22px; align-items: center; }
|
||||
.koolook-tags-empty { opacity: 0.55; font-size: 12px; }
|
||||
.koolook-tag-chip { display: inline-flex; align-items: center; gap: 4px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; padding: 2px 4px 2px 9px; font-size: 11px; line-height: 1.4; }
|
||||
.koolook-tag-chip-x { cursor: pointer; opacity: 0.6; padding: 0 4px; font-size: 13px; line-height: 1; border-radius: 50%; }
|
||||
.koolook-tag-chip-x:hover { opacity: 1; color: #ff7777; background: rgba(255,255,255,0.06); }
|
||||
.koolook-tag-add-row { display: flex; gap: 6px; align-items: center; }
|
||||
.koolook-tag-add-row .koolook-modal-input { flex: 1; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
@@ -502,6 +502,119 @@ export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, on
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tags modal — adds/removes tags one at a time, each via its own
|
||||
// persistMutation (passed in by the caller). The modal itself stays open until
|
||||
// the user clicks Close, so several edits can be made in a row. Chip rendering
|
||||
// reads from `getCurrentTags` after every successful mutation, so the live
|
||||
// state matches what the caller persisted.
|
||||
//
|
||||
// `getCurrentTags()` returns `null` when the underlying workflow no longer
|
||||
// exists (deleted/moved/renamed by a concurrent action). We surface this as
|
||||
// an explicit "gone" state — empty placeholder + disabled add input — so the
|
||||
// user doesn't keep typing tags into a modal that can't persist them.
|
||||
// =============================================================================
|
||||
export function showTagsModal({ wfName, getCurrentTags, onAddTag, onRemoveTag }) {
|
||||
const body = document.createElement("div");
|
||||
|
||||
const lbl = document.createElement("label");
|
||||
lbl.className = "koolook-modal-label";
|
||||
lbl.textContent = "Current tags";
|
||||
body.appendChild(lbl);
|
||||
|
||||
const chipsContainer = document.createElement("div");
|
||||
chipsContainer.className = "koolook-tags-chips";
|
||||
body.appendChild(chipsContainer);
|
||||
|
||||
let input;
|
||||
let addBtn;
|
||||
function renderChips() {
|
||||
chipsContainer.innerHTML = "";
|
||||
const tags = getCurrentTags();
|
||||
if (tags === null) {
|
||||
// Workflow disappeared between menu-open and now. Stay open so
|
||||
// the user sees the explicit reason instead of a generic "no
|
||||
// tags" placeholder, and disable the add path so a second click
|
||||
// can't fire a doomed persistMutation.
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "koolook-tags-empty";
|
||||
empty.textContent = "Workflow no longer exists.";
|
||||
chipsContainer.appendChild(empty);
|
||||
if (input) {
|
||||
input.disabled = true;
|
||||
input.placeholder = "(workflow gone)";
|
||||
}
|
||||
if (addBtn) addBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (tags.length === 0) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "koolook-tags-empty";
|
||||
empty.textContent = "No tags yet.";
|
||||
chipsContainer.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const tag of tags) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "koolook-tag-chip";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = tag;
|
||||
chip.appendChild(name);
|
||||
const x = document.createElement("span");
|
||||
x.className = "koolook-tag-chip-x";
|
||||
x.textContent = "×";
|
||||
x.title = `Remove "${tag}"`;
|
||||
x.addEventListener("click", () => {
|
||||
onRemoveTag(tag, () => renderChips());
|
||||
});
|
||||
chip.appendChild(x);
|
||||
chipsContainer.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
const addLbl = document.createElement("label");
|
||||
addLbl.className = "koolook-modal-label";
|
||||
addLbl.textContent = "Add tag";
|
||||
body.appendChild(addLbl);
|
||||
|
||||
const addRow = document.createElement("div");
|
||||
addRow.className = "koolook-tag-add-row";
|
||||
body.appendChild(addRow);
|
||||
|
||||
input = document.createElement("input");
|
||||
input.className = "koolook-modal-input";
|
||||
input.placeholder = "tag name";
|
||||
addRow.appendChild(input);
|
||||
|
||||
const submit = () => {
|
||||
if (input.disabled) return;
|
||||
const v = input.value.trim();
|
||||
if (!v) { input.focus(); return; }
|
||||
onAddTag(v, () => {
|
||||
input.value = "";
|
||||
renderChips();
|
||||
input.focus();
|
||||
});
|
||||
};
|
||||
input.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||||
|
||||
addBtn = makeModalButton({ label: "Add", primary: true, onClick: submit });
|
||||
addRow.appendChild(addBtn);
|
||||
|
||||
// Now that input + addBtn exist, run the initial render so the
|
||||
// gone-state branch can disable them if the workflow is already missing.
|
||||
renderChips();
|
||||
|
||||
let overlay;
|
||||
const close = makeModalButton({ label: "Close", onClick: () => overlay.remove() });
|
||||
({ overlay } = makeModalShell({
|
||||
title: `Tags for "${wfName}"`,
|
||||
body,
|
||||
actions: [close],
|
||||
}));
|
||||
setTimeout(() => { if (!input.disabled) input.focus(); }, 0);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context menu helper
|
||||
// =============================================================================
|
||||
|
||||
+221
-1
@@ -44,6 +44,10 @@ import {
|
||||
moveDirectory,
|
||||
clearArchive,
|
||||
pathsEqual,
|
||||
getWorkflowGraph,
|
||||
getWorkflowTags,
|
||||
addTag,
|
||||
removeTag,
|
||||
} from "./workflows_store.js";
|
||||
import {
|
||||
serializeFullCanvas,
|
||||
@@ -58,6 +62,7 @@ import {
|
||||
showConfirmModal,
|
||||
showSaveWorkflowModal,
|
||||
showContextMenu,
|
||||
showTagsModal,
|
||||
} from "./modals.js";
|
||||
|
||||
// =============================================================================
|
||||
@@ -564,6 +569,55 @@ function countDescendantsOfRawDir(dir) {
|
||||
return { workflows, subdirs };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Data gathering — tags (DFS walk over the workflow tree).
|
||||
//
|
||||
// Each output entry has shape:
|
||||
// { name, entries: [{ wfName, path }, ...] }
|
||||
// `path` is the full directory string[] address of the workflow's home dir.
|
||||
// Archived workflows are skipped here so the Tags section reflects the
|
||||
// active set only — their tags are still persisted on the entry, so a
|
||||
// restore (via the Archive folder's right-click) brings them back into
|
||||
// the section. Tag grouping is case-sensitive: "AI" and "ai" produce
|
||||
// separate tag folders (the dedupe in `normalizeDirNode` is also
|
||||
// case-sensitive). The search filter is case-insensitive substring match
|
||||
// against tag name OR workflow name — typing a tag narrows to that group,
|
||||
// typing a workflow name keeps every tag the workflow carries.
|
||||
// =============================================================================
|
||||
function gatherTags(query) {
|
||||
const q = (query || "").trim().toLowerCase();
|
||||
const tagMap = new Map();
|
||||
|
||||
const walk = (parentPath) => {
|
||||
for (const dirName of listDirectoryNames(parentPath)) {
|
||||
const dirPath = [...parentPath, dirName];
|
||||
const dir = dirOf(dirPath);
|
||||
if (!dir) continue;
|
||||
for (const [wfName, wf] of Object.entries(dir.workflows || {})) {
|
||||
if (wf.archived === true) continue;
|
||||
const tags = Array.isArray(wf.tags) ? wf.tags : [];
|
||||
if (tags.length === 0) continue;
|
||||
for (const tag of tags) {
|
||||
if (q && !tag.toLowerCase().includes(q) && !wfName.toLowerCase().includes(q)) continue;
|
||||
if (!tagMap.has(tag)) tagMap.set(tag, []);
|
||||
tagMap.get(tag).push({ wfName, path: dirPath });
|
||||
}
|
||||
}
|
||||
walk(dirPath);
|
||||
}
|
||||
};
|
||||
walk([]);
|
||||
|
||||
const tags = [...tagMap.entries()]
|
||||
.map(([name, entries]) => ({
|
||||
name,
|
||||
entries: entries.sort((a, b) => compareNames(a.wfName, b.wfName)),
|
||||
}))
|
||||
.sort((a, b) => compareNames(a.name, b.name));
|
||||
const total = tags.reduce((acc, t) => acc + t.entries.length, 0);
|
||||
return { tags, total };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DOM helpers
|
||||
// =============================================================================
|
||||
@@ -765,6 +819,45 @@ function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) {
|
||||
}),
|
||||
}));
|
||||
|
||||
// "+ New directory…" / "+ New subdirectory under …" both create a fresh
|
||||
// directory and move the workflow into it as one atomic mutation. If
|
||||
// the move couldn't land we manually undo the dir add so we don't leak
|
||||
// an empty orphan; in practice that branch is unreachable (the new dir
|
||||
// is empty so the move can't collide), but the explicit cleanup keeps a
|
||||
// future regression from silently leaving stray directories behind.
|
||||
function moveToNewDirAction(parentPath, parentLabel) {
|
||||
showInputModal({
|
||||
title: parentLabel
|
||||
? `New subdirectory under "${parentLabel}"`
|
||||
: "New top-level directory",
|
||||
label: "Name",
|
||||
placeholder: "e.g. drafts",
|
||||
confirmLabel: "Create & Move",
|
||||
onSubmit: (name) => persistMutation({
|
||||
mutate: () => {
|
||||
if (!addDirectory(parentPath, name)) return false;
|
||||
const target = [...parentPath, name];
|
||||
if (!moveWorkflow(dirPath, wfName, target)) {
|
||||
deleteDirectory(parentPath, name);
|
||||
return false;
|
||||
}
|
||||
return target;
|
||||
},
|
||||
onSuccess: (target) => toast(`Moved "${wfName}" to ${target.join(" / ")}.`),
|
||||
onNoOp: () => toast(`Could not create — name in use, empty, or "Archive" reserved.`),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const newDirItem = {
|
||||
label: "+ New directory…",
|
||||
action: () => moveToNewDirAction([], null),
|
||||
};
|
||||
const newSubdirItem = {
|
||||
label: `+ New subdirectory under "${dirPath.join(" / ")}"…`,
|
||||
action: () => moveToNewDirAction(dirPath, dirPath.join(" / ")),
|
||||
};
|
||||
|
||||
const archiveItem = isArchived
|
||||
? {
|
||||
label: "Restore from archive",
|
||||
@@ -781,6 +874,84 @@ function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) {
|
||||
}),
|
||||
};
|
||||
|
||||
const duplicateItem = {
|
||||
label: "Duplicate…",
|
||||
action: () => {
|
||||
const sourceGraph = getWorkflowGraph(dirPath, wfName);
|
||||
if (!sourceGraph) {
|
||||
toast("Could not duplicate — workflow not found.");
|
||||
return;
|
||||
}
|
||||
// Capture tags at modal-open time so a same-name duplicate (which
|
||||
// archives the original via `saveWorkflowEntry`) still inherits
|
||||
// the source's categorization onto the new active entry.
|
||||
const sourceTags = getWorkflowTags(dirPath, wfName) || [];
|
||||
showInputModal({
|
||||
title: "Duplicate workflow",
|
||||
label: "New name",
|
||||
defaultValue: `${wfName} (copy)`,
|
||||
confirmLabel: "Duplicate",
|
||||
onSubmit: (newName) => persistMutation({
|
||||
mutate: () => {
|
||||
// Re-validate at submit-time. A concurrent action
|
||||
// (another tab, drag-and-drop) could have deleted
|
||||
// the source between menu-open and submit. Without
|
||||
// this guard, `saveWorkflowEntry` would call
|
||||
// `ensureDirectoryAtPath` and silently re-create
|
||||
// the parent directory with a clone of the
|
||||
// captured graph — effectively undoing someone
|
||||
// else's delete with no feedback.
|
||||
if (getWorkflowGraph(dirPath, wfName) === null) return false;
|
||||
const cloned = JSON.parse(JSON.stringify(sourceGraph));
|
||||
const result = saveWorkflowEntry(dirPath, newName, cloned);
|
||||
if (!result) return false;
|
||||
// Route tag inheritance through the public `addTag`
|
||||
// mutator instead of writing `wf.tags` directly, so
|
||||
// the Duplicate path stays inside the documented
|
||||
// store API. The whole sequence rides on the same
|
||||
// persistMutation snapshot — commit failure rolls
|
||||
// back the save AND the tag adds together.
|
||||
for (const t of sourceTags) addTag(dirPath, newName, t);
|
||||
return result;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result && result.archivedAs) {
|
||||
toast(`Duplicated to "${newName}" — previous "${newName}" archived as "${result.archivedAs}".`);
|
||||
} else {
|
||||
toast(`Duplicated to "${newName}".`);
|
||||
}
|
||||
},
|
||||
onNoOp: () => toast(`Could not duplicate — "${wfName}" no longer exists.`),
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const tagsItem = {
|
||||
label: "Tags…",
|
||||
action: () => {
|
||||
showTagsModal({
|
||||
wfName,
|
||||
// Pass the raw `null` through so the modal can render an
|
||||
// explicit gone-state when the workflow is deleted/moved/
|
||||
// renamed by a concurrent action. Collapsing to `[]` would
|
||||
// silently morph "workflow gone" into "no tags yet" and
|
||||
// accept doomed Add inputs.
|
||||
getCurrentTags: () => getWorkflowTags(dirPath, wfName),
|
||||
onAddTag: (tag, onDone) => persistMutation({
|
||||
mutate: () => addTag(dirPath, wfName, tag),
|
||||
onSuccess: () => { onDone(); toast(`Tagged "${wfName}" with "${tag}".`); },
|
||||
onNoOp: () => toast(`Could not add — empty or already tagged.`),
|
||||
}),
|
||||
onRemoveTag: (tag, onDone) => persistMutation({
|
||||
mutate: () => removeTag(dirPath, wfName, tag),
|
||||
onSuccess: () => { onDone(); toast(`Removed tag "${tag}" from "${wfName}".`); },
|
||||
onNoOp: () => toast(`Tag "${tag}" was not present.`),
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
showContextMenu(event, [
|
||||
{
|
||||
label: "Load",
|
||||
@@ -802,8 +973,19 @@ function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) {
|
||||
});
|
||||
},
|
||||
},
|
||||
duplicateItem,
|
||||
tagsItem,
|
||||
archiveItem,
|
||||
...(moveItems.length > 0 ? [null, ...moveItems] : []),
|
||||
// Move section — separator first; existing-path entries (when any),
|
||||
// then a separator, then the always-available new-dir / new-subdir
|
||||
// entries. Skipping the inner separator when there are no existing
|
||||
// paths keeps the menu visually clean for a fresh store with one
|
||||
// directory.
|
||||
null,
|
||||
...moveItems,
|
||||
...(moveItems.length > 0 ? [null] : []),
|
||||
newDirItem,
|
||||
newSubdirItem,
|
||||
null,
|
||||
{
|
||||
label: "Delete",
|
||||
@@ -995,6 +1177,44 @@ addSection({
|
||||
},
|
||||
});
|
||||
|
||||
// Tags section — flat list of tag folders, each containing the workflows
|
||||
// carrying that tag. Workflows that share a tag appear under the tag folder
|
||||
// regardless of which directory they live in. Click loads the workflow from
|
||||
// its real path; the right-click menu operates on the workflow at its
|
||||
// canonical location, so renames/moves originating here update the same
|
||||
// underlying entry the Workflows section shows.
|
||||
addSection({
|
||||
id: "tags",
|
||||
label: "Tags",
|
||||
iconKind: "folder",
|
||||
// No emptyMessage — until any workflow carries a tag, the section stays
|
||||
// silent rather than nag the user about an empty feature.
|
||||
gather(q) {
|
||||
return gatherTags(q);
|
||||
},
|
||||
build(ctx) {
|
||||
for (const tag of ctx.data.tags) {
|
||||
ctx.folder({
|
||||
name: tag.name,
|
||||
count: tag.entries.length,
|
||||
path: tag.name,
|
||||
build: (tagCtx) => {
|
||||
for (const entry of tag.entries) {
|
||||
tagCtx.leaf({
|
||||
row: makeWorkflowLeafRow({
|
||||
name: entry.wfName,
|
||||
dirName: entry.path.join(" / "),
|
||||
onClick: () => loadWorkflowOntoCanvas(entry.path, entry.wfName),
|
||||
onContextMenu: (e) => workflowRowContextMenu(e, entry.path, entry.wfName, false),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Render one gathered directory entry (and its descendants) under
|
||||
// `parentCtx`. Layout per directory: subdirectories first (so their own
|
||||
// trees nest under the parent), then the synthetic Archive folder if any
|
||||
|
||||
@@ -61,12 +61,34 @@ function normalizeDirNode(node, stats) {
|
||||
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.
|
||||
// can't accidentally flag an entry as archived. Coerce `tags` to a
|
||||
// clean string[] (trim, drop empties, dedupe) so old entries without
|
||||
// a tags field load with `tags: []` and the rest of the code can
|
||||
// assume the field always exists.
|
||||
if (!wf || typeof wf !== "object" || !wf.graph || typeof wf.graph !== "object") {
|
||||
stats.dropped += 1;
|
||||
continue;
|
||||
}
|
||||
cleanedWfs[wfName] = { ...wf, archived: wf.archived === true };
|
||||
const tags = [];
|
||||
if (Array.isArray(wf.tags)) {
|
||||
const seen = new Set();
|
||||
for (const raw of wf.tags) {
|
||||
// Reject non-strings outright instead of coercing — `String(null)`
|
||||
// becomes the literal string `"null"`, which would silently
|
||||
// surface as a garbage tag in the Tags section. The schema
|
||||
// contract is `string[]`; anything else is corruption from a
|
||||
// hand-edited /userdata file or a bug elsewhere.
|
||||
if (typeof raw !== "string") {
|
||||
stats.dropped += 1;
|
||||
continue;
|
||||
}
|
||||
const t = raw.trim();
|
||||
if (!t || seen.has(t)) continue;
|
||||
seen.add(t);
|
||||
tags.push(t);
|
||||
}
|
||||
}
|
||||
cleanedWfs[wfName] = { ...wf, archived: wf.archived === true, tags };
|
||||
}
|
||||
// Recurse into subdirectories. Pre-v0.3 nodes don't have a `directories`
|
||||
// field — give them an empty one so the rest of the code can assume it
|
||||
@@ -505,6 +527,43 @@ export function getWorkflowGraph(path, wfName) {
|
||||
return dir.workflows[wfName].graph || null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Per-workflow tag operations. Tags are insertion-ordered string[] on each
|
||||
// workflow entry. Comparison is case-sensitive: "AI" and "ai" are distinct.
|
||||
// `normalizeDirNode` guarantees `tags` is always an array on cached entries,
|
||||
// but the mutators still defensively coerce so a freshly-saved workflow that
|
||||
// hasn't gone through normalize still gets sane behavior.
|
||||
// =============================================================================
|
||||
export function getWorkflowTags(path, wfName) {
|
||||
const dir = dirOf(path);
|
||||
if (!dir || !dir.workflows[wfName]) return null;
|
||||
const tags = dir.workflows[wfName].tags;
|
||||
return Array.isArray(tags) ? [...tags] : [];
|
||||
}
|
||||
|
||||
export function addTag(path, wfName, tag) {
|
||||
tag = (tag || "").trim();
|
||||
if (!tag) return false;
|
||||
const dir = dirOf(path);
|
||||
if (!dir || !dir.workflows[wfName]) return false;
|
||||
const wf = dir.workflows[wfName];
|
||||
if (!Array.isArray(wf.tags)) wf.tags = [];
|
||||
if (wf.tags.includes(tag)) return false;
|
||||
wf.tags.push(tag);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeTag(path, wfName, tag) {
|
||||
const dir = dirOf(path);
|
||||
if (!dir || !dir.workflows[wfName]) return false;
|
||||
const wf = dir.workflows[wfName];
|
||||
if (!Array.isArray(wf.tags)) return false;
|
||||
const idx = wf.tags.indexOf(tag);
|
||||
if (idx < 0) return false;
|
||||
wf.tags.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Delete every archived workflow under the directory at `path`. Returns
|
||||
// `{ count }` (number of entries removed) on success, `false` if the
|
||||
// directory is missing or has no archived entries. Active (non-archived)
|
||||
|
||||
Reference in New Issue
Block a user