From 1f16fb97fc7c793b453eeebd1cbbb6b93acaa6ab Mon Sep 17 00:00:00 2001 From: phantomptr Date: Sat, 17 Oct 2026 13:47:56 -0700 Subject: [PATCH] console files: decompress progress, case-only rename, unpack name hardening Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS --- client/src/console/files/Files.tsx | 41 +++++++++- client/src/console/files/InfoDialog.tsx | 9 ++- client/src/console/files/UnpackDialog.tsx | 7 ++ client/src/console/files/VerifyDialog.tsx | 10 +-- client/src/console/files/batch.test.ts | 14 ++++ client/src/console/files/batch.ts | 11 +++ client/src/console/files/moreActions.test.tsx | 20 ++++- client/src/console/ui/restoreFocus.test.ts | 46 ++++++++++- client/src/console/ui/restoreFocus.ts | 20 +++++ payload/include/fs_jobs.h | 1 + payload/include/webui_codec.h | 14 ++++ payload/src/ffpfsc_unpack.c | 23 +++--- payload/src/fs_jobs.c | 3 + payload/src/webui_api.c | 79 +++++++++++++++---- payload/src/webui_codec.c | 30 ++++++- payload/tests/webui_codec_selftest.c | 32 ++++++++ 16 files changed, 322 insertions(+), 38 deletions(-) diff --git a/client/src/console/files/Files.tsx b/client/src/console/files/Files.tsx index 0b74391a..91f6f16c 100644 --- a/client/src/console/files/Files.tsx +++ b/client/src/console/files/Files.tsx @@ -256,7 +256,14 @@ export function busyText(b: Busy | null): string | null { if (b.op === "move") return t("cw_files_batch_move", { at, total: b.total }); if (b.op === "chmod") return t("cw_files_fixperm_batch", { at, total: b.total }); if (b.op === "rename") return t("cw_files_br_batch", { at, total: b.total }); - if (b.op === "unpack") return t("cw_files_unpack_batch", { name: baseName(b.paths?.[0] ?? "") }); + if (b.op === "unpack") { + const line = t("cw_files_unpack_batch", { name: baseName(b.paths?.[0] ?? "") }); + const total = b.bytesTotal ?? 0; + if (total <= 0) return line; + const done = Math.min(b.bytesDone ?? 0, total); + const pct = Math.min(100, Math.floor((done / total) * 100)); + return `${line} · ${t("cw_home_percent", { n: pct })} · ${formatBytes(done)} / ${formatBytes(total)}`; + } return t("cw_files_batch_delete", { at, total: b.total }); } @@ -287,6 +294,32 @@ export function renameItems(names: string[], shown: Entry[], entries: Entry[]): return ordered.map((name) => ({ name, dir: kinds.get(name) === "dir" })); } +/* + * askView is a plain call (no hooks), so the bulk rename dialog's inputs are + * kept by their last arguments: the same names/shown/entries arrays give the + * same items and names arrays, and the dialog's plan useMemo is not redone on + * every Files render (a job poll, the batch line). + */ +let lastItems: { names: string[]; shown: Entry[]; entries: Entry[]; out: RenameItem[] } | null = null; +let lastNames: { entries: Entry[]; out: string[] } | null = null; + +/** renameItems, the same array again for the same arguments. */ +export function renameItemsKept(names: string[], shown: Entry[], entries: Entry[]): RenameItem[] { + const c = lastItems; + if (c && c.names === names && c.shown === shown && c.entries === entries) return c.out; + const out = renameItems(names, shown, entries); + lastItems = { names, shown, entries, out }; + return out; +} + +/** The folder's names, the same array again for the same entries. */ +export function entryNamesKept(entries: Entry[]): string[] { + if (lastNames && lastNames.entries === entries) return lastNames.out; + const out = entries.map((e) => e.name); + lastNames = { entries, out }; + return out; +} + /** The line a finished bulk rename leaves. */ export function renameMessage(r: RenameResult): Message | null { if (r.failedRow) { @@ -529,8 +562,8 @@ function askView(p: FilesViewProps) { case "bulkrename": return ( e.name)} + items={renameItemsKept(a.names, p.shown ?? p.dir.entries, p.dir.entries)} + existing={entryNamesKept(p.dir.entries)} onRename={(plan) => p.onBulkRename?.(plan.rows)} onCancel={p.onDismiss} /> @@ -1251,7 +1284,7 @@ export function Files({ ctx }: { ctx: SectionContext }) { try { const job = await startOp("/api/fs/unpack", outName ? { path: src, name: outName } : { path: src }); r.started.push(job); - end = await watchOp(job, { stopped: ctl.stopped }); + end = await watchOp(job, { stopped: ctl.stopped, onStatus: (s) => ctl.onBytes(s.bytes_done, s.bytes_total) }); } catch (error) { end = { end: "error", error }; } diff --git a/client/src/console/files/InfoDialog.tsx b/client/src/console/files/InfoDialog.tsx index 85743927..bebf4931 100644 --- a/client/src/console/files/InfoDialog.tsx +++ b/client/src/console/files/InfoDialog.tsx @@ -3,7 +3,9 @@ import { Calculator, Square } from "lucide-react"; import { formatBytes } from "../../lib/format"; import { call } from "../api"; import { getLanguage, t } from "../strings"; +import { topModal } from "../nav"; import { Modal } from "../ui/Modal"; +import { refocusIfLost } from "../ui/restoreFocus"; import { startFolderSize, SIZE_MAX_ENTRIES, SIZE_MAX_MS, type SizeCount, type SizeResult } from "./folderSize"; import { LIST_PAGE } from "./useDir"; @@ -56,6 +58,11 @@ function FolderSize({ path }: { path: string }) { }, [], ); + // Calculate turns into Cancel and back: focus follows instead of falling out of the dialog. + const phase = walk?.state ?? null; + useEffect(() => { + refocusIfLost(topModal(), phase === "running" ? ['[data-action="size-cancel"]'] : ['[data-action="size-start"]']); + }, [phase]); const start = () => { const zero: SizeCount = { files: 0, folders: 0, bytes: 0, entries: 0, unreadable: 0 }; const w = startFolderSize(path, (count) => setWalk({ state: "running", count })); @@ -103,7 +110,7 @@ function FolderSize({ path }: { path: string }) { ) : null} {note ?

{note}

: null} - {walk?.state === "held" ?

{t("cw_files_size_busy")}

: null} + {walk?.state === "held" ?

{t("cw_files_size_busy")}

: null}