/** * ComfyUI Snapshot Manager * * Automatically captures workflow snapshots as you edit, stores them on the * server as JSON files, and provides a sidebar panel to browse and restore * any previous version. */ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const EXTENSION_NAME = "ComfyUI.SnapshotManager"; const RESTORE_GUARD_MS = 500; const INITIAL_CAPTURE_DELAY_MS = 1500; const MIGRATE_BATCH_SIZE = 10; const OLD_DB_NAME = "ComfySnapshotManager"; const OLD_STORE_NAME = "snapshots"; // ─── Configurable Settings (updated via ComfyUI settings UI) ──────── let maxSnapshots = 50; let debounceMs = 3000; let autoCaptureEnabled = true; let captureOnLoad = true; let maxNodeSnapshots = 5; let showTimeline = false; // ─── State ─────────────────────────────────────────────────────────── const lastCapturedHashMap = new Map(); const lastGraphDataMap = new Map(); // workflowKey -> previous graphData for change-type detection let restoreLock = null; let captureTimer = null; let sidebarRefresh = null; // callback set by sidebar render let viewingWorkflowKey = null; // null = follow active workflow; string = override let pickerDirty = true; // forces workflow picker to re-fetch on next expand let timelineEl = null; // root DOM element for timeline bar let timelineRefresh = null; // callback to re-render timeline let activeSnapshotId = null; // ID of the snapshot currently loaded via swap let currentSnapshotId = null; // ID of the auto-saved "Current" snapshot before a swap let diffBaseSnapshot = null; // snapshot record selected as diff base (shift+click) // ─── Server API Layer ─────────────────────────────────────────────── async function db_put(record) { try { const resp = await api.fetchApi("/snapshot-manager/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ record }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || resp.statusText); } } catch (err) { console.warn(`[${EXTENSION_NAME}] Save failed:`, err); showToast("Failed to save snapshot", "error"); throw err; } } async function db_getAllForWorkflow(workflowKey) { try { const resp = await api.fetchApi("/snapshot-manager/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workflowKey }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || resp.statusText); } return await resp.json(); } catch (err) { console.warn(`[${EXTENSION_NAME}] List failed:`, err); showToast("Failed to read snapshots", "error"); return []; } } async function db_delete(workflowKey, id) { try { const resp = await api.fetchApi("/snapshot-manager/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workflowKey, id }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || resp.statusText); } } catch (err) { console.warn(`[${EXTENSION_NAME}] Delete failed:`, err); showToast("Failed to delete snapshot", "error"); } } async function db_deleteAllForWorkflow(workflowKey) { try { const resp = await api.fetchApi("/snapshot-manager/delete-all", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workflowKey }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || resp.statusText); } return await resp.json(); } catch (err) { console.warn(`[${EXTENSION_NAME}] Bulk delete failed:`, err); showToast("Failed to clear snapshots", "error"); throw err; } } async function db_getAllWorkflowKeys() { try { const resp = await api.fetchApi("/snapshot-manager/workflows"); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || resp.statusText); } return await resp.json(); } catch (err) { console.warn(`[${EXTENSION_NAME}] Workflow key scan failed:`, err); return []; } } async function pruneSnapshots(workflowKey) { try { const resp = await api.fetchApi("/snapshot-manager/prune", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workflowKey, maxSnapshots, source: "regular" }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || resp.statusText); } } catch (err) { console.warn(`[${EXTENSION_NAME}] Prune failed:`, err); } } async function pruneNodeSnapshots(workflowKey) { try { const resp = await api.fetchApi("/snapshot-manager/prune", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workflowKey, maxSnapshots: maxNodeSnapshots, source: "node" }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || resp.statusText); } } catch (err) { console.warn(`[${EXTENSION_NAME}] Node prune failed:`, err); } } // ─── IndexedDB Migration ──────────────────────────────────────────── async function migrateFromIndexedDB() { try { // Check if the old database exists (databases() not supported in all browsers) if (typeof indexedDB.databases === "function") { const databases = await indexedDB.databases(); if (!databases.some((db) => db.name === OLD_DB_NAME)) return; } const db = await new Promise((resolve, reject) => { const req = indexedDB.open(OLD_DB_NAME, 1); req.onupgradeneeded = (e) => { // DB didn't exist before — close and clean up e.target.transaction.abort(); reject(new Error("no-existing-db")); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); const allRecords = await new Promise((resolve, reject) => { const tx = db.transaction(OLD_STORE_NAME, "readonly"); const req = tx.objectStore(OLD_STORE_NAME).getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); db.close(); if (allRecords.length === 0) { indexedDB.deleteDatabase(OLD_DB_NAME); return; } // Send in batches let totalImported = 0; for (let i = 0; i < allRecords.length; i += MIGRATE_BATCH_SIZE) { const batch = allRecords.slice(i, i + MIGRATE_BATCH_SIZE); const resp = await api.fetchApi("/snapshot-manager/migrate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ records: batch }), }); if (!resp.ok) throw new Error("Migration batch failed"); const result = await resp.json(); totalImported += result.imported; } // Success — delete old database indexedDB.deleteDatabase(OLD_DB_NAME); console.log(`[${EXTENSION_NAME}] Migrated ${totalImported} snapshots from IndexedDB to server`); } catch (err) { if (err.message === "no-existing-db") return; console.warn(`[${EXTENSION_NAME}] IndexedDB migration failed (old data preserved):`, err); } } // ─── Helpers ───────────────────────────────────────────────────────── function quickHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; } return hash; } function getWorkflowKey() { try { const wf = app.extensionManager?.workflow?.activeWorkflow; return wf?.key || wf?.filename || wf?.path || "default"; } catch { return "default"; } } function getEffectiveWorkflowKey() { return viewingWorkflowKey ?? getWorkflowKey(); } function getGraphData() { try { return app.graph.serialize(); } catch { return null; } } function generateId() { return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } function validateSnapshotData(graphData) { return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes); } // ─── Change-Type Detection ────────────────────────────────────────── function detectChangeType(prevGraph, currGraph) { if (!prevGraph) return "initial"; const prevNodes = prevGraph.nodes || []; const currNodes = currGraph.nodes || []; // Quick length check before building Sets if (prevNodes.length !== currNodes.length) { return prevNodes.length < currNodes.length ? "node_add" : "node_remove"; } const prevIds = new Set(prevNodes.map(n => n.id)); let hasAdded = false; let hasRemoved = false; for (let i = 0; i < currNodes.length; i++) { if (!prevIds.has(currNodes[i].id)) { hasAdded = true; break; } } if (hasAdded) { // Same length but different IDs → both add and remove return "mixed"; } // Node sets identical (same length, all curr IDs exist in prev) // — check links, params, positions with early exits let flags = 0; const FLAG_CONNECTION = 1; const FLAG_PARAM = 2; const FLAG_MOVE = 4; const ALL_FLAGS = FLAG_CONNECTION | FLAG_PARAM | FLAG_MOVE; // Compare links — check length first to avoid stringify when possible const prevLinks = prevGraph.links || []; const currLinks = currGraph.links || []; if (prevLinks.length !== currLinks.length) { flags |= FLAG_CONNECTION; } else if (prevLinks.length > 0) { // Same length — spot-check first/last before full stringify const pFirst = prevLinks[0], cFirst = currLinks[0]; const pLast = prevLinks[prevLinks.length - 1], cLast = currLinks[currLinks.length - 1]; if (pFirst?.[0] !== cFirst?.[0] || pFirst?.[1] !== cFirst?.[1] || pLast?.[0] !== cLast?.[0] || pLast?.[1] !== cLast?.[1]) { flags |= FLAG_CONNECTION; } else if (JSON.stringify(prevLinks) !== JSON.stringify(currLinks)) { flags |= FLAG_CONNECTION; } } // Build lookup for prev nodes by id const prevNodeMap = new Map(prevNodes.map(n => [n.id, n])); for (const cn of currNodes) { const pn = prevNodeMap.get(cn.id); if (!pn) continue; // Compare widget values — cheap ref/length check before stringify if (!(flags & FLAG_PARAM)) { const cw = cn.widgets_values; const pw = pn.widgets_values; if (cw !== pw) { if (cw == null || pw == null || !Array.isArray(cw) || !Array.isArray(pw) || cw.length !== pw.length) { flags |= FLAG_PARAM; } else { // Same-length arrays — compare elements directly for (let i = 0; i < cw.length; i++) { if (cw[i] !== pw[i]) { flags |= FLAG_PARAM; break; } } } } } // Compare positions if (!(flags & FLAG_MOVE)) { const cp = cn.pos, pp = pn.pos; if (cp?.[0] !== pp?.[0] || cp?.[1] !== pp?.[1]) flags |= FLAG_MOVE; } if (flags === ALL_FLAGS) break; } if (flags === 0) return "unknown"; // Count set flags const count = ((flags & FLAG_CONNECTION) ? 1 : 0) + ((flags & FLAG_PARAM) ? 1 : 0) + ((flags & FLAG_MOVE) ? 1 : 0); if (count > 1) return "mixed"; if (flags & FLAG_CONNECTION) return "connection"; if (flags & FLAG_PARAM) return "param"; if (flags & FLAG_MOVE) return "move"; return "unknown"; } // ─── Detailed Diff ────────────────────────────────────────────────── function buildNodeLookup(...graphs) { const map = new Map(); for (const g of graphs) { if (!g || !Array.isArray(g.nodes)) continue; for (const n of g.nodes) { if (!map.has(n.id)) { map.set(n.id, { type: n.type || "?", title: n.title || n.type || `#${n.id}` }); } } } return map; } function computeDetailedDiff(baseGraph, targetGraph) { const empty = { addedNodes: [], removedNodes: [], modifiedNodes: [], addedLinks: [], removedLinks: [], summary: { nodesAdded: 0, nodesRemoved: 0, nodesModified: 0, linksAdded: 0, linksRemoved: 0 }, }; if (!baseGraph && !targetGraph) return empty; const bNodes = (baseGraph?.nodes || []); const tNodes = (targetGraph?.nodes || []); const baseMap = new Map(bNodes.map(n => [n.id, n])); const targetMap = new Map(tNodes.map(n => [n.id, n])); const addedNodes = []; const removedNodes = []; const modifiedNodes = []; // Removed: in base but not in target for (const [id, n] of baseMap) { if (!targetMap.has(id)) { removedNodes.push({ id, type: n.type || "?", title: n.title || n.type || `#${id}` }); } } // Added or modified: in target for (const [id, tn] of targetMap) { const bn = baseMap.get(id); if (!bn) { addedNodes.push({ id, type: tn.type || "?", title: tn.title || tn.type || `#${id}` }); continue; } // Check modifications const changes = {}; // Position if (bn.pos?.[0] !== tn.pos?.[0] || bn.pos?.[1] !== tn.pos?.[1]) { changes.position = { from: bn.pos, to: tn.pos }; } // Size if (bn.size?.[0] !== tn.size?.[0] || bn.size?.[1] !== tn.size?.[1]) { changes.size = { from: bn.size, to: tn.size }; } // Title if ((bn.title || "") !== (tn.title || "")) { changes.title = { from: bn.title || "", to: tn.title || "" }; } // Mode if (bn.mode !== tn.mode) { changes.mode = { from: bn.mode, to: tn.mode }; } // Widget values const bw = bn.widgets_values; const tw = tn.widgets_values; if (bw !== tw) { if (bw == null || tw == null || !Array.isArray(bw) || !Array.isArray(tw) || bw.length !== tw.length) { changes.widgetValues = { from: bw, to: tw }; } else { const diffs = []; for (let i = 0; i < Math.max(bw.length, tw.length); i++) { const bv = i < bw.length ? bw[i] : undefined; const tv = i < tw.length ? tw[i] : undefined; if (bv !== tv) { const bs = typeof bv === "object" ? JSON.stringify(bv) : String(bv ?? ""); const ts = typeof tv === "object" ? JSON.stringify(tv) : String(tv ?? ""); if (bs !== ts) diffs.push({ index: i, from: bs, to: ts }); } } if (diffs.length > 0) changes.widgetValues = diffs; } } // Properties (shallow key comparison) const bp = bn.properties || {}; const tp = tn.properties || {}; const allPropKeys = new Set([...Object.keys(bp), ...Object.keys(tp)]); const propDiffs = []; for (const key of allPropKeys) { const bv = bp[key]; const tv = tp[key]; if (bv !== tv) { const bs = typeof bv === "object" ? JSON.stringify(bv) : String(bv ?? ""); const ts = typeof tv === "object" ? JSON.stringify(tv) : String(tv ?? ""); if (bs !== ts) propDiffs.push({ key, from: bs, to: ts }); } } if (propDiffs.length > 0) changes.properties = propDiffs; if (Object.keys(changes).length > 0) { modifiedNodes.push({ id, type: tn.type || "?", title: tn.title || tn.type || `#${id}`, changes, }); } } // Links const bLinks = (baseGraph?.links || []).filter(Boolean); const tLinks = (targetGraph?.links || []).filter(Boolean); const baseLinkMap = new Map(bLinks.map(l => [l[0], l])); const targetLinkMap = new Map(tLinks.map(l => [l[0], l])); const addedLinks = []; const removedLinks = []; for (const [linkId, l] of baseLinkMap) { if (!targetLinkMap.has(linkId)) { removedLinks.push({ linkId, srcNodeId: l[1], srcSlot: l[2], destNodeId: l[3], destSlot: l[4], type: l[5] }); } } for (const [linkId, l] of targetLinkMap) { if (!baseLinkMap.has(linkId)) { addedLinks.push({ linkId, srcNodeId: l[1], srcSlot: l[2], destNodeId: l[3], destSlot: l[4], type: l[5] }); } } return { addedNodes, removedNodes, modifiedNodes, addedLinks, removedLinks, summary: { nodesAdded: addedNodes.length, nodesRemoved: removedNodes.length, nodesModified: modifiedNodes.length, linksAdded: addedLinks.length, linksRemoved: removedLinks.length, }, }; } // ─── Restore Lock ─────────────────────────────────────────────────── async function withRestoreLock(fn) { if (restoreLock) return; let resolve; restoreLock = new Promise((r) => { resolve = r; }); try { await fn(); } finally { setTimeout(() => { restoreLock = null; resolve(); if (sidebarRefresh) { sidebarRefresh().catch(() => {}); } if (timelineRefresh) { timelineRefresh().catch(() => {}); } }, RESTORE_GUARD_MS); } } // ─── UI Utilities ─────────────────────────────────────────────────── function showToast(message, severity = "info") { try { app.extensionManager.toast.add({ severity, summary: "Snapshot Manager", detail: message, life: 2500, }); } catch { /* silent fallback */ } } async function showConfirmDialog(message) { try { return await app.extensionManager.dialog.confirm({ title: "Snapshot Manager", message, }); } catch { return window.confirm(message); } } async function showPromptDialog(message, defaultValue = "Manual") { try { const result = await app.extensionManager.dialog.prompt({ title: "Snapshot Name", message, }); return result; } catch { return window.prompt(message, defaultValue); } } // ─── Diff Modal ───────────────────────────────────────────────────── function showDiffModal(baseLabel, targetLabel, diff, allNodes) { // Overlay const overlay = document.createElement("div"); overlay.className = "snap-diff-overlay"; // Modal const modal = document.createElement("div"); modal.className = "snap-diff-modal"; // Header const hdr = document.createElement("div"); hdr.className = "snap-diff-header"; const hdrTitle = document.createElement("span"); hdrTitle.textContent = `${baseLabel} \u2192 ${targetLabel}`; const closeBtn = document.createElement("button"); closeBtn.textContent = "\u2715"; closeBtn.addEventListener("click", dismiss); hdr.appendChild(hdrTitle); hdr.appendChild(closeBtn); // Summary pills const { summary } = diff; const summaryBar = document.createElement("div"); summaryBar.className = "snap-diff-summary"; const pills = [ { count: summary.nodesAdded, label: "added", color: "#22c55e" }, { count: summary.nodesRemoved, label: "removed", color: "#dc2626" }, { count: summary.nodesModified, label: "modified", color: "#f59e0b" }, { count: summary.linksAdded, label: "links +", color: "#3b82f6" }, { count: summary.linksRemoved, label: "links \u2212", color: "#3b82f6" }, ]; for (const p of pills) { if (p.count === 0) continue; const pill = document.createElement("span"); pill.style.cssText = `background:${p.color}; color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600;`; pill.textContent = `${p.count} ${p.label}`; summaryBar.appendChild(pill); } // Body (scrollable) const body = document.createElement("div"); body.className = "snap-diff-body"; const totalChanges = summary.nodesAdded + summary.nodesRemoved + summary.nodesModified + summary.linksAdded + summary.linksRemoved; if (totalChanges === 0) { const emptyMsg = document.createElement("div"); emptyMsg.className = "snap-diff-empty"; emptyMsg.textContent = "No differences found."; body.appendChild(emptyMsg); } else { // Helper: collapsible section function makeSection(title, count, entries, renderEntry) { if (count === 0) return null; const section = document.createElement("div"); section.className = "snap-diff-section"; const sectionHdr = document.createElement("div"); sectionHdr.className = "snap-diff-section-header"; const arrow = document.createElement("span"); arrow.className = "snap-diff-section-arrow"; arrow.textContent = "\u25BC"; const sectionTitle = document.createElement("span"); sectionTitle.textContent = `${title} (${count})`; sectionHdr.appendChild(arrow); sectionHdr.appendChild(sectionTitle); const sectionBody = document.createElement("div"); sectionBody.className = "snap-diff-section-body"; for (const entry of entries) { sectionBody.appendChild(renderEntry(entry)); } let collapsed = false; sectionHdr.addEventListener("click", () => { collapsed = !collapsed; sectionBody.style.display = collapsed ? "none" : ""; arrow.textContent = collapsed ? "\u25B6" : "\u25BC"; }); section.appendChild(sectionHdr); section.appendChild(sectionBody); return section; } function nodeEntry(n, colorClass) { const el = document.createElement("div"); el.className = `snap-diff-node-entry ${colorClass}`; el.textContent = `${n.title} (${n.type}) #${n.id}`; return el; } // Added Nodes const addedSec = makeSection("Added Nodes", diff.addedNodes.length, diff.addedNodes, (n) => nodeEntry(n, "snap-diff-added")); if (addedSec) body.appendChild(addedSec); // Removed Nodes const removedSec = makeSection("Removed Nodes", diff.removedNodes.length, diff.removedNodes, (n) => nodeEntry(n, "snap-diff-removed")); if (removedSec) body.appendChild(removedSec); // Modified Nodes const modSec = makeSection("Modified Nodes", diff.modifiedNodes.length, diff.modifiedNodes, (n) => { const wrap = document.createElement("div"); wrap.className = "snap-diff-node-entry snap-diff-neutral"; const header = document.createElement("div"); header.textContent = `${n.title} (${n.type}) #${n.id}`; wrap.appendChild(header); const { changes } = n; if (changes.position) { const d = document.createElement("div"); d.className = "snap-diff-change-detail"; const from = changes.position.from || [0, 0]; const to = changes.position.to || [0, 0]; d.appendChild(makeValueChange("Position", `[${Math.round(from[0])}, ${Math.round(from[1])}]`, `[${Math.round(to[0])}, ${Math.round(to[1])}]`)); wrap.appendChild(d); } if (changes.size) { const d = document.createElement("div"); d.className = "snap-diff-change-detail"; const from = changes.size.from || [0, 0]; const to = changes.size.to || [0, 0]; d.appendChild(makeValueChange("Size", `[${Math.round(from[0])}, ${Math.round(from[1])}]`, `[${Math.round(to[0])}, ${Math.round(to[1])}]`)); wrap.appendChild(d); } if (changes.title) { const d = document.createElement("div"); d.className = "snap-diff-change-detail"; d.appendChild(makeValueChange("Title", changes.title.from, changes.title.to)); wrap.appendChild(d); } if (changes.mode) { const d = document.createElement("div"); d.className = "snap-diff-change-detail"; d.appendChild(makeValueChange("Mode", String(changes.mode.from), String(changes.mode.to))); wrap.appendChild(d); } if (changes.widgetValues) { if (Array.isArray(changes.widgetValues)) { for (const wv of changes.widgetValues) { const d = document.createElement("div"); d.className = "snap-diff-change-detail"; d.appendChild(makeValueChange(`Value[${wv.index}]`, wv.from, wv.to)); wrap.appendChild(d); } } else { const d = document.createElement("div"); d.className = "snap-diff-change-detail"; d.appendChild(makeValueChange("Widget values", JSON.stringify(changes.widgetValues.from), JSON.stringify(changes.widgetValues.to))); wrap.appendChild(d); } } if (changes.properties) { for (const pv of changes.properties) { const d = document.createElement("div"); d.className = "snap-diff-change-detail"; d.appendChild(makeValueChange(`prop.${pv.key}`, pv.from, pv.to)); wrap.appendChild(d); } } return wrap; }); if (modSec) body.appendChild(modSec); // Link changes (combined section) const allLinkChanges = [ ...diff.addedLinks.map(l => ({ ...l, action: "added" })), ...diff.removedLinks.map(l => ({ ...l, action: "removed" })), ]; const linkSec = makeSection("Link Changes", allLinkChanges.length, allLinkChanges, (l) => { const el = document.createElement("div"); el.className = `snap-diff-link-entry ${l.action === "added" ? "snap-diff-added" : "snap-diff-removed"}`; const srcInfo = allNodes.get(l.srcNodeId) || { title: `#${l.srcNodeId}` }; const destInfo = allNodes.get(l.destNodeId) || { title: `#${l.destNodeId}` }; const prefix = l.action === "added" ? "+" : "\u2212"; el.textContent = `${prefix} ${srcInfo.title} [${l.srcSlot}] \u2192 ${destInfo.title} [${l.destSlot}]${l.type ? ` (${l.type})` : ""}`; return el; }); if (linkSec) body.appendChild(linkSec); } function makeValueChange(label, oldVal, newVal) { const span = document.createElement("span"); const lbl = document.createElement("span"); lbl.textContent = `${label}: `; const oldSpan = document.createElement("span"); oldSpan.className = "snap-diff-val-old"; oldSpan.textContent = truncateVal(oldVal); const arrow = document.createElement("span"); arrow.textContent = " \u2192 "; const newSpan = document.createElement("span"); newSpan.className = "snap-diff-val-new"; newSpan.textContent = truncateVal(newVal); span.appendChild(lbl); span.appendChild(oldSpan); span.appendChild(arrow); span.appendChild(newSpan); return span; } function truncateVal(v) { const s = String(v ?? ""); return s.length > 80 ? s.slice(0, 77) + "\u2026" : s; } modal.appendChild(hdr); modal.appendChild(summaryBar); modal.appendChild(body); overlay.appendChild(modal); document.body.appendChild(overlay); function dismiss() { overlay.remove(); document.removeEventListener("keydown", onKey); } function onKey(e) { if (e.key === "Escape") dismiss(); } document.addEventListener("keydown", onKey); overlay.addEventListener("click", (e) => { if (e.target === overlay) dismiss(); }); } // ─── Snapshot Capture ──────────────────────────────────────────────── async function captureSnapshot(label = "Auto") { if (restoreLock) return false; const graphData = getGraphData(); if (!graphData) return false; const nodes = graphData.nodes || []; if (nodes.length === 0) return false; const workflowKey = getWorkflowKey(); const serialized = JSON.stringify(graphData); const hash = quickHash(serialized); if (hash === lastCapturedHashMap.get(workflowKey)) return false; const prevGraph = lastGraphDataMap.get(workflowKey); const changeType = detectChangeType(prevGraph, graphData); const record = { id: generateId(), workflowKey, timestamp: Date.now(), label, nodeCount: nodes.length, graphData, locked: false, changeType, }; try { await db_put(record); await pruneSnapshots(workflowKey); } catch { return false; } lastCapturedHashMap.set(workflowKey, hash); lastGraphDataMap.set(workflowKey, graphData); pickerDirty = true; currentSnapshotId = null; // new capture supersedes "current" bookmark activeSnapshotId = null; // graph has changed, no snapshot is "active" if (sidebarRefresh) { sidebarRefresh().catch(() => {}); } if (timelineRefresh) { timelineRefresh().catch(() => {}); } return record.id; } async function captureNodeSnapshot(label = "Node Trigger") { if (restoreLock) return false; const graphData = getGraphData(); if (!graphData) return false; const nodes = graphData.nodes || []; if (nodes.length === 0) return false; const workflowKey = getWorkflowKey(); const prevGraph = lastGraphDataMap.get(workflowKey); const changeType = detectChangeType(prevGraph, graphData); const record = { id: generateId(), workflowKey, timestamp: Date.now(), label, nodeCount: nodes.length, graphData, locked: false, source: "node", changeType, }; try { await db_put(record); await pruneNodeSnapshots(workflowKey); } catch { return false; } lastGraphDataMap.set(workflowKey, graphData); pickerDirty = true; currentSnapshotId = null; activeSnapshotId = null; if (sidebarRefresh) { sidebarRefresh().catch(() => {}); } if (timelineRefresh) { timelineRefresh().catch(() => {}); } return true; } function scheduleCaptureSnapshot() { if (!autoCaptureEnabled) return; if (restoreLock) return; if (captureTimer) clearTimeout(captureTimer); captureTimer = setTimeout(() => { captureTimer = null; captureSnapshot("Auto").catch((err) => { console.warn(`[${EXTENSION_NAME}] Auto-capture failed:`, err); }); }, debounceMs); } // ─── Restore ───────────────────────────────────────────────────────── async function restoreSnapshot(record) { await withRestoreLock(async () => { if (!validateSnapshotData(record.graphData)) { showToast("Invalid snapshot data", "error"); return; } try { await app.loadGraphData(record.graphData, true, true); lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData))); lastGraphDataMap.set(getWorkflowKey(), record.graphData); showToast("Snapshot restored", "success"); } catch (err) { console.warn(`[${EXTENSION_NAME}] Restore failed:`, err); showToast("Failed to restore snapshot", "error"); } }); } async function swapSnapshot(record) { // Warn when swapping in a snapshot from a different workflow const currentKey = getWorkflowKey(); if (record.workflowKey && record.workflowKey !== currentKey) { const confirmed = await showConfirmDialog( `This snapshot belongs to a different workflow ("${record.workflowKey}").\nSwap it into the current workflow anyway?` ); if (!confirmed) return; } // Auto-save current state before swapping (so user can get back), // but skip if the graph is already a saved snapshot (browsing between old ones) const prevCurrentId = currentSnapshotId; if (!activeSnapshotId) { const capturedId = await captureSnapshot("Current"); currentSnapshotId = capturedId || prevCurrentId; } await withRestoreLock(async () => { if (!validateSnapshotData(record.graphData)) { showToast("Invalid snapshot data", "error"); return; } try { const workflow = app.extensionManager?.workflow?.activeWorkflow; await app.loadGraphData(record.graphData, true, true, workflow); lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData))); lastGraphDataMap.set(getWorkflowKey(), record.graphData); activeSnapshotId = record.id; showToast("Snapshot swapped", "success"); } catch (err) { console.warn(`[${EXTENSION_NAME}] Swap failed:`, err); showToast("Failed to swap snapshot", "error"); } }); } // ─── Sidebar UI ────────────────────────────────────────────────────── const CSS = ` .snap-sidebar { display: flex; flex-direction: column; height: 100%; color: var(--input-text, #ccc); font-family: system-ui, -apple-system, sans-serif; font-size: 13px; } .snap-header { padding: 8px 10px; border-bottom: 1px solid var(--border-color, #444); display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .snap-header button { padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600; background: #3b82f6; color: #fff; white-space: nowrap; } .snap-header button:hover { background: #2563eb; } .snap-header button:disabled { opacity: 0.5; cursor: not-allowed; } .snap-header .snap-count { margin-left: auto; font-size: 11px; color: var(--descrip-text, #888); white-space: nowrap; } .snap-search { padding: 6px 10px; border-bottom: 1px solid var(--border-color, #444); display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .snap-search input { flex: 1; padding: 4px 8px; border: 1px solid var(--border-color, #444); border-radius: 4px; background: var(--comfy-menu-bg, #2a2a2a); color: var(--input-text, #ccc); font-size: 12px; outline: none; } .snap-search input::placeholder { color: var(--descrip-text, #888); } .snap-search-clear { background: none; border: none; color: var(--descrip-text, #888); cursor: pointer; font-size: 14px; padding: 2px 4px; line-height: 1; visibility: hidden; } .snap-search-clear.visible { visibility: visible; } .snap-list { flex: 1; overflow-y: auto; padding: 4px 0; } .snap-item { display: flex; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--border-color, #333); gap: 8px; } .snap-item:hover { background: var(--comfy-menu-bg, #2a2a2a); } .snap-item-info { flex: 1; min-width: 0; } .snap-item-label { font-size: 13px; font-weight: 600; color: var(--input-text, #ddd); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .snap-item-time { font-size: 12px; color: var(--input-text, #ddd); } .snap-item-date { font-size: 10px; color: var(--descrip-text, #777); } .snap-item-meta { font-size: 10px; color: var(--descrip-text, #666); } .snap-item-label-input { width: 100%; padding: 2px 6px; border: 1px solid var(--border-color, #444); border-radius: 4px; background: var(--comfy-menu-bg, #2a2a2a); color: var(--input-text, #ccc); font-size: 13px; font-weight: 600; outline: none; box-sizing: border-box; } .snap-btn-note { background: none; border: none; cursor: pointer; font-size: 13px; padding: 3px 4px; opacity: 0.5; transition: opacity 0.15s; } .snap-btn-note:hover { opacity: 1; } .snap-btn-note.has-note { opacity: 1; filter: sepia(1) saturate(3) hue-rotate(15deg) brightness(1.1); } .snap-item-notes { font-size: 10px; font-style: italic; color: var(--descrip-text, #888); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } .snap-item-notes-input { width: 100%; padding: 4px 6px; border: 1px solid var(--border-color, #444); border-radius: 4px; background: var(--comfy-menu-bg, #2a2a2a); color: var(--input-text, #ccc); font-size: 11px; outline: none; resize: vertical; min-height: 32px; max-height: 80px; box-sizing: border-box; font-family: inherit; } .snap-item-actions { display: flex; gap: 4px; flex-shrink: 0; } .snap-item-actions button { padding: 3px 8px; border: none; border-radius: 3px; cursor: pointer; font-size: 11px; font-weight: 500; } .snap-item-actions button:disabled { opacity: 0.4; cursor: not-allowed; } .snap-btn-swap { background: #f59e0b; color: #fff; } .snap-btn-swap:hover:not(:disabled) { background: #d97706; } .snap-btn-restore { background: #22c55e; color: #fff; } .snap-btn-restore:hover:not(:disabled) { background: #16a34a; } .snap-btn-lock { background: var(--comfy-menu-bg, #444); color: var(--descrip-text, #aaa); font-size: 13px; min-width: 28px; text-align: center; } .snap-btn-lock.snap-btn-locked { background: #2563eb; color: #fff; } .snap-btn-delete { background: var(--comfy-menu-bg, #444); color: var(--descrip-text, #aaa); } .snap-btn-delete:hover:not(:disabled) { background: #dc2626; color: #fff; } .snap-footer { padding: 8px 10px; border-top: 1px solid var(--border-color, #444); flex-shrink: 0; } .snap-footer button { width: 100%; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600; background: var(--comfy-menu-bg, #555); color: var(--input-text, #ccc); } .snap-footer button:hover { background: #dc2626; color: #fff; } .snap-item-node { border-left: 3px solid #6d28d9; } .snap-node-badge { display: inline-block; font-size: 9px; padding: 1px 5px; border-radius: 3px; background: #6d28d9; color: #fff; margin-left: 6px; vertical-align: middle; font-weight: 500; } .snap-empty { padding: 20px; text-align: center; color: var(--descrip-text, #666); font-size: 12px; } .snap-workflow-selector { padding: 6px 10px; border-bottom: 1px solid var(--border-color, #444); display: flex; align-items: center; cursor: pointer; gap: 4px; flex-shrink: 0; user-select: none; } .snap-workflow-selector:hover { background: var(--comfy-menu-bg, #2a2a2a); } .snap-workflow-selector.snap-viewing-other { border-left: 3px solid #f59e0b; padding-left: 7px; } .snap-workflow-selector-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--descrip-text, #888); } .snap-workflow-selector-arrow { font-size: 10px; color: var(--descrip-text, #888); flex-shrink: 0; transition: transform 0.15s; } .snap-workflow-selector-arrow.expanded { transform: rotate(180deg); } .snap-workflow-list { max-height: 0; overflow: hidden; transition: max-height 0.15s ease-out; } .snap-workflow-list.expanded { max-height: 200px; overflow-y: auto; border-bottom: 1px solid var(--border-color, #444); } .snap-workflow-item { padding: 4px 10px 4px 18px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 6px; color: var(--input-text, #ccc); } .snap-workflow-item:hover { background: var(--comfy-menu-bg, #2a2a2a); } .snap-workflow-item.active { font-weight: 700; color: #3b82f6; } .snap-workflow-item-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .snap-workflow-item-count { flex-shrink: 0; font-size: 11px; color: var(--descrip-text, #888); } .snap-workflow-viewing-banner { padding: 5px 10px; border-bottom: 1px solid var(--border-color, #444); display: flex; align-items: center; gap: 6px; background: rgba(245, 158, 11, 0.1); font-size: 11px; flex-shrink: 0; } .snap-workflow-viewing-banner span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #f59e0b; } .snap-workflow-viewing-banner button { padding: 2px 8px; border: 1px solid #f59e0b; border-radius: 3px; background: transparent; color: #f59e0b; font-size: 11px; cursor: pointer; white-space: nowrap; flex-shrink: 0; } .snap-workflow-viewing-banner button:hover { background: rgba(245, 158, 11, 0.2); } .snap-timeline { position: absolute; bottom: 4px; left: 10%; right: 10%; height: 32px; background: rgba(15, 23, 42, 0.85); border: 1px solid var(--border-color, #334155); border-radius: 8px; display: flex; align-items: center; padding: 0 16px; z-index: 9; pointer-events: auto; } .snap-timeline-track { flex: 1; height: 100%; display: flex; align-items: center; gap: 6px; overflow-x: auto; } .snap-timeline-marker { width: 18px; height: 18px; border-radius: 50%; cursor: pointer; transition: transform 0.1s, box-shadow 0.1s; border: 2px solid transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: var(--snap-marker-color, #3b82f6); } .snap-timeline-marker svg { display: block; color: #fff; pointer-events: none; } .snap-timeline-marker:hover { transform: scale(1.4); box-shadow: 0 0 8px var(--snap-marker-color, rgba(59,130,246,0.6)); } .snap-timeline-marker-node { background: #6d28d9; } .snap-timeline-marker-node:hover { box-shadow: 0 0 6px rgba(109, 40, 217, 0.6); } .snap-timeline-marker-locked { border-color: #facc15; } .snap-timeline-marker-active { border-color: #fff; transform: scale(1.3); } .snap-timeline-marker-active:hover { transform: scale(1.5); } .snap-timeline-marker-current { background: #10b981; } .snap-timeline-marker-current:hover { box-shadow: 0 0 6px rgba(16, 185, 129, 0.6); } .snap-timeline-snap-btn { background: none; border: 1px solid var(--descrip-text, #64748b); color: var(--descrip-text, #94a3b8); border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer; margin-left: 8px; white-space: nowrap; flex-shrink: 0; font-family: system-ui, sans-serif; } .snap-timeline-snap-btn:hover { border-color: #3b82f6; color: #3b82f6; } .snap-timeline-empty { color: var(--descrip-text, #64748b); font-size: 11px; font-family: system-ui, sans-serif; line-height: 32px; } .snap-diff-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 10000; display: flex; align-items: center; justify-content: center; } .snap-diff-modal { width: min(720px, 90vw); max-height: 80vh; background: #1e1e2e; border: 1px solid #444; border-radius: 8px; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5); font-family: system-ui, -apple-system, sans-serif; font-size: 13px; color: #ccc; } .snap-diff-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid #444; font-weight: 700; font-size: 14px; } .snap-diff-header button { background: none; border: none; color: #888; font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 4px; } .snap-diff-header button:hover { background: #333; color: #fff; } .snap-diff-summary { display: flex; gap: 6px; padding: 8px 14px; flex-wrap: wrap; border-bottom: 1px solid #333; } .snap-diff-body { flex: 1; overflow-y: auto; padding: 8px 14px 14px; } .snap-diff-section { margin-bottom: 8px; } .snap-diff-section-header { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 6px 4px; font-weight: 600; font-size: 12px; color: #aaa; user-select: none; border-radius: 4px; } .snap-diff-section-header:hover { background: #2a2a3a; } .snap-diff-section-arrow { font-size: 10px; width: 14px; text-align: center; } .snap-diff-section-body { padding-left: 20px; } .snap-diff-node-entry { padding: 4px 6px; margin: 2px 0; border-radius: 4px; font-size: 12px; } .snap-diff-node-entry.snap-diff-added { color: #22c55e; background: rgba(34,197,94,0.08); } .snap-diff-node-entry.snap-diff-removed { color: #dc2626; background: rgba(220,38,38,0.08); } .snap-diff-node-entry.snap-diff-neutral { color: #f59e0b; background: rgba(245,158,11,0.06); } .snap-diff-change-detail { padding: 2px 0 2px 16px; font-size: 11px; color: #999; } .snap-diff-val-old { color: #dc2626; text-decoration: line-through; } .snap-diff-val-new { color: #22c55e; } .snap-diff-link-entry { padding: 3px 6px; margin: 2px 0; border-radius: 4px; font-size: 12px; } .snap-diff-link-entry.snap-diff-added { color: #22c55e; background: rgba(34,197,94,0.08); } .snap-diff-link-entry.snap-diff-removed { color: #dc2626; background: rgba(220,38,38,0.08); } .snap-diff-empty { text-align: center; padding: 32px 16px; color: #666; font-size: 13px; } .snap-item.snap-diff-base { outline: 2px solid #6d28d9; outline-offset: -2px; border-radius: 4px; } .snap-btn-diff { background: #6d28d9; color: #fff; } .snap-btn-diff:hover:not(:disabled) { background: #5b21b6; } .snap-btn-diff.snap-diff-base-active { box-shadow: 0 0 6px rgba(109,40,217,0.6); } `; const CHANGE_TYPE_ICONS = { initial: { svg: '', color: "#3b82f6", label: "Initial snapshot", }, node_add: { svg: '', color: "#22c55e", label: "Nodes added", }, node_remove: { svg: '', color: "#ef4444", label: "Nodes removed", }, connection: { svg: '', color: "#f59e0b", label: "Connections changed", }, param: { svg: '', color: "#a78bfa", label: "Parameters changed", }, move: { svg: '', color: "#64748b", label: "Nodes repositioned", }, mixed: { svg: '', color: "#f97316", label: "Multiple changes", }, unknown: { svg: '', color: "#6b7280", label: "Unknown change", }, }; function injectStyles() { if (document.getElementById("snapshot-manager-styles")) return; const style = document.createElement("style"); style.id = "snapshot-manager-styles"; style.textContent = CSS; document.head.appendChild(style); } function formatTime(ts) { const d = new Date(ts); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function formatDate(ts) { const d = new Date(ts); return d.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); } async function buildSidebar(el) { injectStyles(); el.innerHTML = ""; const container = document.createElement("div"); container.className = "snap-sidebar"; // Header const header = document.createElement("div"); header.className = "snap-header"; const takeBtn = document.createElement("button"); takeBtn.textContent = "Take Snapshot"; takeBtn.addEventListener("click", async () => { let name = await showPromptDialog("Enter a name for this snapshot:", "Manual"); if (name == null) return; // cancelled (null or undefined) name = name.trim() || "Manual"; takeBtn.disabled = true; takeBtn.textContent = "Saving..."; try { const saved = await captureSnapshot(name); if (saved) showToast("Snapshot saved", "success"); } finally { const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== getWorkflowKey(); takeBtn.disabled = isViewingOther; takeBtn.textContent = "Take Snapshot"; } }); const countSpan = document.createElement("span"); countSpan.className = "snap-count"; header.appendChild(takeBtn); header.appendChild(countSpan); // Search const searchRow = document.createElement("div"); searchRow.className = "snap-search"; const searchInput = document.createElement("input"); searchInput.type = "text"; searchInput.placeholder = "Filter snapshots..."; const searchClear = document.createElement("button"); searchClear.className = "snap-search-clear"; searchClear.textContent = "\u2715"; searchClear.addEventListener("click", () => { searchInput.value = ""; searchClear.classList.remove("visible"); filterItems(""); }); searchInput.addEventListener("input", () => { const term = searchInput.value; searchClear.classList.toggle("visible", term.length > 0); filterItems(term.toLowerCase()); }); searchRow.appendChild(searchInput); searchRow.appendChild(searchClear); // Workflow selector const selectorRow = document.createElement("div"); selectorRow.className = "snap-workflow-selector"; const selectorLabel = document.createElement("span"); selectorLabel.className = "snap-workflow-selector-label"; selectorLabel.textContent = getWorkflowKey(); const selectorArrow = document.createElement("span"); selectorArrow.className = "snap-workflow-selector-arrow"; selectorArrow.textContent = "\u25BC"; selectorRow.appendChild(selectorLabel); selectorRow.appendChild(selectorArrow); // Workflow picker list (expandable) const pickerList = document.createElement("div"); pickerList.className = "snap-workflow-list"; let pickerExpanded = false; async function populatePicker() { pickerList.innerHTML = ""; const keys = await db_getAllWorkflowKeys(); const effectiveKey = getEffectiveWorkflowKey(); const currentKey = getWorkflowKey(); if (keys.length === 0) { const empty = document.createElement("div"); empty.style.cssText = "padding: 6px 18px; font-size: 11px; color: var(--descrip-text, #888);"; empty.textContent = "No workflows found"; pickerList.appendChild(empty); return; } for (const entry of keys) { const row = document.createElement("div"); row.className = "snap-workflow-item"; if (entry.workflowKey === effectiveKey) row.classList.add("active"); const nameSpan = document.createElement("span"); nameSpan.className = "snap-workflow-item-name"; nameSpan.textContent = entry.workflowKey; const countSpanItem = document.createElement("span"); countSpanItem.className = "snap-workflow-item-count"; countSpanItem.textContent = `(${entry.count})`; row.appendChild(nameSpan); row.appendChild(countSpanItem); row.addEventListener("click", async () => { if (entry.workflowKey === currentKey) { viewingWorkflowKey = null; } else { viewingWorkflowKey = entry.workflowKey; } collapsePicker(); await refresh(true); }); pickerList.appendChild(row); } pickerDirty = false; } function collapsePicker() { pickerExpanded = false; pickerList.classList.remove("expanded"); selectorArrow.classList.remove("expanded"); } selectorRow.addEventListener("click", async () => { pickerExpanded = !pickerExpanded; if (pickerExpanded) { if (pickerDirty) await populatePicker(); pickerList.classList.add("expanded"); selectorArrow.classList.add("expanded"); } else { collapsePicker(); } }); // Viewing-other-workflow banner const viewingBanner = document.createElement("div"); viewingBanner.className = "snap-workflow-viewing-banner"; viewingBanner.style.display = "none"; const viewingLabel = document.createElement("span"); viewingLabel.textContent = ""; const backBtn = document.createElement("button"); backBtn.textContent = "Back to current"; backBtn.addEventListener("click", async () => { viewingWorkflowKey = null; await refresh(true); }); viewingBanner.appendChild(viewingLabel); viewingBanner.appendChild(backBtn); // List const list = document.createElement("div"); list.className = "snap-list"; // Footer const footer = document.createElement("div"); footer.className = "snap-footer"; const clearBtn = document.createElement("button"); clearBtn.textContent = "Clear All Snapshots"; clearBtn.addEventListener("click", async () => { const effKey = getEffectiveWorkflowKey(); const confirmed = await showConfirmDialog(`Delete all snapshots for "${effKey}"?`); if (!confirmed) return; try { const { lockedCount } = await db_deleteAllForWorkflow(effKey); pickerDirty = true; if (lockedCount > 0) { showToast(`Cleared snapshots (${lockedCount} locked kept)`, "info"); } else { showToast("All snapshots cleared", "info"); } } catch { // db_deleteAllForWorkflow already toasts on error } await refresh(true); }); footer.appendChild(clearBtn); container.appendChild(header); container.appendChild(selectorRow); container.appendChild(pickerList); container.appendChild(viewingBanner); container.appendChild(searchRow); container.appendChild(list); container.appendChild(footer); el.appendChild(container); // Track items for filtering let itemEntries = []; function filterItems(term) { for (const entry of itemEntries) { const match = !term || entry.label.toLowerCase().includes(term) || entry.notes.toLowerCase().includes(term); entry.element.style.display = match ? "" : "none"; } } function setActionButtonsDisabled(disabled) { const buttons = list.querySelectorAll(".snap-btn-swap, .snap-btn-restore, .snap-btn-delete"); for (const btn of buttons) { btn.disabled = disabled; } } async function refresh(resetSearch = false) { const currentKey = getWorkflowKey(); const effKey = getEffectiveWorkflowKey(); const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== currentKey; const records = await db_getAllForWorkflow(effKey); // newest first records.sort((a, b) => b.timestamp - a.timestamp); const regularCount = records.filter(r => r.source !== "node").length; const nodeCount = records.filter(r => r.source === "node").length; countSpan.textContent = nodeCount > 0 ? `${regularCount}/${maxSnapshots} + ${nodeCount}/${maxNodeSnapshots} node` : `${regularCount} / ${maxSnapshots}`; // Update selector label and styling selectorLabel.textContent = effKey; selectorRow.classList.toggle("snap-viewing-other", isViewingOther); // Show/hide viewing banner if (isViewingOther) { viewingLabel.textContent = `Viewing: ${viewingWorkflowKey}`; viewingBanner.style.display = ""; takeBtn.disabled = true; } else { viewingBanner.style.display = "none"; takeBtn.disabled = false; } // Mark picker stale; only collapse on user-initiated refreshes pickerDirty = true; if (resetSearch) { collapsePicker(); searchInput.value = ""; searchClear.classList.remove("visible"); } list.innerHTML = ""; itemEntries = []; if (records.length === 0) { const empty = document.createElement("div"); empty.className = "snap-empty"; empty.textContent = "No snapshots yet. Edit the workflow or click 'Take Snapshot'."; list.appendChild(empty); return; } for (const rec of records) { const item = document.createElement("div"); item.className = rec.source === "node" ? "snap-item snap-item-node" : "snap-item"; if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) { item.classList.add("snap-diff-base"); } const info = document.createElement("div"); info.className = "snap-item-info"; const labelDiv = document.createElement("div"); labelDiv.className = "snap-item-label"; labelDiv.textContent = rec.label; if (rec.source === "node") { const badge = document.createElement("span"); badge.className = "snap-node-badge"; badge.textContent = "Node"; labelDiv.appendChild(badge); } labelDiv.addEventListener("dblclick", (e) => { e.stopPropagation(); const originalLabel = rec.label; const input = document.createElement("input"); input.type = "text"; input.className = "snap-item-label-input"; input.value = originalLabel; labelDiv.textContent = ""; labelDiv.appendChild(input); input.select(); input.focus(); let committed = false; const commit = async () => { if (committed) return; committed = true; const newLabel = input.value.trim() || originalLabel; if (newLabel !== originalLabel) { rec.label = newLabel; await db_put(rec); await refresh(); } else { labelDiv.textContent = originalLabel; if (rec.source === "node") { const b = document.createElement("span"); b.className = "snap-node-badge"; b.textContent = "Node"; labelDiv.appendChild(b); } } }; input.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); input.blur(); } if (ev.key === "Escape") { ev.preventDefault(); committed = true; labelDiv.textContent = originalLabel; if (rec.source === "node") { const b = document.createElement("span"); b.className = "snap-node-badge"; b.textContent = "Node"; labelDiv.appendChild(b); } } }); input.addEventListener("blur", commit); }); const time = document.createElement("div"); time.className = "snap-item-time"; time.textContent = formatTime(rec.timestamp); const date = document.createElement("div"); date.className = "snap-item-date"; date.textContent = formatDate(rec.timestamp); const meta = document.createElement("div"); meta.className = "snap-item-meta"; const changeLabel = (CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown).label; meta.textContent = `${rec.nodeCount} nodes \u00b7 ${changeLabel}`; const notesDiv = document.createElement("div"); notesDiv.className = "snap-item-notes"; if (rec.notes) { notesDiv.textContent = rec.notes; notesDiv.title = rec.notes; } else { notesDiv.style.display = "none"; } info.appendChild(labelDiv); info.appendChild(time); info.appendChild(date); info.appendChild(meta); info.appendChild(notesDiv); const actions = document.createElement("div"); actions.className = "snap-item-actions"; const noteBtn = document.createElement("button"); noteBtn.className = "snap-btn-note" + (rec.notes ? " has-note" : ""); noteBtn.textContent = "\uD83D\uDCDD"; noteBtn.title = rec.notes ? "Edit note" : "Add note"; noteBtn.addEventListener("click", (e) => { e.stopPropagation(); // Toggle: if textarea already open, close it const existing = info.querySelector(".snap-item-notes-input"); if (existing) { existing.remove(); return; } const textarea = document.createElement("textarea"); textarea.className = "snap-item-notes-input"; textarea.value = rec.notes || ""; info.appendChild(textarea); textarea.focus(); let saved = false; const saveNote = async () => { if (saved) return; saved = true; const newNotes = textarea.value.trim(); rec.notes = newNotes || undefined; await db_put(rec); await refresh(); }; textarea.addEventListener("keydown", (ev) => { if (ev.key === "Enter" && ev.ctrlKey) { ev.preventDefault(); textarea.blur(); } if (ev.key === "Escape") { ev.preventDefault(); saved = true; textarea.remove(); } }); textarea.addEventListener("blur", saveNote); }); const lockBtn = document.createElement("button"); lockBtn.className = rec.locked ? "snap-btn-lock snap-btn-locked" : "snap-btn-lock"; lockBtn.textContent = rec.locked ? "\uD83D\uDD12" : "\uD83D\uDD13"; lockBtn.title = rec.locked ? "Unlock snapshot" : "Lock snapshot"; lockBtn.addEventListener("click", async () => { rec.locked = !rec.locked; await db_put(rec); await refresh(); }); const swapBtn = document.createElement("button"); swapBtn.className = "snap-btn-swap"; swapBtn.textContent = "Swap"; swapBtn.title = "Replace current workflow in-place"; swapBtn.addEventListener("click", async () => { setActionButtonsDisabled(true); await swapSnapshot(rec); }); const restoreBtn = document.createElement("button"); restoreBtn.className = "snap-btn-restore"; restoreBtn.textContent = "Restore"; restoreBtn.title = "Open as new workflow"; restoreBtn.addEventListener("click", async () => { setActionButtonsDisabled(true); await restoreSnapshot(rec); }); const deleteBtn = document.createElement("button"); deleteBtn.className = "snap-btn-delete"; deleteBtn.textContent = "\u2715"; deleteBtn.title = "Delete this snapshot"; deleteBtn.addEventListener("click", async () => { if (rec.locked) { const confirmed = await showConfirmDialog("This snapshot is locked. Delete anyway?"); if (!confirmed) return; } await db_delete(rec.workflowKey, rec.id); pickerDirty = true; await refresh(); }); const diffBtn = document.createElement("button"); diffBtn.className = "snap-btn-diff" + (diffBaseSnapshot && diffBaseSnapshot.id === rec.id ? " snap-diff-base-active" : ""); diffBtn.textContent = "Diff"; diffBtn.title = diffBaseSnapshot && diffBaseSnapshot.id !== rec.id ? `Compare '${diffBaseSnapshot.label}' vs this snapshot` : "Compare vs current workflow (Shift+click to set as base)"; diffBtn.addEventListener("click", (e) => { e.stopPropagation(); if (e.shiftKey) { // Toggle base selection if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) { diffBaseSnapshot = null; showToast("Diff base cleared", "info"); } else { diffBaseSnapshot = rec; showToast(`Diff base set: "${rec.label}"`, "info"); } refresh(); return; } // Normal click let baseGraph, targetGraph, baseLabel, targetLabel; if (diffBaseSnapshot && diffBaseSnapshot.id !== rec.id) { // Two-snapshot compare: base vs this baseGraph = diffBaseSnapshot.graphData || {}; targetGraph = rec.graphData || {}; baseLabel = diffBaseSnapshot.label; targetLabel = rec.label; diffBaseSnapshot = null; refresh(); // clear highlight } else { // Compare this snapshot vs current live workflow baseGraph = rec.graphData || {}; targetGraph = getGraphData() || {}; baseLabel = rec.label; targetLabel = "Current Workflow"; } const diff = computeDetailedDiff(baseGraph, targetGraph); const allNodes = buildNodeLookup(baseGraph, targetGraph); showDiffModal(baseLabel, targetLabel, diff, allNodes); }); actions.appendChild(noteBtn); actions.appendChild(diffBtn); actions.appendChild(lockBtn); actions.appendChild(swapBtn); actions.appendChild(restoreBtn); actions.appendChild(deleteBtn); item.appendChild(info); item.appendChild(actions); list.appendChild(item); itemEntries.push({ element: item, label: rec.label, notes: rec.notes || "" }); } // Re-apply current search filter to newly built items const currentTerm = searchInput.value.toLowerCase(); if (currentTerm) { filterItems(currentTerm); } } sidebarRefresh = refresh; await refresh(true); } // ─── Timeline Bar ──────────────────────────────────────────────────── function buildTimeline() { // Guard against duplicate calls if (timelineEl) return; injectStyles(); const canvasParent = app.canvas?.canvas?.parentElement; if (!canvasParent) { console.warn(`[${EXTENSION_NAME}] Cannot build timeline: canvas parent not found`); return; } // Ensure parent is positioned so absolute children work const parentPos = getComputedStyle(canvasParent).position; if (parentPos === "static") { canvasParent.style.position = "relative"; } // Create root element const bar = document.createElement("div"); bar.className = "snap-timeline"; bar.style.display = showTimeline ? "" : "none"; const track = document.createElement("div"); track.className = "snap-timeline-track"; const snapBtn = document.createElement("button"); snapBtn.className = "snap-timeline-snap-btn"; snapBtn.textContent = "Snapshot"; snapBtn.title = "Take a manual snapshot (Ctrl+S)"; snapBtn.addEventListener("click", async () => { snapBtn.disabled = true; const saved = await captureSnapshot("Manual"); if (saved) showToast("Snapshot saved", "success"); snapBtn.disabled = false; }); bar.appendChild(track); bar.appendChild(snapBtn); canvasParent.appendChild(bar); timelineEl = bar; async function refresh() { if (!showTimeline) return; const records = await db_getAllForWorkflow(getWorkflowKey()); records.sort((a, b) => a.timestamp - b.timestamp); track.innerHTML = ""; if (records.length === 0) { const empty = document.createElement("span"); empty.className = "snap-timeline-empty"; empty.textContent = "No snapshots"; track.appendChild(empty); return; } for (const rec of records) { const marker = document.createElement("div"); marker.className = "snap-timeline-marker"; // Change-type icon and color const iconInfo = CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown; marker.style.setProperty("--snap-marker-color", iconInfo.color); marker.innerHTML = iconInfo.svg; // Node snapshot styling — override color to purple but keep the SVG icon if (rec.source === "node") { marker.classList.add("snap-timeline-marker-node"); marker.style.setProperty("--snap-marker-color", "#6d28d9"); } // Locked snapshot styling if (rec.locked) { marker.classList.add("snap-timeline-marker-locked"); } // Active snapshot styling (the one swapped TO) if (rec.id === activeSnapshotId) { marker.classList.add("snap-timeline-marker-active"); } // Current snapshot styling (auto-saved "you were here" bookmark) if (rec.id === currentSnapshotId) { marker.classList.add("snap-timeline-marker-current"); marker.style.setProperty("--snap-marker-color", "#10b981"); } // Native tooltip with change-type description let tip = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`; if (rec.notes) tip += `\n${rec.notes}`; marker.title = tip; // Click to swap marker.addEventListener("click", () => { swapSnapshot(rec); }); track.appendChild(marker); } } timelineRefresh = refresh; refresh().catch(() => {}); } // ─── Extension Registration ────────────────────────────────────────── if (window.__COMFYUI_FRONTEND_VERSION__) { app.registerExtension({ name: EXTENSION_NAME, settings: [ { id: "SnapshotManager.autoCapture", name: "Auto-capture on edit", type: "boolean", defaultValue: true, category: ["Snapshot Manager", "Capture Settings", "Auto-capture on edit"], onChange(value) { autoCaptureEnabled = value; }, }, { id: "SnapshotManager.debounceSeconds", name: "Capture delay (seconds)", type: "slider", defaultValue: 3, attrs: { min: 1, max: 30, step: 1 }, category: ["Snapshot Manager", "Capture Settings", "Capture delay (seconds)"], onChange(value) { debounceMs = value * 1000; }, }, { id: "SnapshotManager.maxSnapshots", name: "Max snapshots per workflow", type: "slider", defaultValue: 50, attrs: { min: 5, max: 200, step: 5 }, category: ["Snapshot Manager", "Capture Settings", "Max snapshots per workflow"], onChange(value) { maxSnapshots = value; }, }, { id: "SnapshotManager.captureOnLoad", name: "Capture on workflow load", type: "boolean", defaultValue: true, category: ["Snapshot Manager", "Capture Settings", "Capture on workflow load"], onChange(value) { captureOnLoad = value; }, }, { id: "SnapshotManager.maxNodeSnapshots", name: "Max node-triggered snapshots per workflow", type: "slider", defaultValue: 5, attrs: { min: 1, max: 50, step: 1 }, category: ["Snapshot Manager", "Capture Settings", "Max node-triggered snapshots"], onChange(value) { maxNodeSnapshots = value; }, }, { id: "SnapshotManager.showTimeline", name: "Show snapshot timeline on canvas", type: "boolean", defaultValue: false, category: ["Snapshot Manager", "Timeline", "Show snapshot timeline on canvas"], onChange(value) { showTimeline = value; if (timelineEl) timelineEl.style.display = value ? "" : "none"; if (value && timelineRefresh) timelineRefresh().catch(() => {}); }, }, ], init() { app.extensionManager.registerSidebarTab({ id: "snapshot-manager", icon: "pi pi-history", title: "Snapshots", tooltip: "Browse and restore workflow snapshots", type: "custom", render: async (el) => { await buildSidebar(el); }, destroy: () => { sidebarRefresh = null; viewingWorkflowKey = null; }, }); }, async setup() { // Migrate old IndexedDB data to server on first load await migrateFromIndexedDB(); // Listen for graph changes (dispatched by ChangeTracker via api) api.addEventListener("graphChanged", () => { scheduleCaptureSnapshot(); }); // Listen for node-triggered snapshot captures via WebSocket api.addEventListener("snapshot-manager-capture", (event) => { const label = event.detail?.label || "Node Trigger"; captureNodeSnapshot(label).catch((err) => { console.warn(`[${EXTENSION_NAME}] Node-triggered capture failed:`, err); }); }); // Listen for workflow switches via Pinia store action const workflowStore = app.extensionManager?.workflow; if (workflowStore?.$onAction) { workflowStore.$onAction(({ name, after }) => { if (name === "openWorkflow") { after(() => { // Cancel any pending capture from the previous workflow if (captureTimer) { clearTimeout(captureTimer); captureTimer = null; } viewingWorkflowKey = null; activeSnapshotId = null; currentSnapshotId = null; diffBaseSnapshot = null; if (sidebarRefresh) { sidebarRefresh(true).catch(() => {}); } if (timelineRefresh) { timelineRefresh().catch(() => {}); } }); } }); } // Ctrl+S / Cmd+S shortcut for manual snapshot document.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { captureSnapshot("Manual (Ctrl+S)").then((saved) => { if (saved) showToast("Snapshot saved", "success"); }).catch(() => {}); // Don't preventDefault — let ComfyUI's own workflow save still fire } }); // Build the timeline bar on the canvas buildTimeline(); // Capture initial state after a short delay (decoupled from debounceMs) setTimeout(() => { if (!captureOnLoad) return; captureSnapshot("Initial").catch((err) => { console.warn(`[${EXTENSION_NAME}] Initial capture failed:`, err); }); }, INITIAL_CAPTURE_DELAY_MS); }, }); } else { // Legacy frontend: register without sidebar app.registerExtension({ name: EXTENSION_NAME, async setup() { console.log(`[${EXTENSION_NAME}] Sidebar requires modern ComfyUI frontend, skipping.`); }, }); }