Files
Comfyui-Workflow-Snapshot-M…/js/snapshot_manager.js
Ethanfel ee4051ad72 Add timeline active marker, auto-save before swap, snapshot button & Ctrl+S
- Track active (white ring) and current (green dot) snapshots on timeline
- Auto-capture "Current" state before swapping so user can navigate back
- Add "Snapshot" button to timeline bar for quick manual captures
- Register Ctrl+S / Cmd+S shortcut for manual snapshots
- Clear active/current markers on new captures and workflow switches
- Return record.id from captureSnapshot (backward-compatible truthy value)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:40:03 +01:00

1514 lines
47 KiB
JavaScript

/**
* 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();
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);
}
// ─── 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 record = {
id: generateId(),
workflowKey,
timestamp: Date.now(),
label,
nodeCount: nodes.length,
graphData,
locked: false,
};
try {
await db_put(record);
await pruneSnapshots(workflowKey);
} catch {
return false;
}
lastCapturedHashMap.set(workflowKey, hash);
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 record = {
id: generateId(),
workflowKey,
timestamp: Date.now(),
label,
nodeCount: nodes.length,
graphData,
locked: false,
source: "node",
};
try {
await db_put(record);
await pruneNodeSnapshots(workflowKey);
} catch {
return false;
}
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)));
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)));
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: 10;
pointer-events: auto;
}
.snap-timeline-track {
flex: 1;
height: 100%;
position: relative;
}
.snap-timeline-marker {
position: absolute;
top: 50%;
width: 10px;
height: 10px;
border-radius: 50%;
background: #3b82f6;
transform: translate(-50%, -50%);
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
border: 2px solid transparent;
}
.snap-timeline-marker:hover {
transform: translate(-50%, -50%) scale(1.5);
box-shadow: 0 0 6px 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: translate(-50%, -50%) scale(1.3);
}
.snap-timeline-marker-active:hover {
transform: translate(-50%, -50%) 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;
}
`;
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";
meta.textContent = `${rec.nodeCount} nodes`;
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;
}
const minTs = records[0].timestamp;
const maxTs = records[records.length - 1].timestamp;
const range = maxTs - minTs;
for (const rec of records) {
const marker = document.createElement("div");
marker.className = "snap-timeline-marker";
// Position: spread evenly if all same timestamp, otherwise by time
const pct = range > 0
? ((rec.timestamp - minTs) / range) * 100
: 50;
marker.style.left = `${pct}%`;
// Node snapshot styling
if (rec.source === "node") {
marker.classList.add("snap-timeline-marker-node");
}
// 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");
}
// Native tooltip
marker.title = `${rec.label}${formatTime(rec.timestamp)}`;
// 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.`);
},
});
}