Compare commits

...
6 Commits
17 changed files with 852 additions and 336 deletions
+1 -2
View File
@@ -1,3 +1,2 @@
__pycache__/
*.pyc
js/vendor/
*.pyc
+5 -3
View File
@@ -16,9 +16,10 @@ A compact sidebar for ComfyUI that replaces the stock queue/history UI with a fa
* Hide sidebar tabs and UI elements you don't use.
* Optionally replace the stock Job History sidebar.
* Optional unified top bar for workflow tabs and controls.
* Exclude selected nodes from sidebar results with `Ctrl+Q`.
* [NEW] Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
* [NEW] Ctrl-clicking the delete button places the generated file to the recycle bin.
* Exclude selected nodes from sidebar results with `Ctrl+Q` (or the header button).
* Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
* Ctrl-clicking the delete button places the generated file to the recycle bin.
* [NEW] Reset all node IDs in topological execution order (`Alt+R`, right-click menu, or header button) to ensure the final output node is always prioritized as the primary preview.
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
@@ -55,6 +56,7 @@ Open **Settings → Comfy Sidebar** to configure:
| ------------- | -------------------------------------------------- |
| `Q` | Toggle ComfySidebar |
| `Ctrl+Q` | Exclude the selected node from the sidebar outputs |
| `Alt+R` | Reset all node IDs by execution order |
| `Click` | Open result preview |
| `Shift+Click` | Compare another result |
| `Ctrl+Click` | Open file location/alternative action |
+32 -69
View File
@@ -16,7 +16,12 @@ def is_path_safe(base_dir, target_path):
"""Prevents Path Traversal attacks."""
real_base = os.path.realpath(base_dir)
real_target = os.path.realpath(target_path)
return real_target == real_base or real_target.startswith(real_base + os.sep)
try:
# commonpath reliably handles OS-specific case sensitivity and slash logic
return os.path.commonpath([real_base, real_target]) == real_base
except ValueError:
# Raised if paths are on different drives on Windows
return False
def get_all_candidate_roots(subfolder="", folder_type="output"):
@@ -55,9 +60,9 @@ def get_all_candidate_roots(subfolder="", folder_type="output"):
return unique
def resolve_existing_path(filename="", subfolder="", folder_type="output"):
def resolve_existing_path(filename="", subfolder="", folder_type="output", candidate_roots=None):
"""Finds the actual location of a file or folder across all candidate root directories."""
candidates = get_all_candidate_roots(subfolder, folder_type)
candidates = candidate_roots if candidate_roots is not None else get_all_candidate_roots(subfolder, folder_type)
if filename:
for root in candidates:
@@ -92,7 +97,7 @@ def open_file_in_os(target_path):
# Passed as a single argument so Windows Explorer highlights the file properly
subprocess.Popen(["explorer", f"/select,{norm_path}"])
else:
os.startfile(norm_path)
subprocess.Popen(["explorer", norm_path])
elif system == "Darwin": # macOS
if not os.path.exists(target_path):
@@ -141,19 +146,34 @@ async def delete_file_handler(request):
if not filename:
return web.json_response({"error": "No filename provided"}, status=400)
target_file, root = resolve_existing_path(filename, subfolder, folder_type)
# Strictly restrict deletion to generated output and temp files (protects models/checkpoints)
delete_roots = [
folder_paths.get_output_directory(),
folder_paths.get_temp_directory()
]
delete_roots = [os.path.realpath(r) for r in delete_roots if r and os.path.exists(r)]
target_file, root = resolve_existing_path(filename, subfolder, folder_type, candidate_roots=delete_roots)
if not target_file or not os.path.isfile(target_file) or not is_path_safe(root, target_file):
return web.json_response({"error": "File not found or unsafe path"}, status=404)
return web.json_response({"error": "File not found or outside output directory"}, status=404)
# Try moving to Recycle Bin / Trash first, fallback to os.remove
try:
import send2trash
send2trash.send2trash(target_file)
except Exception:
permanent = bool(data.get("permanent", False))
action = "deleted"
if not permanent:
try:
import send2trash
send2trash.send2trash(target_file)
action = "trashed"
except Exception:
os.remove(target_file)
action = "deleted"
else:
os.remove(target_file)
action = "deleted"
return web.json_response({"success": True, "deleted": target_file})
return web.json_response({"success": True, "deleted": target_file, "action": action})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@@ -180,61 +200,4 @@ async def promote_output_handler(request):
shutil.copy2(target_file, dest_path)
return web.json_response({"name": filename})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ============================================================================
# Local Three.js Proxy & Offline Cache (Bypasses CSP & --disable-api-nodes)
# ============================================================================
SIDEBAR_CACHE_DIR = os.path.join(os.path.dirname(__file__), "js", "vendor", "cache")
@PromptServer.instance.routes.get("/comfy-sidebar/three-proxy/{path:.*}")
async def three_proxy_handler(request):
raw_path = request.match_info.get("path", "").lstrip("/")
if not raw_path:
return web.Response(status=404)
query = request.query_string
full_req = f"{raw_path}?{query}" if query else raw_path
os.makedirs(SIDEBAR_CACHE_DIR, exist_ok=True)
import re
safe_name = re.sub(r'[^a-zA-Z0-9_.-]', '_', full_req)
if not any(safe_name.endswith(ext) for ext in [".js", ".mjs", ".wasm", ".bin", ".json"]):
safe_name += ".js"
cache_file = os.path.join(SIDEBAR_CACHE_DIR, safe_name)
# 1. Serve immediately from disk cache if present
if os.path.isfile(cache_file) and os.path.getsize(cache_file) > 0:
content_type = "application/wasm" if raw_path.endswith(".wasm") else "application/javascript"
return web.FileResponse(cache_file, headers={"Content-Type": content_type})
# 2. Otherwise download via Python (not blocked by browser CSP)
url = f"https://esm.sh/{full_req}"
try:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
with urllib.request.urlopen(req, timeout=20) as resp:
raw_data = resp.read()
if raw_path.endswith((".wasm", ".bin", ".png", ".jpg")):
with open(cache_file, "wb") as f:
f.write(raw_data)
return web.Response(body=raw_data, content_type="application/octet-stream")
content = raw_data.decode("utf-8", errors="replace")
# Rewrite absolute esm.sh paths to route through our local 'self' proxy
content = content.replace("https://esm.sh/", "/comfy-sidebar/three-proxy/")
# Rewrite root-relative module paths (e.g. from "/v135/...")
content = re.sub(
r'((?:from|import)\s*["\'])/(v\d+|[a-zA-Z0-9@_.~-]+/)',
r'\1/comfy-sidebar/three-proxy/\2',
content
)
with open(cache_file, "w", encoding="utf-8") as f:
f.write(content)
return web.Response(text=content, content_type="application/javascript")
except Exception as e:
return web.Response(text=f"/* Proxy error fetching {url}: {e} */", status=502, content_type="application/javascript")
return web.json_response({"error": str(e)}, status=500)
+31 -22
View File
@@ -103,18 +103,30 @@ export function findGraphButton() {
}
export function findOurSidebarButton() {
const byTestId = document.querySelector('[data-testid="classic-comfy-sidebar-tab-button"]');
if (byTestId) return byTestId;
const icon = document.querySelector('.pi-images');
return icon ? icon.closest('.comfyui-sidebar-tab, button, [role="tab"]') : null;
}
export function findStandardQueueButton() {
for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h"]) {
// 1. Try modern ComfyUI stable test-ids first
const byTestId = document.querySelector('[data-testid="queue-tab-button"], [data-testid="job-history-tab-button"]');
if (byTestId && !byTestId.id?.includes('classic-comfy-sidebar') && !byTestId.querySelector('.pi-images')) {
return byTestId;
}
// 2. Fallback to icon discovery
for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h", '[class*="lucide--history"]']) {
const icon = document.querySelector(iconSelector);
if (icon) {
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
if (btn && !btn.querySelector('.pi-images')) return btn;
}
}
// 3. Fallback to semantic labels
const buttons = document.querySelectorAll('.comfyui-sidebar-tab, button, [role="tab"]');
for (const btn of buttons) {
const title = btn.title || btn.getAttribute('aria-label') || '';
@@ -128,29 +140,26 @@ export function findStandardQueueButton() {
}
export function updateSidebarBadge(count) {
const icons = document.querySelectorAll('.pi-images');
icons.forEach(icon => {
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
if (btn) {
let badge = btn.querySelector('.comfy-sidebar-badge');
if (count > 0) {
if (!badge) {
btn.style.position = 'relative';
badge = document.createElement('div');
badge.className = 'comfy-sidebar-badge';
Object.assign(badge.style, {
position: 'absolute', top: '2px', right: '2px', background: '#0ea5e9', color: '#fff',
borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', pointerEvents: 'none'
});
btn.appendChild(badge);
}
badge.textContent = count > 99 ? '99+' : count;
} else if (badge) {
badge.remove();
const btn = findOurSidebarButton();
if (btn) {
let badge = btn.querySelector('.comfy-sidebar-badge');
if (count > 0) {
if (!badge) {
btn.style.position = 'relative';
badge = document.createElement('div');
badge.className = 'comfy-sidebar-badge';
Object.assign(badge.style, {
position: 'absolute', top: '2px', right: '2px', background: '#0ea5e9', color: '#fff',
borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', pointerEvents: 'none'
});
btn.appendChild(badge);
}
badge.textContent = count > 99 ? '99+' : count;
} else if (badge) {
badge.remove();
}
});
}
}
export function applySidebarOverride() {
+151 -143
View File
@@ -163,20 +163,21 @@ function updateSidebarTabsVisibility() {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return;
const tabIconSelectors = {
"Assets": '[class*="comfy--image-ai-edit"]',
"Nodes": '[class*="comfy--node"]',
"Models": '[class*="comfy--ai-model"]',
"Workflows": '[class*="comfy--workflow"], [class*="workflow"]',
"Apps": '[class*="lucide--panels-top-left"]',
"Templates": '[class*="comfy--template"]'
// Use stable ComfyUI data-testid selectors first, with icon classes as safe fallback
const tabSelectors = {
"Assets": '[data-testid="assets-tab-button"], [class*="comfy--image-ai-edit"]',
"Nodes": '[data-testid="node-library-tab-button"], [class*="comfy--node"]',
"Models": '[data-testid="model-library-tab-button"], [class*="comfy--ai-model"]',
"Workflows": '[data-testid="workflows-tab-button"], [class*="comfy--workflow"], [class*="workflow"]',
"Apps": '[data-testid="apps-tab-button"], [class*="lucide--panels-top-left"]',
"Templates": '[data-testid="templates-tab-button"], [class*="comfy--template"]'
};
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
Object.entries(tabSelectors).forEach(([tab, selector]) => {
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
const icon = sidebar.querySelector(selector);
if (icon) {
const tabBtn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || icon;
const target = sidebar.querySelector(selector);
if (target) {
const tabBtn = target.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || target;
if (shouldHide) {
if (tabBtn.style.display !== "none") tabBtn.style.setProperty("display", "none", "important");
} else {
@@ -186,9 +187,9 @@ function updateSidebarTabsVisibility() {
});
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const historyIcon = sidebar.querySelector('[class*="lucide--history"]');
if (historyIcon) {
const historyBtn = historyIcon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyIcon;
const historyTarget = sidebar.querySelector('[data-testid="queue-tab-button"], [data-testid="job-history-tab-button"], [class*="lucide--history"]');
if (historyTarget) {
const historyBtn = historyTarget.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyTarget;
if (hideStockHistory) {
if (historyBtn.style.display !== "none") historyBtn.style.setProperty("display", "none", "important");
} else {
@@ -223,141 +224,148 @@ function cleanHTML(html) {
return tempDiv.innerHTML;
}
function syncGraphButton() {
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
const graphBtn = findGraphButton();
if (graphBtn) {
if (hideGraphBtn) {
if (graphBtn.style.display !== "none") graphBtn.style.setProperty("display", "none", "important");
} else {
if (graphBtn.style.display === "none") graphBtn.style.removeProperty("display");
}
}
}
function syncExtensionsPanel(isClassicLayoutEnabled) {
const extensionsPanel = findNativeExtensionsPanel();
if (!extensionsPanel) return;
if (isClassicLayoutEnabled) {
const container = findTopbarContainer();
if (container && extensionsPanel.parentNode !== container) {
if (!extensionsPanel._originalParent) {
extensionsPanel._originalParent = extensionsPanel.parentNode;
extensionsPanel._originalNextSibling = extensionsPanel.nextSibling;
}
container.appendChild(extensionsPanel);
extensionsPanel.classList.add("comfy-sidebar-extensions-override");
}
} else if (extensionsPanel._originalParent && extensionsPanel.parentNode !== extensionsPanel._originalParent) {
extensionsPanel._originalParent.insertBefore(extensionsPanel, extensionsPanel._originalNextSibling || null);
extensionsPanel.classList.remove("comfy-sidebar-extensions-override");
}
}
function syncPropertiesButton(isClassicLayoutEnabled) {
if (!isClassicLayoutEnabled) {
const originalBtn = findOriginalPropertiesButton();
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
}
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (customBtn) customBtn.remove();
return;
}
const originalBtn = findOriginalPropertiesButton();
const container = findTopbarContainer();
const openState = isPropertiesPanelOpen();
if (originalBtn) {
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
}
savedButtonData = {
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
innerHTML: originalBtn.innerHTML,
tagName: originalBtn.tagName,
attributes: Array.from(originalBtn.attributes).map(attr => ({
name: attr.name,
value: attr.value
}))
};
}
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (!customBtn && savedButtonData && container) {
customBtn = document.createElement(savedButtonData.tagName);
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
customBtn.innerHTML = cleanHTML(savedButtonData.innerHTML);
for (const attr of savedButtonData.attributes) {
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
customBtn.setAttribute(attr.name, attr.value);
}
}
customBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const nativeBtn = findOriginalPropertiesButton();
if (nativeBtn) {
nativeBtn.click();
requestAnimationFrame(() => syncClassicLayout());
}
});
container.appendChild(customBtn);
}
if (customBtn) {
if (originalBtn) {
const cleanedHTML = cleanHTML(originalBtn.innerHTML);
if (cleanedHTML && customBtn.innerHTML !== cleanedHTML) {
const existingDot = customBtn.querySelector('.comfy-sidebar-error-dot');
customBtn.innerHTML = cleanedHTML;
if (existingDot) customBtn.appendChild(existingDot);
}
const titleVal = originalBtn.getAttribute("title") || originalBtn.getAttribute("aria-label") || "Toggle properties panel";
customBtn.setAttribute("title", titleVal);
customBtn.setAttribute("aria-label", titleVal);
for (const cls of originalBtn.classList) {
if (cls !== "comfy-sidebar-hide-original-properties-btn" && !customBtn.classList.contains(cls)) {
customBtn.classList.add(cls);
}
}
syncErrorBadge(originalBtn, customBtn);
} else if (!customBtn.getAttribute("title")) {
customBtn.setAttribute("title", "Toggle properties panel");
customBtn.setAttribute("aria-label", "Toggle properties panel");
}
customBtn.classList.toggle("comfy-panel-open", openState);
customBtn.style.display = "inline-flex";
}
}
function syncActiveQueueIndicator(isClassicLayoutEnabled) {
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const indicator = findActiveQueueIndicator();
if (indicator) {
if (isClassicLayoutEnabled && hideQueueIndicator) {
if (indicator.style.display !== "none") indicator.style.setProperty("display", "none", "important");
} else if (indicator.style.display === "none") {
indicator.style.removeProperty("display");
}
}
}
export function syncClassicLayout() {
if (domObserver) domObserver.disconnect();
try {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
const graphBtn = findGraphButton();
if (graphBtn) {
if (hideGraphBtn) {
if (graphBtn.style.display !== "none") graphBtn.style.setProperty("display", "none", "important");
} else {
if (graphBtn.style.display === "none") graphBtn.style.removeProperty("display");
}
}
const extensionsPanel = findNativeExtensionsPanel();
if (extensionsPanel) {
if (isClassicLayoutEnabled) {
const container = findTopbarContainer();
if (container && extensionsPanel.parentNode !== container) {
if (!extensionsPanel._originalParent) {
extensionsPanel._originalParent = extensionsPanel.parentNode;
extensionsPanel._originalNextSibling = extensionsPanel.nextSibling;
}
container.appendChild(extensionsPanel);
extensionsPanel.classList.add("comfy-sidebar-extensions-override");
}
} else if (extensionsPanel._originalParent && extensionsPanel.parentNode !== extensionsPanel._originalParent) {
extensionsPanel._originalParent.insertBefore(extensionsPanel, extensionsPanel._originalNextSibling || null);
extensionsPanel.classList.remove("comfy-sidebar-extensions-override");
}
}
if (!isClassicLayoutEnabled) {
const originalBtn = findOriginalPropertiesButton();
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
}
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (customBtn) customBtn.remove();
const indicator = findActiveQueueIndicator();
if (indicator && indicator.style.display === "none") indicator.style.removeProperty("display");
updateSidebarTabsVisibility();
return;
}
const originalBtn = findOriginalPropertiesButton();
const container = findTopbarContainer();
const openState = isPropertiesPanelOpen();
if (originalBtn) {
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
}
savedButtonData = {
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
innerHTML: originalBtn.innerHTML,
tagName: originalBtn.tagName,
attributes: Array.from(originalBtn.attributes).map(attr => ({
name: attr.name,
value: attr.value
}))
};
}
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (!customBtn && savedButtonData && container) {
customBtn = document.createElement(savedButtonData.tagName);
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
customBtn.innerHTML = cleanHTML(savedButtonData.innerHTML);
for (const attr of savedButtonData.attributes) {
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
customBtn.setAttribute(attr.name, attr.value);
}
}
customBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const nativeBtn = findOriginalPropertiesButton();
if (nativeBtn) {
nativeBtn.click();
requestAnimationFrame(() => syncClassicLayout());
}
});
container.appendChild(customBtn);
}
if (customBtn) {
if (originalBtn) {
const cleanedHTML = cleanHTML(originalBtn.innerHTML);
if (cleanedHTML && customBtn.innerHTML !== cleanedHTML) {
const existingDot = customBtn.querySelector('.comfy-sidebar-error-dot');
customBtn.innerHTML = cleanedHTML;
if (existingDot) customBtn.appendChild(existingDot);
}
const titleVal = originalBtn.getAttribute("title") || originalBtn.getAttribute("aria-label") || "Toggle properties panel";
customBtn.setAttribute("title", titleVal);
customBtn.setAttribute("aria-label", titleVal);
for (const cls of originalBtn.classList) {
if (cls !== "comfy-sidebar-hide-original-properties-btn" && !customBtn.classList.contains(cls)) {
customBtn.classList.add(cls);
}
}
syncErrorBadge(originalBtn, customBtn);
} else if (!customBtn.getAttribute("title")) {
customBtn.setAttribute("title", "Toggle properties panel");
customBtn.setAttribute("aria-label", "Toggle properties panel");
}
customBtn.classList.toggle("comfy-panel-open", openState);
customBtn.style.display = "inline-flex";
}
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const indicator = findActiveQueueIndicator();
if (indicator) {
if (hideQueueIndicator) {
if (indicator.style.display !== "none") indicator.style.setProperty("display", "none", "important");
} else if (indicator.style.display === "none") {
indicator.style.removeProperty("display");
}
}
syncGraphButton();
syncExtensionsPanel(isClassicLayoutEnabled);
syncPropertiesButton(isClassicLayoutEnabled);
syncActiveQueueIndicator(isClassicLayoutEnabled);
updateSidebarTabsVisibility();
} catch (err) {
+1 -1
View File
@@ -118,7 +118,7 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
prompt.images = nodeImgs;
// If fullscreen is open, smoothly transition to the final saved image
const finalImg = nodeImgs[0];
const finalSrc = finalImg.url || (window.location.origin + `/view?filename=${encodeURIComponent(finalImg.filename)}&type=${finalImg.type || 'output'}&subfolder=${encodeURIComponent(finalImg.subfolder || '')}`);
const finalSrc = finalImg.url || (window.location.origin + `/view?filename=${encodeURIComponent(finalImg.filename)}&type=${finalImg.type || 'output'}&subfolder=${encodeURIComponent(finalImg.subfolder || '')}&rand=${encodeURIComponent(pid)}`);
updateActiveComparisonPreview(pid, finalSrc);
}
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, prompt.workflow);
+44
View File
@@ -2,6 +2,7 @@ import { app } from "/scripts/app.js";
import { SettingIds } from "./core/constants.js";
import { injectStyles } from "./utils/styles.js";
import { setupDragAndDrop } from "./utils/dragdrop.js";
import { renumberNodesTopologically, setManualNodeId } from "./utils/nodeRenumber.js";
import { findOurSidebarButton } from "./comfy/adapter.js";
import { syncAllNodeBadges, setupVueNodeObserver, toggleIgnoreActiveNode } from "./comfy/nodes.js";
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./comfy/layout.js";
@@ -43,6 +44,12 @@ function registerSettings() {
type: "boolean",
defaultValue: false
});
app.ui.settings.addSetting({
id: "Comfy Sidebar.Permanent Delete on Disk Deletion",
name: "Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)",
type: "boolean",
defaultValue: false
});
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
sidebarTabs.forEach(tab => {
@@ -115,6 +122,13 @@ function setupKeydownShortcuts() {
e.stopPropagation();
toggleIgnoreActiveNode(() => renderSidebar());
}
// Alt+R: Renumber all nodes in execution order
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
renumberNodesTopologically();
}
};
document.addEventListener("keydown", activeKeydownHandler, true);
@@ -140,9 +154,39 @@ app.registerExtension({
id: "ComfySidebar.ToggleIgnoreNode",
label: "Toggle Ignore Selected Node(s) in Queue",
function: () => toggleIgnoreActiveNode(() => renderSidebar())
},
{
id: "ComfySidebar.RenumberNodes",
label: "Renumber All Nodes (Execution Order)",
function: () => renumberNodesTopologically()
},
{
id: "ComfySidebar.SetNodeId",
label: "Set Selected Node ID...",
function: () => setManualNodeId()
}
],
getNodeMenuItems(node) {
return [
null,
{
content: "Set Node ID...",
callback: () => setManualNodeId(node)
}
];
},
getCanvasMenuItems() {
return [
null,
{
content: "Renumber All Nodes (Execution Order)",
callback: () => renumberNodesTopologically()
}
];
},
nodeCreated(node) {
if (node && node.properties?.ignoreInQueue) {
node.boxcolor = "#ff3333";
+3 -10
View File
@@ -1,5 +1,6 @@
import { isAudioViewerOpen as checkAudioViewerOpen } from "../utils/comparison.js";
let currentlyPlayingAudio = null;
let activeComparisonViewer = null;
export function stopAllAudioPlayback() {
if (currentlyPlayingAudio) {
@@ -21,14 +22,6 @@ export function getCurrentlyPlayingAudio() {
return currentlyPlayingAudio;
}
export function setActiveComparisonViewer(viewer) {
activeComparisonViewer = viewer;
}
export function getActiveComparisonViewer() {
return activeComparisonViewer;
}
export function isAudioViewerOpen() {
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
return checkAudioViewerOpen();
}
+85 -8
View File
@@ -2,12 +2,13 @@ import { app } from "/scripts/app.js";
import { PromptStatus } from "../core/constants.js";
import { store } from "../core/store.js";
import { renderCardImages } from "./mediaPreview.js";
import { copyImageToClipboard, openFileOrFolder, deleteFileOnServer, findNodeIdForImage } from "./mediaActions.js";
import { copyImageToClipboard, openFileOrFolder, deleteFileOnServer, findNodeIdForImage, removeImageFromNodeOutputs } from "./mediaActions.js";
import { centerAndSelectCanvasNode } from "../comfy/adapter.js";
import { getRunOutputs, extractWorkflowFromPng, isImageFormat } from "../utils/utils.js";
import { showFullscreenPreview } from "../utils/comparison.js";
import { stopAllAudioPlayback } from "./audioController.js";
import { deleteHistoryItem, cancelPendingTask, interruptActive } from "../queue/queueService.js";
import { showNotification } from "../utils/nodeRenumber.js";
export const cardPool = new Map();
@@ -31,6 +32,8 @@ export function syncCardButtonVisibility(cardObj, state) {
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.removeProperty("display");
}
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
// Check if the card has an actual verified file saved on disk
const hasRealDiskFiles = state.images && state.images.length > 0 &&
state.images.some(img => img.filename && !img.isFallback && (!img.url || !img.url.startsWith("blob:")));
@@ -47,7 +50,7 @@ export function syncCardButtonVisibility(cardObj, state) {
ev.stopPropagation();
state.images.forEach(img => {
const a = document.createElement("a");
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
a.download = img.filename || "output";
a.click();
});
@@ -64,7 +67,7 @@ export function syncCardButtonVisibility(cardObj, state) {
cardObj.btnCopy.style.display = "inline-flex";
cardObj.btnCopy.onclick = async (ev) => {
ev.stopPropagation();
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`;
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}${randParam}`;
const success = await copyImageToClipboard(src);
if (success) {
cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
@@ -88,7 +91,7 @@ export function syncCardButtonVisibility(cardObj, state) {
ev.stopPropagation();
let wf = state.workflow;
if (!wf && currentImg) {
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`;
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}${randParam}`;
wf = await extractWorkflowFromPng(src);
if (wf) state.workflow = wf;
}
@@ -158,17 +161,75 @@ export function syncCardButtonVisibility(cardObj, state) {
(state.status !== PromptStatus.COMPLETED && validOutputs.length > 0);
if (hasIntermediates) {
// Check if intermediate files still exist on disk
for (const out of distinctOutputs) {
for (const img of out.images) {
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
if (!src.startsWith("blob:")) {
fetch(src, { method: "HEAD" }).then(res => {
if (res.status === 404) {
const liveState = store.getPrompt(state.pid);
if (liveState && liveState.nodeOutputs) {
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
store.updatePrompt(liveState.pid, liveState);
syncCardButtonVisibility(cardObj, liveState);
}
}
}).catch(() => {});
}
}
}
cardObj.leftHoverBtn.style.removeProperty("display");
cardObj.leftHoverBtn.style.display = "inline-flex";
cardObj.leftHoverBtn.onclick = (ev) => {
cardObj.leftHoverBtn.onclick = async (ev) => {
ev.stopPropagation();
// On click: verify distinct outputs still exist before opening
const liveState = store.getPrompt(state.pid) || state;
const curOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
const primSigs = new Set((liveState.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
const curDistinct = curOutputs.filter(o => o.images && o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
if (curDistinct.length === 0) {
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
return;
}
let anyMissing = false;
for (const out of curDistinct) {
for (const img of out.images) {
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
if (!src.startsWith("blob:")) {
try {
const res = await fetch(src, { method: "HEAD" });
if (res.status === 404) {
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
anyMissing = true;
}
} catch (e) {}
}
}
}
if (anyMissing) {
store.updatePrompt(liveState.pid, liveState);
syncCardButtonVisibility(cardObj, liveState);
const remaining = getRunOutputs(liveState.nodeOutputs, liveState.workflow)
.filter(o => o.images && o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
if (remaining.length === 0) {
return; // All intermediate outputs were wiped; button is now hidden
}
}
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| store.ui.cardStack?.parentElement
|| store.ui.cardStack;
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
store.openOutputsSubmenu(state.pid);
store.openOutputsSubmenu(liveState.pid);
};
} else {
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
@@ -190,7 +251,8 @@ export function updateCardProgressTargeted(cardObj, progress, activeNodeName, sh
// Fast-path: update live preview image directly on active card
if (state && state.images && state.images.length > 0) {
const firstImg = state.images[0];
const previewUrl = firstImg.url || (firstImg.filename ? `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}` : null);
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
const previewUrl = firstImg.url || (firstImg.filename ? `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}${randParam}` : null);
if (previewUrl && cardObj.firstImgElement && cardObj.firstImgElement.tagName === "IMG") {
const currentSrc = cardObj.firstImgElement.getAttribute("src") || cardObj.firstImgElement.src;
if (currentSrc !== previewUrl && !cardObj.firstImgElement.src.endsWith(previewUrl)) {
@@ -405,8 +467,21 @@ export function getOrCreateCard(state, callbacks = {}) {
const allOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
allOutputs.forEach(out => (out.images || []).forEach(addFile));
let deletedCount = 0;
let lastAction = "trashed";
for (const fileItem of filesToDelete) {
await deleteFileOnServer(fileItem);
const action = await deleteFileOnServer(fileItem);
if (action) {
deletedCount++;
lastAction = action;
}
}
if (deletedCount > 0) {
const msg = lastAction === "trashed"
? `Moved ${deletedCount} file${deletedCount > 1 ? "s" : ""} to Recycle Bin.`
: `Permanently deleted ${deletedCount} file${deletedCount > 1 ? "s" : ""}.`;
showNotification(msg, "success", "Delete Files");
}
}
@@ -451,12 +526,14 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
if (state.status === PromptStatus.PENDING && !showPendingSummary) {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.title = "Cancel Queued Task";
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
cancelPendingTask(state.pid);
};
} else if (state.status === PromptStatus.ACTIVE) {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.title = "Interrupt Execution";
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
interruptActive();
+12 -4
View File
@@ -50,8 +50,11 @@ export async function openFileOrFolder(img) {
a.remove();
}
import { app } from "/scripts/app.js";
export async function deleteFileOnServer(fileItem) {
if (!fileItem || !fileItem.filename) return false;
if (!fileItem || !fileItem.filename) return null;
const isPermanent = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Permanent Delete on Disk Deletion") ?? false;
try {
const res = await fetch("/comfy-sidebar/delete-file", {
method: "POST",
@@ -59,13 +62,18 @@ export async function deleteFileOnServer(fileItem) {
body: JSON.stringify({
filename: fileItem.filename,
subfolder: fileItem.subfolder || "",
type: fileItem.type || "output"
type: fileItem.type || "output",
permanent: isPermanent
})
});
return res.ok;
if (res.ok) {
const data = await res.json();
return data.action || (isPermanent ? "deleted" : "trashed");
}
return null;
} catch (e) {
console.error("Comfy Sidebar: Failed to delete file on server", e);
return false;
return null;
}
}
+92 -15
View File
@@ -1,12 +1,14 @@
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "../utils/utils.js";
import { openFileOrFolder } from "./mediaActions.js";
import { openFileOrFolder, removeImageFromNodeOutputs } from "./mediaActions.js";
import { stopAllAudioPlayback, isAudioViewerOpen, setCurrentlyPlayingAudio, getCurrentlyPlayingAudio } from "./audioController.js";
import { showFullscreenPreview } from "../utils/comparison.js";
import { store } from "../core/store.js";
import { PromptStatus } from "../core/constants.js";
export function render3DCardPreview(cardObj, wrapper, src, img, state) {
let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
const imageAssets = state?.images?.filter(i => isImageFormat(i.filename || i.url)) || [];
const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null;
@@ -91,7 +93,8 @@ export function render3DCardPreview(cardObj, wrapper, src, img, state) {
export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper");
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
const filename = img.filename || getFilenameFromUrl(src) || "audio.wav";
const ext = filename.split('.').pop().toUpperCase();
@@ -272,8 +275,12 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
}
export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) {
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
if (playIcon) playIcon.remove();
let previewFile = wrapper.querySelector(".comfy-sidebar-file-wrapper");
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
const filename = img.filename || getFilenameFromUrl(src) || "output_file";
const ext = (filename.split('.').pop() || "FILE").toUpperCase();
const folderName = img.subfolder ? `${img.subfolder}/` : (img.type || "output");
@@ -364,7 +371,8 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
return;
}
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
const isVideo = isVideoFormat(src) || isVideoFormat(img.filename);
const is3D = is3DFormat(src) || is3DFormat(img.filename);
const isAudio = isAudioFormat(src) || isAudioFormat(img.filename);
@@ -441,18 +449,87 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
showFullscreenPreview([activeSrc], ev.shiftKey, state?.pid);
};
mediaEl.onerror = async () => {
if (src && !src.startsWith("blob:") && !is3D && !isAudio) {
try {
const res = await fetch(src, { method: "HEAD" });
if (res.status === 404) {
store.deletePrompt(state.pid);
if (cardObj.element) cardObj.element.remove();
return;
mediaEl.onerror = () => {
mediaEl.onerror = null;
if ((src && src.startsWith("blob:")) || is3D || isAudio) return;
const cardId = cardObj.element?.id || "";
// 1. If an intermediate output item is missing, remove only this item
if (cardId.startsWith("card-submenu-")) {
const parentState = store.getPrompt(state.pid);
if (parentState && parentState.nodeOutputs) {
if (state.nodeId && parentState.nodeOutputs[state.nodeId]) {
delete parentState.nodeOutputs[state.nodeId];
} else {
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
}
} catch (e) {}
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
store.updatePrompt(parentState.pid, parentState);
}
cardObj.element?.remove();
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
if (!remaining || remaining.length === 0) {
store.closeSubmenu();
}
return;
}
// 2. If a batch item is missing, remove only this item from the batch
if (cardId.startsWith("card-batch-")) {
const parentPid = state.parentPromptId || state.pid;
const parentState = store.getPrompt(parentPid);
if (parentState) {
if (Array.isArray(parentState.images)) {
const idx = parentState.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
if (idx > -1) parentState.images.splice(idx, 1);
}
if (parentState.nodeOutputs) {
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
}
store.updatePrompt(parentPid, parentState);
}
cardObj.element?.remove();
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
if (!remaining || remaining.length === 0) {
store.closeSubmenu();
}
return;
}
// 3. Main card: if this file is gone, clean it up
const liveState = store.getPrompt(state.pid);
if (liveState) {
if (Array.isArray(liveState.images)) {
liveState.images = liveState.images.filter(i =>
!(i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""))
);
}
if (liveState.nodeOutputs) {
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
}
// If the card still has other images (e.g. from a batch), persist and switch to next image
if (liveState.images && liveState.images.length > 0) {
cardObj.currentImageIndex = 0;
cardObj.lastImagesSignature = "";
store.updatePrompt(liveState.pid, liveState);
return;
}
// If the card has text outputs or is still in progress, persist
if ((liveState.texts && liveState.texts.length > 0) || (liveState.status && liveState.status !== PromptStatus.COMPLETED)) {
cardObj.lastImagesSignature = "";
store.updatePrompt(liveState.pid, liveState);
return;
}
// Only delete the main card when all items belonging to it are gone
store.deletePrompt(liveState.pid);
cardObj.element?.remove();
return;
}
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
};
const applyDimensions = (width, height) => {
+167 -39
View File
@@ -7,11 +7,87 @@ import { updateSidebarBadge, centerAndSelectCanvasNode } from "../comfy/adapter.
import { matchesFilter, getRunOutputs, isImageFormat } from "../utils/utils.js";
import { renderCardImages } from "./mediaPreview.js";
import { deleteFileOnServer, removeImageFromNodeOutputs, copyImageToClipboard, findNodeIdForImage } from "./mediaActions.js";
import { toggleIgnoreActiveNode } from "../comfy/nodes.js";
import { renumberNodesTopologically, showNotification } from "../utils/nodeRenumber.js";
let scrollToTopBtnEl = null;
let globalClickRegistered = false;
let renderAnimationFrameId = null;
let activeTimerInterval = null;
let currentColumnCount = 1;
function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
if (!cardStack) return;
// 1. Full-width container (for banners like Pending Queue summary)
let bannerContainer = cardStack.querySelector('.comfy-sidebar-banners');
if (!bannerContainer) {
bannerContainer = document.createElement('div');
bannerContainer.className = 'comfy-sidebar-banners';
Object.assign(bannerContainer.style, { display: 'flex', flexDirection: 'column', gap: '12px', width: '100%' });
cardStack.insertBefore(bannerContainer, cardStack.firstChild);
}
fullWidthEls.forEach((el, i) => {
if (bannerContainer.children[i] !== el) {
bannerContainer.insertBefore(el, bannerContainer.children[i] || null);
}
});
while (bannerContainer.children.length > fullWidthEls.length) {
bannerContainer.removeChild(bannerContainer.lastChild);
}
bannerContainer.style.display = fullWidthEls.length > 0 ? 'flex' : 'none';
bannerContainer.style.marginBottom = fullWidthEls.length > 0 ? '12px' : '0';
// 2. Masonry Columns container
let columnsWrapper = cardStack.querySelector('.comfy-sidebar-masonry-wrapper');
if (!columnsWrapper) {
columnsWrapper = document.createElement('div');
columnsWrapper.className = 'comfy-sidebar-masonry-wrapper';
Object.assign(columnsWrapper.style, { display: 'flex', gap: '12px', alignItems: 'flex-start', width: '100%' });
cardStack.appendChild(columnsWrapper);
}
while (columnsWrapper.children.length < cols) {
const colDiv = document.createElement('div');
colDiv.className = 'comfy-sidebar-masonry-col';
Object.assign(colDiv.style, { flex: '1', display: 'flex', flexDirection: 'column', gap: '12px', minWidth: '0' });
columnsWrapper.appendChild(colDiv);
}
while (columnsWrapper.children.length > cols) {
columnsWrapper.removeChild(columnsWrapper.lastChild);
}
// Distribute cards into the shortest column so short cards stack next to tall cards
const colBuckets = Array.from({ length: cols }, () => []);
const colHeights = new Array(cols).fill(0);
cardEls.forEach((cardEl) => {
let shortestCol = 0;
for (let c = 1; c < cols; c++) {
if (colHeights[c] < colHeights[shortestCol]) {
shortestCol = c;
}
}
colBuckets[shortestCol].push(cardEl);
const cardH = cardEl.offsetHeight > 0 ? cardEl.offsetHeight : (cardEl._cachedHeight || 160);
cardEl._cachedHeight = cardH;
colHeights[shortestCol] += cardH + 12;
});
for (let c = 0; c < cols; c++) {
const colDiv = columnsWrapper.children[c];
const bucket = colBuckets[c];
bucket.forEach((el, i) => {
if (colDiv.children[i] !== el) {
colDiv.insertBefore(el, colDiv.children[i] || null);
}
});
while (colDiv.children.length > bucket.length) {
colDiv.removeChild(colDiv.lastChild);
}
}
}
function getScrollContainer() {
if (!store.ui.cardStack) return null;
@@ -112,7 +188,20 @@ export function setupSidebarView() {
standardHeader.appendChild(titleGroup);
const actionsGroup = document.createElement("div");
Object.assign(actionsGroup.style, { display: "flex", gap: "6px", alignItems: "center" });
Object.assign(actionsGroup.style, { display: "flex", gap: "5px", alignItems: "center" });
const createInstantBtn = (iconClass, tooltip, onClickFn) => {
const btn = document.createElement("button");
btn.className = `${iconClass} comfy-sidebar-header-btn`;
btn.title = tooltip;
btn.addEventListener("click", async (ev) => {
ev.stopPropagation();
btn.style.transform = "scale(0.88)";
setTimeout(() => { btn.style.transform = ""; }, 120);
await onClickFn();
});
return btn;
};
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
const btn = document.createElement("button");
@@ -146,6 +235,31 @@ export function setupSidebarView() {
return btn;
};
const btnToggleIgnore = createInstantBtn("pi pi-eye-slash", "Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)", () => {
const raw = app.canvas?.selected_nodes;
const selected = raw instanceof Map || raw instanceof Set
? Array.from(raw.values())
: (Array.isArray(raw) ? raw : (raw && typeof raw === "object" ? Object.values(raw) : []));
const selectedNodes = selected.length > 0 ? selected : (app.canvas?.selected_node ? [app.canvas.selected_node] : []);
if (selectedNodes.length === 0) {
showNotification("No nodes selected. Click a node on the canvas first to toggle ignore.", "warn");
return;
}
toggleIgnoreActiveNode(() => renderSidebar());
});
const btnRenumber = createInstantBtn("pi pi-sort-numeric-down", "Renumber All Nodes by Execution Order (Alt+R)", async () => {
await renumberNodesTopologically();
});
const divider = document.createElement("div");
Object.assign(divider.style, {
width: "1px", height: "14px", background: "var(--border-color, #444)",
margin: "0 2px", opacity: "0.6"
});
const btnClearInterrupted = createActionBtn("pi pi-eraser", "Clear Cancelled & Failed", "#ffc107", async () => {
await clearCancelledOrFailed();
renderSidebar();
@@ -156,6 +270,9 @@ export function setupSidebarView() {
renderSidebar();
});
actionsGroup.appendChild(btnToggleIgnore);
actionsGroup.appendChild(btnRenumber);
actionsGroup.appendChild(divider);
actionsGroup.appendChild(btnClearInterrupted);
actionsGroup.appendChild(btnClearAll);
standardHeader.appendChild(actionsGroup);
@@ -213,7 +330,13 @@ export function setupSidebarView() {
const cardStack = document.createElement("div");
store.ui.cardStack = cardStack;
Object.assign(cardStack.style, { flex: "1", overflowY: "visible", display: "block", paddingBottom: "28px" });
Object.assign(cardStack.style, {
flex: "1",
overflowY: "visible",
display: "flex",
flexDirection: "column",
paddingBottom: "28px"
});
container.appendChild(cardStack);
scrollToTopBtnEl = document.createElement("button");
@@ -233,9 +356,9 @@ export function setupSidebarView() {
new ResizeObserver((entries) => {
const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
const cols = Math.max(1, Math.floor(entries[0].contentRect.width / (threshold / 2)));
if (store.ui.cardStack) {
store.ui.cardStack.style.columnCount = cols.toString();
store.ui.cardStack.style.columnGap = cols > 1 ? "12px" : "0";
if (cols !== currentColumnCount) {
currentColumnCount = cols;
renderSidebar();
}
}).observe(container);
@@ -389,7 +512,11 @@ export function renderSidebar() {
resetDelete();
if (shouldDeleteFromDisk && img && img.filename) {
await deleteFileOnServer(img);
const action = await deleteFileOnServer(img);
if (action) {
const msg = action === "trashed" ? "Moved file to Recycle Bin." : "Permanently deleted file.";
showNotification(msg, "success", "Delete File");
}
}
const pid = batchInfo.parentPromptId || batchInfo.pid;
@@ -504,14 +631,7 @@ export function renderSidebar() {
targetElements.push(cardObj.element);
});
targetElements.forEach((el, index) => {
if (store.ui.cardStack.children[index] !== el) {
store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null);
}
});
while (store.ui.cardStack.children.length > targetElements.length) {
store.ui.cardStack.removeChild(store.ui.cardStack.lastChild);
}
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
const scrollEl = getScrollContainer();
if (scrollEl) scrollEl.scrollTop = 0;
@@ -568,7 +688,8 @@ export function renderSidebar() {
const emptyCard = document.createElement("div");
emptyCard.className = "comfy-sidebar-card completed";
Object.assign(emptyCard.style, {
padding: "16px", textAlign: "center", color: "var(--desc-color, #aaa)", fontSize: "12px"
padding: "16px", textAlign: "center", color: "var(--desc-color, #aaa)", fontSize: "12px",
gridColumn: "1 / -1"
});
emptyCard.textContent = "No separate intermediate outputs found.";
targetElements.push(emptyCard);
@@ -662,8 +783,17 @@ export function renderSidebar() {
resetDelete();
if (shouldDeleteFromDisk && out.images) {
let count = 0;
let lastAct = "trashed";
for (const imgItem of out.images) {
await deleteFileOnServer(imgItem);
const action = await deleteFileOnServer(imgItem);
if (action) { count++; lastAct = action; }
}
if (count > 0) {
const msg = lastAct === "trashed"
? `Moved ${count} file${count > 1 ? "s" : ""} to Recycle Bin.`
: `Permanently deleted ${count} file${count > 1 ? "s" : ""}.`;
showNotification(msg, "success", "Delete Files");
}
}
@@ -749,6 +879,7 @@ export function renderSidebar() {
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, {
pid: st.pid,
nodeId: out.nodeId,
status: PromptStatus.COMPLETED,
images: out.images,
workflow: st.workflow,
@@ -777,13 +908,10 @@ export function renderSidebar() {
});
}
targetElements.forEach((el, index) => {
if (store.ui.cardStack.children[index] !== el) {
store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null);
}
});
while (store.ui.cardStack.children.length > targetElements.length) {
store.ui.cardStack.removeChild(store.ui.cardStack.lastChild);
if (outputs.length === 0) {
mountCards(store.ui.cardStack, targetElements, [], currentColumnCount);
} else {
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
}
updateScrollTopBtnVisibility();
@@ -817,7 +945,8 @@ export function renderSidebar() {
return updateCardDOM(cardObj, state, showPendingSummary, showWorkingNode);
};
const targetElements = [];
const bannerElements = [];
const cardElements = [];
const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length;
if (pendingCount > 0) {
@@ -827,8 +956,8 @@ export function renderSidebar() {
const el = document.createElement("div");
Object.assign(el.style, {
background: "var(--comfy-input-bg, #181818)", border: "2px solid #6c757d", borderRadius: "4px", padding: "10px",
marginBottom: "12px", textAlign: "center", fontSize: "12px", fontWeight: "bold",
color: "var(--desc-color, #aaa)", breakInside: "avoid", display: "flex", flexDirection: "column", gap: "8px"
textAlign: "center", fontSize: "12px", fontWeight: "bold",
color: "var(--desc-color, #aaa)", display: "flex", flexDirection: "column", gap: "8px"
});
const textDiv = document.createElement("div");
el.appendChild(textDiv);
@@ -846,7 +975,7 @@ export function renderSidebar() {
cardPool.set("pending-summary-card", pCard);
}
pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`;
targetElements.push(pCard.element);
bannerElements.push(pCard.element);
} else {
let btnCard = cardPool.get("pending-cancel-all-standalone");
if (!btnCard) {
@@ -854,7 +983,7 @@ export function renderSidebar() {
btn.textContent = "Cancel All Pending";
Object.assign(btn.style, {
background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "6px",
cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%", marginBottom: "12px", breakInside: "avoid"
cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%"
});
btn.onclick = async () => {
await cancelAllPending();
@@ -862,23 +991,22 @@ export function renderSidebar() {
btnCard = { element: btn };
cardPool.set("pending-cancel-all-standalone", btnCard);
}
targetElements.push(btnCard.element);
pendingTasks.forEach(st => targetElements.push(syncTaskCard(st)));
bannerElements.push(btnCard.element);
pendingTasks.forEach(st => cardElements.push(syncTaskCard(st)));
}
}
activeTasks.forEach(st => targetElements.push(syncTaskCard(st)));
completedTasks.forEach(st => targetElements.push(syncTaskCard(st)));
activeTasks.forEach(st => cardElements.push(syncTaskCard(st)));
completedTasks.forEach(st => cardElements.push(syncTaskCard(st)));
targetElements.forEach((el, index) => {
if (store.ui.cardStack.children[index] !== el) {
store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null);
}
});
while (store.ui.cardStack.children.length > targetElements.length) {
store.ui.cardStack.removeChild(store.ui.cardStack.lastChild);
const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
const containerWidth = store.ui.sidebarContainer?.clientWidth || 0;
if (containerWidth > 0) {
currentColumnCount = Math.max(1, Math.floor(containerWidth / (threshold / 2)));
}
mountCards(store.ui.cardStack, bannerElements, cardElements, currentColumnCount);
if (store.ui.mainQueueScrollTop !== null) {
const restorePos = store.ui.mainQueueScrollTop;
store.ui.mainQueueScrollTop = null;
+213
View File
@@ -0,0 +1,213 @@
import { app } from "/scripts/app.js";
import { syncAllNodeBadges } from "../comfy/nodes.js";
export function showNotification(message, type = "success", title = null) {
const summary = title || (type === "error" ? "Error" : type === "warn" ? "Warning" : "Comfy Sidebar");
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: type === "error" ? "error" : type === "warn" ? "warn" : "success",
summary,
detail: message,
life: 4000
});
return;
}
// Elegant non-blocking fallback if native toast is unavailable
const id = "comfy-sidebar-toast";
let toast = document.getElementById(id);
if (!toast) {
toast = document.createElement("div");
toast.id = id;
document.body.appendChild(toast);
}
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background: #18181b;
color: #f4f4f5;
padding: 12px 18px;
border-radius: 8px;
border-left: 4px solid ${type === "error" ? "#ef4444" : type === "warn" ? "#f59e0b" : "#10b981"};
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 99999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
line-height: 1.4;
max-width: 380px;
transition: opacity 0.25s ease, transform 0.25s ease;
opacity: 0;
transform: translateY(10px);
pointer-events: none;
`;
toast.innerHTML = `<strong style="color:${type === "error" ? "#f87171" : type === "warn" ? "#fbbf24" : "#34d399"}">${summary}</strong><div style="margin-top:2px;">${message}</div>`;
requestAnimationFrame(() => {
toast.style.opacity = "1";
toast.style.transform = "translateY(0)";
});
clearTimeout(toast._timeout);
toast._timeout = setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateY(10px)";
setTimeout(() => toast.remove(), 300);
}, 3500);
}
export async function renumberNodesTopologically() {
if (!app.graph || !app.graph._nodes || app.graph._nodes.length === 0) {
showNotification("No nodes found on canvas to renumber.", "warn");
return;
}
const confirmed = confirm(
"Renumbering will rewrite all node IDs on the canvas in topological execution order.\n\n" +
"This will fix intermediate node outputs showing as the final result, but may break external scripts that rely on specific node IDs\n\n" +
"Do you want to proceed?"
);
if (!confirmed) return;
try {
// Compute LiteGraph's topological execution order
if (typeof app.graph.computeExecutionOrder === "function") {
app.graph.computeExecutionOrder(false);
}
// Sort nodes by order (lowest executes first; highest executes last)
const sortedNodes = [...app.graph._nodes].sort((a, b) => {
const orderA = typeof a.order === "number" ? a.order : Infinity;
const orderB = typeof b.order === "number" ? b.order : Infinity;
if (orderA !== orderB) return orderA - orderB;
const posXA = Array.isArray(a.pos) ? a.pos[0] : 0;
const posXB = Array.isArray(b.pos) ? b.pos[0] : 0;
if (posXA !== posXB) return posXA - posXB;
const posYA = Array.isArray(a.pos) ? a.pos[1] : 0;
const posYB = Array.isArray(b.pos) ? b.pos[1] : 0;
return posYA - posYB;
});
const oldToNew = new Map();
sortedNodes.forEach((node, index) => {
const newId = index + 1;
oldToNew.set(node.id, newId);
oldToNew.set(String(node.id), newId);
});
// 1. Remap node IDs in-place on app.graph
app.graph._nodes_by_id = {};
sortedNodes.forEach(node => {
const oldId = node.id;
const newId = oldToNew.get(oldId);
if (newId != null) {
// Update internal widget IDs like "$2034-0" (PreviewBridge)
if (Array.isArray(node.widgets)) {
for (const w of node.widgets) {
if (typeof w.value === "string" && w.value.startsWith(`$${oldId}-`)) {
w.value = w.value.replace(`$${oldId}-`, `$${newId}-`);
}
}
}
node.id = newId;
app.graph._nodes_by_id[newId] = node;
}
});
// 2. Remap links
if (app.graph.links) {
const links = app.graph.links instanceof Map
? Array.from(app.graph.links.values())
: Object.values(app.graph.links);
links.forEach(link => {
if (!link) return;
if (oldToNew.has(link.origin_id)) link.origin_id = oldToNew.get(link.origin_id);
if (oldToNew.has(link.target_id)) link.target_id = oldToNew.get(link.target_id);
});
}
app.graph.last_node_id = sortedNodes.length;
app.graph.setDirtyCanvas(true, true);
if (app.canvas?.draw) {
app.canvas.draw(true, true);
}
syncAllNodeBadges();
const lastNode = sortedNodes[sortedNodes.length - 1];
const lastNodeName = lastNode ? (lastNode.title || lastNode.type) : "SaveImage";
showNotification(`Renumbered ${sortedNodes.length} nodes by execution order. Latest node: #${lastNode.id} (${lastNodeName}).`, "success");
} catch (err) {
console.error("ComfySidebar: Failed to renumber nodes", err);
showNotification("Failed to renumber nodes: " + err.message, "error");
}
}
export async function setManualNodeId(targetNode = null) {
if (!app.graph) return;
const node = targetNode || (app.canvas?.selected_nodes ? Object.values(app.canvas.selected_nodes)[0] : null);
if (!node) {
showNotification("Please select a node first to change its ID.", "warn");
return;
}
const currentId = node.id;
const input = prompt(`Enter new ID for node #${currentId} (${node.title || node.type}):`, String(currentId));
if (!input) return;
const newId = parseInt(input.trim(), 10);
if (isNaN(newId) || newId <= 0) {
showNotification("Please enter a valid positive number.", "error");
return;
}
if (newId === currentId) return;
try {
const existingNode = app.graph.getNodeById ? app.graph.getNodeById(newId) : null;
if (existingNode && existingNode !== node) {
const confirmSwap = confirm(`Node ID #${newId} is already used by "${existingNode.title || existingNode.type}". Swap their IDs?`);
if (!confirmSwap) return;
}
const oldToNew = new Map();
if (existingNode && existingNode !== node) {
oldToNew.set(currentId, newId);
oldToNew.set(newId, currentId);
existingNode.id = currentId;
} else {
oldToNew.set(currentId, newId);
}
node.id = newId;
// Rebuild lookup map
app.graph._nodes_by_id = {};
for (const n of app.graph._nodes) {
app.graph._nodes_by_id[n.id] = n;
}
// Remap links
if (app.graph.links) {
const links = app.graph.links instanceof Map
? Array.from(app.graph.links.values())
: Object.values(app.graph.links);
links.forEach(link => {
if (!link) return;
if (oldToNew.has(link.origin_id)) link.origin_id = oldToNew.get(link.origin_id);
if (oldToNew.has(link.target_id)) link.target_id = oldToNew.get(link.target_id);
});
}
app.graph.last_node_id = Math.max(app.graph.last_node_id || 0, newId, app.graph._nodes.length);
app.graph.setDirtyCanvas(true, true);
if (app.canvas?.draw) {
app.canvas.draw(true, true);
}
syncAllNodeBadges();
showNotification(`Assigned node ID #${newId} to "${node.title || node.type}".`, "success");
} catch (err) {
console.error("ComfySidebar: Failed to set node ID", err);
showNotification("Failed to set node ID: " + err.message, "error");
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export function injectStyles() {
.comfy-sidebar-card {
background: var(--comfy-input-bg, #181818);
border-radius: 4px; padding: 8px; position: relative;
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
min-height: 80px; margin-bottom: 0px; break-inside: avoid;
user-select: none; -webkit-user-select: none;
border: 2px solid var(--border-color, #333);
color: var(--comfy-input-color, var(--fg-color, #eee));
+9 -17
View File
@@ -36,19 +36,9 @@ async function getThreeLibs() {
};
try {
let loaded = null;
// 1. Try local Python proxy on 'self' (bypasses CSP & --disable-api-nodes)
try {
loaded = await loadFromBase("/comfy-sidebar/three-proxy/");
window.__COMFY_SIDEBAR_THREE_BASE = "/comfy-sidebar/three-proxy/";
} catch (proxyErr) {
// 2. Fall back to direct CDN if proxy is unreachable
console.warn("Comfy Sidebar: Proxy unavailable, trying direct CDN...", proxyErr);
loaded = await loadFromBase("https://esm.sh/");
window.__COMFY_SIDEBAR_THREE_BASE = "https://esm.sh/";
}
const { three, controls, gltf, obj, stl, ply } = loaded;
const base = "https://esm.sh/";
window.__COMFY_SIDEBAR_THREE_BASE = base;
const { three, controls, gltf, obj, stl, ply } = await loadFromBase(base);
cachedThreeLibs = {
...three,
OrbitControls: controls.OrbitControls || controls.default || controls,
@@ -232,7 +222,7 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
loadingSpinner.style.display = "flex";
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> Exporting ${format}...`;
const base = window.__COMFY_SIDEBAR_THREE_BASE || "/comfy-sidebar/three-proxy/";
const base = window.__COMFY_SIDEBAR_THREE_BASE || "https://esm.sh/";
if (format === "OBJ") {
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/OBJExporter.js`);
const OBJExporter = mod.OBJExporter || mod.default || mod;
@@ -283,9 +273,11 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
THREE = await getThreeLibs();
if (!THREE) {
loadingSpinner.innerHTML = `
<div style="text-align: center; max-width: 320px; line-height: 1.4;">
<span style="color: #f87171; font-weight: bold;">Failed to load 3D engine.</span><br>
<span style="font-size: 11px; color: #94a3b8;">Blocked by ComfyUI Content Security Policy (CSP). Check if <code>--disable-api-nodes</code> is enabled in your startup arguments, or vendor Three.js locally.</span>
<div style="text-align: center; max-width: 340px; line-height: 1.4; padding: 6px;">
<span style="color: #f87171; font-weight: bold; font-size: 13px;">Failed to load 3D engine.</span><br>
<span style="font-size: 11px; color: #cbd5e1; display: inline-block; margin-top: 4px;">
Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.
</span>
</div>
`;
return;
+4 -2
View File
@@ -1,9 +1,11 @@
[project]
name = "comfy-sidebar"
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
version = "1.5.1"
version = "1.6.2"
license = { file = "LICENSE" }
dependencies = []
dependencies = [
"send2trash>=1.8.0",
]
[project.urls]
Repository = "https://github.com/m0rtus59/ComfyUI-ComfySidebar"
+1
View File
@@ -0,0 +1 @@
send2trash>=1.8.0