Skip to content

Commit 4e52029

Browse files
rubzclaude
andcommitted
Add "Show in Explorer" to the history panel's right-click menu
Resolves a history row's stored thumbnail_path back to the real output file (mirroring ComfyUI's own /view resolution) and opens it selected in the OS file manager. On Windows, also force-brings the Explorer window to the foreground via a PowerShell/Shell.Application script (matching the actual folder path, not window-title text) and fixes a command-line quoting bug that could pop a second, unselected window for paths containing spaces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 13ad36f commit 4e52029

6 files changed

Lines changed: 269 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to Smart Queue are documented here. Format based on
55

66
## [Unreleased]
77

8+
### Added
9+
- "Show in Explorer" on the history panel's right-click menu: resolves the row's stored thumbnail path back to the real output file (mirroring ComfyUI's own `/view` resolution) and opens it selected in the OS file manager. Windows also force-brings the Explorer window to the foreground, since a background process is normally blocked from stealing focus (`SetForegroundWindow`'s "foreground lock") — worked around with a synthetic Alt keypress plus matching the window by its actual folder path via `Shell.Application`, not by guessing from window title text.
10+
11+
### Fixed
12+
- On Windows, revealing a file whose path contained spaces could pop a second, unselected Explorer window alongside the real one: the `/select,<path>` argument was passed as a list element, which Python's own command-line quoting wraps in an extra outer pair of quotes that Explorer parses inconsistently. Now passed as a single raw command-line string so only the path itself is quoted, matching Explorer's documented syntax.
13+
814
## [0.1.5] - 2026-09-04
915

