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:
@@ -42,6 +42,9 @@ let diffBaseSnapshot = null; // snapshot record selected as diff base (shift+c
|
|||||||
const svgCache = new Map(); // "snapshotId:WxH" -> SVGElement template
|
const svgCache = new Map(); // "snapshotId:WxH" -> SVGElement template
|
||||||
let svgClipCounter = 0; // unique prefix for SVG clipPath IDs
|
let svgClipCounter = 0; // unique prefix for SVG clipPath IDs
|
||||||
let sidebarTooltipEl = null; // tooltip element for sidebar hover previews
|
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 ───────────────────────────────────────────────
|
// ─── Server API Layer ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,12 +167,12 @@ async function db_getFullRecord(workflowKey, id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pruneSnapshots(workflowKey) {
|
async function pruneSnapshots(workflowKey, protectedIds = []) {
|
||||||
try {
|
try {
|
||||||
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ workflowKey, maxSnapshots, source: "regular" }),
|
body: JSON.stringify({ workflowKey, maxSnapshots, source: "regular", protectedIds }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
@@ -180,12 +183,12 @@ async function pruneSnapshots(workflowKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pruneNodeSnapshots(workflowKey) {
|
async function pruneNodeSnapshots(workflowKey, protectedIds = []) {
|
||||||
try {
|
try {
|
||||||
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
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 ────────────────────────────────────────────
|
// ─── IndexedDB Migration ────────────────────────────────────────────
|
||||||
|
|
||||||
async function migrateFromIndexedDB() {
|
async function migrateFromIndexedDB() {
|
||||||
@@ -822,6 +885,92 @@ function getCachedSVG(snapshotId, graphData, options = {}) {
|
|||||||
return null;
|
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 ───────────────────────────────────────────────────
|
// ─── Restore Lock ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async function withRestoreLock(fn) {
|
async function withRestoreLock(fn) {
|
||||||
@@ -1225,6 +1374,14 @@ async function captureSnapshot(label = "Auto") {
|
|||||||
const prevGraph = lastGraphDataMap.get(workflowKey);
|
const prevGraph = lastGraphDataMap.get(workflowKey);
|
||||||
const changeType = detectChangeType(prevGraph, graphData);
|
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 = {
|
const record = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
workflowKey,
|
workflowKey,
|
||||||
@@ -1234,17 +1391,28 @@ async function captureSnapshot(label = "Auto") {
|
|||||||
graphData,
|
graphData,
|
||||||
locked: false,
|
locked: false,
|
||||||
changeType,
|
changeType,
|
||||||
|
parentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db_put(record);
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastCapturedHashMap.set(workflowKey, hash);
|
lastCapturedHashMap.set(workflowKey, hash);
|
||||||
lastGraphDataMap.set(workflowKey, graphData);
|
lastGraphDataMap.set(workflowKey, graphData);
|
||||||
|
lastCapturedIdMap.set(workflowKey, record.id);
|
||||||
pickerDirty = true;
|
pickerDirty = true;
|
||||||
currentSnapshotId = null; // new capture supersedes "current" bookmark
|
currentSnapshotId = null; // new capture supersedes "current" bookmark
|
||||||
activeSnapshotId = null; // graph has changed, no snapshot is "active"
|
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 prevGraph = lastGraphDataMap.get(workflowKey);
|
||||||
const changeType = detectChangeType(prevGraph, graphData);
|
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 = {
|
const record = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
workflowKey,
|
workflowKey,
|
||||||
@@ -1281,16 +1457,26 @@ async function captureNodeSnapshot(label = "Node Trigger") {
|
|||||||
locked: false,
|
locked: false,
|
||||||
source: "node",
|
source: "node",
|
||||||
changeType,
|
changeType,
|
||||||
|
parentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db_put(record);
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastGraphDataMap.set(workflowKey, graphData);
|
lastGraphDataMap.set(workflowKey, graphData);
|
||||||
|
lastCapturedIdMap.set(workflowKey, record.id);
|
||||||
pickerDirty = true;
|
pickerDirty = true;
|
||||||
currentSnapshotId = null;
|
currentSnapshotId = null;
|
||||||
activeSnapshotId = null;
|
activeSnapshotId = null;
|
||||||
@@ -1766,7 +1952,7 @@ const CSS = `
|
|||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
left: 10%;
|
left: 10%;
|
||||||
right: 10%;
|
right: 10%;
|
||||||
height: 32px;
|
height: 38px;
|
||||||
background: rgba(15, 23, 42, 0.85);
|
background: rgba(15, 23, 42, 0.85);
|
||||||
border: 1px solid var(--border-color, #334155);
|
border: 1px solid var(--border-color, #334155);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -1851,6 +2037,47 @@ const CSS = `
|
|||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
line-height: 32px;
|
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 {
|
.snap-diff-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -2096,6 +2323,149 @@ const CSS = `
|
|||||||
.snap-btn-preview:hover:not(:disabled) {
|
.snap-btn-preview:hover:not(:disabled) {
|
||||||
background: #475569;
|
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 = {
|
const CHANGE_TYPE_ICONS = {
|
||||||
@@ -2159,6 +2529,44 @@ function formatDate(ts) {
|
|||||||
return d.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
|
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) {
|
async function buildSidebar(el) {
|
||||||
injectStyles();
|
injectStyles();
|
||||||
// Clean up previous tooltip if sidebar is being rebuilt
|
// Clean up previous tooltip if sidebar is being rebuilt
|
||||||
@@ -2287,6 +2695,7 @@ async function buildSidebar(el) {
|
|||||||
} else {
|
} else {
|
||||||
viewingWorkflowKey = entry.workflowKey;
|
viewingWorkflowKey = entry.workflowKey;
|
||||||
}
|
}
|
||||||
|
activeBranchSelections.clear();
|
||||||
collapsePicker();
|
collapsePicker();
|
||||||
await refresh(true);
|
await refresh(true);
|
||||||
});
|
});
|
||||||
@@ -2363,10 +2772,178 @@ async function buildSidebar(el) {
|
|||||||
});
|
});
|
||||||
footer.appendChild(clearBtn);
|
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(header);
|
||||||
container.appendChild(selectorRow);
|
container.appendChild(selectorRow);
|
||||||
container.appendChild(pickerList);
|
container.appendChild(pickerList);
|
||||||
container.appendChild(viewingBanner);
|
container.appendChild(viewingBanner);
|
||||||
|
container.appendChild(profilesSection);
|
||||||
container.appendChild(searchRow);
|
container.appendChild(searchRow);
|
||||||
container.appendChild(list);
|
container.appendChild(list);
|
||||||
container.appendChild(footer);
|
container.appendChild(footer);
|
||||||
@@ -2398,12 +2975,10 @@ async function buildSidebar(el) {
|
|||||||
const effKey = getEffectiveWorkflowKey();
|
const effKey = getEffectiveWorkflowKey();
|
||||||
const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== currentKey;
|
const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== currentKey;
|
||||||
|
|
||||||
const records = await db_getAllForWorkflow(effKey);
|
const allRecords = await db_getAllForWorkflow(effKey);
|
||||||
// newest first
|
|
||||||
records.sort((a, b) => b.timestamp - a.timestamp);
|
|
||||||
|
|
||||||
const regularCount = records.filter(r => r.source !== "node").length;
|
const regularCount = allRecords.filter(r => r.source !== "node").length;
|
||||||
const nodeCount = records.filter(r => r.source === "node").length;
|
const nodeCount = allRecords.filter(r => r.source === "node").length;
|
||||||
countSpan.textContent = nodeCount > 0
|
countSpan.textContent = nodeCount > 0
|
||||||
? `${regularCount}/${maxSnapshots} + ${nodeCount}/${maxNodeSnapshots} node`
|
? `${regularCount}/${maxSnapshots} + ${nodeCount}/${maxNodeSnapshots} node`
|
||||||
: `${regularCount} / ${maxSnapshots}`;
|
: `${regularCount} / ${maxSnapshots}`;
|
||||||
@@ -2433,7 +3008,7 @@ async function buildSidebar(el) {
|
|||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
itemEntries = [];
|
itemEntries = [];
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (allRecords.length === 0) {
|
||||||
const empty = document.createElement("div");
|
const empty = document.createElement("div");
|
||||||
empty.className = "snap-empty";
|
empty.className = "snap-empty";
|
||||||
empty.textContent = "No snapshots yet. Edit the workflow or click 'Take Snapshot'.";
|
empty.textContent = "No snapshots yet. Edit the workflow or click 'Take Snapshot'.";
|
||||||
@@ -2441,7 +3016,26 @@ async function buildSidebar(el) {
|
|||||||
return;
|
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) {
|
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");
|
const item = document.createElement("div");
|
||||||
item.className = rec.source === "node" ? "snap-item snap-item-node" : "snap-item";
|
item.className = rec.source === "node" ? "snap-item snap-item-node" : "snap-item";
|
||||||
if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) {
|
if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) {
|
||||||
@@ -2613,6 +3207,20 @@ async function buildSidebar(el) {
|
|||||||
const confirmed = await showConfirmDialog("This snapshot is locked. Delete anyway?");
|
const confirmed = await showConfirmDialog("This snapshot is locked. Delete anyway?");
|
||||||
if (!confirmed) return;
|
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);
|
await db_delete(rec.workflowKey, rec.id);
|
||||||
pickerDirty = true;
|
pickerDirty = true;
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -2784,12 +3392,11 @@ function buildTimeline() {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!showTimeline) return;
|
if (!showTimeline) return;
|
||||||
|
|
||||||
const records = await db_getAllForWorkflow(getWorkflowKey());
|
const allRecords = await db_getAllForWorkflow(getWorkflowKey());
|
||||||
records.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
track.innerHTML = "";
|
track.innerHTML = "";
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (allRecords.length === 0) {
|
||||||
const empty = document.createElement("span");
|
const empty = document.createElement("span");
|
||||||
empty.className = "snap-timeline-empty";
|
empty.className = "snap-timeline-empty";
|
||||||
empty.textContent = "No snapshots";
|
empty.textContent = "No snapshots";
|
||||||
@@ -2797,6 +3404,16 @@ function buildTimeline() {
|
|||||||
return;
|
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) {
|
for (const rec of records) {
|
||||||
const marker = document.createElement("div");
|
const marker = document.createElement("div");
|
||||||
marker.className = "snap-timeline-marker";
|
marker.className = "snap-timeline-marker";
|
||||||
@@ -2838,7 +3455,56 @@ function buildTimeline() {
|
|||||||
swapSnapshot(rec);
|
swapSnapshot(rec);
|
||||||
});
|
});
|
||||||
|
|
||||||
track.appendChild(marker);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2964,6 +3630,7 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
|||||||
if (workflowStore?.$onAction) {
|
if (workflowStore?.$onAction) {
|
||||||
workflowStore.$onAction(({ name, after }) => {
|
workflowStore.$onAction(({ name, after }) => {
|
||||||
if (name === "openWorkflow") {
|
if (name === "openWorkflow") {
|
||||||
|
const prevKey = getWorkflowKey(); // capture BEFORE switch
|
||||||
after(() => {
|
after(() => {
|
||||||
// Cancel any pending capture from the previous workflow
|
// Cancel any pending capture from the previous workflow
|
||||||
if (captureTimer) {
|
if (captureTimer) {
|
||||||
@@ -2974,6 +3641,11 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
|||||||
activeSnapshotId = null;
|
activeSnapshotId = null;
|
||||||
currentSnapshotId = null;
|
currentSnapshotId = null;
|
||||||
diffBaseSnapshot = 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) {
|
if (sidebarRefresh) {
|
||||||
sidebarRefresh(true).catch(() => {});
|
sidebarRefresh(true).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -2998,6 +3670,9 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
|||||||
// Build the timeline bar on the canvas
|
// Build the timeline bar on the canvas
|
||||||
buildTimeline();
|
buildTimeline();
|
||||||
|
|
||||||
|
// Track initial workflow for profiles
|
||||||
|
trackSessionWorkflow(getWorkflowKey());
|
||||||
|
|
||||||
// Capture initial state after a short delay (decoupled from debounceMs)
|
// Capture initial state after a short delay (decoupled from debounceMs)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!captureOnLoad) return;
|
if (!captureOnLoad) return;
|
||||||
|
|||||||
@@ -122,9 +122,10 @@ async def prune_snapshots(request):
|
|||||||
workflow_key = data.get("workflowKey")
|
workflow_key = data.get("workflowKey")
|
||||||
max_snapshots = data.get("maxSnapshots")
|
max_snapshots = data.get("maxSnapshots")
|
||||||
source = data.get("source")
|
source = data.get("source")
|
||||||
|
protected_ids = data.get("protectedIds")
|
||||||
if not workflow_key or max_snapshots is None:
|
if not workflow_key or max_snapshots is None:
|
||||||
return web.json_response({"error": "Missing workflowKey or maxSnapshots"}, status=400)
|
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})
|
return web.json_response({"deleted": deleted})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
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)
|
return web.json_response({"error": str(e)}, status=400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
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)
|
||||||
|
|||||||
@@ -202,21 +202,25 @@ def get_all_workflow_keys():
|
|||||||
return results
|
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.
|
"""Delete oldest unlocked snapshots beyond limit. Returns count deleted.
|
||||||
|
|
||||||
source filtering:
|
source filtering:
|
||||||
- "node": only prune records where source == "node"
|
- "node": only prune records where source == "node"
|
||||||
- "regular": only prune records where source is absent or not "node"
|
- "regular": only prune records where source is absent or not "node"
|
||||||
- None: prune all unlocked (existing behavior)
|
- 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)
|
entries = _ensure_cached(workflow_key)
|
||||||
if source == "node":
|
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":
|
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:
|
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:
|
if len(candidates) <= max_snapshots:
|
||||||
return 0
|
return 0
|
||||||
to_delete = candidates[: len(candidates) - max_snapshots]
|
to_delete = candidates[: len(candidates) - max_snapshots]
|
||||||
@@ -243,3 +247,96 @@ def prune(workflow_key, max_snapshots, source=None):
|
|||||||
os.rmdir(d)
|
os.rmdir(d)
|
||||||
|
|
||||||
return deleted
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user