mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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:
parent
e9461c0ce1
commit
e4093a2e61
3 changed files with 126 additions and 13 deletions
|
|
@ -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 } =
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue