Add Fusion 360-style change-type icons to timeline markers

Each snapshot now detects what kind of change it represents (node add,
node remove, connection, parameter, move, mixed) and displays a distinct
colored icon on the timeline. Sidebar meta line shows the change type.
Existing snapshots without change data gracefully fall back to a faded
"unknown" dot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 00:10:36 +01:00
parent 51cb2f6855
commit 8a8a01adff
3 changed files with 291 additions and 36 deletions

View File

@@ -5,7 +5,7 @@
<p align="center"> <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="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> <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.2.0-blue" alt="Version"/> <img src="https://img.shields.io/badge/version-2.3.0-blue" alt="Version"/>
<img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/> <img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/>
</p> </p>
@@ -28,7 +28,8 @@
- **Theme-aware UI** — Adapts to light and dark ComfyUI themes - **Theme-aware UI** — Adapts to light and dark ComfyUI themes
- **Toast notifications** — Visual feedback for save, restore, and error operations - **Toast notifications** — Visual feedback for save, restore, and error operations
- **SaveSnapshot node** — Trigger snapshot captures from your workflow with a custom node; node snapshots are visually distinct (purple border + "Node" badge) and have their own rolling limit - **SaveSnapshot node** — Trigger snapshot captures from your workflow with a custom node; node snapshots are visually distinct (purple border + "Node" badge) and have their own rolling limit
- **Timeline bar** — Optional visual timeline on the canvas showing all snapshots as dots, with a Snapshot button for quick captures - **Change-type icons** — Timeline markers show what kind of change each snapshot represents (node add, remove, connection, parameter, move, mixed) with distinct colored icons — like Fusion 360's operation timeline
- **Timeline bar** — Optional visual timeline on the canvas showing all snapshots as iconic markers, with a Snapshot button for quick captures
- **Active & current markers** — When you swap to a snapshot, the timeline highlights where you came from (green dot) and where you are (white ring) - **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 - **Auto-save before swap** — Swapping to an older snapshot automatically saves your current state first, so you can always get back
- **Ctrl+S shortcut** — Press Ctrl+S (or Cmd+S on Mac) to take a manual snapshot alongside ComfyUI's own save - **Ctrl+S shortcut** — Press Ctrl+S (or Cmd+S on Mac) to take a manual snapshot alongside ComfyUI's own save
@@ -97,17 +98,35 @@ This is especially useful for recovering snapshots from workflows that were rena
### 8. Timeline Bar ### 8. Timeline Bar
Enable the timeline in **Settings > Snapshot Manager > Timeline > Show snapshot timeline on canvas**. A thin bar appears at the bottom of the canvas with a dot for each snapshot: Enable the timeline in **Settings > Snapshot Manager > Timeline > Show snapshot timeline on canvas**. A thin bar appears at the bottom of the canvas with an iconic marker for each snapshot — each icon shows what kind of change the snapshot represents:
| Marker | Meaning | <p align="center">
|--------|---------| <img src="assets/timeline-icons.svg" alt="Timeline change-type icons" width="720"/>
| **Blue dot** | Regular snapshot | </p>
| **Purple dot** | Node-triggered snapshot |
| Icon | Color | Change Type |
|------|-------|-------------|
| Filled circle | Blue | **Initial** — first snapshot after load |
| Plus **+** | Green | **Node Add** — nodes were added |
| Minus **** | Red | **Node Remove** — nodes were removed |
| Zigzag | Amber | **Connection** — links/wires changed |
| Wave | Purple | **Param** — widget values changed |
| Arrows ↕ | Gray | **Move** — nodes repositioned |
| Star ✱ | Orange | **Mixed** — multiple change types |
| Faded dot | Gray | **Unknown** — legacy snapshot or no detected change |
Additional marker styles are layered on top of the change-type icon:
| Overlay | Meaning |
|---------|---------|
| **Purple background** | Node-triggered snapshot (overrides change-type color) |
| **Yellow border** | Locked snapshot | | **Yellow border** | Locked snapshot |
| **White ring (larger)** | Active — the snapshot you swapped TO | | **White ring (larger)** | Active — the snapshot you swapped TO |
| **Green dot** | Current — your auto-saved state before the swap | | **Green background** | Current — your auto-saved state before the swap |
Click any dot to swap to that snapshot. The **Snapshot** button on the right side of the bar takes a quick manual snapshot. Click any marker to swap to that snapshot. Hover to see a tooltip with the snapshot name, time, and change description. The **Snapshot** button on the right takes a quick manual snapshot.
The sidebar list also shows the change type in the meta line below each snapshot (e.g., "5 nodes · Parameters changed").
### 9. Auto-save Before Swap ### 9. Auto-save Before Swap
@@ -146,9 +165,10 @@ All settings are available in **ComfyUI Settings > Snapshot Manager**:
1. **Graph edits** trigger a `graphChanged` event 1. **Graph edits** trigger a `graphChanged` event
2. A **debounce timer** prevents excessive writes 2. A **debounce timer** prevents excessive writes
3. The workflow is serialized and **hash-checked** against the last capture (per-workflow) to avoid duplicates 3. The workflow is serialized and **hash-checked** against the last capture (per-workflow) to avoid duplicates
4. New snapshots are sent to the **server** and stored as individual JSON files under `data/snapshots/` 4. The previous graph state is diffed against the current to **detect the change type** (node add/remove, connection, parameter, move, or mixed) — stored as a `changeType` field on the record
5. The **sidebar panel** fetches snapshots from the server and renders the snapshot list 5. New snapshots are sent to the **server** and stored as individual JSON files under `data/snapshots/`
6. **Restore/Swap** loads graph data back into ComfyUI with a lock guard to prevent concurrent operations 6. The **sidebar panel** and **timeline bar** fetch snapshots from the server and render them with change-type icons
7. **Restore/Swap** loads graph data back into ComfyUI with a lock guard to prevent concurrent operations, and updates the graph cache so the next diff is accurate
**Node-triggered capture flow:** **Node-triggered capture flow:**

