/** * 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 // ─── 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"; } // ─── 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); } } // ─── 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) { // Auto-save current state before swapping (so user can get back) const prevCurrentId = currentSnapshotId; const capturedId = await captureSnapshot("Current"); // captureSnapshot clears currentSnapshotId; restore or update it 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-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: 0; left: 0; right: 0; height: 32px; background: rgba(15, 23, 42, 0.85); border-top: 1px solid var(--border-color, #334155); display: flex; align-items: center; padding: 0 16px; z-index: 1000; 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; } `; 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.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"; 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); } 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}`; info.appendChild(labelDiv); info.appendChild(time); info.appendChild(date); info.appendChild(meta); const actions = document.createElement("div"); actions.className = "snap-item-actions"; 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(); }); 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 }); } // 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 marker.title = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`; // 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; 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.`); }, }); }