Initial release: Workflow Snapshot Manager v1.0.0
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Auto-capture workflow snapshots with per-workflow hash map, promise-based restore lock, custom naming, search/filter, theme-aware CSS, toast notifications, and native confirm/prompt dialogs. Includes README with SVG/PNG assets, MIT license, and ComfyUI registry publish action. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21
.github/workflows/publish.yml
vendored
Normal file
21
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: Publish to ComfyUI Registry
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "pyproject.toml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-node:
|
||||||
|
name: Publish Custom Node to Registry
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'ethanfel/Comfyui-Workflow-Snapshot-Manager'
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Publish Custom Node
|
||||||
|
uses: Comfy-Org/publish-node-action@main
|
||||||
|
with:
|
||||||
|
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 ethanfel
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="assets/banner.png" alt="Workflow Snapshot Manager" width="100%"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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/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.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/sidebar-preview.png" alt="Sidebar Preview" width="300"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- **Restore or Swap** — Open a snapshot as a new workflow, or replace the current one in-place
|
||||||
|
- **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
|
||||||
|
- **Concurrency-safe** — Lock guard prevents double-click issues during restore
|
||||||
|
- **Zero backend** — Pure frontend extension, no server dependencies
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### ComfyUI Manager (Recommended)
|
||||||
|
|
||||||
|
Search for **Workflow Snapshot Manager** in [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) and click Install.
|
||||||
|
|
||||||
|
### Git Clone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ComfyUI/custom_nodes
|
||||||
|
git clone https://github.com/ethanfel/Comfyui-Workflow-Snapshot-Manager.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart ComfyUI after installing.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Open the Sidebar
|
||||||
|
|
||||||
|
Click the **clock icon** (<img src="https://img.shields.io/badge/-pi pi--history-333?style=flat" alt="history icon"/>) in the ComfyUI sidebar to open the Snapshots panel.
|
||||||
|
|
||||||
|
### 2. Snapshots are Captured Automatically
|
||||||
|
|
||||||
|
As you edit your workflow, snapshots are saved automatically after a configurable delay (default: 3 seconds). An initial snapshot is also captured when the workflow loads.
|
||||||
|
|
||||||
|
### 3. Take a Named Snapshot
|
||||||
|
|
||||||
|
Click **Take Snapshot** to manually save the current state. A prompt lets you enter a custom name — great for checkpoints like "Before refactor" or "Working config".
|
||||||
|
|
||||||
|
### 4. Search & Filter
|
||||||
|
|
||||||
|
Use the filter bar at the top of the panel to search snapshots by name. The clear button (**×**) resets the filter.
|
||||||
|
|
||||||
|
### 5. Restore or Swap
|
||||||
|
|
||||||
|
Each snapshot has two action buttons:
|
||||||
|
|
||||||
|
| Button | Action |
|
||||||
|
|--------|--------|
|
||||||
|
| **Swap** | Replaces the current workflow in-place (same tab) |
|
||||||
|
| **Restore** | Opens the snapshot as a new workflow |
|
||||||
|
|
||||||
|
### 6. Delete & Clear
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
All settings are available in **ComfyUI Settings > Snapshot Manager > Capture Settings**:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| **Capture on workflow load** | Toggle | `On` | Save an "Initial" snapshot when a workflow is first loaded |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/architecture.png" alt="Architecture Diagram" width="100%"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Data 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
|
||||||
|
|
||||||
|
**Storage:** All data stays in your browser's IndexedDB — nothing is sent to any server. Snapshots persist across browser sessions and ComfyUI restarts.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Can I use this with ComfyUI Manager?**
|
||||||
|
Yes — install via ComfyUI Manager or clone the repo into `custom_nodes/`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
10
__init__.py
Normal file
10
__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
ComfyUI Snapshot Manager
|
||||||
|
|
||||||
|
Automatically snapshots workflow state as you edit, with a sidebar panel
|
||||||
|
to browse and restore any previous version. Stored in IndexedDB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
WEB_DIRECTORY = "./js"
|
||||||
|
NODE_CLASS_MAPPINGS = {}
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||||
BIN
assets/architecture.png
Normal file
BIN
assets/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
93
assets/architecture.svg
Normal file
93
assets/architecture.svg
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420">
|
||||||
|
<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>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/banner.png
Normal file
BIN
assets/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
32
assets/banner.svg
Normal file
32
assets/banner.svg
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 200">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#1a1a2e"/>
|
||||||
|
<stop offset="100%" style="stop-color:#16213e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#3b82f6"/>
|
||||||
|
<stop offset="100%" style="stop-color:#8b5cf6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="840" height="200" rx="12" fill="url(#bg)"/>
|
||||||
|
<rect x="0" y="192" width="840" height="8" rx="0 0 12 12" fill="url(#accent)"/>
|
||||||
|
|
||||||
|
<!-- Clock/History icon -->
|
||||||
|
<circle cx="100" cy="100" r="40" fill="none" stroke="#3b82f6" stroke-width="3"/>
|
||||||
|
<line x1="100" y1="70" x2="100" y2="100" stroke="#3b82f6" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<line x1="100" y1="100" x2="120" y2="110" stroke="#3b82f6" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M 65 72 A 40 40 0 0 0 60 100" fill="none" stroke="#8b5cf6" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<polygon points="58,68 68,76 64,64" fill="#8b5cf6"/>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<text x="170" y="85" font-family="system-ui, -apple-system, sans-serif" font-size="32" font-weight="700" fill="#e2e8f0">Workflow Snapshot Manager</text>
|
||||||
|
<text x="170" y="120" font-family="system-ui, -apple-system, sans-serif" font-size="16" fill="#94a3b8">Auto-capture, browse, name, and restore workflow snapshots</text>
|
||||||
|
<text x="170" y="150" font-family="system-ui, -apple-system, sans-serif" font-size="13" fill="#64748b">ComfyUI Extension</text>
|
||||||
|
|
||||||
|
<!-- Decorative dots -->
|
||||||
|
<circle cx="750" cy="50" r="3" fill="#3b82f6" opacity="0.4"/>
|
||||||
|
<circle cx="770" cy="70" r="2" fill="#8b5cf6" opacity="0.3"/>
|
||||||
|
<circle cx="790" cy="45" r="2.5" fill="#22c55e" opacity="0.3"/>
|
||||||
|
<circle cx="760" cy="90" r="2" fill="#f59e0b" opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/sidebar-preview.png
Normal file
BIN
assets/sidebar-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
94
assets/sidebar-preview.svg
Normal file
94
assets/sidebar-preview.svg
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 520">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sbg" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#1e1e1e"/>
|
||||||
|
<stop offset="100%" style="stop-color:#252525"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Panel background -->
|
||||||
|
<rect width="300" height="520" rx="10" fill="url(#sbg)" stroke="#333" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<rect x="0" y="0" width="300" height="50" rx="10 10 0 0" fill="#1a1a1a"/>
|
||||||
|
<rect x="12" y="12" width="120" height="28" rx="5" fill="#3b82f6"/>
|
||||||
|
<text x="72" y="31" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#fff">Take Snapshot</text>
|
||||||
|
<text x="265" y="31" text-anchor="end" font-family="system-ui, sans-serif" font-size="11" fill="#888">5 / 50</text>
|
||||||
|
|
||||||
|
<!-- Separator -->
|
||||||
|
<line x1="0" y1="50" x2="300" y2="50" stroke="#444"/>
|
||||||
|
|
||||||
|
<!-- Search bar -->
|
||||||
|
<rect x="12" y="58" width="252" height="30" rx="5" fill="#2a2a2a" stroke="#444" stroke-width="1"/>
|
||||||
|
<text x="24" y="78" font-family="system-ui, sans-serif" font-size="12" fill="#666">Filter snapshots...</text>
|
||||||
|
<text x="252" y="78" font-family="system-ui, sans-serif" font-size="13" fill="#666"></text>
|
||||||
|
|
||||||
|
<!-- Separator -->
|
||||||
|
<line x1="0" y1="96" x2="300" y2="96" stroke="#444"/>
|
||||||
|
|
||||||
|
<!-- Snapshot item 1 -->
|
||||||
|
<rect x="0" y="97" width="300" height="80" fill="#1e1e1e"/>
|
||||||
|
<text x="12" y="118" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#ddd">Checkpoint before merge</text>
|
||||||
|
<text x="12" y="136" font-family="system-ui, sans-serif" font-size="12" fill="#ddd">2:34:15 PM</text>
|
||||||
|
<text x="12" y="150" font-family="system-ui, sans-serif" font-size="10" fill="#777">Jan 5, 2026</text>
|
||||||
|
<text x="12" y="164" font-family="system-ui, sans-serif" font-size="10" fill="#666">42 nodes</text>
|
||||||
|
|
||||||
|
<!-- Action buttons row 1 -->
|
||||||
|
<rect x="180" y="112" width="42" height="22" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="201" y="127" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Swap</text>
|
||||||
|
<rect x="226" y="112" width="52" height="22" rx="3" fill="#22c55e"/>
|
||||||
|
<text x="252" y="127" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Restore</text>
|
||||||
|
<rect x="282" y="112" width="5" height="22" rx="2" fill="#444"/>
|
||||||
|
|
||||||
|
<line x1="0" y1="177" x2="300" y2="177" stroke="#333"/>
|
||||||
|
|
||||||
|
<!-- Snapshot item 2 -->
|
||||||
|
<rect x="0" y="178" width="300" height="80" fill="#1e1e1e"/>
|
||||||
|
<text x="12" y="199" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#ddd">Auto</text>
|
||||||
|
<text x="12" y="217" font-family="system-ui, sans-serif" font-size="12" fill="#ddd">2:31:02 PM</text>
|
||||||
|
<text x="12" y="231" font-family="system-ui, sans-serif" font-size="10" fill="#777">Jan 5, 2026</text>
|
||||||
|
<text x="12" y="245" font-family="system-ui, sans-serif" font-size="10" fill="#666">40 nodes</text>
|
||||||
|
|
||||||
|
<rect x="180" y="193" width="42" height="22" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="201" y="208" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Swap</text>
|
||||||
|
<rect x="226" y="193" width="52" height="22" rx="3" fill="#22c55e"/>
|
||||||
|
<text x="252" y="208" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Restore</text>
|
||||||
|
|
||||||
|
<line x1="0" y1="258" x2="300" y2="258" stroke="#333"/>
|
||||||
|
|
||||||
|
<!-- Snapshot item 3 -->
|
||||||
|
<rect x="0" y="259" width="300" height="80" fill="#1e1e1e"/>
|
||||||
|
<text x="12" y="280" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#ddd">Before KSampler change</text>
|
||||||
|
<text x="12" y="298" font-family="system-ui, sans-serif" font-size="12" fill="#ddd">2:28:44 PM</text>
|
||||||
|
<text x="12" y="312" font-family="system-ui, sans-serif" font-size="10" fill="#777">Jan 5, 2026</text>
|
||||||
|
<text x="12" y="326" font-family="system-ui, sans-serif" font-size="10" fill="#666">38 nodes</text>
|
||||||
|
|
||||||
|
<rect x="180" y="274" width="42" height="22" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="201" y="289" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Swap</text>
|
||||||
|
<rect x="226" y="274" width="52" height="22" rx="3" fill="#22c55e"/>
|
||||||
|
<text x="252" y="289" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Restore</text>
|
||||||
|
|
||||||
|
<line x1="0" y1="339" x2="300" y2="339" stroke="#333"/>
|
||||||
|
|
||||||
|
<!-- Snapshot item 4 -->
|
||||||
|
<rect x="0" y="340" width="300" height="80" fill="#1e1e1e"/>
|
||||||
|
<text x="12" y="361" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#ddd">Initial</text>
|
||||||
|
<text x="12" y="379" font-family="system-ui, sans-serif" font-size="12" fill="#ddd">2:15:30 PM</text>
|
||||||
|
<text x="12" y="393" font-family="system-ui, sans-serif" font-size="10" fill="#777">Jan 5, 2026</text>
|
||||||
|
<text x="12" y="407" font-family="system-ui, sans-serif" font-size="10" fill="#666">35 nodes</text>
|
||||||
|
|
||||||
|
<rect x="180" y="355" width="42" height="22" rx="3" fill="#f59e0b"/>
|
||||||
|
<text x="201" y="370" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Swap</text>
|
||||||
|
<rect x="226" y="355" width="52" height="22" rx="3" fill="#22c55e"/>
|
||||||
|
<text x="252" y="370" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Restore</text>
|
||||||
|
|
||||||
|
<line x1="0" y1="420" x2="300" y2="420" stroke="#333"/>
|
||||||
|
|
||||||
|
<!-- Empty space -->
|
||||||
|
<rect x="0" y="421" width="300" height="57" fill="transparent"/>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<line x1="0" y1="478" x2="300" y2="478" stroke="#444"/>
|
||||||
|
<rect x="12" y="486" width="276" height="26" rx="5" fill="#555"/>
|
||||||
|
<text x="150" y="504" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#ccc">Clear All Snapshots</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
847
js/snapshot_manager.js
Normal file
847
js/snapshot_manager.js
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
/**
|
||||||
|
* ComfyUI Snapshot Manager
|
||||||
|
*
|
||||||
|
* Automatically captures workflow snapshots as you edit, stores them in
|
||||||
|
* IndexedDB, and provides a sidebar panel to browse and restore any
|
||||||
|
* previous version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
const EXTENSION_NAME = "ComfyUI.SnapshotManager";
|
||||||
|
const DB_NAME = "ComfySnapshotManager";
|
||||||
|
const STORE_NAME = "snapshots";
|
||||||
|
const RESTORE_GUARD_MS = 500;
|
||||||
|
const INITIAL_CAPTURE_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
// ─── Configurable Settings (updated via ComfyUI settings UI) ────────
|
||||||
|
|
||||||
|
let maxSnapshots = 50;
|
||||||
|
let debounceMs = 3000;
|
||||||
|
let autoCaptureEnabled = true;
|
||||||
|
let captureOnLoad = true;
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const lastCapturedHashMap = new Map();
|
||||||
|
let restoreLock = null;
|
||||||
|
let captureTimer = null;
|
||||||
|
let sidebarRefresh = null; // callback set by sidebar render
|
||||||
|
|
||||||
|
// ─── IndexedDB Layer ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let dbPromise = null;
|
||||||
|
|
||||||
|
function openDB() {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||||
|
store.createIndex("workflowKey", "workflowKey", { unique: false });
|
||||||
|
store.createIndex("timestamp", "timestamp", { unique: false });
|
||||||
|
store.createIndex("workflowKey_timestamp", ["workflowKey", "timestamp"], { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const db = req.result;
|
||||||
|
db.onclose = () => { dbPromise = null; };
|
||||||
|
db.onversionchange = () => { db.close(); dbPromise = null; };
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
req.onerror = () => {
|
||||||
|
dbPromise = null;
|
||||||
|
reject(req.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function db_put(record) {
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||||
|
tx.objectStore(STORE_NAME).put(record);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] IndexedDB write failed:`, err);
|
||||||
|
showToast("Failed to save snapshot", "error");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function db_getAllForWorkflow(workflowKey) {
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readonly");
|
||||||
|
const idx = tx.objectStore(STORE_NAME).index("workflowKey_timestamp");
|
||||||
|
const range = IDBKeyRange.bound([workflowKey, 0], [workflowKey, Infinity]);
|
||||||
|
const req = idx.getAll(range);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] IndexedDB read failed:`, err);
|
||||||
|
showToast("Failed to read snapshots", "error");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function db_delete(id) {
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||||
|
tx.objectStore(STORE_NAME).delete(id);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] IndexedDB delete failed:`, err);
|
||||||
|
showToast("Failed to delete snapshot", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function db_deleteAllForWorkflow(workflowKey) {
|
||||||
|
try {
|
||||||
|
const records = await db_getAllForWorkflow(workflowKey);
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
for (const r of records) {
|
||||||
|
store.delete(r.id);
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] IndexedDB bulk delete failed:`, err);
|
||||||
|
showToast("Failed to clear snapshots", "error");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneSnapshots(workflowKey) {
|
||||||
|
try {
|
||||||
|
const all = await db_getAllForWorkflow(workflowKey);
|
||||||
|
if (all.length <= maxSnapshots) return;
|
||||||
|
// sorted ascending by timestamp (index order), oldest first
|
||||||
|
const toDelete = all.slice(0, all.length - maxSnapshots);
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
for (const r of toDelete) {
|
||||||
|
store.delete(r.id);
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] IndexedDB prune failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function quickHash(str) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkflowKey() {
|
||||||
|
try {
|
||||||
|
const wf = app.workflowManager?.activeWorkflow;
|
||||||
|
return wf?.name || wf?.path || "default";
|
||||||
|
} catch {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGraphData() {
|
||||||
|
try {
|
||||||
|
return app.graph.serialize();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSnapshotData(graphData) {
|
||||||
|
return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Restore Lock ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function withRestoreLock(fn) {
|
||||||
|
if (restoreLock) return;
|
||||||
|
let resolve;
|
||||||
|
restoreLock = new Promise((r) => { resolve = r; });
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
restoreLock = null;
|
||||||
|
resolve();
|
||||||
|
if (sidebarRefresh) {
|
||||||
|
sidebarRefresh().catch(() => {});
|
||||||
|
}
|
||||||
|
}, RESTORE_GUARD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UI Utilities ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showToast(message, severity = "info") {
|
||||||
|
try {
|
||||||
|
app.extensionManager.toast.add({
|
||||||
|
severity,
|
||||||
|
summary: "Snapshot Manager",
|
||||||
|
detail: message,
|
||||||
|
life: 2500,
|
||||||
|
});
|
||||||
|
} catch { /* silent fallback */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showConfirmDialog(message) {
|
||||||
|
try {
|
||||||
|
return await app.extensionManager.dialog.confirm({
|
||||||
|
title: "Snapshot Manager",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return window.confirm(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showPromptDialog(message, defaultValue = "Manual") {
|
||||||
|
try {
|
||||||
|
const result = await app.extensionManager.dialog.prompt({
|
||||||
|
title: "Snapshot Name",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return window.prompt(message, defaultValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Snapshot Capture ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function captureSnapshot(label = "Auto") {
|
||||||
|
if (restoreLock) return false;
|
||||||
|
|
||||||
|
const graphData = getGraphData();
|
||||||
|
if (!graphData) return false;
|
||||||
|
|
||||||
|
const nodes = graphData.nodes || [];
|
||||||
|
if (nodes.length === 0) return false;
|
||||||
|
|
||||||
|
const workflowKey = getWorkflowKey();
|
||||||
|
const serialized = JSON.stringify(graphData);
|
||||||
|
const hash = quickHash(serialized);
|
||||||
|
if (hash === lastCapturedHashMap.get(workflowKey)) return false;
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
id: generateId(),
|
||||||
|
workflowKey,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
label,
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
graphData,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db_put(record);
|
||||||
|
await pruneSnapshots(workflowKey);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCapturedHashMap.set(workflowKey, hash);
|
||||||
|
|
||||||
|
if (sidebarRefresh) {
|
||||||
|
sidebarRefresh().catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCaptureSnapshot() {
|
||||||
|
if (!autoCaptureEnabled) return;
|
||||||
|
if (restoreLock) return;
|
||||||
|
if (captureTimer) clearTimeout(captureTimer);
|
||||||
|
captureTimer = setTimeout(() => {
|
||||||
|
captureTimer = null;
|
||||||
|
captureSnapshot("Auto").catch((err) => {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] Auto-capture failed:`, err);
|
||||||
|
});
|
||||||
|
}, debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Restore ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function restoreSnapshot(record) {
|
||||||
|
await withRestoreLock(async () => {
|
||||||
|
if (!validateSnapshotData(record.graphData)) {
|
||||||
|
showToast("Invalid snapshot data", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await app.loadGraphData(record.graphData, true, true);
|
||||||
|
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
|
||||||
|
showToast("Snapshot restored", "success");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] Restore failed:`, err);
|
||||||
|
showToast("Failed to restore snapshot", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swapSnapshot(record) {
|
||||||
|
await withRestoreLock(async () => {
|
||||||
|
if (!validateSnapshotData(record.graphData)) {
|
||||||
|
showToast("Invalid snapshot data", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const workflow = app.workflowManager?.activeWorkflow;
|
||||||
|
await app.loadGraphData(record.graphData, true, true, workflow);
|
||||||
|
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
|
||||||
|
showToast("Snapshot swapped", "success");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] Swap failed:`, err);
|
||||||
|
showToast("Failed to swap snapshot", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sidebar UI ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.snap-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--input-text, #ccc);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.snap-header {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #444);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.snap-header button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.snap-header button:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
.snap-header button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.snap-header .snap-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--descrip-text, #888);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.snap-search {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #444);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.snap-search input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--comfy-menu-bg, #2a2a2a);
|
||||||
|
color: var(--input-text, #ccc);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.snap-search input::placeholder {
|
||||||
|
color: var(--descrip-text, #888);
|
||||||
|
}
|
||||||
|
.snap-search-clear {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--descrip-text, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
line-height: 1;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.snap-search-clear.visible {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.snap-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.snap-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.snap-item:hover {
|
||||||
|
background: var(--comfy-menu-bg, #2a2a2a);
|
||||||
|
}
|
||||||
|
.snap-item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.snap-item-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--input-text, #ddd);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.snap-item-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--input-text, #ddd);
|
||||||
|
}
|
||||||
|
.snap-item-date {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--descrip-text, #777);
|
||||||
|
}
|
||||||
|
.snap-item-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--descrip-text, #666);
|
||||||
|
}
|
||||||
|
.snap-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.snap-item-actions button {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.snap-item-actions button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.snap-btn-swap {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.snap-btn-swap:hover:not(:disabled) {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
.snap-btn-restore {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.snap-btn-restore:hover:not(:disabled) {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
.snap-btn-delete {
|
||||||
|
background: var(--comfy-menu-bg, #444);
|
||||||
|
color: var(--descrip-text, #aaa);
|
||||||
|
}
|
||||||
|
.snap-btn-delete:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.snap-footer {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid var(--border-color, #444);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.snap-footer button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--comfy-menu-bg, #555);
|
||||||
|
color: var(--input-text, #ccc);
|
||||||
|
}
|
||||||
|
.snap-footer button:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.snap-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--descrip-text, #666);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById("snapshot-manager-styles")) return;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "snapshot-manager-styles";
|
||||||
|
style.textContent = CSS;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSidebar(el) {
|
||||||
|
injectStyles();
|
||||||
|
el.innerHTML = "";
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "snap-sidebar";
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "snap-header";
|
||||||
|
|
||||||
|
const takeBtn = document.createElement("button");
|
||||||
|
takeBtn.textContent = "Take Snapshot";
|
||||||
|
takeBtn.addEventListener("click", async () => {
|
||||||
|
let name = await showPromptDialog("Enter a name for this snapshot:", "Manual");
|
||||||
|
if (name == null) return; // cancelled (null or undefined)
|
||||||
|
name = name.trim() || "Manual";
|
||||||
|
takeBtn.disabled = true;
|
||||||
|
takeBtn.textContent = "Saving...";
|
||||||
|
try {
|
||||||
|
const saved = await captureSnapshot(name);
|
||||||
|
if (saved) showToast("Snapshot saved", "success");
|
||||||
|
} finally {
|
||||||
|
takeBtn.disabled = false;
|
||||||
|
takeBtn.textContent = "Take Snapshot";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const countSpan = document.createElement("span");
|
||||||
|
countSpan.className = "snap-count";
|
||||||
|
|
||||||
|
header.appendChild(takeBtn);
|
||||||
|
header.appendChild(countSpan);
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const searchRow = document.createElement("div");
|
||||||
|
searchRow.className = "snap-search";
|
||||||
|
|
||||||
|
const searchInput = document.createElement("input");
|
||||||
|
searchInput.type = "text";
|
||||||
|
searchInput.placeholder = "Filter snapshots...";
|
||||||
|
|
||||||
|
const searchClear = document.createElement("button");
|
||||||
|
searchClear.className = "snap-search-clear";
|
||||||
|
searchClear.textContent = "\u2715";
|
||||||
|
searchClear.addEventListener("click", () => {
|
||||||
|
searchInput.value = "";
|
||||||
|
searchClear.classList.remove("visible");
|
||||||
|
filterItems("");
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", () => {
|
||||||
|
const term = searchInput.value;
|
||||||
|
searchClear.classList.toggle("visible", term.length > 0);
|
||||||
|
filterItems(term.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
searchRow.appendChild(searchInput);
|
||||||
|
searchRow.appendChild(searchClear);
|
||||||
|
|
||||||
|
// List
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "snap-list";
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.className = "snap-footer";
|
||||||
|
|
||||||
|
const clearBtn = document.createElement("button");
|
||||||
|
clearBtn.textContent = "Clear All Snapshots";
|
||||||
|
clearBtn.addEventListener("click", async () => {
|
||||||
|
const confirmed = await showConfirmDialog("Delete all snapshots for this workflow?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
await db_deleteAllForWorkflow(getWorkflowKey());
|
||||||
|
showToast("All snapshots cleared", "info");
|
||||||
|
} catch {
|
||||||
|
// db_deleteAllForWorkflow already toasts on error
|
||||||
|
}
|
||||||
|
await refresh(true);
|
||||||
|
});
|
||||||
|
footer.appendChild(clearBtn);
|
||||||
|
|
||||||
|
container.appendChild(header);
|
||||||
|
container.appendChild(searchRow);
|
||||||
|
container.appendChild(list);
|
||||||
|
container.appendChild(footer);
|
||||||
|
el.appendChild(container);
|
||||||
|
|
||||||
|
// Track items for filtering
|
||||||
|
let itemEntries = [];
|
||||||
|
|
||||||
|
function filterItems(term) {
|
||||||
|
for (const entry of itemEntries) {
|
||||||
|
const match = !term || entry.label.toLowerCase().includes(term);
|
||||||
|
entry.element.style.display = match ? "" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActionButtonsDisabled(disabled) {
|
||||||
|
const buttons = list.querySelectorAll(".snap-btn-swap, .snap-btn-restore, .snap-btn-delete");
|
||||||
|
for (const btn of buttons) {
|
||||||
|
btn.disabled = disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(resetSearch = false) {
|
||||||
|
const workflowKey = getWorkflowKey();
|
||||||
|
const records = await db_getAllForWorkflow(workflowKey);
|
||||||
|
// newest first
|
||||||
|
records.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
|
||||||
|
countSpan.textContent = `${records.length} / ${maxSnapshots}`;
|
||||||
|
|
||||||
|
list.innerHTML = "";
|
||||||
|
itemEntries = [];
|
||||||
|
|
||||||
|
if (resetSearch) {
|
||||||
|
searchInput.value = "";
|
||||||
|
searchClear.classList.remove("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "snap-empty";
|
||||||
|
empty.textContent = "No snapshots yet. Edit the workflow or click 'Take Snapshot'.";
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rec of records) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "snap-item";
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "snap-item-info";
|
||||||
|
|
||||||
|
const labelDiv = document.createElement("div");
|
||||||
|
labelDiv.className = "snap-item-label";
|
||||||
|
labelDiv.textContent = rec.label;
|
||||||
|
|
||||||
|
const time = document.createElement("div");
|
||||||
|
time.className = "snap-item-time";
|
||||||
|
time.textContent = formatTime(rec.timestamp);
|
||||||
|
|
||||||
|
const date = document.createElement("div");
|
||||||
|
date.className = "snap-item-date";
|
||||||
|
date.textContent = formatDate(rec.timestamp);
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "snap-item-meta";
|
||||||
|
meta.textContent = `${rec.nodeCount} nodes`;
|
||||||
|
|
||||||
|
info.appendChild(labelDiv);
|
||||||
|
info.appendChild(time);
|
||||||
|
info.appendChild(date);
|
||||||
|
info.appendChild(meta);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "snap-item-actions";
|
||||||
|
|
||||||
|
const swapBtn = document.createElement("button");
|
||||||
|
swapBtn.className = "snap-btn-swap";
|
||||||
|
swapBtn.textContent = "Swap";
|
||||||
|
swapBtn.title = "Replace current workflow in-place";
|
||||||
|
swapBtn.addEventListener("click", async () => {
|
||||||
|
setActionButtonsDisabled(true);
|
||||||
|
await swapSnapshot(rec);
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoreBtn = document.createElement("button");
|
||||||
|
restoreBtn.className = "snap-btn-restore";
|
||||||
|
restoreBtn.textContent = "Restore";
|
||||||
|
restoreBtn.title = "Open as new workflow";
|
||||||
|
restoreBtn.addEventListener("click", async () => {
|
||||||
|
setActionButtonsDisabled(true);
|
||||||
|
await restoreSnapshot(rec);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "snap-btn-delete";
|
||||||
|
deleteBtn.textContent = "\u2715";
|
||||||
|
deleteBtn.title = "Delete this snapshot";
|
||||||
|
deleteBtn.addEventListener("click", async () => {
|
||||||
|
await db_delete(rec.id);
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.appendChild(swapBtn);
|
||||||
|
actions.appendChild(restoreBtn);
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
item.appendChild(info);
|
||||||
|
item.appendChild(actions);
|
||||||
|
list.appendChild(item);
|
||||||
|
|
||||||
|
itemEntries.push({ element: item, label: rec.label });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply current search filter to newly built items
|
||||||
|
const currentTerm = searchInput.value.toLowerCase();
|
||||||
|
if (currentTerm) {
|
||||||
|
filterItems(currentTerm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarRefresh = refresh;
|
||||||
|
await refresh(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Extension Registration ──────────────────────────────────────────
|
||||||
|
|
||||||
|
if (window.__COMFYUI_FRONTEND_VERSION__) {
|
||||||
|
app.registerExtension({
|
||||||
|
name: EXTENSION_NAME,
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: "SnapshotManager.autoCapture",
|
||||||
|
name: "Auto-capture on edit",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: true,
|
||||||
|
category: ["Snapshot Manager", "Capture Settings", "Auto-capture on edit"],
|
||||||
|
onChange(value) {
|
||||||
|
autoCaptureEnabled = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SnapshotManager.debounceSeconds",
|
||||||
|
name: "Capture delay (seconds)",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: 3,
|
||||||
|
attrs: { min: 1, max: 30, step: 1 },
|
||||||
|
category: ["Snapshot Manager", "Capture Settings", "Capture delay (seconds)"],
|
||||||
|
onChange(value) {
|
||||||
|
debounceMs = value * 1000;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SnapshotManager.maxSnapshots",
|
||||||
|
name: "Max snapshots per workflow",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: 50,
|
||||||
|
attrs: { min: 5, max: 200, step: 5 },
|
||||||
|
category: ["Snapshot Manager", "Capture Settings", "Max snapshots per workflow"],
|
||||||
|
onChange(value) {
|
||||||
|
maxSnapshots = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SnapshotManager.captureOnLoad",
|
||||||
|
name: "Capture on workflow load",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: true,
|
||||||
|
category: ["Snapshot Manager", "Capture Settings", "Capture on workflow load"],
|
||||||
|
onChange(value) {
|
||||||
|
captureOnLoad = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
app.extensionManager.registerSidebarTab({
|
||||||
|
id: "snapshot-manager",
|
||||||
|
icon: "pi pi-history",
|
||||||
|
title: "Snapshots",
|
||||||
|
tooltip: "Browse and restore workflow snapshots",
|
||||||
|
type: "custom",
|
||||||
|
render: async (el) => {
|
||||||
|
await buildSidebar(el);
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
sidebarRefresh = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async setup() {
|
||||||
|
// Listen for graph changes (dispatched by ChangeTracker via api)
|
||||||
|
api.addEventListener("graphChanged", () => {
|
||||||
|
scheduleCaptureSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for workflow switches
|
||||||
|
if (app.workflowManager) {
|
||||||
|
app.workflowManager.addEventListener("changeWorkflow", () => {
|
||||||
|
// Cancel any pending capture from the previous workflow
|
||||||
|
if (captureTimer) {
|
||||||
|
clearTimeout(captureTimer);
|
||||||
|
captureTimer = null;
|
||||||
|
}
|
||||||
|
if (sidebarRefresh) {
|
||||||
|
sidebarRefresh(true).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture initial state after a short delay (decoupled from debounceMs)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!captureOnLoad) return;
|
||||||
|
captureSnapshot("Initial").catch((err) => {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] Initial capture failed:`, err);
|
||||||
|
});
|
||||||
|
}, INITIAL_CAPTURE_DELAY_MS);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Legacy frontend: register without sidebar
|
||||||
|
app.registerExtension({
|
||||||
|
name: EXTENSION_NAME,
|
||||||
|
async setup() {
|
||||||
|
console.log(`[${EXTENSION_NAME}] Sidebar requires modern ComfyUI frontend, skipping.`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
13
pyproject.toml
Normal file
13
pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[project]
|
||||||
|
name = "comfyui-snapshot-manager"
|
||||||
|
description = "Automatically snapshots workflow state with a sidebar to browse and restore previous versions."
|
||||||
|
version = "1.0.0"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/ethanfel/Comfyui-Workflow-Snapshot-Manager"
|
||||||
|
|
||||||
|
[tool.comfy]
|
||||||
|
PublisherId = "ethanfel"
|
||||||
|
DisplayName = "Workflow Snapshot Manager"
|
||||||
|
Icon = ""
|
||||||
Reference in New Issue
Block a user