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

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:
2026-02-24 19:29:44 +01:00
parent e5ba68c356
commit b90ebba068
3 changed files with 294 additions and 12 deletions

View File

@@ -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(() => {});
}