Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c2afb1cbb | |||
| 0d1415fca4 | |||
| 8c84d2ff4e | |||
| 43fb4c7cef | |||
| e30d6a1540 | |||
| c11de7f7c3 | |||
| b591d3e281 | |||
| d04731afc6 | |||
| 67fc6d4f7a | |||
| ab8860dc7c | |||
| 04e4095e82 | |||
| cf8208c540 | |||
| 9946013946 | |||
| 7782bda677 | |||
| 53b377d2d1 | |||
| 048171cb81 | |||
| 4a8128b5ce | |||
| 01e09949fb | |||
| f9bdc75a2a | |||
| e29e7dfad1 | |||
| a1c6716ef6 | |||
| 320ab8647d | |||
| fd14d4f1a6 | |||
| 0c707b43f3 | |||
| 463e234cd1 | |||
| 4bfc1912f2 | |||
| 74e1e35e6d | |||
| ea69db87b1 | |||
| b933c34c73 | |||
| f0e407616e | |||
| ebbdfa4249 | |||
| 24fe846646 | |||
| eddf3c6acc | |||
| 0b0b9d4c39 | |||
| fa76fbf97a | |||
| f93441b187 | |||
| e1a48a2456 | |||
| 54387c2df4 | |||
| e4d1c496f2 | |||
| f3fab45bf0 | |||
| d7bd9c4991 | |||
| 284f4e9538 | |||
| d0057db397 | |||
| d142df5332 | |||
| 3809af2662 | |||
| bca7e7cf8f | |||
| 7518821447 | |||
| ab3bbc7f71 | |||
| 0b7fb5be0e | |||
| e5d9e6ca99 | |||
| 31b846cd5f | |||
| 4b392a89cf | |||
| f9a821f8b4 | |||
| efc3791a57 | |||
| dc2e408026 | |||
| 73cde5ade1 | |||
| e554dc9973 | |||
| 95cbcd4f6c | |||
| 0219d86301 | |||
| 862045ecf1 | |||
| 0b8304f458 | |||
| 8a8a01adff | |||
| 51cb2f6855 | |||
| ee4051ad72 | |||
| 25b909f99f | |||
| 11c6b7237b | |||
| 3877c5838c | |||
| 838b3d0b00 | |||
| 4cbd8fd0a9 | |||
| d32349bfdf | |||
| 81118f4610 | |||
| 4eec4cf135 | |||
| b90ebba068 | |||
| e5ba68c356 |
@@ -1,2 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<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-1.0.0-blue" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/version-3.0.1-blue" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
**Workflow Snapshot Manager** automatically captures your ComfyUI workflow as you edit. Browse, name, search, and restore any previous version from a sidebar panel — all stored locally in your browser's IndexedDB.
|
||||
**Workflow Snapshot Manager** automatically captures your ComfyUI workflow as you edit. Browse, name, search, and restore any previous version from a sidebar panel — stored as JSON files on the server, accessible from any browser.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/sidebar-preview.png" alt="Sidebar Preview" width="300"/>
|
||||
@@ -20,14 +20,32 @@
|
||||
## Features
|
||||
|
||||
- **Auto-capture** — Snapshots are saved automatically as you edit, with configurable debounce
|
||||
- **Custom naming** — Name your snapshots when taking them manually ("Before merge", "Working v2", etc.)
|
||||
- **Search & filter** — Quickly find snapshots by name with the filter bar
|
||||
- **Custom naming** — Name your snapshots when taking them manually ("Before merge", "Working v2", etc.); double-click a snapshot's name in the sidebar to rename it later
|
||||
- **Notes** — Attach a freeform note to any snapshot with the note (pencil) button; notes are searchable from the filter bar
|
||||
- **Search & filter** — Quickly find snapshots by name or note with the filter bar
|
||||
- **Restore or Swap** — Open a snapshot as a new workflow, or replace the current one in-place
|
||||
- **Workflow browser** — Browse and recover snapshots from any workflow, including renamed or deleted ones
|
||||
- **Per-workflow storage** — Each workflow has its own independent snapshot history
|
||||
- **Theme-aware UI** — Adapts to light and dark ComfyUI themes
|
||||
- **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
|
||||
- **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 centered floating bar on the canvas showing all snapshots as iconic markers, with a Snapshot button for quick captures; tucks behind the sidebar when open
|
||||
- **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
|
||||
- **SVG graph previews** — Hover any snapshot for a tooltip preview of the workflow graph; click the eye button for a full-size modal; diff view now shows side-by-side SVG comparison with color-coded highlights (green = added, red = removed, amber = modified)
|
||||
- **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
|
||||
- **Profile manager** — Save and load named sets of workflows as session profiles (like browser tab groups); profiles track which workflows you visited and restore the latest snapshot for each
|
||||
- **Hide auto-saves** — Toggle button next to the search bar hides auto-save snapshots to reduce clutter while keeping manual, locked, and node-triggered snapshots visible
|
||||
- **Pause auto-capture** — Toggle automatic capture on/off for the session without leaving the panel (Auto: On/Off button in the filter row)
|
||||
- **Export / Import** — Download a workflow's full snapshot history as a JSON file and re-import it on another machine or server
|
||||
- **Storage usage** — The sidebar footer shows total snapshot storage used on the server across all workflows
|
||||
- **Retention by age** — Optionally auto-delete snapshots older than a configurable number of days (off by default), alongside the per-workflow count limit
|
||||
- **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
|
||||
- **Zero backend** — Pure frontend extension, no server dependencies
|
||||
- **Server-side storage** — Snapshots persist on the ComfyUI server's filesystem, accessible from any browser
|
||||
- **Automatic migration** — Existing IndexedDB snapshots are imported to the server on first load
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -64,28 +82,147 @@ Use the filter bar at the top of the panel to search snapshots by name. The clea
|
||||
|
||||
### 5. Restore or Swap
|
||||
|
||||
Each snapshot has two action buttons:
|
||||
Each snapshot has action buttons:
|
||||
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| **Preview** (eye icon) | Opens a full-size SVG preview of the workflow graph |
|
||||
| **Lock** | Toggles lock protection (padlock icon) |
|
||||
| **Swap** | Replaces the current workflow in-place (same tab) |
|
||||
| **Restore** | Opens the snapshot as a new workflow |
|
||||
|
||||
### 6. Delete & Clear
|
||||
### 6. Lock / Pin Snapshots
|
||||
|
||||
- Click **×** on any snapshot to delete it individually
|
||||
- Click **Clear All Snapshots** in the footer to remove all snapshots for the current workflow (with confirmation dialog)
|
||||
Click the **padlock icon** on any snapshot to lock it. Locked snapshots are protected from:
|
||||
|
||||
- **Auto-pruning** — When the snapshot count exceeds the max, only unlocked snapshots are pruned
|
||||
- **Clear All** — Locked snapshots survive bulk deletion (the toast reports how many were kept)
|
||||
|
||||
To unlock, click the padlock again. Deleting a locked snapshot individually is still possible but requires confirmation.
|
||||
|
||||
### 7. Browse Other Workflows
|
||||
|
||||
Click the **workflow name** below the header to expand the workflow picker. It lists every workflow that has snapshots in the database, with counts. Click any workflow to view its snapshots — an amber banner confirms you're viewing a different workflow, and "Take Snapshot" is disabled to avoid confusion. Click **Back to current** to return.
|
||||
|
||||
This is especially useful for recovering snapshots from workflows that were renamed or deleted.
|
||||
|
||||
### 8. Timeline Bar
|
||||
|
||||
Enable the timeline in **Settings > Snapshot Manager > Timeline > Show snapshot timeline on canvas**. A centered floating 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:
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/timeline-icons.svg" alt="Timeline change-type icons" width="720"/>
|
||||
</p>
|
||||
|
||||
| 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 |
|
||||
| **White ring (larger)** | Active — the snapshot you swapped TO |
|
||||
| **Green background** | Current — your auto-saved state before the swap |
|
||||
|
||||
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 bar is centered at 80% width to clear both the sidebar icon strip and bottom-right controls, and tucks behind the sidebar panel when it's open.
|
||||
|
||||
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
|
||||
|
||||
When you swap to an older snapshot (via the sidebar or timeline), the extension automatically captures a "Current" snapshot of your work-in-progress first. This green-marked snapshot appears on the timeline so you can click it to get back. The marker disappears once you edit the graph (since auto-capture creates a proper snapshot at that point). Browsing between existing snapshots does not create additional "Current" entries — the auto-save only triggers on the first swap away from unsaved work.
|
||||
|
||||
### 10. Keyboard Shortcut
|
||||
|
||||
Press **Ctrl+S** (or **Cmd+S** on Mac) to take a manual snapshot. This works alongside ComfyUI's own workflow save — both fire simultaneously.
|
||||
|
||||
### 11. Delete & Clear
|
||||
|
||||
- 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 |
|
||||
|---------|---------|
|
||||
| **SVG comparison** | Side-by-side graph previews at the top — base on the left, target on the right, with highlighted nodes (green = added, red = removed, amber = modified) |
|
||||
| **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.
|
||||
|
||||
### 13. SVG Graph Previews
|
||||
|
||||
Visually inspect any snapshot without restoring or swapping it.
|
||||
|
||||
**Hover tooltip:** Hover over any snapshot in the sidebar list. After 200ms, a small SVG preview appears next to the item showing the graph layout with nodes, links, and groups. Move the mouse away to dismiss.
|
||||
|
||||
**Preview modal:** Click the **eye button** on any snapshot to open a full-size preview modal showing the complete graph with node titles, colored link beziers, input/output slot dots, and group overlays. Dismiss with **Escape**, the **X** button, or by clicking outside.
|
||||
|
||||
The SVG renderer draws nodes with their stored position, size, and colors. Links are rendered as bezier curves colored by type (blue for IMAGE, orange for CLIP, purple for MODEL, etc.). Collapsed nodes appear as thin title-only strips. Thumbnails (hover tooltips) auto-simplify by hiding labels and slot dots for clarity at small sizes.
|
||||
|
||||
### 14. Hide Auto-saves
|
||||
|
||||
Click the **Hide Auto** button next to the search bar to hide all auto-save snapshots ("Auto" and "Initial"). The button turns blue and switches to **Show Auto** when active.
|
||||
|
||||
The filter works together with text search — both are applied simultaneously. Manual, locked, node-triggered, and "Current" snapshots are always shown.
|
||||
|
||||
### 15. Session Profiles
|
||||
|
||||
Save and load named sets of workflows — like browser tab groups for ComfyUI.
|
||||
|
||||
**Save a profile:**
|
||||
|
||||
1. Click the **`>`** Profiles toggle between the workflow picker and search bar to expand
|
||||
2. Click **Save** — enter a profile name
|
||||
3. The profile captures all workflows you've visited in this session
|
||||
|
||||
**Load a profile:**
|
||||
|
||||
1. Expand the Profiles section
|
||||
2. Click **Load** on any profile — the extension fetches the latest snapshot for each workflow and loads them via `loadGraphData`
|
||||
3. The profile's active workflow is loaded last so it ends up visible
|
||||
4. A toast reports how many workflows were loaded and how many were skipped (missing snapshots)
|
||||
|
||||
**Delete a profile:** Click **X** on any profile (with confirmation).
|
||||
|
||||
Profiles are stored as JSON files on the server at `<extension_dir>/data/profiles/`.
|
||||
|
||||
> **Note:** ComfyUI's `loadGraphData` replaces the current workflow — there is no API to open new tabs. Each loaded workflow overwrites the previous one. The user ends up seeing the last loaded workflow (the active one). Previously loaded workflows may appear in ComfyUI's workflow history/tabs depending on the frontend version.
|
||||
|
||||
## Settings
|
||||
|
||||
All settings are available in **ComfyUI Settings > Snapshot Manager > Capture Settings**:
|
||||
All settings are available in **ComfyUI Settings > Snapshot Manager**:
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| **Auto-capture on edit** | Toggle | `On` | Automatically save snapshots when the workflow changes |
|
||||
| **Capture delay** | Slider | `3s` | Seconds to wait after the last edit before auto-capturing (1–30s) |
|
||||
| **Max snapshots per workflow** | Slider | `50` | Maximum number of snapshots kept per workflow (5–200). Oldest are pruned automatically |
|
||||
| **Max snapshots per workflow** | Slider | `50` | Maximum number of unlocked snapshots kept per workflow (5–200). Oldest unlocked are pruned automatically; locked snapshots are never pruned |
|
||||
| **Capture on workflow load** | Toggle | `On` | Save an "Initial" snapshot when a workflow is first loaded |
|
||||
| **Max node-triggered snapshots** | Slider | `5` | Rolling limit for SaveSnapshot node captures per workflow (1–50). Node snapshots are pruned independently from auto/manual snapshots |
|
||||
| **Show snapshot timeline** | Toggle | `Off` | Display a timeline bar at the bottom of the canvas with snapshot markers, active/current indicators, and a quick Snapshot button |
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -93,27 +230,65 @@ All settings are available in **ComfyUI Settings > Snapshot Manager > Capture Se
|
||||
<img src="assets/architecture.png" alt="Architecture Diagram" width="100%"/>
|
||||
</p>
|
||||
|
||||
**Data flow:**
|
||||
**Auto/manual capture flow:**
|
||||
|
||||
1. **Graph edits** trigger a `graphChanged` event
|
||||
2. A **debounce timer** prevents excessive writes
|
||||
3. The workflow is serialized and **hash-checked** against the last capture (per-workflow) to avoid duplicates
|
||||
4. New snapshots are written to **IndexedDB** (browser-local, persistent)
|
||||
5. The **sidebar panel** reads from IndexedDB and renders the snapshot list
|
||||
6. **Restore/Swap** loads graph data back into ComfyUI with a lock guard to prevent concurrent operations
|
||||
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. New snapshots are sent to the **server** and stored as individual JSON files under `<user_dir>/snapshot_manager/snapshots/`
|
||||
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
|
||||
|
||||
**Storage:** All data stays in your browser's IndexedDB — nothing is sent to any server. Snapshots persist across browser sessions and ComfyUI restarts.
|
||||
**Node-triggered capture flow:**
|
||||
|
||||
1. **SaveSnapshot node** executes during a queue prompt run
|
||||
2. A **WebSocket event** is sent to the frontend, **skipping hash dedup** (the workflow doesn't change between runs)
|
||||
3. The snapshot is saved with `source: "node"` and pruned against its own rolling limit (`maxNodeSnapshots`)
|
||||
4. Node snapshots appear in the sidebar with a **purple left border** and **"Node" badge**
|
||||
|
||||
**Swap with auto-save:**
|
||||
|
||||
1. User clicks **Swap** (sidebar or timeline marker)
|
||||
2. If the current graph is unsaved work (not already a swapped snapshot), `captureSnapshot("Current")` saves it **before** the swap — browsing between existing snapshots skips this step
|
||||
3. The target snapshot is loaded into the graph
|
||||
4. The **timeline** updates: the swapped-to snapshot gets a white ring (active), the auto-saved snapshot gets a green dot (current)
|
||||
5. Clicking the green dot swaps back; editing the graph clears both markers (the next auto-capture supersedes them)
|
||||
|
||||
**Branching** (implemented but disabled in the current release — the code remains for a future enable):
|
||||
|
||||
1. Each snapshot stores a `parentId` pointing to its predecessor
|
||||
2. `buildSnapshotTree()` constructs parent/child maps from all records — legacy snapshots (no `parentId`) are chained by timestamp automatically
|
||||
3. `getDisplayPath()` walks the tree from root to tip, following `activeBranchSelections` at each fork point, producing the linear branch view
|
||||
4. **Pruning protection**: before pruning, `getAncestorIds()` collects all ancestors of the branch tip; these IDs plus fork-point IDs are sent as `protectedIds` to the server
|
||||
|
||||
**Profiles:**
|
||||
|
||||
1. Session tracking records each visited workflow key with timestamps
|
||||
2. **Save** creates a JSON file at `<user_dir>/snapshot_manager/profiles/<id>.json` with the workflow list and active workflow
|
||||
3. **Load** fetches the latest snapshot for each workflow in the profile and calls `loadGraphData`
|
||||
|
||||
**Storage:** Snapshots are stored as JSON files on the server in ComfyUI's user directory at `<user_dir>/snapshot_manager/snapshots/<workflow_key>/<id>.json`. Profiles are stored at `<user_dir>/snapshot_manager/profiles/<id>.json`. Data from older versions (kept under the extension's own `data/` folder) is migrated here automatically on first load. Both persist across browser sessions, ComfyUI restarts, and are accessible from any browser connecting to the same server.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Where are snapshots stored?**
|
||||
In your browser's IndexedDB under the database `ComfySnapshotManager`. They persist across sessions but are browser-local (not synced between devices).
|
||||
On the server's filesystem in ComfyUI's user directory under `snapshot_manager/snapshots/`. Each workflow gets its own directory, and each snapshot is an individual JSON file. They persist across browser sessions and are accessible from any browser connecting to the same ComfyUI server.
|
||||
|
||||
**I'm upgrading from v1.x — what happens to my existing snapshots?**
|
||||
On first load after upgrading, the extension automatically migrates all snapshots from your browser's IndexedDB to the server. Once migration succeeds, the old IndexedDB database is deleted. If migration fails (e.g., server unreachable), your old data is preserved and migration will retry on the next load.
|
||||
|
||||
**Will this slow down ComfyUI?**
|
||||
No. Snapshots are captured asynchronously after a debounce delay. The hash check prevents redundant writes.
|
||||
|
||||
**What happens if I switch workflows?**
|
||||
Each workflow has its own snapshot history. Switching workflows cancels any pending captures and shows the correct snapshot list.
|
||||
Each workflow has its own snapshot history. Switching workflows cancels any pending captures and shows the correct snapshot list. You can also browse snapshots from other workflows using the workflow picker.
|
||||
|
||||
**I renamed/deleted a workflow — are my snapshots gone?**
|
||||
No. Snapshots are keyed by the workflow name at capture time. Use the workflow picker to find and restore them under the old name.
|
||||
|
||||
**What are profiles?**
|
||||
Profiles save a list of workflows you've visited in a session. Loading a profile restores the latest snapshot for each workflow. They're useful for switching between project contexts — like browser tab groups.
|
||||
|
||||
**Can I use this with ComfyUI Manager?**
|
||||
Yes — install via ComfyUI Manager or clone the repo into `custom_nodes/`.
|
||||
|
||||
+6
-3
@@ -2,9 +2,12 @@
|
||||
ComfyUI Snapshot Manager
|
||||
|
||||
Automatically snapshots workflow state as you edit, with a sidebar panel
|
||||
to browse and restore any previous version. Stored in IndexedDB.
|
||||
to browse and restore any previous version. Stored in server-side JSON files.
|
||||
"""
|
||||
|
||||
from . import snapshot_routes
|
||||
from .snapshot_node import SaveSnapshot
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
NODE_CLASS_MAPPINGS = {"SaveSnapshot": SaveSnapshot}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SaveSnapshot": "Save Snapshot"}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 161 KiB |
+84
-77
@@ -1,93 +1,100 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 450">
|
||||
<defs>
|
||||
<linearGradient id="abg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0f172a"/>
|
||||
<stop offset="100%" style="stop-color:#1e293b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="420" rx="12" fill="url(#abg)"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="400" y="35" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#94a3b8">How It Works</text>
|
||||
|
||||
<!-- Graph Edit box -->
|
||||
<rect x="40" y="60" width="160" height="70" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
|
||||
<text x="120" y="92" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Graph Edit</text>
|
||||
<text x="120" y="112" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">graphChanged event</text>
|
||||
|
||||
<!-- Arrow 1 -->
|
||||
<line x1="200" y1="95" x2="250" y2="95" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Debounce box -->
|
||||
<rect x="250" y="60" width="160" height="70" rx="8" fill="#1e293b" stroke="#f59e0b" stroke-width="2"/>
|
||||
<text x="330" y="92" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Debounce Timer</text>
|
||||
<text x="330" y="112" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">configurable delay</text>
|
||||
|
||||
<!-- Arrow 2 -->
|
||||
<line x1="410" y1="95" x2="460" y2="95" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Hash Check box -->
|
||||
<rect x="460" y="60" width="160" height="70" rx="8" fill="#1e293b" stroke="#8b5cf6" stroke-width="2"/>
|
||||
<text x="540" y="92" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Hash Check</text>
|
||||
<text x="540" y="112" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">per-workflow map</text>
|
||||
|
||||
<!-- Arrow 3 -->
|
||||
<line x1="620" y1="95" x2="640" y2="95" stroke="#475569" stroke-width="2"/>
|
||||
<line x1="640" y1="95" x2="640" y2="180" stroke="#475569" stroke-width="2"/>
|
||||
<line x1="640" y1="180" x2="620" y2="180" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead-left)"/>
|
||||
|
||||
<!-- IndexedDB box -->
|
||||
<rect x="460" y="150" width="160" height="70" rx="8" fill="#1e293b" stroke="#22c55e" stroke-width="2"/>
|
||||
<text x="540" y="182" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">IndexedDB</text>
|
||||
<text x="540" y="202" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">persistent storage</text>
|
||||
|
||||
<!-- Arrow 4 down to sidebar -->
|
||||
<line x1="540" y1="220" x2="540" y2="265" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Sidebar Panel box (wide) -->
|
||||
<rect x="250" y="265" width="370" height="130" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
|
||||
<text x="435" y="295" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#e2e8f0">Sidebar Panel</text>
|
||||
|
||||
<!-- Sidebar sub-items -->
|
||||
<rect x="270" y="310" width="100" height="32" rx="5" fill="#3b82f6" opacity="0.15" stroke="#3b82f6" stroke-width="1"/>
|
||||
<text x="320" y="330" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#93c5fd">Take Snapshot</text>
|
||||
|
||||
<rect x="380" y="310" width="70" height="32" rx="5" fill="#22c55e" opacity="0.15" stroke="#22c55e" stroke-width="1"/>
|
||||
<text x="415" y="330" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#86efac">Restore</text>
|
||||
|
||||
<rect x="460" y="310" width="55" height="32" rx="5" fill="#f59e0b" opacity="0.15" stroke="#f59e0b" stroke-width="1"/>
|
||||
<text x="488" y="330" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#fcd34d">Swap</text>
|
||||
|
||||
<rect x="525" y="310" width="70" height="32" rx="5" fill="#8b5cf6" opacity="0.15" stroke="#8b5cf6" stroke-width="1"/>
|
||||
<text x="560" y="330" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#c4b5fd">Search</text>
|
||||
|
||||
<text x="435" y="375" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">toast notifications · confirm dialogs · loading states</text>
|
||||
|
||||
<!-- Restore arrow back up -->
|
||||
<rect x="40" y="180" width="160" height="70" rx="8" fill="#1e293b" stroke="#22c55e" stroke-width="2"/>
|
||||
<text x="120" y="207" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Restore / Swap</text>
|
||||
<text x="120" y="227" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">with lock guard</text>
|
||||
|
||||
<line x1="250" y1="330" x2="200" y2="265" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-green)"/>
|
||||
<line x1="120" y1="180" x2="120" y2="130" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-green)"/>
|
||||
<text x="120" y="155" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#22c55e">loadGraphData</text>
|
||||
|
||||
<!-- Manual capture arrow -->
|
||||
<line x1="320" y1="310" x2="460" y2="185" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-blue)"/>
|
||||
|
||||
<!-- Arrowhead markers -->
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569"/>
|
||||
</marker>
|
||||
<marker id="arrowhead-left" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto-start-reverse">
|
||||
<polygon points="10 0, 0 3.5, 10 7" fill="#475569"/>
|
||||
</marker>
|
||||
<marker id="arrowhead-green" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#22c55e"/>
|
||||
</marker>
|
||||
<marker id="arrowhead-blue" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6"/>
|
||||
</marker>
|
||||
<marker id="arrowhead-purple" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#6d28d9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="800" height="450" rx="12" fill="url(#abg)"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="400" y="35" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#94a3b8">How It Works</text>
|
||||
|
||||
<!-- ═══ Row 1: Auto-capture path ═══ -->
|
||||
|
||||
<rect x="40" y="55" width="160" height="70" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
|
||||
<text x="120" y="87" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Graph Edit</text>
|
||||
<text x="120" y="107" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">graphChanged event</text>
|
||||
|
||||
<line x1="200" y1="90" x2="250" y2="90" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<rect x="250" y="55" width="160" height="70" rx="8" fill="#1e293b" stroke="#f59e0b" stroke-width="2"/>
|
||||
<text x="330" y="87" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Debounce Timer</text>
|
||||
<text x="330" y="107" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">configurable delay</text>
|
||||
|
||||
<line x1="410" y1="90" x2="460" y2="90" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<rect x="460" y="55" width="160" height="70" rx="8" fill="#1e293b" stroke="#8b5cf6" stroke-width="2"/>
|
||||
<text x="540" y="87" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Hash Check</text>
|
||||
<text x="540" y="107" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">per-workflow dedup</text>
|
||||
|
||||
<!-- Arrow: Hash Check → Server Storage (bend down) -->
|
||||
<line x1="620" y1="90" x2="655" y2="90" stroke="#475569" stroke-width="2"/>
|
||||
<line x1="655" y1="90" x2="655" y2="160" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- ═══ Row 2: Node path + Server Storage ═══ -->
|
||||
|
||||
<rect x="40" y="165" width="170" height="70" rx="8" fill="#1e293b" stroke="#6d28d9" stroke-width="2"/>
|
||||
<text x="125" y="197" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">SaveSnapshot Node</text>
|
||||
<text x="125" y="217" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">queue prompt trigger</text>
|
||||
|
||||
<!-- Arrow: SaveSnapshot → Server Storage with label -->
|
||||
<line x1="210" y1="200" x2="545" y2="200" stroke="#6d28d9" stroke-width="2" marker-end="url(#arrowhead-purple)"/>
|
||||
<text x="378" y="192" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#a78bfa">WS event · skips hash dedup</text>
|
||||
|
||||
<rect x="545" y="160" width="220" height="80" rx="8" fill="#1e293b" stroke="#22c55e" stroke-width="2"/>
|
||||
<text x="655" y="195" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Server Storage</text>
|
||||
<text x="655" y="215" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">JSON files · per-workflow dirs</text>
|
||||
|
||||
<!-- Arrow: Server Storage → Sidebar -->
|
||||
<line x1="655" y1="240" x2="655" y2="285" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- ═══ Row 3: Sidebar + Restore ═══ -->
|
||||
|
||||
<rect x="250" y="285" width="515" height="130" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
|
||||
<text x="507" y="313" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#e2e8f0">Sidebar Panel</text>
|
||||
|
||||
<rect x="270" y="327" width="100" height="32" rx="5" fill="#3b82f6" opacity="0.15" stroke="#3b82f6" stroke-width="1"/>
|
||||
<text x="320" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#93c5fd">Take Snapshot</text>
|
||||
|
||||
<rect x="380" y="327" width="70" height="32" rx="5" fill="#22c55e" opacity="0.15" stroke="#22c55e" stroke-width="1"/>
|
||||
<text x="415" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#86efac">Restore</text>
|
||||
|
||||
<rect x="460" y="327" width="55" height="32" rx="5" fill="#f59e0b" opacity="0.15" stroke="#f59e0b" stroke-width="1"/>
|
||||
<text x="488" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#fcd34d">Swap</text>
|
||||
|
||||
<rect x="525" y="327" width="70" height="32" rx="5" fill="#8b5cf6" opacity="0.15" stroke="#8b5cf6" stroke-width="1"/>
|
||||
<text x="560" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#c4b5fd">Search</text>
|
||||
|
||||
<rect x="605" y="327" width="55" height="32" rx="5" fill="#6d28d9" opacity="0.15" stroke="#6d28d9" stroke-width="1"/>
|
||||
<text x="633" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#a78bfa">Lock</text>
|
||||
|
||||
<text x="507" y="395" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">toast notifications · confirm dialogs · loading states</text>
|
||||
|
||||
<!-- Restore/Swap box -->
|
||||
<rect x="40" y="305" width="160" height="70" rx="8" fill="#1e293b" stroke="#22c55e" stroke-width="2"/>
|
||||
<text x="120" y="337" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Restore / Swap</text>
|
||||
<text x="120" y="357" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">with lock guard</text>
|
||||
|
||||
<!-- Dashed: Sidebar → Restore -->
|
||||
<line x1="250" y1="350" x2="200" y2="340" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-green)"/>
|
||||
|
||||
<!-- Dashed: Restore → Graph Edit (routed around left side) -->
|
||||
<path d="M 40 335 H 20 V 90 H 40" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-green)"/>
|
||||
<text x="11" y="250" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#22c55e" transform="rotate(-90, 11, 250)">loadGraphData</text>
|
||||
|
||||
<!-- Dashed: Take Snapshot → Server Storage -->
|
||||
<line x1="320" y1="327" x2="558" y2="240" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-blue)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -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 |
@@ -0,0 +1,55 @@
|
||||
# UX Rework Design — 2026-04-03
|
||||
|
||||
## Context
|
||||
|
||||
After regular use, three pain points emerged:
|
||||
1. Autosave generates too many snapshots (moves spam the timeline bar)
|
||||
2. Branching UI is confusing and never used
|
||||
3. No way to know what specifically changed when hovering a snapshot
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Autosave — Skip move-only snapshots
|
||||
|
||||
**Problem:** Moving nodes triggers auto-captures as frequently as structural changes, filling the timeline fast.
|
||||
|
||||
**Solution:** Add a guard in the capture path: if `detectChangeType()` returns `'move'`, skip the save and return early. No new config, no debounce changes.
|
||||
|
||||
Existing snapshots are unaffected.
|
||||
|
||||
### 2. Detailed diff metadata on hover
|
||||
|
||||
**Problem:** Snapshot icons show a change type icon but not *what* specifically changed.
|
||||
|
||||
**Solution:** At capture time, after `detectChangeType()`, run a new `computeDetailedDiff(prevGraph, currentGraph)` that produces:
|
||||
|
||||
```json
|
||||
{
|
||||
"added": ["CLIPTextEncode", "KSampler"],
|
||||
"removed": ["VAEDecode"],
|
||||
"params": ["KSampler: steps 20→30", "KSampler: cfg 7→9"]
|
||||
}
|
||||
```
|
||||
|
||||
This object is stored in snapshot metadata. On hover in the sidebar or timeline, a tooltip renders these lines as plain text.
|
||||
|
||||
For snapshots captured before this change (no `diff` field), the tooltip falls back to the existing change type label.
|
||||
|
||||
**Diff computation:**
|
||||
- Added/removed nodes: compare node sets by ID, label by title+type
|
||||
- Changed params: compare `widgets_values` per node between previous and current graph
|
||||
|
||||
### 3. Hide branching UI
|
||||
|
||||
**Problem:** Branch navigation is confusing and adds visual noise.
|
||||
|
||||
**Solution:** Add `const BRANCHING_ENABLED = false` at the top of `snapshot_manager.js`. All branch UI rendering (the `< 1/2 >` navigator, branch buttons, `activeBranchSelections` sidebar/timeline logic) checks this flag and skips when false.
|
||||
|
||||
Underlying data and code (parentId, buildSnapshotTree, etc.) are left intact.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Removing branching code
|
||||
- Making branching a user-facing settings toggle
|
||||
- Changing debounce timing
|
||||
- Retroactively hiding or deleting move-type snapshots
|
||||
@@ -0,0 +1,336 @@
|
||||
# UX Rework Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Three focused UX improvements: hide branching UI, skip move-only autosaves, and show specific diff details on hover.
|
||||
|
||||
**Architecture:** All changes are in `js/snapshot_manager.js`. No backend changes needed. Diff summary is computed at capture time and stored in snapshot metadata so hover display is instant (no extra fetch).
|
||||
|
||||
**Tech Stack:** Vanilla JS, ComfyUI extension API, existing `computeDetailedDiff()` function (line 485).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Hide branching UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/snapshot_manager.js:27` (branchingDefault)
|
||||
- Modify: `js/snapshot_manager.js:350-354` (isBranchingEnabled)
|
||||
- Modify: `js/snapshot_manager.js:2847-2866` (branchToggleBtn)
|
||||
|
||||
**Step 1: Add BRANCHING_ENABLED constant**
|
||||
|
||||
At the very top of the file (after the opening comment block, around line 1-30), add this constant near the other state variables:
|
||||
|
||||
```js
|
||||
const BRANCHING_ENABLED = false;
|
||||
```
|
||||
|
||||
Place it just before line 27 (`let branchingDefault = true;`).
|
||||
|
||||
**Step 2: Short-circuit isBranchingEnabled()**
|
||||
|
||||
Current code at line 350:
|
||||
```js
|
||||
function isBranchingEnabled(wk) {
|
||||
if (!wk) wk = getEffectiveWorkflowKey();
|
||||
if (workflowBranchOverrides.has(wk)) return workflowBranchOverrides.get(wk);
|
||||
return branchingDefault;
|
||||
}
|
||||
```
|
||||
|
||||
Add an early return at the top of the function:
|
||||
```js
|
||||
function isBranchingEnabled(wk) {
|
||||
if (!BRANCHING_ENABLED) return false;
|
||||
if (!wk) wk = getEffectiveWorkflowKey();
|
||||
if (workflowBranchOverrides.has(wk)) return workflowBranchOverrides.get(wk);
|
||||
return branchingDefault;
|
||||
}
|
||||
```
|
||||
|
||||
This makes all 14 existing `isBranchingEnabled()` callsites automatically behave as if branching is off — no other changes needed in capture, sidebar, or timeline code.
|
||||
|
||||
**Step 3: Hide the branch toggle button in the sidebar**
|
||||
|
||||
The button is created at line 2847 and appended at line 2866. Hide it:
|
||||
|
||||
Current (line 2847):
|
||||
```js
|
||||
const branchToggleBtn = document.createElement("button");
|
||||
branchToggleBtn.className = "snap-filter-auto-btn" + (isBranchingEnabled() ? " active" : "");
|
||||
```
|
||||
|
||||
Change to:
|
||||
```js
|
||||
const branchToggleBtn = document.createElement("button");
|
||||
branchToggleBtn.className = "snap-filter-auto-btn" + (isBranchingEnabled() ? " active" : "");
|
||||
branchToggleBtn.style.display = "none";
|
||||
```
|
||||
|
||||
**Step 4: Verify manually in browser**
|
||||
- Open ComfyUI, open snapshot manager sidebar
|
||||
- Confirm "Branch" button is not visible
|
||||
- Create 2-3 snapshots, confirm no branch navigator (`< 1/2 >`) appears
|
||||
- Confirm timeline has no expand button
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add js/snapshot_manager.js
|
||||
git commit -m "Hide branching UI (BRANCHING_ENABLED = false)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Skip move-only autosaves
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/snapshot_manager.js:1459-1460`
|
||||
|
||||
**Step 1: Add move filter in _captureSnapshotInner**
|
||||
|
||||
Current code at lines 1458-1460:
|
||||
```js
|
||||
const prevGraph = lastGraphDataMap.get(workflowKey);
|
||||
const changeType = detectChangeType(prevGraph, graphData);
|
||||
|
||||
// Determine parentId for branching
|
||||
```
|
||||
|
||||
Add one line after the changeType computation:
|
||||
```js
|
||||
const prevGraph = lastGraphDataMap.get(workflowKey);
|
||||
const changeType = detectChangeType(prevGraph, graphData);
|
||||
if (changeType === "move") return false;
|
||||
|
||||
// Determine parentId for branching
|
||||
```
|
||||
|
||||
**Step 2: Verify manually in browser**
|
||||
- Open ComfyUI, move several nodes around
|
||||
- Confirm no new snapshots appear in the sidebar/timeline while moving
|
||||
- Add a node → confirm a snapshot IS created
|
||||
- Change a parameter → confirm a snapshot IS created
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add js/snapshot_manager.js
|
||||
git commit -m "Skip autosave for move-only changes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Store diff summary at capture time
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/snapshot_manager.js:614` (after computeDetailedDiff, add new helper)
|
||||
- Modify: `js/snapshot_manager.js:1471-1481` (record construction in _captureSnapshotInner)
|
||||
- Modify: `js/snapshot_manager.js:1550-1562` (record construction in captureNodeSnapshot)
|
||||
|
||||
**Step 1: Add computeCaptureMetaDiff() and formatCaptureDiffLines() helpers**
|
||||
|
||||
Insert these two functions right after `computeDetailedDiff` ends (after line 614, before the SVG section comment at line 616):
|
||||
|
||||
```js
|
||||
// Compact diff stored in snapshot metadata for hover display
|
||||
function computeCaptureMetaDiff(prevGraph, currGraph) {
|
||||
if (!prevGraph || !currGraph) return null;
|
||||
const diff = computeDetailedDiff(prevGraph, currGraph);
|
||||
const result = {};
|
||||
if (diff.addedNodes.length > 0)
|
||||
result.added = diff.addedNodes.map(n => n.title);
|
||||
if (diff.removedNodes.length > 0)
|
||||
result.removed = diff.removedNodes.map(n => n.title);
|
||||
// Nodes with param/property changes (ignore pure position/size changes)
|
||||
const paramChanged = diff.modifiedNodes.filter(n =>
|
||||
n.changes.widgetValues || n.changes.properties || n.changes.title || n.changes.mode
|
||||
);
|
||||
if (paramChanged.length > 0)
|
||||
result.params = paramChanged.map(n => {
|
||||
const count = (n.changes.widgetValues?.length ?? 0) + (n.changes.properties?.length ?? 0);
|
||||
return count > 0 ? `${n.title} (${count} value${count > 1 ? "s" : ""})` : n.title;
|
||||
});
|
||||
if (diff.addedLinks.length > 0 || diff.removedLinks.length > 0)
|
||||
result.links = { added: diff.addedLinks.length, removed: diff.removedLinks.length };
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
function formatCaptureDiffLines(captureDiff) {
|
||||
if (!captureDiff) return [];
|
||||
const lines = [];
|
||||
if (captureDiff.added?.length)
|
||||
lines.push(`+ ${captureDiff.added.join(", ")}`);
|
||||
if (captureDiff.removed?.length)
|
||||
lines.push(`− ${captureDiff.removed.join(", ")}`);
|
||||
if (captureDiff.params?.length)
|
||||
lines.push(`~ ${captureDiff.params.join(", ")}`);
|
||||
if (captureDiff.links) {
|
||||
const parts = [];
|
||||
if (captureDiff.links.added) parts.push(`+${captureDiff.links.added} link${captureDiff.links.added > 1 ? "s" : ""}`);
|
||||
if (captureDiff.links.removed) parts.push(`−${captureDiff.links.removed} link${captureDiff.links.removed > 1 ? "s" : ""}`);
|
||||
if (parts.length) lines.push(parts.join(", "));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add captureDiff to record in _captureSnapshotInner**
|
||||
|
||||
Current record construction at lines 1471-1481:
|
||||
```js
|
||||
const record = {
|
||||
id: generateId(),
|
||||
workflowKey,
|
||||
timestamp: Date.now(),
|
||||
label,
|
||||
nodeCount: nodes.length,
|
||||
graphData,
|
||||
locked: false,
|
||||
changeType,
|
||||
parentId,
|
||||
};
|
||||
```
|
||||
|
||||
Add `captureDiff` field:
|
||||
```js
|
||||
const captureDiff = computeCaptureMetaDiff(prevGraph, graphData);
|
||||
const record = {
|
||||
id: generateId(),
|
||||
workflowKey,
|
||||
timestamp: Date.now(),
|
||||
label,
|
||||
nodeCount: nodes.length,
|
||||
graphData,
|
||||
locked: false,
|
||||
changeType,
|
||||
parentId,
|
||||
...(captureDiff ? { captureDiff } : {}),
|
||||
};
|
||||
```
|
||||
|
||||
**Step 3: Same for captureNodeSnapshot**
|
||||
|
||||
Current record construction at lines 1550-1562:
|
||||
```js
|
||||
const record = {
|
||||
id: generateId(),
|
||||
workflowKey,
|
||||
timestamp: Date.now(),
|
||||
label,
|
||||
nodeCount: nodes.length,
|
||||
graphData,
|
||||
locked: false,
|
||||
source: "node",
|
||||
changeType,
|
||||
parentId,
|
||||
...(thumbnail ? { thumbnail } : {}),
|
||||
};
|
||||
```
|
||||
|
||||
Change to:
|
||||
```js
|
||||
const captureDiff = computeCaptureMetaDiff(prevGraph, graphData);
|
||||
const record = {
|
||||
id: generateId(),
|
||||
workflowKey,
|
||||
timestamp: Date.now(),
|
||||
label,
|
||||
nodeCount: nodes.length,
|
||||
graphData,
|
||||
locked: false,
|
||||
source: "node",
|
||||
changeType,
|
||||
parentId,
|
||||
...(captureDiff ? { captureDiff } : {}),
|
||||
...(thumbnail ? { thumbnail } : {}),
|
||||
};
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add js/snapshot_manager.js
|
||||
git commit -m "Compute and store diff summary at capture time"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Show diff in sidebar hover tooltip
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/snapshot_manager.js:3573-3603` (mouseenter handler in sidebar)
|
||||
|
||||
**Step 1: Add diff lines to tooltip**
|
||||
|
||||
Current mouseenter callback (lines 3572-3608) renders an SVG or thumbnail then positions/shows the tooltip. After the SVG/thumbnail is appended (right before the `rect` / positioning block), add diff lines:
|
||||
|
||||
Locate this block in the mouseenter callback:
|
||||
```js
|
||||
if (!tooltipTimer) return;
|
||||
const svg = getCachedSVG(rec.id, graphData, { width: 240, height: 180 });
|
||||
if (!svg) return;
|
||||
tooltip.appendChild(svg);
|
||||
}
|
||||
const rect = item.getBoundingClientRect();
|
||||
```
|
||||
|
||||
Change to:
|
||||
```js
|
||||
if (!tooltipTimer) return;
|
||||
const svg = getCachedSVG(rec.id, graphData, { width: 240, height: 180 });
|
||||
if (!svg) return;
|
||||
tooltip.appendChild(svg);
|
||||
}
|
||||
// Diff summary lines
|
||||
const diffLines = formatCaptureDiffLines(rec.captureDiff);
|
||||
if (diffLines.length > 0) {
|
||||
const diffEl = document.createElement("div");
|
||||
diffEl.style.cssText = "margin-top:6px;font-size:11px;line-height:1.5;color:#ccc;white-space:pre;";
|
||||
diffEl.textContent = diffLines.join("\n");
|
||||
tooltip.appendChild(diffEl);
|
||||
}
|
||||
const rect = item.getBoundingClientRect();
|
||||
```
|
||||
|
||||
**Step 2: Verify manually in browser**
|
||||
- Create a snapshot by adding a node → hover it in sidebar → tooltip shows SVG preview + diff lines like `+ KSampler`
|
||||
- Create a snapshot by changing a param → hover it → tooltip shows `~ KSampler (1 value)`
|
||||
- Hover an old snapshot without captureDiff → tooltip shows SVG only (no crash)
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add js/snapshot_manager.js
|
||||
git commit -m "Show diff summary in sidebar hover tooltip"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Show diff in timeline marker tooltip
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/snapshot_manager.js:3715-3717` (buildMarker tip construction)
|
||||
|
||||
**Step 1: Append diff lines to marker title**
|
||||
|
||||
Current code at lines 3715-3717:
|
||||
```js
|
||||
let tip = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`;
|
||||
if (rec.notes) tip += `\n${rec.notes}`;
|
||||
marker.title = tip;
|
||||
```
|
||||
|
||||
Change to:
|
||||
```js
|
||||
let tip = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`;
|
||||
const diffLines = formatCaptureDiffLines(rec.captureDiff);
|
||||
if (diffLines.length > 0) tip += `\n${diffLines.join("\n")}`;
|
||||
if (rec.notes) tip += `\n${rec.notes}`;
|
||||
marker.title = tip;
|
||||
```
|
||||
|
||||
**Step 2: Verify manually in browser**
|
||||
- Hover a timeline marker → native tooltip shows label, time, change type, then diff lines
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add js/snapshot_manager.js
|
||||
git commit -m "Show diff summary in timeline marker tooltip"
|
||||
```
|
||||
@@ -0,0 +1,258 @@
|
||||
# Non-Destructive Timeline — Research Report & Implementation Plan
|
||||
|
||||
**Date:** 2026-06-29
|
||||
**Goal:** Make the Snapshot Manager timeline behave like Autodesk Fusion 360's parametric
|
||||
timeline — jumping back and forth is easy, fast, and **non-destructive** — while fixing the
|
||||
three reported pain points: (1) weak "what changed" info, (2) spammy autosave, (3) workflow
|
||||
switching spams "now save".
|
||||
|
||||
This document has two parts:
|
||||
- **Part 1 — Research report** (web research, 25 claims survived 3-vote adversarial verification).
|
||||
- **Part 2 — Implementation plan** (each recommendation mapped to concrete changes in `js/snapshot_manager.js`).
|
||||
|
||||
---
|
||||
|
||||
# Part 1 — Research Report
|
||||
|
||||
## Executive summary
|
||||
|
||||
Across Fusion 360 (CAD), Houdini (node graph), Unreal (Blueprint diff), Final Cut Pro (NLE),
|
||||
Figma and Google Docs (history panels), the tools that feel non-destructive share four moves:
|
||||
**(a)** jumping back never deletes later work — it *disables and recomputes* it (Fusion) or keeps
|
||||
the live state untouched while you preview (Final Cut Pro skimmer); **(b)** *preview* is decoupled
|
||||
from *commit* — you can hover/scrub other states and still "return to where you were"; **(c)** a
|
||||
restore/jump records the pre-jump state **once** as a checkpoint (Figma) rather than spamming
|
||||
checkpoints; and **(d)** "what changed" is shown **semantically** (named parameters, fixed
|
||||
color-coded change types — Unreal's red/green/cyan/grey), with trivial moves *de-emphasized* and
|
||||
auto-history *coalesced* by time or by filtering out streaming/cosmetic edits (Figma's 30-min
|
||||
cadence; redux-undo's `filter`/`excludeAction`/`groupBy`; Houdini's explicit warning against
|
||||
over-automatic capture). These map cleanly onto a snapshot-per-state model and onto all three pain
|
||||
points.
|
||||
|
||||
## Findings
|
||||
|
||||
### F1 — Non-destructive "jump back" = disable + recompute, never delete (Fusion 360) — **HIGH**
|
||||
Fusion records every step in creation order and lets you "go back in time" to edit earlier
|
||||
decisions without starting over. Mechanically, editing/rolling to a past feature **rolls the
|
||||
history marker to just before it and disables (not deletes) all downstream nodes**, then
|
||||
**recomputes them forward** when you roll back to the end — "all the features that were after that
|
||||
point are preserved and will reappear." Rolling the marker back "has not deleted the features in
|
||||
front of the marker." This is the load-bearing property: navigating history is a *reversible
|
||||
editing surface*, not a destructive log.
|
||||
*Sources: autodesk.com Fusion blog (timeline-edits, beginners-guide-part-4) — primary; help.autodesk.com Fusion-360-API CustomFeatures_UM — primary; productdesignonline.com; Ace Makerspace. Votes 3-0 (×5), 2-1 (×1).*
|
||||
|
||||
### F2 — Navigation gesture == edit gesture, plus a draggable marker with playback controls (Fusion) — **HIGH**
|
||||
A past step is edited in place by **double-clicking** it, or **right-click → Edit Sketch/Edit
|
||||
Feature**. Pure navigation is a **draggable history-marker slider** ("rolling back the design")
|
||||
with **playback controls** (move-to-beginning, previous step, play, next step, move-to-end) and a
|
||||
right-click **"Roll History Marker Here"** to jump the marker directly to any step. So back/forth
|
||||
is a one-gesture, low-friction interaction distinct from editing.
|
||||
*Sources: autodesk.com Fusion blog — primary; productdesignonline.com (blog); Noble Desktop. Votes 3-0.*
|
||||
|
||||
### F3 — Decouple *preview* from *committed position* — the skimmer pattern (Final Cut Pro) — **HIGH**
|
||||
Final Cut Pro uses **two indicators**: a persistent **playhead** (your committed position, fixed
|
||||
unless you move it) and a transient **skimmer** (a preview cursor that follows the pointer). The
|
||||
skimmer "lets you preview clips… **without affecting the playhead position**," so you can "skim to
|
||||
see what's in other clips but still keep your playhead position." This is the canonical
|
||||
"scrub-to-preview, keep your place / return to where I was" pattern — non-destructive preview
|
||||
decoupled from the committed edit position.
|
||||
*Sources: support.apple.com Final Cut Pro (skimmer, intro-to-playback) — primary; Larry Jordan; Ripple Training. Votes 3-0 (×3), 2-1 (×1).*
|
||||
|
||||
### F4 — Semantic, color-coded "what changed" + difference navigation (Unreal Blueprint diff) — **HIGH**
|
||||
Unreal's Blueprint Diff Tool communicates **change type visually** with a fixed legend:
|
||||
**red = removed, green = added, cyan = changed, grey = moved nodes/comments.** It provides
|
||||
**Next/Previous** buttons to cycle differences one at a time and a **clickable navigation tree** to
|
||||
jump to a specific difference. The lesson: show the *kind* of change semantically (not raw
|
||||
indices), and explicitly give **moves their own subdued category** (grey).
|
||||
*Sources: dev.epicgames.com UE Diff Tool docs (UE 5.7) — primary. Votes 2-1 (color legend), 3-0 (navigation).*
|
||||
|
||||
### F5 — Scope change capture explicitly; warn against over-automatic capture (Houdini takes) — **HIGH**
|
||||
Houdini "takes" are **hierarchically overlaid sets of parameter changes**: any parameter you don't
|
||||
explicitly change is **inherited from the parent**, enabling non-destructive parallel variations
|
||||
that preserve the original. Only **explicitly included** parameters are editable in a take (others
|
||||
appear disabled), and the takes pane **shows which parameters changed** for the selected take.
|
||||
Critically, SideFX **explicitly warns against overusing Auto-take mode** because it "makes it easy
|
||||
to unintentionally include parameters… which… can make diagnosing problems difficult" — a
|
||||
first-party anti-pattern for automatic change capture that mirrors this tool's autosave spam.
|
||||
*Sources: sidefx.com/docs/houdini takes + ref/panes/takes — primary. Votes 3-0.*
|
||||
|
||||
### F6 — Restore/jump is non-destructive *because it checkpoints the pre-jump state once* (Figma) — **HIGH**
|
||||
Restoring a previous version in Figma "is a **non-destructive action**, so you can still access the
|
||||
current version." It works by adding **two autosave checkpoints**: one preserving the
|
||||
current/pre-restore state, one marking the restored version. So later work is never silently lost —
|
||||
it's captured as a checkpoint exactly once at the moment of restore.
|
||||
*Sources: help.figma.com version-history — primary. Votes 3-0.*
|
||||
|
||||
### F7 — Coalesce auto-history by time; separate viewing from restoring; named vs auto (Figma, Google Docs) — **HIGH**
|
||||
Figma records an auto **checkpoint every 30 minutes** (time-based coalescing) while keeping the
|
||||
live "current version" continuously up to date — it does **not** snapshot on every edit. Google
|
||||
Docs separates **viewing** an earlier version (read-only; a **"Back"** control returns you to
|
||||
current, no state change) from **restoring** it (explicit "Restore this version"). It also supports
|
||||
**named versions** distinct from auto-saved revisions, with an **"Only show named versions"** filter
|
||||
to cut noise (caps: 40 named/doc, 15/spreadsheet).
|
||||
*Sources: help.figma.com; support.google.com/docs/answer/190843 — primary. Votes 3-0.*
|
||||
|
||||
### F8 — History-noise control patterns: filter, exclude streaming actions, group into chunks (redux-undo) — **HIGH**
|
||||
A `filter` keeps intermediate state changes **out of history** (`state.past`) **without affecting
|
||||
the live state**. For drag-like continuous edits, `excludeAction(['MOVE_CURSOR','UPDATE_OBJECT_POS'])`
|
||||
records **only the final committed state**. And undo/redo in "reasonable chunks" needs deliberate
|
||||
**custom filters + `groupBy`** rather than recording every micro-action. This is the concrete
|
||||
recipe for de-noising autosave: gate out cosmetic/streaming edits, coalesce a burst into one entry.
|
||||
*Sources: redux-undo.js.org/main/faq — primary; GitHub README. Votes 3-0 (×2), 2-1 (×1).*
|
||||
|
||||
## Confidence & caveats
|
||||
- **High confidence** on every finding above (primary vendor docs; mostly unanimous 3-0 votes).
|
||||
- **Vendor-doc framing:** Fusion's "non-destructive" language partly comes from Autodesk marketing
|
||||
blogs, but the *mechanism* (disable + recompute, marker preserves downstream) is independently
|
||||
corroborated by API docs and third parties — solid.
|
||||
- **Edge cases noted but not refuting:** editing a far-upstream Fusion feature can break downstream
|
||||
dependency references (inherent to any dependency graph; the design is still never destroyed);
|
||||
Figma history retention is tier-bounded (30 days on free); redux-undo `filter` is technically a
|
||||
warning-context API, not an endorsement (the *pattern* still holds).
|
||||
- **Coverage gaps:** DAW (Ableton/Logic), DaVinci/Premiere timeline scrubbing, Photoshop history,
|
||||
and Git-GUI detached-HEAD UX were searched but produced no *separately verified* surviving claims
|
||||
beyond what Final Cut Pro / Figma / Google Docs already cover. The FCP skimmer is the strongest
|
||||
scrubbing-ergonomics result.
|
||||
|
||||
## Open questions
|
||||
1. For a **snapshot-per-state** model (vs Fusion's parametric feature graph), should "jump back and
|
||||
edit" create a **branch** automatically, or keep the linear list and rely on non-destructive
|
||||
"Current" checkpoints? (Branching is currently hard-disabled in the build.)
|
||||
2. What's the right **auto-snapshot cadence** — pure event-debounce (today: 3 s), a Figma-style
|
||||
time floor (e.g. ≥1 per N minutes), or both?
|
||||
3. Should **scrub/preview** load the graph at all (expensive), or only show the SVG/thumbnail
|
||||
preview until the user explicitly commits — to keep back/forth instant on large graphs?
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Implementation Plan
|
||||
|
||||
Each workstream cites concrete code in `js/snapshot_manager.js`. Ordered by impact;
|
||||
**Must-have → Should-have → Nice-to-have**.
|
||||
|
||||
## Implementation status (2026-06-29)
|
||||
**Done** (all four batches landed, 19/19 unit tests on the extracted diff logic pass):
|
||||
- **C1 + C2** — `seedWorkflowBaseline()` + `suppressAutoCapture(SWITCH_GUARD_MS)` on `openWorkflow`; `scheduleCaptureSnapshot` honours the suppression window.
|
||||
- **B1** — `detectChangeType` now classifies move/resize/collapse(+pin) as `"cosmetic"` and never lets a cosmetic flag escalate a real edit; `mode` (mute/bypass) treated as meaningful. `_captureCore` skips `changeType==="cosmetic"` for auto-captures (`skipCosmetic`).
|
||||
- **A1 + A3** — `getLiveWidgetNames()`/`widgetNameFor()` map `widgets_values` indices → names at capture; `computeDetailedDiff`/`computeCaptureMetaDiff` carry names; diff modal shows `seed:`/`text:` (meaningful first) and collapses position/size into one muted "Layout: moved, resized" line; tooltips read `KSampler (seed, cfg)`.
|
||||
- **D1 + D3** — non-destructive jump confirmed (swap re-seeds hash + dedup → repeat steps are storage no-ops); `stepToSnapshot()` + **Alt+◀ / Alt+▶** keyboard step nav with a quiet swap and a `N/total · label` position toast.
|
||||
|
||||
**Deferred (nice-to-have, not yet built):** A2 full Unreal-style color legend per change type; B2 time-based checkpoint floor; B3 Google-Docs "only show manual/named" filter; D2 drag-scrub skimmer mode (hover preview already exists); D4 explicit "return to where I was" affordance.
|
||||
|
||||
## Pain-point ↔ finding map
|
||||
| Pain point | Backed by | Workstreams |
|
||||
|---|---|---|
|
||||
| Weak "what changed" info | F4, F5, F7 | **A** |
|
||||
| Spammy autosave | F5, F7, F8 | **B** |
|
||||
| WF-switch spams "now save" | (codebase bug) F6 | **C** |
|
||||
| Fusion-360 non-destructive feel | F1, F2, F3, F6 | **D** |
|
||||
|
||||
---
|
||||
|
||||
## Workstream A — Semantic "what changed" (MUST) — *F4, F5, F7*
|
||||
|
||||
**Problem (code):** `computeDetailedDiff` (`:565`) compares `widgets_values` positionally and emits
|
||||
`Value[6]: "a cat" → "a dog"` (`:1370`). `getGraphData()` is `app.graph.serialize()` (`:438`), so
|
||||
at capture time the **live** `app.graph._nodes[i].widgets[]` array — each with a `.name` in the
|
||||
same order as `widgets_values` — is available but never used. `detectChangeType` (`:454`) only
|
||||
yields a coarse single bucket.
|
||||
|
||||
**A1. Capture a widget index→name map (MUST).** At capture in `_captureCore` (`:1574`), walk
|
||||
`app.graph._nodes`, build `{nodeId: [widgetName,…]}`, and use it so diffs read `text:`, `seed:`,
|
||||
`cfg:`, `sampler_name:` instead of `Value[i]`. Store a compact, named `captureDiff` (extend
|
||||
`computeCaptureMetaDiff` `:697`). Persisted per snapshot so old snapshots without it degrade
|
||||
gracefully to the current `Value[i]` form.
|
||||
|
||||
**A2. Semantic change classification + Unreal-style legend (SHOULD).** Replace the single
|
||||
`changeType` with a small set the user cares about: `prompt`, `param` (seed/cfg/sampler/steps/…),
|
||||
`model` (checkpoint/LoRA names), `connection`, `node_add`/`node_remove`, and a subordinate
|
||||
`cosmetic` (move/resize/collapse). Reuse `CHANGE_TYPE_ICONS` (`:2755`) with the red=removed /
|
||||
green=added / cyan=changed / grey=cosmetic palette (F4). **Moves/resizes get the grey, de-emphasized
|
||||
treatment** — exactly what you asked for.
|
||||
|
||||
**A3. De-noise the diff modal + tooltip (SHOULD).** In `showDiffModal` (`:1232`) and
|
||||
`formatCaptureDiffLines` (`:720`), put position/size/move rows in a **collapsed "Cosmetic"
|
||||
section** and surface prompt/param/model/connection changes first with their widget names. The
|
||||
hover tooltip headline should read e.g. `~ KSampler (seed, cfg) · + CLIPTextEncode` rather than
|
||||
`~ 2 values`.
|
||||
|
||||
## Workstream B — Tame autosave noise (MUST) — *F5, F7, F8*
|
||||
|
||||
**Problem (code):** every `graphChanged` schedules a capture (`:4236`→`scheduleCaptureSnapshot`
|
||||
`:1671`, 3 s debounce). `_captureCore` only skips `changeType === "move"` (`:1589`), so **resize and
|
||||
collapse/expand fall through as `"unknown"` and ARE saved** as snapshots — pure visual noise. No
|
||||
floor on auto-snapshot frequency.
|
||||
|
||||
**B1. Cosmetic-change gate (MUST).** Generalize `skipMove` → `skipCosmetic`: skip auto-capture when
|
||||
the *only* changes are position/size/collapse/pin (redux-undo `filter`/`excludeAction`; Houdini
|
||||
Auto-take warning F5/F8). Manual snapshots (Ctrl+S `:4297`, Snapshot button `:3901`) and node-trigger
|
||||
captures still save everything. This alone removes most of the spam.
|
||||
|
||||
**B2. Coalesce bursts; optional time floor (SHOULD).** Keep the 3 s event-debounce (already
|
||||
coalesces a typing burst). Add an optional minimum interval between *auto* snapshots and/or a
|
||||
Figma-style time-based fallback checkpoint (F7), configurable via the existing settings
|
||||
(`debounceSeconds` lives at `:4130`).
|
||||
|
||||
**B3. Make auto vs manual legible + filterable (NICE).** Auto-snapshots already carry labels;
|
||||
borrow Google Docs' **"Only show named/manual versions"** filter (F7) in the sidebar so the auto
|
||||
stream can be hidden. The existing search/filter UI (`:2934`+) is the natural home.
|
||||
|
||||
## Workstream C — Fix workflow-switch "now save" spam (MUST) — *codebase bug, F6*
|
||||
|
||||
**Root cause (code):** the `openWorkflow` handler (`:4252`) resets state and seeds
|
||||
`lastCapturedIdMap` for the new tab but **never seeds `lastCapturedHashMap` / `lastGraphData`** for
|
||||
it. ComfyUI's `loadGraphData` for the freshly-opened workflow then fires `graphChanged` →
|
||||
`scheduleCaptureSnapshot` → 3 s later `captureSnapshot("Auto")` runs, finds no seeded hash, **can't
|
||||
dedupe**, and saves a redundant snapshot of a workflow you only just opened.
|
||||
|
||||
**C1. Re-seed hash on switch (MUST).** In the `openWorkflow` `after()` block (`:4255`), after the
|
||||
new graph is live, set `lastCapturedHashMap`/`setLastGraphData` for `newKey` (mirror the setup
|
||||
seeding at `:4309-4322`). The post-load `graphChanged` then dedupes to a no-op.
|
||||
|
||||
**C2. Programmatic-load suppression window (MUST).** Add a short-lived `loadingLock` flag (sibling
|
||||
to `restoreLock` `:35`/`:1170`) set around tab switches and snapshot loads so `scheduleCaptureSnapshot`
|
||||
(`:1671`) ignores the `graphChanged` events that *we* caused. Belt-and-suspenders with C1.
|
||||
|
||||
## Workstream D — Fusion-360 non-destructive navigation (SHOULD) — *F1, F2, F3, F6*
|
||||
|
||||
**Problem (code):** every timeline marker click calls `swapSnapshot(rec)` (`:3955`), which
|
||||
`captureSnapshot("Current")` **before** loading (`:1728`). So *navigating* is what generates "now
|
||||
save" and it feels destructive. There's no prev/next step nav and the current-position indicator is
|
||||
subtle.
|
||||
|
||||
**D1. Non-destructive jump = checkpoint-once-then-load (SHOULD).** Adopt Figma's model (F6): when
|
||||
leaving a dirty live state, capture the pre-jump state **once** (already hash-deduped, so browsing
|
||||
between *saved* states is a no-op), then load the target. Confirm/tighten that repeated back/forth
|
||||
between existing snapshots creates **zero** new "Current" snapshots. Never delete later snapshots
|
||||
(we already don't) — that's the non-destructive guarantee (F1).
|
||||
|
||||
**D2. Preview vs commit — skimmer pattern (SHOULD).** The hover tooltip already previews via
|
||||
SVG/thumbnail (`:3793`). Lean into it: hovering = preview (skimmer), clicking = commit (playhead)
|
||||
(F3). Optionally a "scrub" mode where dragging along the timeline updates only the preview, and you
|
||||
commit on release — keeps back/forth instant on big graphs (open question 3).
|
||||
|
||||
**D3. Prev/Next step + clear current marker (SHOULD).** Add **keyboard step navigation**
|
||||
(`[` / `]` or arrow keys) and Fusion-style playback buttons (begin/prev/next/end) to the timeline
|
||||
(`buildTimeline` `:3869`), cycling snapshots one at a time (Unreal Next/Prev F4; Fusion controls
|
||||
F2). Strengthen the current-position indicator (`marker-current`/`marker-active` `:3940`) so "where
|
||||
am I" is obvious.
|
||||
|
||||
**D4. "Return to where I was" (NICE).** Remember the snapshot you were on before scrubbing and offer
|
||||
a one-click jump back (FCP playhead F3; Houdini go-back-and-forth F5). Lightweight: a single
|
||||
`preScrubSnapshotId` + a "↩ return" affordance.
|
||||
|
||||
---
|
||||
|
||||
## Suggested sequencing
|
||||
1. **C1 + C2** (kill wf-switch spam) — small, isolated, immediate relief.
|
||||
2. **B1** (cosmetic gate) — biggest reduction in autosave noise, low risk.
|
||||
3. **A1 + A3** (named semantic diffs) — the "what changed" payoff you asked for.
|
||||
4. **D1 + D3** (non-destructive jump + step nav) — the Fusion-360 feel.
|
||||
5. **A2, B2/B3, D2, D4** — polish / configurable refinements.
|
||||
|
||||
## Risks
|
||||
- Widget-name mapping (A1) depends on live `_nodes`/`widgets` internals — guard for nodes whose
|
||||
widget count ≠ `widgets_values` length and for headless/serialize-only paths.
|
||||
- The cosmetic gate (B1) must not swallow a *meaningful* change that co-occurs with a move — gate
|
||||
only when changes are **exclusively** cosmetic.
|
||||
- Suppression window (C2) must auto-release even if a load throws (mirror `withRestoreLock` `:1170`).
|
||||
+3747
-138
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-snapshot-manager"
|
||||
description = "Automatically snapshots workflow state with a sidebar to browse and restore previous versions."
|
||||
version = "1.0.0"
|
||||
version = "3.0.1"
|
||||
license = {text = "MIT"}
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import base64
|
||||
import io
|
||||
|
||||
from server import PromptServer
|
||||
|
||||
|
||||
class _AnyType(str):
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
ANY_TYPE = _AnyType("*")
|
||||
|
||||
|
||||
def _make_thumbnail(value):
|
||||
"""Convert an image tensor to a base64 JPEG thumbnail, or return None."""
|
||||
try:
|
||||
import torch
|
||||
if not isinstance(value, torch.Tensor):
|
||||
return None
|
||||
if value.ndim != 4 or value.shape[3] not in (3, 4):
|
||||
return None
|
||||
|
||||
from PIL import Image
|
||||
|
||||
frame = value[0] # first frame only
|
||||
if frame.shape[2] == 4:
|
||||
frame = frame[:, :, :3] # drop alpha
|
||||
arr = frame.clamp(0, 1).mul(255).byte().cpu().numpy()
|
||||
img = Image.fromarray(arr, mode="RGB")
|
||||
img.thumbnail((200, 150), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=75)
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class SaveSnapshot:
|
||||
CATEGORY = "Snapshot Manager"
|
||||
FUNCTION = "execute"
|
||||
RETURN_TYPES = (ANY_TYPE,)
|
||||
OUTPUT_NODE = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"value": (ANY_TYPE, {}),
|
||||
"label": ("STRING", {"default": "Node Trigger"}),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, input_types):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, *args, **kwargs):
|
||||
return float("NaN")
|
||||
|
||||
def execute(self, value, label):
|
||||
payload = {"label": label}
|
||||
thumbnail = _make_thumbnail(value)
|
||||
if thumbnail is not None:
|
||||
payload["thumbnail"] = thumbnail
|
||||
PromptServer.instance.send_sync(
|
||||
"snapshot-manager-capture", payload
|
||||
)
|
||||
return (value,)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
HTTP route handlers for snapshot storage.
|
||||
|
||||
Registers endpoints with PromptServer.instance.routes at import time.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
from . import snapshot_storage as storage
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
# Sanity caps to bound disk/memory use from a single request.
|
||||
_MAX_BODY_BYTES = 128 * 1024 * 1024 # 128 MB per request
|
||||
_MAX_MIGRATE_RECORDS = 10000
|
||||
|
||||
|
||||
def _too_large(request):
|
||||
"""True if the request advertises a body larger than the cap."""
|
||||
cl = request.content_length
|
||||
return cl is not None and cl > _MAX_BODY_BYTES
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/save")
|
||||
async def save_snapshot(request):
|
||||
try:
|
||||
if _too_large(request):
|
||||
return web.json_response({"error": "Request too large"}, status=413)
|
||||
data = await request.json()
|
||||
record = data.get("record")
|
||||
if not record or "id" not in record or "workflowKey" not in record:
|
||||
return web.json_response({"error": "Missing record with id and workflowKey"}, status=400)
|
||||
storage.put(record)
|
||||
return web.json_response({"ok": True})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/list")
|
||||
async def list_snapshots(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
workflow_key = data.get("workflowKey")
|
||||
if not workflow_key:
|
||||
return web.json_response({"error": "Missing workflowKey"}, status=400)
|
||||
records = storage.get_all_for_workflow(workflow_key)
|
||||
return web.json_response(records)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/get")
|
||||
async def get_snapshot(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
workflow_key = data.get("workflowKey")
|
||||
snapshot_id = data.get("id")
|
||||
if not workflow_key or not snapshot_id:
|
||||
return web.json_response({"error": "Missing workflowKey or id"}, status=400)
|
||||
record = storage.get_full_record(workflow_key, snapshot_id)
|
||||
if record is None:
|
||||
return web.json_response({"error": "Not found"}, status=404)
|
||||
return web.json_response(record)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/update-meta")
|
||||
async def update_snapshot_meta(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
workflow_key = data.get("workflowKey")
|
||||
snapshot_id = data.get("id")
|
||||
fields = data.get("fields")
|
||||
if not workflow_key or not snapshot_id or not isinstance(fields, dict):
|
||||
return web.json_response({"error": "Missing workflowKey, id, or fields"}, status=400)
|
||||
ok = storage.update_meta(workflow_key, snapshot_id, fields)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Not found"}, status=404)
|
||||
return web.json_response({"ok": True})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/delete")
|
||||
async def delete_snapshot(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
workflow_key = data.get("workflowKey")
|
||||
snapshot_id = data.get("id")
|
||||
if not workflow_key or not snapshot_id:
|
||||
return web.json_response({"error": "Missing workflowKey or id"}, status=400)
|
||||
storage.delete(workflow_key, snapshot_id)
|
||||
return web.json_response({"ok": True})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/delete-all")
|
||||
async def delete_all_snapshots(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
workflow_key = data.get("workflowKey")
|
||||
if not workflow_key:
|
||||
return web.json_response({"error": "Missing workflowKey"}, status=400)
|
||||
result = storage.delete_all_for_workflow(workflow_key)
|
||||
return web.json_response(result)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.get("/snapshot-manager/workflows")
|
||||
async def list_workflows(request):
|
||||
try:
|
||||
keys = storage.get_all_workflow_keys()
|
||||
return web.json_response(keys)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.get("/snapshot-manager/usage")
|
||||
async def storage_usage(request):
|
||||
try:
|
||||
return web.json_response(storage.get_storage_usage())
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/export")
|
||||
async def export_workflow(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
workflow_key = data.get("workflowKey")
|
||||
if not workflow_key:
|
||||
return web.json_response({"error": "Missing workflowKey"}, status=400)
|
||||
records = storage.get_full_records_for_workflow(workflow_key)
|
||||
return web.json_response({"version": 1, "workflowKey": workflow_key, "records": records})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/prune")
|
||||
async def prune_snapshots(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
workflow_key = data.get("workflowKey")
|
||||
max_snapshots = data.get("maxSnapshots")
|
||||
source = data.get("source")
|
||||
protected_ids = data.get("protectedIds")
|
||||
max_age_days = data.get("maxAgeDays")
|
||||
if not workflow_key or max_snapshots is None:
|
||||
return web.json_response({"error": "Missing workflowKey or maxSnapshots"}, status=400)
|
||||
deleted = storage.prune(
|
||||
workflow_key, int(max_snapshots),
|
||||
source=source, protected_ids=protected_ids,
|
||||
max_age_days=int(max_age_days) if max_age_days else None,
|
||||
)
|
||||
return web.json_response({"deleted": deleted})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/snapshot-manager/migrate")
|
||||
async def migrate_snapshots(request):
|
||||
try:
|
||||
if _too_large(request):
|
||||
return web.json_response({"error": "Request too large"}, status=413)
|
||||
data = await request.json()
|
||||
records = data.get("records")
|
||||
if not isinstance(records, list):
|
||||
return web.json_response({"error": "Missing records array"}, status=400)
|
||||
if len(records) > _MAX_MIGRATE_RECORDS:
|
||||
return web.json_response({"error": "Too many records"}, status=413)
|
||||
imported = 0
|
||||
for record in records:
|
||||
if "id" in record and "workflowKey" in record:
|
||||
storage.put(record)
|
||||
imported += 1
|
||||
return web.json_response({"imported": imported})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, 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:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, 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:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, 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:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, 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:
|
||||
logging.exception("[Snapshot Manager] request handler error")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
Filesystem storage layer for workflow snapshots.
|
||||
|
||||
Stores each snapshot as an individual JSON file under:
|
||||
<user_dir>/snapshot_manager/snapshots/<encoded_workflow_key>/<id>.json
|
||||
|
||||
Workflow keys are percent-encoded for filesystem safety.
|
||||
|
||||
An in-memory metadata cache avoids redundant disk reads for list/prune/delete
|
||||
operations. Only get_full_record() reads a file from disk after warm-up.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
# ─── Data directory resolution ───────────────────────────────────────
|
||||
# Prefer ComfyUI's persistent user directory; fall back to extension-local
|
||||
# paths when running outside ComfyUI (e.g. tests).
|
||||
|
||||
_OLD_DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
_USER_SM_DIR = os.path.join(folder_paths.get_user_directory(), "snapshot_manager")
|
||||
except Exception:
|
||||
_USER_SM_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||
|
||||
_DATA_DIR = os.path.join(_USER_SM_DIR, "snapshots")
|
||||
|
||||
# ─── In-memory metadata cache ────────────────────────────────────────
|
||||
# Maps workflow_key -> list of metadata dicts (sorted by timestamp asc).
|
||||
# Metadata is everything *except* graphData.
|
||||
_cache = {}
|
||||
_cache_warmed = set() # workflow keys already loaded from disk
|
||||
|
||||
|
||||
def _extract_meta(record):
|
||||
"""Return a lightweight copy of *record* without graphData or thumbnail.
|
||||
|
||||
The (potentially large, base64) thumbnail is replaced by a boolean
|
||||
``hasThumbnail`` flag; clients lazy-load the image via get_full_record.
|
||||
"""
|
||||
meta = {k: v for k, v in record.items() if k not in ("graphData", "thumbnail")}
|
||||
if record.get("thumbnail"):
|
||||
meta["hasThumbnail"] = True
|
||||
return meta
|
||||
|
||||
|
||||
def _ensure_cached(workflow_key):
|
||||
"""Warm the cache for *workflow_key* if not already loaded. Return cached list."""
|
||||
if workflow_key not in _cache_warmed:
|
||||
d = _workflow_dir(workflow_key)
|
||||
entries = []
|
||||
if os.path.isdir(d):
|
||||
for fname in os.listdir(d):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
path = os.path.join(d, fname)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
entries.append(_extract_meta(json.load(f)))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
entries.sort(key=lambda r: r.get("timestamp", 0))
|
||||
_cache[workflow_key] = entries
|
||||
_cache_warmed.add(workflow_key)
|
||||
return _cache.get(workflow_key, [])
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _workflow_dir(workflow_key):
|
||||
if not workflow_key or not isinstance(workflow_key, str):
|
||||
raise ValueError(f"Invalid workflow key: {workflow_key!r}")
|
||||
encoded = urllib.parse.quote(workflow_key, safe="")
|
||||
path = os.path.normpath(os.path.join(_DATA_DIR, encoded))
|
||||
# Defense in depth: urllib.parse.quote() leaves "." and ".." unescaped, so a
|
||||
# key like ".." would escape the snapshots root (the "/" in "../.." *is*
|
||||
# escaped, so escapes are bounded to one level — but block it anyway).
|
||||
# Require the resolved directory to be a direct child of _DATA_DIR.
|
||||
if os.path.dirname(path) != os.path.normpath(_DATA_DIR):
|
||||
raise ValueError(f"Invalid workflow key: {workflow_key!r}")
|
||||
return path
|
||||
|
||||
|
||||
def _validate_id(snapshot_id):
|
||||
if not snapshot_id or "/" in snapshot_id or "\\" in snapshot_id or ".." in snapshot_id:
|
||||
raise ValueError(f"Invalid snapshot id: {snapshot_id!r}")
|
||||
|
||||
|
||||
def _atomic_write_json(path, obj):
|
||||
"""Write *obj* as JSON to *path* atomically (temp file + os.replace).
|
||||
|
||||
Prevents a crash or concurrent reader mid-write from observing a
|
||||
truncated/corrupt file (the old in-place open("w") truncated first).
|
||||
"""
|
||||
directory = os.path.dirname(path)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(obj, f, separators=(",", ":"))
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# ─── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
def put(record):
|
||||
"""Write one snapshot record to disk and update the cache."""
|
||||
snapshot_id = record["id"]
|
||||
workflow_key = record["workflowKey"]
|
||||
_validate_id(snapshot_id)
|
||||
d = _workflow_dir(workflow_key)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
path = os.path.join(d, f"{snapshot_id}.json")
|
||||
_atomic_write_json(path, record)
|
||||
|
||||
# Update cache only if already warmed; otherwise _ensure_cached will
|
||||
# pick up the new file from disk on next read.
|
||||
if workflow_key in _cache_warmed:
|
||||
meta = _extract_meta(record)
|
||||
cached = _cache[workflow_key]
|
||||
cached[:] = [e for e in cached if e.get("id") != snapshot_id]
|
||||
cached.append(meta)
|
||||
cached.sort(key=lambda r: r.get("timestamp", 0))
|
||||
|
||||
|
||||
def get_all_for_workflow(workflow_key):
|
||||
"""Return all snapshot metadata for a workflow (no graphData), sorted ascending by timestamp."""
|
||||
return [dict(e) for e in _ensure_cached(workflow_key)]
|
||||
|
||||
|
||||
def get_full_record(workflow_key, snapshot_id):
|
||||
"""Read a single snapshot file from disk (with graphData). Returns dict or None."""
|
||||
_validate_id(snapshot_id)
|
||||
path = os.path.join(_workflow_dir(workflow_key), f"{snapshot_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 update_meta(workflow_key, snapshot_id, fields):
|
||||
"""Merge *fields* into an existing snapshot on disk without touching graphData.
|
||||
|
||||
Returns True on success, False if the file does not exist.
|
||||
"""
|
||||
_validate_id(snapshot_id)
|
||||
path = os.path.join(_workflow_dir(workflow_key), f"{snapshot_id}.json")
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
record = json.load(f)
|
||||
# Merge fields; None values remove the key
|
||||
for k, v in fields.items():
|
||||
if v is None:
|
||||
record.pop(k, None)
|
||||
else:
|
||||
record[k] = v
|
||||
_atomic_write_json(path, record)
|
||||
# Update cache entry
|
||||
for entry in _cache.get(workflow_key, []):
|
||||
if entry.get("id") == snapshot_id:
|
||||
for k, v in fields.items():
|
||||
if k == "graphData":
|
||||
continue
|
||||
if v is None:
|
||||
entry.pop(k, None)
|
||||
else:
|
||||
entry[k] = v
|
||||
break
|
||||
return True
|
||||
|
||||
|
||||
def delete(workflow_key, snapshot_id):
|
||||
"""Remove one snapshot file and its cache entry. Cleans up empty workflow dir."""
|
||||
_validate_id(snapshot_id)
|
||||
d = _workflow_dir(workflow_key)
|
||||
path = os.path.join(d, f"{snapshot_id}.json")
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
|
||||
# Update cache
|
||||
if workflow_key in _cache:
|
||||
_cache[workflow_key] = [e for e in _cache[workflow_key] if e.get("id") != snapshot_id]
|
||||
if not _cache[workflow_key]:
|
||||
del _cache[workflow_key]
|
||||
_cache_warmed.discard(workflow_key)
|
||||
|
||||
# Clean up empty directory
|
||||
if os.path.isdir(d) and not os.listdir(d):
|
||||
os.rmdir(d)
|
||||
|
||||
|
||||
def delete_all_for_workflow(workflow_key):
|
||||
"""Delete all unlocked snapshots for a workflow. Returns {lockedCount}."""
|
||||
entries = _ensure_cached(workflow_key)
|
||||
locked = []
|
||||
locked_count = 0
|
||||
d = _workflow_dir(workflow_key)
|
||||
for rec in entries:
|
||||
if rec.get("locked"):
|
||||
locked_count += 1
|
||||
locked.append(rec)
|
||||
else:
|
||||
_validate_id(rec["id"])
|
||||
path = os.path.join(d, f"{rec['id']}.json")
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
|
||||
# Update cache to locked-only
|
||||
if locked:
|
||||
_cache[workflow_key] = locked
|
||||
else:
|
||||
_cache.pop(workflow_key, None)
|
||||
_cache_warmed.discard(workflow_key)
|
||||
|
||||
# Clean up empty directory
|
||||
if os.path.isdir(d) and not os.listdir(d):
|
||||
os.rmdir(d)
|
||||
return {"lockedCount": locked_count}
|
||||
|
||||
|
||||
def get_all_workflow_keys():
|
||||
"""Scan subdirs and return [{workflowKey, count}]."""
|
||||
if not os.path.isdir(_DATA_DIR):
|
||||
return []
|
||||
results = []
|
||||
for encoded_name in os.listdir(_DATA_DIR):
|
||||
subdir = os.path.join(_DATA_DIR, encoded_name)
|
||||
if not os.path.isdir(subdir):
|
||||
continue
|
||||
workflow_key = urllib.parse.unquote(encoded_name)
|
||||
try:
|
||||
entries = _ensure_cached(workflow_key)
|
||||
except ValueError:
|
||||
continue # skip stray/legacy dirs whose name is not a valid key
|
||||
if not entries:
|
||||
continue
|
||||
results.append({"workflowKey": workflow_key, "count": len(entries)})
|
||||
results.sort(key=lambda r: r["workflowKey"])
|
||||
return results
|
||||
|
||||
|
||||
def get_storage_usage():
|
||||
"""Return {totalBytes, workflows: [{workflowKey, bytes, count}]} for all snapshots."""
|
||||
workflows = []
|
||||
total = 0
|
||||
if os.path.isdir(_DATA_DIR):
|
||||
for encoded_name in os.listdir(_DATA_DIR):
|
||||
subdir = os.path.join(_DATA_DIR, encoded_name)
|
||||
if not os.path.isdir(subdir):
|
||||
continue
|
||||
size = 0
|
||||
count = 0
|
||||
for fname in os.listdir(subdir):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
size += os.path.getsize(os.path.join(subdir, fname))
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
if count == 0:
|
||||
continue
|
||||
total += size
|
||||
workflows.append({
|
||||
"workflowKey": urllib.parse.unquote(encoded_name),
|
||||
"bytes": size,
|
||||
"count": count,
|
||||
})
|
||||
workflows.sort(key=lambda w: w["bytes"], reverse=True)
|
||||
return {"totalBytes": total, "workflows": workflows}
|
||||
|
||||
|
||||
def get_full_records_for_workflow(workflow_key):
|
||||
"""Return all full snapshot records (with graphData) for a workflow, for export."""
|
||||
d = _workflow_dir(workflow_key)
|
||||
records = []
|
||||
if os.path.isdir(d):
|
||||
for fname in os.listdir(d):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(d, fname), "r", encoding="utf-8") as f:
|
||||
records.append(json.load(f))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
records.sort(key=lambda r: r.get("timestamp", 0))
|
||||
return records
|
||||
|
||||
|
||||
def prune(workflow_key, max_snapshots, source=None, protected_ids=None, max_age_days=None):
|
||||
"""Delete oldest unlocked snapshots beyond limit. Returns count deleted.
|
||||
|
||||
source filtering:
|
||||
- "node": only prune records where source == "node"
|
||||
- "regular": only prune records where source is absent or not "node"
|
||||
- None: prune all unlocked (existing behavior)
|
||||
|
||||
protected_ids: set/list of snapshot IDs that must not be pruned
|
||||
(e.g. ancestors of active branch tip, fork-point snapshots).
|
||||
|
||||
max_age_days: when > 0, also delete unlocked/unprotected snapshots older
|
||||
than this many days, regardless of the count limit.
|
||||
"""
|
||||
_protected = set(protected_ids) if protected_ids else set()
|
||||
entries = _ensure_cached(workflow_key)
|
||||
if source == "node":
|
||||
candidates = [r for r in entries if not r.get("locked") and r.get("source") == "node" and r.get("id") not in _protected]
|
||||
elif source == "regular":
|
||||
candidates = [r for r in entries if not r.get("locked") and r.get("source") != "node" and r.get("id") not in _protected]
|
||||
else:
|
||||
candidates = [r for r in entries if not r.get("locked") and r.get("id") not in _protected]
|
||||
delete_ids = set()
|
||||
to_delete = []
|
||||
# Oldest-beyond-count get deleted...
|
||||
if len(candidates) > max_snapshots:
|
||||
for rec in candidates[: len(candidates) - max_snapshots]:
|
||||
to_delete.append(rec)
|
||||
delete_ids.add(rec["id"])
|
||||
# ...as do any candidates older than the age cutoff (locked/protected
|
||||
# snapshots were already excluded from candidates above).
|
||||
if max_age_days and max_age_days > 0:
|
||||
cutoff = time.time() * 1000 - max_age_days * 86400000
|
||||
for rec in candidates:
|
||||
if rec["id"] not in delete_ids and rec.get("timestamp", 0) < cutoff:
|
||||
to_delete.append(rec)
|
||||
delete_ids.add(rec["id"])
|
||||
if not to_delete:
|
||||
return 0
|
||||
d = _workflow_dir(workflow_key)
|
||||
deleted = 0
|
||||
delete_ids = set()
|
||||
for rec in to_delete:
|
||||
_validate_id(rec["id"])
|
||||
path = os.path.join(d, f"{rec['id']}.json")
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
deleted += 1
|
||||
delete_ids.add(rec["id"])
|
||||
|
||||
# Update cache
|
||||
if delete_ids and workflow_key in _cache:
|
||||
_cache[workflow_key] = [e for e in _cache[workflow_key] if e.get("id") not in delete_ids]
|
||||
if not _cache[workflow_key]:
|
||||
del _cache[workflow_key]
|
||||
_cache_warmed.discard(workflow_key)
|
||||
|
||||
# Clean up empty directory
|
||||
if os.path.isdir(d) and not os.listdir(d):
|
||||
os.rmdir(d)
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
# ─── Profile Storage ─────────────────────────────────────────────────
|
||||
# Profiles are stored as individual JSON files under snapshot_manager/profiles/<id>.json
|
||||
|
||||
_PROFILES_DIR = os.path.join(_USER_SM_DIR, "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")
|
||||
_atomic_write_json(path, profile)
|
||||
_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
|
||||
_atomic_write_json(path, profile)
|
||||
_invalidate_profile_cache()
|
||||
return True
|
||||
|
||||
|
||||
# ─── Migration from old extension-local data ─────────────────────────
|
||||
|
||||
def _migrate_old_data():
|
||||
"""Move data from the old <extension>/data/ location to the new user directory.
|
||||
|
||||
Only runs when the old directory exists, has content, and the new location
|
||||
differs from the old one (i.e. we're actually inside ComfyUI).
|
||||
"""
|
||||
old_snapshots = os.path.join(_OLD_DATA_DIR, "snapshots")
|
||||
old_profiles = os.path.join(_OLD_DATA_DIR, "profiles")
|
||||
|
||||
# Nothing to migrate if old data dir doesn't exist or paths are the same
|
||||
if os.path.normpath(_OLD_DATA_DIR) == os.path.normpath(_USER_SM_DIR):
|
||||
return
|
||||
if not os.path.isdir(_OLD_DATA_DIR):
|
||||
return
|
||||
|
||||
migrated_anything = False
|
||||
|
||||
# Migrate snapshot workflow directories
|
||||
if os.path.isdir(old_snapshots):
|
||||
os.makedirs(_DATA_DIR, exist_ok=True)
|
||||
for name in os.listdir(old_snapshots):
|
||||
src = os.path.join(old_snapshots, name)
|
||||
dst = os.path.join(_DATA_DIR, name)
|
||||
if not os.path.isdir(src):
|
||||
continue
|
||||
if os.path.exists(dst):
|
||||
# Merge: move individual files that don't already exist
|
||||
for fname in os.listdir(src):
|
||||
s = os.path.join(src, fname)
|
||||
d = os.path.join(dst, fname)
|
||||
if not os.path.exists(d):
|
||||
shutil.move(s, d)
|
||||
# Remove source dir if now empty
|
||||
try:
|
||||
os.rmdir(src)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
shutil.move(src, dst)
|
||||
migrated_anything = True
|
||||
|
||||
# Migrate profile files
|
||||
if os.path.isdir(old_profiles):
|
||||
os.makedirs(_PROFILES_DIR, exist_ok=True)
|
||||
for fname in os.listdir(old_profiles):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
src = os.path.join(old_profiles, fname)
|
||||
dst = os.path.join(_PROFILES_DIR, fname)
|
||||
if not os.path.exists(dst):
|
||||
shutil.move(src, dst)
|
||||
else:
|
||||
os.remove(src)
|
||||
migrated_anything = True
|
||||
|
||||
if migrated_anything:
|
||||
# Clean up old directories if empty
|
||||
for d in (old_snapshots, old_profiles, _OLD_DATA_DIR):
|
||||
try:
|
||||
if os.path.isdir(d) and not os.listdir(d):
|
||||
os.rmdir(d)
|
||||
except OSError:
|
||||
pass
|
||||
print("[Snapshot Manager] Migrated data to", _USER_SM_DIR)
|
||||
|
||||
|
||||
try:
|
||||
_migrate_old_data()
|
||||
except Exception as e:
|
||||
print(f"[Snapshot Manager] Migration failed, old data preserved: {e}")
|
||||
Reference in New Issue
Block a user