diff --git a/CHANGELOG.md b/CHANGELOG.md index 5402a8a..852c98a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` (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 …"** + 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/` diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index 71d711b..56bcfee 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -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); } diff --git a/web/sidebar/modals.js b/web/sidebar/modals.js index b8b0180..8849f13 100644 --- a/web/sidebar/modals.js +++ b/web/sidebar/modals.js @@ -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 // ============================================================================= diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index 2f3a7b6..2f81a39 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -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 diff --git a/web/sidebar/workflows_store.js b/web/sidebar/workflows_store.js index f0196ba..7482de1 100644 --- a/web/sidebar/workflows_store.js +++ b/web/sidebar/workflows_store.js @@ -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)