diff --git a/README.md b/README.md
index bcc31da..ff99fa7 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@ A compact and powerful layout overhaul for ComfyUI that makes generation results
+* **[NEW] Output Gallery sidebar:** Browse files and subfolders directly from your ComfyUI `output` directory without leaving the interface. Features dedicated cards for images, videos, audio, and 3D assets (`.glb`/`.gltf`), breadcrumb navigation, prompt metadata search across subfolders, and persistent sorting.
+
* **Runtime preview mode** combined with workflow overview makes ComfyUI practical for running and monitoring workflows in a more streamlined, queue-oriented way.
@@ -58,6 +60,7 @@ Open **Settings → Comfy Sidebar** to configure:
* Graph button visibility
* Unified top bar layout
* Permanent deletion behavior
+* Output Gallery sidebar toggle
## ⌨️ Controls
diff --git a/__init__.py b/__init__.py
index bdcae9b..644be37 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,6 +1,7 @@
import os
import platform
import subprocess
+import json
from aiohttp import web
from server import PromptServer
import folder_paths
@@ -117,6 +118,138 @@ def open_file_in_os(target_path):
raise RuntimeError("Headless Linux server detected (no desktop GUI available).")
+def check_png_prompt_match(filepath, search_term):
+ """Checks PNG prompt metadata strictly for text inputs matching search."""
+ try:
+ with open(filepath, "rb") as f:
+ if f.read(8) != b"\x89PNG\r\n\x1a\n":
+ return False
+ while True:
+ len_b = f.read(4)
+ if len(len_b) < 4:
+ break
+ length = int.from_bytes(len_b, "big")
+ ctype = f.read(4)
+ if ctype in (b"tEXt", b"iTXt"):
+ data = f.read(length)
+ if data.startswith(b"prompt\x00") or b'"inputs"' in data:
+ text = data.decode("utf-8", errors="ignore")
+ idx = text.find("{")
+ if idx != -1:
+ try:
+ prompt_dict = json.loads(text[idx:])
+ for node in prompt_dict.values():
+ if isinstance(node, dict) and "inputs" in node:
+ for val in node["inputs"].values():
+ if isinstance(val, str) and search_term in val.lower():
+ return True
+ except Exception:
+ pass
+ elif ctype in (b"IDAT", b"IEND"):
+ break
+ else:
+ f.seek(length, 1)
+ f.seek(4, 1)
+ return False
+ except Exception:
+ return False
+
+
+@PromptServer.instance.routes.get("/comfy-sidebar/gallery/list")
+async def gallery_list_handler(request):
+ try:
+ subfolder = request.query.get("subfolder", "").strip().strip("/\\")
+ search = request.query.get("search", "").strip().lower()
+ output_dir = folder_paths.get_output_directory()
+
+ target_dir = os.path.realpath(os.path.join(output_dir, subfolder)) if subfolder else os.path.realpath(output_dir)
+
+ if not is_path_safe(output_dir, target_dir):
+ return web.json_response({"error": "Unsafe path"}, status=403)
+
+ if not os.path.exists(target_dir) or not os.path.isdir(target_dir):
+ return web.json_response({"subfolder": subfolder, "folders": [], "files": []})
+
+ folders = []
+ files = []
+
+ supported_exts = {
+ ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif",
+ ".mp4", ".webm", ".mov", ".mkv",
+ ".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac",
+ ".glb", ".gltf", ".obj", ".fbx", ".stl",
+ ".json"
+ }
+
+ if search:
+ # Recursive search in current directory and all subfolders
+ for root, dirs, fnames in os.walk(target_dir):
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
+ for fname in fnames:
+ if fname.startswith("."):
+ continue
+ ext = os.path.splitext(fname)[1].lower()
+ if ext in supported_exts:
+ fpath = os.path.join(root, fname)
+ matched = search in fname.lower()
+ if not matched and ext == ".png":
+ matched = check_png_prompt_match(fpath, search)
+
+ if matched:
+ rel_sub = os.path.relpath(root, output_dir).replace("\\", "/")
+ if rel_sub == ".":
+ rel_sub = ""
+ stat = os.stat(fpath)
+ files.append({
+ "filename": fname,
+ "subfolder": rel_sub,
+ "type": "output",
+ "mtime": stat.st_mtime,
+ "size": stat.st_size
+ })
+ else:
+ # Instant single-directory listing without reading files from disk
+ with os.scandir(target_dir) as entries:
+ for entry in entries:
+ if entry.name.startswith("."):
+ continue
+ try:
+ if entry.is_dir(follow_symlinks=False):
+ rel_sub = os.path.relpath(entry.path, output_dir).replace("\\", "/")
+ folders.append({
+ "name": entry.name,
+ "subfolder": rel_sub,
+ "mtime": entry.stat().st_mtime
+ })
+ elif entry.is_file(follow_symlinks=False):
+ ext = os.path.splitext(entry.name)[1].lower()
+ if ext in supported_exts:
+ rel_sub = os.path.relpath(os.path.dirname(entry.path), output_dir).replace("\\", "/")
+ if rel_sub == ".":
+ rel_sub = ""
+ stat = entry.stat()
+ files.append({
+ "filename": entry.name,
+ "subfolder": rel_sub,
+ "type": "output",
+ "mtime": stat.st_mtime,
+ "size": stat.st_size
+ })
+ except (PermissionError, FileNotFoundError):
+ continue
+
+ folders.sort(key=lambda x: x["name"].lower())
+ files.sort(key=lambda x: x["mtime"], reverse=True)
+
+ return web.json_response({
+ "subfolder": subfolder.replace("\\", "/"),
+ "folders": folders,
+ "files": files
+ })
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+
@PromptServer.instance.routes.post("/comfy-sidebar/open-folder")
async def open_folder_handler(request):
try:
diff --git a/js/locales/ar/main.json b/js/locales/ar/main.json
index aa1e7e1..d2daab7 100644
--- a/js/locales/ar/main.json
+++ b/js/locales/ar/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "تجاهل/تضمين العقد المحددة في قائمة الانتظار",
"Renumber All Nodes (Execution Order)": "إعادة ترقيم جميع العقد (ترتيب التنفيذ)",
"Set Selected Node ID...": "تعيين معرف العقدة المحددة...",
- "Set Node ID...": "تعيين معرف العقدة..."
+ "Set Node ID...": "تعيين معرف العقدة...",
+ "Gallery": "المعرض",
+ "Gallery (Outputs)": "المعرض (المخرجات)",
+ "Go Up One Level": "الانتقال لمستوى أعلى",
+ "Open Current Folder in File Manager": "فتح المجلد الحالي في مدير الملفات",
+ "Refresh Gallery": "تحديث المعرض",
+ "Filter gallery files...": "تصفية ملفات المعرض...",
+ "Loading files...": "جاري تحميل الملفات...",
+ "No files found in this folder.": "لم يتم العثور على ملفات في هذا المجلد.",
+ "Failed to load gallery from disk.": "فشل تحميل المعرض من القرص.",
+ "Sort: Newest First": "الترتيب: الأحدث أولاً",
+ "Sort: Oldest First": "الترتيب: الأقدم أولاً",
+ "Search Gallery (Filename or Prompt)": "البحث في المعرض (اسم الملف أو الموجه)",
+ "Search filenames or prompt metadata...": "البحث في أسماء الملفات أو بيانات الموجه..."
}
\ No newline at end of file
diff --git a/js/locales/ar/settings.json b/js/locales/ar/settings.json
index 74ba6c7..8c290e4 100644
--- a/js/locales/ar/settings.json
+++ b/js/locales/ar/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "إخفاء العناصر غير المرغوبة",
"Default": "افتراضي",
"Dock": "إرساء",
- "Hide": "إخفاء"
+ "Hide": "إخفاء",
+ "Enable Output Gallery Sidebar": "تمكين الشريط الجانبي لمعرض المخرجات"
}
\ No newline at end of file
diff --git a/js/locales/en/main.json b/js/locales/en/main.json
index 58b9f4e..e51bfb0 100644
--- a/js/locales/en/main.json
+++ b/js/locales/en/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "Toggle Ignore Selected Node(s) in Queue",
"Renumber All Nodes (Execution Order)": "Renumber All Nodes (Execution Order)",
"Set Selected Node ID...": "Set Selected Node ID...",
- "Set Node ID...": "Set Node ID..."
+ "Set Node ID...": "Set Node ID...",
+ "Gallery": "Gallery",
+ "Gallery (Outputs)": "Gallery (Outputs)",
+ "Go Up One Level": "Go Up One Level",
+ "Open Current Folder in File Manager": "Open Current Folder in File Manager",
+ "Refresh Gallery": "Refresh Gallery",
+ "Filter gallery files...": "Filter gallery files...",
+ "Loading files...": "Loading files...",
+ "No files found in this folder.": "No files found in this folder.",
+ "Failed to load gallery from disk.": "Failed to load gallery from disk.",
+ "Sort: Newest First": "Sort: Newest First",
+ "Sort: Oldest First": "Sort: Oldest First",
+ "Search Gallery (Filename or Prompt)": "Search Gallery (Filename or Prompt)",
+ "Search filenames or prompt metadata...": "Search filenames or prompt metadata..."
}
\ No newline at end of file
diff --git a/js/locales/en/settings.json b/js/locales/en/settings.json
index 086c722..598ae8f 100644
--- a/js/locales/en/settings.json
+++ b/js/locales/en/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "Hide Junk",
"Default": "Default",
"Dock": "Dock",
- "Hide": "Hide"
+ "Hide": "Hide",
+ "Enable Output Gallery Sidebar": "Enable Output Gallery Sidebar"
}
\ No newline at end of file
diff --git a/js/locales/es/main.json b/js/locales/es/main.json
index bfd52ad..2731a9f 100644
--- a/js/locales/es/main.json
+++ b/js/locales/es/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "Ignorar/Incluir nodo(s) seleccionado(s) en la cola",
"Renumber All Nodes (Execution Order)": "Renumerar todos los nodos (orden de ejecución)",
"Set Selected Node ID...": "Establecer ID del nodo seleccionado...",
- "Set Node ID...": "Establecer ID de nodo..."
+ "Set Node ID...": "Establecer ID de nodo...",
+ "Gallery": "Galería",
+ "Gallery (Outputs)": "Galería (Salidas)",
+ "Go Up One Level": "Subir un nivel",
+ "Open Current Folder in File Manager": "Abrir carpeta actual en el explorador de archivos",
+ "Refresh Gallery": "Actualizar galería",
+ "Filter gallery files...": "Filtrar archivos de la galería...",
+ "Loading files...": "Cargando archivos...",
+ "No files found in this folder.": "No se encontraron archivos en esta carpeta.",
+ "Failed to load gallery from disk.": "Error al cargar la galería desde el disco.",
+ "Sort: Newest First": "Ordenar: más recientes primero",
+ "Sort: Oldest First": "Ordenar: más antiguos primero",
+ "Search Gallery (Filename or Prompt)": "Buscar en galería (nombre de archivo o prompt)",
+ "Search filenames or prompt metadata...": "Buscar nombres de archivo o metadatos de prompt..."
}
\ No newline at end of file
diff --git a/js/locales/es/settings.json b/js/locales/es/settings.json
index ad4c82c..7c4e76e 100644
--- a/js/locales/es/settings.json
+++ b/js/locales/es/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "Ocultar elementos innecesarios",
"Default": "Por defecto",
"Dock": "Acoplar",
- "Hide": "Ocultar"
+ "Hide": "Ocultar",
+ "Enable Output Gallery Sidebar": "Habilitar barra lateral de galería de salidas"
}
\ No newline at end of file
diff --git a/js/locales/fa/main.json b/js/locales/fa/main.json
index 7dc881b..2b18326 100644
--- a/js/locales/fa/main.json
+++ b/js/locales/fa/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "نادیده گرفتن/فعالسازی گرههای انتخابشده در صف",
"Renumber All Nodes (Execution Order)": "شمارهگذاری مجدد همه گرهها (ترتیب اجرا)",
"Set Selected Node ID...": "تنظیم شناسه گره انتخابشده...",
- "Set Node ID...": "تنظیم شناسه گره..."
+ "Set Node ID...": "تنظیم شناسه گره...",
+ "Gallery": "گالری",
+ "Gallery (Outputs)": "گالری (خروجیها)",
+ "Go Up One Level": "یک سطح به بالا",
+ "Open Current Folder in File Manager": "باز کردن پوشه فعلی در مدیریت فایل",
+ "Refresh Gallery": "بهروزرسانی گالری",
+ "Filter gallery files...": "فیلتر کردن فایلهای گالری...",
+ "Loading files...": "در حال بارگذاری فایلها...",
+ "No files found in this folder.": "هیچ فایلی در این پوشه یافت نشد.",
+ "Failed to load gallery from disk.": "بارگذاری گالری از دیسک ناموفق بود.",
+ "Sort: Newest First": "مرتبسازی: جدیدترینها در ابتدا",
+ "Sort: Oldest First": "مرتبسازی: قدیمیترینها در ابتدا",
+ "Search Gallery (Filename or Prompt)": "جستجو در گالری (نام فایل یا پرامپت)",
+ "Search filenames or prompt metadata...": "جستجوی نام فایلها یا متادیتای پرامپت..."
}
\ No newline at end of file
diff --git a/js/locales/fa/settings.json b/js/locales/fa/settings.json
index 4090337..f07fbd7 100644
--- a/js/locales/fa/settings.json
+++ b/js/locales/fa/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "پنهان کردن موارد غیرضروری",
"Default": "پیشفرض",
"Dock": "اتصال",
- "Hide": "پنهان"
+ "Hide": "پنهان",
+ "Enable Output Gallery Sidebar": "فعالسازی نوار کناری گالری خروجیها"
}
\ No newline at end of file
diff --git a/js/locales/fr/main.json b/js/locales/fr/main.json
index 3060bb4..f8a607d 100644
--- a/js/locales/fr/main.json
+++ b/js/locales/fr/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "Ignorer/Activer les nœuds sélectionnés dans la file",
"Renumber All Nodes (Execution Order)": "Renuméroter tous les nœuds (ordre d'exécution)",
"Set Selected Node ID...": "Définir l'ID du nœud sélectionné...",
- "Set Node ID...": "Définir l'ID du nœud..."
+ "Set Node ID...": "Définir l'ID du nœud...",
+ "Gallery": "Galerie",
+ "Gallery (Outputs)": "Galerie (Sorties)",
+ "Go Up One Level": "Dossier parent",
+ "Open Current Folder in File Manager": "Ouvrir le dossier actuel dans le gestionnaire de fichiers",
+ "Refresh Gallery": "Actualiser la galerie",
+ "Filter gallery files...": "Filtrer les fichiers de la galerie...",
+ "Loading files...": "Chargement des fichiers...",
+ "No files found in this folder.": "Aucun fichier trouvé dans ce dossier.",
+ "Failed to load gallery from disk.": "Échec du chargement de la galerie depuis le disque.",
+ "Sort: Newest First": "Trier : Plus récents d'abord",
+ "Sort: Oldest First": "Trier : Plus anciens d'abord",
+ "Search Gallery (Filename or Prompt)": "Rechercher dans la galerie (nom de fichier ou prompt)",
+ "Search filenames or prompt metadata...": "Rechercher des noms de fichiers ou métadonnées de prompt..."
}
\ No newline at end of file
diff --git a/js/locales/fr/settings.json b/js/locales/fr/settings.json
index 7373994..65415bb 100644
--- a/js/locales/fr/settings.json
+++ b/js/locales/fr/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "Masquer les éléments inutiles",
"Default": "Par défaut",
"Dock": "Ancrer",
- "Hide": "Masquer"
+ "Hide": "Masquer",
+ "Enable Output Gallery Sidebar": "Activer la barre latérale de la galerie de sorties"
}
\ No newline at end of file
diff --git a/js/locales/he/main.json b/js/locales/he/main.json
index 987db81..e2896d4 100644
--- a/js/locales/he/main.json
+++ b/js/locales/he/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "התעלם/כלול צמתים נבחרים בתור",
"Renumber All Nodes (Execution Order)": "מספר מחדש את כל הצמתים (סדר ביצוע)",
"Set Selected Node ID...": "הגדר מזהה צומת נבחר...",
- "Set Node ID...": "הגדר מזהה צומת..."
+ "Set Node ID...": "הגדר מזהה צומת...",
+ "Gallery": "גלריה",
+ "Gallery (Outputs)": "גלריה (תוצרים)",
+ "Go Up One Level": "עלה שלב אחד למעלה",
+ "Open Current Folder in File Manager": "פתח תיקייה נוכחית בסייר הקבצים",
+ "Refresh Gallery": "רענן גלריה",
+ "Filter gallery files...": "סנן קבצי גלריה...",
+ "Loading files...": "טוען קבצים...",
+ "No files found in this folder.": "לא נמצאו קבצים בתיקייה זו.",
+ "Failed to load gallery from disk.": "טעינת הגלריה מהכונן נכשלה.",
+ "Sort: Newest First": "מיון: החדשים ביותר קודם",
+ "Sort: Oldest First": "מיון: הישנים ביותר קודם",
+ "Search Gallery (Filename or Prompt)": "חיפוש בגלריה (שם קובץ או פרומפט)",
+ "Search filenames or prompt metadata...": "חיפוש שמות קבצים או מטא-דאטה של פרומפט..."
}
\ No newline at end of file
diff --git a/js/locales/he/settings.json b/js/locales/he/settings.json
index 4e9d875..5c62b8a 100644
--- a/js/locales/he/settings.json
+++ b/js/locales/he/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "הסתר פריטים מיותרים",
"Default": "ברירת מחדל",
"Dock": "הצמד",
- "Hide": "הסתר"
+ "Hide": "הסתר",
+ "Enable Output Gallery Sidebar": "הפעל סרגל צד של גלריית תוצרים"
}
\ No newline at end of file
diff --git a/js/locales/it/main.json b/js/locales/it/main.json
index 5d801d8..77b3b15 100644
--- a/js/locales/it/main.json
+++ b/js/locales/it/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "Ignora/Attiva nodi selezionati nella coda",
"Renumber All Nodes (Execution Order)": "Rinumera tutti i nodi (ordine di esecuzione)",
"Set Selected Node ID...": "Imposta ID nodo selezionato...",
- "Set Node ID...": "Imposta ID nodo..."
+ "Set Node ID...": "Imposta ID nodo...",
+ "Gallery": "Galleria",
+ "Gallery (Outputs)": "Galleria (Output)",
+ "Go Up One Level": "Sali di un livello",
+ "Open Current Folder in File Manager": "Apri cartella corrente in Esplora file",
+ "Refresh Gallery": "Aggiorna galleria",
+ "Filter gallery files...": "Filtra file della galleria...",
+ "Loading files...": "Caricamento file...",
+ "No files found in this folder.": "Nessun file trovato in questa cartella.",
+ "Failed to load gallery from disk.": "Impossibile caricare la galleria dal disco.",
+ "Sort: Newest First": "Ordina: Più recenti prima",
+ "Sort: Oldest First": "Ordina: Più vecchi prima",
+ "Search Gallery (Filename or Prompt)": "Cerca nella galleria (nome file o prompt)",
+ "Search filenames or prompt metadata...": "Cerca nomi di file o metadati del prompt..."
}
\ No newline at end of file
diff --git a/js/locales/it/settings.json b/js/locales/it/settings.json
index 587d981..f21ed39 100644
--- a/js/locales/it/settings.json
+++ b/js/locales/it/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "Nascondi elementi inutili",
"Default": "Predefinito",
"Dock": "Aggancia",
- "Hide": "Nascondi"
+ "Hide": "Nascondi",
+ "Enable Output Gallery Sidebar": "Abilita barra laterale galleria di output"
}
\ No newline at end of file
diff --git a/js/locales/ja/main.json b/js/locales/ja/main.json
index 81ee9d2..a0e7539 100644
--- a/js/locales/ja/main.json
+++ b/js/locales/ja/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "キューで選択したノードを無視/解除",
"Renumber All Nodes (Execution Order)": "すべてのノード番号を再割り当て (実行順)",
"Set Selected Node ID...": "選択したノードIDを設定...",
- "Set Node ID...": "ノードIDを設定..."
+ "Set Node ID...": "ノードIDを設定...",
+ "Gallery": "ギャラリー",
+ "Gallery (Outputs)": "ギャラリー (出力)",
+ "Go Up One Level": "1つ上の階層へ",
+ "Open Current Folder in File Manager": "現在のフォルダーをファイルマネージャーで開く",
+ "Refresh Gallery": "ギャラリーを更新",
+ "Filter gallery files...": "ギャラリーファイルを絞り込み...",
+ "Loading files...": "ファイルを読み込み中...",
+ "No files found in this folder.": "このフォルダーにはファイルがありません。",
+ "Failed to load gallery from disk.": "ディスクからギャラリーを読み込めませんでした。",
+ "Sort: Newest First": "並び替え:新しい順",
+ "Sort: Oldest First": "並び替え:古い順",
+ "Search Gallery (Filename or Prompt)": "ギャラリーを検索 (ファイル名またはプロンプト)",
+ "Search filenames or prompt metadata...": "ファイル名またはプロンプトを検索..."
}
\ No newline at end of file
diff --git a/js/locales/ja/settings.json b/js/locales/ja/settings.json
index f27bf37..adc8c59 100644
--- a/js/locales/ja/settings.json
+++ b/js/locales/ja/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "不要な項目を非表示",
"Default": "デフォルト",
"Dock": "ドック",
- "Hide": "非表示"
+ "Hide": "非表示",
+ "Enable Output Gallery Sidebar": "出力ギャラリーのサイドバーを有効化"
}
\ No newline at end of file
diff --git a/js/locales/ko/main.json b/js/locales/ko/main.json
index 6e09601..2e0d9ef 100644
--- a/js/locales/ko/main.json
+++ b/js/locales/ko/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "대기열에서 선택한 노드 무시/해제",
"Renumber All Nodes (Execution Order)": "모든 노드 재번호 매기기 (실행 순서)",
"Set Selected Node ID...": "선택한 노드 ID 설정...",
- "Set Node ID...": "노드 ID 설정..."
+ "Set Node ID...": "노드 ID 설정...",
+ "Gallery": "갤러리",
+ "Gallery (Outputs)": "갤러리 (출력)",
+ "Go Up One Level": "한 수준 위로",
+ "Open Current Folder in File Manager": "파일 탐색기에서 현재 폴더 열기",
+ "Refresh Gallery": "갤러리 새로고침",
+ "Filter gallery files...": "갤러리 파일 필터링...",
+ "Loading files...": "파일 로딩 중...",
+ "No files found in this folder.": "이 폴더에 파일이 없습니다.",
+ "Failed to load gallery from disk.": "디스크에서 갤러리를 불러오지 못했습니다.",
+ "Sort: Newest First": "정렬: 최신순",
+ "Sort: Oldest First": "정렬: 오래된순",
+ "Search Gallery (Filename or Prompt)": "갤러리 검색 (파일명 또는 프롬프트)",
+ "Search filenames or prompt metadata...": "파일명 또는 프롬프트 메타데이터 검색..."
}
\ No newline at end of file
diff --git a/js/locales/ko/settings.json b/js/locales/ko/settings.json
index 58a09d3..48eec38 100644
--- a/js/locales/ko/settings.json
+++ b/js/locales/ko/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "불필요한 항목 숨기기",
"Default": "기본값",
"Dock": "도킹",
- "Hide": "숨기기"
+ "Hide": "숨기기",
+ "Enable Output Gallery Sidebar": "출력 갤러리 사이드바 활성화"
}
\ No newline at end of file
diff --git a/js/locales/pt-br/main.json b/js/locales/pt-br/main.json
index 69b777b..78d0dd2 100644
--- a/js/locales/pt-br/main.json
+++ b/js/locales/pt-br/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "Ignorar/Incluir nós selecionados na fila",
"Renumber All Nodes (Execution Order)": "Renumerar todos os nós (ordem de execução)",
"Set Selected Node ID...": "Definir ID do nó selecionado...",
- "Set Node ID...": "Definir ID do nó..."
+ "Set Node ID...": "Definir ID do nó...",
+ "Gallery": "Galeria",
+ "Gallery (Outputs)": "Galeria (Saídas)",
+ "Go Up One Level": "Subir um nível",
+ "Open Current Folder in File Manager": "Abrir pasta atual no gerenciador de arquivos",
+ "Refresh Gallery": "Atualizar galeria",
+ "Filter gallery files...": "Filtrar arquivos da galeria...",
+ "Loading files...": "Carregando arquivos...",
+ "No files found in this folder.": "Nenhum arquivo encontrado nesta pasta.",
+ "Failed to load gallery from disk.": "Falha ao carregar a galeria do disco.",
+ "Sort: Newest First": "Ordenar: Mais recentes primeiro",
+ "Sort: Oldest First": "Ordenar: Mais antigos primeiro",
+ "Search Gallery (Filename or Prompt)": "Pesquisar na galeria (nome do arquivo ou prompt)",
+ "Search filenames or prompt metadata...": "Pesquisar nomes de arquivos ou metadados de prompt..."
}
\ No newline at end of file
diff --git a/js/locales/pt-br/settings.json b/js/locales/pt-br/settings.json
index b83832f..1b65767 100644
--- a/js/locales/pt-br/settings.json
+++ b/js/locales/pt-br/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "Ocultar itens desnecessários",
"Default": "Padrão",
"Dock": "Fixar",
- "Hide": "Ocultar"
+ "Hide": "Ocultar",
+ "Enable Output Gallery Sidebar": "Ativar barra lateral da galeria de saídas"
}
\ No newline at end of file
diff --git a/js/locales/ru/main.json b/js/locales/ru/main.json
index 003528f..1359dd4 100644
--- a/js/locales/ru/main.json
+++ b/js/locales/ru/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "Игнорировать выбранные ноды в очереди",
"Renumber All Nodes (Execution Order)": "Перенумеровать ноды (порядок выполнения)",
"Set Selected Node ID...": "Задать ID выбранной ноды...",
- "Set Node ID...": "Задать ID ноды..."
+ "Set Node ID...": "Задать ID ноды...",
+ "Gallery": "Галерея",
+ "Gallery (Outputs)": "Галерея (Результаты)",
+ "Go Up One Level": "На один уровень вверх",
+ "Open Current Folder in File Manager": "Открыть текущую папку в проводнике",
+ "Refresh Gallery": "Обновить галерею",
+ "Filter gallery files...": "Фильтр файлов галереи...",
+ "Loading files...": "Загрузка файлов...",
+ "No files found in this folder.": "В этой папке нет файлов.",
+ "Failed to load gallery from disk.": "Не удалось загрузить галерею с диска.",
+ "Sort: Newest First": "Сортировка: сначала новые",
+ "Sort: Oldest First": "Сортировка: сначала старые",
+ "Search Gallery (Filename or Prompt)": "Поиск по галерее (имя файла или промпт)",
+ "Search filenames or prompt metadata...": "Поиск по именам файлов или промптам..."
}
\ No newline at end of file
diff --git a/js/locales/ru/settings.json b/js/locales/ru/settings.json
index c301d07..ea6e261 100644
--- a/js/locales/ru/settings.json
+++ b/js/locales/ru/settings.json
@@ -29,5 +29,6 @@
"Hide Junk": "Скрыть лишнее",
"Default": "По умолчанию",
"Dock": "Закрепить",
- "Hide": "Скрыть"
+ "Hide": "Скрыть",
+ "Enable Output Gallery Sidebar": "Включить боковую панель галереи результатов"
}
\ No newline at end of file
diff --git a/js/locales/tr/main.json b/js/locales/tr/main.json
index 2184b0c..f94c7a7 100644
--- a/js/locales/tr/main.json
+++ b/js/locales/tr/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "Kuyrukta Seçili Düğümleri Yoksay/Etkinleştir",
"Renumber All Nodes (Execution Order)": "Tüm Düğümleri Yeniden Numaralandır (Çalıştırma Sırası)",
"Set Selected Node ID...": "Seçili Düğüm Kimliğini Belirle...",
- "Set Node ID...": "Düğüm Kimliğini Belirle..."
+ "Set Node ID...": "Düğüm Kimliğini Belirle...",
+ "Gallery": "Galeri",
+ "Gallery (Outputs)": "Galeri (Çıktılar)",
+ "Go Up One Level": "Bir Düzey Yukarı Çık",
+ "Open Current Folder in File Manager": "Mevcut Klasörü Dosya Yöneticisinde Aç",
+ "Refresh Gallery": "Galeriyi Yenile",
+ "Filter gallery files...": "Galeri dosyalarını filtrele...",
+ "Loading files...": "Dosyalar yükleniyor...",
+ "No files found in this folder.": "Bu klasörde dosya bulunamadı.",
+ "Failed to load gallery from disk.": "Galeri diskten yüklenemedi.",
+ "Sort: Newest First": "Sıralama: Önce En Yeniler",
+ "Sort: Oldest First": "Sıralama: Önce En Eskiler",
+ "Search Gallery (Filename or Prompt)": "Galeride Ara (Dosya Adı veya Prompt)",
+ "Search filenames or prompt metadata...": "Dosya adlarını veya prompt meta verilerini ara..."
}
\ No newline at end of file
diff --git a/js/locales/tr/settings.json b/js/locales/tr/settings.json
index b89c6dc..d9123b9 100644
--- a/js/locales/tr/settings.json
+++ b/js/locales/tr/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "Gereksiz Öğeleri Gizle",
"Default": "Varsayılan",
"Dock": "Sabitle",
- "Hide": "Gizle"
+ "Hide": "Gizle",
+ "Enable Output Gallery Sidebar": "Çıktı Galerisi Kenar Çubuğunu Etkinleştir"
}
\ No newline at end of file
diff --git a/js/locales/zh-tw/main.json b/js/locales/zh-tw/main.json
index ce98e46..8670eb2 100644
--- a/js/locales/zh-tw/main.json
+++ b/js/locales/zh-tw/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "在隊列中忽略選取節點",
"Renumber All Nodes (Execution Order)": "重新編號所有節點 (按執行順序)",
"Set Selected Node ID...": "設定選取節點 ID...",
- "Set Node ID...": "設定節點 ID..."
+ "Set Node ID...": "設定節點 ID...",
+ "Gallery": "圖庫",
+ "Gallery (Outputs)": "圖庫 (輸出)",
+ "Go Up One Level": "返回上一層",
+ "Open Current Folder in File Manager": "在檔案管理器中開啟目前資料夾",
+ "Refresh Gallery": "重新整理圖庫",
+ "Filter gallery files...": "篩選圖庫檔案...",
+ "Loading files...": "正在載入檔案...",
+ "No files found in this folder.": "此資料夾中未找到檔案。",
+ "Failed to load gallery from disk.": "從磁碟載入圖庫失敗。",
+ "Sort: Newest First": "排序:最新優先",
+ "Sort: Oldest First": "排序:最舊優先",
+ "Search Gallery (Filename or Prompt)": "搜尋圖庫 (檔案名稱或提示詞)",
+ "Search filenames or prompt metadata...": "搜尋檔案名稱或提示詞元資料..."
}
\ No newline at end of file
diff --git a/js/locales/zh-tw/settings.json b/js/locales/zh-tw/settings.json
index e03886f..064cd82 100644
--- a/js/locales/zh-tw/settings.json
+++ b/js/locales/zh-tw/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "隱藏雜項",
"Default": "預設",
"Dock": "停靠",
- "Hide": "隱藏"
+ "Hide": "隱藏",
+ "Enable Output Gallery Sidebar": "啟用輸出圖庫側邊欄"
}
\ No newline at end of file
diff --git a/js/locales/zh/main.json b/js/locales/zh/main.json
index 61ed5a6..8274774 100644
--- a/js/locales/zh/main.json
+++ b/js/locales/zh/main.json
@@ -101,5 +101,18 @@
"Toggle Ignore Selected Node(s) in Queue": "在队列中忽略所选节点",
"Renumber All Nodes (Execution Order)": "重新编号所有节点 (按执行顺序)",
"Set Selected Node ID...": "设置所选节点 ID...",
- "Set Node ID...": "设置节点 ID..."
+ "Set Node ID...": "设置节点 ID...",
+ "Gallery": "图库",
+ "Gallery (Outputs)": "图库 (输出)",
+ "Go Up One Level": "返回上一级",
+ "Open Current Folder in File Manager": "在文件管理器中打开当前文件夹",
+ "Refresh Gallery": "刷新图库",
+ "Filter gallery files...": "筛选图库文件...",
+ "Loading files...": "正在加载文件...",
+ "No files found in this folder.": "此文件夹中未找到文件。",
+ "Failed to load gallery from disk.": "从磁盘加载图库失败。",
+ "Sort: Newest First": "排序:最新优先",
+ "Sort: Oldest First": "排序:最早优先",
+ "Search Gallery (Filename or Prompt)": "搜索图库 (文件名或提示词)",
+ "Search filenames or prompt metadata...": "搜索文件名或提示词元数据..."
}
\ No newline at end of file
diff --git a/js/locales/zh/settings.json b/js/locales/zh/settings.json
index bbc427d..4f6296e 100644
--- a/js/locales/zh/settings.json
+++ b/js/locales/zh/settings.json
@@ -28,5 +28,6 @@
"Hide Junk": "隐藏杂项",
"Default": "默认",
"Dock": "停靠",
- "Hide": "隐藏"
+ "Hide": "隐藏",
+ "Enable Output Gallery Sidebar": "启用输出图库侧边栏"
}
\ No newline at end of file
diff --git a/js/sidebar.js b/js/sidebar.js
index 2b72e26..ba93aef 100644
--- a/js/sidebar.js
+++ b/js/sidebar.js
@@ -9,6 +9,7 @@ import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, s
import { initSessionAndHistory, syncQueue } from "./queue/queueService.js";
import { setupExecutionTracker } from "./queue/executionTracker.js";
import { setupSidebarView, renderSidebar, teardownSidebarView } from "./ui/sidebarView.js";
+import { setupGalleryView, teardownGalleryView } from "./ui/galleryView.js";
import { initI18n, t } from "./core/i18n.js";
import { updateCardProgressTargeted, cardPool } from "./ui/cardRenderer.js";
import { store } from "./core/store.js";
@@ -46,6 +47,32 @@ function registerSettings() {
defaultValue: false
});
+ const setGalleryButtonVisible = (visible) => {
+ try {
+ const buttons = document.querySelectorAll(".side-bar-button, [data-tab-id]");
+ for (const btn of buttons) {
+ if (btn.getAttribute("data-tab-id") === "classic-comfy-gallery" ||
+ btn.getAttribute("title")?.includes("Gallery") ||
+ btn.querySelector(".pi-folder")) {
+ btn.style.display = visible ? "" : "none";
+ break;
+ }
+ }
+ } catch (e) {
+ console.warn("[ComfySidebar] Error toggling gallery tab button:", e);
+ }
+ };
+
+ app.ui.settings.addSetting({
+ id: "Comfy Sidebar.Enable Gallery",
+ name: t("Enable Output Gallery Sidebar"),
+ type: "boolean",
+ defaultValue: true,
+ onChange: (val) => {
+ setGalleryButtonVisible(val ?? true);
+ }
+ });
+
app.ui.settings.addSetting({
id: "Comfy Sidebar.Permanent Delete on Disk Deletion",
name: t("Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)"),
@@ -446,15 +473,65 @@ app.registerExtension({
syncAllNodeBadges();
+ let queueScrollPos = 0;
+ let galleryScrollPos = 0;
+ let activeSidebarTab = "queue";
+
+ const getRealScrollEl = (childEl) => {
+ if (!childEl) return null;
+ return childEl.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
+ || childEl.parentElement
+ || childEl;
+ };
+
app.extensionManager.registerSidebarTab({
id: "classic-comfy-sidebar",
icon: "pi pi-images",
title: t("Queue"),
tooltip: `${t("Queue")} (Q)`,
type: "custom",
- render: (el) => { el.appendChild(sidebarContainer); }
+ render: (el) => {
+ document.querySelectorAll(".comfy-sidebar-scroll-top-btn").forEach(btn => { btn.style.display = "none"; });
+ const scrollParent = getRealScrollEl(el);
+ if (activeSidebarTab === "gallery" && scrollParent) {
+ galleryScrollPos = scrollParent.scrollTop;
+ }
+ activeSidebarTab = "queue";
+ el.replaceChildren(sidebarContainer);
+ requestAnimationFrame(() => {
+ const sp = getRealScrollEl(el);
+ if (sp) sp.scrollTop = queueScrollPos;
+ });
+ }
});
+ const galleryContainer = setupGalleryView();
+ app.extensionManager.registerSidebarTab({
+ id: "classic-comfy-gallery",
+ icon: "pi pi-folder",
+ title: t("Gallery"),
+ tooltip: t("Gallery (Outputs)"),
+ type: "custom",
+ render: (el) => {
+ document.querySelectorAll(".comfy-sidebar-scroll-top-btn").forEach(btn => { btn.style.display = "none"; });
+ const scrollParent = getRealScrollEl(el);
+ if (activeSidebarTab === "queue" && scrollParent) {
+ queueScrollPos = scrollParent.scrollTop;
+ }
+ activeSidebarTab = "gallery";
+ el.replaceChildren(galleryContainer);
+ requestAnimationFrame(() => {
+ const sp = getRealScrollEl(el);
+ if (sp) sp.scrollTop = galleryScrollPos;
+ });
+ }
+ });
+
+ const isGalleryEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Enable Gallery") ?? true;
+ if (!isGalleryEnabled) {
+ setTimeout(() => setGalleryButtonVisible(false), 50);
+ }
+
requestAnimationFrame(() => {
applySidebarOverride();
});
@@ -466,6 +543,11 @@ app.registerExtension({
}
destroyLayoutFix();
teardownSidebarView();
+ teardownGalleryView();
+
+ if (app.extensionManager?.unregisterSidebarTab) {
+ app.extensionManager.unregisterSidebarTab("classic-comfy-gallery");
+ }
if (activeKeydownHandler) {
document.removeEventListener("keydown", activeKeydownHandler, true);
diff --git a/js/ui/galleryView.js b/js/ui/galleryView.js
new file mode 100644
index 0000000..c3ab49d
--- /dev/null
+++ b/js/ui/galleryView.js
@@ -0,0 +1,633 @@
+import { app } from "/scripts/app.js";
+import { SettingIds, PromptStatus } from "../core/constants.js";
+import { t } from "../core/i18n.js";
+import { deleteFileOnServer, openFileOrFolder } from "./mediaActions.js";
+import { extractWorkflowFromPng, isImageFormat } from "../utils/utils.js";
+import { renderCardImages } from "./mediaPreview.js";
+import { showNotification } from "../utils/nodeRenumber.js";
+
+let galleryContainerEl = null;
+let cardStackEl = null;
+let scrollToTopBtnEl = null;
+let currentColumnCount = 1;
+let currentSubfolder = "";
+let currentSearchQuery = "";
+
+// Persistent sort order
+let sortOrder = "newest";
+try {
+ sortOrder = localStorage.getItem("ComfySidebar.GallerySortOrder") || "newest";
+} catch (e) {}
+
+let cachedFolders = [];
+let cachedFiles = [];
+let displayedFilesCount = 0;
+const BATCH_SIZE = 36;
+let isLoading = false;
+
+function formatFileSize(bytes) {
+ if (!bytes || bytes <= 0) return "";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+}
+
+function getScrollContainer() {
+ if (!cardStackEl) return null;
+ return cardStackEl.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
+ || cardStackEl.parentElement
+ || cardStackEl;
+}
+
+function updateScrollTopVisibility() {
+ if (!scrollToTopBtnEl) return;
+ if (!galleryContainerEl || !galleryContainerEl.isConnected) {
+ scrollToTopBtnEl.style.display = "none";
+ return;
+ }
+ const scrollEl = getScrollContainer();
+ if (scrollEl) {
+ scrollToTopBtnEl.style.display = scrollEl.scrollTop > 200 ? "flex" : "none";
+
+ if (scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 300) {
+ loadNextBatch();
+ }
+ }
+}
+
+async function fetchGalleryData(subfolder = "", searchQuery = "") {
+ try {
+ isLoading = true;
+ const normalized = subfolder.replace(/\\/g, "/");
+ const queryParams = new URLSearchParams({ subfolder: normalized });
+ if (searchQuery) queryParams.set("search", searchQuery);
+
+ const res = await fetch(`/comfy-sidebar/gallery/list?${queryParams.toString()}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ cachedFolders = data.folders || [];
+ cachedFiles = data.files || [];
+ currentSubfolder = (data.subfolder || "").replace(/\\/g, "/");
+ applySorting();
+ displayedFilesCount = 0;
+ } catch (e) {
+ console.error("[ComfySidebar] Failed to load gallery", e);
+ showNotification(t("Failed to load gallery from disk."), "error");
+ } finally {
+ isLoading = false;
+ }
+}
+
+function applySorting() {
+ if (sortOrder === "newest") {
+ cachedFiles.sort((a, b) => b.mtime - a.mtime);
+ } else {
+ cachedFiles.sort((a, b) => a.mtime - b.mtime);
+ }
+}
+
+function mountGalleryCards(cardStack, folderWrapperEl, cardEls, cols = 1, append = false) {
+ if (!cardStack) return;
+
+ const oldEmpty = cardStack.querySelector(".comfy-sidebar-gallery-empty-notice");
+ if (oldEmpty) oldEmpty.remove();
+
+ if (!append) {
+ let subfolderArea = cardStack.querySelector(".comfy-sidebar-subfolders-area");
+ if (!subfolderArea) {
+ subfolderArea = document.createElement("div");
+ subfolderArea.className = "comfy-sidebar-subfolders-area";
+ cardStack.insertBefore(subfolderArea, cardStack.firstChild);
+ }
+ subfolderArea.innerHTML = "";
+ if (folderWrapperEl) {
+ subfolderArea.appendChild(folderWrapperEl);
+ subfolderArea.style.display = "block";
+ } else {
+ subfolderArea.style.display = "none";
+ }
+ }
+
+ 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);
+ }
+
+ if (!append && cardEls.length === 0 && !folderWrapperEl) {
+ columnsWrapper.innerHTML = "";
+ const emptyEl = document.createElement("div");
+ emptyEl.className = "comfy-sidebar-card comfy-sidebar-gallery-empty-notice";
+ Object.assign(emptyEl.style, {
+ padding: "24px", textAlign: "center", color: "var(--desc-color, #888)", fontSize: "12px", width: "100%"
+ });
+ emptyEl.textContent = isLoading ? t("Loading files...") : t("No files found in this folder.");
+ cardStack.appendChild(emptyEl);
+ return;
+ }
+
+ 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);
+ }
+
+ const colHeights = Array.from(columnsWrapper.children).map(c => c.scrollHeight || 0);
+
+ cardEls.forEach((cardEl) => {
+ let shortestCol = 0;
+ for (let c = 1; c < cols; c++) {
+ if (colHeights[c] < colHeights[shortestCol]) shortestCol = c;
+ }
+ columnsWrapper.children[shortestCol].appendChild(cardEl);
+ const cardH = cardEl.offsetHeight > 0 ? cardEl.offsetHeight : 160;
+ colHeights[shortestCol] += cardH + 12;
+ });
+}
+
+function createFileCard(file) {
+ const card = document.createElement("div");
+ card.className = "comfy-sidebar-card completed";
+ card.style.position = "relative";
+ card.style.cursor = "pointer";
+
+ const previewUrl = `/view?filename=${encodeURIComponent(file.filename)}&type=output&subfolder=${encodeURIComponent(file.subfolder || "")}`;
+ const isPng = file.filename.toLowerCase().endsWith(".png");
+
+ const grid = document.createElement("div");
+ grid.style.display = "flex";
+ grid.style.flexDirection = "column";
+ grid.style.width = "100%";
+
+ const cardObj = {
+ element: card,
+ grid: grid,
+ dimEl: null,
+ currentImageIndex: 0,
+ firstImgElement: null
+ };
+
+ const fileAsset = {
+ filename: file.filename,
+ subfolder: file.subfolder,
+ type: "output",
+ url: previewUrl
+ };
+
+ // Render using Queue's exact media pipeline (Images, Videos, 3D, Audio, Files)
+ renderCardImages(cardObj, {
+ pid: `gallery-${file.filename}`,
+ status: PromptStatus.COMPLETED,
+ images: [fileAsset],
+ workflow: null
+ });
+
+ const sizeBadge = document.createElement("div");
+ sizeBadge.className = "comfy-sidebar-card-timer";
+ sizeBadge.textContent = formatFileSize(file.size);
+ sizeBadge.style.display = file.size ? "block" : "none";
+
+ const isMedia = isImageFormat(file.filename) || /\.(mp4|webm|mov|mkv|avi)$/i.test(file.filename);
+ let nameBadge = null;
+ if (isMedia) {
+ nameBadge = document.createElement("div");
+ nameBadge.className = "comfy-sidebar-card-filename-badge";
+ nameBadge.textContent = file.filename;
+ nameBadge.title = file.filename;
+ }
+
+ const hoverPanel = document.createElement("div");
+ hoverPanel.className = "comfy-sidebar-hover-panel";
+ Object.assign(hoverPanel.style, {
+ position: "absolute", bottom: "4px", right: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
+ });
+
+ const btnDownload = document.createElement("span");
+ btnDownload.className = "pi pi-download comfy-sidebar-card-action-btn";
+ btnDownload.title = t("Download Object");
+ btnDownload.onclick = (ev) => {
+ ev.stopPropagation();
+ const a = document.createElement("a");
+ a.href = previewUrl;
+ a.download = file.filename;
+ a.click();
+ };
+
+ let btnJson = null;
+ if (isPng) {
+ btnJson = document.createElement("span");
+ btnJson.className = "pi pi-file comfy-sidebar-card-action-btn";
+ btnJson.title = t("Download JSON");
+ btnJson.onclick = async (ev) => {
+ ev.stopPropagation();
+ const wf = await extractWorkflowFromPng(previewUrl);
+ if (!wf) {
+ alert(t("No workflow metadata found in this file."));
+ return;
+ }
+ const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `workflow_${file.filename.replace(/\.[^/.]+$/, "")}.json`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1500);
+ };
+ }
+
+ const btnDel = document.createElement("span");
+ btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
+ btnDel.title = t("Delete file (Hold Ctrl to delete file from disk)");
+ const btnDelLabel = document.createElement("span");
+ btnDelLabel.className = "comfy-sidebar-del-label";
+ btnDelLabel.textContent = t("Delete File");
+ btnDel.appendChild(btnDelLabel);
+
+ let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
+ const resetDelete = () => {
+ isDeletePending = false;
+ isDiskDelete = false;
+ btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
+ btnDel.title = t("Delete file (Hold Ctrl to delete file from disk)");
+ if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
+ };
+
+ btnDel.onclick = async (ev) => {
+ ev.stopPropagation();
+ const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
+
+ if (!isDeletePending) {
+ isDeletePending = true;
+ isDiskDelete = wantsDiskDelete;
+ btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
+ btnDel.title = wantsDiskDelete
+ ? t("Ctrl+Click again to delete file from DISK / TRASH")
+ : t("Click again to confirm deleting file");
+ deleteTimeout = setTimeout(resetDelete, 2000);
+ } else {
+ resetDelete();
+ const action = await deleteFileOnServer(fileAsset);
+ if (action) {
+ const msg = action === "trashed" ? t("Moved file to Recycle Bin.") : t("Permanently deleted file.");
+ showNotification(msg, "success", t("Delete File"));
+ cachedFiles = cachedFiles.filter(f => f.filename !== file.filename || f.subfolder !== file.subfolder);
+ renderGallery();
+ }
+ }
+ };
+
+ hoverPanel.appendChild(btnDownload);
+ if (btnJson) hoverPanel.appendChild(btnJson);
+ hoverPanel.appendChild(btnDel);
+
+ const leftHoverPanel = document.createElement("div");
+ leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
+ Object.assign(leftHoverPanel.style, {
+ position: "absolute", bottom: "4px", left: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
+ });
+
+ const isImage = isImageFormat(file.filename);
+ const btnCopy = document.createElement("span");
+ btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
+ btnCopy.title = t("Copy to Clipboard");
+ if (!isImage) {
+ btnCopy.style.display = "none";
+ } else {
+ btnCopy.onclick = async (ev) => {
+ ev.stopPropagation();
+ const success = await copyImageToClipboard(previewUrl);
+ if (success) {
+ btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
+ btnCopy.style.color = "#4ade80";
+ setTimeout(() => {
+ btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
+ btnCopy.style.color = "";
+ }, 1500);
+ }
+ };
+ }
+ leftHoverPanel.appendChild(btnCopy);
+
+ card.append(grid, sizeBadge);
+ if (nameBadge) card.append(nameBadge);
+ card.append(hoverPanel, leftHoverPanel);
+ return card;
+}
+
+function updateBreadcrumbUI() {
+ if (!galleryContainerEl) return;
+ const breadcrumbBar = galleryContainerEl.querySelector(".comfy-sidebar-breadcrumb-bar");
+ if (!breadcrumbBar) return;
+
+ breadcrumbBar.innerHTML = "";
+
+ const rootBtn = document.createElement("span");
+ rootBtn.className = "comfy-sidebar-breadcrumb-item";
+ rootBtn.innerHTML = `output`;
+ rootBtn.onclick = () => navigateToSubfolder("");
+ breadcrumbBar.appendChild(rootBtn);
+
+ const normalized = (currentSubfolder || "").replace(/\\/g, "/");
+ if (normalized) {
+ const parts = normalized.split("/").filter(Boolean);
+ let accumulated = "";
+
+ parts.forEach((part) => {
+ accumulated = accumulated ? `${accumulated}/${part}` : part;
+ const targetPath = accumulated;
+
+ const sep = document.createElement("span");
+ sep.className = "comfy-sidebar-breadcrumb-sep";
+ sep.textContent = "/";
+ breadcrumbBar.appendChild(sep);
+
+ const crumb = document.createElement("span");
+ crumb.className = "comfy-sidebar-breadcrumb-item";
+ crumb.textContent = part;
+ crumb.onclick = () => navigateToSubfolder(targetPath);
+ breadcrumbBar.appendChild(crumb);
+ });
+ }
+}
+
+function loadNextBatch() {
+ if (displayedFilesCount >= cachedFiles.length) return;
+ const nextFiles = cachedFiles.slice(displayedFilesCount, displayedFilesCount + BATCH_SIZE);
+ displayedFilesCount += nextFiles.length;
+ const cardElements = nextFiles.map(file => createFileCard(file));
+ mountGalleryCards(cardStackEl, null, cardElements, currentColumnCount, true);
+}
+
+export function renderGallery() {
+ if (!galleryContainerEl || !cardStackEl) return;
+
+ updateBreadcrumbUI();
+
+ let subfolderWrapper = null;
+ if (cachedFolders.length > 0 && !currentSearchQuery) {
+ subfolderWrapper = document.createElement("div");
+ subfolderWrapper.className = "comfy-sidebar-folders-grid";
+
+ cachedFolders.forEach(folder => {
+ const folderCard = document.createElement("div");
+ folderCard.className = "comfy-sidebar-folder-card";
+ folderCard.title = folder.name;
+
+ const icon = document.createElement("span");
+ icon.className = "pi pi-folder comfy-sidebar-folder-icon";
+
+ const name = document.createElement("span");
+ name.className = "comfy-sidebar-folder-name";
+ name.textContent = folder.name;
+
+ const ctrlOverlay = document.createElement("div");
+ ctrlOverlay.className = "comfy-sidebar-ctrl-overlay";
+ ctrlOverlay.innerHTML = `${t("Open Folder")}`;
+
+ folderCard.appendChild(icon);
+ folderCard.appendChild(name);
+ folderCard.appendChild(ctrlOverlay);
+
+ folderCard.onclick = async (e) => {
+ if (e.ctrlKey || e.metaKey) {
+ e.stopPropagation();
+ await openFileOrFolder({ subfolder: folder.subfolder, type: "output" });
+ return;
+ }
+ await navigateToSubfolder(folder.subfolder);
+ };
+
+ subfolderWrapper.appendChild(folderCard);
+ });
+ }
+
+ const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
+ const containerWidth = galleryContainerEl.clientWidth || 0;
+ if (containerWidth > 0) {
+ currentColumnCount = Math.max(1, Math.floor(containerWidth / (threshold / 2)));
+ }
+
+ let columnsWrapper = cardStackEl.querySelector(".comfy-sidebar-masonry-wrapper");
+ if (columnsWrapper) columnsWrapper.innerHTML = "";
+
+ const initialFiles = cachedFiles.slice(0, BATCH_SIZE);
+ displayedFilesCount = initialFiles.length;
+ const cardElements = initialFiles.map(file => createFileCard(file));
+
+ mountGalleryCards(cardStackEl, subfolderWrapper, cardElements, currentColumnCount, false);
+ updateScrollTopVisibility();
+}
+
+async function navigateToSubfolder(subfolder) {
+ currentSubfolder = (subfolder || "").replace(/\\/g, "/");
+ currentSearchQuery = "";
+ await fetchGalleryData(currentSubfolder);
+ const scrollEl = getScrollContainer();
+ if (scrollEl) scrollEl.scrollTop = 0;
+ renderGallery();
+}
+
+export function setupGalleryView() {
+ const container = document.createElement("div");
+ galleryContainerEl = container;
+
+ Object.assign(container.style, {
+ display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box",
+ background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)", position: "relative"
+ });
+
+ const header = document.createElement("div");
+ header.className = "comfy-sidebar-header-root";
+ Object.assign(header.style, { position: "relative", marginBottom: "8px", height: "26px", display: "flex", alignItems: "center" });
+
+ const standardHeader = document.createElement("div");
+ Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" });
+
+ const titleGroup = document.createElement("div");
+ Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" });
+
+ const searchIcon = document.createElement("span");
+ searchIcon.className = "pi pi-search comfy-sidebar-icon-btn";
+ searchIcon.title = t("Search Gallery (Filename or Prompt)");
+
+ const title = document.createElement("h3");
+ title.textContent = t("Gallery");
+ Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" });
+
+ titleGroup.appendChild(searchIcon);
+ titleGroup.appendChild(title);
+ standardHeader.appendChild(titleGroup);
+
+ const actionsGroup = document.createElement("div");
+ Object.assign(actionsGroup.style, { display: "flex", gap: "5px", alignItems: "center" });
+
+ const createHeaderBtn = (iconClass, tooltip, onClickFn) => {
+ const btn = document.createElement("button");
+ btn.className = `${iconClass} comfy-sidebar-header-btn`;
+ btn.title = tooltip;
+ btn.onclick = async (ev) => {
+ ev.stopPropagation();
+ btn.style.transform = "scale(0.88)";
+ setTimeout(() => { btn.style.transform = ""; }, 120);
+ await onClickFn();
+ };
+ return btn;
+ };
+
+ const initialSortIcon = sortOrder === "newest" ? "pi-sort-amount-down" : "pi-sort-amount-up";
+ const initialSortTitle = sortOrder === "newest" ? t("Sort: Newest First") : t("Sort: Oldest First");
+
+ const btnSort = createHeaderBtn(`pi ${initialSortIcon}`, initialSortTitle, () => {
+ sortOrder = sortOrder === "newest" ? "oldest" : "newest";
+ try { localStorage.setItem("ComfySidebar.GallerySortOrder", sortOrder); } catch (e) {}
+ btnSort.className = `pi ${sortOrder === "newest" ? "pi-sort-amount-down" : "pi-sort-amount-up"} comfy-sidebar-header-btn`;
+ btnSort.title = sortOrder === "newest" ? t("Sort: Newest First") : t("Sort: Oldest First");
+ applySorting();
+ renderGallery();
+ });
+
+ const btnUp = createHeaderBtn("pi pi-arrow-up", t("Go Up One Level"), async () => {
+ if (!currentSubfolder) return;
+ const parts = currentSubfolder.split("/").filter(Boolean);
+ parts.pop();
+ await navigateToSubfolder(parts.join("/"));
+ });
+
+ const btnOpenFolder = createHeaderBtn("pi pi-folder-open", t("Open Current Folder in File Manager"), async () => {
+ await openFileOrFolder({ subfolder: currentSubfolder, type: "output" });
+ });
+
+ const btnRefresh = createHeaderBtn("pi pi-refresh", t("Refresh Gallery"), async () => {
+ await fetchGalleryData(currentSubfolder, currentSearchQuery);
+ renderGallery();
+ });
+
+ actionsGroup.append(btnSort, btnUp, btnOpenFolder, btnRefresh);
+ standardHeader.appendChild(actionsGroup);
+
+ const searchContainer = document.createElement("div");
+ Object.assign(searchContainer.style, {
+ display: "none", width: "100%", alignItems: "center", background: "var(--comfy-input-bg, #181818)",
+ border: "1px solid var(--border-color, #555)", borderRadius: "4px", padding: "2px 8px",
+ boxSizing: "border-box", height: "26px"
+ });
+ const sIcon = document.createElement("span");
+ sIcon.className = "pi pi-search";
+ Object.assign(sIcon.style, { fontSize: "11px", opacity: "0.6", marginRight: "6px", transition: "transform 0.2s" });
+ const sInput = document.createElement("input");
+ Object.assign(sInput, { type: "text", placeholder: t("Search filenames or prompt metadata...") });
+ Object.assign(sInput.style, {
+ flex: "1", background: "transparent", border: "none", outline: "none",
+ color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0"
+ });
+ const sClear = document.createElement("span");
+ sClear.className = "pi pi-times comfy-sidebar-icon-btn";
+ sClear.title = t("Clear & Close Search");
+ Object.assign(sClear.style, { marginLeft: "6px" });
+
+ searchContainer.append(sIcon, sInput, sClear);
+ header.append(standardHeader, searchContainer);
+ container.appendChild(header);
+
+ searchIcon.onclick = (e) => {
+ e.stopPropagation();
+ standardHeader.style.display = "none";
+ searchContainer.style.display = "flex";
+ sInput.focus();
+ };
+
+ let searchDebounce = null;
+ const closeSearch = () => {
+ sInput.value = "";
+ currentSearchQuery = "";
+ sIcon.className = "pi pi-search";
+ searchContainer.style.display = "none";
+ standardHeader.style.display = "flex";
+ fetchGalleryData(currentSubfolder, "").then(() => renderGallery());
+ };
+
+ sClear.onclick = (e) => { e.stopPropagation(); closeSearch(); };
+ sInput.onkeydown = (e) => { if (e.key === "Escape") closeSearch(); };
+ sInput.oninput = () => {
+ currentSearchQuery = sInput.value.trim();
+ if (searchDebounce) clearTimeout(searchDebounce);
+
+ sIcon.className = "pi pi-spin pi-spinner";
+
+ searchDebounce = setTimeout(async () => {
+ try {
+ await fetchGalleryData(currentSubfolder, currentSearchQuery);
+ renderGallery();
+ } finally {
+ sIcon.className = "pi pi-search";
+ }
+ }, 350);
+ };
+
+ const breadcrumbBar = document.createElement("div");
+ breadcrumbBar.className = "comfy-sidebar-breadcrumb-bar";
+ container.appendChild(breadcrumbBar);
+
+ const cardStack = document.createElement("div");
+ cardStackEl = cardStack;
+ Object.assign(cardStack.style, {
+ flex: "1", overflowY: "visible", display: "flex", flexDirection: "column", paddingBottom: "28px"
+ });
+ container.appendChild(cardStack);
+
+ scrollToTopBtnEl = document.createElement("button");
+ scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
+ scrollToTopBtnEl.title = t("Scroll to Top");
+ Object.assign(scrollToTopBtnEl.style, {
+ position: "sticky",
+ bottom: "14px",
+ marginLeft: "auto",
+ zIndex: "30"
+ });
+ scrollToTopBtnEl.onclick = (e) => {
+ e.stopPropagation();
+ const scrollEl = getScrollContainer();
+ if (scrollEl) scrollEl.scrollTo({ top: 0, behavior: "smooth" });
+ };
+ container.appendChild(scrollToTopBtnEl);
+
+ const handleCtrlDown = (e) => {
+ if (e.key === "Control" || e.key === "Meta") document.body.classList.add("comfy-sidebar-ctrl-active");
+ };
+ const handleCtrlUp = (e) => {
+ if (e.key === "Control" || e.key === "Meta") document.body.classList.remove("comfy-sidebar-ctrl-active");
+ };
+ window.addEventListener("keydown", handleCtrlDown);
+ window.addEventListener("keyup", handleCtrlUp);
+
+ document.addEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true });
+
+ 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 (cols !== currentColumnCount) {
+ currentColumnCount = cols;
+ renderGallery();
+ }
+ }).observe(container);
+
+ fetchGalleryData("").then(() => renderGallery());
+
+ return container;
+}
+
+export function teardownGalleryView() {
+ document.removeEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true });
+ cachedFolders = [];
+ cachedFiles = [];
+ galleryContainerEl = null;
+ cardStackEl = null;
+ scrollToTopBtnEl = null;
+}
\ No newline at end of file
diff --git a/js/ui/sidebarView.js b/js/ui/sidebarView.js
index a3bfe31..fcd0b0a 100644
--- a/js/ui/sidebarView.js
+++ b/js/ui/sidebarView.js
@@ -98,7 +98,11 @@ function getScrollContainer() {
}
function updateScrollTopBtnVisibility() {
- if (!scrollToTopBtnEl || !store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) return;
+ if (!scrollToTopBtnEl) return;
+ if (!store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) {
+ scrollToTopBtnEl.style.display = "none";
+ return;
+ }
const scrollEl = getScrollContainer();
if (scrollEl) {
const viewportEl = scrollEl.parentElement || store.ui.sidebarContainer;
diff --git a/js/utils/styles.js b/js/utils/styles.js
index e9831f9..2a12919 100644
--- a/js/utils/styles.js
+++ b/js/utils/styles.js
@@ -584,6 +584,115 @@ export function injectStyles() {
border-radius: 3px !important;
}
+ /* Gallery Subfolders & Breadcrumb Styles */
+ .comfy-sidebar-breadcrumb-bar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--desc-color, #aaa);
+ overflow-x: auto;
+ white-space: nowrap;
+ padding: 4px 0 8px 0;
+ border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
+ margin-bottom: 10px;
+ }
+ .comfy-sidebar-breadcrumb-item {
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 3px;
+ background: rgba(255, 255, 255, 0.04);
+ transition: all 0.15s ease-in-out;
+ color: var(--fg-color, #eee);
+ text-decoration: none;
+ }
+ .comfy-sidebar-breadcrumb-item:hover {
+ background: var(--p-primary-color, rgba(59, 130, 246, 0.2));
+ color: #fff;
+ }
+ .comfy-sidebar-breadcrumb-sep {
+ opacity: 0.4;
+ font-size: 10px;
+ }
+
+ .comfy-sidebar-folders-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+ gap: 8px;
+ margin-bottom: 12px;
+ width: 100%;
+ }
+ .comfy-sidebar-folder-card {
+ background: var(--comfy-input-bg, #181818);
+ border-radius: 4px;
+ padding: 8px 10px;
+ border: 1px solid var(--border-color, #333);
+ color: var(--comfy-input-color, var(--fg-color, #eee));
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ transition: border-color 0.15s ease, background 0.15s ease, transform 0.1s ease;
+ user-select: none;
+ min-width: 0;
+ }
+ .comfy-sidebar-folder-card:hover {
+ border-color: var(--p-primary-color, #0ea5e9) !important;
+ background: rgba(14, 165, 233, 0.12);
+ }
+ .comfy-sidebar-folder-icon {
+ font-size: 16px;
+ color: var(--p-primary-color, #0ea5e9);
+ flex-shrink: 0;
+ }
+ body.comfy-sidebar-ctrl-active .comfy-sidebar-folder-card {
+ cursor: alias !important;
+ border-color: var(--p-primary-color, #0ea5e9) !important;
+ }
+ .comfy-sidebar-folder-name {
+ font-size: 11px;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ }
+
+ .comfy-sidebar-card-filename-badge {
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ font-size: 10px;
+ font-family: monospace;
+ opacity: 0.7;
+ background: rgba(0, 0, 0, 0.6);
+ padding: 2px 4px;
+ border-radius: 3px;
+ pointer-events: none;
+ z-index: 5;
+ color: #fff;
+ max-width: 62%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ transform: translateZ(0);
+ }
+
+ /* Folder card layout & Ctrl overlay support */
+ .comfy-sidebar-folder-card {
+ position: relative;
+ overflow: hidden;
+ }
+ body.comfy-sidebar-ctrl-active .comfy-sidebar-folder-card:hover .comfy-sidebar-ctrl-overlay {
+ display: flex;
+ }
+
+ /* Comparison selected image */
+ .comfy-sidebar-card.comfy-sidebar-comparison-source {
+ outline: 2px solid #eab308 !important;
+ box-shadow: 0 0 12px rgba(234, 179, 8, 0.5);
+ }
+
/* Smooth animation for the circular loader */
@keyframes comfy-sidebar-spin {
from { transform: rotate(0deg); }
diff --git a/pyproject.toml b/pyproject.toml
index 7f8c8a2..c14d56f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "comfy-sidebar"
description = "A compact and powerful layout overhaul for ComfyUI that makes generation results easier to browse, preview, compare, and manage."
-version = "1.7.4"
+version = "1.8.0"
license = { file = "LICENSE" }
dependencies = [
"send2trash>=1.8.0",