1016
### Fixed

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ComfyUI removed its native pause button and has no built-in way to gate the queu
2020
- **Autopilot**: three independent, opt-in rules that pause the queue when your GPU gets too hot, VRAM gets too tight, or too many jobs have run back-to-back. Each has hysteresis (a resume threshold below the pause threshold) so it never flaps. Never interrupts a job that's already running: it only holds back what hasn't started yet.
2121
- **Manual pause**: one click in the toolbar. Holds every not-yet-running job out of ComfyUI's own queue (not just new submissions) and puts it back in the same order on resume. Survives a ComfyUI restart.
2222
- **Persistent queue & history panel** (left sidebar): survives restarts, shows readable job names (read from the workflow itself, no extra node needed), thumbnails in history with click-to-restore, per-job duration, and a live GPU temp/VRAM readout (hidden automatically if you already have Crystools installed, so you don't get two gauges).
23+
- **Show in Explorer**: right-click a history thumbnail to jump straight to the rendered file on disk, selected and ready, in your OS file manager: no more digging through ComfyUI's output folder by hand.
2324
- **Rename, filter/search, auto-archive**: double-click to rename a job, search box above each list, and automatic history cleanup after N days (configurable, off by default).
2425
- **Bulk actions & drag-reorder**: ctrl/shift-click to multi-select, right-click for a context menu (rename / cancel / cancel & requeue), and drag-and-drop priority reordering that actually changes ComfyUI's real execution order.
2526
- **Pause/resume toast notifications**: so an autopilot-triggered pause doesn't go unnoticed just because you weren't looking at the toolbar.

‎backend/reveal_in_explorer.py‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Reveals a rendered output file in the OS file manager, from a history row's
2+
stored thumbnail_path (e.g. "filename=a.png&subfolder=&type=output").
3+
4+
Path resolution mirrors ComfyUI's own view_image handler (server.py) so the
5+
same query string that already renders a thumbnail resolves to the same file.
6+
"""
7+
8+
import asyncio
9+
import logging
10+
import os
11+
import subprocess
12+
import sys
13+
from typing import Callable
14+
from urllib.parse import parse_qs
15+
16+
from aiohttp import web
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def _resolve_output_path(query: str, get_directory_by_type: Callable[[str], str | None] | None = None) -> str | None:
22+
if get_directory_by_type is None:
23+
import folder_paths # type: ignore[import-not-found]
24+
get_directory_by_type = folder_paths.get_directory_by_type
25+
26+
params = parse_qs(query)
27+
filename = params.get("filename", [""])[0]
28+
if not filename or filename[0] == "/" or ".." in filename:
29+
return None
30+
31+
output_dir = get_directory_by_type(params.get("type", ["output"])[0])
32+
if output_dir is None:
33+
return None
34+
35+
subfolder = params.get("subfolder", [""])[0]
36+
if subfolder:
37+
full_dir = os.path.join(output_dir, subfolder)
38+
if os.path.commonpath((os.path.abspath(full_dir), output_dir)) != output_dir:
39+
return None
40+
output_dir = full_dir
41+
42+
return os.path.join(output_dir, os.path.basename(filename))
43+
44+
45+
# PowerShell script (same COM/P-Invoke style as native_dialog.py's file
46+
# picker): finds the Explorer window actually showing our target folder via
47+
# Shell.Application's Windows() collection — matching on the real folder
48+
# path rather than guessing from window title text — and foregrounds it.
49+
#
50+
# SetForegroundWindow alone would silently no-op here: Windows enforces a
51+
# "foreground lock" that refuses it for a background process (this Python
52+
# server owns no window of its own) and just flashes the taskbar icon
53+
# instead. A synthetic Alt keypress is the standard, minimal way to reset
54+
# that lock's timer so the very next SetForegroundWindow call is honored.
55+
_FOREGROUND_EXPLORER_PS = r"""
56+
Add-Type @"
57+
using System;
58+
using System.Runtime.InteropServices;
59+
public class SmartQueueForeground {
60+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
61+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
62+
[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
63+
}
64+
"@
65+
66+
$target = '__FOLDER__'
67+
$shell = New-Object -ComObject Shell.Application
68+
$deadline = (Get-Date).AddSeconds(2)
69+
$hwnd = [IntPtr]::Zero
70+
while ((Get-Date) -lt $deadline -and $hwnd -eq [IntPtr]::Zero) {
71+
foreach ($w in @($shell.Windows())) {
72+
try {
73+
if ($w.Document.Folder.Self.Path -eq $target) { $hwnd = [IntPtr]$w.HWND; break }
74+
} catch {}
75+
}
76+
if ($hwnd -eq [IntPtr]::Zero) { Start-Sleep -Milliseconds 150 }
77+
}
78+
79+
if ($hwnd -ne [IntPtr]::Zero) {
80+
[SmartQueueForeground]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) # Alt down
81+
[SmartQueueForeground]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) # Alt up
82+
[SmartQueueForeground]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE
83+
[SmartQueueForeground]::SetForegroundWindow($hwnd) | Out-Null
84+
}
85+
"""
86+
87+
88+
def _bring_explorer_to_foreground(folder: str) -> None:
89+
script = _FOREGROUND_EXPLORER_PS.replace("__FOLDER__", folder.replace("'", "''"))
90+
subprocess.run(
91+
["powershell", "-NoProfile", "-Command", script],
92+
capture_output=True, timeout=5,
93+
)
94+
95+
96+
def _reveal(path: str) -> None:
97+
if sys.platform == "win32":
98+
# Passed as a raw command-line string (not a list) so only the path
99+
# itself is quoted, matching Explorer's documented "/select,<quoted
100+
# path>" syntax exactly. A list argument here would go through
101+
# Python's own list2cmdline quoting, which wraps the *entire*
102+
# "/select,<path with spaces>" token in an outer pair of quotes —
103+
# Explorer parses that malformed form inconsistently and can open a
104+
# second, unselected fallback window alongside the real one.
105+
subprocess.Popen(f'explorer /select,"{path}"')
106+
# Best-effort UI polish only — the file is already selected in
107+
# Explorer at this point regardless of whether this succeeds, so a
108+
# failure here must never turn into an error response for the user.
109+
try:
110+
_bring_explorer_to_foreground(os.path.dirname(path))
111+
except (OSError, subprocess.TimeoutExpired):
112+
logger.exception("[Smart Queue] could not bring Explorer to the foreground")
113+
elif sys.platform == "darwin":
114+
subprocess.Popen(["open", "-R", path])
115+
else:
116+
# No universal "select this file" affordance on Linux file managers;
117+
# opening the containing folder is the best portable fallback.
118+
subprocess.Popen(["xdg-open", os.path.dirname(path)])
119+
120+
121+
async def post_reveal_in_explorer(request: web.Request) -> web.Response:
122+
if request.remote not in ("127.0.0.1", "::1"):
123+
return web.json_response({"ok": False, "error": "forbidden"}, status=403)
124+
125+
payload = await request.json()
126+
query = str(payload.get("thumbnail_path", ""))
127+
if not query:
128+
return web.json_response({"ok": False, "error": "no file for this item"}, status=400)
129+
130+
path = _resolve_output_path(query)
131+
if path is None or not os.path.isfile(path):
132+
return web.json_response({"ok": False, "error": "file not found on disk"}, status=404)
133+
134+
try:
135+
# _reveal briefly polls for the Explorer window on win32 (up to
136+
# ~2s) — keep that off the event loop.
137+
loop = asyncio.get_running_loop()
138+
await loop.run_in_executor(None, _reveal, path)
139+
except OSError as exc:
140+
logger.error("[Smart Queue] failed to open file manager: %s", exc)
141+
return web.json_response({"ok": False, "error": str(exc)}, status=500)
142+
143+
return web.json_response({"ok": True})

‎backend/routes.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .autopilot_state import AutopilotState
1111
from .continue_registry import list_pending, signal_cancel, signal_continue
1212
from .native_dialog import browse_path
13+
from .reveal_in_explorer import post_reveal_in_explorer
1314
from .sound_library import import_sound
1415
from .persistence import (
1516
list_history,
@@ -219,3 +220,4 @@ async def post_browse_sound_file(request: web.Request) -> web.Response:
219220
app.router.add_post("/smart_queue/rename", post_rename)
220221
app.router.add_post("/smart_queue/cancel", post_cancel)
221222
app.router.add_post("/smart_queue/browse_sound_file", post_browse_sound_file)
223+
app.router.add_post("/smart_queue/reveal_in_explorer", post_reveal_in_explorer)

‎tests/test_reveal_in_explorer.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import os
2+
from unittest.mock import patch, MagicMock
3+
4+
from aiohttp import web
5+
from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop
6+
7+
from backend.reveal_in_explorer import _resolve_output_path, post_reveal_in_explorer
8+
9+
10+
_OUTPUT_DIR = os.path.abspath(os.path.join(os.path.sep, "comfy", "output"))
11+
12+
13+
def _fake_output_dir(_type):
14+
return _OUTPUT_DIR
15+
16+
17+
def test_resolves_filename_subfolder_and_type_into_an_absolute_path():
18+
path = _resolve_output_path(
19+
"filename=a.png&subfolder=sub&type=output",
20+
get_directory_by_type=_fake_output_dir,
21+
)
22+
assert path == os.path.join(_OUTPUT_DIR, "sub", "a.png")
23+
24+
25+
def test_rejects_missing_filename():
26+
assert _resolve_output_path("subfolder=&type=output", get_directory_by_type=_fake_output_dir) is None
27+
28+
29+
def test_rejects_path_traversal_in_filename():
30+
assert _resolve_output_path(
31+
"filename=..%2F..%2Fetc%2Fpasswd&type=output",
32+
get_directory_by_type=_fake_output_dir,
33+
) is None
34+
35+
36+
def test_rejects_subfolder_that_escapes_the_output_dir():
37+
assert _resolve_output_path(
38+
"filename=a.png&subfolder=..%2F..%2F..%2Fsecrets&type=output",
39+
get_directory_by_type=_fake_output_dir,
40+
) is None
41+
42+
43+
def test_unknown_type_returns_none():
44+
assert _resolve_output_path(
45+
"filename=a.png&type=bogus",
46+
get_directory_by_type=lambda _type: None,
47+
) is None
48+
49+
50+
class TestRevealInExplorerRoute(AioHTTPTestCase):
51+
async def get_application(self):
52+
app = web.Application()
53+
app.router.add_post("/smart_queue/reveal_in_explorer", post_reveal_in_explorer)
54+
return app
55+
56+
@unittest_run_loop
57+
async def test_rejects_missing_thumbnail_path(self):
58+
resp = await self.client.post("/smart_queue/reveal_in_explorer", json={})
59+
assert resp.status == 400
60+
61+
@unittest_run_loop
62+
@patch("backend.reveal_in_explorer._resolve_output_path")
63+
@patch("backend.reveal_in_explorer.os.path.isfile", return_value=False)
64+
async def test_reports_not_found_for_a_missing_file(self, mock_isfile, mock_resolve):
65+
mock_resolve.return_value = "/comfy/output/gone.png"
66+
resp = await self.client.post(
67+
"/smart_queue/reveal_in_explorer",
68+
json={"thumbnail_path": "filename=gone.png&type=output"},
69+
)
70+
assert resp.status == 404
71+
72+
@unittest_run_loop
73+
@patch("backend.reveal_in_explorer._reveal")
74+
@patch("backend.reveal_in_explorer._resolve_output_path")
75+
@patch("backend.reveal_in_explorer.os.path.isfile", return_value=True)
76+
async def test_reveals_the_resolved_file(self, mock_isfile, mock_resolve, mock_reveal):
77+
mock_resolve.return_value = "/comfy/output/a.png"
78+
resp = await self.client.post(
79+
"/smart_queue/reveal_in_explorer",
80+
json={"thumbnail_path": "filename=a.png&type=output"},
81+
)
82+
body = await resp.json()
83+
assert body["ok"] is True
84+
mock_reveal.assert_called_once_with("/comfy/output/a.png")

‎web/smart_queue.js‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,26 @@ app.registerExtension({
735735
}
736736
}
737737

738+
async function revealInExplorer(item) {
739+
try {
740+
const res = await fetch("/smart_queue/reveal_in_explorer", {
741+
method: "POST",
742+
body: JSON.stringify({ thumbnail_path: item.thumbnail_path }),
743+
headers: { "Content-Type": "application/json" },
744+
});
745+
const data = await res.json();
746+
if (!data.ok && app.extensionManager?.toast) {
747+
app.extensionManager.toast.add({
748+
severity: "error",
749+
summary: "Couldn't open file manager",
750+
detail: data.error || "Unknown error.",
751+
});
752+
}
753+
} catch (err) {
754+
console.error("[Smart Queue] reveal in explorer failed:", err);
755+
}
756+
}
757+
738758
let lastHistorySignature = null;
739759
async function refreshHistory() {
740760
try {
@@ -756,16 +776,24 @@ app.registerExtension({
756776
}
757777
for (const item of data.items) {
758778
const li = document.createElement("li");
759-
if (item.workflow_json) {
779+
if (item.workflow_json || item.thumbnail_path) {
760780
li.addEventListener("contextmenu", (e) => {
761781
removeContextMenu();
762782
e.preventDefault();
763783
const menu = document.createElement("div");
764784
menu.className = "smart-queue-context-menu";
765-
menu.appendChild(makeContextItem("Load workflow", () => {
766-
removeContextMenu();
767-
restoreWorkflowFromHistory(item);
768-
}));
785+
if (item.workflow_json) {
786+
menu.appendChild(makeContextItem("Load workflow", () => {
787+
removeContextMenu();
788+
restoreWorkflowFromHistory(item);
789+
}));
790+
}
791+
if (item.thumbnail_path) {
792+
menu.appendChild(makeContextItem("Show in Explorer", () => {
793+
removeContextMenu();
794+
revealInExplorer(item);
795+
}));
796+
}
769797
document.body.appendChild(menu);
770798
menu.style.left = e.clientX + "px";
771799
menu.style.top = e.clientY + "px";

0 commit comments

Comments
 (0)