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