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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-10 10:25:08 -04:00
parent e9461c0ce1
commit e4093a2e61
No known key found for this signature in database
3 changed files with 126 additions and 13 deletions

View file

@ -58,6 +58,12 @@ mock.module("@pierre/trees", () => ({ themeToTreeStyles: () => ({}) }));
mock.module("@pierre/theme/pierre-dark", () => ({ default: {} }));
mock.module("@pierre/diffs/react", () => ({
File: () => <div data-test-id="pierre-file-stub" />,
Virtualizer: ({ children }: { children?: ReactNode }) => (
<div data-test-id="pierre-virtualizer-stub">{children}</div>
),
WorkerPoolContextProvider: ({ children }: { children?: ReactNode }) => (
<div data-test-id="pierre-worker-pool-stub">{children}</div>
),
}));
const { default: RunSandbox, formatBytesAsMemory, normalizeSandboxMode } =

View file

@ -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 } }) => (
<div data-test-id="pierre-file" data-file-name={props.file.name}>
{props.file.contents}
</div>
),
File: (props: { file: { name: string; contents: string; cacheKey?: string } }) => {
pierreFileCalls.push(props);
return (
<div data-test-id="pierre-file" data-file-name={props.file.name}>
{props.file.contents}
</div>
);
},
Virtualizer: (props: any) => {
virtualizerCalls.push(props);
return <div data-test-id="pierre-virtualizer">{props.children}</div>;
},
WorkerPoolContextProvider: (props: any) => {
providerCalls.push(props);
return <div data-test-id="pierre-worker-pool">{props.children}</div>;
},
}));
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", () => {

View file

@ -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<string, SandboxFileEntry>;
@ -486,8 +506,13 @@ function PreviewPane({
</a>
</div>
</header>
<div className="min-h-0 flex-1 overflow-auto">
<PreviewBody preview={preview} fileName={name} />
<div className="min-h-0 flex-1">
<PreviewBody
preview={preview}
runId={runId}
fileName={name}
filePath={filePath}
/>
</div>
</div>
);
@ -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({
</div>
);
}
if ((preview.text ?? "").length === 0) {
return (
<div className="flex h-full items-center justify-center p-6">
<EmptyState
title="Empty file"
description="This file has no contents."
/>
</div>
);
}
const file: FileContents = {
name: fileName,
contents: preview.text ?? "",
cacheKey: sandboxFileCacheKey({
runId,
path: filePath,
contents: preview.text ?? "",
}),
};
return (
<File
file={{ name: fileName, contents: preview.text ?? "" }}
options={{ theme: "pierre-dark", disableFileHeader: true }}
/>
<WorkerPoolContextProvider
poolOptions={pierrePoolOptions}
highlighterOptions={pierreHighlighterOptions}
>
<Virtualizer
className="h-full min-h-0 overflow-auto"
contentClassName="min-w-0 pb-4"
>
<File
file={file}
options={{ theme: "pierre-dark", disableFileHeader: true }}
/>
</Virtualizer>
</WorkerPoolContextProvider>
);
}