fix(files): keep large source previews responsive (#3665)

Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
This commit is contained in:
Mohamed Boudra
2026-08-22 10:13:31 +02:00
committed by GitHub
parent 8905ad4160
commit c763b80824
22 changed files with 589 additions and 183 deletions
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-lockfile-",
title: "Large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "package-lock.json");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText('"package-0"');
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
} finally {
await session.cleanup();
}
});
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-plain-",
title: "Plain large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "plain.txt"),
"plain source\n".repeat(1_050_000),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "plain.txt");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText("plain source");
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
await expect(page.getByTestId("file-source-editor")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refuses a file above the display budget and keeps its tab recoverable", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-unsupported-",
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "too-large.txt");
await expect(page.getByTestId("file-source-too-large")).toContainText(
"This file is too large to display",
);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
+8 -1
View File
@@ -1,6 +1,8 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { LiveFileModel, type LiveFileSession } from "./model";
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
import { isWeb } from "@/constants/platform";
export function useLiveFile(input: {
client: DaemonClient | null;
@@ -18,7 +20,12 @@ export function useLiveFile(input: {
return client.subscribeFile(target, onVersion);
},
read(target) {
return client.readFile(target.cwd, target.path);
return client.readFile(
target.cwd,
target.path,
undefined,
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
);
},
};
}, [input.client]);
+60 -179
View File
@@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
@@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface CodeLineProps {
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
}
interface FilePreviewBodyProps {
preview: ExplorerFile | null;
mode?: "preview" | "source";
@@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
interface FileLineSelection {
lineStart: number;
lineEnd: number;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function clampLineSelection(input: {
lineStart?: number;
lineEnd?: number;
lineCount: number;
}): FileLineSelection | null {
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
return null;
}
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
const rawLineEnd =
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
function ReadonlySource({
preview,
filename,
location,
navigationRevision,
}: {
preview: ExplorerFile;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<View style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken key={key} token={token} />
))}
</Text>
<FileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
function TooLargeSource({ size }: { size?: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
</View>
);
});
interface CodeLineTokenProps {
token: HighlightToken;
}
function CodeLineToken({ token }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: "row",
},
highlightedLine: {
backgroundColor: theme.colors.accentBorder,
},
gutter: {
alignItems: "flex-end",
paddingRight: theme.spacing[3],
flexShrink: 0,
},
gutterText: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
opacity: 0.4,
userSelect: "none",
},
lineText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
flex: 1,
},
}));
function FilePreviewBody({
preview,
mode,
isLoading,
isMobile,
isMobile: _isMobile,
location,
navigationRevision,
imagePreviewUri,
}: FilePreviewBodyProps) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const filePath = location.path;
// A line target means the caller wants to land on that line, so fall back to
@@ -197,48 +146,11 @@ function FilePreviewBody({
const previewScrollRef = useRef<RNScrollView>(null);
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text" || renderKind) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
}, [renderKind, preview, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri],
);
useEffect(() => {
if (!lineSelection) {
return;
}
const timeout = setTimeout(() => {
previewScrollRef.current?.scrollTo({
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
animated: false,
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection, navigationRevision]);
if (isLoading && !preview) {
return (
<View style={styles.centerState} testID="file-preview-loading">
@@ -280,51 +192,13 @@ function FilePreviewBody({
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const keyedLines = lines.map((tokens, index) => ({
key: `line-${index}`,
tokens,
lineNumber: index + 1,
}));
const codeLines = (
<View dataSet={CODE_SURFACE_DATASET}>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
highlighted={
Boolean(lineSelection) &&
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/>
))}
</View>
);
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
showsVerticalScrollIndicator
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
</View>
<ReadonlySource
preview={preview}
filename={filePath}
location={location}
navigationRevision={navigationRevision}
/>
);
}
@@ -557,6 +431,13 @@ function FilePanePresentation({
}
if (errorMessage) {
if (errorMessage === "File is too large to display") {
return (
<View style={styles.container} testID="workspace-file-pane">
<TooLargeSource />
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { SOURCE_PRESENTATION_BUDGETS, selectSourcePresentation } from "./presentation";
describe("selectSourcePresentation", () => {
for (const platform of ["web", "native"] as const) {
const budgets = SOURCE_PRESENTATION_BUDGETS[platform];
it(`keeps ${platform} threshold edges predictable`, () => {
expect(selectSourcePresentation({ platform, size: budgets.highlighted })).toBe("highlighted");
expect(selectSourcePresentation({ platform, size: budgets.highlighted + 1 })).toBe("plain");
expect(selectSourcePresentation({ platform, size: budgets.plain })).toBe("plain");
expect(selectSourcePresentation({ platform, size: budgets.plain + 1 })).toBe("unsupported");
});
}
});
@@ -0,0 +1,17 @@
export const SOURCE_PRESENTATION_BUDGETS = {
web: { highlighted: 10 * 1024 * 1024, plain: 50 * 1024 * 1024 },
native: { highlighted: 1024 * 1024, plain: 10 * 1024 * 1024 },
} as const;
export type SourcePresentation = "highlighted" | "plain" | "unsupported";
export type SourcePlatform = keyof typeof SOURCE_PRESENTATION_BUDGETS;
export function selectSourcePresentation(input: {
size: number;
platform: SourcePlatform;
}): SourcePresentation {
const budgets = SOURCE_PRESENTATION_BUDGETS[input.platform];
if (input.size <= budgets.highlighted) return "highlighted";
if (input.size <= budgets.plain) return "plain";
return "unsupported";
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { selectSourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
interface SourceLine {
number: number;
tokens: HighlightToken[];
}
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "native" });
if (presentation === "unsupported") {
return (
<View style={styles.unsupported} testID="file-source-too-large">
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
</View>
);
}
return (
<VirtualizedSource
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
/>
);
}
function VirtualizedSource({
content,
filename,
location,
navigationRevision,
presentation,
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const listRef = useRef<FlatList<SourceLine>>(null);
const lines = useMemo(() => {
if (presentation === "highlighted")
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
useEffect(() => {
if (!location.lineStart) return;
listRef.current?.scrollToIndex({
index: Math.min(location.lineStart - 1, lines.length - 1),
animated: false,
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
}
function sourceLineKey(line: SourceLine): string {
return String(line.number);
}
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
const styles = StyleSheet.create((theme) => ({
unsupported: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
gutter: {
width: 56,
paddingRight: theme.spacing[3],
textAlign: "right",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
text: {
flex: 1,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
},
}));
@@ -0,0 +1 @@
export { FileSourceView } from "./view.native";
@@ -0,0 +1,131 @@
import { useEffect, useRef } from "react";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
const languageCompartment = new Compartment();
const themeCompartment = new Compartment();
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
theme,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "web" });
if (presentation === "unsupported") {
return (
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
{tooLargeMessage}
</div>
);
}
return (
<ReadonlyCodeMirror
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
theme={theme}
/>
);
}
function ReadonlyCodeMirror({
content,
filename,
location,
navigationRevision,
presentation,
theme,
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const initial = useRef({ content, filename, presentation, theme });
useEffect(() => {
if (!hostRef.current) return;
const values = initial.current;
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: values.content,
extensions: [
EditorState.readOnly.of(true),
EditorView.editable.of(false),
languageCompartment.of(
languageFor({ filename: values.filename, presentation: values.presentation }),
),
themeCompartment.of(editorTheme(values.theme)),
],
}),
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view || view.state.doc.toString() === content) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } });
}, [content]);
useEffect(() => {
viewRef.current?.dispatch({
effects: [
languageCompartment.reconfigure(languageFor({ filename, presentation })),
themeCompartment.reconfigure(editorTheme(theme)),
],
});
}, [filename, presentation, theme]);
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const line = Math.min(location.lineStart, view.state.doc.lines);
const from = view.state.doc.line(line).from;
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
}, [location.lineStart, navigationRevision]);
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
}
function languageFor(input: {
filename: string;
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
return input.presentation === "highlighted"
? (getLanguageForFile(input.filename)?.extension ?? [])
: [];
}
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
const UNSUPPORTED_STYLE = {
alignItems: "center",
display: "flex",
flex: 1,
justifyContent: "center",
} as const;
+1
View File
@@ -1700,6 +1700,7 @@ export const ar: TranslationResources = {
loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
failedToLoad: "فشل تحميل الملف",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
+1
View File
@@ -1710,6 +1710,7 @@ export const en = {
loading: "Loading file...",
noPreview: "No preview available",
binaryPreviewUnavailable: "Binary preview unavailable",
tooLargeToDisplay: "This file is too large to display",
failedToLoad: "Failed to load file",
failedToLoadPreview: "Failed to load file preview",
editor: {
+1
View File
@@ -1746,6 +1746,7 @@ export const es: TranslationResources = {
loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible",
binaryPreviewUnavailable: "Vista previa binaria no disponible",
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
failedToLoad: "No se pudo cargar el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
+1
View File
@@ -1750,6 +1750,7 @@ export const fr: TranslationResources = {
loading: "Chargement du fichier...",
noPreview: "Aucun aperçu disponible",
binaryPreviewUnavailable: "Aperçu binaire indisponible",
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
failedToLoad: "Échec du chargement du fichier",
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
editor: {
+1
View File
@@ -1717,6 +1717,7 @@ export const ja: TranslationResources = {
loading: "ファイルを読み込み中...",
noPreview: "プレビューが利用できません",
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
failedToLoad: "ファイルの読み込みに失敗しました",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
+1
View File
@@ -1710,6 +1710,7 @@ export const ko: TranslationResources = {
loading: "파일 불러오는 중...",
noPreview: "사용 가능한 미리보기가 없습니다",
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
failedToLoad: "파일을 불러오지 못했습니다",
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
editor: {
+1
View File
@@ -1732,6 +1732,7 @@ export const ptBR: TranslationResources = {
loading: "Carregando arquivo...",
noPreview: "Nenhuma prévia disponível",
binaryPreviewUnavailable: "Prévia binária indisponível",
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
failedToLoad: "Falha ao carregar arquivo",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
+1
View File
@@ -1730,6 +1730,7 @@ export const ru: TranslationResources = {
loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
tooLargeToDisplay: "Этот файл слишком велик для отображения",
failedToLoad: "Не удалось загрузить файл",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
+1
View File
@@ -1680,6 +1680,7 @@ export const zhCN: TranslationResources = {
loading: "正在加载文件...",
noPreview: "没有可用预览",
binaryPreviewUnavailable: "二进制预览不可用",
tooLargeToDisplay: "此文件过大,无法显示",
failedToLoad: "加载文件失败",
failedToLoadPreview: "加载文件预览失败",
editor: {
+55
View File
@@ -2212,6 +2212,61 @@ test("readFile resolves from binary file frames when the daemon supports them",
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_file_budget_compat",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10);
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
cwd: "/tmp/project",
path: "large.txt",
mode: "file",
acceptBinary: true,
maxBytes: 10,
requestId: "req-budget",
},
});
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-budget",
metadata: {
mime: "text/plain",
size: 100,
encoding: "utf-8",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
);
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-budget",
payload: new Uint8Array(100),
}),
);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }),
);
await expect(responsePromise).rejects.toThrow("File is too large to display");
});
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+41 -3
View File
@@ -831,6 +831,7 @@ interface CorrelatedResponseIdentity {
interface PendingBinaryFileRead {
cwd: string;
path: string;
maxBytes?: number;
}
interface BinaryFileTransferState extends PendingBinaryFileRead {
@@ -4308,6 +4309,7 @@ export class DaemonClient {
mode: "list" | "file",
requestId?: string,
acceptBinary = false,
maxBytes?: number,
): Promise<FileExplorerPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -4317,6 +4319,7 @@ export class DaemonClient {
path,
mode,
...(acceptBinary ? { acceptBinary: true } : {}),
...(maxBytes ? { maxBytes } : {}),
},
responseType: "file_explorer_response",
});
@@ -4337,11 +4340,23 @@ export class DaemonClient {
return payload.directory;
}
async readFile(cwd: string, path: string, requestId?: string): Promise<FileReadResult> {
async readFile(
cwd: string,
path: string,
requestId?: string,
maxBytes?: number,
): Promise<FileReadResult> {
const resolvedRequestId = this.createRequestId(requestId);
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path });
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
try {
const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true);
const payload = await this.requestFileExplorer(
cwd,
path,
"file",
resolvedRequestId,
true,
maxBytes,
);
if (payload.error) {
throw new Error(payload.error);
}
@@ -5761,10 +5776,33 @@ export class DaemonClient {
}
if (frame.opcode === FileTransferOpcode.FileChunk) {
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
// Old daemons stream despite maxBytes; discard before client-side accumulation.
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
return;
}
transfer.chunks.push(frame.payload);
return;
}
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
this.activeBinaryFileTransfers.delete(frame.requestId);
this.handleSessionMessage({
type: "file_explorer_response",
payload: {
cwd: transfer.cwd,
path: transfer.path,
mode: "file",
directory: null,
file: null,
error: "File is too large to display",
requestId: frame.requestId,
},
});
return;
}
const bytes = concatByteChunks(transfer.chunks, transfer.size);
this.activeBinaryFileTransfers.delete(frame.requestId);
this.completedBinaryFileReads.set(frame.requestId, {
+1
View File
@@ -2555,6 +2555,7 @@ export const FileExplorerRequestSchema = z.object({
mode: z.enum(["list", "file"]),
requestId: z.string(),
acceptBinary: z.boolean().optional(),
maxBytes: z.number().int().positive().optional(),
});
export const FileVersionSchema = z.discriminatedUnion("status", [
@@ -372,6 +372,30 @@ describe("WorkspaceFilesSession", () => {
]);
});
test("rejects an over-budget file before opening a binary transfer", async () => {
const cwd = makeDir("workspace-files-read-budget-");
writeFileSync(join(cwd, "notes.txt"), "hello world");
const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: true });
await subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd,
path: "notes.txt",
mode: "file",
requestId: "req-read-budget",
acceptBinary: true,
maxBytes: 5,
});
expect(binary).toEqual([]);
expect(emitted).toEqual([
expect.objectContaining({
type: "file_explorer_response",
payload: expect.objectContaining({ error: "File is too large to display" }),
}),
]);
});
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
const cwd = makeDir("workspace-files-large-binary-");
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
@@ -263,6 +263,12 @@ export class WorkspaceFilesSession {
source,
);
} else {
if (request.maxBytes) {
const file = await getDownloadableFileInfo({ root: cwd, relativePath: requestedPath });
if (file.size > request.maxBytes) {
throw new Error("File is too large to display");
}
}
if (request.acceptBinary && this.host.hasBinaryChannel()) {
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
await this.host.emitBinary(