feat(sidebar): Snapshot Compare — read-only A/B side-by-side with diff tint (#181) (#183)

Phase 1 of #181: read-only side-by-side Compare. A/B toggle renders the existing sidebar twice — live working copy (A) + a loaded snapshot (B), B labeled with its own name + diff-tinted (green=only in comparison, red=same name/graph differs ignoring savedAt). Non-destructive: read-only capture guard on B, onChoose chooser never calls applySnapshot. Phase 2 (pull-in + A<->B swap) tracked in #197.
This commit is contained in:
malkuthro
2026-06-02 08:22:15 +02:00
committed by GitHub
parent b706a94030
commit b5b46d7fcc
9 changed files with 648 additions and 23 deletions
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import subprocess
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def test_compare_load_path_returns_before_applysnapshot() -> None:
# Spec #181 hard constraint: "no applySnapshot() call on the compare path
# (static guard test)." The Load dialog doubles as the read-only compare
# chooser via `onChoose`; both load functions MUST short-circuit (return) in
# onChoose mode BEFORE reaching the destructive applySnapshot. Pin that
# ordering so a future refactor can't silently reintroduce a destructive
# load on the compare path.
src = (REPO_ROOT / "web" / "sidebar" / "modals.js").read_text(encoding="utf-8")
for fn in ("doNamedLoad", "doAutosaveRestore"):
fn_start = src.index(f"async function {fn}(")
choose_idx = src.index("if (onChoose)", fn_start)
# Match the actual call (with paren), not the word in a comment.
apply_idx = src.index("applySnapshot(", fn_start)
assert choose_idx < apply_idx, (
f"{fn}: the onChoose compare-mode guard must precede applySnapshot"
)
assert "return;" in src[choose_idx:apply_idx], (
f"{fn}: the onChoose guard must return before reaching applySnapshot"
)
def test_enter_compare_mode_uses_readonly_chooser() -> None:
# The compare ENTRY path opens the existing Load dialog in read-only choose
# mode (onChoose), never the destructive apply path.
src = (REPO_ROOT / "web" / "sidebar" / "tree.js").read_text(encoding="utf-8")
start = src.index("function enterCompareMode(")
body = src[start:start + 1400]
assert "showLoadSnapshotDialog(" in body
assert "onChoose:" in body, (
"enterCompareMode must open the Load dialog in read-only choose mode"
)
def _compare_guard_block(src: str) -> str:
# The read-only capture-guard block lives between the NAV_ALLOW declaration
# and the "Forward declarations" marker that follows the `if (compare)` block.
start = src.index("const NAV_ALLOW =")
return src[start:src.index("// Forward declarations", start)]
def test_compare_panel_guard_blocks_drag_drop_fully() -> None:
# #181 read-only guarantee: the comparison panel must neutralize cross-panel
# drag-and-drop, not just dragstart. A drag begun in the live panel and
# dropped on a comparison folder row would otherwise persist a live
# move/archive (handleDndDrop -> persistMutation on the live store).
guard = _compare_guard_block(
(REPO_ROOT / "web" / "sidebar" / "tree.js").read_text(encoding="utf-8")
)
for evt in ("click", "contextmenu", "dragstart", "dragover", "drop"):
assert f'addEventListener("{evt}"' in guard, (
f"compare read-only guard is missing a capture handler for '{evt}'"
)
# Every guard listener must be capture-phase (third arg `true`).
assert guard.count(", true)") >= 5, (
"all compare-guard listeners must be registered capture-phase"
)
def test_compare_nav_allow_excludes_group_mode_toggle() -> None:
# The grouping mode toggle writes a shared global key (GROUP_MODE_KEY), so it
# must NOT be allow-listed in the read-only comparison panel.
src = (REPO_ROOT / "web" / "sidebar" / "tree.js").read_text(encoding="utf-8")
start = src.index("const NAV_ALLOW =")
nav = src[start:src.index(";", start)]
assert "koolook-mode-toggle" not in nav, (
"grouping mode toggle writes a shared global key; drop it from NAV_ALLOW"
)
def run_node_scenario(source: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["node", "--input-type=module"],
input=source,
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
def test_picks_render_source_override_is_read_only() -> None:
# Compare mode feeds the comparison panel a snapshot's picks via a
# render-source override. It must SERVE the override to loadUserPicks but
# never persist it — clearing returns to live picks (empty under Node,
# which has no localStorage). Proves the non-destructive guarantee.
proc = run_node_scenario(
textwrap.dedent(
"""
import assert from "node:assert/strict";
import {
setPicksRenderSource,
clearPicksRenderSource,
loadUserPicks,
} from "./web/sidebar/picks_store.js";
assert.deepEqual(loadUserPicks(), []); // live (no localStorage in Node)
setPicksRenderSource(["NodeA", "NodeB"]);
assert.deepEqual(loadUserPicks(), ["NodeA", "NodeB"]); // override served
clearPicksRenderSource();
assert.deepEqual(loadUserPicks(), []); // live again, untouched
"""
)
)
assert proc.returncode == 0, proc.stderr
def test_workflows_render_source_override_does_not_mutate_live() -> None:
proc = run_node_scenario(
textwrap.dedent(
"""
import assert from "node:assert/strict";
import {
setWorkflowsRenderSource,
clearWorkflowsRenderSource,
dirOf,
getAllWorkflowsForExport,
} from "./web/sidebar/workflows_store.js";
const liveBefore = JSON.stringify(getAllWorkflowsForExport());
setWorkflowsRenderSource({
directories: { Cmp: { workflows: { w: { graph: {} } }, directories: {} } },
});
assert.ok(dirOf(["Cmp"])); // reads resolve against the override
clearWorkflowsRenderSource();
// The live export (workflowsCache) is never touched by the override.
assert.equal(JSON.stringify(getAllWorkflowsForExport()), liveBefore);
assert.equal(dirOf(["Cmp"]), undefined); // override gone -> not in live store
"""
)
)
assert proc.returncode == 0, proc.stderr
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import subprocess
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def run_node_scenario(source: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["node", "--input-type=module"],
input=source,
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
def test_diff_picks_partitions_working_comparison_and_shared() -> None:
proc = run_node_scenario(
textwrap.dedent(
"""
import assert from "node:assert/strict";
import { diffPicks } from "./web/sidebar/snapshot_diff.js";
const r = diffPicks(["x", "y"], ["y", "z"]);
assert.deepEqual(r.onlyWorking, ["x"]);
assert.deepEqual(r.onlyComparison, ["z"]);
assert.deepEqual(r.shared, ["y"]);
"""
)
)
assert proc.returncode == 0, proc.stderr
def test_diff_workflows_classifies_comparison_workflows_by_presence() -> None:
proc = run_node_scenario(
textwrap.dedent(
"""
import assert from "node:assert/strict";
import { diffWorkflows } from "./web/sidebar/snapshot_diff.js";
const working = {
directories: {
Basics: { workflows: { txt2img: { graph: { a: 1 } } }, directories: {} },
},
};
const comparison = {
directories: {
Basics: {
workflows: {
txt2img: { graph: { a: 1 } },
img2img: { graph: { b: 2 } },
},
directories: {},
},
},
};
const status = diffWorkflows(working, comparison);
// present only in the comparison -> green/new
assert.equal(status["Basics/img2img"], "new");
// present in both, identical graph -> plain/same
assert.equal(status["Basics/txt2img"], "same");
"""
)
)
assert proc.returncode == 0, proc.stderr
def test_diff_workflows_compares_graph_ignoring_saved_at_and_key_order() -> None:
proc = run_node_scenario(
textwrap.dedent(
"""
import assert from "node:assert/strict";
import { diffWorkflows } from "./web/sidebar/snapshot_diff.js";
const working = { directories: { D: { workflows: {
changed: { graph: { nodes: [1, 2] }, savedAt: "2026-01-01T00:00:00Z" },
unchanged: { graph: { a: 1, b: 2 }, savedAt: "2026-01-01T00:00:00Z" },
}, directories: {} } } };
const comparison = { directories: { D: { workflows: {
changed: { graph: { nodes: [1, 2, 3] }, savedAt: "2026-05-29T00:00:00Z" },
unchanged: { graph: { b: 2, a: 1 }, savedAt: "2026-05-29T12:34:56Z" },
}, directories: {} } } };
const status = diffWorkflows(working, comparison);
// graph content differs -> red/diff
assert.equal(status["D/changed"], "diff");
// only savedAt + key order differ -> plain/same (savedAt is volatile)
assert.equal(status["D/unchanged"], "same");
"""
)
)
assert proc.returncode == 0, proc.stderr
+2 -2
View File
@@ -35,7 +35,7 @@ import {
} from "./sidebar/workflows_store.js";
import { loadUserPicks } from "./sidebar/picks_store.js";
import { showConfirmModal } from "./sidebar/modals.js";
import { renderPanel } from "./sidebar/tree.js";
import { renderSidebar } from "./sidebar/tree.js";
import { patchCanvasMenu } from "./sidebar/canvas_menu.js";
function wireOfflineFallbackRecovery(initialBlob) {
@@ -192,7 +192,7 @@ app.registerExtension({
tooltip: TAB_TOOLTIP,
icon: TAB_ICON,
type: "custom",
render: (el) => renderPanel(el),
render: (el) => renderSidebar(el),
});
patchCanvasMenu();
// Periodic defensive auto-save — fires every 5 minutes if state has
+11
View File
@@ -127,6 +127,7 @@ export function ensureStyle() {
.koolook-snap-status-autosaved { background: #6db4ff; }
.koolook-snap-status-unsaved { background: #e0a64a; }
.koolook-snap-status-none { background: rgba(255,255,255,0.28); }
.koolook-snap-status-comparing { background: #6db4ff; }
.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; }
@@ -359,6 +360,16 @@ export function ensureStyle() {
/* Inline delete state outlines the target row in red. Reminder: no
backticks in comments inside this CSS template literal. */
.koolook-snapshot-row-pending-delete { outline: 1.5px solid rgba(220, 80, 80, 0.75); outline-offset: -1px; background: rgba(220, 80, 80, 0.06); }
/* Compare mode (issue 181): two live sidebars side by side, plus a bottom
status bar. The columns reuse the normal koolook-sidebar render verbatim.
Reminder: no backticks in comments inside this CSS template literal. */
.koolook-compare-host { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.koolook-compare-split { display: flex; flex: 1 1 auto; gap: 8px; min-height: 0; overflow: auto; padding: 0 4px; }
.koolook-compare-col { flex: 1 1 0; min-width: 0; }
.koolook-compare-status { flex: 0 0 auto; padding: 5px 10px; font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; text-align: center; color: rgba(170, 200, 255, 0.95); background: rgba(80, 140, 235, 0.18); border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); }
/* Text-only diff tint, applied only to the comparison column's rows. */
.koolook-cmp-new .koolook-name { color: #7be08a; }
.koolook-cmp-diff .koolook-name { color: #ff7d7d; }
`;
document.head.appendChild(s);
}
+29
View File
@@ -1449,6 +1449,10 @@ export function showLoadSnapshotDialog({
listAutosaves,
revealPresetFolder,
onToast,
// Compare mode (#181): when provided, selecting a preset (or an autosave)
// calls onChoose(snapshot, meta) and closes — instead of the destructive
// applySnapshot load. Default undefined keeps the normal Load flow intact.
onChoose,
}) {
const toast = onToast || (() => {});
const body = document.createElement("div");
@@ -1606,6 +1610,19 @@ export function showLoadSnapshotDialog({
// case, and no YES/NO choice modal at all). ``close()`` here refers
// to the OUTER Load Snapshot dialog's overlay.
async function doNamedLoad(preview) {
// Compare mode: read-only — hand the parsed snapshot back and close.
// No pre-load auto-save, no applySnapshot, no tracker change.
if (onChoose) {
try {
const snap = await readPreset(preview.fileName);
close();
onChoose(snap, { fileName: preview.fileName, displayName: preview.displayName });
} catch (e) {
console.error("[Koolook] preset read (compare) failed:", e);
toast(`Could not read "${preview.displayName}": ${e.message}`);
}
return;
}
try {
// Defensive auto-save BEFORE any destructive write — see
// `writePreLoadAutosave` rationale in snapshot.js. If the
@@ -1667,6 +1684,18 @@ export function showLoadSnapshotDialog({
async function doAutosaveRestore(item) {
const tooltipName = `${item.dir}/${item.fileName}`;
// Compare mode: read-only — hand the parsed snapshot back and close.
if (onChoose) {
try {
const snap = await readPreset(item.fileName, { dir: item.dir });
close();
onChoose(snap, { fileName: item.fileName, displayName: tooltipName });
} catch (e) {
console.error("[Koolook] autosave read (compare) failed:", e);
toast(`Could not read "${tooltipName}": ${e.message}`);
}
return;
}
try {
let backupName = null;
if (typeof writePreLoadAutosave === "function") {
+17
View File
@@ -10,7 +10,24 @@ import {
AUTOPULL_HIDDEN_KEY,
} from "./constants.js";
// Read-only render-source override (issue #181, Compare mode). When set, the
// render path (`loadUserPicks`) returns this list instead of the user's live
// localStorage picks, so the Compare view can render a *second* sidebar from a
// loaded snapshot without touching live state. Set only synchronously around a
// read-only render and always cleared afterwards (see `withSnapshotSource` in
// tree.js); never written back to localStorage.
let renderSourcePicks = null;
export function setPicksRenderSource(picks) {
renderSourcePicks = Array.isArray(picks) ? picks.filter(p => typeof p === "string") : [];
}
export function clearPicksRenderSource() {
renderSourcePicks = null;
}
export function loadUserPicks() {
if (renderSourcePicks) return [...renderSourcePicks];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
+70
View File
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
// SPDX-License-Identifier: GPL-3.0-only
import { sortJsonValue } from "./workflows_store.js";
// Pure snapshot-comparison helpers for the Compare-mode sidebar (issue #181).
// No DOM, no globals, no I/O — every function takes plain data and returns
// plain data so it can be unit-tested by piping a scenario through Node. The
// Compare view renders the existing sidebar a second time and uses these
// results purely to tint comparison rows (green = only in comparison, red =
// same key, contents differ); it never mutates the working state.
// Partition two pick lists (arrays of string identifiers) against each other.
// Order follows the `working` list for `onlyWorking`/`shared` and the
// `comparison` list for `onlyComparison`. Membership is by set, so duplicates
// collapse to a single classification.
export function diffPicks(working, comparison) {
const workingSet = new Set(working);
const comparisonSet = new Set(comparison);
return {
onlyWorking: working.filter((id) => !comparisonSet.has(id)),
onlyComparison: comparison.filter((id) => !workingSet.has(id)),
shared: working.filter((id) => comparisonSet.has(id)),
};
}
// Flatten a workflows store ({ directories: { [name]: DirNode } }, the shape
// returned by getAllWorkflowsForExport / stored under snapshot.workflows) into
// a Map of full path ("Basics/txt2img", "Basics/Sub/wf") -> WorkflowEntry.
function collectWorkflows(node, prefix, out) {
const dirs = node && typeof node.directories === "object" && node.directories ? node.directories : {};
for (const dirName of Object.keys(dirs)) {
const child = dirs[dirName];
if (!child || typeof child !== "object") continue;
const childPrefix = `${prefix}${dirName}/`;
const wfs = child.workflows && typeof child.workflows === "object" ? child.workflows : {};
for (const wfName of Object.keys(wfs)) {
out.set(`${childPrefix}${wfName}`, wfs[wfName]);
}
collectWorkflows(child, childPrefix, out);
}
return out;
}
// Two workflow graphs are equal when their canonical (key-order-normalized)
// JSON matches. Comparing the `graph` subtree alone ignores the sibling
// `savedAt` stamp, which is rewritten on every save and would otherwise make
// every re-saved workflow read as changed.
function graphsEqual(a, b) {
return JSON.stringify(sortJsonValue(a)) === JSON.stringify(sortJsonValue(b));
}
// Classify every workflow in the comparison store against the working store,
// keyed by full path:
// "new" -> present only in the comparison (green tint)
// "diff" -> present in both, graph differs ignoring savedAt (red tint)
// "same" -> present in both, identical graph (plain)
export function diffWorkflows(workingStore, comparisonStore) {
const working = collectWorkflows(workingStore, "", new Map());
const comparison = collectWorkflows(comparisonStore, "", new Map());
const status = {};
for (const [path, entry] of comparison) {
if (!working.has(path)) {
status[path] = "new";
} else {
status[path] = graphsEqual(entry.graph, working.get(path).graph) ? "same" : "diff";
}
}
return status;
}
+244 -17
View File
@@ -36,6 +36,8 @@ import {
addToMyPicks,
loadAutoPullHidden,
hideAutoPullType,
setPicksRenderSource,
clearPicksRenderSource,
} from "./picks_store.js";
import {
persistMutation,
@@ -58,7 +60,11 @@ import {
isWorkflowModule,
addTag,
removeTag,
getAllWorkflowsForExport,
setWorkflowsRenderSource,
clearWorkflowsRenderSource,
} from "./workflows_store.js";
import { diffPicks, diffWorkflows } from "./snapshot_diff.js";
import {
serializeFullCanvas,
serializeSelection,
@@ -124,6 +130,7 @@ const SVG_NS = "http://www.w3.org/2000/svg";
const TOOLBAR_ICONS = {
loadSnapshot: { kind: "letter", text: "L" },
saveSnapshot: { kind: "letter", text: "S" },
compareSnapshot: { kind: "letter", text: "A/B" },
help: { kind: "letter", text: "H" },
exportStarter: { kind: "letter", text: "E" },
installMissing: { kind: "letter", text: "I" },
@@ -1314,6 +1321,8 @@ function makeNodeLeafRow({ display, type, removable, onClick, unresolved, breadc
const row = document.createElement("div");
row.className = "koolook-row koolook-leaf";
if (unresolved) row.classList.add("koolook-leaf-unresolved");
// Stable identity for Compare-mode tinting (#181). Invisible otherwise.
if (type) row.dataset.koolookNodeType = type;
// Breadcrumb-equipped rows under search-flatten mode also surface the
// origin path in the row tooltip (`type` is still appended) so a hover
// confirms exactly which folder the leaf would live under in the tree.
@@ -1383,9 +1392,11 @@ function makeNodeLeafRow({ display, type, removable, onClick, unresolved, breadc
return row;
}
function makeWorkflowLeafRow({ name, dirName, onClick, onContextMenu, draggablePayload, isModule = false }) {
function makeWorkflowLeafRow({ name, dirName, wfPath, onClick, onContextMenu, draggablePayload, isModule = false }) {
const row = document.createElement("div");
row.className = "koolook-row koolook-leaf";
// Stable identity for Compare-mode tinting (#181). Invisible otherwise.
if (wfPath) row.dataset.koolookWfPath = wfPath;
// 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
@@ -2149,6 +2160,7 @@ addSection({
row: makeWorkflowLeafRow({
name: entry.wfName,
dirName: entry.path.join(" / "),
wfPath: [...entry.path, entry.wfName].join("/"),
onClick: () => moduleEntry
? insertWorkflowOntoCanvas(entry.path, entry.wfName)
: loadWorkflowOntoCanvas(entry.path, entry.wfName),
@@ -2200,6 +2212,7 @@ function renderGatheredDir(parentCtx, dir) {
row: makeWorkflowLeafRow({
name: wfName,
dirName: dirDisplay,
wfPath: [...dirPath, wfName].join("/"),
onClick: () => loadWorkflowOntoCanvas(dirPath, wfName),
onContextMenu: (e) => workflowRowContextMenu(e, dirPath, wfName, true),
draggablePayload: { type: "workflow", path: dirPath, name: wfName },
@@ -2215,6 +2228,7 @@ function renderGatheredDir(parentCtx, dir) {
row: makeWorkflowLeafRow({
name: wfName,
dirName: dirDisplay,
wfPath: [...dirPath, wfName].join("/"),
// Module convention: left-click inserts; non-module
// entries left-click loads (replaces canvas, with
// confirm modal as before). Both actions are always
@@ -2235,10 +2249,190 @@ function renderGatheredDir(parentCtx, dir) {
// =============================================================================
// Panel rendering
// =============================================================================
export function renderPanel(container) {
// =============================================================================
// Compare mode (#181) — host + helpers.
//
// `renderSidebar` is the panel entry point bound by koolook_sidebar.js. With
// compare off it renders the single live `renderPanel`, exactly as before.
// With compare on it lays out two columns — the live working copy and a
// read-only render of a chosen snapshot — plus a bottom "Compare mode" status,
// then tints the comparison column from the snapshot diff. The existing
// `renderPanel` + CSS are reused verbatim; only the data source differs.
// =============================================================================
let compareSnapshot = null; // null = compare off; else the chosen snapshot object
let compareMeta = null; // { fileName, displayName } of the chosen snapshot
let sidebarContainer = null; // remembered so a toggle can re-render in place
let liveListeners = null; // AbortController for the live panel's window listeners
// Run `fn` synchronously with the render path pointed at `snapshot`'s data
// instead of live state. Read-only: always clears the overrides afterwards
// (even if `fn` throws) so later live renders see live data. Must wrap only
// synchronous render work — never an await.
function withSnapshotSource(snapshot, fn) {
setPicksRenderSource(snapshot && Array.isArray(snapshot.picks) ? snapshot.picks : []);
setWorkflowsRenderSource(snapshot && snapshot.workflows ? snapshot.workflows : { directories: {} });
try {
return fn();
} finally {
clearPicksRenderSource();
clearWorkflowsRenderSource();
}
}
export function renderSidebar(container) {
sidebarContainer = container;
// Drop the previous mount's live-panel window listeners before re-rendering
// (toggling Compare re-runs renderPanel for the live panel).
if (liveListeners) liveListeners.abort();
liveListeners = new AbortController();
const signal = liveListeners.signal;
if (!compareSnapshot) {
container.classList.remove("koolook-compare-host");
renderPanel(container, { onToggleCompare: enterCompareMode, signal });
return;
}
container.innerHTML = "";
container.classList.remove("koolook-sidebar");
container.classList.add("koolook-compare-host");
const split = document.createElement("div");
split.className = "koolook-compare-split";
const leftCol = document.createElement("div");
leftCol.className = "koolook-compare-col";
const rightCol = document.createElement("div");
rightCol.className = "koolook-compare-col";
split.appendChild(leftCol);
split.appendChild(rightCol);
container.appendChild(split);
// Left = live working copy (untouched). Right = the same render, fed the
// chosen snapshot read-only, then tinted.
renderPanel(leftCol, { onToggleCompare: exitCompareMode, signal });
renderPanel(rightCol, {
compare: true,
snapshot: compareSnapshot,
onToggleCompare: exitCompareMode,
signal,
compareName: compareMeta && compareMeta.displayName ? compareMeta.displayName : "",
});
applyCompareTint(rightCol, compareSnapshot);
const status = document.createElement("div");
status.className = "koolook-compare-status";
status.textContent = compareMeta && compareMeta.displayName
? `Compare mode · ${compareMeta.displayName}`
: "Compare mode";
container.appendChild(status);
}
function rerenderSidebar() {
if (sidebarContainer) renderSidebar(sidebarContainer);
}
// Enter Compare mode: reuse the existing Load dialog in read-only "choose"
// mode (`onChoose`) so picking a preset hands back its parsed snapshot WITHOUT
// applying it. Never calls applySnapshot — the non-destructive guarantee.
function enterCompareMode() {
showLoadSnapshotDialog({
listPresets,
readPreset,
deletePreset,
applySnapshot,
setCurrentPresetName,
getCurrentPresetName,
getLibraryInfo,
saveSettings,
browseDirectories,
createBrowseDirectory,
writePreLoadAutosave,
markStateSaved,
markStateAutosaved,
listAutosaves,
revealPresetFolder,
onToast: toast,
onChoose: (snap, meta) => {
compareSnapshot = snap;
compareMeta = meta || null;
rerenderSidebar();
},
});
}
function exitCompareMode() {
compareSnapshot = null;
compareMeta = null;
rerenderSidebar();
}
// Tint the comparison column from the snapshot diff (text-only, via CSS
// classes on the existing rows). Nodes: a pick only in the comparison →
// green/new. Workflows: per full path → green/new or red/diff; identical
// (ignoring savedAt) stays plain. Runs after the column rendered with the
// store overrides already cleared, so the working-side reads are live.
function applyCompareTint(panelEl, snapshot) {
if (!panelEl || !snapshot) return;
const pickDiff = diffPicks(loadUserPicks(), Array.isArray(snapshot.picks) ? snapshot.picks : []);
const newPicks = new Set(pickDiff.onlyComparison);
for (const row of panelEl.querySelectorAll("[data-koolook-node-type]")) {
if (newPicks.has(row.dataset.koolookNodeType)) row.classList.add("koolook-cmp-new");
}
const snapWorkflows = snapshot.workflows && typeof snapshot.workflows === "object"
? snapshot.workflows
: { directories: {} };
const wfStatus = diffWorkflows(getAllWorkflowsForExport(), snapWorkflows);
for (const row of panelEl.querySelectorAll("[data-koolook-wf-path]")) {
const status = wfStatus[row.dataset.koolookWfPath];
if (status === "new") row.classList.add("koolook-cmp-new");
else if (status === "diff") row.classList.add("koolook-cmp-diff");
}
}
export function renderPanel(container, options = {}) {
const { compare = false, snapshot = null, onToggleCompare = null, signal = null, compareName = "" } = options;
// Compare mode (#181): when `compare` is set, every tree (re-)render in
// this panel reads from the loaded `snapshot` via the read-only store
// overrides, and the live-change listeners at the bottom are skipped so
// the snapshot panel stays static. compare=false is byte-identical to the
// pre-#181 single live panel.
const withSource = compare
? (fn) => withSnapshotSource(snapshot, fn)
: (fn) => fn();
// `signal` (from renderSidebar's AbortController) lets a re-render drop the
// previous mount's window listeners, so toggling Compare never leaks a set.
const listenerOpts = signal ? { signal } : undefined;
ensureStyle();
container.innerHTML = "";
container.classList.add("koolook-sidebar");
if (compare) {
container.classList.add("koolook-compare-panel");
// Read-only comparison panel (#181): the same interactive renderPanel is
// reused (chrome stays visually identical), but the snapshot side must
// never mutate or load live state. Capture-phase guards allow navigation
// only — folder expand/collapse, search, and the A/B exit button — and
// neutralize every mutating/loading affordance: leaf load/insert clicks,
// pick ×/+, the snapshot + tools buttons, the grouping mode toggle
// (which writes the shared global GROUP_MODE_KEY), context menus, and
// drag — dragstart on the source PLUS dragover/drop so a drag begun in
// the live panel can't land on (and mutate via) the snapshot column.
// Buttons stay visible but inert.
const NAV_ALLOW =
".koolook-search, [data-koolook-compare-exit], .koolook-row:not(.koolook-leaf)";
container.addEventListener("click", (e) => {
const t = e.target;
if (t instanceof Element && t.closest(NAV_ALLOW)) return;
e.preventDefault();
e.stopPropagation();
}, true);
container.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); }, true);
container.addEventListener("dragstart", (e) => { e.preventDefault(); e.stopPropagation(); }, true);
container.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); }, true);
container.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); }, true);
}
// Forward declarations — `tree` and `search` are created several rows
// below, but the Nodes-row mode toggle (built mid-renderPanel) needs to
@@ -2249,7 +2443,7 @@ export function renderPanel(container) {
let tree;
let search;
const rerenderTree = () => {
if (tree) renderTree({ treeEl: tree, query: search ? search.value : "" });
if (tree) withSource(() => renderTree({ treeEl: tree, query: search ? search.value : "" }));
};
// Three small wrappers over the snapshot dialogs. Each opens directly
@@ -2379,10 +2573,21 @@ export function renderPanel(container) {
snapshotStatus.title = snapshotTooltip(status);
});
}
refreshSnapshotStatus();
window.addEventListener(PICKS_CHANGED_EVENT, refreshSnapshotStatus);
window.addEventListener(WORKFLOWS_CHANGED_EVENT, refreshSnapshotStatus);
window.addEventListener(SNAPSHOT_STATUS_CHANGED_EVENT, refreshSnapshotStatus);
if (compare) {
// The comparison panel identifies the snapshot it is showing — its own
// name + a static "comparing" state — rather than echoing the live
// working-copy status. It never refreshes off live status events.
snapshotDot.className = "koolook-snap-status-dot koolook-snap-status-comparing";
snapshotName.classList.remove("koolook-snap-status-name-empty");
snapshotName.textContent = compareName || "(snapshot)";
snapshotState.textContent = "· comparing";
snapshotStatus.title = compareName ? `Comparing: ${compareName}` : "Comparing";
} else {
refreshSnapshotStatus();
window.addEventListener(PICKS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts);
window.addEventListener(WORKFLOWS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts);
window.addEventListener(SNAPSHOT_STATUS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts);
}
snapshotRow.appendChild(makeToolbarButton({
icon: TOOLBAR_ICONS.loadSnapshot,
@@ -2418,13 +2623,29 @@ export function renderPanel(container) {
quickSaveBtn.disabled = !getCurrentPresetName();
}
refreshQuickSaveDisabled();
window.addEventListener(SNAPSHOT_STATUS_CHANGED_EVENT, refreshQuickSaveDisabled);
window.addEventListener(SNAPSHOT_STATUS_CHANGED_EVENT, refreshQuickSaveDisabled, listenerOpts);
snapshotRow.appendChild(quickSaveBtn);
snapshotRow.appendChild(makeToolbarButton({
icon: TOOLBAR_ICONS.saveSnapshot,
title: "Save current state — overwrites the last-loaded preset, or prompts for a name",
onClick: openSaveDialog,
}));
// A/B — Compare mode toggle (#181). Same letter-button style as L/S; the
// host (`renderSidebar`) supplies the toggle handler (enter on the live
// panel, exit on the comparison panels).
if (onToggleCompare) {
const compareBtn = makeToolbarButton({
icon: TOOLBAR_ICONS.compareSnapshot,
title: compare
? "Exit Compare mode"
: "Compare mode — open another snapshot side by side",
onClick: onToggleCompare,
});
// Exempt from the read-only comparison-panel click guard so the user
// can always toggle back out of Compare mode.
compareBtn.dataset.koolookCompareExit = "1";
snapshotRow.appendChild(compareBtn);
}
// Settings cog removed in #137: library-path change now lives inside the
// Save dialog ("Save to...") and the Load dialog ("Load from..."), both
// backed by the same folder picker. The cog is gone; the snapshot row
@@ -2793,7 +3014,7 @@ export function renderPanel(container) {
tree.className = "koolook-tree";
container.appendChild(tree);
renderTree({ treeEl: tree, query: "" });
withSource(() => renderTree({ treeEl: tree, query: "" }));
// ---- Build tag (dev-sync verification) ----
// `scripts/sync_to_dev.py` writes `web/_dev_build.json` post-sync so the
@@ -2840,15 +3061,21 @@ export function renderPanel(container) {
search.addEventListener("input", (e) => {
const q = e.target.value;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => renderTree({ treeEl: tree, query: q }), 60);
debounce = setTimeout(() => withSource(() => renderTree({ treeEl: tree, query: q })), 60);
});
// ---- Event subscriptions for live re-rendering ----
window.addEventListener(PICKS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }));
window.addEventListener(WORKFLOWS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }));
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY || e.key === WORKFLOWS_FALLBACK_KEY || e.key === GROUP_MODE_KEY) {
renderTree({ treeEl: tree, query: search.value });
}
});
// Skipped in compare mode: a comparison panel shows a static snapshot, so
// it must not re-render off live picks/workflows/storage changes (which
// would read live state instead of the snapshot) or leak window listeners
// across compare toggles.
if (!compare) {
window.addEventListener(PICKS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }), listenerOpts);
window.addEventListener(WORKFLOWS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }), listenerOpts);
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY || e.key === WORKFLOWS_FALLBACK_KEY || e.key === GROUP_MODE_KEY) {
renderTree({ treeEl: tree, query: search.value });
}
}, listenerOpts);
}
}
+36 -4
View File
@@ -113,7 +113,12 @@ function cloneJson(value) {
return JSON.parse(JSON.stringify(value));
}
function sortJsonValue(value) {
// Exported for reuse by snapshot_diff.js (issue #181) — the Compare view needs
// the same key-order canonicalization to decide whether two workflow graphs
// differ. Kept defined here (next to its `compareNames` dependency and
// `normalizedStoresEqual`) rather than moved, so the store's own callers are
// untouched.
export function sortJsonValue(value) {
if (Array.isArray(value)) return value.map(sortJsonValue);
if (!value || typeof value !== "object") return value;
const out = {};
@@ -538,16 +543,43 @@ export async function seedWorkflowDefaultsIfNeeded() {
const ARCHIVE_RESERVED_NAME = "archive";
// Read-only render-source override (issue #181, Compare mode). When set,
// everything that resolves through `dirOf` — `listDirectoryNames`,
// `getWorkflowGraph`, `getWorkflowTags`, `isWorkflowModule`, and the tree
// gather in tree.js — reads this store instead of the live `workflowsCache`,
// letting the Compare view render a second sidebar from a loaded snapshot
// without mutating live state. Mutators also resolve paths through `dirOf`, so
// the override is scoped to one synchronous render pass and always cleared
// afterwards (see `withSnapshotSource` in tree.js); it must never straddle an
// await or a user interaction.
let renderSourceOverride = null;
function activeStore() {
return renderSourceOverride || workflowsCache;
}
export function setWorkflowsRenderSource(store) {
renderSourceOverride =
store && typeof store === "object" && store.directories && typeof store.directories === "object"
? store
: { directories: {} };
}
export function clearWorkflowsRenderSource() {
renderSourceOverride = null;
}
// Resolves to the DirNode at `path`, or `undefined` if any segment is missing.
// `[]` returns a synthetic wrapper around the root (with `directories`
// pointing at `workflowsCache.directories`) so callers don't need a special
// pointing at the active store's directories) so callers don't need a special
// case for root vs. nested.
export function dirOf(path) {
if (!Array.isArray(path)) return undefined;
const store = activeStore();
if (path.length === 0) {
return { workflows: {}, directories: workflowsCache.directories || {} };
return { workflows: {}, directories: store.directories || {} };
}
let node = { directories: workflowsCache.directories || {} };
let node = { directories: store.directories || {} };
for (const seg of path) {
if (typeof seg !== "string" || !seg) return undefined;
if (!node.directories || !node.directories[seg]) return undefined;