106
assets/timeline-icons.svg Normal file
View File

@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="80" viewBox="0 0 720 80">
<!-- Background -->
<rect width="720" height="80" rx="8" fill="#1e293b"/>
<!-- Icon definitions -->
<defs>
<!-- Initial (blue) -->
<symbol id="icon-initial" viewBox="0 0 12 12">
<circle cx="6" cy="6" r="5" fill="currentColor"/>
</symbol>
<!-- Node Add (green) -->
<symbol id="icon-node-add" viewBox="0 0 12 12">
<path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</symbol>
<!-- Node Remove (red) -->
<symbol id="icon-node-remove" viewBox="0 0 12 12">
<path d="M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</symbol>
<!-- Connection (amber) -->
<symbol id="icon-connection" viewBox="0 0 12 12">
<path d="M1 9L4 3L8 9L11 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</symbol>
<!-- Param (purple) -->
<symbol id="icon-param" viewBox="0 0 12 12">
<path d="M0 6Q3 2 6 6Q9 10 12 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
</symbol>
<!-- Move (gray) -->
<symbol id="icon-move" viewBox="0 0 12 12">
<path d="M6 1L3 4h6L6 1ZM6 11L3 8h6L6 11Z" fill="currentColor"/>
</symbol>
<!-- Mixed (orange) -->
<symbol id="icon-mixed" viewBox="0 0 12 12">
<path d="M6 1L7.5 4.5H11L8.25 6.75L9.5 10.5L6 8L2.5 10.5L3.75 6.75L1 4.5H4.5Z" fill="currentColor"/>
</symbol>
<!-- Unknown (gray) -->
<symbol id="icon-unknown" viewBox="0 0 12 12">
<circle cx="6" cy="6" r="3" fill="currentColor" opacity="0.5"/>
</symbol>
</defs>
<!-- 8 icons evenly spaced: center positions at 45, 141.43, 237.86, 334.29, 430.71, 527.14, 623.57, 720-45=675 -->
<!-- Spacing: (720 - 2*45) / 7 = 90 -->
<!-- 1. Initial -->
<g transform="translate(45, 0)">
<circle cx="0" cy="28" r="16" fill="#3b82f6"/>
<use href="#icon-initial" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Initial</text>
</g>
<!-- 2. Node Add -->
<g transform="translate(135, 0)">
<circle cx="0" cy="28" r="16" fill="#22c55e"/>
<use href="#icon-node-add" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Node Add</text>
</g>
<!-- 3. Node Remove -->
<g transform="translate(225, 0)">
<circle cx="0" cy="28" r="16" fill="#ef4444"/>
<use href="#icon-node-remove" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Node Remove</text>
</g>
<!-- 4. Connection -->
<g transform="translate(315, 0)">
<circle cx="0" cy="28" r="16" fill="#f59e0b"/>
<use href="#icon-connection" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Connection</text>
</g>
<!-- 5. Param -->
<g transform="translate(405, 0)">
<circle cx="0" cy="28" r="16" fill="#a78bfa"/>
<use href="#icon-param" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Param</text>
</g>
<!-- 6. Move -->
<g transform="translate(495, 0)">
<circle cx="0" cy="28" r="16" fill="#64748b"/>
<use href="#icon-move" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Move</text>
</g>
<!-- 7. Mixed -->
<g transform="translate(585, 0)">
<circle cx="0" cy="28" r="16" fill="#f97316"/>
<use href="#icon-mixed" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Mixed</text>
</g>
<!-- 8. Unknown -->
<g transform="translate(675, 0)">
<circle cx="0" cy="28" r="16" fill="#6b7280"/>
<use href="#icon-unknown" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -28,6 +28,7 @@ let showTimeline = false;
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
const lastCapturedHashMap = new Map(); const lastCapturedHashMap = new Map();
const lastGraphDataMap = new Map(); // workflowKey -> previous graphData for change-type detection
let restoreLock = null; let restoreLock = null;
let captureTimer = null; let captureTimer = null;
let sidebarRefresh = null; // callback set by sidebar render let sidebarRefresh = null; // callback set by sidebar render
@@ -256,6 +257,73 @@ function validateSnapshotData(graphData) {
return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes); return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes);
} }
// ─── Change-Type Detection ──────────────────────────────────────────
function detectChangeType(prevGraph, currGraph) {
if (!prevGraph) return "initial";
const prevNodes = prevGraph.nodes || [];
const currNodes = currGraph.nodes || [];
const prevIds = new Set(prevNodes.map(n => n.id));
const currIds = new Set(currNodes.map(n => n.id));
const added = currNodes.filter(n => !prevIds.has(n.id));
const removed = prevNodes.filter(n => !currIds.has(n.id));
const nodesChanged = added.length > 0 || removed.length > 0;
// When nodes are added/removed, link changes are expected — don't flag separately
if (nodesChanged) {
if (added.length > 0 && removed.length > 0) return "mixed";
return added.length > 0 ? "node_add" : "node_remove";
}
// Node sets identical — check links, params, positions
let flags = 0;
const FLAG_CONNECTION = 1;
const FLAG_PARAM = 2;
const FLAG_MOVE = 4;
// Compare links
const prevLinks = JSON.stringify(prevGraph.links || []);
const currLinks = JSON.stringify(currGraph.links || []);
if (prevLinks !== currLinks) flags |= FLAG_CONNECTION;
// Build lookup for prev nodes by id
const prevNodeMap = new Map(prevNodes.map(n => [n.id, n]));
for (const cn of currNodes) {
const pn = prevNodeMap.get(cn.id);
if (!pn) continue;
// Compare widget values
const cw = JSON.stringify(cn.widgets_values ?? null);
const pw = JSON.stringify(pn.widgets_values ?? null);
if (cw !== pw) flags |= FLAG_PARAM;
// Compare positions
const cPos = Array.isArray(cn.pos) ? cn.pos : [0, 0];
const pPos = Array.isArray(pn.pos) ? pn.pos : [0, 0];
if (cPos[0] !== pPos[0] || cPos[1] !== pPos[1]) flags |= FLAG_MOVE;
// Early exit if all flags set
if (flags === (FLAG_CONNECTION | FLAG_PARAM | FLAG_MOVE)) break;
}
if (flags === 0) return "unknown";
// Count set flags
const count = ((flags & FLAG_CONNECTION) ? 1 : 0)
+ ((flags & FLAG_PARAM) ? 1 : 0)
+ ((flags & FLAG_MOVE) ? 1 : 0);
if (count > 1) return "mixed";
if (flags & FLAG_CONNECTION) return "connection";
if (flags & FLAG_PARAM) return "param";
if (flags & FLAG_MOVE) return "move";
return "unknown";
}
// ─── Restore Lock ─────────────────────────────────────────────────── // ─── Restore Lock ───────────────────────────────────────────────────
async function withRestoreLock(fn) { async function withRestoreLock(fn) {
@@ -330,6 +398,9 @@ async function captureSnapshot(label = "Auto") {
const hash = quickHash(serialized); const hash = quickHash(serialized);
if (hash === lastCapturedHashMap.get(workflowKey)) return false; if (hash === lastCapturedHashMap.get(workflowKey)) return false;
const prevGraph = lastGraphDataMap.get(workflowKey);
const changeType = detectChangeType(prevGraph, graphData);
const record = { const record = {
id: generateId(), id: generateId(),
workflowKey, workflowKey,
@@ -338,6 +409,7 @@ async function captureSnapshot(label = "Auto") {
nodeCount: nodes.length, nodeCount: nodes.length,
graphData, graphData,
locked: false, locked: false,
changeType,
}; };
try { try {
@@ -348,6 +420,7 @@ async function captureSnapshot(label = "Auto") {
} }
lastCapturedHashMap.set(workflowKey, hash); lastCapturedHashMap.set(workflowKey, hash);
lastGraphDataMap.set(workflowKey, graphData);
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"
@@ -371,6 +444,8 @@ async function captureNodeSnapshot(label = "Node Trigger") {
if (nodes.length === 0) return false; if (nodes.length === 0) return false;
const workflowKey = getWorkflowKey(); const workflowKey = getWorkflowKey();
const prevGraph = lastGraphDataMap.get(workflowKey);
const changeType = detectChangeType(prevGraph, graphData);
const record = { const record = {
id: generateId(), id: generateId(),
@@ -381,6 +456,7 @@ async function captureNodeSnapshot(label = "Node Trigger") {
graphData, graphData,
locked: false, locked: false,
source: "node", source: "node",
changeType,
}; };
try { try {
@@ -390,6 +466,7 @@ async function captureNodeSnapshot(label = "Node Trigger") {
return false; return false;
} }
lastGraphDataMap.set(workflowKey, graphData);
pickerDirty = true; pickerDirty = true;
currentSnapshotId = null; currentSnapshotId = null;
activeSnapshotId = null; activeSnapshotId = null;
@@ -426,6 +503,7 @@ async function restoreSnapshot(record) {
try { try {
await app.loadGraphData(record.graphData, true, true); await app.loadGraphData(record.graphData, true, true);
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData))); lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
lastGraphDataMap.set(getWorkflowKey(), record.graphData);
showToast("Snapshot restored", "success"); showToast("Snapshot restored", "success");
} catch (err) { } catch (err) {
console.warn(`[${EXTENSION_NAME}] Restore failed:`, err); console.warn(`[${EXTENSION_NAME}] Restore failed:`, err);
@@ -450,6 +528,7 @@ async function swapSnapshot(record) {
const workflow = app.extensionManager?.workflow?.activeWorkflow; const workflow = app.extensionManager?.workflow?.activeWorkflow;
await app.loadGraphData(record.graphData, true, true, workflow); await app.loadGraphData(record.graphData, true, true, workflow);
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData))); lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
lastGraphDataMap.set(getWorkflowKey(), record.graphData);
activeSnapshotId = record.id; activeSnapshotId = record.id;
showToast("Snapshot swapped", "success"); showToast("Snapshot swapped", "success");
} catch (err) { } catch (err) {
@@ -787,23 +866,32 @@ const CSS = `
.snap-timeline-track { .snap-timeline-track {
flex: 1; flex: 1;
height: 100%; height: 100%;
position: relative; display: flex;
align-items: center;
gap: 6px;
overflow-x: auto;
} }
.snap-timeline-marker { .snap-timeline-marker {
position: absolute; width: 18px;
top: 50%; height: 18px;
width: 10px;
height: 10px;
border-radius: 50%; border-radius: 50%;
background: #3b82f6;
transform: translate(-50%, -50%);
cursor: pointer; cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s; transition: transform 0.1s, box-shadow 0.1s;
border: 2px solid transparent; border: 2px solid transparent;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--snap-marker-color, #3b82f6);
}
.snap-timeline-marker svg {
display: block;
color: #fff;
pointer-events: none;
} }
.snap-timeline-marker:hover { .snap-timeline-marker:hover {
transform: translate(-50%, -50%) scale(1.5); transform: scale(1.4);
box-shadow: 0 0 6px rgba(59, 130, 246, 0.6); box-shadow: 0 0 8px var(--snap-marker-color, rgba(59,130,246,0.6));
} }
.snap-timeline-marker-node { .snap-timeline-marker-node {
background: #6d28d9; background: #6d28d9;
@@ -816,10 +904,10 @@ const CSS = `
} }
.snap-timeline-marker-active { .snap-timeline-marker-active {
border-color: #fff; border-color: #fff;
transform: translate(-50%, -50%) scale(1.3); transform: scale(1.3);
} }
.snap-timeline-marker-active:hover { .snap-timeline-marker-active:hover {
transform: translate(-50%, -50%) scale(1.5); transform: scale(1.5);
} }
.snap-timeline-marker-current { .snap-timeline-marker-current {
background: #10b981; background: #10b981;
@@ -852,6 +940,49 @@ const CSS = `
} }
`; `;
const CHANGE_TYPE_ICONS = {
initial: {
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="currentColor"/></svg>',
color: "#3b82f6",
label: "Initial snapshot",
},
node_add: {
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
color: "#22c55e",
label: "Nodes added",
},
node_remove: {
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
color: "#ef4444",
label: "Nodes removed",
},
connection: {
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M1 9L4 3L8 9L11 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
color: "#f59e0b",
label: "Connections changed",
},
param: {
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M0 6Q3 2 6 6Q9 10 12 6" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>',
color: "#a78bfa",
label: "Parameters changed",
},
move: {
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 1L3 4h6L6 1ZM6 11L3 8h6L6 11Z" fill="currentColor"/></svg>',
color: "#64748b",
label: "Nodes repositioned",
},
mixed: {
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 1L7.5 4.5H11L8.25 6.75L9.5 10.5L6 8L2.5 10.5L3.75 6.75L1 4.5H4.5Z" fill="currentColor"/></svg>',
color: "#f97316",
label: "Multiple changes",
},
unknown: {
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><circle cx="6" cy="6" r="3" fill="currentColor" opacity="0.5"/></svg>',
color: "#6b7280",
label: "Unknown change",
},
};
function injectStyles() { function injectStyles() {
if (document.getElementById("snapshot-manager-styles")) return; if (document.getElementById("snapshot-manager-styles")) return;
const style = document.createElement("style"); const style = document.createElement("style");
@@ -1161,7 +1292,8 @@ async function buildSidebar(el) {
const meta = document.createElement("div"); const meta = document.createElement("div");
meta.className = "snap-item-meta"; meta.className = "snap-item-meta";
meta.textContent = `${rec.nodeCount} nodes`; const changeLabel = (CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown).label;
meta.textContent = `${rec.nodeCount} nodes \u00b7 ${changeLabel}`;
info.appendChild(labelDiv); info.appendChild(labelDiv);
info.appendChild(time); info.appendChild(time);
@@ -1297,23 +1429,19 @@ function buildTimeline() {
return; return;
} }
const minTs = records[0].timestamp;
const maxTs = records[records.length - 1].timestamp;
const range = maxTs - minTs;
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";
// Position: spread evenly if all same timestamp, otherwise by time // Change-type icon and color
const pct = range > 0 const iconInfo = CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown;
? ((rec.timestamp - minTs) / range) * 100 marker.style.setProperty("--snap-marker-color", iconInfo.color);
: 50; marker.innerHTML = iconInfo.svg;
marker.style.left = `${pct}%`;
// Node snapshot styling // Node snapshot styling — override color to purple but keep the SVG icon
if (rec.source === "node") { if (rec.source === "node") {
marker.classList.add("snap-timeline-marker-node"); marker.classList.add("snap-timeline-marker-node");
marker.style.setProperty("--snap-marker-color", "#6d28d9");
} }
// Locked snapshot styling // Locked snapshot styling
@@ -1329,10 +1457,11 @@ function buildTimeline() {
// Current snapshot styling (auto-saved "you were here" bookmark) // Current snapshot styling (auto-saved "you were here" bookmark)
if (rec.id === currentSnapshotId) { if (rec.id === currentSnapshotId) {
marker.classList.add("snap-timeline-marker-current"); marker.classList.add("snap-timeline-marker-current");
marker.style.setProperty("--snap-marker-color", "#10b981");
} }
// Native tooltip // Native tooltip with change-type description
marker.title = `${rec.label}${formatTime(rec.timestamp)}`; marker.title = `${rec.label}${formatTime(rec.timestamp)}\n${iconInfo.label}`;
// Click to swap // Click to swap
marker.addEventListener("click", () => { marker.addEventListener("click", () => {