The footer "Clear All Snapshots" button deleted records from the server but left stale in-memory state (lastCapturedIdMap, activeSnapshotId, etc.) intact. New captures then referenced deleted parents, creating isolated branches. Reset all branching state on both clear-all paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4060 lines
143 KiB
JavaScript
4060 lines
143 KiB
JavaScript
/**
|
|
* ComfyUI Snapshot Manager
|
|
*
|
|
* Automatically captures workflow snapshots as you edit, stores them on the
|
|
* server as JSON files, 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 RESTORE_GUARD_MS = 500;
|
|
const INITIAL_CAPTURE_DELAY_MS = 1500;
|
|
const MIGRATE_BATCH_SIZE = 10;
|
|
const OLD_DB_NAME = "ComfySnapshotManager";
|
|
const OLD_STORE_NAME = "snapshots";
|
|
|
|
// ─── Configurable Settings (updated via ComfyUI settings UI) ────────
|
|
|
|
let maxSnapshots = 50;
|
|
let debounceMs = 3000;
|
|
let autoCaptureEnabled = true;
|
|
let captureOnLoad = true;
|
|
let maxNodeSnapshots = 5;
|
|
let showTimeline = false;
|
|
|
|
// ─── State ───────────────────────────────────────────────────────────
|
|
|
|
const lastCapturedHashMap = new Map();
|
|
const lastGraphDataMap = new Map(); // workflowKey -> previous graphData for change-type detection
|
|
let restoreLock = null;
|
|
let captureTimer = null;
|
|
let sidebarRefresh = null; // callback set by sidebar render
|
|
let viewingWorkflowKey = null; // null = follow active workflow; string = override
|
|
let pickerDirty = true; // forces workflow picker to re-fetch on next expand
|
|
let timelineEl = null; // root DOM element for timeline bar
|
|
let timelineRefresh = null; // callback to re-render timeline
|
|
let activeSnapshotId = null; // ID of the snapshot currently loaded via swap
|
|
let currentSnapshotId = null; // ID of the auto-saved "Current" snapshot before a swap
|
|
let diffBaseSnapshot = null; // snapshot record selected as diff base (shift+click)
|
|
const svgCache = new Map(); // "snapshotId:WxH" -> SVGElement template
|
|
let svgClipCounter = 0; // unique prefix for SVG clipPath IDs
|
|
let sidebarTooltipEl = null; // tooltip element for sidebar hover previews
|
|
const lastCapturedIdMap = new Map(); // workflowKey -> id of most recent capture (for parentId chaining)
|
|
const activeBranchSelections = new Map(); // forkPointId -> selected child index
|
|
let branchingEnabled = localStorage.getItem("snapshotManager_branchingEnabled") !== "false";
|
|
let timelineExpanded = localStorage.getItem("snapshotManager_timelineExpanded") === "true";
|
|
const sessionWorkflows = new Map(); // workflowKey -> { firstSeen, lastSeen }
|
|
|
|
// ─── Server API Layer ───────────────────────────────────────────────
|
|
|
|
async function db_put(record) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/save", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ record }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Save failed:`, err);
|
|
showToast("Failed to save snapshot", "error");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function db_getAllForWorkflow(workflowKey) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/list", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ workflowKey }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
return await resp.json();
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] List failed:`, err);
|
|
showToast("Failed to read snapshots", "error");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function db_delete(workflowKey, id) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/delete", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ workflowKey, id }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Delete failed:`, err);
|
|
showToast("Failed to delete snapshot", "error");
|
|
}
|
|
}
|
|
|
|
async function db_deleteAllForWorkflow(workflowKey) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/delete-all", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ workflowKey }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
return await resp.json();
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Bulk delete failed:`, err);
|
|
showToast("Failed to clear snapshots", "error");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function db_getAllWorkflowKeys() {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/workflows");
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
return await resp.json();
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Workflow key scan failed:`, err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function db_updateMeta(workflowKey, id, fields) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/update-meta", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ workflowKey, id, fields }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Update meta failed:`, err);
|
|
showToast("Failed to update snapshot", "error");
|
|
}
|
|
}
|
|
|
|
async function db_getFullRecord(workflowKey, id) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/get", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ workflowKey, id }),
|
|
});
|
|
if (!resp.ok) return null;
|
|
return await resp.json();
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Get full record failed:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function pruneSnapshots(workflowKey, protectedIds = []) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ workflowKey, maxSnapshots, source: "regular", protectedIds }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Prune failed:`, err);
|
|
}
|
|
}
|
|
|
|
async function pruneNodeSnapshots(workflowKey, protectedIds = []) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ workflowKey, maxSnapshots: maxNodeSnapshots, source: "node", protectedIds }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Node prune failed:`, err);
|
|
}
|
|
}
|
|
|
|
// ─── Profile API Layer ───────────────────────────────────────────────
|
|
|
|
async function profile_save(profile) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/profile/save", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ profile }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Profile save failed:`, err);
|
|
showToast("Failed to save profile", "error");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function profile_list() {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/profile/list");
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
return await resp.json();
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Profile list failed:`, err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function profile_delete(profileId) {
|
|
try {
|
|
const resp = await api.fetchApi("/snapshot-manager/profile/delete", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id: profileId }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || resp.statusText);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Profile delete failed:`, err);
|
|
showToast("Failed to delete profile", "error");
|
|
}
|
|
}
|
|
|
|
function trackSessionWorkflow(workflowKey) {
|
|
const now = Date.now();
|
|
if (sessionWorkflows.has(workflowKey)) {
|
|
sessionWorkflows.get(workflowKey).lastSeen = now;
|
|
} else {
|
|
sessionWorkflows.set(workflowKey, { firstSeen: now, lastSeen: now });
|
|
}
|
|
}
|
|
|
|
// ─── IndexedDB Migration ────────────────────────────────────────────
|
|
|
|
async function migrateFromIndexedDB() {
|
|
try {
|
|
// Check if the old database exists (databases() not supported in all browsers)
|
|
if (typeof indexedDB.databases === "function") {
|
|
const databases = await indexedDB.databases();
|
|
if (!databases.some((db) => db.name === OLD_DB_NAME)) return;
|
|
}
|
|
|
|
const db = await new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(OLD_DB_NAME, 1);
|
|
req.onupgradeneeded = (e) => {
|
|
// DB didn't exist before — close and clean up
|
|
e.target.transaction.abort();
|
|
reject(new Error("no-existing-db"));
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
|
|
const allRecords = await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(OLD_STORE_NAME, "readonly");
|
|
const req = tx.objectStore(OLD_STORE_NAME).getAll();
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
|
|
db.close();
|
|
|
|
if (allRecords.length === 0) {
|
|
indexedDB.deleteDatabase(OLD_DB_NAME);
|
|
return;
|
|
}
|
|
|
|
// Send in batches
|
|
let totalImported = 0;
|
|
for (let i = 0; i < allRecords.length; i += MIGRATE_BATCH_SIZE) {
|
|
const batch = allRecords.slice(i, i + MIGRATE_BATCH_SIZE);
|
|
const resp = await api.fetchApi("/snapshot-manager/migrate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ records: batch }),
|
|
});
|
|
if (!resp.ok) throw new Error("Migration batch failed");
|
|
const result = await resp.json();
|
|
totalImported += result.imported;
|
|
}
|
|
|
|
// Success — delete old database
|
|
indexedDB.deleteDatabase(OLD_DB_NAME);
|
|
console.log(`[${EXTENSION_NAME}] Migrated ${totalImported} snapshots from IndexedDB to server`);
|
|
} catch (err) {
|
|
if (err.message === "no-existing-db") return;
|
|
console.warn(`[${EXTENSION_NAME}] IndexedDB migration failed (old data preserved):`, 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.extensionManager?.workflow?.activeWorkflow;
|
|
return wf?.key || wf?.filename || wf?.path || "default";
|
|
} catch {
|
|
return "default";
|
|
}
|
|
}
|
|
|
|
function getEffectiveWorkflowKey() {
|
|
return viewingWorkflowKey ?? getWorkflowKey();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ─── Change-Type Detection ──────────────────────────────────────────
|
|
|
|
function detectChangeType(prevGraph, currGraph) {
|
|
if (!prevGraph) return "initial";
|
|
|
|
const prevNodes = prevGraph.nodes || [];
|
|
const currNodes = currGraph.nodes || [];
|
|
|
|
// Quick length check before building Sets
|
|
if (prevNodes.length !== currNodes.length) {
|
|
return prevNodes.length < currNodes.length ? "node_add" : "node_remove";
|
|
}
|
|
|
|
const prevIds = new Set(prevNodes.map(n => n.id));
|
|
let hasAdded = false;
|
|
let hasRemoved = false;
|
|
for (let i = 0; i < currNodes.length; i++) {
|
|
if (!prevIds.has(currNodes[i].id)) { hasAdded = true; break; }
|
|
}
|
|
if (hasAdded) {
|
|
// Same length but different IDs → both add and remove
|
|
return "mixed";
|
|
}
|
|
|
|
// Node sets identical (same length, all curr IDs exist in prev)
|
|
// — check links, params, positions with early exits
|
|
let flags = 0;
|
|
const FLAG_CONNECTION = 1;
|
|
const FLAG_PARAM = 2;
|
|
const FLAG_MOVE = 4;
|
|
const ALL_FLAGS = FLAG_CONNECTION | FLAG_PARAM | FLAG_MOVE;
|
|
|
|
// Compare links — check length first to avoid stringify when possible
|
|
const prevLinks = prevGraph.links || [];
|
|
const currLinks = currGraph.links || [];
|
|
if (prevLinks.length !== currLinks.length) {
|
|
flags |= FLAG_CONNECTION;
|
|
} else if (prevLinks.length > 0) {
|
|
// Same length — spot-check first/last before full stringify
|
|
const pFirst = prevLinks[0], cFirst = currLinks[0];
|
|
const pLast = prevLinks[prevLinks.length - 1], cLast = currLinks[currLinks.length - 1];
|
|
if (pFirst?.[0] !== cFirst?.[0] || pFirst?.[1] !== cFirst?.[1]
|
|
|| pLast?.[0] !== cLast?.[0] || pLast?.[1] !== cLast?.[1]) {
|
|
flags |= FLAG_CONNECTION;
|
|
} else if (JSON.stringify(prevLinks) !== JSON.stringify(currLinks)) {
|
|
flags |= FLAG_CONNECTION;
|
|
}
|
|
}
|
|
|
|
// Build lookup for prev nodes by id
|
|
const prevNodeMap = new Map(prevNodes.map(n => [n.id, n]));
|
|
|
|
for (const cn of currNodes) {
|
|
const pn = prevNodeMap.get(cn.id);
|
|
if (!pn) continue;
|
|
|
|
// Compare widget values — cheap ref/length check before stringify
|
|
if (!(flags & FLAG_PARAM)) {
|
|
const cw = cn.widgets_values;
|
|
const pw = pn.widgets_values;
|
|
if (cw !== pw) {
|
|
if (cw == null || pw == null
|
|
|| !Array.isArray(cw) || !Array.isArray(pw)
|
|
|| cw.length !== pw.length) {
|
|
flags |= FLAG_PARAM;
|
|
} else {
|
|
// Same-length arrays — compare elements directly
|
|
for (let i = 0; i < cw.length; i++) {
|
|
if (cw[i] !== pw[i]) { flags |= FLAG_PARAM; break; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compare positions
|
|
if (!(flags & FLAG_MOVE)) {
|
|
const cp = cn.pos, pp = pn.pos;
|
|
if (cp?.[0] !== pp?.[0] || cp?.[1] !== pp?.[1]) flags |= FLAG_MOVE;
|
|
}
|
|
|
|
if (flags === ALL_FLAGS) break;
|
|
}
|
|
|
|
if (flags === 0) return "unknown";
|
|
|
|
// Count set flags
|
|
const count = ((flags & FLAG_CONNECTION) ? 1 : 0)
|
|
+ ((flags & FLAG_PARAM) ? 1 : 0)
|
|
+ ((flags & FLAG_MOVE) ? 1 : 0);
|
|
if (count > 1) return "mixed";
|
|
|
|
if (flags & FLAG_CONNECTION) return "connection";
|
|
if (flags & FLAG_PARAM) return "param";
|
|
if (flags & FLAG_MOVE) return "move";
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
// ─── Detailed Diff ──────────────────────────────────────────────────
|
|
|
|
function buildNodeLookup(...graphs) {
|
|
const map = new Map();
|
|
for (const g of graphs) {
|
|
if (!g || !Array.isArray(g.nodes)) continue;
|
|
for (const n of g.nodes) {
|
|
if (!map.has(n.id)) {
|
|
map.set(n.id, { type: n.type || "?", title: n.title || n.type || `#${n.id}` });
|
|
}
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function computeDetailedDiff(baseGraph, targetGraph) {
|
|
const empty = {
|
|
addedNodes: [], removedNodes: [], modifiedNodes: [],
|
|
addedLinks: [], removedLinks: [],
|
|
summary: { nodesAdded: 0, nodesRemoved: 0, nodesModified: 0, linksAdded: 0, linksRemoved: 0 },
|
|
};
|
|
if (!baseGraph && !targetGraph) return empty;
|
|
const bNodes = (baseGraph?.nodes || []);
|
|
const tNodes = (targetGraph?.nodes || []);
|
|
|
|
const baseMap = new Map(bNodes.map(n => [n.id, n]));
|
|
const targetMap = new Map(tNodes.map(n => [n.id, n]));
|
|
|
|
const addedNodes = [];
|
|
const removedNodes = [];
|
|
const modifiedNodes = [];
|
|
|
|
// Removed: in base but not in target
|
|
for (const [id, n] of baseMap) {
|
|
if (!targetMap.has(id)) {
|
|
removedNodes.push({ id, type: n.type || "?", title: n.title || n.type || `#${id}` });
|
|
}
|
|
}
|
|
|
|
// Added or modified: in target
|
|
for (const [id, tn] of targetMap) {
|
|
const bn = baseMap.get(id);
|
|
if (!bn) {
|
|
addedNodes.push({ id, type: tn.type || "?", title: tn.title || tn.type || `#${id}` });
|
|
continue;
|
|
}
|
|
// Check modifications
|
|
const changes = {};
|
|
|
|
// Position
|
|
if (bn.pos?.[0] !== tn.pos?.[0] || bn.pos?.[1] !== tn.pos?.[1]) {
|
|
changes.position = { from: bn.pos, to: tn.pos };
|
|
}
|
|
|
|
// Size
|
|
if (bn.size?.[0] !== tn.size?.[0] || bn.size?.[1] !== tn.size?.[1]) {
|
|
changes.size = { from: bn.size, to: tn.size };
|
|
}
|
|
|
|
// Title
|
|
if ((bn.title || "") !== (tn.title || "")) {
|
|
changes.title = { from: bn.title || "", to: tn.title || "" };
|
|
}
|
|
|
|
// Mode
|
|
if (bn.mode !== tn.mode) {
|
|
changes.mode = { from: bn.mode, to: tn.mode };
|
|
}
|
|
|
|
// Widget values
|
|
const bw = bn.widgets_values;
|
|
const tw = tn.widgets_values;
|
|
if (bw !== tw) {
|
|
if (bw == null || tw == null || !Array.isArray(bw) || !Array.isArray(tw) || bw.length !== tw.length) {
|
|
changes.widgetValues = { from: bw, to: tw };
|
|
} else {
|
|
const diffs = [];
|
|
for (let i = 0; i < Math.max(bw.length, tw.length); i++) {
|
|
const bv = i < bw.length ? bw[i] : undefined;
|
|
const tv = i < tw.length ? tw[i] : undefined;
|
|
if (bv !== tv) {
|
|
const bs = typeof bv === "object" ? JSON.stringify(bv) : String(bv ?? "");
|
|
const ts = typeof tv === "object" ? JSON.stringify(tv) : String(tv ?? "");
|
|
if (bs !== ts) diffs.push({ index: i, from: bs, to: ts });
|
|
}
|
|
}
|
|
if (diffs.length > 0) changes.widgetValues = diffs;
|
|
}
|
|
}
|
|
|
|
// Properties (shallow key comparison)
|
|
const bp = bn.properties || {};
|
|
const tp = tn.properties || {};
|
|
const allPropKeys = new Set([...Object.keys(bp), ...Object.keys(tp)]);
|
|
const propDiffs = [];
|
|
for (const key of allPropKeys) {
|
|
const bv = bp[key];
|
|
const tv = tp[key];
|
|
if (bv !== tv) {
|
|
const bs = typeof bv === "object" ? JSON.stringify(bv) : String(bv ?? "");
|
|
const ts = typeof tv === "object" ? JSON.stringify(tv) : String(tv ?? "");
|
|
if (bs !== ts) propDiffs.push({ key, from: bs, to: ts });
|
|
}
|
|
}
|
|
if (propDiffs.length > 0) changes.properties = propDiffs;
|
|
|
|
if (Object.keys(changes).length > 0) {
|
|
modifiedNodes.push({
|
|
id, type: tn.type || "?", title: tn.title || tn.type || `#${id}`, changes,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Links
|
|
const bLinks = (baseGraph?.links || []).filter(Boolean);
|
|
const tLinks = (targetGraph?.links || []).filter(Boolean);
|
|
|
|
const baseLinkMap = new Map(bLinks.map(l => [l[0], l]));
|
|
const targetLinkMap = new Map(tLinks.map(l => [l[0], l]));
|
|
|
|
const addedLinks = [];
|
|
const removedLinks = [];
|
|
|
|
for (const [linkId, l] of baseLinkMap) {
|
|
if (!targetLinkMap.has(linkId)) {
|
|
removedLinks.push({ linkId, srcNodeId: l[1], srcSlot: l[2], destNodeId: l[3], destSlot: l[4], type: l[5] });
|
|
}
|
|
}
|
|
for (const [linkId, l] of targetLinkMap) {
|
|
if (!baseLinkMap.has(linkId)) {
|
|
addedLinks.push({ linkId, srcNodeId: l[1], srcSlot: l[2], destNodeId: l[3], destSlot: l[4], type: l[5] });
|
|
}
|
|
}
|
|
|
|
return {
|
|
addedNodes, removedNodes, modifiedNodes, addedLinks, removedLinks,
|
|
summary: {
|
|
nodesAdded: addedNodes.length,
|
|
nodesRemoved: removedNodes.length,
|
|
nodesModified: modifiedNodes.length,
|
|
linksAdded: addedLinks.length,
|
|
linksRemoved: removedLinks.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── SVG Graph Renderer ─────────────────────────────────────────────
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
const SVG_NODE_TITLE_HEIGHT = 25;
|
|
const SVG_SLOT_SPACING = 20;
|
|
const SVG_DEFAULTS = { width: 140, height: 80, color: "#333", bgcolor: "#353535" };
|
|
const SVG_LINK_TYPE_COLORS = {
|
|
IMAGE: "#64b5f6", CLIP: "#ffa726", MODEL: "#b39ddb",
|
|
CONDITIONING: "#ef9a9a", LATENT: "#ff63c9", VAE: "#ff6e6e",
|
|
MASK: "#81c784", INT: "#7986cb", FLOAT: "#7986cb", STRING: "#7986cb",
|
|
};
|
|
const SVG_HIGHLIGHT_COLORS = { added: "#22c55e", removed: "#dc2626", modified: "#f59e0b" };
|
|
|
|
function renderGraphSVG(graphData, options = {}) {
|
|
const {
|
|
width = 400, height = 300, padding = 40,
|
|
highlightNodes = null, showLabels = true,
|
|
showLinks = true, showSlots = true, showGroups = true,
|
|
backgroundColor = "#1a1a2e",
|
|
} = options;
|
|
|
|
const nodes = graphData?.nodes;
|
|
if (!nodes || nodes.length === 0) return null;
|
|
|
|
// Build node map (skip null entries)
|
|
const nodeMap = new Map();
|
|
for (const n of nodes) {
|
|
if (n == null) continue;
|
|
nodeMap.set(n.id, n);
|
|
}
|
|
if (nodeMap.size === 0) return null;
|
|
|
|
// Compute bounding box
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const n of nodeMap.values()) {
|
|
const x = n.pos?.[0] ?? 0;
|
|
const y = n.pos?.[1] ?? 0;
|
|
const w = n.size?.[0] ?? SVG_DEFAULTS.width;
|
|
const h = n.flags?.collapsed ? SVG_NODE_TITLE_HEIGHT : (n.size?.[1] ?? SVG_DEFAULTS.height);
|
|
if (x < minX) minX = x;
|
|
if (y < minY) minY = y;
|
|
if (x + w > maxX) maxX = x + w;
|
|
if (y + h > maxY) maxY = y + h;
|
|
}
|
|
|
|
// Include groups in bbox
|
|
const groups = graphData.groups || [];
|
|
if (showGroups) {
|
|
for (const g of groups) {
|
|
if (!g?.bounding) continue;
|
|
const [gx, gy, gw, gh] = g.bounding;
|
|
if (gx < minX) minX = gx;
|
|
if (gy < minY) minY = gy;
|
|
if (gx + gw > maxX) maxX = gx + gw;
|
|
if (gy + gh > maxY) maxY = gy + gh;
|
|
}
|
|
}
|
|
|
|
// Guard zero-area bbox (single node edge case)
|
|
if (maxX - minX < 1) maxX = minX + SVG_DEFAULTS.width;
|
|
if (maxY - minY < 1) maxY = minY + SVG_DEFAULTS.height;
|
|
|
|
const bboxW = maxX - minX + padding * 2;
|
|
const bboxH = maxY - minY + padding * 2;
|
|
const vbX = minX - padding;
|
|
const vbY = minY - padding;
|
|
|
|
const svg = document.createElementNS(SVG_NS, "svg");
|
|
svg.setAttribute("viewBox", `${vbX} ${vbY} ${bboxW} ${bboxH}`);
|
|
svg.setAttribute("width", width);
|
|
svg.setAttribute("height", height);
|
|
svg.style.display = "block";
|
|
|
|
// Auto-simplify for thumbnails
|
|
const effectiveLabels = width < 200 ? false : showLabels;
|
|
const effectiveSlots = width < 200 ? false : showSlots;
|
|
const clipPrefix = `sc${svgClipCounter++}`;
|
|
|
|
// Background
|
|
const bg = document.createElementNS(SVG_NS, "rect");
|
|
bg.setAttribute("x", vbX);
|
|
bg.setAttribute("y", vbY);
|
|
bg.setAttribute("width", bboxW);
|
|
bg.setAttribute("height", bboxH);
|
|
bg.setAttribute("fill", backgroundColor);
|
|
svg.appendChild(bg);
|
|
|
|
// Groups
|
|
if (showGroups && groups.length > 0) {
|
|
for (const g of groups) {
|
|
if (!g?.bounding) continue;
|
|
const [gx, gy, gw, gh] = g.bounding;
|
|
const gRect = document.createElementNS(SVG_NS, "rect");
|
|
gRect.setAttribute("x", gx);
|
|
gRect.setAttribute("y", gy);
|
|
gRect.setAttribute("width", gw);
|
|
gRect.setAttribute("height", gh);
|
|
gRect.setAttribute("rx", "5");
|
|
const gColor = g.color || "#335";
|
|
gRect.setAttribute("fill", gColor);
|
|
gRect.setAttribute("fill-opacity", "0.3");
|
|
gRect.setAttribute("stroke", gColor);
|
|
gRect.setAttribute("stroke-opacity", "0.5");
|
|
gRect.setAttribute("stroke-width", "1");
|
|
svg.appendChild(gRect);
|
|
|
|
// Group title
|
|
if (effectiveLabels && g.title) {
|
|
const gText = document.createElementNS(SVG_NS, "text");
|
|
gText.setAttribute("x", gx + 8);
|
|
gText.setAttribute("y", gy + 16);
|
|
gText.setAttribute("fill", "#aaa");
|
|
gText.setAttribute("font-size", "14");
|
|
gText.setAttribute("font-family", "system-ui, sans-serif");
|
|
gText.textContent = g.title;
|
|
svg.appendChild(gText);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slot position helpers
|
|
function getOutputSlotPos(node, slotIndex) {
|
|
const x = node.pos?.[0] ?? 0;
|
|
const y = node.pos?.[1] ?? 0;
|
|
const w = node.size?.[0] ?? SVG_DEFAULTS.width;
|
|
return [x + w, y + SVG_NODE_TITLE_HEIGHT + slotIndex * SVG_SLOT_SPACING + SVG_SLOT_SPACING / 2];
|
|
}
|
|
|
|
function getInputSlotPos(node, slotIndex) {
|
|
const x = node.pos?.[0] ?? 0;
|
|
const y = node.pos?.[1] ?? 0;
|
|
return [x, y + SVG_NODE_TITLE_HEIGHT + slotIndex * SVG_SLOT_SPACING + SVG_SLOT_SPACING / 2];
|
|
}
|
|
|
|
// Links
|
|
const links = (graphData.links || []).filter(Boolean);
|
|
if (showLinks && links.length <= 2000) {
|
|
for (const link of links) {
|
|
const srcNodeId = link[1];
|
|
const srcSlot = link[2];
|
|
const destNodeId = link[3];
|
|
const destSlot = link[4];
|
|
const linkType = link[5];
|
|
|
|
const srcNode = nodeMap.get(srcNodeId);
|
|
const destNode = nodeMap.get(destNodeId);
|
|
if (!srcNode || !destNode) continue;
|
|
|
|
// Skip links from collapsed source nodes where slot is hidden
|
|
const srcCollapsed = srcNode.flags?.collapsed;
|
|
const destCollapsed = destNode.flags?.collapsed;
|
|
|
|
const [srcX, srcY] = srcCollapsed
|
|
? [(srcNode.pos?.[0] ?? 0) + (srcNode.size?.[0] ?? SVG_DEFAULTS.width), (srcNode.pos?.[1] ?? 0) + SVG_NODE_TITLE_HEIGHT / 2]
|
|
: getOutputSlotPos(srcNode, srcSlot);
|
|
const [destX, destY] = destCollapsed
|
|
? [(destNode.pos?.[0] ?? 0), (destNode.pos?.[1] ?? 0) + SVG_NODE_TITLE_HEIGHT / 2]
|
|
: getInputSlotPos(destNode, destSlot);
|
|
|
|
const dx = Math.max(Math.abs(destX - srcX) * 0.5, 50);
|
|
const d = `M ${srcX} ${srcY} C ${srcX + dx} ${srcY}, ${destX - dx} ${destY}, ${destX} ${destY}`;
|
|
|
|
const path = document.createElementNS(SVG_NS, "path");
|
|
path.setAttribute("d", d);
|
|
path.setAttribute("fill", "none");
|
|
const color = SVG_LINK_TYPE_COLORS[linkType] || "#888";
|
|
path.setAttribute("stroke", color);
|
|
path.setAttribute("stroke-width", "2");
|
|
path.setAttribute("stroke-opacity", "0.5");
|
|
svg.appendChild(path);
|
|
}
|
|
}
|
|
|
|
// Nodes
|
|
for (const n of nodeMap.values()) {
|
|
const x = n.pos?.[0] ?? 0;
|
|
const y = n.pos?.[1] ?? 0;
|
|
const w = n.size?.[0] ?? SVG_DEFAULTS.width;
|
|
const isCollapsed = n.flags?.collapsed;
|
|
const h = isCollapsed ? SVG_NODE_TITLE_HEIGHT : (n.size?.[1] ?? SVG_DEFAULTS.height);
|
|
const bgcolor = n.bgcolor || SVG_DEFAULTS.bgcolor;
|
|
const color = n.color || SVG_DEFAULTS.color;
|
|
|
|
const highlightType = highlightNodes?.get(n.id);
|
|
const highlightColor = highlightType ? SVG_HIGHLIGHT_COLORS[highlightType] : null;
|
|
|
|
// Node body
|
|
const body = document.createElementNS(SVG_NS, "rect");
|
|
body.setAttribute("x", x);
|
|
body.setAttribute("y", y);
|
|
body.setAttribute("width", w);
|
|
body.setAttribute("height", h);
|
|
body.setAttribute("rx", "4");
|
|
body.setAttribute("fill", bgcolor);
|
|
if (highlightColor) {
|
|
body.setAttribute("stroke", highlightColor);
|
|
body.setAttribute("stroke-width", "3");
|
|
} else {
|
|
body.setAttribute("stroke", "#555");
|
|
body.setAttribute("stroke-width", "1");
|
|
}
|
|
svg.appendChild(body);
|
|
|
|
// Title bar
|
|
const titleBar = document.createElementNS(SVG_NS, "rect");
|
|
titleBar.setAttribute("x", x);
|
|
titleBar.setAttribute("y", y);
|
|
titleBar.setAttribute("width", w);
|
|
titleBar.setAttribute("height", SVG_NODE_TITLE_HEIGHT);
|
|
titleBar.setAttribute("rx", "4");
|
|
titleBar.setAttribute("fill", color);
|
|
svg.appendChild(titleBar);
|
|
|
|
// Title text
|
|
if (effectiveLabels) {
|
|
const titleText = document.createElementNS(SVG_NS, "text");
|
|
titleText.setAttribute("x", x + 8);
|
|
titleText.setAttribute("y", y + 16);
|
|
titleText.setAttribute("fill", "#eee");
|
|
titleText.setAttribute("font-size", "11");
|
|
titleText.setAttribute("font-family", "system-ui, sans-serif");
|
|
// Truncate to fit node width
|
|
const maxChars = Math.max(4, Math.floor(w / 7));
|
|
const title = n.title || n.type || "";
|
|
titleText.textContent = title.length > maxChars ? title.slice(0, maxChars - 1) + "\u2026" : title;
|
|
// Clip to node width
|
|
const clipId = `${clipPrefix}-${n.id}`;
|
|
const clipPath = document.createElementNS(SVG_NS, "clipPath");
|
|
clipPath.setAttribute("id", clipId);
|
|
const clipRect = document.createElementNS(SVG_NS, "rect");
|
|
clipRect.setAttribute("x", x);
|
|
clipRect.setAttribute("y", y);
|
|
clipRect.setAttribute("width", w);
|
|
clipRect.setAttribute("height", SVG_NODE_TITLE_HEIGHT);
|
|
clipPath.appendChild(clipRect);
|
|
svg.appendChild(clipPath);
|
|
titleText.setAttribute("clip-path", `url(#${clipId})`);
|
|
svg.appendChild(titleText);
|
|
}
|
|
|
|
// Slots
|
|
if (effectiveSlots && !isCollapsed) {
|
|
const inputs = n.inputs || [];
|
|
for (let i = 0; i < inputs.length; i++) {
|
|
const [sx, sy] = getInputSlotPos(n, i);
|
|
const circle = document.createElementNS(SVG_NS, "circle");
|
|
circle.setAttribute("cx", sx);
|
|
circle.setAttribute("cy", sy);
|
|
circle.setAttribute("r", "4");
|
|
const slotType = inputs[i]?.type || "";
|
|
circle.setAttribute("fill", SVG_LINK_TYPE_COLORS[slotType] || "#888");
|
|
svg.appendChild(circle);
|
|
}
|
|
|
|
const outputs = n.outputs || [];
|
|
for (let i = 0; i < outputs.length; i++) {
|
|
const [sx, sy] = getOutputSlotPos(n, i);
|
|
const circle = document.createElementNS(SVG_NS, "circle");
|
|
circle.setAttribute("cx", sx);
|
|
circle.setAttribute("cy", sy);
|
|
circle.setAttribute("r", "4");
|
|
const slotType = outputs[i]?.type || "";
|
|
circle.setAttribute("fill", SVG_LINK_TYPE_COLORS[slotType] || "#888");
|
|
svg.appendChild(circle);
|
|
}
|
|
}
|
|
}
|
|
|
|
return svg;
|
|
}
|
|
|
|
function getCachedSVG(snapshotId, graphData, options = {}) {
|
|
const { width = 400, height = 300 } = options;
|
|
const key = `${snapshotId}:${width}x${height}`;
|
|
if (svgCache.has(key)) {
|
|
return svgCache.get(key).cloneNode(true);
|
|
}
|
|
const svg = renderGraphSVG(graphData, options);
|
|
if (svg) {
|
|
svgCache.set(key, svg);
|
|
return svg.cloneNode(true);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── Snapshot Tree (Branching) ───────────────────────────────────────
|
|
|
|
function buildSnapshotTree(records) {
|
|
const childrenOf = new Map(); // parentId -> [children records]
|
|
const parentOf = new Map(); // id -> parentId
|
|
const roots = [];
|
|
const byId = new Map();
|
|
|
|
for (const r of records) byId.set(r.id, r);
|
|
|
|
// Separate legacy (no parentId) from branched records
|
|
const legacy = [];
|
|
const branched = [];
|
|
for (const r of records) {
|
|
if (r.parentId === undefined || r.parentId === null) {
|
|
legacy.push(r);
|
|
} else {
|
|
branched.push(r);
|
|
}
|
|
}
|
|
|
|
// Chain legacy snapshots by timestamp order (backwards compat)
|
|
legacy.sort((a, b) => a.timestamp - b.timestamp);
|
|
for (let i = 0; i < legacy.length; i++) {
|
|
const r = legacy[i];
|
|
const syntheticParent = i > 0 ? legacy[i - 1].id : null;
|
|
if (syntheticParent) {
|
|
parentOf.set(r.id, syntheticParent);
|
|
if (!childrenOf.has(syntheticParent)) childrenOf.set(syntheticParent, []);
|
|
childrenOf.get(syntheticParent).push(r);
|
|
} else {
|
|
roots.push(r);
|
|
}
|
|
}
|
|
|
|
// Process branched records
|
|
for (const r of branched) {
|
|
parentOf.set(r.id, r.parentId);
|
|
if (byId.has(r.parentId)) {
|
|
if (!childrenOf.has(r.parentId)) childrenOf.set(r.parentId, []);
|
|
childrenOf.get(r.parentId).push(r);
|
|
} else {
|
|
// Parent not found (deleted?), treat as root
|
|
roots.push(r);
|
|
}
|
|
}
|
|
|
|
// Sort children by timestamp at each fork point
|
|
for (const [, children] of childrenOf) {
|
|
children.sort((a, b) => a.timestamp - b.timestamp);
|
|
}
|
|
|
|
return { childrenOf, parentOf, roots, byId };
|
|
}
|
|
|
|
function getDisplayPath(tree, branchSelections) {
|
|
const { childrenOf, roots } = tree;
|
|
if (roots.length === 0) return [];
|
|
|
|
// Pick root (should normally be 1, but handle multiple)
|
|
const rootIndex = branchSelections.get("__root__") ?? 0;
|
|
let current = roots[Math.min(rootIndex, roots.length - 1)];
|
|
if (!current) return [];
|
|
|
|
const path = [current];
|
|
while (true) {
|
|
const children = childrenOf.get(current.id);
|
|
if (!children || children.length === 0) break;
|
|
const selectedIndex = branchSelections.get(current.id) ?? 0;
|
|
current = children[Math.min(selectedIndex, children.length - 1)];
|
|
path.push(current);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function getAncestorIds(snapshotId, parentOf) {
|
|
const ancestors = new Set();
|
|
let current = snapshotId;
|
|
while (parentOf.has(current)) {
|
|
current = parentOf.get(current);
|
|
if (ancestors.has(current)) break; // safety: cycle detection
|
|
ancestors.add(current);
|
|
}
|
|
return ancestors;
|
|
}
|
|
|
|
function getAllBranches(tree) {
|
|
const branches = [];
|
|
function walk(nodeId, path) {
|
|
const record = tree.byId.get(nodeId);
|
|
if (!record) return;
|
|
const currentPath = [...path, record];
|
|
const children = tree.childrenOf.get(nodeId);
|
|
if (!children || children.length === 0) {
|
|
branches.push(currentPath);
|
|
} else {
|
|
for (const child of children) {
|
|
walk(child.id, currentPath);
|
|
}
|
|
}
|
|
}
|
|
for (const root of tree.roots) {
|
|
walk(root.id, []);
|
|
}
|
|
return branches;
|
|
}
|
|
|
|
function selectBranchContaining(snapshotId, tree) {
|
|
// Walk from snapshot to root, at each fork set activeBranchSelections
|
|
const pathToRoot = [];
|
|
const visited = new Set();
|
|
let current = snapshotId;
|
|
while (current) {
|
|
if (visited.has(current)) break; // cycle detection
|
|
visited.add(current);
|
|
pathToRoot.push(current);
|
|
current = tree.parentOf.get(current) || null;
|
|
}
|
|
pathToRoot.reverse(); // now root → snapshot
|
|
|
|
// Handle multiple roots
|
|
if (pathToRoot.length > 0 && tree.roots.length > 1) {
|
|
const rootId = pathToRoot[0];
|
|
const rootIdx = tree.roots.findIndex(r => r.id === rootId);
|
|
if (rootIdx >= 0) activeBranchSelections.set("__root__", rootIdx);
|
|
}
|
|
|
|
for (let i = 0; i < pathToRoot.length - 1; i++) {
|
|
const parentId = pathToRoot[i];
|
|
const childId = pathToRoot[i + 1];
|
|
const children = tree.childrenOf.get(parentId);
|
|
if (children && children.length > 1) {
|
|
const idx = children.findIndex(c => c.id === childId);
|
|
if (idx >= 0) activeBranchSelections.set(parentId, idx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 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(() => {});
|
|
}
|
|
if (timelineRefresh) {
|
|
timelineRefresh().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);
|
|
}
|
|
}
|
|
|
|
// ─── Diff Modal ─────────────────────────────────────────────────────
|
|
|
|
function showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraphData, targetGraphData) {
|
|
// Overlay
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "snap-diff-overlay";
|
|
|
|
// Modal
|
|
const modal = document.createElement("div");
|
|
modal.className = "snap-diff-modal";
|
|
|
|
// Header
|
|
const hdr = document.createElement("div");
|
|
hdr.className = "snap-diff-header";
|
|
const hdrTitle = document.createElement("span");
|
|
hdrTitle.textContent = `${baseLabel} \u2192 ${targetLabel}`;
|
|
const closeBtn = document.createElement("button");
|
|
closeBtn.textContent = "\u2715";
|
|
closeBtn.addEventListener("click", dismiss);
|
|
hdr.appendChild(hdrTitle);
|
|
hdr.appendChild(closeBtn);
|
|
|
|
// Summary pills
|
|
const { summary } = diff;
|
|
const summaryBar = document.createElement("div");
|
|
summaryBar.className = "snap-diff-summary";
|
|
const pills = [
|
|
{ count: summary.nodesAdded, label: "added", color: "#22c55e" },
|
|
{ count: summary.nodesRemoved, label: "removed", color: "#dc2626" },
|
|
{ count: summary.nodesModified, label: "modified", color: "#f59e0b" },
|
|
{ count: summary.linksAdded, label: "links +", color: "#3b82f6" },
|
|
{ count: summary.linksRemoved, label: "links \u2212", color: "#3b82f6" },
|
|
];
|
|
for (const p of pills) {
|
|
if (p.count === 0) continue;
|
|
const pill = document.createElement("span");
|
|
pill.style.cssText = `background:${p.color}; color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600;`;
|
|
pill.textContent = `${p.count} ${p.label}`;
|
|
summaryBar.appendChild(pill);
|
|
}
|
|
|
|
// Body (scrollable)
|
|
const body = document.createElement("div");
|
|
body.className = "snap-diff-body";
|
|
|
|
const totalChanges = summary.nodesAdded + summary.nodesRemoved + summary.nodesModified + summary.linksAdded + summary.linksRemoved;
|
|
|
|
if (totalChanges === 0) {
|
|
const emptyMsg = document.createElement("div");
|
|
emptyMsg.className = "snap-diff-empty";
|
|
emptyMsg.textContent = "No differences found.";
|
|
body.appendChild(emptyMsg);
|
|
} else {
|
|
// Helper: collapsible section
|
|
function makeSection(title, count, entries, renderEntry) {
|
|
if (count === 0) return null;
|
|
const section = document.createElement("div");
|
|
section.className = "snap-diff-section";
|
|
const sectionHdr = document.createElement("div");
|
|
sectionHdr.className = "snap-diff-section-header";
|
|
const arrow = document.createElement("span");
|
|
arrow.className = "snap-diff-section-arrow";
|
|
arrow.textContent = "\u25BC";
|
|
const sectionTitle = document.createElement("span");
|
|
sectionTitle.textContent = `${title} (${count})`;
|
|
sectionHdr.appendChild(arrow);
|
|
sectionHdr.appendChild(sectionTitle);
|
|
const sectionBody = document.createElement("div");
|
|
sectionBody.className = "snap-diff-section-body";
|
|
for (const entry of entries) {
|
|
sectionBody.appendChild(renderEntry(entry));
|
|
}
|
|
let collapsed = false;
|
|
sectionHdr.addEventListener("click", () => {
|
|
collapsed = !collapsed;
|
|
sectionBody.style.display = collapsed ? "none" : "";
|
|
arrow.textContent = collapsed ? "\u25B6" : "\u25BC";
|
|
});
|
|
section.appendChild(sectionHdr);
|
|
section.appendChild(sectionBody);
|
|
return section;
|
|
}
|
|
|
|
function nodeEntry(n, colorClass) {
|
|
const el = document.createElement("div");
|
|
el.className = `snap-diff-node-entry ${colorClass}`;
|
|
el.textContent = `${n.title} (${n.type}) #${n.id}`;
|
|
return el;
|
|
}
|
|
|
|
// Added Nodes
|
|
const addedSec = makeSection("Added Nodes", diff.addedNodes.length, diff.addedNodes, (n) => nodeEntry(n, "snap-diff-added"));
|
|
if (addedSec) body.appendChild(addedSec);
|
|
|
|
// Removed Nodes
|
|
const removedSec = makeSection("Removed Nodes", diff.removedNodes.length, diff.removedNodes, (n) => nodeEntry(n, "snap-diff-removed"));
|
|
if (removedSec) body.appendChild(removedSec);
|
|
|
|
// Modified Nodes
|
|
const modSec = makeSection("Modified Nodes", diff.modifiedNodes.length, diff.modifiedNodes, (n) => {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "snap-diff-node-entry snap-diff-neutral";
|
|
const header = document.createElement("div");
|
|
header.textContent = `${n.title} (${n.type}) #${n.id}`;
|
|
wrap.appendChild(header);
|
|
|
|
const { changes } = n;
|
|
if (changes.position) {
|
|
const d = document.createElement("div");
|
|
d.className = "snap-diff-change-detail";
|
|
const from = changes.position.from || [0, 0];
|
|
const to = changes.position.to || [0, 0];
|
|
d.appendChild(makeValueChange("Position", `[${Math.round(from[0])}, ${Math.round(from[1])}]`, `[${Math.round(to[0])}, ${Math.round(to[1])}]`));
|
|
wrap.appendChild(d);
|
|
}
|
|
if (changes.size) {
|
|
const d = document.createElement("div");
|
|
d.className = "snap-diff-change-detail";
|
|
const from = changes.size.from || [0, 0];
|
|
const to = changes.size.to || [0, 0];
|
|
d.appendChild(makeValueChange("Size", `[${Math.round(from[0])}, ${Math.round(from[1])}]`, `[${Math.round(to[0])}, ${Math.round(to[1])}]`));
|
|
wrap.appendChild(d);
|
|
}
|
|
if (changes.title) {
|
|
const d = document.createElement("div");
|
|
d.className = "snap-diff-change-detail";
|
|
d.appendChild(makeValueChange("Title", changes.title.from, changes.title.to));
|
|
wrap.appendChild(d);
|
|
}
|
|
if (changes.mode) {
|
|
const d = document.createElement("div");
|
|
d.className = "snap-diff-change-detail";
|
|
d.appendChild(makeValueChange("Mode", String(changes.mode.from), String(changes.mode.to)));
|
|
wrap.appendChild(d);
|
|
}
|
|
if (changes.widgetValues) {
|
|
if (Array.isArray(changes.widgetValues)) {
|
|
for (const wv of changes.widgetValues) {
|
|
const d = document.createElement("div");
|
|
d.className = "snap-diff-change-detail";
|
|
d.appendChild(makeValueChange(`Value[${wv.index}]`, wv.from, wv.to));
|
|
wrap.appendChild(d);
|
|
}
|
|
} else {
|
|
const d = document.createElement("div");
|
|
d.className = "snap-diff-change-detail";
|
|
d.appendChild(makeValueChange("Widget values", JSON.stringify(changes.widgetValues.from), JSON.stringify(changes.widgetValues.to)));
|
|
wrap.appendChild(d);
|
|
}
|
|
}
|
|
if (changes.properties) {
|
|
for (const pv of changes.properties) {
|
|
const d = document.createElement("div");
|
|
d.className = "snap-diff-change-detail";
|
|
d.appendChild(makeValueChange(`prop.${pv.key}`, pv.from, pv.to));
|
|
wrap.appendChild(d);
|
|
}
|
|
}
|
|
return wrap;
|
|
});
|
|
if (modSec) body.appendChild(modSec);
|
|
|
|
// Link changes (combined section)
|
|
const allLinkChanges = [
|
|
...diff.addedLinks.map(l => ({ ...l, action: "added" })),
|
|
...diff.removedLinks.map(l => ({ ...l, action: "removed" })),
|
|
];
|
|
const linkSec = makeSection("Link Changes", allLinkChanges.length, allLinkChanges, (l) => {
|
|
const el = document.createElement("div");
|
|
el.className = `snap-diff-link-entry ${l.action === "added" ? "snap-diff-added" : "snap-diff-removed"}`;
|
|
const srcInfo = allNodes.get(l.srcNodeId) || { title: `#${l.srcNodeId}` };
|
|
const destInfo = allNodes.get(l.destNodeId) || { title: `#${l.destNodeId}` };
|
|
const prefix = l.action === "added" ? "+" : "\u2212";
|
|
el.textContent = `${prefix} ${srcInfo.title} [${l.srcSlot}] \u2192 ${destInfo.title} [${l.destSlot}]${l.type ? ` (${l.type})` : ""}`;
|
|
return el;
|
|
});
|
|
if (linkSec) body.appendChild(linkSec);
|
|
}
|
|
|
|
function makeValueChange(label, oldVal, newVal) {
|
|
const span = document.createElement("span");
|
|
const lbl = document.createElement("span");
|
|
lbl.textContent = `${label}: `;
|
|
const oldSpan = document.createElement("span");
|
|
oldSpan.className = "snap-diff-val-old";
|
|
oldSpan.textContent = truncateVal(oldVal);
|
|
const arrow = document.createElement("span");
|
|
arrow.textContent = " \u2192 ";
|
|
const newSpan = document.createElement("span");
|
|
newSpan.className = "snap-diff-val-new";
|
|
newSpan.textContent = truncateVal(newVal);
|
|
span.appendChild(lbl);
|
|
span.appendChild(oldSpan);
|
|
span.appendChild(arrow);
|
|
span.appendChild(newSpan);
|
|
return span;
|
|
}
|
|
|
|
function truncateVal(v) {
|
|
const s = String(v ?? "");
|
|
return s.length > 80 ? s.slice(0, 77) + "\u2026" : s;
|
|
}
|
|
|
|
// SVG comparison panel
|
|
let svgCompare = null;
|
|
if (baseGraphData && targetGraphData) {
|
|
const highlightNodesBase = new Map();
|
|
for (const n of diff.removedNodes) highlightNodesBase.set(n.id, "removed");
|
|
for (const n of diff.modifiedNodes) highlightNodesBase.set(n.id, "modified");
|
|
|
|
const highlightNodesTarget = new Map();
|
|
for (const n of diff.addedNodes) highlightNodesTarget.set(n.id, "added");
|
|
for (const n of diff.modifiedNodes) highlightNodesTarget.set(n.id, "modified");
|
|
|
|
const svgOpts = { width: 330, height: 220, showLabels: true, showLinks: true, showSlots: false, showGroups: true };
|
|
const baseSvg = renderGraphSVG(baseGraphData, { ...svgOpts, highlightNodes: highlightNodesBase });
|
|
const targetSvg = renderGraphSVG(targetGraphData, { ...svgOpts, highlightNodes: highlightNodesTarget });
|
|
|
|
if (baseSvg || targetSvg) {
|
|
svgCompare = document.createElement("div");
|
|
svgCompare.className = "snap-diff-svg-compare";
|
|
|
|
const basePanel = document.createElement("div");
|
|
basePanel.className = "snap-diff-svg-panel";
|
|
const baseLbl = document.createElement("div");
|
|
baseLbl.className = "snap-diff-svg-panel-label";
|
|
baseLbl.textContent = "Base";
|
|
basePanel.appendChild(baseLbl);
|
|
if (baseSvg) basePanel.appendChild(baseSvg);
|
|
|
|
const targetPanel = document.createElement("div");
|
|
targetPanel.className = "snap-diff-svg-panel";
|
|
const targetLbl = document.createElement("div");
|
|
targetLbl.className = "snap-diff-svg-panel-label";
|
|
targetLbl.textContent = "Target";
|
|
targetPanel.appendChild(targetLbl);
|
|
if (targetSvg) targetPanel.appendChild(targetSvg);
|
|
|
|
svgCompare.appendChild(basePanel);
|
|
svgCompare.appendChild(targetPanel);
|
|
}
|
|
}
|
|
|
|
modal.appendChild(hdr);
|
|
modal.appendChild(summaryBar);
|
|
if (svgCompare) modal.appendChild(svgCompare);
|
|
modal.appendChild(body);
|
|
overlay.appendChild(modal);
|
|
document.body.appendChild(overlay);
|
|
|
|
function dismiss() {
|
|
overlay.remove();
|
|
document.removeEventListener("keydown", onKey);
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.key === "Escape") dismiss();
|
|
}
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) dismiss();
|
|
});
|
|
}
|
|
|
|
// ─── Preview Modal ──────────────────────────────────────────────────
|
|
|
|
async function showPreviewModal(record) {
|
|
if (!record.graphData) {
|
|
const full = await db_getFullRecord(record.workflowKey, record.id);
|
|
if (!full) { showToast("Failed to load snapshot data", "error"); return; }
|
|
record = full;
|
|
}
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "snap-preview-overlay";
|
|
|
|
const modal = document.createElement("div");
|
|
modal.className = "snap-preview-modal";
|
|
|
|
const hdr = document.createElement("div");
|
|
hdr.className = "snap-preview-header";
|
|
const hdrTitle = document.createElement("span");
|
|
hdrTitle.textContent = `${record.label} \u2014 ${formatTime(record.timestamp)}`;
|
|
const closeBtn = document.createElement("button");
|
|
closeBtn.textContent = "\u2715";
|
|
closeBtn.addEventListener("click", dismiss);
|
|
hdr.appendChild(hdrTitle);
|
|
hdr.appendChild(closeBtn);
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "snap-preview-body";
|
|
|
|
if (record.thumbnail) {
|
|
const thumbImg = document.createElement("img");
|
|
thumbImg.src = `data:image/jpeg;base64,${record.thumbnail}`;
|
|
thumbImg.style.cssText = "max-width:100%;max-height:300px;border-radius:6px;margin-bottom:12px;display:block;";
|
|
body.appendChild(thumbImg);
|
|
}
|
|
|
|
const svg = renderGraphSVG(record.graphData, {
|
|
width: 860, height: 600,
|
|
showLabels: true, showLinks: true, showSlots: true, showGroups: true,
|
|
});
|
|
if (svg) {
|
|
body.appendChild(svg);
|
|
} else if (!record.thumbnail) {
|
|
const fallback = document.createElement("div");
|
|
fallback.style.cssText = "color: #666; font-size: 13px; padding: 32px;";
|
|
fallback.textContent = "Unable to render preview";
|
|
body.appendChild(fallback);
|
|
}
|
|
|
|
modal.appendChild(hdr);
|
|
modal.appendChild(body);
|
|
overlay.appendChild(modal);
|
|
document.body.appendChild(overlay);
|
|
|
|
function dismiss() {
|
|
overlay.remove();
|
|
document.removeEventListener("keydown", onKey);
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.key === "Escape") dismiss();
|
|
}
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) dismiss();
|
|
});
|
|
}
|
|
|
|
// ─── Snapshot Capture ────────────────────────────────────────────────
|
|
|
|
let captureInProgress = false;
|
|
|
|
async function captureSnapshot(label = "Auto") {
|
|
if (restoreLock) return false;
|
|
if (captureInProgress) return false;
|
|
captureInProgress = true;
|
|
try { return await _captureSnapshotInner(label); } finally { captureInProgress = false; }
|
|
}
|
|
|
|
async function _captureSnapshotInner(label) {
|
|
|
|
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 prevGraph = lastGraphDataMap.get(workflowKey);
|
|
const changeType = detectChangeType(prevGraph, graphData);
|
|
|
|
// Determine parentId for branching
|
|
let parentId = null;
|
|
if (branchingEnabled) {
|
|
if (activeSnapshotId) {
|
|
parentId = activeSnapshotId; // fork from swapped snapshot
|
|
} else if (lastCapturedIdMap.has(workflowKey)) {
|
|
parentId = lastCapturedIdMap.get(workflowKey); // continuation
|
|
}
|
|
}
|
|
|
|
const record = {
|
|
id: generateId(),
|
|
workflowKey,
|
|
timestamp: Date.now(),
|
|
label,
|
|
nodeCount: nodes.length,
|
|
graphData,
|
|
locked: false,
|
|
changeType,
|
|
parentId,
|
|
};
|
|
|
|
try {
|
|
await db_put(record);
|
|
if (branchingEnabled) {
|
|
// Compute protected IDs: ancestors of this capture + fork points + ancestors of locked snapshots
|
|
const allRecs = await db_getAllForWorkflow(workflowKey);
|
|
const tempTree = buildSnapshotTree(allRecs);
|
|
const protectedIds = getAncestorIds(record.id, tempTree.parentOf);
|
|
// Protect fork points (snapshots with >1 child)
|
|
for (const [pid, children] of tempTree.childrenOf) {
|
|
if (children.length > 1) protectedIds.add(pid);
|
|
}
|
|
// Protect ancestors of locked snapshots to prevent orphan branches
|
|
for (const rec of allRecs) {
|
|
if (rec.locked) {
|
|
for (const aid of getAncestorIds(rec.id, tempTree.parentOf)) {
|
|
protectedIds.add(aid);
|
|
}
|
|
}
|
|
}
|
|
protectedIds.add(record.id); // protect the just-captured snapshot
|
|
await pruneSnapshots(workflowKey, [...protectedIds]);
|
|
} else {
|
|
await pruneSnapshots(workflowKey);
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
lastCapturedHashMap.set(workflowKey, hash);
|
|
lastGraphDataMap.set(workflowKey, graphData);
|
|
lastCapturedIdMap.set(workflowKey, record.id);
|
|
pickerDirty = true;
|
|
currentSnapshotId = null; // new capture supersedes "current" bookmark
|
|
activeSnapshotId = null; // graph has changed, no snapshot is "active"
|
|
|
|
if (sidebarRefresh) {
|
|
sidebarRefresh().catch(() => {});
|
|
}
|
|
if (timelineRefresh) {
|
|
timelineRefresh().catch(() => {});
|
|
}
|
|
return record.id;
|
|
}
|
|
|
|
async function captureNodeSnapshot(label = "Node Trigger", thumbnail = null) {
|
|
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 prevGraph = lastGraphDataMap.get(workflowKey);
|
|
const changeType = detectChangeType(prevGraph, graphData);
|
|
|
|
// Determine parentId for branching
|
|
let parentId = null;
|
|
if (branchingEnabled) {
|
|
if (activeSnapshotId) {
|
|
parentId = activeSnapshotId;
|
|
} else if (lastCapturedIdMap.has(workflowKey)) {
|
|
parentId = lastCapturedIdMap.get(workflowKey);
|
|
}
|
|
}
|
|
|
|
const record = {
|
|
id: generateId(),
|
|
workflowKey,
|
|
timestamp: Date.now(),
|
|
label,
|
|
nodeCount: nodes.length,
|
|
graphData,
|
|
locked: false,
|
|
source: "node",
|
|
changeType,
|
|
parentId,
|
|
...(thumbnail ? { thumbnail } : {}),
|
|
};
|
|
|
|
try {
|
|
await db_put(record);
|
|
if (branchingEnabled) {
|
|
// Compute protected IDs: ancestors + fork points + ancestors of locked snapshots
|
|
const allRecs = await db_getAllForWorkflow(workflowKey);
|
|
const tempTree = buildSnapshotTree(allRecs);
|
|
const protectedNodeIds = getAncestorIds(record.id, tempTree.parentOf);
|
|
for (const [pid, children] of tempTree.childrenOf) {
|
|
if (children.length > 1) protectedNodeIds.add(pid);
|
|
}
|
|
for (const rec of allRecs) {
|
|
if (rec.locked) {
|
|
for (const aid of getAncestorIds(rec.id, tempTree.parentOf)) {
|
|
protectedNodeIds.add(aid);
|
|
}
|
|
}
|
|
}
|
|
protectedNodeIds.add(record.id);
|
|
await pruneNodeSnapshots(workflowKey, [...protectedNodeIds]);
|
|
} else {
|
|
await pruneNodeSnapshots(workflowKey);
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
lastGraphDataMap.set(workflowKey, graphData);
|
|
lastCapturedIdMap.set(workflowKey, record.id);
|
|
pickerDirty = true;
|
|
currentSnapshotId = null;
|
|
activeSnapshotId = null;
|
|
|
|
if (sidebarRefresh) {
|
|
sidebarRefresh().catch(() => {});
|
|
}
|
|
if (timelineRefresh) {
|
|
timelineRefresh().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) {
|
|
if (!record.graphData) {
|
|
const full = await db_getFullRecord(record.workflowKey, record.id);
|
|
if (!full) { showToast("Failed to load snapshot data", "error"); return; }
|
|
record = full;
|
|
}
|
|
await withRestoreLock(async () => {
|
|
if (!validateSnapshotData(record.graphData)) {
|
|
showToast("Invalid snapshot data", "error");
|
|
return;
|
|
}
|
|
try {
|
|
await app.loadGraphData(record.graphData, true, true);
|
|
const wfKey = getWorkflowKey();
|
|
lastCapturedHashMap.set(wfKey, quickHash(JSON.stringify(record.graphData)));
|
|
lastGraphDataMap.set(wfKey, 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) {
|
|
// Warn when swapping in a snapshot from a different workflow
|
|
const currentKey = getWorkflowKey();
|
|
if (record.workflowKey && record.workflowKey !== currentKey) {
|
|
const confirmed = await showConfirmDialog(
|
|
`This snapshot belongs to a different workflow ("${record.workflowKey}").\nSwap it into the current workflow anyway?`
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
|
|
// Auto-save current state before swapping (so user can get back),
|
|
// but skip if the graph is already a saved snapshot (browsing between old ones)
|
|
const prevCurrentId = currentSnapshotId;
|
|
if (!activeSnapshotId) {
|
|
const capturedId = await captureSnapshot("Current");
|
|
currentSnapshotId = capturedId || prevCurrentId;
|
|
}
|
|
|
|
if (!record.graphData) {
|
|
const full = await db_getFullRecord(record.workflowKey, record.id);
|
|
if (!full) { showToast("Failed to load snapshot data", "error"); return; }
|
|
record = full;
|
|
}
|
|
|
|
await withRestoreLock(async () => {
|
|
if (!validateSnapshotData(record.graphData)) {
|
|
showToast("Invalid snapshot data", "error");
|
|
return;
|
|
}
|
|
try {
|
|
const workflow = app.extensionManager?.workflow?.activeWorkflow;
|
|
await app.loadGraphData(record.graphData, true, true, workflow);
|
|
const wfKey = getWorkflowKey();
|
|
lastCapturedHashMap.set(wfKey, quickHash(JSON.stringify(record.graphData)));
|
|
lastGraphDataMap.set(wfKey, record.graphData);
|
|
activeSnapshotId = record.id;
|
|
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-filter-auto-btn {
|
|
background: none;
|
|
border: 1px solid var(--border-color, #555);
|
|
color: var(--descrip-text, #888);
|
|
cursor: pointer;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 3px 6px;
|
|
border-radius: 4px;
|
|
white-space: nowrap;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
|
}
|
|
.snap-filter-auto-btn:hover {
|
|
border-color: var(--descrip-text, #888);
|
|
color: var(--input-text, #ccc);
|
|
}
|
|
.snap-filter-auto-btn.active {
|
|
background: rgba(59, 130, 246, 0.15);
|
|
border-color: #3b82f6;
|
|
color: #3b82f6;
|
|
}
|
|
.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-thumb {
|
|
width: 40px;
|
|
height: 30px;
|
|
object-fit: cover;
|
|
border-radius: 3px;
|
|
flex-shrink: 0;
|
|
}
|
|
.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-label-input {
|
|
width: 100%;
|
|
padding: 2px 6px;
|
|
border: 1px solid var(--border-color, #444);
|
|
border-radius: 4px;
|
|
background: var(--comfy-menu-bg, #2a2a2a);
|
|
color: var(--input-text, #ccc);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
}
|
|
.snap-btn-note {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
padding: 3px 4px;
|
|
color: var(--descrip-text, #888);
|
|
opacity: 0.5;
|
|
transition: opacity 0.15s, color 0.15s;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.snap-btn-note:hover {
|
|
opacity: 1;
|
|
}
|
|
.snap-btn-note.has-note {
|
|
opacity: 1;
|
|
color: #f59e0b;
|
|
}
|
|
.snap-item-notes {
|
|
font-size: 10px;
|
|
font-style: italic;
|
|
color: var(--descrip-text, #888);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 100%;
|
|
}
|
|
.snap-item-notes-input {
|
|
width: 100%;
|
|
padding: 4px 6px;
|
|
border: 1px solid var(--border-color, #444);
|
|
border-radius: 4px;
|
|
background: var(--comfy-menu-bg, #2a2a2a);
|
|
color: var(--input-text, #ccc);
|
|
font-size: 11px;
|
|
outline: none;
|
|
resize: vertical;
|
|
min-height: 32px;
|
|
max-height: 80px;
|
|
box-sizing: border-box;
|
|
font-family: inherit;
|
|
}
|
|
.snap-item-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
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-lock {
|
|
background: var(--comfy-menu-bg, #444);
|
|
color: var(--descrip-text, #aaa);
|
|
font-size: 13px;
|
|
min-width: 28px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.snap-btn-lock.snap-btn-locked {
|
|
background: #2563eb;
|
|
color: #fff;
|
|
}
|
|
.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-item-node {
|
|
border-left: 3px solid #6d28d9;
|
|
}
|
|
.snap-item-active {
|
|
background: rgba(255,255,255,0.06);
|
|
border-left: 3px solid #fff;
|
|
}
|
|
.snap-item-current {
|
|
background: rgba(16,185,129,0.06);
|
|
border-left: 3px solid #10b981;
|
|
}
|
|
.snap-node-badge {
|
|
display: inline-block;
|
|
font-size: 9px;
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
background: #6d28d9;
|
|
color: #fff;
|
|
margin-left: 6px;
|
|
vertical-align: middle;
|
|
font-weight: 500;
|
|
}
|
|
.snap-empty {
|
|
padding: 20px;
|
|
text-align: center;
|
|
color: var(--descrip-text, #666);
|
|
font-size: 12px;
|
|
}
|
|
.snap-workflow-selector {
|
|
padding: 6px 10px;
|
|
border-bottom: 1px solid var(--border-color, #444);
|
|
display: flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
user-select: none;
|
|
}
|
|
.snap-workflow-selector:hover {
|
|
background: var(--comfy-menu-bg, #2a2a2a);
|
|
}
|
|
.snap-workflow-selector.snap-viewing-other {
|
|
border-left: 3px solid #f59e0b;
|
|
padding-left: 7px;
|
|
}
|
|
.snap-workflow-selector-label {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 12px;
|
|
color: var(--descrip-text, #888);
|
|
}
|
|
.snap-workflow-selector-arrow {
|
|
font-size: 10px;
|
|
color: var(--descrip-text, #888);
|
|
flex-shrink: 0;
|
|
transition: transform 0.15s;
|
|
}
|
|
.snap-workflow-selector-arrow.expanded {
|
|
transform: rotate(180deg);
|
|
}
|
|
.snap-workflow-list {
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.15s ease-out;
|
|
}
|
|
.snap-workflow-list.expanded {
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
border-bottom: 1px solid var(--border-color, #444);
|
|
}
|
|
.snap-workflow-item {
|
|
padding: 4px 10px 4px 18px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
color: var(--input-text, #ccc);
|
|
}
|
|
.snap-workflow-item:hover {
|
|
background: var(--comfy-menu-bg, #2a2a2a);
|
|
}
|
|
.snap-workflow-item.active {
|
|
font-weight: 700;
|
|
color: #3b82f6;
|
|
}
|
|
.snap-workflow-item-name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.snap-workflow-item-count {
|
|
flex-shrink: 0;
|
|
font-size: 11px;
|
|
color: var(--descrip-text, #888);
|
|
}
|
|
.snap-workflow-del-btn {
|
|
flex-shrink: 0;
|
|
margin-left: 6px;
|
|
padding: 0 4px;
|
|
font-size: 14px;
|
|
line-height: 1;
|
|
border: none;
|
|
border-radius: 3px;
|
|
background: none;
|
|
color: var(--descrip-text, #888);
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.snap-workflow-item:hover .snap-workflow-del-btn {
|
|
opacity: 1;
|
|
}
|
|
.snap-workflow-del-btn:hover {
|
|
color: #ef4444;
|
|
background: rgba(239, 68, 68, 0.15);
|
|
}
|
|
.snap-workflow-viewing-banner {
|
|
padding: 5px 10px;
|
|
border-bottom: 1px solid var(--border-color, #444);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: rgba(245, 158, 11, 0.1);
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
}
|
|
.snap-workflow-viewing-banner span {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: #f59e0b;
|
|
}
|
|
.snap-workflow-viewing-banner button {
|
|
padding: 2px 8px;
|
|
border: 1px solid #f59e0b;
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
color: #f59e0b;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.snap-workflow-viewing-banner button:hover {
|
|
background: rgba(245, 158, 11, 0.2);
|
|
}
|
|
.snap-timeline {
|
|
position: absolute;
|
|
bottom: 4px;
|
|
left: 10%;
|
|
right: 10%;
|
|
height: 38px;
|
|
background: rgba(15, 23, 42, 0.85);
|
|
border: 1px solid var(--border-color, #334155);
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
z-index: 9;
|
|
pointer-events: auto;
|
|
}
|
|
.snap-timeline-track {
|
|
flex: 1;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
overflow-x: auto;
|
|
}
|
|
.snap-timeline-marker {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
transition: transform 0.1s, box-shadow 0.1s;
|
|
border: 2px solid transparent;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--snap-marker-color, #3b82f6);
|
|
}
|
|
.snap-timeline-marker svg {
|
|
display: block;
|
|
color: #fff;
|
|
pointer-events: none;
|
|
}
|
|
.snap-timeline-marker:hover {
|
|
transform: scale(1.4);
|
|
box-shadow: 0 0 8px var(--snap-marker-color, rgba(59,130,246,0.6));
|
|
}
|
|
.snap-timeline-marker-node {
|
|
background: #6d28d9;
|
|
}
|
|
.snap-timeline-marker-node:hover {
|
|
box-shadow: 0 0 6px rgba(109, 40, 217, 0.6);
|
|
}
|
|
.snap-timeline-marker-locked {
|
|
border-color: #facc15;
|
|
}
|
|
.snap-timeline-marker-active {
|
|
border-color: #fff;
|
|
transform: scale(1.3);
|
|
}
|
|
.snap-timeline-marker-active:hover {
|
|
transform: scale(1.5);
|
|
}
|
|
.snap-timeline-marker-current {
|
|
background: #10b981;
|
|
}
|
|
.snap-timeline-marker-current:hover {
|
|
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
|
|
}
|
|
.snap-timeline-marker-latest {
|
|
box-shadow: 0 0 0 2px rgba(250, 204, 21, 0.5);
|
|
}
|
|
.snap-timeline-marker-latest:hover {
|
|
box-shadow: 0 0 6px rgba(250, 204, 21, 0.6);
|
|
}
|
|
.snap-timeline-snap-btn {
|
|
background: none;
|
|
border: 1px solid var(--descrip-text, #64748b);
|
|
color: var(--descrip-text, #94a3b8);
|
|
border-radius: 4px;
|
|
padding: 2px 8px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
margin-left: 8px;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
font-family: system-ui, sans-serif;
|
|
}
|
|
.snap-timeline-snap-btn:hover {
|
|
border-color: #3b82f6;
|
|
color: #3b82f6;
|
|
}
|
|
.snap-timeline-empty {
|
|
color: var(--descrip-text, #64748b);
|
|
font-size: 11px;
|
|
font-family: system-ui, sans-serif;
|
|
line-height: 32px;
|
|
}
|
|
.snap-timeline-fork-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
height: 100%;
|
|
}
|
|
.snap-timeline-branch-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #3b82f6;
|
|
font-size: 8px;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
margin: 0;
|
|
line-height: 1;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
width: 18px;
|
|
flex: 1;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
opacity: 0.7;
|
|
transition: opacity 0.1s, background 0.1s;
|
|
}
|
|
.snap-timeline-branch-btn:last-child {
|
|
align-items: flex-end;
|
|
}
|
|
.snap-timeline-branch-btn:hover {
|
|
opacity: 1;
|
|
background: rgba(59, 130, 246, 0.2);
|
|
}
|
|
.snap-timeline-expand-btn {
|
|
font-size: 13px;
|
|
padding: 2px 6px;
|
|
line-height: 1;
|
|
}
|
|
.snap-timeline-expanded {
|
|
height: auto;
|
|
align-items: flex-start;
|
|
padding: 8px 16px;
|
|
}
|
|
.snap-timeline-expanded .snap-timeline-track {
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
height: auto;
|
|
max-height: 180px;
|
|
overflow-y: auto;
|
|
align-items: stretch;
|
|
}
|
|
.snap-timeline-branch-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 3px 6px;
|
|
border-radius: 4px;
|
|
min-height: 24px;
|
|
border-left: 2px solid transparent;
|
|
}
|
|
.snap-timeline-branch-row-active {
|
|
background: rgba(59, 130, 246, 0.12);
|
|
border-left-color: #3b82f6;
|
|
}
|
|
.snap-diff-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.6);
|
|
z-index: 10000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.snap-diff-modal {
|
|
width: min(720px, 90vw);
|
|
max-height: 80vh;
|
|
background: #1e1e2e;
|
|
border: 1px solid #444;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
font-size: 13px;
|
|
color: #ccc;
|
|
}
|
|
.snap-diff-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 10px 14px;
|
|
border-bottom: 1px solid #444;
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
}
|
|
.snap-diff-header button {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
.snap-diff-header button:hover {
|
|
background: #333;
|
|
color: #fff;
|
|
}
|
|
.snap-diff-summary {
|
|
display: flex;
|
|
gap: 6px;
|
|
padding: 8px 14px;
|
|
flex-wrap: wrap;
|
|
border-bottom: 1px solid #333;
|
|
}
|
|
.snap-diff-body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px 14px 14px;
|
|
}
|
|
.snap-diff-section {
|
|
margin-bottom: 8px;
|
|
}
|
|
.snap-diff-section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
padding: 6px 4px;
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
color: #aaa;
|
|
user-select: none;
|
|
border-radius: 4px;
|
|
}
|
|
.snap-diff-section-header:hover {
|
|
background: #2a2a3a;
|
|
}
|
|
.snap-diff-section-arrow {
|
|
font-size: 10px;
|
|
width: 14px;
|
|
text-align: center;
|
|
}
|
|
.snap-diff-section-body {
|
|
padding-left: 20px;
|
|
}
|
|
.snap-diff-node-entry {
|
|
padding: 4px 6px;
|
|
margin: 2px 0;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
}
|
|
.snap-diff-node-entry.snap-diff-added {
|
|
color: #22c55e;
|
|
background: rgba(34,197,94,0.08);
|
|
}
|
|
.snap-diff-node-entry.snap-diff-removed {
|
|
color: #dc2626;
|
|
background: rgba(220,38,38,0.08);
|
|
}
|
|
.snap-diff-node-entry.snap-diff-neutral {
|
|
color: #f59e0b;
|
|
background: rgba(245,158,11,0.06);
|
|
}
|
|
.snap-diff-change-detail {
|
|
padding: 2px 0 2px 16px;
|
|
font-size: 11px;
|
|
color: #999;
|
|
}
|
|
.snap-diff-val-old {
|
|
color: #dc2626;
|
|
text-decoration: line-through;
|
|
}
|
|
.snap-diff-val-new {
|
|
color: #22c55e;
|
|
}
|
|
.snap-diff-link-entry {
|
|
padding: 3px 6px;
|
|
margin: 2px 0;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
}
|
|
.snap-diff-link-entry.snap-diff-added {
|
|
color: #22c55e;
|
|
background: rgba(34,197,94,0.08);
|
|
}
|
|
.snap-diff-link-entry.snap-diff-removed {
|
|
color: #dc2626;
|
|
background: rgba(220,38,38,0.08);
|
|
}
|
|
.snap-diff-empty {
|
|
text-align: center;
|
|
padding: 32px 16px;
|
|
color: #666;
|
|
font-size: 13px;
|
|
}
|
|
.snap-item.snap-diff-base {
|
|
outline: 2px solid #6d28d9;
|
|
outline-offset: -2px;
|
|
border-radius: 4px;
|
|
}
|
|
.snap-btn-diff {
|
|
background: #6d28d9;
|
|
color: #fff;
|
|
}
|
|
.snap-btn-diff:hover:not(:disabled) {
|
|
background: #5b21b6;
|
|
}
|
|
.snap-btn-diff.snap-diff-base-active {
|
|
box-shadow: 0 0 6px rgba(109,40,217,0.6);
|
|
}
|
|
.snap-preview-tooltip {
|
|
position: fixed;
|
|
z-index: 10001;
|
|
pointer-events: none;
|
|
background: #1e1e2e;
|
|
border: 1px solid #444;
|
|
border-radius: 6px;
|
|
padding: 6px;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.snap-preview-tooltip.visible {
|
|
opacity: 1;
|
|
}
|
|
.snap-preview-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.6);
|
|
z-index: 10000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.snap-preview-modal {
|
|
width: min(900px, 90vw);
|
|
max-height: 90vh;
|
|
background: #1e1e2e;
|
|
border: 1px solid #444;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
font-size: 13px;
|
|
color: #ccc;
|
|
}
|
|
.snap-preview-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 10px 14px;
|
|
border-bottom: 1px solid #444;
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
}
|
|
.snap-preview-header button {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
.snap-preview-header button:hover {
|
|
background: #333;
|
|
color: #fff;
|
|
}
|
|
.snap-preview-body {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: auto;
|
|
padding: 16px;
|
|
}
|
|
.snap-diff-svg-compare {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 8px 14px;
|
|
border-bottom: 1px solid #333;
|
|
justify-content: center;
|
|
}
|
|
.snap-diff-svg-panel {
|
|
flex: 1;
|
|
text-align: center;
|
|
}
|
|
.snap-diff-svg-panel-label {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: #888;
|
|
margin-bottom: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.snap-btn-preview {
|
|
background: #334155;
|
|
color: #fff;
|
|
font-size: 13px;
|
|
min-width: 28px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.snap-btn-preview:hover:not(:disabled) {
|
|
background: #475569;
|
|
}
|
|
.snap-branch-nav {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 3px 10px;
|
|
background: rgba(59, 130, 246, 0.08);
|
|
border-bottom: 1px solid var(--border-color, #333);
|
|
user-select: none;
|
|
}
|
|
.snap-branch-nav button {
|
|
background: none;
|
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
color: #3b82f6;
|
|
border-radius: 3px;
|
|
width: 22px;
|
|
height: 20px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
line-height: 1;
|
|
}
|
|
.snap-branch-nav button:hover {
|
|
background: rgba(59, 130, 246, 0.15);
|
|
border-color: #3b82f6;
|
|
}
|
|
.snap-branch-nav-label {
|
|
font-size: 11px;
|
|
color: #3b82f6;
|
|
font-weight: 600;
|
|
min-width: 30px;
|
|
text-align: center;
|
|
}
|
|
.snap-profiles {
|
|
border-bottom: 1px solid var(--border-color, #444);
|
|
flex-shrink: 0;
|
|
}
|
|
.snap-profiles-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 6px 10px;
|
|
cursor: pointer;
|
|
gap: 6px;
|
|
user-select: none;
|
|
}
|
|
.snap-profiles-header:hover {
|
|
background: var(--comfy-menu-bg, #2a2a2a);
|
|
}
|
|
.snap-profiles-arrow {
|
|
font-size: 10px;
|
|
color: var(--descrip-text, #888);
|
|
flex-shrink: 0;
|
|
transition: transform 0.15s;
|
|
}
|
|
.snap-profiles-arrow.expanded {
|
|
transform: rotate(90deg);
|
|
}
|
|
.snap-profiles-title {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--descrip-text, #888);
|
|
}
|
|
.snap-profiles-save-btn {
|
|
padding: 2px 8px;
|
|
border: 1px solid #3b82f6;
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
color: #3b82f6;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.snap-profiles-save-btn:hover {
|
|
background: rgba(59, 130, 246, 0.15);
|
|
}
|
|
.snap-profiles-body {
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.15s ease-out;
|
|
}
|
|
.snap-profiles-body.expanded {
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
.snap-profile-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 4px 10px 4px 18px;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
}
|
|
.snap-profile-item:hover {
|
|
background: var(--comfy-menu-bg, #2a2a2a);
|
|
}
|
|
.snap-profile-name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--input-text, #ccc);
|
|
}
|
|
.snap-profile-count {
|
|
font-size: 10px;
|
|
color: var(--descrip-text, #888);
|
|
flex-shrink: 0;
|
|
}
|
|
.snap-profile-load-btn {
|
|
padding: 2px 8px;
|
|
border: none;
|
|
border-radius: 3px;
|
|
background: #22c55e;
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
.snap-profile-load-btn:hover {
|
|
background: #16a34a;
|
|
}
|
|
.snap-profile-delete-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--descrip-text, #888);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
padding: 2px 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
.snap-profile-delete-btn:hover {
|
|
color: #dc2626;
|
|
}
|
|
.snap-profiles-empty {
|
|
padding: 6px 18px;
|
|
font-size: 11px;
|
|
color: var(--descrip-text, #888);
|
|
}
|
|
`;
|
|
|
|
const CHANGE_TYPE_ICONS = {
|
|
initial: {
|
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="currentColor"/></svg>',
|
|
color: "#3b82f6",
|
|
label: "Initial snapshot",
|
|
},
|
|
node_add: {
|
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
|
|
color: "#22c55e",
|
|
label: "Nodes added",
|
|
},
|
|
node_remove: {
|
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
|
|
color: "#ef4444",
|
|
label: "Nodes removed",
|
|
},
|
|
connection: {
|
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M1 9L4 3L8 9L11 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
|
|
color: "#f59e0b",
|
|
label: "Connections changed",
|
|
},
|
|
param: {
|
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M0 6Q3 2 6 6Q9 10 12 6" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>',
|
|
color: "#a78bfa",
|
|
label: "Parameters changed",
|
|
},
|
|
move: {
|
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 1L3 4h6L6 1ZM6 11L3 8h6L6 11Z" fill="currentColor"/></svg>',
|
|
color: "#64748b",
|
|
label: "Nodes repositioned",
|
|
},
|
|
mixed: {
|
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 1L7.5 4.5H11L8.25 6.75L9.5 10.5L6 8L2.5 10.5L3.75 6.75L1 4.5H4.5Z" fill="currentColor"/></svg>',
|
|
color: "#f97316",
|
|
label: "Multiple changes",
|
|
},
|
|
unknown: {
|
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><circle cx="6" cy="6" r="3" fill="currentColor" opacity="0.5"/></svg>',
|
|
color: "#6b7280",
|
|
label: "Unknown change",
|
|
},
|
|
};
|
|
|
|
function injectStyles() {
|
|
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" });
|
|
}
|
|
|
|
function buildBranchNavigator(forkPointId, children, selectedIndex, refreshFn) {
|
|
const nav = document.createElement("div");
|
|
nav.className = "snap-branch-nav";
|
|
|
|
const leftBtn = document.createElement("button");
|
|
leftBtn.textContent = "\u25C0";
|
|
leftBtn.title = "Previous branch";
|
|
leftBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const newIndex = Math.max(0, selectedIndex - 1);
|
|
activeBranchSelections.set(forkPointId, newIndex);
|
|
refreshFn();
|
|
if (timelineRefresh) timelineRefresh().catch(() => {});
|
|
});
|
|
if (selectedIndex <= 0) leftBtn.style.visibility = "hidden";
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "snap-branch-nav-label";
|
|
label.textContent = `${selectedIndex + 1}/${children.length}`;
|
|
|
|
const rightBtn = document.createElement("button");
|
|
rightBtn.textContent = "\u25B6";
|
|
rightBtn.title = "Next branch";
|
|
rightBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const newIndex = Math.min(children.length - 1, selectedIndex + 1);
|
|
activeBranchSelections.set(forkPointId, newIndex);
|
|
refreshFn();
|
|
if (timelineRefresh) timelineRefresh().catch(() => {});
|
|
});
|
|
if (selectedIndex >= children.length - 1) rightBtn.style.visibility = "hidden";
|
|
|
|
nav.appendChild(leftBtn);
|
|
nav.appendChild(label);
|
|
nav.appendChild(rightBtn);
|
|
return nav;
|
|
}
|
|
|
|
async function buildSidebar(el) {
|
|
injectStyles();
|
|
// Clean up previous tooltip if sidebar is being rebuilt
|
|
if (sidebarTooltipEl) {
|
|
sidebarTooltipEl.remove();
|
|
sidebarTooltipEl = null;
|
|
}
|
|
el.innerHTML = "";
|
|
|
|
const container = document.createElement("div");
|
|
container.className = "snap-sidebar";
|
|
|
|
// Shared hover tooltip
|
|
const tooltip = document.createElement("div");
|
|
tooltip.className = "snap-preview-tooltip";
|
|
document.body.appendChild(tooltip);
|
|
let tooltipTimer = null;
|
|
|
|
// 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 {
|
|
const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== getWorkflowKey();
|
|
takeBtn.disabled = isViewingOther;
|
|
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());
|
|
});
|
|
|
|
let hideAutoSaves = false;
|
|
|
|
const autoFilterBtn = document.createElement("button");
|
|
autoFilterBtn.className = "snap-filter-auto-btn";
|
|
autoFilterBtn.textContent = "Hide Auto";
|
|
autoFilterBtn.title = "Hide auto-save snapshots";
|
|
autoFilterBtn.addEventListener("click", () => {
|
|
hideAutoSaves = !hideAutoSaves;
|
|
autoFilterBtn.classList.toggle("active", hideAutoSaves);
|
|
autoFilterBtn.textContent = hideAutoSaves ? "Show Auto" : "Hide Auto";
|
|
filterItems(searchInput.value.toLowerCase());
|
|
});
|
|
|
|
const branchToggleBtn = document.createElement("button");
|
|
branchToggleBtn.className = "snap-filter-auto-btn" + (branchingEnabled ? " active" : "");
|
|
branchToggleBtn.textContent = "Branch";
|
|
branchToggleBtn.title = "Toggle snapshot branching";
|
|
branchToggleBtn.addEventListener("click", async () => {
|
|
branchingEnabled = !branchingEnabled;
|
|
localStorage.setItem("snapshotManager_branchingEnabled", branchingEnabled);
|
|
branchToggleBtn.classList.toggle("active", branchingEnabled);
|
|
activeBranchSelections.clear();
|
|
if (sidebarRefresh) await sidebarRefresh().catch(() => {});
|
|
if (timelineRefresh) await timelineRefresh().catch(() => {});
|
|
});
|
|
|
|
searchRow.appendChild(searchInput);
|
|
searchRow.appendChild(searchClear);
|
|
searchRow.appendChild(autoFilterBtn);
|
|
searchRow.appendChild(branchToggleBtn);
|
|
|
|
// Workflow selector
|
|
const selectorRow = document.createElement("div");
|
|
selectorRow.className = "snap-workflow-selector";
|
|
|
|
const selectorLabel = document.createElement("span");
|
|
selectorLabel.className = "snap-workflow-selector-label";
|
|
selectorLabel.textContent = getWorkflowKey();
|
|
|
|
const selectorArrow = document.createElement("span");
|
|
selectorArrow.className = "snap-workflow-selector-arrow";
|
|
selectorArrow.textContent = "\u25BC";
|
|
|
|
selectorRow.appendChild(selectorLabel);
|
|
selectorRow.appendChild(selectorArrow);
|
|
|
|
// Workflow picker list (expandable)
|
|
const pickerList = document.createElement("div");
|
|
pickerList.className = "snap-workflow-list";
|
|
let pickerExpanded = false;
|
|
|
|
async function populatePicker() {
|
|
pickerList.innerHTML = "";
|
|
const keys = await db_getAllWorkflowKeys();
|
|
const currentKey = getWorkflowKey();
|
|
const effectiveKey = viewingWorkflowKey ?? currentKey;
|
|
|
|
if (keys.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.style.cssText = "padding: 6px 18px; font-size: 11px; color: var(--descrip-text, #888);";
|
|
empty.textContent = "No workflows found";
|
|
pickerList.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
for (const entry of keys) {
|
|
const row = document.createElement("div");
|
|
row.className = "snap-workflow-item";
|
|
if (entry.workflowKey === effectiveKey) row.classList.add("active");
|
|
|
|
const nameSpan = document.createElement("span");
|
|
nameSpan.className = "snap-workflow-item-name";
|
|
nameSpan.textContent = entry.workflowKey;
|
|
|
|
const countSpanItem = document.createElement("span");
|
|
countSpanItem.className = "snap-workflow-item-count";
|
|
countSpanItem.textContent = `(${entry.count})`;
|
|
|
|
row.appendChild(nameSpan);
|
|
row.appendChild(countSpanItem);
|
|
|
|
// Delete button — removes all snapshots for this workflow
|
|
const delBtn = document.createElement("button");
|
|
delBtn.className = "snap-workflow-del-btn";
|
|
delBtn.textContent = "\u00D7";
|
|
delBtn.title = `Delete all snapshots for "${entry.workflowKey}"`;
|
|
delBtn.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
const confirmed = await showConfirmDialog(
|
|
`Delete all ${entry.count} snapshot(s) for "${entry.workflowKey}"?`
|
|
);
|
|
if (!confirmed) return;
|
|
try {
|
|
await db_deleteAllForWorkflow(entry.workflowKey);
|
|
// Clear stale in-memory state for this workflow
|
|
lastCapturedIdMap.delete(entry.workflowKey);
|
|
lastCapturedHashMap.delete(entry.workflowKey);
|
|
lastGraphDataMap.delete(entry.workflowKey);
|
|
if (entry.workflowKey === currentKey) {
|
|
activeSnapshotId = null;
|
|
currentSnapshotId = null;
|
|
activeBranchSelections.clear();
|
|
}
|
|
if (viewingWorkflowKey === entry.workflowKey) {
|
|
viewingWorkflowKey = null;
|
|
}
|
|
await populatePicker();
|
|
await refresh(true);
|
|
if (timelineRefresh) timelineRefresh().catch(() => {});
|
|
showToast(`Deleted snapshots for "${entry.workflowKey}"`, "success");
|
|
} catch {}
|
|
});
|
|
row.appendChild(delBtn);
|
|
|
|
row.addEventListener("click", async () => {
|
|
if (entry.workflowKey === currentKey) {
|
|
viewingWorkflowKey = null;
|
|
} else {
|
|
viewingWorkflowKey = entry.workflowKey;
|
|
}
|
|
activeBranchSelections.clear();
|
|
collapsePicker();
|
|
await refresh(true);
|
|
});
|
|
|
|
pickerList.appendChild(row);
|
|
}
|
|
pickerDirty = false;
|
|
}
|
|
|
|
function collapsePicker() {
|
|
pickerExpanded = false;
|
|
pickerList.classList.remove("expanded");
|
|
selectorArrow.classList.remove("expanded");
|
|
}
|
|
|
|
selectorRow.addEventListener("click", async () => {
|
|
pickerExpanded = !pickerExpanded;
|
|
if (pickerExpanded) {
|
|
if (pickerDirty) await populatePicker();
|
|
pickerList.classList.add("expanded");
|
|
selectorArrow.classList.add("expanded");
|
|
} else {
|
|
collapsePicker();
|
|
}
|
|
});
|
|
|
|
// Viewing-other-workflow banner
|
|
const viewingBanner = document.createElement("div");
|
|
viewingBanner.className = "snap-workflow-viewing-banner";
|
|
viewingBanner.style.display = "none";
|
|
|
|
const viewingLabel = document.createElement("span");
|
|
viewingLabel.textContent = "";
|
|
|
|
const backBtn = document.createElement("button");
|
|
backBtn.textContent = "Back to current";
|
|
backBtn.addEventListener("click", async () => {
|
|
viewingWorkflowKey = null;
|
|
await refresh(true);
|
|
});
|
|
|
|
viewingBanner.appendChild(viewingLabel);
|
|
viewingBanner.appendChild(backBtn);
|
|
|
|
// 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 effKey = getEffectiveWorkflowKey();
|
|
const confirmed = await showConfirmDialog(`Delete all snapshots for "${effKey}"?`);
|
|
if (!confirmed) return;
|
|
try {
|
|
const { lockedCount } = await db_deleteAllForWorkflow(effKey);
|
|
// Clear stale in-memory state to prevent orphaned branches
|
|
lastCapturedIdMap.delete(effKey);
|
|
lastCapturedHashMap.delete(effKey);
|
|
lastGraphDataMap.delete(effKey);
|
|
activeSnapshotId = null;
|
|
currentSnapshotId = null;
|
|
activeBranchSelections.clear();
|
|
pickerDirty = true;
|
|
if (lockedCount > 0) {
|
|
showToast(`Cleared snapshots (${lockedCount} locked kept)`, "info");
|
|
} else {
|
|
showToast("All snapshots cleared", "info");
|
|
}
|
|
} catch {
|
|
// db_deleteAllForWorkflow already toasts on error
|
|
}
|
|
await refresh(true);
|
|
if (timelineRefresh) {
|
|
timelineRefresh().catch(() => {});
|
|
}
|
|
});
|
|
footer.appendChild(clearBtn);
|
|
|
|
// ─── Profiles Section ──────────────────────────────────────────
|
|
const profilesSection = document.createElement("div");
|
|
profilesSection.className = "snap-profiles";
|
|
|
|
const profilesHeader = document.createElement("div");
|
|
profilesHeader.className = "snap-profiles-header";
|
|
|
|
const profilesArrow = document.createElement("span");
|
|
profilesArrow.className = "snap-profiles-arrow";
|
|
profilesArrow.textContent = "\u25B6";
|
|
|
|
const profilesTitle = document.createElement("span");
|
|
profilesTitle.className = "snap-profiles-title";
|
|
profilesTitle.textContent = "Profiles";
|
|
|
|
const profilesSaveBtn = document.createElement("button");
|
|
profilesSaveBtn.className = "snap-profiles-save-btn";
|
|
profilesSaveBtn.textContent = "Save";
|
|
profilesSaveBtn.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
const name = await showPromptDialog("Profile name:", "My Profile");
|
|
if (name == null) return;
|
|
const trimmed = name.trim() || "My Profile";
|
|
|
|
// Gather session workflows
|
|
const workflows = [];
|
|
for (const [wk, info] of sessionWorkflows) {
|
|
workflows.push({ workflowKey: wk, displayName: wk });
|
|
}
|
|
if (workflows.length === 0) {
|
|
// At least include current workflow
|
|
const currentKey = getWorkflowKey();
|
|
workflows.push({ workflowKey: currentKey, displayName: currentKey });
|
|
}
|
|
|
|
const profile = {
|
|
id: generateId(),
|
|
name: trimmed,
|
|
timestamp: Date.now(),
|
|
workflows,
|
|
activeWorkflowKey: getWorkflowKey(),
|
|
};
|
|
|
|
try {
|
|
await profile_save(profile);
|
|
showToast(`Profile "${trimmed}" saved (${workflows.length} workflow${workflows.length === 1 ? "" : "s"})`, "success");
|
|
await refreshProfiles();
|
|
} catch {
|
|
// profile_save already toasts
|
|
}
|
|
});
|
|
|
|
profilesHeader.appendChild(profilesArrow);
|
|
profilesHeader.appendChild(profilesTitle);
|
|
profilesHeader.appendChild(profilesSaveBtn);
|
|
|
|
const profilesBody = document.createElement("div");
|
|
profilesBody.className = "snap-profiles-body";
|
|
let profilesExpanded = false;
|
|
|
|
profilesHeader.addEventListener("click", async () => {
|
|
profilesExpanded = !profilesExpanded;
|
|
profilesArrow.classList.toggle("expanded", profilesExpanded);
|
|
profilesBody.classList.toggle("expanded", profilesExpanded);
|
|
if (profilesExpanded) await refreshProfiles();
|
|
});
|
|
|
|
async function refreshProfiles() {
|
|
profilesBody.innerHTML = "";
|
|
const profiles = await profile_list();
|
|
if (profiles.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "snap-profiles-empty";
|
|
empty.textContent = "No saved profiles";
|
|
profilesBody.appendChild(empty);
|
|
return;
|
|
}
|
|
for (const p of profiles) {
|
|
const row = document.createElement("div");
|
|
row.className = "snap-profile-item";
|
|
|
|
const nameSpan = document.createElement("span");
|
|
nameSpan.className = "snap-profile-name";
|
|
nameSpan.textContent = p.name;
|
|
nameSpan.title = p.name;
|
|
|
|
const countSpanP = document.createElement("span");
|
|
countSpanP.className = "snap-profile-count";
|
|
countSpanP.textContent = `${(p.workflows || []).length} wf`;
|
|
|
|
const loadBtn = document.createElement("button");
|
|
loadBtn.className = "snap-profile-load-btn";
|
|
loadBtn.textContent = "Load";
|
|
loadBtn.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
loadBtn.disabled = true;
|
|
loadBtn.textContent = "Loading...";
|
|
try {
|
|
const workflows = p.workflows || [];
|
|
let loaded = 0;
|
|
let skipped = 0;
|
|
// Load non-active workflows first (each overwrites previous —
|
|
// ComfyUI can only display one workflow at a time, but loading
|
|
// them populates the workflow history/tabs in some frontends)
|
|
for (const wf of workflows) {
|
|
// Skip active workflow — loaded last so it ends up visible
|
|
if (wf.workflowKey === p.activeWorkflowKey) continue;
|
|
const records = await db_getAllForWorkflow(wf.workflowKey);
|
|
if (records.length === 0) { skipped++; continue; }
|
|
records.sort((a, b) => b.timestamp - a.timestamp);
|
|
const full = await db_getFullRecord(records[0].workflowKey, records[0].id);
|
|
if (!full || !full.graphData) { skipped++; continue; }
|
|
try {
|
|
await app.loadGraphData(full.graphData, true, true);
|
|
loaded++;
|
|
} catch { skipped++; }
|
|
}
|
|
// Load the active workflow last so it's the one visible
|
|
if (p.activeWorkflowKey) {
|
|
const activeRecs = await db_getAllForWorkflow(p.activeWorkflowKey);
|
|
if (activeRecs.length > 0) {
|
|
activeRecs.sort((a, b) => b.timestamp - a.timestamp);
|
|
const activeFull = await db_getFullRecord(activeRecs[0].workflowKey, activeRecs[0].id);
|
|
if (activeFull?.graphData) {
|
|
try {
|
|
await app.loadGraphData(activeFull.graphData, true, true);
|
|
loaded++;
|
|
} catch { skipped++; }
|
|
} else { skipped++; }
|
|
} else { skipped++; }
|
|
}
|
|
let msg = `Profile "${p.name}" loaded (${loaded} workflow${loaded === 1 ? "" : "s"})`;
|
|
if (skipped > 0) msg += `, ${skipped} skipped`;
|
|
showToast(msg, "success");
|
|
} catch (err) {
|
|
console.warn(`[${EXTENSION_NAME}] Profile load failed:`, err);
|
|
showToast("Failed to load profile", "error");
|
|
} finally {
|
|
loadBtn.disabled = false;
|
|
loadBtn.textContent = "Load";
|
|
}
|
|
});
|
|
|
|
const deleteBtn2 = document.createElement("button");
|
|
deleteBtn2.className = "snap-profile-delete-btn";
|
|
deleteBtn2.textContent = "\u2715";
|
|
deleteBtn2.title = "Delete profile";
|
|
deleteBtn2.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
const confirmed = await showConfirmDialog(`Delete profile "${p.name}"?`);
|
|
if (!confirmed) return;
|
|
await profile_delete(p.id);
|
|
showToast(`Profile "${p.name}" deleted`, "info");
|
|
await refreshProfiles();
|
|
});
|
|
|
|
row.appendChild(nameSpan);
|
|
row.appendChild(countSpanP);
|
|
row.appendChild(loadBtn);
|
|
row.appendChild(deleteBtn2);
|
|
profilesBody.appendChild(row);
|
|
}
|
|
}
|
|
|
|
profilesSection.appendChild(profilesHeader);
|
|
profilesSection.appendChild(profilesBody);
|
|
|
|
container.appendChild(header);
|
|
container.appendChild(selectorRow);
|
|
container.appendChild(pickerList);
|
|
container.appendChild(viewingBanner);
|
|
container.appendChild(profilesSection);
|
|
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 matchesSearch = !term || entry.label.toLowerCase().includes(term) || entry.notes.toLowerCase().includes(term);
|
|
const matchesAutoFilter = !hideAutoSaves || !entry.isAuto;
|
|
entry.element.style.display = (matchesSearch && matchesAutoFilter) ? "" : "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) {
|
|
svgCache.clear();
|
|
// Hide tooltip — items are about to be destroyed so mouseleave won't fire
|
|
if (tooltipTimer) { clearTimeout(tooltipTimer); tooltipTimer = null; }
|
|
tooltip.classList.remove("visible");
|
|
const currentKey = getWorkflowKey();
|
|
const effKey = viewingWorkflowKey ?? currentKey;
|
|
const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== currentKey;
|
|
|
|
const allRecords = await db_getAllForWorkflow(effKey);
|
|
|
|
let nodeCount = 0;
|
|
for (const r of allRecords) if (r.source === "node") nodeCount++;
|
|
const regularCount = allRecords.length - nodeCount;
|
|
countSpan.textContent = nodeCount > 0
|
|
? `${regularCount}/${maxSnapshots} + ${nodeCount}/${maxNodeSnapshots} node`
|
|
: `${regularCount} / ${maxSnapshots}`;
|
|
|
|
// Update selector label and styling
|
|
selectorLabel.textContent = effKey;
|
|
selectorRow.classList.toggle("snap-viewing-other", isViewingOther);
|
|
|
|
// Show/hide viewing banner
|
|
if (isViewingOther) {
|
|
viewingLabel.textContent = `Viewing: ${viewingWorkflowKey}`;
|
|
viewingBanner.style.display = "";
|
|
takeBtn.disabled = true;
|
|
} else {
|
|
viewingBanner.style.display = "none";
|
|
takeBtn.disabled = false;
|
|
}
|
|
|
|
// Mark picker stale; only collapse on user-initiated refreshes
|
|
pickerDirty = true;
|
|
if (resetSearch) {
|
|
collapsePicker();
|
|
searchInput.value = "";
|
|
searchClear.classList.remove("visible");
|
|
}
|
|
|
|
list.innerHTML = "";
|
|
itemEntries = [];
|
|
|
|
if (allRecords.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;
|
|
}
|
|
|
|
let records;
|
|
let tree = null;
|
|
let forkPointIds = new Set();
|
|
if (branchingEnabled) {
|
|
// Build tree and get display path for current branch
|
|
tree = buildSnapshotTree(allRecords);
|
|
const displayPath = getDisplayPath(tree, activeBranchSelections);
|
|
records = [...displayPath].reverse();
|
|
|
|
for (const [parentId, children] of tree.childrenOf) {
|
|
if (children.length > 1) forkPointIds.add(parentId);
|
|
}
|
|
} else {
|
|
// Flat: all records newest-first
|
|
records = [...allRecords].sort((a, b) => b.timestamp - a.timestamp);
|
|
}
|
|
|
|
for (const rec of records) {
|
|
// Insert branch navigator above fork-point snapshots
|
|
if (branchingEnabled && forkPointIds.has(rec.id)) {
|
|
const children = tree.childrenOf.get(rec.id);
|
|
const selectedIndex = Math.min(activeBranchSelections.get(rec.id) ?? 0, children.length - 1);
|
|
const nav = buildBranchNavigator(rec.id, children, selectedIndex, refresh);
|
|
list.appendChild(nav);
|
|
}
|
|
const item = document.createElement("div");
|
|
item.className = rec.source === "node" ? "snap-item snap-item-node" : "snap-item";
|
|
if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) {
|
|
item.classList.add("snap-diff-base");
|
|
}
|
|
if (rec.id === activeSnapshotId) {
|
|
item.classList.add("snap-item-active");
|
|
}
|
|
if (rec.id === currentSnapshotId) {
|
|
item.classList.add("snap-item-current");
|
|
}
|
|
|
|
const info = document.createElement("div");
|
|
info.className = "snap-item-info";
|
|
|
|
const labelDiv = document.createElement("div");
|
|
labelDiv.className = "snap-item-label";
|
|
labelDiv.textContent = rec.label;
|
|
if (rec.source === "node") {
|
|
const badge = document.createElement("span");
|
|
badge.className = "snap-node-badge";
|
|
badge.textContent = "Node";
|
|
labelDiv.appendChild(badge);
|
|
}
|
|
labelDiv.addEventListener("dblclick", (e) => {
|
|
e.stopPropagation();
|
|
const originalLabel = rec.label;
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.className = "snap-item-label-input";
|
|
input.value = originalLabel;
|
|
labelDiv.textContent = "";
|
|
labelDiv.appendChild(input);
|
|
input.select();
|
|
input.focus();
|
|
let committed = false;
|
|
const commit = async () => {
|
|
if (committed) return;
|
|
committed = true;
|
|
const newLabel = input.value.trim() || originalLabel;
|
|
if (newLabel !== originalLabel) {
|
|
rec.label = newLabel;
|
|
await db_updateMeta(rec.workflowKey, rec.id, { label: newLabel });
|
|
await refresh();
|
|
} else {
|
|
labelDiv.textContent = originalLabel;
|
|
if (rec.source === "node") {
|
|
const b = document.createElement("span");
|
|
b.className = "snap-node-badge";
|
|
b.textContent = "Node";
|
|
labelDiv.appendChild(b);
|
|
}
|
|
}
|
|
};
|
|
input.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") { ev.preventDefault(); input.blur(); }
|
|
if (ev.key === "Escape") {
|
|
ev.preventDefault();
|
|
committed = true;
|
|
labelDiv.textContent = originalLabel;
|
|
if (rec.source === "node") {
|
|
const b = document.createElement("span");
|
|
b.className = "snap-node-badge";
|
|
b.textContent = "Node";
|
|
labelDiv.appendChild(b);
|
|
}
|
|
}
|
|
});
|
|
input.addEventListener("blur", commit);
|
|
});
|
|
|
|
const time = document.createElement("div");
|
|
time.className = "snap-item-time";
|
|
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";
|
|
const changeLabel = (CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown).label;
|
|
meta.textContent = `${rec.nodeCount} nodes \u00b7 ${changeLabel}`;
|
|
|
|
const notesDiv = document.createElement("div");
|
|
notesDiv.className = "snap-item-notes";
|
|
if (rec.notes) {
|
|
notesDiv.textContent = rec.notes;
|
|
notesDiv.title = rec.notes;
|
|
} else {
|
|
notesDiv.style.display = "none";
|
|
}
|
|
|
|
info.appendChild(labelDiv);
|
|
info.appendChild(time);
|
|
info.appendChild(date);
|
|
info.appendChild(meta);
|
|
info.appendChild(notesDiv);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "snap-item-actions";
|
|
|
|
const noteBtn = document.createElement("button");
|
|
noteBtn.className = "snap-btn-note" + (rec.notes ? " has-note" : "");
|
|
noteBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M2 11.5V14h2.5L12.06 6.44 9.56 3.94 2 11.5zM14.35 4.15a.67.67 0 000-.94l-1.56-1.56a.67.67 0 00-.94 0L10.5 3l2.5 2.5 1.35-1.35z" fill="currentColor"/></svg>';
|
|
noteBtn.title = rec.notes ? "Edit note" : "Add note";
|
|
noteBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
// Toggle: if textarea already open, close it
|
|
const existing = info.querySelector(".snap-item-notes-input");
|
|
if (existing) { existing.remove(); return; }
|
|
const textarea = document.createElement("textarea");
|
|
textarea.className = "snap-item-notes-input";
|
|
textarea.value = rec.notes || "";
|
|
info.appendChild(textarea);
|
|
textarea.focus();
|
|
let saved = false;
|
|
const saveNote = async () => {
|
|
if (saved) return;
|
|
saved = true;
|
|
const newNotes = textarea.value.trim();
|
|
rec.notes = newNotes || undefined;
|
|
await db_updateMeta(rec.workflowKey, rec.id, { notes: newNotes || null });
|
|
await refresh();
|
|
};
|
|
textarea.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter" && ev.ctrlKey) { ev.preventDefault(); textarea.blur(); }
|
|
if (ev.key === "Escape") { ev.preventDefault(); saved = true; textarea.remove(); }
|
|
});
|
|
textarea.addEventListener("blur", saveNote);
|
|
});
|
|
|
|
const lockBtn = document.createElement("button");
|
|
lockBtn.className = rec.locked ? "snap-btn-lock snap-btn-locked" : "snap-btn-lock";
|
|
lockBtn.innerHTML = rec.locked
|
|
? '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="3" y="7" width="10" height="7" rx="1.5" fill="currentColor"/><path d="M5 7V5a3 3 0 016 0v2" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>'
|
|
: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="3" y="7" width="10" height="7" rx="1.5" fill="currentColor"/><path d="M5 7V5a3 3 0 016 0" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
|
|
lockBtn.title = rec.locked ? "Unlock snapshot" : "Lock snapshot";
|
|
lockBtn.addEventListener("click", async () => {
|
|
rec.locked = !rec.locked;
|
|
await db_updateMeta(rec.workflowKey, rec.id, { locked: rec.locked });
|
|
await refresh();
|
|
});
|
|
|
|
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 () => {
|
|
if (rec.locked) {
|
|
const confirmed = await showConfirmDialog("This snapshot is locked. Delete anyway?");
|
|
if (!confirmed) return;
|
|
}
|
|
// Fork-point deletion: rebuild tree from fresh data, then re-parent children
|
|
if (branchingEnabled) {
|
|
const freshRecords = await db_getAllForWorkflow(rec.workflowKey);
|
|
const freshTree = buildSnapshotTree(freshRecords);
|
|
const children = freshTree.childrenOf.get(rec.id);
|
|
if (children && children.length > 0) {
|
|
const confirmed = await showConfirmDialog(
|
|
`This snapshot is a branch point with ${children.length} child snapshot(s). Deleting it will re-parent them. Continue?`
|
|
);
|
|
if (!confirmed) return;
|
|
const newParent = freshTree.parentOf.get(rec.id) ?? null;
|
|
for (const child of children) {
|
|
await db_updateMeta(rec.workflowKey, child.id, { parentId: newParent });
|
|
}
|
|
}
|
|
}
|
|
await db_delete(rec.workflowKey, rec.id);
|
|
pickerDirty = true;
|
|
await refresh();
|
|
if (timelineRefresh) {
|
|
timelineRefresh().catch(() => {});
|
|
}
|
|
});
|
|
|
|
const diffBtn = document.createElement("button");
|
|
diffBtn.className = "snap-btn-diff" + (diffBaseSnapshot && diffBaseSnapshot.id === rec.id ? " snap-diff-base-active" : "");
|
|
diffBtn.textContent = "Diff";
|
|
diffBtn.title = diffBaseSnapshot && diffBaseSnapshot.id !== rec.id
|
|
? `Compare '${diffBaseSnapshot.label}' vs this snapshot`
|
|
: "Compare vs current workflow (Shift+click to set as base)";
|
|
diffBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (e.shiftKey) {
|
|
// Toggle base selection
|
|
if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) {
|
|
diffBaseSnapshot = null;
|
|
showToast("Diff base cleared", "info");
|
|
} else {
|
|
diffBaseSnapshot = rec;
|
|
showToast(`Diff base set: "${rec.label}"`, "info");
|
|
}
|
|
refresh();
|
|
return;
|
|
}
|
|
// Normal click — async to allow lazy graphData fetch
|
|
(async () => {
|
|
let baseGraph, targetGraph, baseLabel, targetLabel;
|
|
if (diffBaseSnapshot && diffBaseSnapshot.id !== rec.id) {
|
|
// Two-snapshot compare: base vs this
|
|
const baseFull = diffBaseSnapshot.graphData ? diffBaseSnapshot : await db_getFullRecord(diffBaseSnapshot.workflowKey, diffBaseSnapshot.id);
|
|
const targetFull = rec.graphData ? rec : await db_getFullRecord(rec.workflowKey, rec.id);
|
|
baseGraph = (baseFull && baseFull.graphData) || {};
|
|
targetGraph = (targetFull && targetFull.graphData) || {};
|
|
baseLabel = diffBaseSnapshot.label;
|
|
targetLabel = rec.label;
|
|
diffBaseSnapshot = null;
|
|
refresh(); // clear highlight
|
|
} else {
|
|
// Compare this snapshot vs current live workflow
|
|
const full = rec.graphData ? rec : await db_getFullRecord(rec.workflowKey, rec.id);
|
|
baseGraph = (full && full.graphData) || {};
|
|
targetGraph = getGraphData() || {};
|
|
baseLabel = rec.label;
|
|
targetLabel = "Current Workflow";
|
|
}
|
|
const diff = computeDetailedDiff(baseGraph, targetGraph);
|
|
const allNodes = buildNodeLookup(baseGraph, targetGraph);
|
|
showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraph, targetGraph);
|
|
})();
|
|
});
|
|
|
|
const previewBtn = document.createElement("button");
|
|
previewBtn.className = "snap-btn-preview";
|
|
previewBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M8 3C4 3 1.5 8 1.5 8s2.5 5 6.5 5 6.5-5 6.5-5S12 3 8 3z" stroke="currentColor" stroke-width="1.3" fill="none"/><circle cx="8" cy="8" r="2" fill="currentColor"/></svg>';
|
|
previewBtn.title = "Preview workflow graph";
|
|
previewBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
showPreviewModal(rec);
|
|
});
|
|
|
|
actions.appendChild(noteBtn);
|
|
actions.appendChild(previewBtn);
|
|
actions.appendChild(diffBtn);
|
|
actions.appendChild(lockBtn);
|
|
actions.appendChild(swapBtn);
|
|
actions.appendChild(restoreBtn);
|
|
actions.appendChild(deleteBtn);
|
|
|
|
// Hover tooltip
|
|
item.addEventListener("mouseenter", () => {
|
|
tooltipTimer = setTimeout(async () => {
|
|
tooltip.innerHTML = "";
|
|
if (rec.thumbnail) {
|
|
const img = document.createElement("img");
|
|
img.src = `data:image/jpeg;base64,${rec.thumbnail}`;
|
|
img.style.cssText = "max-width:240px;max-height:180px;border-radius:4px;display:block;";
|
|
tooltip.appendChild(img);
|
|
} else {
|
|
const svgCacheKey = `${rec.id}:240x180`;
|
|
let graphData = rec.graphData;
|
|
if (!graphData && !svgCache.has(svgCacheKey)) {
|
|
const full = await db_getFullRecord(rec.workflowKey, rec.id);
|
|
if (!full || !tooltipTimer) return;
|
|
graphData = full.graphData;
|
|
}
|
|
if (!tooltipTimer) return;
|
|
const svg = getCachedSVG(rec.id, graphData, { width: 240, height: 180 });
|
|
if (!svg) return;
|
|
tooltip.appendChild(svg);
|
|
}
|
|
const rect = item.getBoundingClientRect();
|
|
let left = rect.right + 8;
|
|
let top = rect.top;
|
|
// Clamp to viewport
|
|
if (left + 260 > window.innerWidth) left = rect.left - 260;
|
|
if (top + 200 > window.innerHeight) top = window.innerHeight - 200;
|
|
if (top < 0) top = 0;
|
|
tooltip.style.left = `${left}px`;
|
|
tooltip.style.top = `${top}px`;
|
|
tooltip.classList.add("visible");
|
|
}, 200);
|
|
});
|
|
item.addEventListener("mouseleave", () => {
|
|
if (tooltipTimer) { clearTimeout(tooltipTimer); tooltipTimer = null; }
|
|
tooltip.classList.remove("visible");
|
|
});
|
|
|
|
if (rec.thumbnail) {
|
|
const thumb = document.createElement("img");
|
|
thumb.className = "snap-item-thumb";
|
|
thumb.src = `data:image/jpeg;base64,${rec.thumbnail}`;
|
|
item.appendChild(thumb);
|
|
}
|
|
item.appendChild(info);
|
|
item.appendChild(actions);
|
|
list.appendChild(item);
|
|
|
|
itemEntries.push({ element: item, label: rec.label, notes: rec.notes || "", isAuto: rec.label === "Auto" || rec.label === "Initial" });
|
|
}
|
|
|
|
// Re-apply current filters to newly built items
|
|
const currentTerm = searchInput.value.toLowerCase();
|
|
if (currentTerm || hideAutoSaves) {
|
|
filterItems(currentTerm);
|
|
}
|
|
}
|
|
|
|
sidebarRefresh = refresh;
|
|
sidebarTooltipEl = tooltip;
|
|
await refresh(true);
|
|
}
|
|
|
|
// ─── Timeline Bar ────────────────────────────────────────────────────
|
|
|
|
function buildTimeline() {
|
|
// Guard against duplicate calls
|
|
if (timelineEl) return;
|
|
|
|
injectStyles();
|
|
|
|
const canvasParent = app.canvas?.canvas?.parentElement;
|
|
if (!canvasParent) {
|
|
console.warn(`[${EXTENSION_NAME}] Cannot build timeline: canvas parent not found`);
|
|
return;
|
|
}
|
|
|
|
// Ensure parent is positioned so absolute children work
|
|
const parentPos = getComputedStyle(canvasParent).position;
|
|
if (parentPos === "static") {
|
|
canvasParent.style.position = "relative";
|
|
}
|
|
|
|
// Create root element
|
|
const bar = document.createElement("div");
|
|
bar.className = "snap-timeline";
|
|
bar.style.display = showTimeline ? "" : "none";
|
|
|
|
const track = document.createElement("div");
|
|
track.className = "snap-timeline-track";
|
|
|
|
const snapBtn = document.createElement("button");
|
|
snapBtn.className = "snap-timeline-snap-btn";
|
|
snapBtn.textContent = "Snapshot";
|
|
snapBtn.title = "Take a manual snapshot (Ctrl+S)";
|
|
snapBtn.addEventListener("click", async () => {
|
|
snapBtn.disabled = true;
|
|
const saved = await captureSnapshot("Manual");
|
|
if (saved) showToast("Snapshot saved", "success");
|
|
snapBtn.disabled = false;
|
|
});
|
|
|
|
const expandBtn = document.createElement("button");
|
|
expandBtn.className = "snap-timeline-snap-btn snap-timeline-expand-btn";
|
|
expandBtn.textContent = timelineExpanded ? "\u25BE" : "\u25B4";
|
|
expandBtn.title = timelineExpanded ? "Collapse timeline" : "Expand timeline to show all branches";
|
|
expandBtn.addEventListener("click", () => {
|
|
timelineExpanded = !timelineExpanded;
|
|
localStorage.setItem("snapshotManager_timelineExpanded", timelineExpanded);
|
|
expandBtn.textContent = timelineExpanded ? "\u25BE" : "\u25B4";
|
|
expandBtn.title = timelineExpanded ? "Collapse timeline" : "Expand timeline to show all branches";
|
|
bar.classList.toggle("snap-timeline-expanded", timelineExpanded);
|
|
refresh();
|
|
});
|
|
|
|
bar.appendChild(track);
|
|
bar.appendChild(expandBtn);
|
|
bar.appendChild(snapBtn);
|
|
|
|
if (timelineExpanded) bar.classList.add("snap-timeline-expanded");
|
|
canvasParent.appendChild(bar);
|
|
timelineEl = bar;
|
|
|
|
function buildMarker(rec, { onClickBranch = null, isLatest = false, activeId = null } = {}) {
|
|
const marker = document.createElement("div");
|
|
marker.className = "snap-timeline-marker";
|
|
|
|
const iconInfo = CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown;
|
|
marker.style.setProperty("--snap-marker-color", iconInfo.color);
|
|
marker.innerHTML = iconInfo.svg;
|
|
|
|
if (rec.source === "node") {
|
|
marker.classList.add("snap-timeline-marker-node");
|
|
marker.style.setProperty("--snap-marker-color", "#6d28d9");
|
|
}
|
|
if (rec.locked) marker.classList.add("snap-timeline-marker-locked");
|
|
if (rec.id === activeId) marker.classList.add("snap-timeline-marker-active");
|
|
if (rec.id === currentSnapshotId) {
|
|
marker.classList.add("snap-timeline-marker-current");
|
|
marker.style.setProperty("--snap-marker-color", "#10b981");
|
|
}
|
|
if (isLatest) marker.classList.add("snap-timeline-marker-latest");
|
|
|
|
let tip = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`;
|
|
if (rec.notes) tip += `\n${rec.notes}`;
|
|
marker.title = tip;
|
|
|
|
marker.addEventListener("click", () => {
|
|
if (onClickBranch) onClickBranch();
|
|
swapSnapshot(rec);
|
|
});
|
|
|
|
return marker;
|
|
}
|
|
|
|
async function refresh() {
|
|
if (!showTimeline) return;
|
|
|
|
// Hide/show expand button based on branching
|
|
expandBtn.style.display = branchingEnabled ? "" : "none";
|
|
if (!branchingEnabled && timelineExpanded) {
|
|
timelineExpanded = false;
|
|
localStorage.setItem("snapshotManager_timelineExpanded", "false");
|
|
bar.classList.remove("snap-timeline-expanded");
|
|
expandBtn.textContent = "\u25B4";
|
|
}
|
|
|
|
const wfKey = getWorkflowKey();
|
|
const allRecords = await db_getAllForWorkflow(wfKey);
|
|
|
|
track.innerHTML = "";
|
|
|
|
if (allRecords.length === 0) {
|
|
const empty = document.createElement("span");
|
|
empty.className = "snap-timeline-empty";
|
|
empty.textContent = "No snapshots";
|
|
track.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
// Compute once for all markers
|
|
const effectiveActiveId = activeSnapshotId ?? lastCapturedIdMap.get(wfKey) ?? null;
|
|
const latestId = allRecords.reduce((best, r) => (!best || r.timestamp > best.timestamp) ? r : best, null)?.id ?? null;
|
|
|
|
let tree = null;
|
|
if (branchingEnabled) {
|
|
tree = buildSnapshotTree(allRecords);
|
|
// Auto-select branch containing the active snapshot on first render
|
|
if (activeBranchSelections.size === 0 && effectiveActiveId && tree.byId.has(effectiveActiveId)) {
|
|
selectBranchContaining(effectiveActiveId, tree);
|
|
}
|
|
}
|
|
|
|
// ── Expanded mode: one row per branch ──
|
|
if (timelineExpanded && branchingEnabled) {
|
|
const allBranches = getAllBranches(tree);
|
|
const currentPath = getDisplayPath(tree, activeBranchSelections);
|
|
const currentIds = new Set(currentPath.map(r => r.id));
|
|
|
|
// Determine which branch is the active one
|
|
const currentLeafId = currentPath.length > 0 ? currentPath[currentPath.length - 1].id : null;
|
|
|
|
for (const branch of allBranches) {
|
|
const row = document.createElement("div");
|
|
row.className = "snap-timeline-branch-row";
|
|
|
|
const branchLeafId = branch[branch.length - 1].id;
|
|
const isActiveBranch = branchLeafId === currentLeafId;
|
|
if (isActiveBranch) row.classList.add("snap-timeline-branch-row-active");
|
|
|
|
let skippedCount = 0;
|
|
for (const rec of branch) {
|
|
if (!isActiveBranch && currentIds.has(rec.id)) {
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
const marker = buildMarker(rec, {
|
|
onClickBranch: isActiveBranch ? null : () => {
|
|
selectBranchContaining(branchLeafId, tree);
|
|
},
|
|
isLatest: rec.id === latestId,
|
|
activeId: effectiveActiveId,
|
|
});
|
|
row.appendChild(marker);
|
|
}
|
|
|
|
// Indent non-active rows so fork points align with the active branch
|
|
if (skippedCount > 0) {
|
|
row.style.paddingLeft = `${6 + skippedCount * 24}px`;
|
|
}
|
|
|
|
track.appendChild(row);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Collapsed mode (default) ──
|
|
let records;
|
|
let forkPointSet = new Set();
|
|
if (branchingEnabled) {
|
|
records = getDisplayPath(tree, activeBranchSelections);
|
|
for (const [parentId, children] of tree.childrenOf) {
|
|
if (children.length > 1) forkPointSet.add(parentId);
|
|
}
|
|
} else {
|
|
records = [...allRecords].sort((a, b) => a.timestamp - b.timestamp);
|
|
}
|
|
|
|
for (const rec of records) {
|
|
const marker = buildMarker(rec, { isLatest: rec.id === latestId, activeId: effectiveActiveId });
|
|
|
|
// Fork point: vertical stack — up arrow, marker, down arrow
|
|
if (branchingEnabled && forkPointSet.has(rec.id)) {
|
|
const children = tree.childrenOf.get(rec.id);
|
|
const selectedIndex = Math.min(activeBranchSelections.get(rec.id) ?? 0, children.length - 1);
|
|
|
|
const group = document.createElement("div");
|
|
group.className = "snap-timeline-fork-group";
|
|
|
|
const arrowColor = marker.style.getPropertyValue("--snap-marker-color") || "#3b82f6";
|
|
|
|
const upBtn = document.createElement("button");
|
|
upBtn.className = "snap-timeline-branch-btn";
|
|
upBtn.textContent = "\u25B2";
|
|
upBtn.style.color = arrowColor;
|
|
upBtn.title = `Branch ${selectedIndex}/${children.length}`;
|
|
if (selectedIndex <= 0) upBtn.style.visibility = "hidden";
|
|
upBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
activeBranchSelections.set(rec.id, Math.max(0, selectedIndex - 1));
|
|
refresh();
|
|
if (sidebarRefresh) sidebarRefresh().catch(() => {});
|
|
});
|
|
|
|
const downBtn = document.createElement("button");
|
|
downBtn.className = "snap-timeline-branch-btn";
|
|
downBtn.textContent = "\u25BC";
|
|
downBtn.style.color = arrowColor;
|
|
downBtn.title = `Branch ${selectedIndex + 2}/${children.length}`;
|
|
if (selectedIndex >= children.length - 1) downBtn.style.visibility = "hidden";
|
|
downBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
activeBranchSelections.set(rec.id, Math.min(children.length - 1, selectedIndex + 1));
|
|
refresh();
|
|
if (sidebarRefresh) sidebarRefresh().catch(() => {});
|
|
});
|
|
|
|
group.appendChild(upBtn);
|
|
group.appendChild(marker);
|
|
group.appendChild(downBtn);
|
|
track.appendChild(group);
|
|
} else {
|
|
track.appendChild(marker);
|
|
}
|
|
}
|
|
}
|
|
|
|
timelineRefresh = refresh;
|
|
refresh().catch(() => {});
|
|
}
|
|
|
|
// ─── 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;
|
|
},
|
|
},
|
|
{
|
|
id: "SnapshotManager.maxNodeSnapshots",
|
|
name: "Max node-triggered snapshots per workflow",
|
|
type: "slider",
|
|
defaultValue: 5,
|
|
attrs: { min: 1, max: 50, step: 1 },
|
|
category: ["Snapshot Manager", "Capture Settings", "Max node-triggered snapshots"],
|
|
onChange(value) {
|
|
maxNodeSnapshots = value;
|
|
},
|
|
},
|
|
{
|
|
id: "SnapshotManager.showTimeline",
|
|
name: "Show snapshot timeline on canvas",
|
|
type: "boolean",
|
|
defaultValue: false,
|
|
category: ["Snapshot Manager", "Timeline", "Show snapshot timeline on canvas"],
|
|
onChange(value) {
|
|
showTimeline = value;
|
|
if (timelineEl) timelineEl.style.display = value ? "" : "none";
|
|
if (value && timelineRefresh) timelineRefresh().catch(() => {});
|
|
},
|
|
},
|
|
],
|
|
|
|
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;
|
|
viewingWorkflowKey = null;
|
|
// Clean up tooltip
|
|
if (sidebarTooltipEl) {
|
|
sidebarTooltipEl.remove();
|
|
sidebarTooltipEl = null;
|
|
}
|
|
},
|
|
});
|
|
},
|
|
|
|
async setup() {
|
|
// Migrate old IndexedDB data to server on first load
|
|
await migrateFromIndexedDB();
|
|
|
|
// Listen for graph changes (dispatched by ChangeTracker via api)
|
|
api.addEventListener("graphChanged", () => {
|
|
scheduleCaptureSnapshot();
|
|
});
|
|
|
|
// Listen for node-triggered snapshot captures via WebSocket
|
|
api.addEventListener("snapshot-manager-capture", (event) => {
|
|
const label = event.detail?.label || "Node Trigger";
|
|
const thumbnail = event.detail?.thumbnail || null;
|
|
captureNodeSnapshot(label, thumbnail).catch((err) => {
|
|
console.warn(`[${EXTENSION_NAME}] Node-triggered capture failed:`, err);
|
|
});
|
|
});
|
|
|
|
// Listen for workflow switches via Pinia store action
|
|
const workflowStore = app.extensionManager?.workflow;
|
|
if (workflowStore?.$onAction) {
|
|
workflowStore.$onAction(({ name, after }) => {
|
|
if (name === "openWorkflow") {
|
|
const prevKey = getWorkflowKey(); // capture BEFORE switch
|
|
after(() => {
|
|
// Cancel any pending capture from the previous workflow
|
|
if (captureTimer) {
|
|
clearTimeout(captureTimer);
|
|
captureTimer = null;
|
|
}
|
|
viewingWorkflowKey = null;
|
|
activeSnapshotId = null;
|
|
currentSnapshotId = null;
|
|
diffBaseSnapshot = null;
|
|
// Clear branching state for the old workflow
|
|
lastCapturedIdMap.delete(prevKey);
|
|
activeBranchSelections.clear();
|
|
// Seed active ring for the new workflow tab
|
|
const newKey = getWorkflowKey();
|
|
trackSessionWorkflow(newKey);
|
|
db_getAllForWorkflow(newKey).then(recs => {
|
|
if (recs.length > 0 && !lastCapturedIdMap.has(newKey)) {
|
|
const latest = recs.reduce((best, r) => r.timestamp > best.timestamp ? r : best);
|
|
lastCapturedIdMap.set(newKey, latest.id);
|
|
if (timelineRefresh) timelineRefresh().catch(() => {});
|
|
}
|
|
}).catch(() => {});
|
|
if (sidebarRefresh) {
|
|
sidebarRefresh(true).catch(() => {});
|
|
}
|
|
if (timelineRefresh) {
|
|
timelineRefresh().catch(() => {});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Ctrl+S / Cmd+S shortcut for manual snapshot
|
|
document.addEventListener("keydown", (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
captureSnapshot("Manual (Ctrl+S)").then((saved) => {
|
|
if (saved) showToast("Snapshot saved", "success");
|
|
}).catch(() => {});
|
|
// Don't preventDefault — let ComfyUI's own workflow save still fire
|
|
}
|
|
});
|
|
|
|
// Build the timeline bar on the canvas
|
|
buildTimeline();
|
|
|
|
// Seed in-memory state so the active ring shows on reload
|
|
// and the first auto-capture gets correct change-type detection
|
|
try {
|
|
const wfKey = getWorkflowKey();
|
|
const records = await db_getAllForWorkflow(wfKey);
|
|
if (records.length > 0) {
|
|
const latest = records.reduce((best, r) => r.timestamp > best.timestamp ? r : best);
|
|
lastCapturedIdMap.set(wfKey, latest.id);
|
|
const graphData = getGraphData();
|
|
if (graphData) {
|
|
lastCapturedHashMap.set(wfKey, quickHash(JSON.stringify(graphData)));
|
|
lastGraphDataMap.set(wfKey, graphData);
|
|
}
|
|
if (timelineRefresh) timelineRefresh().catch(() => {});
|
|
}
|
|
} catch {}
|
|
|
|
// Track initial workflow for profiles
|
|
trackSessionWorkflow(getWorkflowKey());
|
|
|
|
// 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.`);
|
|
},
|
|
});
|
|
}
|