From c763b8082436a9758b419544e8aa5fc8540044cc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 22 Aug 2026 10:13:31 +0200 Subject: [PATCH] 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. --- packages/app/e2e/browser/file-editing.spec.ts | 81 ++++++ packages/app/src/file-pane/live-file/hook.ts | 9 +- packages/app/src/file-pane/pane.tsx | 239 +++++------------- .../src/file-pane/source/presentation.test.ts | 14 + .../app/src/file-pane/source/presentation.ts | 17 ++ .../app/src/file-pane/source/view.native.tsx | 141 +++++++++++ packages/app/src/file-pane/source/view.tsx | 1 + .../app/src/file-pane/source/view.web.tsx | 131 ++++++++++ packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/client/src/daemon-client.test.ts | 55 ++++ packages/client/src/daemon-client.ts | 44 +++- packages/protocol/src/messages.ts | 1 + .../files/workspace-files-session.test.ts | 24 ++ .../session/files/workspace-files-session.ts | 6 + 22 files changed, 589 insertions(+), 183 deletions(-) create mode 100644 packages/app/src/file-pane/source/presentation.test.ts create mode 100644 packages/app/src/file-pane/source/presentation.ts create mode 100644 packages/app/src/file-pane/source/view.native.tsx create mode 100644 packages/app/src/file-pane/source/view.tsx create mode 100644 packages/app/src/file-pane/source/view.web.tsx diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index c815b3a06..d2e11f23f 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -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({ diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts index 932301055..7e64facbd 100644 --- a/packages/app/src/file-pane/live-file/hook.ts +++ b/packages/app/src/file-pane/live-file/hook.ts @@ -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]); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 2d49eecac..0cce3b82f 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -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 ( - - - - {String(lineNumber)} - - - - {keyedTokens.map(({ key, token }) => ( - - ))} - + + ); +} + +function TooLargeSource({ size }: { size?: number }) { + const { t } = useTranslation(); + return ( + + {t("panels.file.tooLargeToDisplay")} + {size ? {formatFileSize({ size })} : null} ); -}); - -interface CodeLineTokenProps { - token: HighlightToken; } -function CodeLineToken({ token }: CodeLineTokenProps) { - return {token.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(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 ( @@ -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 = ( - - {keyedLines.map(({ key, tokens, lineNumber }) => ( - = (lineSelection?.lineStart ?? 0) && - lineNumber <= (lineSelection?.lineEnd ?? 0) - } - /> - ))} - - ); - return ( - - - {isMobile ? ( - {codeLines} - ) : ( - - {codeLines} - - )} - - + ); } @@ -557,6 +431,13 @@ function FilePanePresentation({ } if (errorMessage) { + if (errorMessage === "File is too large to display") { + return ( + + + + ); + } return ( diff --git a/packages/app/src/file-pane/source/presentation.test.ts b/packages/app/src/file-pane/source/presentation.test.ts new file mode 100644 index 000000000..fd5578fd9 --- /dev/null +++ b/packages/app/src/file-pane/source/presentation.test.ts @@ -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"); + }); + } +}); diff --git a/packages/app/src/file-pane/source/presentation.ts b/packages/app/src/file-pane/source/presentation.ts new file mode 100644 index 000000000..992331a54 --- /dev/null +++ b/packages/app/src/file-pane/source/presentation.ts @@ -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"; +} diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx new file mode 100644 index 000000000..de7c5eeae --- /dev/null +++ b/packages/app/src/file-pane/source/view.native.tsx @@ -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 ( + + {tooLargeMessage} + + ); + } + return ( + + ); +} + +function VirtualizedSource({ + content, + filename, + location, + navigationRevision, + presentation, +}: Omit & { + presentation: "highlighted" | "plain"; +}) { + const listRef = useRef>(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 ( + + ); +} + +function SourceLineView({ line }: { line: SourceLine }) { + return ( + + {line.number} + + {line.tokens.map((token) => ( + + {token.text} + + ))} + + + ); +} + +function sourceLineKey(line: SourceLine): string { + return String(line.number); +} + +function sourceLineLayout(_data: ArrayLike | null | undefined, index: number) { + return { length: 20, offset: index * 20, index }; +} + +const renderSourceLine: ListRenderItem = ({ 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, + }, +})); diff --git a/packages/app/src/file-pane/source/view.tsx b/packages/app/src/file-pane/source/view.tsx new file mode 100644 index 000000000..5d1b488e7 --- /dev/null +++ b/packages/app/src/file-pane/source/view.tsx @@ -0,0 +1 @@ +export { FileSourceView } from "./view.native"; diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-pane/source/view.web.tsx new file mode 100644 index 000000000..3a38b5d07 --- /dev/null +++ b/packages/app/src/file-pane/source/view.web.tsx @@ -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 ( +
+ {tooLargeMessage} +
+ ); + } + return ( + + ); +} + +function ReadonlyCodeMirror({ + content, + filename, + location, + navigationRevision, + presentation, + theme, +}: Omit & { + presentation: Exclude; +}) { + const hostRef = useRef(null); + const viewRef = useRef(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
; +} + +function languageFor(input: { + filename: string; + presentation: Exclude; +}) { + 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; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 140294a8b..1e0cf7f75 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1700,6 +1700,7 @@ export const ar: TranslationResources = { loading: "جارٍ تحميل الملف...", noPreview: "لا تتوفر معاينة", binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة", + tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه", failedToLoad: "فشل تحميل الملف", failedToLoadPreview: "فشل تحميل معاينة الملف", editor: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3182c168d..72c190c88 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 74ce77e13..4eadca32d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 4a3917b47..ef312200a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index c757afe00..57cf19418 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1717,6 +1717,7 @@ export const ja: TranslationResources = { loading: "ファイルを読み込み中...", noPreview: "プレビューが利用できません", binaryPreviewUnavailable: "バイナリプレビューが利用できません", + tooLargeToDisplay: "このファイルは大きすぎて表示できません", failedToLoad: "ファイルの読み込みに失敗しました", failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました", editor: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d56d3999d..537d724dc 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1710,6 +1710,7 @@ export const ko: TranslationResources = { loading: "파일 불러오는 중...", noPreview: "사용 가능한 미리보기가 없습니다", binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다", + tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다", failedToLoad: "파일을 불러오지 못했습니다", failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다", editor: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 9593d5787..a65d05391 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index aa46e22a1..6a4e719e7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1730,6 +1730,7 @@ export const ru: TranslationResources = { loading: "Загрузка файла...", noPreview: "Предварительный просмотр недоступен", binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.", + tooLargeToDisplay: "Этот файл слишком велик для отображения", failedToLoad: "Не удалось загрузить файл", failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", editor: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 05cf66c9d..3fb546b88 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1680,6 +1680,7 @@ export const zhCN: TranslationResources = { loading: "正在加载文件...", noPreview: "没有可用预览", binaryPreviewUnavailable: "二进制预览不可用", + tooLargeToDisplay: "此文件过大,无法显示", failedToLoad: "加载文件失败", failedToLoadPreview: "加载文件预览失败", editor: { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 140eb6aa4..f2ed2125a 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -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(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5e07dc1f2..0921f0136 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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 { 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 { + async readFile( + cwd: string, + path: string, + requestId?: string, + maxBytes?: number, + ): Promise { 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, { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index bcc9c5845..d7a0011dc 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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", [ diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index d176e03bf..6d27509fb 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -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); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index c74de3aca..0f833479c 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -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(