Add workflow browser — browse snapshots from any workflow (v1.1.0)
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Adds a workflow selector to the sidebar so users can browse and recover snapshots from any workflow in the database, including renamed or deleted ones. Includes amber viewing banner, take-snapshot guard, and lazy-loaded workflow picker with counts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
README.md
16
README.md
@@ -5,7 +5,7 @@
|
||||
<p align="center">
|
||||
<a href="https://registry.comfy.org/publishers/ethanfel/nodes/comfyui-snapshot-manager"><img src="https://img.shields.io/badge/ComfyUI-Registry-blue?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMMyA3djEwbDkgNSA5LTVWN2wtOS01eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=" alt="ComfyUI Registry"/></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"/></a>
|
||||
<img src="https://img.shields.io/badge/version-1.0.1-blue" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/version-1.1.0-blue" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/>
|
||||
</p>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- **Custom naming** — Name your snapshots when taking them manually ("Before merge", "Working v2", etc.)
|
||||
- **Search & filter** — Quickly find snapshots by name with the filter bar
|
||||
- **Restore or Swap** — Open a snapshot as a new workflow, or replace the current one in-place
|
||||
- **Workflow browser** — Browse and recover snapshots from any workflow, including renamed or deleted ones
|
||||
- **Per-workflow storage** — Each workflow has its own independent snapshot history
|
||||
- **Theme-aware UI** — Adapts to light and dark ComfyUI themes
|
||||
- **Toast notifications** — Visual feedback for save, restore, and error operations
|
||||
@@ -82,7 +83,13 @@ Click the **padlock icon** on any snapshot to lock it. Locked snapshots are prot
|
||||
|
||||
To unlock, click the padlock again. Deleting a locked snapshot individually is still possible but requires confirmation.
|
||||
|
||||
### 7. Delete & Clear
|
||||
### 7. Browse Other Workflows
|
||||
|
||||
Click the **workflow name** below the header to expand the workflow picker. It lists every workflow that has snapshots in the database, with counts. Click any workflow to view its snapshots — an amber banner confirms you're viewing a different workflow, and "Take Snapshot" is disabled to avoid confusion. Click **Back to current** to return.
|
||||
|
||||
This is especially useful for recovering snapshots from workflows that were renamed or deleted.
|
||||
|
||||
### 8. Delete & Clear
|
||||
|
||||
- Click **×** on any snapshot to delete it individually (locked snapshots prompt for confirmation)
|
||||
- Click **Clear All Snapshots** in the footer to remove all unlocked snapshots for the current workflow (locked snapshots are preserved)
|
||||
@@ -124,7 +131,10 @@ In your browser's IndexedDB under the database `ComfySnapshotManager`. They pers
|
||||
No. Snapshots are captured asynchronously after a debounce delay. The hash check prevents redundant writes.
|
||||
|
||||
**What happens if I switch workflows?**
|
||||
Each workflow has its own snapshot history. Switching workflows cancels any pending captures and shows the correct snapshot list.
|
||||
Each workflow has its own snapshot history. Switching workflows cancels any pending captures and shows the correct snapshot list. You can also browse snapshots from other workflows using the workflow picker.
|
||||
|
||||
**I renamed/deleted a workflow — are my snapshots gone?**
|
||||
No. Snapshots are keyed by the workflow name at capture time. Use the workflow picker to find and restore them under the old name.
|
||||
|
||||
**Can I use this with ComfyUI Manager?**
|
||||
Yes — install via ComfyUI Manager or clone the repo into `custom_nodes/`.
|
||||
|
||||
@@ -28,6 +28,8 @@ 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
|
||||
|
||||
// ─── IndexedDB Layer ─────────────────────────────────────────────────
|
||||
|
||||
@@ -133,6 +135,34 @@ async function db_deleteAllForWorkflow(workflowKey) {
|
||||
}
|
||||
}
|
||||
|
||||
async function db_getAllWorkflowKeys() {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const idx = tx.objectStore(STORE_NAME).index("workflowKey");
|
||||
const req = idx.openKeyCursor();
|
||||
const counts = new Map();
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (cursor) {
|
||||
counts.set(cursor.key, (counts.get(cursor.key) || 0) + 1);
|
||||
cursor.continue();
|
||||
} else {
|
||||
const result = Array.from(counts.entries())
|
||||
.map(([workflowKey, count]) => ({ workflowKey, count }))
|
||||
.sort((a, b) => a.workflowKey.localeCompare(b.workflowKey));
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[${EXTENSION_NAME}] IndexedDB key scan failed:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneSnapshots(workflowKey) {
|
||||
try {
|
||||
const all = await db_getAllForWorkflow(workflowKey);
|
||||
@@ -175,6 +205,10 @@ function getWorkflowKey() {
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveWorkflowKey() {
|
||||
return viewingWorkflowKey ?? getWorkflowKey();
|
||||
}
|
||||
|
||||
function getGraphData() {
|
||||
try {
|
||||
return app.graph.serialize();
|
||||
@@ -280,6 +314,7 @@ async function captureSnapshot(label = "Auto") {
|
||||
}
|
||||
|
||||
lastCapturedHashMap.set(workflowKey, hash);
|
||||
pickerDirty = true;
|
||||
|
||||
if (sidebarRefresh) {
|
||||
sidebarRefresh().catch(() => {});
|
||||
@@ -487,9 +522,12 @@ const CSS = `
|
||||
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 {
|
||||
color: #3b82f6;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
.snap-btn-delete {
|
||||
background: var(--comfy-menu-bg, #444);
|
||||
@@ -525,6 +563,111 @@ const CSS = `
|
||||
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() {
|
||||
@@ -568,7 +711,8 @@ async function buildSidebar(el) {
|
||||
const saved = await captureSnapshot(name);
|
||||
if (saved) showToast("Snapshot saved", "success");
|
||||
} finally {
|
||||
takeBtn.disabled = false;
|
||||
const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== getWorkflowKey();
|
||||
takeBtn.disabled = isViewingOther;
|
||||
takeBtn.textContent = "Take Snapshot";
|
||||
}
|
||||
});
|
||||
@@ -605,6 +749,106 @@ async function buildSidebar(el) {
|
||||
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";
|
||||
@@ -616,10 +860,12 @@ async function buildSidebar(el) {
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.textContent = "Clear All Snapshots";
|
||||
clearBtn.addEventListener("click", async () => {
|
||||
const confirmed = await showConfirmDialog("Delete all snapshots for this workflow?");
|
||||
const effKey = getEffectiveWorkflowKey();
|
||||
const confirmed = await showConfirmDialog(`Delete all snapshots for "${effKey}"?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const { lockedCount } = await db_deleteAllForWorkflow(getWorkflowKey());
|
||||
const { lockedCount } = await db_deleteAllForWorkflow(effKey);
|
||||
pickerDirty = true;
|
||||
if (lockedCount > 0) {
|
||||
showToast(`Cleared snapshots (${lockedCount} locked kept)`, "info");
|
||||
} else {
|
||||
@@ -633,6 +879,9 @@ async function buildSidebar(el) {
|
||||
footer.appendChild(clearBtn);
|
||||
|
||||
container.appendChild(header);
|
||||
container.appendChild(selectorRow);
|
||||
container.appendChild(pickerList);
|
||||
container.appendChild(viewingBanner);
|
||||
container.appendChild(searchRow);
|
||||
container.appendChild(list);
|
||||
container.appendChild(footer);
|
||||
@@ -656,21 +905,41 @@ async function buildSidebar(el) {
|
||||
}
|
||||
|
||||
async function refresh(resetSearch = false) {
|
||||
const workflowKey = getWorkflowKey();
|
||||
const records = await db_getAllForWorkflow(workflowKey);
|
||||
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}`;
|
||||
|
||||
list.innerHTML = "";
|
||||
itemEntries = [];
|
||||
// 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";
|
||||
@@ -748,6 +1017,7 @@ async function buildSidebar(el) {
|
||||
if (!confirmed) return;
|
||||
}
|
||||
await db_delete(rec.id);
|
||||
pickerDirty = true;
|
||||
await refresh();
|
||||
});
|
||||
|
||||
@@ -837,6 +1107,7 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
||||
},
|
||||
destroy: () => {
|
||||
sidebarRefresh = null;
|
||||
viewingWorkflowKey = null;
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -855,6 +1126,7 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
||||
clearTimeout(captureTimer);
|
||||
captureTimer = null;
|
||||
}
|
||||
viewingWorkflowKey = null;
|
||||
if (sidebarRefresh) {
|
||||
sidebarRefresh(true).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-snapshot-manager"
|
||||
description = "Automatically snapshots workflow state with a sidebar to browse and restore previous versions."
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
license = {text = "MIT"}
|
||||
|
||||
[project.urls]
|
||||
|
||||
Reference in New Issue
Block a user