app.workflowManager doesn't exist in the modern Vue-based frontend.
The active workflow is accessed via the Pinia store at
app.extensionManager.workflow.activeWorkflow. Also replaces the
non-existent addEventListener("changeWorkflow") with Pinia's
$onAction watching for "openWorkflow" actions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1171 lines
36 KiB
JavaScript
1171 lines
36 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;
|
|
|
|
// ─── 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
|
|
|
|
// ─── 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 }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] 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(() => {});
|
|
}
|
|
}, 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;
|
|
|
|
if (sidebarRefresh) {
|
|
sidebarRefresh().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) {
|
|
await withRestoreLock(async () => {
|
|
if (!validateSnapshotData(record.graphData)) {
|
|
showToast("Invalid snapshot data", "error");
|
|
return;
|
|
}
|
|
try {
|
|
const workflow = app.workflowManager?.activeWorkflow;
|
|
await app.loadGraphData(record.graphData, true, true, workflow);
|
|
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
|
|
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-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);
|
|
}
|
|
`;
|
|
|
|
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);
|
|
|
|
countSpan.textContent = `${records.length} / ${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 = "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;
|
|
|
|
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);
|
|
}
|
|
|
|
// ─── 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;
|
|
},
|
|
},
|
|
],
|
|
|
|
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 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;
|
|
if (sidebarRefresh) {
|
|
sidebarRefresh(true).catch(() => {});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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.`);
|
|
},
|
|
});
|
|
}
|