// Sidebar bridge — lazy-loads the Svelte asset browser and registers it as a ComfyUI sidebar tab. import { api } from "../../../scripts/api.js"; import { app } from "../../../scripts/app.js"; import { getWorkflowId } from "./workflow-id.js"; import { ensureSvelteCSS, createModuleLoader } from "./lazy-load.js"; const ensureCSS = ensureSvelteCSS; const loadModule = createModuleLoader(() => import("./svelte/promptchain-sidebar.js")); // inject sidebar icon CSS (chain-link SVG matching legacy style) function ensureIconCSS() { if (document.getElementById("pcr-sidebar-icon-css")) return; const style = document.createElement("style"); style.id = "pcr-sidebar-icon-css"; style.textContent = ` .pcr-sidebar-icon { display: inline-block; width: 24px; height: 24px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%23a1a1aa' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M13.213 9.787a3.391 3.391 0 0 0-4.795 0l-3.425 3.426a3.39 3.39 0 0 0 4.795 4.794l.321-.304m-.321-4.49a3.39 3.39 0 0 0 4.795 0l3.424-3.426a3.39 3.39 0 0 0-4.794-4.795l-1.028.961'/%3E%3C/svg%3E"); background-size: contain; background-repeat: no-repeat; background-position: center; } .side-bar-button-selected .pcr-sidebar-icon { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M13.213 9.787a3.391 3.391 0 0 0-4.795 0l-3.425 3.426a3.39 3.39 0 0 0 4.795 4.794l.321-.304m-.321-4.49a3.39 3.39 0 0 0 4.795 0l3.424-3.426a3.39 3.39 0 0 0-4.794-4.795l-1.028.961'/%3E%3C/svg%3E"); } .promptchain-browser-tab-button .side-bar-button-title, .promptchain-browser-tab-button .side-bar-button-label { line-height: 1.1; white-space: pre-line; } .promptchain-browser-tab-button .side-bar-button-content { gap: 2px; } .promptchain-browser-tab-button .sidebar-icon-wrapper { display: flex; align-items: flex-end; justify-content: center; height: 100%; } `; document.head.appendChild(style); } let gutterObserver = null; let visObserver = null; let renderAc = null; function watchGutter(container) { // find the adjacent gutter once the container is in the DOM const apply = () => { const panel = container.closest(".side-bar-panel"); if (!panel) return; const gutter = (panel.nextElementSibling?.classList.contains("p-splitter-gutter") && panel.nextElementSibling) || (panel.previousElementSibling?.classList.contains("p-splitter-gutter") && panel.previousElementSibling) || null; if (!gutter) return; const narrow = () => gutter.style.setProperty("width", "2px", "important"); const restore = () => gutter.style.removeProperty("width"); // apply immediately if visible if (container.offsetParent !== null) narrow(); gutterObserver = new IntersectionObserver((entries) => { if (entries[0]?.isIntersecting) narrow(); else restore(); }, { threshold: 0.01 }); gutterObserver.observe(container); }; requestAnimationFrame(apply); } function unwatchGutter() { gutterObserver?.disconnect(); gutterObserver = null; } export function createSidebarTab() { ensureIconCSS(); const TAB_ID = "promptchain-browser"; const tabDef = { id: "promptchain-browser", type: "custom", title: "Prompt\nChain", icon: "pcr-sidebar-icon", tooltip: "PromptChain Browser", render(container) { ensureCSS(); watchGutter(container); container.style.height = "100%"; container.style.overflow = "hidden"; try { localStorage.setItem("pcr-sidebar-open", "1"); } catch {} loadModule().then((mod) => { // abort on destroy() to tear down listeners bound via signal below — // ComfyUI's ExtensionSlot remounts on every sidebar toggle, so // without this the executed-handler stack grows per toggle. renderAc = new AbortController(); const signal = renderAc.signal; // event-driven refresh: sidebar registers callbacks, bridge fires them on events const refreshListeners = new Set(); const fireRefresh = (scope, detail) => { for (const cb of refreshListeners) cb(scope, detail); }; // collect output filenames during execution, fire targeted refresh on success const pendingOutputs = new Map(); api.addEventListener("executed", ({ detail }) => { if (!detail?.output) return; if (!pendingOutputs.has(detail.prompt_id)) pendingOutputs.set(detail.prompt_id, []); const list = pendingOutputs.get(detail.prompt_id); for (const key of ["images", "video", "audio"]) { const arr = detail.output[key]; if (!Array.isArray(arr)) continue; for (const item of arr) { if (item.filename && item.type === "output") { list.push({ filename: item.filename, subfolder: item.subfolder || "" }); } } } }, { signal }); api.addEventListener("execution_success", ({ detail }) => { const files = pendingOutputs.get(detail.prompt_id) || []; pendingOutputs.delete(detail.prompt_id); fireRefresh("output", { files }); fireRefresh("workflows", { thumbnailUpdate: true, workflowId: getWorkflowId() }); }, { signal }); api.addEventListener("execution_error", ({ detail }) => { pendingOutputs.delete(detail.prompt_id); }, { signal }); api.addEventListener("execution_interrupted", ({ detail }) => { pendingOutputs.delete(detail.prompt_id); }, { signal }); // all scopes: refresh when sidebar tab becomes visible (covers external file changes) let wasHidden = true; visObserver = new IntersectionObserver((entries) => { const visible = entries[0]?.isIntersecting; if (visible && wasHidden) fireRefresh(null, { visibility: true }); wasHidden = !visible; }, { threshold: 0.1 }); visObserver.observe(container); // input: refresh after drag-to-canvas uploads (fired by asset-drop.js) window.addEventListener("promptchain:input-changed", (e) => fireRefresh("input", e.detail || null), { signal }); window.addEventListener("promptchain:file-deleted", (e) => { fireRefresh(e.detail?.scope || "output", { removedPaths: e.detail?.paths || [] }); }, { signal }); // workflows: refresh after workflow save/save-as (fired by save interceptor) window.addEventListener("promptchain:workflows-changed", (e) => fireRefresh("workflows", e.detail || null), { signal }); mod.mountSidebar(container, { apiURL: (path) => api.apiURL(path), fetchApi: (path, opts) => api.fetchApi(path, opts), toast: (severity, detail) => { app.extensionManager?.toast?.add({ severity, summary: "PromptChain", detail, life: 4000 }); }, logoUrl: new URL("../logo.png", import.meta.url).href, onSubscribeRefresh: (cb) => { refreshListeners.add(cb); return () => refreshListeners.delete(cb); }, onSubscribeDedup: (cb) => { const handler = ({ detail }) => cb(detail); api.addEventListener("promptchain.dedup.progress", handler); return () => api.removeEventListener("promptchain.dedup.progress", handler); }, onAddNode: async () => { if (!app.graph) { await app.loadGraphData({ last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 }); } const LG = window.LiteGraph || globalThis.LiteGraph; if (!LG || !app.graph) return; const node = LG.createNode("PromptChain_PromptChain"); if (!node) return; const canvas = app.canvas; if (canvas?.ds && canvas.canvas) { const rect = canvas.canvas.getBoundingClientRect(); const gx = (rect.width / 2) / canvas.ds.scale - canvas.ds.offset[0]; const gy = (rect.height / 2) / canvas.ds.scale - canvas.ds.offset[1]; node.pos = [gx - 150, gy - 50]; } else { node.pos = [100, 100]; } app.graph.add(node); app.graph.setDirtyCanvas(true, true); canvas?.selectNode(node); }, onCreateWorkflow: async (workflowName, template, modelFilename, subfolder) => { const workflowId = crypto.randomUUID(); const displayName = subfolder ? `${subfolder}/${workflowName}` : workflowName; if (template) { // template mode: seed with a PromptChain node, then apply template const { applyTemplate } = await import("./model-bridge.js"); const seed = { id: workflowId, last_node_id: 1, last_link_id: 0, nodes: [{ id: 1, type: "PromptChain_PromptChain", pos: [50, 50], flags: {}, order: 0, mode: 0, inputs: [], outputs: [], properties: {}, widgets_values: [], }], links: [], groups: [], config: {}, extra: { promptchain: { id: workflowId, name: workflowName } }, version: 0.4, }; await app.loadGraphData(seed, true, true, displayName); const pcNode = app.graph._nodes.find(n => n.type === "PromptChain_PromptChain" || n.comfyClass === "PromptChain_PromptChain" ); if (pcNode) { applyTemplate(pcNode, template, modelFilename || ""); app.graph.setDirtyCanvas(true, true); } } else { // blank mode: empty canvas const seed = { id: workflowId, last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4, }; await app.loadGraphData(seed, true, true, displayName); } // serialize and save the fully-connected graph const dir = subfolder ? `workflows/${subfolder}` : "workflows"; const savePath = `${dir}/${workflowName}.json`; try { const graphData = app.graph.serialize(); graphData.extra = graphData.extra || {}; graphData.extra.promptchain = { id: workflowId, name: workflowName }; const resp = await api.fetchApi(`/userdata/${encodeURIComponent(savePath)}?full_info=true`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(graphData), }); // loadGraphData created a temporary workflow (size=-1). Patch it // to persisted so subsequent Ctrl+S sends overwrite:true and // doesn't 409 against the file we just created. const store = app.extensionManager?.workflow; const active = store?.activeWorkflow; if (active && active.isTemporary && resp.ok) { const info = await resp.json().catch(() => null); if (info && typeof info === "object") { active.size = info.size; active.lastModified = info.modified; } else { active.size = 1; active.lastModified = Date.now(); } // sync content so the workflow isn't marked as modified const saved = JSON.stringify(graphData); active.content = saved; active.originalContent = saved; } store?.syncWorkflows?.(); } catch (e) { console.error("[PromptChain] Failed to save new workflow:", e); } window.dispatchEvent(new CustomEvent("promptchain:workflows-changed", { detail: { path: subfolder ? `${subfolder}/${workflowName}` : workflowName }, })); }, onOpenSettings: () => { app.extensionManager?.command?.execute?.("Comfy.ShowSettingsDialog"); }, onOpenViewer: async (item, scope, browsePath, siblings, startIndex, opts) => { const { openViewer } = await import("./viewer-bridge.js"); const list = siblings?.length ? siblings : [item]; const images = list.map(i => { if (i.hash) return { hash: i.hash, filename: i.name, subfolder: "" }; const url = api.apiURL(`/promptchain/browse/preview?scope=${scope}&path=${encodeURIComponent(i.path)}`); return { hash: i.path, filename: i.name, subfolder: "", _directUrl: url, _browseScope: scope, _browsePath: i.path }; }); openViewer(images, startIndex ?? 0, "", async (hash) => { const img = images.find(i => i.hash === hash); if (!img) return; const path = img._browsePath || (img.subfolder ? `${img.subfolder}/${img.filename}` : img.filename); await api.fetchApi("/promptchain/browse/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, paths: [path] }), }); // targeted removal in sidebar fireRefresh(scope, { removedPaths: [path] }); // notify generated panel window.dispatchEvent(new CustomEvent("promptchain:file-deleted", { detail: { scope, paths: [path] }, })); }, opts); }, onLoadWorkflow: async (item, scope) => { try { const store = app.extensionManager?.workflow; // Switch to existing tab if this workflow is already open // Store paths are prefixed with "workflows/" (ComfyWorkflow.basePath) const storePath = "workflows/" + item.path; const existing = store?.openWorkflows?.find(w => w.path === storePath); if (existing) { if (!store.isActive(existing)) await store.openWorkflow(existing); return; } const resp = await api.fetchApi(`/promptchain/browse/preview?scope=${scope}&path=${encodeURIComponent(item.path)}`); if (!resp.ok) { console.error("[PromptChain] Workflow fetch failed:", resp.status); return; } const data = await resp.json(); // afterLoadNewGraph prepends "workflows/" already — don't double it await app.loadGraphData(data, true, true, item.path); // sync ComfyUI's workflow store so it tracks the opened file store?.syncWorkflows?.(); } catch (e) { console.error("[PromptChain] Failed to load workflow:", e); } }, }); }); }, destroy() { unwatchGutter(); visObserver?.disconnect(); visObserver = null; renderAc?.abort(); renderAc = null; try { localStorage.setItem("pcr-sidebar-open", "0"); } catch {} loadModule().then((mod) => mod.destroySidebar()).catch(e => console.error("[PromptChain] sidebar destroy failed:", e)); }, }; // register once extensionManager exists const register = () => { if (!app.extensionManager) { requestAnimationFrame(register); return; } app.extensionManager.registerSidebarTab(tabDef); // restore sidebar open state from last session try { if (localStorage.getItem("pcr-sidebar-open") === "1") { const selector = `.${TAB_ID}-tab-button`; const existing = document.querySelector(selector); if (existing && !existing.classList.contains("side-bar-button-selected")) { existing.click(); } else if (!existing) { const observer = new MutationObserver(() => { const btn = document.querySelector(selector); if (!btn) return; observer.disconnect(); if (!btn.classList.contains("side-bar-button-selected")) btn.click(); }); observer.observe(document.body, { childList: true, subtree: true }); } } } catch {} }; requestAnimationFrame(register); // canvas drop handler — uses the battle-tested legacy asset-drop module import("./asset-drop.js").then(m => m.setupAssetDropHandler()).catch(e => console.error("[PromptChain] asset-drop setup failed:", e)); }