From e4093a2e610db5ba489a5163b78e4408bfa3d4f5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 10 May 2026 10:25:08 -0400 Subject: [PATCH] feat(web): virtualize sandbox file preview and handle empty files Wraps the Pierre File renderer in WorkerPoolContextProvider + Virtualizer so long file previews scroll efficiently and reuse the shared highlighter worker pool. Adds a content-aware sandbox cacheKey so previews don't re-highlight unchanged content. Empty files now render an "Empty file" state instead of mounting an empty File component. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fabro-web/app/routes/run-sandbox.test.tsx | 6 ++ .../run-sandbox/filesystem-panel.test.tsx | 59 +++++++++++++-- .../routes/run-sandbox/filesystem-panel.tsx | 74 +++++++++++++++++-- 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index 4fd17ad62..fa933a96b 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -58,6 +58,12 @@ mock.module("@pierre/trees", () => ({ themeToTreeStyles: () => ({}) })); mock.module("@pierre/theme/pierre-dark", () => ({ default: {} })); mock.module("@pierre/diffs/react", () => ({ File: () =>
, + Virtualizer: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), + WorkerPoolContextProvider: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), })); const { default: RunSandbox, formatBytesAsMemory, normalizeSandboxMode } = diff --git a/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.test.tsx b/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.test.tsx index 617fc7bb5..b14b609c8 100644 --- a/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.test.tsx @@ -25,6 +25,9 @@ let filesState: FilesQueryState = makeEmptyFilesState(); let lastFileArgs: { id: string | undefined; path: string | null | undefined } | null = null; let fileState: FileQueryState = makeEmptyFileState(); let lastTreeOptions: CapturedTreeOptions | null = null; +const pierreFileCalls: Array<{ file: { name: string; contents: string; cacheKey?: string } }> = []; +const providerCalls: any[] = []; +const virtualizerCalls: any[] = []; function makeEmptyFilesState(): FilesQueryState { return { @@ -81,11 +84,22 @@ mock.module("@pierre/theme/pierre-dark", () => ({ })); mock.module("@pierre/diffs/react", () => ({ - File: (props: { file: { name: string; contents: string } }) => ( -
- {props.file.contents} -
- ), + File: (props: { file: { name: string; contents: string; cacheKey?: string } }) => { + pierreFileCalls.push(props); + return ( +
+ {props.file.contents} +
+ ); + }, + Virtualizer: (props: any) => { + virtualizerCalls.push(props); + return
{props.children}
; + }, + WorkerPoolContextProvider: (props: any) => { + providerCalls.push(props); + return
{props.children}
; + }, })); const filesystemPanelModule = await import("./filesystem-panel"); @@ -119,6 +133,9 @@ beforeEach(() => { lastFilesArgs = null; lastFileArgs = null; lastTreeOptions = null; + pierreFileCalls.length = 0; + providerCalls.length = 0; + virtualizerCalls.length = 0; filesState = makeEmptyFilesState(); fileState = makeEmptyFileState(); }); @@ -380,6 +397,38 @@ describe("FilesystemPanel render", () => { && node.props["data-file-name"] === "README.md", ); expect(previews).toHaveLength(1); + expect(providerCalls).toHaveLength(1); + expect(virtualizerCalls).toHaveLength(1); + expect(pierreFileCalls[0].file.cacheKey).toContain( + "fabro-sandbox-file:run_1:/README.md:", + ); + }); + + test("renders an empty text file without mounting Pierre File", () => { + filesState = { + ...makeEmptyFilesState(), + data: { data: [{ name: ".dockerenv", is_dir: false, size: 0 }] }, + }; + fileState = { + ...makeEmptyFileState(), + data: new TextEncoder().encode("").buffer as ArrayBuffer, + }; + const renderer = renderPanel(); + act(() => { + lastTreeOptions?.onSelectionChange?.([".dockerenv"]); + }); + expect(lastFileArgs).toEqual({ + id: "run_1", + path: "/.dockerenv", + }); + expect(findByTestId(renderer, "pierre-file")).toHaveLength(0); + const titles = renderer.root.findAll( + (node) => + node.type === "p" && + Array.isArray(node.children) && + node.children.includes("Empty file"), + ); + expect(titles).toHaveLength(1); }); test("renders binary fallback when file contents contain a null byte", () => { diff --git a/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx b/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx index 1ea9f935b..835920a19 100644 --- a/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx @@ -15,17 +15,23 @@ import { import { FileTree, useFileTree, - useFileTreeSelection, } from "@pierre/trees/react"; import { themeToTreeStyles } from "@pierre/trees"; import pierreDark from "@pierre/theme/pierre-dark"; -import { File } from "@pierre/diffs/react"; +import { + File, + Virtualizer, + WorkerPoolContextProvider, + type FileContents, +} from "@pierre/diffs/react"; import type { SandboxFileEntry } from "@qltysh/fabro-api-client"; import { useSandboxFile, useSandboxFiles } from "../../lib/queries"; import { ApiError } from "../../lib/api-client"; import { EmptyState, ErrorState, LoadingState } from "../../components/state"; import { SECONDARY_BUTTON_CLASS, Tooltip } from "../../components/ui"; +import { workerFactory } from "../../lib/pierre-diffs-worker"; +import { stringHash } from "../run-files/cache-keys"; export const DEFAULT_DIR = "/"; @@ -34,6 +40,8 @@ export const DEFAULT_DIR = "/"; // download-only state. export const TEXT_PREVIEW_BYTE_LIMIT = 256 * 1024; const BINARY_SAMPLE_BYTES = 8 * 1024; +const pierrePoolOptions = { workerFactory }; +const pierreHighlighterOptions = { theme: "pierre-dark" }; type TreeThemeStyle = CSSProperties & Record<`--${string}`, string | number>; @@ -114,6 +122,18 @@ export function formatFileSize(bytes: number | undefined): string | null { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`; } +export function sandboxFileCacheKey({ + runId, + path, + contents, +}: { + runId: string; + path: string; + contents: string; +}): string { + return `fabro-sandbox-file:${runId}:${path}:${stringHash(contents)}`; +} + interface BuiltTreeInputs { paths: string[]; fileEntries: Map; @@ -486,8 +506,13 @@ function PreviewPane({
-
- +
+
); @@ -495,10 +520,14 @@ function PreviewPane({ function PreviewBody({ preview, + runId, fileName, + filePath, }: { preview: PreviewState; + runId: string; fileName: string; + filePath: string; }) { if (preview.status === "loading") { return ( @@ -538,10 +567,39 @@ function PreviewBody({ ); } + if ((preview.text ?? "").length === 0) { + return ( +
+ +
+ ); + } + const file: FileContents = { + name: fileName, + contents: preview.text ?? "", + cacheKey: sandboxFileCacheKey({ + runId, + path: filePath, + contents: preview.text ?? "", + }), + }; return ( - + + + + + ); }