Add snapshot diff view and bump to v2.4.0
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Compare any snapshot vs the current workflow (one click) or two snapshots against each other (Shift+click to set base). Modal shows added/removed/modified nodes, widget value changes, property diffs, and rewired connections with collapsible sections and colored indicators. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
23
README.md
23
README.md
@@ -5,7 +5,7 @@
|
||||
<p align="center">
|
||||
<a href="https://registry.comfy.org/publishers/ethanfel/nodes/comfyui-snapshot-manager"><img src="https://img.shields.io/badge/ComfyUI-Registry-blue?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMMyA3djEwbDkgNSA5LTVWN2wtOS01eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=" alt="ComfyUI Registry"/></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"/></a>
|
||||
<img src="https://img.shields.io/badge/version-2.3.0-blue" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/version-2.4.0-blue" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/>
|
||||
</p>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
- **Active & current markers** — When you swap to a snapshot, the timeline highlights where you came from (green dot) and where you are (white ring)
|
||||
- **Auto-save before swap** — Swapping to an older snapshot automatically saves your current state first, so you can always get back; browsing between saved snapshots skips redundant saves
|
||||
- **Ctrl+S shortcut** — Press Ctrl+S (or Cmd+S on Mac) to take a manual snapshot alongside ComfyUI's own save
|
||||
- **Diff view** — Compare any snapshot against the current workflow (one click) or two snapshots against each other (Shift+click to set base); see added/removed/modified nodes, widget value changes, and rewired connections in a single modal
|
||||
- **Lock/pin snapshots** — Protect important snapshots from auto-pruning and "Clear All" with a single click
|
||||
- **Concurrency-safe** — Lock guard prevents double-click issues during restore
|
||||
- **Server-side storage** — Snapshots persist on the ComfyUI server's filesystem, accessible from any browser
|
||||
@@ -141,6 +142,26 @@ Press **Ctrl+S** (or **Cmd+S** on Mac) to take a manual snapshot. This works alo
|
||||
- Click **×** on any snapshot to delete it individually (locked snapshots prompt for confirmation)
|
||||
- Click **Clear All Snapshots** in the footer to remove all unlocked snapshots for the current workflow (locked snapshots are preserved)
|
||||
|
||||
### 12. Diff View
|
||||
|
||||
Compare two snapshots — or a snapshot against the current workflow — to see exactly what changed without touching the graph.
|
||||
|
||||
**One-click (vs current workflow):** Click **Diff** on any snapshot to see what changed between that snapshot and your current live workflow.
|
||||
|
||||
**Two-snapshot compare:** **Shift+click** **Diff** on snapshot A to set it as the base (purple outline + toast confirmation), then click **Diff** on snapshot B to compare A → B. The base clears after comparison.
|
||||
|
||||
The diff modal shows:
|
||||
|
||||
| Section | Details |
|
||||
|---------|---------|
|
||||
| **Summary pills** | Colored counts — green (added), red (removed), amber (modified), blue (links) |
|
||||
| **Added Nodes** | Nodes present in the target but not the base |
|
||||
| **Removed Nodes** | Nodes present in the base but not the target |
|
||||
| **Modified Nodes** | Nodes with changed position, size, title, mode, widget values, or properties — each change shown as old (red strikethrough) → new (green) |
|
||||
| **Link Changes** | Added/removed connections with node names and slot indices |
|
||||
|
||||
Sections are collapsible (click the header to toggle). If the two snapshots are identical, a "No differences found." message is shown. Dismiss the modal with **Escape**, the **X** button, or by clicking outside.
|
||||
|
||||
## Settings
|
||||
|
||||
All settings are available in **ComfyUI Settings > Snapshot Manager**:
|
||||
|
||||
@@ -38,6 +38,7 @@ let timelineEl = null; // root DOM element for timeline bar
|
||||
let timelineRefresh = null; // callback to re-render timeline
|
||||
let activeSnapshotId = null; // ID of the snapshot currently loaded via swap
|
||||
let currentSnapshotId = null; // ID of the auto-saved "Current" snapshot before a swap
|
||||
let diffBaseSnapshot = null; // snapshot record selected as diff base (shift+click)
|
||||
|
||||
// ─── Server API Layer ───────────────────────────────────────────────
|
||||
|
||||
@@ -355,6 +356,152 @@ function detectChangeType(prevGraph, currGraph) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// ─── Detailed Diff ──────────────────────────────────────────────────
|
||||
|
||||
function buildNodeLookup(...graphs) {
|
||||
const map = new Map();
|
||||
for (const g of graphs) {
|
||||
if (!g || !Array.isArray(g.nodes)) continue;
|
||||
for (const n of g.nodes) {
|
||||
if (!map.has(n.id)) {
|
||||
map.set(n.id, { type: n.type || "?", title: n.title || n.type || `#${n.id}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function computeDetailedDiff(baseGraph, targetGraph) {
|
||||
const empty = {
|
||||
addedNodes: [], removedNodes: [], modifiedNodes: [],
|
||||
addedLinks: [], removedLinks: [],
|
||||
summary: { nodesAdded: 0, nodesRemoved: 0, nodesModified: 0, linksAdded: 0, linksRemoved: 0 },
|
||||
};
|
||||
if (!baseGraph && !targetGraph) return empty;
|
||||
const bNodes = (baseGraph?.nodes || []);
|
||||
const tNodes = (targetGraph?.nodes || []);
|
||||
|
||||
const baseMap = new Map(bNodes.map(n => [n.id, n]));
|
||||
const targetMap = new Map(tNodes.map(n => [n.id, n]));
|
||||
|
||||
const addedNodes = [];
|
||||
const removedNodes = [];
|
||||
const modifiedNodes = [];
|
||||
|
||||
// Removed: in base but not in target
|
||||
for (const [id, n] of baseMap) {
|
||||
if (!targetMap.has(id)) {
|
||||
removedNodes.push({ id, type: n.type || "?", title: n.title || n.type || `#${id}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Added or modified: in target
|
||||
for (const [id, tn] of targetMap) {
|
||||
const bn = baseMap.get(id);
|
||||
if (!bn) {
|
||||
addedNodes.push({ id, type: tn.type || "?", title: tn.title || tn.type || `#${id}` });
|
||||
continue;
|
||||
}
|
||||
// Check modifications
|
||||
const changes = {};
|
||||
|
||||
// Position
|
||||
if (bn.pos?.[0] !== tn.pos?.[0] || bn.pos?.[1] !== tn.pos?.[1]) {
|
||||
changes.position = { from: bn.pos, to: tn.pos };
|
||||
}
|
||||
|
||||
// Size
|
||||
if (bn.size?.[0] !== tn.size?.[0] || bn.size?.[1] !== tn.size?.[1]) {
|
||||
changes.size = { from: bn.size, to: tn.size };
|
||||
}
|
||||
|
||||
// Title
|
||||
if ((bn.title || "") !== (tn.title || "")) {
|
||||
changes.title = { from: bn.title || "", to: tn.title || "" };
|
||||
}
|
||||
|
||||
// Mode
|
||||
if (bn.mode !== tn.mode) {
|
||||
changes.mode = { from: bn.mode, to: tn.mode };
|
||||
}
|
||||
|
||||
// Widget values
|
||||
const bw = bn.widgets_values;
|
||||
const tw = tn.widgets_values;
|
||||
if (bw !== tw) {
|
||||
if (bw == null || tw == null || !Array.isArray(bw) || !Array.isArray(tw) || bw.length !== tw.length) {
|
||||
changes.widgetValues = { from: bw, to: tw };
|
||||
} else {
|
||||
const diffs = [];
|
||||
for (let i = 0; i < Math.max(bw.length, tw.length); i++) {
|
||||
const bv = i < bw.length ? bw[i] : undefined;
|
||||
const tv = i < tw.length ? tw[i] : undefined;
|
||||
if (bv !== tv) {
|
||||
const bs = typeof bv === "object" ? JSON.stringify(bv) : String(bv ?? "");
|
||||
const ts = typeof tv === "object" ? JSON.stringify(tv) : String(tv ?? "");
|
||||
if (bs !== ts) diffs.push({ index: i, from: bs, to: ts });
|
||||
}
|
||||
}
|
||||
if (diffs.length > 0) changes.widgetValues = diffs;
|
||||
}
|
||||
}
|
||||
|
||||
// Properties (shallow key comparison)
|
||||
const bp = bn.properties || {};
|
||||
const tp = tn.properties || {};
|
||||
const allPropKeys = new Set([...Object.keys(bp), ...Object.keys(tp)]);
|
||||
const propDiffs = [];
|
||||
for (const key of allPropKeys) {
|
||||
const bv = bp[key];
|
||||
const tv = tp[key];
|
||||
if (bv !== tv) {
|
||||
const bs = typeof bv === "object" ? JSON.stringify(bv) : String(bv ?? "");
|
||||
const ts = typeof tv === "object" ? JSON.stringify(tv) : String(tv ?? "");
|
||||
if (bs !== ts) propDiffs.push({ key, from: bs, to: ts });
|
||||
}
|
||||
}
|
||||
if (propDiffs.length > 0) changes.properties = propDiffs;
|
||||
|
||||
if (Object.keys(changes).length > 0) {
|
||||
modifiedNodes.push({
|
||||
id, type: tn.type || "?", title: tn.title || tn.type || `#${id}`, changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Links
|
||||
const bLinks = (baseGraph?.links || []).filter(Boolean);
|
||||
const tLinks = (targetGraph?.links || []).filter(Boolean);
|
||||
|
||||
const baseLinkMap = new Map(bLinks.map(l => [l[0], l]));
|
||||
const targetLinkMap = new Map(tLinks.map(l => [l[0], l]));
|
||||
|
||||
const addedLinks = [];
|
||||
const removedLinks = [];
|
||||
|
||||
for (const [linkId, l] of baseLinkMap) {
|
||||
if (!targetLinkMap.has(linkId)) {
|
||||
removedLinks.push({ linkId, srcNodeId: l[1], srcSlot: l[2], destNodeId: l[3], destSlot: l[4], type: l[5] });
|
||||
}
|
||||
}
|
||||
for (const [linkId, l] of targetLinkMap) {
|
||||
if (!baseLinkMap.has(linkId)) {
|
||||
addedLinks.push({ linkId, srcNodeId: l[1], srcSlot: l[2], destNodeId: l[3], destSlot: l[4], type: l[5] });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
addedNodes, removedNodes, modifiedNodes, addedLinks, removedLinks,
|
||||
summary: {
|
||||
nodesAdded: addedNodes.length,
|
||||
nodesRemoved: removedNodes.length,
|
||||
nodesModified: modifiedNodes.length,
|
||||
linksAdded: addedLinks.length,
|
||||
linksRemoved: removedLinks.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Restore Lock ───────────────────────────────────────────────────
|
||||
|
||||
async function withRestoreLock(fn) {
|
||||
@@ -413,6 +560,230 @@ async function showPromptDialog(message, defaultValue = "Manual") {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Diff Modal ─────────────────────────────────────────────────────
|
||||
|
||||
function showDiffModal(baseLabel, targetLabel, diff, allNodes) {
|
||||
// Overlay
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "snap-diff-overlay";
|
||||
|
||||
// Modal
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "snap-diff-modal";
|
||||
|
||||
// Header
|
||||
const hdr = document.createElement("div");
|
||||
hdr.className = "snap-diff-header";
|
||||
const hdrTitle = document.createElement("span");
|
||||
hdrTitle.textContent = `${baseLabel} \u2192 ${targetLabel}`;
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.textContent = "\u2715";
|
||||
closeBtn.addEventListener("click", dismiss);
|
||||
hdr.appendChild(hdrTitle);
|
||||
hdr.appendChild(closeBtn);
|
||||
|
||||
// Summary pills
|
||||
const { summary } = diff;
|
||||
const summaryBar = document.createElement("div");
|
||||
summaryBar.className = "snap-diff-summary";
|
||||
const pills = [
|
||||
{ count: summary.nodesAdded, label: "added", color: "#22c55e" },
|
||||
{ count: summary.nodesRemoved, label: "removed", color: "#dc2626" },
|
||||
{ count: summary.nodesModified, label: "modified", color: "#f59e0b" },
|
||||
{ count: summary.linksAdded, label: "links +", color: "#3b82f6" },
|
||||
{ count: summary.linksRemoved, label: "links \u2212", color: "#3b82f6" },
|
||||
];
|
||||
for (const p of pills) {
|
||||
if (p.count === 0) continue;
|
||||
const pill = document.createElement("span");
|
||||
pill.style.cssText = `background:${p.color}; color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600;`;
|
||||
pill.textContent = `${p.count} ${p.label}`;
|
||||
summaryBar.appendChild(pill);
|
||||
}
|
||||
|
||||
// Body (scrollable)
|
||||
const body = document.createElement("div");
|
||||
body.className = "snap-diff-body";
|
||||
|
||||
const totalChanges = summary.nodesAdded + summary.nodesRemoved + summary.nodesModified + summary.linksAdded + summary.linksRemoved;
|
||||
|
||||
if (totalChanges === 0) {
|
||||
const emptyMsg = document.createElement("div");
|
||||
emptyMsg.className = "snap-diff-empty";
|
||||
emptyMsg.textContent = "No differences found.";
|
||||
body.appendChild(emptyMsg);
|
||||
} else {
|
||||
// Helper: collapsible section
|
||||
function makeSection(title, count, entries, renderEntry) {
|
||||
if (count === 0) return null;
|
||||
const section = document.createElement("div");
|
||||
section.className = "snap-diff-section";
|
||||
const sectionHdr = document.createElement("div");
|
||||
sectionHdr.className = "snap-diff-section-header";
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "snap-diff-section-arrow";
|
||||
arrow.textContent = "\u25BC";
|
||||
const sectionTitle = document.createElement("span");
|
||||
sectionTitle.textContent = `${title} (${count})`;
|
||||
sectionHdr.appendChild(arrow);
|
||||
sectionHdr.appendChild(sectionTitle);
|
||||
const sectionBody = document.createElement("div");
|
||||
sectionBody.className = "snap-diff-section-body";
|
||||
for (const entry of entries) {
|
||||
sectionBody.appendChild(renderEntry(entry));
|
||||
}
|
||||
let collapsed = false;
|
||||
sectionHdr.addEventListener("click", () => {
|
||||
collapsed = !collapsed;
|
||||
sectionBody.style.display = collapsed ? "none" : "";
|
||||
arrow.textContent = collapsed ? "\u25B6" : "\u25BC";
|
||||
});
|
||||
section.appendChild(sectionHdr);
|
||||
section.appendChild(sectionBody);
|
||||
return section;
|
||||
}
|
||||
|
||||
function nodeEntry(n, colorClass) {
|
||||
const el = document.createElement("div");
|
||||
el.className = `snap-diff-node-entry ${colorClass}`;
|
||||
el.textContent = `${n.title} (${n.type}) #${n.id}`;
|
||||
return el;
|
||||
}
|
||||
|
||||
// Added Nodes
|
||||
const addedSec = makeSection("Added Nodes", diff.addedNodes.length, diff.addedNodes, (n) => nodeEntry(n, "snap-diff-added"));
|
||||
if (addedSec) body.appendChild(addedSec);
|
||||
|
||||
// Removed Nodes
|
||||
const removedSec = makeSection("Removed Nodes", diff.removedNodes.length, diff.removedNodes, (n) => nodeEntry(n, "snap-diff-removed"));
|
||||
if (removedSec) body.appendChild(removedSec);
|
||||
|
||||
// Modified Nodes
|
||||
const modSec = makeSection("Modified Nodes", diff.modifiedNodes.length, diff.modifiedNodes, (n) => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "snap-diff-node-entry snap-diff-neutral";
|
||||
const header = document.createElement("div");
|
||||
header.textContent = `${n.title} (${n.type}) #${n.id}`;
|
||||
wrap.appendChild(header);
|
||||
|
||||
const { changes } = n;
|
||||
if (changes.position) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "snap-diff-change-detail";
|
||||
const from = changes.position.from || [0, 0];
|
||||
const to = changes.position.to || [0, 0];
|
||||
d.appendChild(makeValueChange("Position", `[${Math.round(from[0])}, ${Math.round(from[1])}]`, `[${Math.round(to[0])}, ${Math.round(to[1])}]`));
|
||||
wrap.appendChild(d);
|
||||
}
|
||||
if (changes.size) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "snap-diff-change-detail";
|
||||
const from = changes.size.from || [0, 0];
|
||||
const to = changes.size.to || [0, 0];
|
||||
d.appendChild(makeValueChange("Size", `[${Math.round(from[0])}, ${Math.round(from[1])}]`, `[${Math.round(to[0])}, ${Math.round(to[1])}]`));
|
||||
wrap.appendChild(d);
|
||||
}
|
||||
if (changes.title) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "snap-diff-change-detail";
|
||||
d.appendChild(makeValueChange("Title", changes.title.from, changes.title.to));
|
||||
wrap.appendChild(d);
|
||||
}
|
||||
if (changes.mode) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "snap-diff-change-detail";
|
||||
d.appendChild(makeValueChange("Mode", String(changes.mode.from), String(changes.mode.to)));
|
||||
wrap.appendChild(d);
|
||||
}
|
||||
if (changes.widgetValues) {
|
||||
if (Array.isArray(changes.widgetValues)) {
|
||||
for (const wv of changes.widgetValues) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "snap-diff-change-detail";
|
||||
d.appendChild(makeValueChange(`Value[${wv.index}]`, wv.from, wv.to));
|
||||
wrap.appendChild(d);
|
||||
}
|
||||
} else {
|
||||
const d = document.createElement("div");
|
||||
d.className = "snap-diff-change-detail";
|
||||
d.appendChild(makeValueChange("Widget values", JSON.stringify(changes.widgetValues.from), JSON.stringify(changes.widgetValues.to)));
|
||||
wrap.appendChild(d);
|
||||
}
|
||||
}
|
||||
if (changes.properties) {
|
||||
for (const pv of changes.properties) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "snap-diff-change-detail";
|
||||
d.appendChild(makeValueChange(`prop.${pv.key}`, pv.from, pv.to));
|
||||
wrap.appendChild(d);
|
||||
}
|
||||
}
|
||||
return wrap;
|
||||
});
|
||||
if (modSec) body.appendChild(modSec);
|
||||
|
||||
// Link changes (combined section)
|
||||
const allLinkChanges = [
|
||||
...diff.addedLinks.map(l => ({ ...l, action: "added" })),
|
||||
...diff.removedLinks.map(l => ({ ...l, action: "removed" })),
|
||||
];
|
||||
const linkSec = makeSection("Link Changes", allLinkChanges.length, allLinkChanges, (l) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = `snap-diff-link-entry ${l.action === "added" ? "snap-diff-added" : "snap-diff-removed"}`;
|
||||
const srcInfo = allNodes.get(l.srcNodeId) || { title: `#${l.srcNodeId}` };
|
||||
const destInfo = allNodes.get(l.destNodeId) || { title: `#${l.destNodeId}` };
|
||||
const prefix = l.action === "added" ? "+" : "\u2212";
|
||||
el.textContent = `${prefix} ${srcInfo.title} [${l.srcSlot}] \u2192 ${destInfo.title} [${l.destSlot}]${l.type ? ` (${l.type})` : ""}`;
|
||||
return el;
|
||||
});
|
||||
if (linkSec) body.appendChild(linkSec);
|
||||
}
|
||||
|
||||
function makeValueChange(label, oldVal, newVal) {
|
||||
const span = document.createElement("span");
|
||||
const lbl = document.createElement("span");
|
||||
lbl.textContent = `${label}: `;
|
||||
const oldSpan = document.createElement("span");
|
||||
oldSpan.className = "snap-diff-val-old";
|
||||
oldSpan.textContent = truncateVal(oldVal);
|
||||
const arrow = document.createElement("span");
|
||||
arrow.textContent = " \u2192 ";
|
||||
const newSpan = document.createElement("span");
|
||||
newSpan.className = "snap-diff-val-new";
|
||||
newSpan.textContent = truncateVal(newVal);
|
||||
span.appendChild(lbl);
|
||||
span.appendChild(oldSpan);
|
||||
span.appendChild(arrow);
|
||||
span.appendChild(newSpan);
|
||||
return span;
|
||||
}
|
||||
|
||||
function truncateVal(v) {
|
||||
const s = String(v ?? "");
|
||||
return s.length > 80 ? s.slice(0, 77) + "\u2026" : s;
|
||||
}
|
||||
|
||||
modal.appendChild(hdr);
|
||||
modal.appendChild(summaryBar);
|
||||
modal.appendChild(body);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function dismiss() {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "Escape") dismiss();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Snapshot Capture ────────────────────────────────────────────────
|
||||
|
||||
async function captureSnapshot(label = "Auto") {
|
||||
@@ -544,6 +915,15 @@ async function restoreSnapshot(record) {
|
||||
}
|
||||
|
||||
async function swapSnapshot(record) {
|
||||
// Warn when swapping in a snapshot from a different workflow
|
||||
const currentKey = getWorkflowKey();
|
||||
if (record.workflowKey && record.workflowKey !== currentKey) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
`This snapshot belongs to a different workflow ("${record.workflowKey}").\nSwap it into the current workflow anyway?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
// Auto-save current state before swapping (so user can get back),
|
||||
// but skip if the graph is already a saved snapshot (browsing between old ones)
|
||||
const prevCurrentId = currentSnapshotId;
|
||||
@@ -687,6 +1067,58 @@ const CSS = `
|
||||
font-size: 10px;
|
||||
color: var(--descrip-text, #666);
|
||||
}
|
||||
.snap-item-label-input {
|
||||
width: 100%;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: var(--comfy-menu-bg, #2a2a2a);
|
||||
color: var(--input-text, #ccc);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.snap-btn-note {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 3px 4px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.snap-btn-note:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.snap-btn-note.has-note {
|
||||
opacity: 1;
|
||||
filter: sepia(1) saturate(3) hue-rotate(15deg) brightness(1.1);
|
||||
}
|
||||
.snap-item-notes {
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: var(--descrip-text, #888);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.snap-item-notes-input {
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: var(--comfy-menu-bg, #2a2a2a);
|
||||
color: var(--input-text, #ccc);
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 32px;
|
||||
max-height: 80px;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
.snap-item-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -972,6 +1404,153 @@ const CSS = `
|
||||
font-family: system-ui, sans-serif;
|
||||
line-height: 32px;
|
||||
}
|
||||
.snap-diff-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.snap-diff-modal {
|
||||
width: min(720px, 90vw);
|
||||
max-height: 80vh;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
}
|
||||
.snap-diff-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #444;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.snap-diff-header button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.snap-diff-header button:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
.snap-diff-summary {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.snap-diff-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 14px 14px;
|
||||
}
|
||||
.snap-diff-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.snap-diff-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 6px 4px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.snap-diff-section-header:hover {
|
||||
background: #2a2a3a;
|
||||
}
|
||||
.snap-diff-section-arrow {
|
||||
font-size: 10px;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.snap-diff-section-body {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.snap-diff-node-entry {
|
||||
padding: 4px 6px;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.snap-diff-node-entry.snap-diff-added {
|
||||
color: #22c55e;
|
||||
background: rgba(34,197,94,0.08);
|
||||
}
|
||||
.snap-diff-node-entry.snap-diff-removed {
|
||||
color: #dc2626;
|
||||
background: rgba(220,38,38,0.08);
|
||||
}
|
||||
.snap-diff-node-entry.snap-diff-neutral {
|
||||
color: #f59e0b;
|
||||
background: rgba(245,158,11,0.06);
|
||||
}
|
||||
.snap-diff-change-detail {
|
||||
padding: 2px 0 2px 16px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
.snap-diff-val-old {
|
||||
color: #dc2626;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.snap-diff-val-new {
|
||||
color: #22c55e;
|
||||
}
|
||||
.snap-diff-link-entry {
|
||||
padding: 3px 6px;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.snap-diff-link-entry.snap-diff-added {
|
||||
color: #22c55e;
|
||||
background: rgba(34,197,94,0.08);
|
||||
}
|
||||
.snap-diff-link-entry.snap-diff-removed {
|
||||
color: #dc2626;
|
||||
background: rgba(220,38,38,0.08);
|
||||
}
|
||||
.snap-diff-empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.snap-item.snap-diff-base {
|
||||
outline: 2px solid #6d28d9;
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.snap-btn-diff {
|
||||
background: #6d28d9;
|
||||
color: #fff;
|
||||
}
|
||||
.snap-btn-diff:hover:not(:disabled) {
|
||||
background: #5b21b6;
|
||||
}
|
||||
.snap-btn-diff.snap-diff-base-active {
|
||||
box-shadow: 0 0 6px rgba(109,40,217,0.6);
|
||||
}
|
||||
`;
|
||||
|
||||
const CHANGE_TYPE_ICONS = {
|
||||
@@ -1239,7 +1818,7 @@ async function buildSidebar(el) {
|
||||
|
||||
function filterItems(term) {
|
||||
for (const entry of itemEntries) {
|
||||
const match = !term || entry.label.toLowerCase().includes(term);
|
||||
const match = !term || entry.label.toLowerCase().includes(term) || entry.notes.toLowerCase().includes(term);
|
||||
entry.element.style.display = match ? "" : "none";
|
||||
}
|
||||
}
|
||||
@@ -1302,6 +1881,9 @@ async function buildSidebar(el) {
|
||||
for (const rec of records) {
|
||||
const item = document.createElement("div");
|
||||
item.className = rec.source === "node" ? "snap-item snap-item-node" : "snap-item";
|
||||
if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) {
|
||||
item.classList.add("snap-diff-base");
|
||||
}
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "snap-item-info";
|
||||
@@ -1315,6 +1897,52 @@ async function buildSidebar(el) {
|
||||
badge.textContent = "Node";
|
||||
labelDiv.appendChild(badge);
|
||||
}
|
||||
labelDiv.addEventListener("dblclick", (e) => {
|
||||
e.stopPropagation();
|
||||
const originalLabel = rec.label;
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "snap-item-label-input";
|
||||
input.value = originalLabel;
|
||||
labelDiv.textContent = "";
|
||||
labelDiv.appendChild(input);
|
||||
input.select();
|
||||
input.focus();
|
||||
let committed = false;
|
||||
const commit = async () => {
|
||||
if (committed) return;
|
||||
committed = true;
|
||||
const newLabel = input.value.trim() || originalLabel;
|
||||
if (newLabel !== originalLabel) {
|
||||
rec.label = newLabel;
|
||||
await db_put(rec);
|
||||
await refresh();
|
||||
} else {
|
||||
labelDiv.textContent = originalLabel;
|
||||
if (rec.source === "node") {
|
||||
const b = document.createElement("span");
|
||||
b.className = "snap-node-badge";
|
||||
b.textContent = "Node";
|
||||
labelDiv.appendChild(b);
|
||||
}
|
||||
}
|
||||
};
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") { ev.preventDefault(); input.blur(); }
|
||||
if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
committed = true;
|
||||
labelDiv.textContent = originalLabel;
|
||||
if (rec.source === "node") {
|
||||
const b = document.createElement("span");
|
||||
b.className = "snap-node-badge";
|
||||
b.textContent = "Node";
|
||||
labelDiv.appendChild(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
input.addEventListener("blur", commit);
|
||||
});
|
||||
|
||||
const time = document.createElement("div");
|
||||
time.className = "snap-item-time";
|
||||
@@ -1329,14 +1957,54 @@ async function buildSidebar(el) {
|
||||
const changeLabel = (CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown).label;
|
||||
meta.textContent = `${rec.nodeCount} nodes \u00b7 ${changeLabel}`;
|
||||
|
||||
const notesDiv = document.createElement("div");
|
||||
notesDiv.className = "snap-item-notes";
|
||||
if (rec.notes) {
|
||||
notesDiv.textContent = rec.notes;
|
||||
notesDiv.title = rec.notes;
|
||||
} else {
|
||||
notesDiv.style.display = "none";
|
||||
}
|
||||
|
||||
info.appendChild(labelDiv);
|
||||
info.appendChild(time);
|
||||
info.appendChild(date);
|
||||
info.appendChild(meta);
|
||||
info.appendChild(notesDiv);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "snap-item-actions";
|
||||
|
||||
const noteBtn = document.createElement("button");
|
||||
noteBtn.className = "snap-btn-note" + (rec.notes ? " has-note" : "");
|
||||
noteBtn.textContent = "\uD83D\uDCDD";
|
||||
noteBtn.title = rec.notes ? "Edit note" : "Add note";
|
||||
noteBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// Toggle: if textarea already open, close it
|
||||
const existing = info.querySelector(".snap-item-notes-input");
|
||||
if (existing) { existing.remove(); return; }
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "snap-item-notes-input";
|
||||
textarea.value = rec.notes || "";
|
||||
info.appendChild(textarea);
|
||||
textarea.focus();
|
||||
let saved = false;
|
||||
const saveNote = async () => {
|
||||
if (saved) return;
|
||||
saved = true;
|
||||
const newNotes = textarea.value.trim();
|
||||
rec.notes = newNotes || undefined;
|
||||
await db_put(rec);
|
||||
await refresh();
|
||||
};
|
||||
textarea.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" && ev.ctrlKey) { ev.preventDefault(); textarea.blur(); }
|
||||
if (ev.key === "Escape") { ev.preventDefault(); saved = true; textarea.remove(); }
|
||||
});
|
||||
textarea.addEventListener("blur", saveNote);
|
||||
});
|
||||
|
||||
const lockBtn = document.createElement("button");
|
||||
lockBtn.className = rec.locked ? "snap-btn-lock snap-btn-locked" : "snap-btn-lock";
|
||||
lockBtn.textContent = rec.locked ? "\uD83D\uDD12" : "\uD83D\uDD13";
|
||||
@@ -1379,6 +2047,50 @@ async function buildSidebar(el) {
|
||||
await refresh();
|
||||
});
|
||||
|
||||
const diffBtn = document.createElement("button");
|
||||
diffBtn.className = "snap-btn-diff" + (diffBaseSnapshot && diffBaseSnapshot.id === rec.id ? " snap-diff-base-active" : "");
|
||||
diffBtn.textContent = "Diff";
|
||||
diffBtn.title = diffBaseSnapshot && diffBaseSnapshot.id !== rec.id
|
||||
? `Compare '${diffBaseSnapshot.label}' vs this snapshot`
|
||||
: "Compare vs current workflow (Shift+click to set as base)";
|
||||
diffBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
// Toggle base selection
|
||||
if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) {
|
||||
diffBaseSnapshot = null;
|
||||
showToast("Diff base cleared", "info");
|
||||
} else {
|
||||
diffBaseSnapshot = rec;
|
||||
showToast(`Diff base set: "${rec.label}"`, "info");
|
||||
}
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
// Normal click
|
||||
let baseGraph, targetGraph, baseLabel, targetLabel;
|
||||
if (diffBaseSnapshot && diffBaseSnapshot.id !== rec.id) {
|
||||
// Two-snapshot compare: base vs this
|
||||
baseGraph = diffBaseSnapshot.graphData || {};
|
||||
targetGraph = rec.graphData || {};
|
||||
baseLabel = diffBaseSnapshot.label;
|
||||
targetLabel = rec.label;
|
||||
diffBaseSnapshot = null;
|
||||
refresh(); // clear highlight
|
||||
} else {
|
||||
// Compare this snapshot vs current live workflow
|
||||
baseGraph = rec.graphData || {};
|
||||
targetGraph = getGraphData() || {};
|
||||
baseLabel = rec.label;
|
||||
targetLabel = "Current Workflow";
|
||||
}
|
||||
const diff = computeDetailedDiff(baseGraph, targetGraph);
|
||||
const allNodes = buildNodeLookup(baseGraph, targetGraph);
|
||||
showDiffModal(baseLabel, targetLabel, diff, allNodes);
|
||||
});
|
||||
|
||||
actions.appendChild(noteBtn);
|
||||
actions.appendChild(diffBtn);
|
||||
actions.appendChild(lockBtn);
|
||||
actions.appendChild(swapBtn);
|
||||
actions.appendChild(restoreBtn);
|
||||
@@ -1388,7 +2100,7 @@ async function buildSidebar(el) {
|
||||
item.appendChild(actions);
|
||||
list.appendChild(item);
|
||||
|
||||
itemEntries.push({ element: item, label: rec.label });
|
||||
itemEntries.push({ element: item, label: rec.label, notes: rec.notes || "" });
|
||||
}
|
||||
|
||||
// Re-apply current search filter to newly built items
|
||||
@@ -1495,7 +2207,9 @@ function buildTimeline() {
|
||||
}
|
||||
|
||||
// Native tooltip with change-type description
|
||||
marker.title = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`;
|
||||
let tip = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`;
|
||||
if (rec.notes) tip += `\n${rec.notes}`;
|
||||
marker.title = tip;
|
||||
|
||||
// Click to swap
|
||||
marker.addEventListener("click", () => {
|
||||
@@ -1632,6 +2346,7 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
||||
viewingWorkflowKey = null;
|
||||
activeSnapshotId = null;
|
||||
currentSnapshotId = null;
|
||||
diffBaseSnapshot = null;
|
||||
if (sidebarRefresh) {
|
||||
sidebarRefresh(true).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-snapshot-manager"
|
||||
description = "Automatically snapshots workflow state with a sidebar to browse and restore previous versions."
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
license = {text = "MIT"}
|
||||
|
||||
[project.urls]
|
||||
|
||||
Reference in New Issue
Block a user