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(