Add snapshot branching and profile/session manager

Branching: snapshots now track parentId to form a tree structure.
Swapping to an old snapshot and editing forks into a new branch.
Sidebar and timeline show < 1/3 > navigators at fork points to
switch between branches. Pruning protects ancestors and fork points.
Deleting a fork point re-parents its children.

Profiles: save/load named sets of workflows as session profiles.
Backend stores profiles as JSON in data/profiles/. Sidebar has a
collapsible Profiles section with save, load, and delete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 11:51:00 +01:00
parent 7518821447
commit bca7e7cf8f
3 changed files with 853 additions and 22 deletions

View File

@@ -42,6 +42,9 @@ let diffBaseSnapshot = null; // snapshot record selected as diff base (shift+c
const svgCache = new Map(); // "snapshotId:WxH" -> SVGElement template
let svgClipCounter = 0; // unique prefix for SVG clipPath IDs
let sidebarTooltipEl = null; // tooltip element for sidebar hover previews
const lastCapturedIdMap = new Map(); // workflowKey -> id of most recent capture (for parentId chaining)
const activeBranchSelections = new Map(); // forkPointId -> selected child index
const sessionWorkflows = new Map(); // workflowKey -> { firstSeen, lastSeen }
// ─── Server API Layer ───────────────────────────────────────────────
@@ -164,12 +167,12 @@ async function db_getFullRecord(workflowKey, id) {
}
}
async function pruneSnapshots(workflowKey) {
async function pruneSnapshots(workflowKey, protectedIds = []) {
try {
const resp = await api.fetchApi("/snapshot-manager/prune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workflowKey, maxSnapshots, source: "regular" }),
body: JSON.stringify({ workflowKey, maxSnapshots, source: "regular", protectedIds }),
});
if (!resp.ok) {
const err = await resp.json();
@@ -180,12 +183,12 @@ async function pruneSnapshots(workflowKey) {
}
}
async function pruneNodeSnapshots(workflowKey) {
async function pruneNodeSnapshots(workflowKey, protectedIds = []) {
try {
const resp = await api.fetchApi("/snapshot-manager/prune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workflowKey, maxSnapshots: maxNodeSnapshots, source: "node" }),
body: JSON.stringify({ workflowKey, maxSnapshots: maxNodeSnapshots, source: "node", protectedIds }),
});
if (!resp.ok) {
const err = await resp.json();
@@ -196,6 +199,66 @@ async function pruneNodeSnapshots(workflowKey) {
}
}
// ─── Profile API Layer ───────────────────────────────────────────────
async function profile_save(profile) {
try {
const resp = await api.fetchApi("/snapshot-manager/profile/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile }),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || resp.statusText);
}
} catch (err) {
console.warn(`[${EXTENSION_NAME}] Profile save failed:`, err);
showToast("Failed to save profile", "error");
throw err;
}
}
async function profile_list() {
try {
const resp = await api.fetchApi("/snapshot-manager/profile/list");
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}] Profile list failed:`, err);
return [];
}
}
async function profile_delete(profileId) {
try {
const resp = await api.fetchApi("/snapshot-manager/profile/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: profileId }),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || resp.statusText);
}
} catch (err) {
console.warn(`[${EXTENSION_NAME}] Profile delete failed:`, err);
showToast("Failed to delete profile", "error");
}
}
function trackSessionWorkflow(workflowKey) {
const now = Date.now();
if (sessionWorkflows.has(workflowKey)) {
sessionWorkflows.get(workflowKey).lastSeen = now;
} else {
sessionWorkflows.set(workflowKey, { firstSeen: now, lastSeen: now });
}
}
// ─── IndexedDB Migration ────────────────────────────────────────────
async function migrateFromIndexedDB() {
@@ -822,6 +885,92 @@ function getCachedSVG(snapshotId, graphData, options = {}) {
return null;
}
// ─── Snapshot Tree (Branching) ───────────────────────────────────────
function buildSnapshotTree(records) {
const childrenOf = new Map(); // parentId -> [children records]
const parentOf = new Map(); // id -> parentId
const roots = [];
const byId = new Map();
for (const r of records) byId.set(r.id, r);
// Separate legacy (no parentId) from branched records
const legacy = [];
const branched = [];
for (const r of records) {
if (r.parentId === undefined || r.parentId === null) {
legacy.push(r);
} else {
branched.push(r);
}
}
// Chain legacy snapshots by timestamp order (backwards compat)
legacy.sort((a, b) => a.timestamp - b.timestamp);
for (let i = 0; i < legacy.length; i++) {
const r = legacy[i];
const syntheticParent = i > 0 ? legacy[i - 1].id : null;
if (syntheticParent) {
parentOf.set(r.id, syntheticParent);
if (!childrenOf.has(syntheticParent)) childrenOf.set(syntheticParent, []);
childrenOf.get(syntheticParent).push(r);
} else {
roots.push(r);
}
}
// Process branched records
for (const r of branched) {
parentOf.set(r.id, r.parentId);
if (byId.has(r.parentId)) {
if (!childrenOf.has(r.parentId)) childrenOf.set(r.parentId, []);
childrenOf.get(r.parentId).push(r);
} else {
// Parent not found (deleted?), treat as root
roots.push(r);
}
}
// Sort children by timestamp at each fork point
for (const [, children] of childrenOf) {
children.sort((a, b) => a.timestamp - b.timestamp);
}
return { childrenOf, parentOf, roots, byId };
}
function getDisplayPath(tree, branchSelections) {
const { childrenOf, roots } = tree;
if (roots.length === 0) return [];
// Pick root (should normally be 1, but handle multiple)
const rootIndex = branchSelections.get("__root__") ?? 0;
let current = roots[Math.min(rootIndex, roots.length - 1)];
if (!current) return [];
const path = [current];
while (true) {
const children = childrenOf.get(current.id);
if (!children || children.length === 0) break;
const selectedIndex = branchSelections.get(current.id) ?? 0;
current = children[Math.min(selectedIndex, children.length - 1)];
path.push(current);
}
return path;
}
function getAncestorIds(snapshotId, parentOf) {
const ancestors = new Set();
let current = snapshotId;
while (parentOf.has(current)) {
current = parentOf.get(current);
if (ancestors.has(current)) break; // safety: cycle detection
ancestors.add(current);
}
return ancestors;
}
// ─── Restore Lock ───────────────────────────────────────────────────
async function withRestoreLock(fn) {
@@ -1225,6 +1374,14 @@ async function captureSnapshot(label = "Auto") {
const prevGraph = lastGraphDataMap.get(workflowKey);
const changeType = detectChangeType(prevGraph, graphData);
// Determine parentId for branching
let parentId = null;
if (activeSnapshotId) {
parentId = activeSnapshotId; // fork from swapped snapshot
} else if (lastCapturedIdMap.has(workflowKey)) {
parentId = lastCapturedIdMap.get(workflowKey); // continuation
}
const record = {
id: generateId(),
workflowKey,
@@ -1234,17 +1391,28 @@ async function captureSnapshot(label = "Auto") {
graphData,
locked: false,
changeType,
parentId,
};
try {
await db_put(record);
await pruneSnapshots(workflowKey);
// Compute protected IDs: ancestors of this capture + fork points
const allRecs = await db_getAllForWorkflow(workflowKey);
const tempTree = buildSnapshotTree(allRecs);
const ancestors = getAncestorIds(record.id, tempTree.parentOf);
// Protect fork points (snapshots with >1 child)
for (const [pid, children] of tempTree.childrenOf) {
if (children.length > 1) ancestors.add(pid);
}
ancestors.add(record.id); // protect the just-captured snapshot
await pruneSnapshots(workflowKey, [...ancestors]);
} catch {
return false;
}
lastCapturedHashMap.set(workflowKey, hash);
lastGraphDataMap.set(workflowKey, graphData);
lastCapturedIdMap.set(workflowKey, record.id);
pickerDirty = true;
currentSnapshotId = null; // new capture supersedes "current" bookmark
activeSnapshotId = null; // graph has changed, no snapshot is "active"
@@ -1271,6 +1439,14 @@ async function captureNodeSnapshot(label = "Node Trigger") {
const prevGraph = lastGraphDataMap.get(workflowKey);
const changeType = detectChangeType(prevGraph, graphData);
// Determine parentId for branching
let parentId = null;
if (activeSnapshotId) {
parentId = activeSnapshotId;
} else if (lastCapturedIdMap.has(workflowKey)) {
parentId = lastCapturedIdMap.get(workflowKey);
}
const record = {
id: generateId(),
workflowKey,
@@ -1281,16 +1457,26 @@ async function captureNodeSnapshot(label = "Node Trigger") {
locked: false,
source: "node",
changeType,
parentId,
};
try {
await db_put(record);
await pruneNodeSnapshots(workflowKey);
// Compute protected IDs: ancestors + fork points
const allRecs = await db_getAllForWorkflow(workflowKey);
const tempTree = buildSnapshotTree(allRecs);
const protectedNodeIds = getAncestorIds(record.id, tempTree.parentOf);
for (const [pid, children] of tempTree.childrenOf) {
if (children.length > 1) protectedNodeIds.add(pid);
}
protectedNodeIds.add(record.id);
await pruneNodeSnapshots(workflowKey, [...protectedNodeIds]);
} catch {
return false;
}
lastGraphDataMap.set(workflowKey, graphData);
lastCapturedIdMap.set(workflowKey, record.id);
pickerDirty = true;
currentSnapshotId = null;
activeSnapshotId = null;
@@ -1766,7 +1952,7 @@ const CSS = `
bottom: 4px;
left: 10%;
right: 10%;
height: 32px;
height: 38px;
background: rgba(15, 23, 42, 0.85);
border: 1px solid var(--border-color, #334155);
border-radius: 8px;
@@ -1851,6 +2037,47 @@ const CSS = `
font-family: system-ui, sans-serif;
line-height: 32px;
}
.snap-timeline-fork-group {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
}
.snap-timeline-fork-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
}
.snap-timeline-branch-btn {
background: none;
border: none;
color: #3b82f6;
font-size: 10px;
cursor: pointer;
padding: 0 1px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 18px;
border-radius: 3px;
flex-shrink: 0;
opacity: 0.7;
transition: opacity 0.1s, background 0.1s;
}
.snap-timeline-branch-btn:hover {
opacity: 1;
background: rgba(59, 130, 246, 0.2);
}
.snap-timeline-branch-label {
font-size: 8px;
color: #3b82f6;
font-weight: 600;
white-space: nowrap;
line-height: 1;
}
.snap-diff-overlay {
position: fixed;
inset: 0;
@@ -2096,6 +2323,149 @@ const CSS = `
.snap-btn-preview:hover:not(:disabled) {
background: #475569;
}
.snap-branch-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 3px 10px;
background: rgba(59, 130, 246, 0.08);
border-bottom: 1px solid var(--border-color, #333);
user-select: none;
}
.snap-branch-nav button {
background: none;
border: 1px solid rgba(59, 130, 246, 0.3);
color: #3b82f6;
border-radius: 3px;
width: 22px;
height: 20px;
font-size: 11px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
}
.snap-branch-nav button:hover {
background: rgba(59, 130, 246, 0.15);
border-color: #3b82f6;
}
.snap-branch-nav-label {
font-size: 11px;
color: #3b82f6;
font-weight: 600;
min-width: 30px;
text-align: center;
}
.snap-profiles {
border-bottom: 1px solid var(--border-color, #444);
flex-shrink: 0;
}
.snap-profiles-header {
display: flex;
align-items: center;
padding: 6px 10px;
cursor: pointer;
gap: 6px;
user-select: none;
}
.snap-profiles-header:hover {
background: var(--comfy-menu-bg, #2a2a2a);
}
.snap-profiles-arrow {
font-size: 10px;
color: var(--descrip-text, #888);
flex-shrink: 0;
transition: transform 0.15s;
}
.snap-profiles-arrow.expanded {
transform: rotate(90deg);
}
.snap-profiles-title {
flex: 1;
font-size: 12px;
font-weight: 600;
color: var(--descrip-text, #888);
}
.snap-profiles-save-btn {
padding: 2px 8px;
border: 1px solid #3b82f6;
border-radius: 3px;
background: transparent;
color: #3b82f6;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.snap-profiles-save-btn:hover {
background: rgba(59, 130, 246, 0.15);
}
.snap-profiles-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.15s ease-out;
}
.snap-profiles-body.expanded {
max-height: 200px;
overflow-y: auto;
}
.snap-profile-item {
display: flex;
align-items: center;
padding: 4px 10px 4px 18px;
gap: 6px;
font-size: 12px;
}
.snap-profile-item:hover {
background: var(--comfy-menu-bg, #2a2a2a);
}
.snap-profile-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--input-text, #ccc);
}
.snap-profile-count {
font-size: 10px;
color: var(--descrip-text, #888);
flex-shrink: 0;
}
.snap-profile-load-btn {
padding: 2px 8px;
border: none;
border-radius: 3px;
background: #22c55e;
color: #fff;
font-size: 10px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
}
.snap-profile-load-btn:hover {
background: #16a34a;
}
.snap-profile-delete-btn {
background: none;
border: none;
color: var(--descrip-text, #888);
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
flex-shrink: 0;
}
.snap-profile-delete-btn:hover {
color: #dc2626;
}
.snap-profiles-empty {
padding: 6px 18px;
font-size: 11px;
color: var(--descrip-text, #888);
}
`;
const CHANGE_TYPE_ICONS = {
@@ -2159,6 +2529,44 @@ function formatDate(ts) {
return d.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
}
function buildBranchNavigator(forkPointId, children, selectedIndex, refreshFn) {
const nav = document.createElement("div");
nav.className = "snap-branch-nav";
const leftBtn = document.createElement("button");
leftBtn.textContent = "\u25C0";
leftBtn.title = "Previous branch";
leftBtn.addEventListener("click", (e) => {
e.stopPropagation();
const newIndex = Math.max(0, selectedIndex - 1);
activeBranchSelections.set(forkPointId, newIndex);
refreshFn();
if (timelineRefresh) timelineRefresh().catch(() => {});
});
if (selectedIndex <= 0) leftBtn.style.visibility = "hidden";
const label = document.createElement("span");
label.className = "snap-branch-nav-label";
label.textContent = `${selectedIndex + 1}/${children.length}`;
const rightBtn = document.createElement("button");
rightBtn.textContent = "\u25B6";
rightBtn.title = "Next branch";
rightBtn.addEventListener("click", (e) => {
e.stopPropagation();
const newIndex = Math.min(children.length - 1, selectedIndex + 1);
activeBranchSelections.set(forkPointId, newIndex);
refreshFn();
if (timelineRefresh) timelineRefresh().catch(() => {});
});
if (selectedIndex >= children.length - 1) rightBtn.style.visibility = "hidden";
nav.appendChild(leftBtn);
nav.appendChild(label);
nav.appendChild(rightBtn);
return nav;
}
async function buildSidebar(el) {
injectStyles();
// Clean up previous tooltip if sidebar is being rebuilt
@@ -2287,6 +2695,7 @@ async function buildSidebar(el) {
} else {
viewingWorkflowKey = entry.workflowKey;
}
activeBranchSelections.clear();
collapsePicker();
await refresh(true);
});
@@ -2363,10 +2772,178 @@ async function buildSidebar(el) {
});
footer.appendChild(clearBtn);
// ─── Profiles Section ──────────────────────────────────────────
const profilesSection = document.createElement("div");
profilesSection.className = "snap-profiles";
const profilesHeader = document.createElement("div");
profilesHeader.className = "snap-profiles-header";
const profilesArrow = document.createElement("span");
profilesArrow.className = "snap-profiles-arrow";
profilesArrow.textContent = "\u25B6";
const profilesTitle = document.createElement("span");
profilesTitle.className = "snap-profiles-title";
profilesTitle.textContent = "Profiles";
const profilesSaveBtn = document.createElement("button");
profilesSaveBtn.className = "snap-profiles-save-btn";
profilesSaveBtn.textContent = "Save";
profilesSaveBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const name = await showPromptDialog("Profile name:", "My Profile");
if (name == null) return;
const trimmed = name.trim() || "My Profile";
// Gather session workflows
const workflows = [];
for (const [wk, info] of sessionWorkflows) {
workflows.push({ workflowKey: wk, displayName: wk });
}
if (workflows.length === 0) {
// At least include current workflow
const currentKey = getWorkflowKey();
workflows.push({ workflowKey: currentKey, displayName: currentKey });
}
const profile = {
id: generateId(),
name: trimmed,
timestamp: Date.now(),
workflows,
activeWorkflowKey: getWorkflowKey(),
};
try {
await profile_save(profile);
showToast(`Profile "${trimmed}" saved (${workflows.length} workflow${workflows.length === 1 ? "" : "s"})`, "success");
await refreshProfiles();
} catch {
// profile_save already toasts
}
});
profilesHeader.appendChild(profilesArrow);
profilesHeader.appendChild(profilesTitle);
profilesHeader.appendChild(profilesSaveBtn);
const profilesBody = document.createElement("div");
profilesBody.className = "snap-profiles-body";
let profilesExpanded = false;
profilesHeader.addEventListener("click", async () => {
profilesExpanded = !profilesExpanded;
profilesArrow.classList.toggle("expanded", profilesExpanded);
profilesBody.classList.toggle("expanded", profilesExpanded);
if (profilesExpanded) await refreshProfiles();
});
async function refreshProfiles() {
profilesBody.innerHTML = "";
const profiles = await profile_list();
if (profiles.length === 0) {
const empty = document.createElement("div");
empty.className = "snap-profiles-empty";
empty.textContent = "No saved profiles";
profilesBody.appendChild(empty);
return;
}
for (const p of profiles) {
const row = document.createElement("div");
row.className = "snap-profile-item";
const nameSpan = document.createElement("span");
nameSpan.className = "snap-profile-name";
nameSpan.textContent = p.name;
nameSpan.title = p.name;
const countSpanP = document.createElement("span");
countSpanP.className = "snap-profile-count";
countSpanP.textContent = `${(p.workflows || []).length} wf`;
const loadBtn = document.createElement("button");
loadBtn.className = "snap-profile-load-btn";
loadBtn.textContent = "Load";
loadBtn.addEventListener("click", async (e) => {
e.stopPropagation();
loadBtn.disabled = true;
loadBtn.textContent = "Loading...";
try {
const workflows = p.workflows || [];
let loaded = 0;
let skipped = 0;
// Load non-active workflows first (each overwrites previous —
// ComfyUI can only display one workflow at a time, but loading
// them populates the workflow history/tabs in some frontends)
for (const wf of workflows) {
// Skip active workflow — loaded last so it ends up visible
if (wf.workflowKey === p.activeWorkflowKey) continue;
const records = await db_getAllForWorkflow(wf.workflowKey);
if (records.length === 0) { skipped++; continue; }
records.sort((a, b) => b.timestamp - a.timestamp);
const full = await db_getFullRecord(records[0].workflowKey, records[0].id);
if (!full || !full.graphData) { skipped++; continue; }
try {
await app.loadGraphData(full.graphData, true, true);
loaded++;
} catch { skipped++; }
}
// Load the active workflow last so it's the one visible
if (p.activeWorkflowKey) {
const activeRecs = await db_getAllForWorkflow(p.activeWorkflowKey);
if (activeRecs.length > 0) {
activeRecs.sort((a, b) => b.timestamp - a.timestamp);
const activeFull = await db_getFullRecord(activeRecs[0].workflowKey, activeRecs[0].id);
if (activeFull?.graphData) {
try {
await app.loadGraphData(activeFull.graphData, true, true);
loaded++;
} catch { skipped++; }
} else { skipped++; }
} else { skipped++; }
}
let msg = `Profile "${p.name}" loaded (${loaded} workflow${loaded === 1 ? "" : "s"})`;
if (skipped > 0) msg += `, ${skipped} skipped`;
showToast(msg, "success");
} catch (err) {
console.warn(`[${EXTENSION_NAME}] Profile load failed:`, err);
showToast("Failed to load profile", "error");
} finally {
loadBtn.disabled = false;
loadBtn.textContent = "Load";
}
});
const deleteBtn2 = document.createElement("button");
deleteBtn2.className = "snap-profile-delete-btn";
deleteBtn2.textContent = "\u2715";
deleteBtn2.title = "Delete profile";
deleteBtn2.addEventListener("click", async (e) => {
e.stopPropagation();
const confirmed = await showConfirmDialog(`Delete profile "${p.name}"?`);
if (!confirmed) return;
await profile_delete(p.id);
showToast(`Profile "${p.name}" deleted`, "info");
await refreshProfiles();
});
row.appendChild(nameSpan);
row.appendChild(countSpanP);
row.appendChild(loadBtn);
row.appendChild(deleteBtn2);
profilesBody.appendChild(row);
}
}
profilesSection.appendChild(profilesHeader);
profilesSection.appendChild(profilesBody);
container.appendChild(header);
container.appendChild(selectorRow);
container.appendChild(pickerList);
container.appendChild(viewingBanner);
container.appendChild(profilesSection);
container.appendChild(searchRow);
container.appendChild(list);
container.appendChild(footer);
@@ -2398,12 +2975,10 @@ async function buildSidebar(el) {
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 allRecords = await db_getAllForWorkflow(effKey);
const regularCount = records.filter(r => r.source !== "node").length;
const nodeCount = records.filter(r => r.source === "node").length;
const regularCount = allRecords.filter(r => r.source !== "node").length;
const nodeCount = allRecords.filter(r => r.source === "node").length;
countSpan.textContent = nodeCount > 0
? `${regularCount}/${maxSnapshots} + ${nodeCount}/${maxNodeSnapshots} node`
: `${regularCount} / ${maxSnapshots}`;
@@ -2433,7 +3008,7 @@ async function buildSidebar(el) {
list.innerHTML = "";
itemEntries = [];
if (records.length === 0) {
if (allRecords.length === 0) {
const empty = document.createElement("div");
empty.className = "snap-empty";
empty.textContent = "No snapshots yet. Edit the workflow or click 'Take Snapshot'.";
@@ -2441,7 +3016,26 @@ async function buildSidebar(el) {
return;
}
// Build tree and get display path for current branch
const tree = buildSnapshotTree(allRecords);
const displayPath = getDisplayPath(tree, activeBranchSelections);
// newest first for display
const records = [...displayPath].reverse();
// Build set of fork point IDs and record positions for branch nav insertion
const forkPointIds = new Set();
for (const [parentId, children] of tree.childrenOf) {
if (children.length > 1) forkPointIds.add(parentId);
}
for (const rec of records) {
// Insert branch navigator above fork-point snapshots
if (forkPointIds.has(rec.id)) {
const children = tree.childrenOf.get(rec.id);
const selectedIndex = Math.min(activeBranchSelections.get(rec.id) ?? 0, children.length - 1);
const nav = buildBranchNavigator(rec.id, children, selectedIndex, refresh);
list.appendChild(nav);
}
const item = document.createElement("div");
item.className = rec.source === "node" ? "snap-item snap-item-node" : "snap-item";
if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) {
@@ -2613,6 +3207,20 @@ async function buildSidebar(el) {
const confirmed = await showConfirmDialog("This snapshot is locked. Delete anyway?");
if (!confirmed) return;
}
// Fork-point deletion: rebuild tree from fresh data, then re-parent children
const freshRecords = await db_getAllForWorkflow(rec.workflowKey);
const freshTree = buildSnapshotTree(freshRecords);
const children = freshTree.childrenOf.get(rec.id);
if (children && children.length > 0) {
const confirmed = await showConfirmDialog(
`This snapshot is a branch point with ${children.length} child snapshot(s). Deleting it will re-parent them. Continue?`
);
if (!confirmed) return;
const newParent = freshTree.parentOf.get(rec.id) ?? null;
for (const child of children) {
await db_updateMeta(rec.workflowKey, child.id, { parentId: newParent });
}
}
await db_delete(rec.workflowKey, rec.id);
pickerDirty = true;
await refresh();
@@ -2784,12 +3392,11 @@ function buildTimeline() {
async function refresh() {
if (!showTimeline) return;
const records = await db_getAllForWorkflow(getWorkflowKey());
records.sort((a, b) => a.timestamp - b.timestamp);
const allRecords = await db_getAllForWorkflow(getWorkflowKey());
track.innerHTML = "";
if (records.length === 0) {
if (allRecords.length === 0) {
const empty = document.createElement("span");
empty.className = "snap-timeline-empty";
empty.textContent = "No snapshots";
@@ -2797,6 +3404,16 @@ function buildTimeline() {
return;
}
// Show only current branch's markers
const tree = buildSnapshotTree(allRecords);
const records = getDisplayPath(tree, activeBranchSelections);
// Identify fork points on this path
const forkPointSet = new Set();
for (const [parentId, children] of tree.childrenOf) {
if (children.length > 1) forkPointSet.add(parentId);
}
for (const rec of records) {
const marker = document.createElement("div");
marker.className = "snap-timeline-marker";
@@ -2838,9 +3455,58 @@ function buildTimeline() {
swapSnapshot(rec);
});
// Fork point: wrap marker with branch arrows
if (forkPointSet.has(rec.id)) {
const children = tree.childrenOf.get(rec.id);
const selectedIndex = Math.min(activeBranchSelections.get(rec.id) ?? 0, children.length - 1);
const group = document.createElement("div");
group.className = "snap-timeline-fork-group";
// Left arrow
const leftBtn = document.createElement("button");
leftBtn.className = "snap-timeline-branch-btn";
leftBtn.textContent = "\u25C0";
leftBtn.title = "Previous branch";
if (selectedIndex <= 0) leftBtn.style.visibility = "hidden";
leftBtn.addEventListener("click", (e) => {
e.stopPropagation();
activeBranchSelections.set(rec.id, Math.max(0, selectedIndex - 1));
refresh();
if (sidebarRefresh) sidebarRefresh().catch(() => {});
});
// Right arrow
const rightBtn = document.createElement("button");
rightBtn.className = "snap-timeline-branch-btn";
rightBtn.textContent = "\u25B6";
rightBtn.title = "Next branch";
if (selectedIndex >= children.length - 1) rightBtn.style.visibility = "hidden";
rightBtn.addEventListener("click", (e) => {
e.stopPropagation();
activeBranchSelections.set(rec.id, Math.min(children.length - 1, selectedIndex + 1));
refresh();
if (sidebarRefresh) sidebarRefresh().catch(() => {});
});
// Center column: marker + branch label stacked vertically
const center = document.createElement("div");
center.className = "snap-timeline-fork-center";
const branchLabel = document.createElement("span");
branchLabel.className = "snap-timeline-branch-label";
branchLabel.textContent = `${selectedIndex + 1}/${children.length}`;
center.appendChild(marker);
center.appendChild(branchLabel);
group.appendChild(leftBtn);
group.appendChild(center);
group.appendChild(rightBtn);
track.appendChild(group);
} else {
track.appendChild(marker);
}
}
}
timelineRefresh = refresh;
refresh().catch(() => {});
@@ -2964,6 +3630,7 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
if (workflowStore?.$onAction) {
workflowStore.$onAction(({ name, after }) => {
if (name === "openWorkflow") {
const prevKey = getWorkflowKey(); // capture BEFORE switch
after(() => {
// Cancel any pending capture from the previous workflow
if (captureTimer) {
@@ -2974,6 +3641,11 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
activeSnapshotId = null;
currentSnapshotId = null;
diffBaseSnapshot = null;
// Clear branching state for the old workflow
lastCapturedIdMap.delete(prevKey);
activeBranchSelections.clear();
// Track session workflow (new key, after switch)
trackSessionWorkflow(getWorkflowKey());
if (sidebarRefresh) {
sidebarRefresh(true).catch(() => {});
}
@@ -2998,6 +3670,9 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
// Build the timeline bar on the canvas
buildTimeline();
// Track initial workflow for profiles
trackSessionWorkflow(getWorkflowKey());
// Capture initial state after a short delay (decoupled from debounceMs)
setTimeout(() => {
if (!captureOnLoad) return;

View File

@@ -122,9 +122,10 @@ async def prune_snapshots(request):
workflow_key = data.get("workflowKey")
max_snapshots = data.get("maxSnapshots")
source = data.get("source")
protected_ids = data.get("protectedIds")
if not workflow_key or max_snapshots is None:
return web.json_response({"error": "Missing workflowKey or maxSnapshots"}, status=400)
deleted = storage.prune(workflow_key, int(max_snapshots), source=source)
deleted = storage.prune(workflow_key, int(max_snapshots), source=source, protected_ids=protected_ids)
return web.json_response({"deleted": deleted})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@@ -147,3 +148,61 @@ async def migrate_snapshots(request):
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ─── Profile Endpoints ───────────────────────────────────────────────
@routes.post("/snapshot-manager/profile/save")
async def save_profile(request):
try:
data = await request.json()
profile = data.get("profile")
if not profile or "id" not in profile:
return web.json_response({"error": "Missing profile with id"}, status=400)
storage.profile_put(profile)
return web.json_response({"ok": True})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.get("/snapshot-manager/profile/list")
async def list_profiles(request):
try:
profiles = storage.profile_get_all()
return web.json_response(profiles)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/profile/get")
async def get_profile(request):
try:
data = await request.json()
profile_id = data.get("id")
if not profile_id:
return web.json_response({"error": "Missing id"}, status=400)
profile = storage.profile_get(profile_id)
if profile is None:
return web.json_response({"error": "Not found"}, status=404)
return web.json_response(profile)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/profile/delete")
async def delete_profile(request):
try:
data = await request.json()
profile_id = data.get("id")
if not profile_id:
return web.json_response({"error": "Missing id"}, status=400)
storage.profile_delete(profile_id)
return web.json_response({"ok": True})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)

View File

@@ -202,21 +202,25 @@ def get_all_workflow_keys():
return results
def prune(workflow_key, max_snapshots, source=None):
def prune(workflow_key, max_snapshots, source=None, protected_ids=None):
"""Delete oldest unlocked snapshots beyond limit. Returns count deleted.
source filtering:
- "node": only prune records where source == "node"
- "regular": only prune records where source is absent or not "node"
- None: prune all unlocked (existing behavior)
protected_ids: set/list of snapshot IDs that must not be pruned
(e.g. ancestors of active branch tip, fork-point snapshots).
"""
_protected = set(protected_ids) if protected_ids else set()
entries = _ensure_cached(workflow_key)
if source == "node":
candidates = [r for r in entries if not r.get("locked") and r.get("source") == "node"]
candidates = [r for r in entries if not r.get("locked") and r.get("source") == "node" and r.get("id") not in _protected]
elif source == "regular":
candidates = [r for r in entries if not r.get("locked") and r.get("source") != "node"]
candidates = [r for r in entries if not r.get("locked") and r.get("source") != "node" and r.get("id") not in _protected]
else:
candidates = [r for r in entries if not r.get("locked")]
candidates = [r for r in entries if not r.get("locked") and r.get("id") not in _protected]
if len(candidates) <= max_snapshots:
return 0
to_delete = candidates[: len(candidates) - max_snapshots]
@@ -243,3 +247,96 @@ def prune(workflow_key, max_snapshots, source=None):
os.rmdir(d)
return deleted
# ─── Profile Storage ─────────────────────────────────────────────────
# Profiles are stored as individual JSON files under data/profiles/<id>.json
_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "data", "profiles")
_profile_cache = None # list of profile dicts, or None if not loaded
def _ensure_profiles_dir():
os.makedirs(_PROFILES_DIR, exist_ok=True)
def _load_profile_cache():
global _profile_cache
if _profile_cache is not None:
return _profile_cache
_ensure_profiles_dir()
profiles = []
for fname in os.listdir(_PROFILES_DIR):
if not fname.endswith(".json"):
continue
path = os.path.join(_PROFILES_DIR, fname)
try:
with open(path, "r", encoding="utf-8") as f:
profiles.append(json.load(f))
except (json.JSONDecodeError, OSError):
continue
profiles.sort(key=lambda p: p.get("timestamp", 0))
_profile_cache = profiles
return _profile_cache
def _invalidate_profile_cache():
global _profile_cache
_profile_cache = None
def profile_put(profile):
"""Create or update a profile. profile must have 'id'."""
pid = profile["id"]
_validate_id(pid)
_ensure_profiles_dir()
path = os.path.join(_PROFILES_DIR, f"{pid}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(profile, f, separators=(",", ":"))
_invalidate_profile_cache()
def profile_get_all():
"""Return all profiles sorted by timestamp."""
return [dict(p) for p in _load_profile_cache()]
def profile_get(profile_id):
"""Return a single profile by ID, or None."""
_validate_id(profile_id)
path = os.path.join(_PROFILES_DIR, f"{profile_id}.json")
if not os.path.isfile(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def profile_delete(profile_id):
"""Delete a profile by ID."""
_validate_id(profile_id)
path = os.path.join(_PROFILES_DIR, f"{profile_id}.json")
if os.path.isfile(path):
os.remove(path)
_invalidate_profile_cache()
def profile_update(profile_id, fields):
"""Merge fields into an existing profile. Returns True on success."""
_validate_id(profile_id)
path = os.path.join(_PROFILES_DIR, f"{profile_id}.json")
if not os.path.isfile(path):
return False
with open(path, "r", encoding="utf-8") as f:
profile = json.load(f)
for k, v in fields.items():
if v is None:
profile.pop(k, None)
else:
profile[k] = v
with open(path, "w", encoding="utf-8") as f:
json.dump(profile, f, separators=(",", ":"))
_invalidate_profile_cache()
return True