From 2921ff517b6e475bf1e2e8ba77a50c95d4bb3bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:17:26 +0000 Subject: [PATCH] Gallery: saved videos and pictures next to Temp files, with preview and confirmed delete Settings > Saved & Temp Files now has two tabs. Saved lists what ComfyUI wrote to its output folder; Temp is the old list. Click a file to look at it, open it in a tab, or delete it after a question. Delete only removes media files inside the chosen folder. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015xHQk4XeXzJpBR3HSwnoBZ --- app/ui/features/temp/index.js | 4 +- app/ui/features/temp/view.js | 92 ++++++++++++++++++++++++-------- core/routes.py | 16 +++++- core/temp_files.py | 58 +++++++++++++++++--- core/tests/test_routes_update.py | 32 +++++++++++ core/tests/test_temp_files.py | 27 ++++++++++ 6 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 core/tests/test_temp_files.py diff --git a/app/ui/features/temp/index.js b/app/ui/features/temp/index.js index c6081bf2..0e32d1d5 100644 --- a/app/ui/features/temp/index.js +++ b/app/ui/features/temp/index.js @@ -5,7 +5,7 @@ export default { mount: "settings", needs: ["dom"], setup: ({ host, app }) => host.add({ - id: "temp", group: "System", title: "Temp Files", subtitle: "Where a file went when it did not land in the bin.", - keywords: "temp files output directory", icon: "▥", mount: (ctx) => mount({ ...ctx, dom: app.dom }), + id: "temp", group: "System", title: "Saved & Temp Files", subtitle: "Your saved videos and pictures, and where a file went when it did not land in the bin.", + keywords: "gallery saved temp files output directory videos pictures delete", icon: "▥", mount: (ctx) => mount({ ...ctx, dom: app.dom }), }), }; diff --git a/app/ui/features/temp/view.js b/app/ui/features/temp/view.js index 4bde0e32..9df2f064 100644 --- a/app/ui/features/temp/view.js +++ b/app/ui/features/temp/view.js @@ -1,41 +1,87 @@ -// Where a file went when it did not land in the bin. ComfyUI wipes its temp folder on restart, so -// this is a place to FIND something (opens in a new tab), not to manage it. +// Two places a file can be found: the gallery (what was saved, and stays) and the temp folder (what ComfyUI wipes on restart). +// Clicking a file looks at it here; it can be opened in its own tab, or deleted after a question. import { composer } from "../../composer/composer.js"; const ICON = { image: "▦", video: "▶", audio: "♪" }; -const url = (f) => `/view?${new URLSearchParams({ filename: f.filename, subfolder: f.subfolder || "", type: "temp" })}`; +const PLACES = [{ value: "output", label: "Saved" }, { value: "temp", label: "Temp files" }]; +const EMPTY = { output: "Nothing saved yet", temp: "Nothing in the temp directory" }; +const url = (f, where) => `/view?${new URLSearchParams({ filename: f.filename, subfolder: f.subfolder || "", type: where })}`; const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`); +const when = (t) => new Date((Number(t) || 0) * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); +const id = (f) => `${f.subfolder}/${f.filename}`; export function mount({ dom, setFooter, close }) { - const body = composer.region.stack({ gap: "sm", fill: true, label: "Temp files" }); + let where = "output", files = [], payload = {}, looking = null; + const body = composer.region.stack({ gap: "md", fill: true, label: "Files" }); + const content = composer.region.stack({ gap: "sm", fill: true, label: "Files in this folder" }); + const viewer = composer.viewer.cover({ onClose: () => { looking = null; content.node.hidden = false; footer(); } }); + const tabs = composer.tabs.underline({ label: "Where to look", value: where, tabs: PLACES, onChange: (v) => { where = v; viewer.close(); load(); } }); + body.set([tabs, content, viewer]); - async function load() { - body.set([composer.hint.default({ text: "Looking…" })]); - let payload; + function look(f) { + looking = f; + content.node.hidden = true; // the grid would show through below the picture + viewer.show({ src: url(f, where), kind: f.kind }); + footer(); + } + + async function remove(f) { + const ok = await composer.modal.dialogue({ title: "Delete file", tone: "danger", confirmLabel: "Delete", + message: `Delete “${f.filename}” from ${where === "output" ? "your saved files" : "the temp folder"} for good? It does not go to the trash.` }).result; + if (!ok) return; try { - payload = await (await fetch("/funpack/api/temp", { cache: "no-store" })).json(); + const res = await fetch("/funpack/api/temp/delete", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ where, filename: f.filename, subfolder: f.subfolder || "" }) }); + const out = await res.json().catch(() => ({})); + if (!res.ok || !out.deleted) throw new Error(out.detail || `HTTP ${res.status}`); } catch (err) { - body.set([composer.banner.danger({ text: `Could not read the temp directory: ${err.message}` })]); - return; + return composer.toast.warn({ text: `Could not delete it: ${err.message}` }); } - const files = payload.files || []; - const id = (f) => `${f.subfolder}/${f.filename}`; - body.set([files.length - ? composer.gallery.list({ - items: files.map((f) => ({ - id: id(f), label: f.filename, icon: ICON[f.kind] || "▦", badge: f.kind === "image" ? null : f.kind, - hint: `${f.kind} · ${size(Number(f.size) || 0)} · ${new Date((Number(f.mtime) || 0) * 1000).toLocaleTimeString()}`, - thumb: f.kind === "image" ? url(f) : null, - })), - onActivate: (cell) => dom.openTab(url(files.find((f) => id(f) === cell.id))), - }) - : composer.emptyState.default({ icon: "▤", title: "Nothing in the temp directory", hint: payload.detail || "" })]); + viewer.close(); + composer.toast.good({ text: `Deleted ${f.filename}.` }); + load(); + } + + function footer() { setFooter({ note: payload.path || "", actions: [ - composer.button.md({ label: "Refresh", onClick: load }), + ...(looking ? [ + composer.button.md({ label: "Open in a tab", onClick: () => dom.openTab(url(looking, where)) }), + composer.button.md({ label: "Delete", tone: "danger", onClick: () => remove(looking) }), + ] : [composer.button.md({ label: "Refresh", onClick: load })]), composer.button.md({ label: "Close", tone: "primary", onClick: close }), ] }); } + async function load() { + looking = null; + content.set([composer.hint.default({ text: "Looking…" })]); + try { + payload = await (await fetch(`/funpack/api/temp?where=${where}`, { cache: "no-store" })).json(); + } catch (err) { + payload = {}; + content.set([composer.banner.danger({ text: `Could not read the folder: ${err.message}` })]); + return footer(); + } + files = payload.files || []; + content.set([files.length + ? composer.gallery.adaptive({ + id: "files", cols: 4, + items: files.map((f) => ({ + id: id(f), label: f.filename, icon: ICON[f.kind] || "▦", badge: f.kind === "image" ? null : f.kind, + hint: `${f.kind} · ${size(Number(f.size) || 0)} · ${when(f.mtime)}`, + thumb: f.kind === "image" ? url(f, where) : null, + })), + onActivate: (cell) => look(files.find((f) => id(f) === cell.id)), + onContext: (cell, e) => { + const f = files.find((x) => id(x) === cell.id); + composer.menu.context({ x: e.clientX, y: e.clientY, items: [{ id: "look", label: "Look at it" }, { id: "tab", label: "Open in a tab" }, { separator: true }, { id: "del", label: "Delete…", danger: true }], + onPick: (pick) => ({ look: () => look(f), tab: () => dom.openTab(url(f, where)), del: () => remove(f) })[pick]?.() }); + }, + }) + : composer.emptyState.default({ icon: "▤", title: EMPTY[where], hint: payload.detail || "" })]); + footer(); + } + load(); return body; } diff --git a/core/routes.py b/core/routes.py index 19bcba03..935c4bc9 100644 --- a/core/routes.py +++ b/core/routes.py @@ -754,7 +754,21 @@ def register(routes, prefix=None): limit = int(req.query.get("limit", temp_files.MAX_FILES)) except (TypeError, ValueError): limit = temp_files.MAX_FILES - return web.json_response(await asyncio.to_thread(temp_files.listing, limit)) + where = "output" if req.query.get("where") == "output" else "temp" + return web.json_response(await asyncio.to_thread(temp_files.listing, limit, where)) + + @routes.post(P + "/api/temp/delete") + async def _temp_delete(req): + """Delete one saved or temp media file: {"where", "filename", "subfolder"}.""" + import asyncio + try: + body = await req.json() + except ValueError: + body = None + if not isinstance(body, dict) or body.get("where") not in ("temp", "output"): + return web.json_response({"detail": "Say which folder: temp or output."}, status=400) + out = await asyncio.to_thread(temp_files.delete, body.get("filename"), body.get("subfolder") or "", body["where"]) + return web.json_response(out, status=200 if out["deleted"] else 400) # --- node packs ----------------------------------------------------------- # diff --git a/core/temp_files.py b/core/temp_files.py index f897f1c1..98f8c7f3 100644 --- a/core/temp_files.py +++ b/core/temp_files.py @@ -1,8 +1,10 @@ -"""What ComfyUI has in its temp directory. +"""What ComfyUI has in its temp directory, and what it has saved in its output directory. -Previews and other transient outputs land there, and they are wiped when the +Previews and other transient outputs land in temp, and they are wiped when the server restarts -- so "where did that go?" has an answer for as long as the -server has been up, and this is where it is. +server has been up, and this is where it is. Saved videos and pictures land in +output and stay, which makes that the gallery: the same listing, and the one +thing temp does not need, a way to delete. Only media, and only what can be shown: a temp directory also fills with whatever any node felt like writing, and a browser listing 400 .pt files is not @@ -42,7 +44,21 @@ def temp_dir() -> str | None: return None -def listing(limit: int = MAX_FILES) -> dict: +def output_dir() -> str | None: + """ComfyUI's output directory, or None when ComfyUI is not here to ask.""" + try: + import folder_paths + return folder_paths.get_output_directory() + except Exception: # noqa: BLE001 + return None + + +def base_of(where: str) -> str | None: + """The folder `where` ("temp" or "output") names; anything else names nothing.""" + return {"temp": temp_dir, "output": output_dir}.get(where, lambda: None)() + + +def listing(limit: int = MAX_FILES, where: str = "temp") -> dict: """`{files, path, detail}`, newest first. `detail` carries the reason there is nothing rather than leaving an empty @@ -50,13 +66,14 @@ def listing(limit: int = MAX_FILES) -> dict: "this is not a ComfyUI install" and from "the directory was wiped". """ limit = max(1, min(int(limit or MAX_FILES), MAX_FILES)) - base = temp_dir() + saved = where == "output" + base = base_of(where) if not base: return {"files": [], "path": None, - "detail": "ComfyUI is not here to ask where its temp files go."} + "detail": f"ComfyUI is not here to ask where its {'saved' if saved else 'temp'} files go."} if not os.path.isdir(base): return {"files": [], "path": base, - "detail": "The temp directory does not exist yet. It is made when " + "detail": f"The {'output' if saved else 'temp'} directory does not exist yet. It is made when " "something is first written to it."} found: list[dict] = [] @@ -80,5 +97,30 @@ def listing(limit: int = MAX_FILES) -> dict: }) found.sort(key=lambda f: f.get("mtime", 0), reverse=True) - detail = "" if found else "Nothing here. Temp files are wiped when ComfyUI restarts." + detail = "" if found else ("Nothing saved yet. A saved video or picture lands here." if saved + else "Nothing here. Temp files are wiped when ComfyUI restarts.") return {"files": found[:limit], "path": base, "detail": detail} + + +def delete(filename, subfolder="", where: str = "output") -> dict: + """Remove one media file from `where`. `{deleted: True}`, or `{deleted: False, detail}` saying why not. + + The name is resolved the way /view resolves it and must stay inside that folder; and only + what the listing would show can go, so this cannot be turned on a model, a workflow or any + other file that happens to live next to the pictures. + """ + base = base_of(where) + if not base or not isinstance(filename, str) or not filename or not kind_of(filename): + return {"deleted": False, "detail": "Name a saved picture, video or sound."} + base = os.path.realpath(base) + full = os.path.realpath(os.path.join(base, subfolder if isinstance(subfolder, str) else "", + os.path.basename(filename))) + if os.path.commonpath((full, base)) != base or full == base: + return {"deleted": False, "detail": "That file is not in this folder."} + try: + os.remove(full) + except FileNotFoundError: + return {"deleted": False, "detail": "That file is already gone."} + except OSError as err: + return {"deleted": False, "detail": f"Could not delete it: {err.strerror or err}"} + return {"deleted": True} diff --git a/core/tests/test_routes_update.py b/core/tests/test_routes_update.py index 19d50c33..849f3693 100644 --- a/core/tests/test_routes_update.py +++ b/core/tests/test_routes_update.py @@ -664,3 +664,35 @@ def test_requirements_route_refuses_during_a_generation(server, monkeypatch): lambda: (_ for _ in ()).throw(AssertionError("must not install during a generation"))) status, body = _request(server["port"], "POST", "/funpack/api/requirements") assert status == 409 and "generation" in body["detail"] + + +# --- the gallery: saved files, and deleting them ------------------------------- + +def test_saved_files_are_listed_from_the_output_folder(server, monkeypatch, tmp_path): + from core import temp_files + (tmp_path / "FunPack_00001_.mp4").write_bytes(b"x") + monkeypatch.setattr(temp_files, "output_dir", lambda: str(tmp_path)) + monkeypatch.setattr(temp_files, "temp_dir", lambda: None) + status, body = _request(server["port"], "GET", "/funpack/api/temp?where=output") + assert [f["filename"] for f in body["files"]] == ["FunPack_00001_.mp4"] + status, body = _request(server["port"], "GET", "/funpack/api/temp") + assert body["files"] == [], "temp and saved were mixed up" + + +def test_delete_removes_a_media_file_and_nothing_else(server, monkeypatch, tmp_path): + from core import temp_files + out = tmp_path / "out" + (out / "sub").mkdir(parents=True) + pic, weights, outside = out / "sub" / "a.png", out / "model.safetensors", tmp_path / "secret.png" + for f in (pic, weights, outside): + f.write_bytes(b"x") + monkeypatch.setattr(temp_files, "output_dir", lambda: str(out)) + port = server["port"] + ask = lambda **kw: _request(port, "POST", "/funpack/api/temp/delete", {"where": "output", **kw}) + + assert ask(filename="a.png", subfolder="sub")[0] == 200 and not pic.exists() + assert ask(filename="model.safetensors")[0] == 400 and weights.exists(), "a non-media file was deleted" + assert ask(filename="secret.png", subfolder="..")[0] == 400 and outside.exists(), "left the folder" + assert ask(filename="../secret.png")[0] == 400 and outside.exists() + assert ask(filename="a.png", subfolder="sub")[0] == 400, "a second delete should say it is gone" + assert _request(port, "POST", "/funpack/api/temp/delete", {"where": "input", "filename": "a.png"})[0] == 400 diff --git a/core/tests/test_temp_files.py b/core/tests/test_temp_files.py new file mode 100644 index 00000000..2bb476d0 --- /dev/null +++ b/core/tests/test_temp_files.py @@ -0,0 +1,27 @@ +"""The gallery lists what ComfyUI saved, and deletes only media inside that folder.""" + +from core import temp_files + + +def test_saved_files_are_listed_from_output_not_temp(monkeypatch, tmp_path): + (tmp_path / "FunPack_00001_.mp4").write_bytes(b"x") + monkeypatch.setattr(temp_files, "output_dir", lambda: str(tmp_path)) + monkeypatch.setattr(temp_files, "temp_dir", lambda: None) + assert [f["filename"] for f in temp_files.listing(where="output")["files"]] == ["FunPack_00001_.mp4"] + assert temp_files.listing()["files"] == [] + + +def test_delete_removes_a_media_file_and_nothing_else(monkeypatch, tmp_path): + out = tmp_path / "out" + (out / "sub").mkdir(parents=True) + pic, weights, outside = out / "sub" / "a.png", out / "model.safetensors", tmp_path / "secret.png" + for f in (pic, weights, outside): + f.write_bytes(b"x") + monkeypatch.setattr(temp_files, "output_dir", lambda: str(out)) + + assert temp_files.delete("a.png", "sub", "output") == {"deleted": True} and not pic.exists() + assert not temp_files.delete("model.safetensors", "", "output")["deleted"] and weights.exists() + assert not temp_files.delete("secret.png", "..", "output")["deleted"] and outside.exists(), "left the folder" + assert not temp_files.delete("../secret.png", "", "output")["deleted"] and outside.exists() + assert not temp_files.delete("a.png", "sub", "output")["deleted"], "a second delete should say it is gone" + assert not temp_files.delete("a.png", "", "input")["deleted"]