From 096ecac4c8fa8aa62fcf41f3e2854524641e6c41 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:40:44 -0700 Subject: [PATCH] fix(editor): degrade oversized files to an explanatory view instead of a dead editor Three surfaces each hardcoded their own read budget and, past it, the editor showed a generic 'Unable to load file' box whose Retry button re-ran the same deterministic size check. Two further gaps: an oversized non-previewable binary was refused as 'too large' instead of reaching the existing binary placeholder, because the size gate ran before the binary probe; and the editor silently drops tokenization, folding, wrapping, code lenses, sticky scroll and word highlighting on very large models with no explanation. - Add src/shared/editor-file-read-limit.ts as the single definition of the per-transport budgets. No value changes: local text stays 50MB (#1367), SSH stays 10MB, previewable binaries stay 50MB. The divergence is deliberate and now documented rather than duplicated. - Refuse with a machine-readable message so the renderer can render a LargeFileFallback naming the actual size, the limit that applied, and which transport applied it. - Classify binary before applying the text budget on the local and relay read paths, so oversized archives still land on the binary placeholder. - Report the degradation the editor already performs, read from the model itself; an unreadable flag stays silent rather than guessing. - files.read now reports its own budget (new optional field), so the remote-workspace path reaches the same fallback on hosts that send it. --- src/main/ipc/filesystem.test.ts | 24 ++++++- src/main/ipc/filesystem.ts | 41 +++++++----- src/main/runtime/orca-runtime-files.ts | 6 +- src/main/ssh/ssh-file-stream-read-cap.ts | 11 ++-- .../fs-handler-file-read-size-gate.test.ts | 54 ++++++++++++++++ src/relay/fs-handler-file-read.ts | 34 ++++++---- src/relay/fs-handler-utils.ts | 13 ++-- .../editor/EditorConflictReviewSurface.tsx | 1 + .../src/components/editor/EditorContent.tsx | 1 + .../editor/EditorEditFileSurface.tsx | 1 + .../editor/EditorFileLoadErrorView.test.tsx | 43 +++++++++++++ .../editor/EditorFileLoadErrorView.tsx | 10 +++ .../components/editor/LargeFileFallback.tsx | 63 ++++++++++++++++++ .../src/components/editor/MonacoEditor.tsx | 3 + .../monaco-large-file-optimizations.test.ts | 30 +++++++++ .../editor/monaco-large-file-optimizations.ts | 39 +++++++++++ .../use-monaco-large-file-notice.test.tsx | 50 +++++++++++++++ .../editor/use-monaco-large-file-notice.ts | 42 ++++++++++++ src/renderer/src/i18n/locales/en.json | 12 +++- .../src/runtime/runtime-file-client.ts | 12 +++- src/shared/editor-file-read-limit.test.ts | 57 +++++++++++++++++ src/shared/editor-file-read-limit.ts | 64 +++++++++++++++++++ src/shared/runtime-file-contracts.ts | 2 + 23 files changed, 571 insertions(+), 42 deletions(-) create mode 100644 src/relay/fs-handler-file-read-size-gate.test.ts create mode 100644 src/renderer/src/components/editor/EditorFileLoadErrorView.test.tsx create mode 100644 src/renderer/src/components/editor/LargeFileFallback.tsx create mode 100644 src/renderer/src/components/editor/monaco-large-file-optimizations.test.ts create mode 100644 src/renderer/src/components/editor/monaco-large-file-optimizations.ts create mode 100644 src/renderer/src/components/editor/use-monaco-large-file-notice.test.tsx create mode 100644 src/renderer/src/components/editor/use-monaco-large-file-notice.ts create mode 100644 src/shared/editor-file-read-limit.test.ts create mode 100644 src/shared/editor-file-read-limit.ts diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index fe1568dbf72..3972f6221eb 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -430,7 +430,29 @@ describe('registerFilesystemHandlers', () => { await expect( handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.json') }) - ).rejects.toThrow('exceeds 50MB limit') + ).rejects.toThrow('[size=53477376 limit=52428800 scope=local]') + + expect(readFileMock).not.toHaveBeenCalled() + }) + + // The size gate must not swallow the binary placeholder: an oversized archive + // is not "unreadable", it is simply not text, and the editor already has a + // view for that. + it('still reports oversized non-previewable binaries as binary', async () => { + statMock.mockResolvedValue({ size: 60 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) + openMock.mockResolvedValue({ + read: vi.fn(async (buffer: Buffer) => { + buffer[0] = 0x00 + return { bytesRead: 1, buffer } + }), + close: vi.fn() + }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.bin') }) + ).resolves.toEqual({ content: '', isBinary: true }) expect(readFileMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 0ad3825a7fc..a460ab6c5c0 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -144,15 +144,20 @@ import { applyGitStatusUpstreamRefWatchRequest, type GitStatusUpstreamRefWatchRequest } from './git-status-upstream-ref-watch-request' +import { + EDITOR_PREVIEWABLE_BINARY_MAX_BYTES, + EDITOR_TEXT_READ_LIMIT_BYTES, + formatFileTooLargeMessage +} from '../../shared/editor-file-read-limit' -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +// Why: the editor degrades features on large files, so a 5MB block would needlessly lock out ordinary JSON/log files. +const MAX_TEXT_FILE_SIZE = EDITOR_TEXT_READ_LIMIT_BYTES.local const BINARY_PROBE_BYTES = 8192 const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ // 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 // Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +const MAX_PREVIEWABLE_BINARY_SIZE = EDITOR_PREVIEWABLE_BINARY_MAX_BYTES const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', @@ -164,6 +169,11 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.ico': 'image/x-icon', '.pdf': 'application/pdf' } + +function throwFileTooLarge(byteLength: number, limitBytes: number): never { + throw new Error(formatFileTooLargeMessage({ byteLength, limitBytes, scope: 'local' })) +} + async function readLocalLogSnapshot(filePath: string): Promise<{ content: string isBinary: boolean @@ -173,15 +183,11 @@ async function readLocalLogSnapshot(filePath: string): Promise<{ try { const stats = await handle.stat() if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) + throwFileTooLarge(stats.size, MAX_TEXT_FILE_SIZE) } const buffer = await handle.readFile() if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) + throwFileTooLarge(buffer.byteLength, MAX_TEXT_FILE_SIZE) } if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } @@ -599,14 +605,11 @@ export function registerFilesystemHandlers( } const stats = await stat(filePath) const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } if (mimeType) { + if (stats.size > MAX_PREVIEWABLE_BINARY_SIZE) { + throwFileTooLarge(stats.size, MAX_PREVIEWABLE_BINARY_SIZE) + } const buffer = await readFile(filePath) return { content: buffer.toString('base64'), @@ -617,11 +620,17 @@ export function registerFilesystemHandlers( } } - // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. + // Why: classify before the text budget applies. An oversized archive is not + // unreadable, it is not text — the binary placeholder is the right answer, + // and the probe reads 8KB so it never buffers the file to find out. if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { return { content: '', isBinary: true } } + if (stats.size > MAX_TEXT_FILE_SIZE) { + throwFileTooLarge(stats.size, MAX_TEXT_FILE_SIZE) + } + const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 6a4836682ec..f3ad78c3787 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -827,7 +827,8 @@ export class RuntimeFileCommands { relativePath, content: truncated.content, truncated: truncated.truncated, - byteLength: truncated.byteLength + byteLength: truncated.byteLength, + maxByteLength: MOBILE_FILE_READ_MAX_BYTES } } @@ -1260,7 +1261,8 @@ export class RuntimeFileCommands { relativePath: grant.absolutePath, content: truncated.content, truncated: truncated.truncated, - byteLength: truncated.byteLength + byteLength: truncated.byteLength, + maxByteLength: MOBILE_FILE_READ_MAX_BYTES } } diff --git a/src/main/ssh/ssh-file-stream-read-cap.ts b/src/main/ssh/ssh-file-stream-read-cap.ts index e5bdce97e5a..5e6244d5d3d 100644 --- a/src/main/ssh/ssh-file-stream-read-cap.ts +++ b/src/main/ssh/ssh-file-stream-read-cap.ts @@ -1,10 +1,13 @@ import type { FileReadLimits } from '../providers/types' - -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 +import { + EDITOR_PREVIEWABLE_BINARY_MAX_BYTES, + EDITOR_TEXT_READ_LIMIT_BYTES +} from '../../shared/editor-file-read-limit' export function sshFileStreamReadCap(isBinary: boolean, limits?: FileReadLimits): number { - const defaultCap = isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + const defaultCap = isBinary + ? EDITOR_PREVIEWABLE_BINARY_MAX_BYTES + : EDITOR_TEXT_READ_LIMIT_BYTES.ssh const requestedCap = isBinary ? limits?.maxBinaryBytes : limits?.maxTextBytes return requestedCap === undefined ? defaultCap : Math.min(defaultCap, requestedCap) } diff --git a/src/relay/fs-handler-file-read-size-gate.test.ts b/src/relay/fs-handler-file-read-size-gate.test.ts new file mode 100644 index 00000000000..bc5c442e0ec --- /dev/null +++ b/src/relay/fs-handler-file-read-size-gate.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const statMock = vi.hoisted(() => vi.fn()) +const readFileMock = vi.hoisted(() => vi.fn()) +const openMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:fs/promises', () => ({ + stat: statMock, + readFile: readFileMock, + open: openMock +})) + +import { readRelayFileContent } from './fs-handler-file-read' +import { EDITOR_TEXT_READ_LIMIT_BYTES } from '../shared/editor-file-read-limit' + +function probeReturning(firstByte: number): void { + openMock.mockResolvedValue({ + read: vi.fn(async (buffer: Buffer) => { + buffer[0] = firstByte + return { bytesRead: 1, buffer } + }), + close: vi.fn() + }) +} + +describe('readRelayFileContent size gate', () => { + beforeEach(() => { + statMock.mockReset() + readFileMock.mockReset() + openMock.mockReset() + }) + + // The remote budget must not swallow the binary placeholder either. + it('reports an oversized remote archive as binary instead of refusing it', async () => { + statMock.mockResolvedValue({ size: 40 * 1024 * 1024 }) + probeReturning(0x00) + + await expect(readRelayFileContent('/remote/repo/archive.bin')).resolves.toEqual({ + content: '', + isBinary: true + }) + expect(readFileMock).not.toHaveBeenCalled() + }) + + it('refuses oversized remote text with the transport named in the message', async () => { + statMock.mockResolvedValue({ size: 12 * 1024 * 1024 }) + probeReturning(0x61) + + await expect(readRelayFileContent('/remote/repo/huge.json')).rejects.toThrow( + `[size=${12 * 1024 * 1024} limit=${EDITOR_TEXT_READ_LIMIT_BYTES.ssh} scope=ssh]` + ) + expect(readFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index e8b7993fbcd..17e00601293 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -12,26 +12,34 @@ import { isBinaryBuffer, isBinaryFilePrefix } from './fs-handler-utils' +import { formatFileTooLargeMessage } from '../shared/editor-file-read-limit' + +function throwRelayFileTooLarge(byteLength: number, limitBytes: number): never { + throw new Error(formatFileTooLargeMessage({ byteLength, limitBytes, scope: 'ssh' })) +} export async function readRelayFileContent(filePath: string) { const stats = await stat(filePath) const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } if (mimeType) { + if (stats.size > MAX_PREVIEWABLE_BINARY_SIZE) { + throwRelayFileTooLarge(stats.size, MAX_PREVIEWABLE_BINARY_SIZE) + } const buffer = await readFile(filePath) return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } } + // Why: classify before the text budget applies, so an oversized archive still + // reaches the binary placeholder instead of a "too large" refusal. if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { return { content: '', isBinary: true } } + if (stats.size > MAX_TEXT_FILE_SIZE) { + throwRelayFileTooLarge(stats.size, MAX_TEXT_FILE_SIZE) + } + const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } @@ -80,11 +88,8 @@ export async function readRelayFileStreamMetadata( ): Promise { const stats = await stat(filePath) const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) + if (mimeType && stats.size > MAX_PREVIEWABLE_BINARY_SIZE) { + throwRelayFileTooLarge(stats.size, MAX_PREVIEWABLE_BINARY_SIZE) } if (stats.size === 0) { @@ -98,11 +103,16 @@ export async function readRelayFileStreamMetadata( } // Why: unlike the legacy single-shot path, streaming does not read the full // buffer before classifying content. Probe every unknown file so small binary - // files do not get decoded as UTF-8 text over SSH. + // files do not get decoded as UTF-8 text over SSH — and so an oversized + // archive lands on the binary placeholder rather than the text budget. if (!mimeType && (await isBinaryFilePrefix(filePath))) { return { totalSize: 0, isBinary: true, empty: true } } + if (!mimeType && stats.size > MAX_TEXT_FILE_SIZE) { + throwRelayFileTooLarge(stats.size, MAX_TEXT_FILE_SIZE) + } + // Why: reserved before the fd opens so a refusal costs nothing, and released only // once the terminal frame settles — see reserveTerminalFrameSlot. const releaseTerminalFrameSlot = reserveTerminalFrameSlot(registry, context.clientId) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index a9df29dd2f2..23846167767 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -15,6 +15,10 @@ import { SEARCH_TIMEOUT_MS as SHARED_SEARCH_TIMEOUT_MS } from '../shared/text-search' import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' +import { + EDITOR_PREVIEWABLE_BINARY_MAX_BYTES, + EDITOR_TEXT_READ_LIMIT_BYTES +} from '../shared/editor-file-read-limit' import type { SearchResult as SharedSearchResult } from '../shared/code-search-types' import { absorbPendingRipgrepSpawnError, @@ -27,13 +31,12 @@ import { // ─── Constants ─────────────────────────────────────────────────────── // Why: remote reads still travel through bounded JSON-RPC frames, but matching -// the old 5MB search cap would block common JSON/log files before Monaco's +// the old 5MB search cap would block common JSON/log files before the editor's // large-file optimizations can handle them. -export const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 -// Why: matches the local cap (src/main/ipc/filesystem.ts MAX_PREVIEWABLE_BINARY_SIZE). -// Reads above the legacy 16MB single-frame budget go through fs.readFileStream, +export const MAX_TEXT_FILE_SIZE = EDITOR_TEXT_READ_LIMIT_BYTES.ssh +// Why: reads above the legacy 16MB single-frame budget go through fs.readFileStream, // which chunks at STREAM_CHUNK_SIZE; see docs/relay-file-stream-design.md. -export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 +export const MAX_PREVIEWABLE_BINARY_SIZE = EDITOR_PREVIEWABLE_BINARY_MAX_BYTES export const BINARY_PROBE_BYTES = 8192 export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS export const DEFAULT_MAX_RESULTS = 2000 diff --git a/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx b/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx index 85c53cfb41f..fabb0054d84 100644 --- a/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx +++ b/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx @@ -131,6 +131,7 @@ export function EditorConflictReviewSurface({
reloadContent(contentFile)} />
diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index ca12823e846..388c26daecc 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -185,6 +185,7 @@ export function EditorContent({ return ( reloadContent(activeFile)} /> ) diff --git a/src/renderer/src/components/editor/EditorEditFileSurface.tsx b/src/renderer/src/components/editor/EditorEditFileSurface.tsx index d0b83539ee6..fc6b7bad034 100644 --- a/src/renderer/src/components/editor/EditorEditFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorEditFileSurface.tsx @@ -98,6 +98,7 @@ export function EditorEditFileSurface({ return ( reloadContent(activeFile)} /> ) diff --git a/src/renderer/src/components/editor/EditorFileLoadErrorView.test.tsx b/src/renderer/src/components/editor/EditorFileLoadErrorView.test.tsx new file mode 100644 index 00000000000..0a4dd4a181c --- /dev/null +++ b/src/renderer/src/components/editor/EditorFileLoadErrorView.test.tsx @@ -0,0 +1,43 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { EditorFileLoadErrorView } from './EditorFileLoadErrorView' +import { + EDITOR_TEXT_READ_LIMIT_BYTES, + formatFileTooLargeMessage +} from '../../../../shared/editor-file-read-limit' + +const tooLarge = `Error invoking remote method 'fs:readFile': Error: ${formatFileTooLargeMessage({ + byteLength: 53_477_376, + limitBytes: EDITOR_TEXT_READ_LIMIT_BYTES.local, + scope: 'local' +})}` + +describe('EditorFileLoadErrorView', () => { + it('degrades an oversized file to the explanatory fallback', () => { + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('data-testid="large-file-fallback"') + expect(html).toContain('/repo/generated.json') + // Retry re-runs the same size check, so offering it is a guaranteed dead end. + expect(html).not.toContain('Retry') + }) + + it('keeps the retryable error box for every other failure', () => { + const html = renderToStaticMarkup( + + ) + + expect(html).not.toContain('data-testid="large-file-fallback"') + expect(html).toContain('Retry') + }) +}) diff --git a/src/renderer/src/components/editor/EditorFileLoadErrorView.tsx b/src/renderer/src/components/editor/EditorFileLoadErrorView.tsx index 41964fbd444..6e41287682b 100644 --- a/src/renderer/src/components/editor/EditorFileLoadErrorView.tsx +++ b/src/renderer/src/components/editor/EditorFileLoadErrorView.tsx @@ -1,14 +1,24 @@ import { AlertCircle, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' +import { parseFileTooLargeMessage } from '../../../../shared/editor-file-read-limit' +import { LargeFileFallback } from './LargeFileFallback' export function EditorFileLoadErrorView({ message, + filePath, onRetry }: { message: string + filePath?: string onRetry: () => void }): React.JSX.Element { + // Why: a size refusal is deterministic, so the generic retry box is a dead end. + const tooLarge = parseFileTooLargeMessage(message) + if (tooLarge) { + return + } + return (
diff --git a/src/renderer/src/components/editor/LargeFileFallback.tsx b/src/renderer/src/components/editor/LargeFileFallback.tsx new file mode 100644 index 00000000000..caa31363d07 --- /dev/null +++ b/src/renderer/src/components/editor/LargeFileFallback.tsx @@ -0,0 +1,63 @@ +import { translate } from '@/i18n/i18n' +import type { FileTooLargeDetail } from '../../../../shared/editor-file-read-limit' + +const numberFormatter = new Intl.NumberFormat() + +function formatMegabytes(bytes: number): string { + return `${numberFormatter.format(Math.round((bytes / 1024 / 1024) * 10) / 10)} MB` +} + +function describeLimitOwner(scope: FileTooLargeDetail['scope']): string { + if (scope === 'ssh') { + return translate( + 'auto.components.editor.LargeFileFallback.scopeSsh', + 'Files read over SSH share the connection with your terminals, so they use a smaller budget than local files.' + ) + } + if (scope === 'runtime') { + return translate( + 'auto.components.editor.LargeFileFallback.scopeRuntime', + 'Files read from a remote workspace travel over the workspace connection, so they use a smaller budget than local files.' + ) + } + return translate( + 'auto.components.editor.LargeFileFallback.scopeLocal', + 'Local files use the largest budget Orca offers; beyond it the editor cannot hold the whole file in memory safely.' + ) +} + +export function LargeFileFallback({ + filePath, + detail +}: { + filePath: string + detail: FileTooLargeDetail +}): React.JSX.Element { + return ( +
+
+
+ {translate( + 'auto.components.editor.LargeFileFallback.title', + 'This file is too large to open in the editor.' + )} +
+
{filePath}
+
+
+ {translate('auto.components.editor.LargeFileFallback.size', 'File size')}:{' '} + {formatMegabytes(detail.byteLength)} +
+
+ {translate('auto.components.editor.LargeFileFallback.limit', 'Read limit')}:{' '} + {formatMegabytes(detail.limitBytes)} +
+
+
{describeLimitOwner(detail.scope)}
+
+
+ ) +} diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 27b1c240e82..014bf3e67b4 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -21,6 +21,7 @@ import { useMonacoEditorDecorations } from './use-monaco-editor-decorations' import { useMonacoEditorMount } from './use-monaco-editor-mount' import { snapshotMonacoViewState } from './monaco-view-state-persistence' import { MonacoMarkdownAnnotationOverlay } from './MonacoMarkdownAnnotationOverlay' +import { useMonacoLargeFileNotice } from './use-monaco-large-file-notice' type MonacoEditorProps = { fileId: string @@ -166,6 +167,8 @@ export default function MonacoEditor({ }) }, [editorFontFamily, editorFontSize, editorWordWrap]) + useMonacoLargeFileNotice(mountedEditor, filePath) + const decorations = useMonacoEditorDecorations({ editorRef, mountedEditor, diff --git a/src/renderer/src/components/editor/monaco-large-file-optimizations.test.ts b/src/renderer/src/components/editor/monaco-large-file-optimizations.test.ts new file mode 100644 index 00000000000..ad0a172ce5a --- /dev/null +++ b/src/renderer/src/components/editor/monaco-large-file-optimizations.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import type { editor } from 'monaco-editor' +import { readMonacoLargeFileOptimizations } from './monaco-large-file-optimizations' + +function modelReporting(value: unknown): editor.ITextModel { + return { isTooLargeForTokenization: () => value } as unknown as editor.ITextModel +} + +describe('readMonacoLargeFileOptimizations', () => { + it('reports applied only when the model itself says so', () => { + expect(readMonacoLargeFileOptimizations(modelReporting(true))).toBe('applied') + expect(readMonacoLargeFileOptimizations(modelReporting(false))).toBe('not-applied') + }) + + // The flag is @internal in monaco's public typings, so a version bump can + // remove it. "We could not read it" must never be reported as "off" — that + // would make the notice claim features are intact when they may not be. + it('reports unknown when the flag cannot be read', () => { + expect(readMonacoLargeFileOptimizations(null)).toBe('unknown') + expect(readMonacoLargeFileOptimizations({} as editor.ITextModel)).toBe('unknown') + expect(readMonacoLargeFileOptimizations(modelReporting('yes'))).toBe('unknown') + expect( + readMonacoLargeFileOptimizations({ + isTooLargeForTokenization: () => { + throw new Error('disposed') + } + } as unknown as editor.ITextModel) + ).toBe('unknown') + }) +}) diff --git a/src/renderer/src/components/editor/monaco-large-file-optimizations.ts b/src/renderer/src/components/editor/monaco-large-file-optimizations.ts new file mode 100644 index 00000000000..9c537d07444 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-large-file-optimizations.ts @@ -0,0 +1,39 @@ +import type { editor } from 'monaco-editor' + +/** + * Above ~20MB of text or ~300k lines the editor permanently drops tokenization, + * folding, code lenses, sticky scroll, word highlighting and line wrapping for a + * model — decided once at model construction and never revisited. Orca inherits + * that silently, so the user sees features vanish with no explanation. + * + * The verdict is read from the model instead of recomputed from the byte length, + * because the model's own thresholds are internal and can move between editor + * versions; a recomputed guess would eventually describe a degradation that did + * not happen (or miss one that did). + */ +export type MonacoLargeFileOptimizations = 'applied' | 'not-applied' | 'unknown' + +type TokenizationSizeProbe = { + isTooLargeForTokenization?: () => unknown +} + +export function readMonacoLargeFileOptimizations( + model: editor.ITextModel | null | undefined +): MonacoLargeFileOptimizations { + const probe = (model as TokenizationSizeProbe | null | undefined)?.isTooLargeForTokenization + if (typeof probe !== 'function') { + // The flag is @internal in the public typings; not finding it means we do + // not know, which is never the same as "optimizations are off". + return 'unknown' + } + let verdict: unknown + try { + verdict = probe.call(model) + } catch { + return 'unknown' + } + if (typeof verdict !== 'boolean') { + return 'unknown' + } + return verdict ? 'applied' : 'not-applied' +} diff --git a/src/renderer/src/components/editor/use-monaco-large-file-notice.test.tsx b/src/renderer/src/components/editor/use-monaco-large-file-notice.test.tsx new file mode 100644 index 00000000000..5aff018cd5a --- /dev/null +++ b/src/renderer/src/components/editor/use-monaco-large-file-notice.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { editor } from 'monaco-editor' + +const toastMock = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: toastMock })) + +import { useMonacoLargeFileNotice } from './use-monaco-large-file-notice' + +function editorReporting(tooLarge: unknown): editor.IStandaloneCodeEditor { + return { + getModel: () => (tooLarge === undefined ? {} : { isTooLargeForTokenization: () => tooLarge }) + } as unknown as editor.IStandaloneCodeEditor +} + +describe('useMonacoLargeFileNotice', () => { + beforeEach(() => { + toastMock.mockClear() + }) + + it('explains the degradation the model reports, once per file', () => { + const { rerender } = renderHook( + ({ path }: { path: string }) => + useMonacoLargeFileNotice(editorReporting(true), path), + { initialProps: { path: '/repo/generated.json' } } + ) + rerender({ path: '/repo/generated.json' }) + + expect(toastMock).toHaveBeenCalledTimes(1) + expect(toastMock.mock.calls[0][0]).toContain('Large file') + }) + + it('stays silent when the model reports no degradation', () => { + renderHook(() => useMonacoLargeFileNotice(editorReporting(false), '/repo/small.json')) + expect(toastMock).not.toHaveBeenCalled() + }) + + // Claiming features are off because we could not read the flag would be a + // guess presented as an observation. + it('stays silent when the degradation flag cannot be read', () => { + renderHook(() => useMonacoLargeFileNotice(editorReporting(undefined), '/repo/unknown.json')) + expect(toastMock).not.toHaveBeenCalled() + }) + + it('stays silent before the editor mounts', () => { + renderHook(() => useMonacoLargeFileNotice(null, '/repo/generated.json')) + expect(toastMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/use-monaco-large-file-notice.ts b/src/renderer/src/components/editor/use-monaco-large-file-notice.ts new file mode 100644 index 00000000000..e1b33e11b33 --- /dev/null +++ b/src/renderer/src/components/editor/use-monaco-large-file-notice.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react' +import { toast } from 'sonner' +import type { editor } from 'monaco-editor' +import { translate } from '@/i18n/i18n' +import { readMonacoLargeFileOptimizations } from './monaco-large-file-optimizations' + +const LARGE_FILE_NOTICE_DURATION_MS = 12_000 + +/** + * Tells the user why a large file lost tokenization, folding, wrapping, code + * lenses, sticky scroll and word highlighting. Fires only when the model itself + * reports the degradation — an unreadable flag stays silent rather than guessing. + */ +export function useMonacoLargeFileNotice( + mountedEditor: editor.IStandaloneCodeEditor | null, + filePath: string +): void { + const noticedPathsRef = useRef(new Set()) + + useEffect(() => { + if (!mountedEditor || noticedPathsRef.current.has(filePath)) { + return + } + if (readMonacoLargeFileOptimizations(mountedEditor.getModel()) !== 'applied') { + return + } + noticedPathsRef.current.add(filePath) + toast( + translate( + 'auto.components.editor.MonacoEditor.largeFileOptimizations', + 'Large file: syntax highlighting, folding and word wrap are off' + ), + { + description: translate( + 'auto.components.editor.MonacoEditor.largeFileOptimizationsDescription', + 'The editor disables these on very large files to avoid freezing. Editing and search still work.' + ), + duration: LARGE_FILE_NOTICE_DURATION_MS + } + ) + }, [mountedEditor, filePath]) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 970aca9702a..fb4ce7efd68 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14256,7 +14256,9 @@ "MonacoEditor": { "68cb83f4a7": "Add note on selected text", "fd68ae03b3": "Search in Files", - "largePasteTooLarge": "Paste is too large." + "largePasteTooLarge": "Paste is too large.", + "largeFileOptimizations": "Large file: syntax highlighting, folding and word wrap are off", + "largeFileOptimizationsDescription": "The editor disables these on very large files to avoid freezing. Editing and search still work." }, "MonacoGutterContextMenu": { "7b57b1b468": "Copy Remote URL", @@ -14513,6 +14515,14 @@ "23433fcdea": "combined characters", "7944ed9fb8": "Not counted" }, + "LargeFileFallback": { + "title": "This file is too large to open in the editor.", + "size": "File size", + "limit": "Read limit", + "scopeLocal": "Local files use the largest budget Orca offers; beyond it the editor cannot hold the whole file in memory safely.", + "scopeSsh": "Files read over SSH share the connection with your terminals, so they use a smaller budget than local files.", + "scopeRuntime": "Files read from a remote workspace travel over the workspace connection, so they use a smaller budget than local files." + }, "DiffViewer": { "b5675b0694": "Save", "593f2193f6": "This draft crossed the safe display limit, but it can still be saved." diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index 29d1e26ad96..23e4dddee45 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -22,6 +22,7 @@ import { unwrapRuntimeRpcResult } from './runtime-rpc-client' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { formatFileTooLargeMessage } from '../../../shared/editor-file-read-limit' import { basename, joinPath, normalizeRelativePath } from '@/lib/path' import { isWindowsAbsolutePathLike, @@ -295,7 +296,16 @@ export async function readRuntimeFileContent({ if (result.truncated) { // Why: the runtime file RPC is preview-sized today; treating a truncated // payload as editable content would make saves overwrite the rest of the file. - throw new Error(`Remote file is too large to open in the editor (${result.byteLength} bytes)`) + // Older hosts do not report the budget, so only name a limit we were told. + throw new Error( + typeof result.maxByteLength === 'number' + ? formatFileTooLargeMessage({ + byteLength: result.byteLength, + limitBytes: result.maxByteLength, + scope: 'runtime' + }) + : `Remote file is too large to open in the editor (${result.byteLength} bytes)` + ) } return { content: result.content, isBinary: false } } diff --git a/src/shared/editor-file-read-limit.test.ts b/src/shared/editor-file-read-limit.test.ts new file mode 100644 index 00000000000..162ffd25ca8 --- /dev/null +++ b/src/shared/editor-file-read-limit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + EDITOR_PREVIEWABLE_BINARY_MAX_BYTES, + EDITOR_TEXT_READ_LIMIT_BYTES, + formatFileTooLargeMessage, + parseFileTooLargeMessage +} from './editor-file-read-limit' + +describe('editor file read limits', () => { + // Guards #1367 ("Allow large text files to open in editor") against a silent + // revert: a later consistency pass must not quietly shrink the local budget. + it('keeps the local text budget at 50MB', () => { + expect(EDITOR_TEXT_READ_LIMIT_BYTES.local).toBe(50 * 1024 * 1024) + expect(EDITOR_PREVIEWABLE_BINARY_MAX_BYTES).toBe(50 * 1024 * 1024) + }) + + it('keeps the lower SSH budget the transports actually enforce', () => { + expect(EDITOR_TEXT_READ_LIMIT_BYTES.ssh).toBe(10 * 1024 * 1024) + }) + + it('round-trips the too-large message through an IPC wrapper prefix', () => { + const message = formatFileTooLargeMessage({ + byteLength: 53_477_376, + limitBytes: EDITOR_TEXT_READ_LIMIT_BYTES.local, + scope: 'local' + }) + expect(message).toContain('51.0MB') + expect(message).toContain('50MB') + + const wrapped = `Error invoking remote method 'fs:readFile': Error: ${message}` + expect(parseFileTooLargeMessage(wrapped)).toEqual({ + byteLength: 53_477_376, + limitBytes: 52_428_800, + scope: 'local' + }) + }) + + // The editor's automatic backoff keys off this prefix; a size refusal is + // deterministic, so retrying it just burns the budget. + it('keeps the prefix the retry gate treats as terminal', () => { + for (const scope of ['local', 'ssh', 'runtime'] as const) { + expect( + formatFileTooLargeMessage({ byteLength: 1, limitBytes: 1, scope }).toLowerCase() + ).toContain('file too large') + } + }) + + it('names the transport so the fallback never claims one shared limit', () => { + const ssh = formatFileTooLargeMessage({ + byteLength: 12_000_000, + limitBytes: EDITOR_TEXT_READ_LIMIT_BYTES.ssh, + scope: 'ssh' + }) + expect(parseFileTooLargeMessage(ssh)?.scope).toBe('ssh') + expect(parseFileTooLargeMessage('some unrelated read failure')).toBeNull() + }) +}) diff --git a/src/shared/editor-file-read-limit.ts b/src/shared/editor-file-read-limit.ts new file mode 100644 index 00000000000..63ab9a706ff --- /dev/null +++ b/src/shared/editor-file-read-limit.ts @@ -0,0 +1,64 @@ +/** + * One definition of how many bytes the editor will pull over each transport, + * plus a machine-readable refusal so the renderer can degrade to an explanatory + * view instead of a dead "Unable to load file" box. + * + * The values deliberately differ per transport and are NOT converging: a local + * read is a page-cache copy, an SSH read crosses a bounded RPC transport where + * a large transfer stalls the interactive lanes. Reporting the transport in the + * refusal is what keeps that honest — the fallback names the limit that was + * actually applied rather than claiming one shared number. + */ + +/** Transport that produced the bytes, not the workspace kind. */ +export type EditorFileReadScope = 'local' | 'ssh' | 'runtime' + +export const EDITOR_TEXT_READ_LIMIT_BYTES: Record<'local' | 'ssh', number> = { + local: 50 * 1024 * 1024, + ssh: 10 * 1024 * 1024 +} + +/** Previewable binaries are base64 blobs the editor never parses as text. */ +export const EDITOR_PREVIEWABLE_BINARY_MAX_BYTES = 50 * 1024 * 1024 + +export type FileTooLargeDetail = { + byteLength: number + limitBytes: number + scope: EditorFileReadScope +} + +const SCOPE_SUBJECT: Record = { + local: 'local files', + ssh: 'files on this SSH host', + runtime: 'files on this remote workspace' +} + +// Why: parsed out of a message rather than a typed error because the refusal +// crosses ipcRenderer.invoke and the relay, both of which flatten errors to a +// string and prepend their own wrapper text. +const DETAIL_PATTERN = /\[size=(\d+) limit=(\d+) scope=(local|ssh|runtime)\]/ + +function formatMegabytes(bytes: number): string { + return `${(bytes / 1024 / 1024).toFixed(1)}MB` +} + +export function formatFileTooLargeMessage({ + byteLength, + limitBytes, + scope +}: FileTooLargeDetail): string { + const limitLabel = `${Math.round(limitBytes / 1024 / 1024)}MB` + return `File too large: ${formatMegabytes(byteLength)} exceeds the ${limitLabel} read limit for ${SCOPE_SUBJECT[scope]}. [size=${byteLength} limit=${limitBytes} scope=${scope}]` +} + +export function parseFileTooLargeMessage(message: string): FileTooLargeDetail | null { + const match = DETAIL_PATTERN.exec(message) + if (!match) { + return null + } + return { + byteLength: Number(match[1]), + limitBytes: Number(match[2]), + scope: match[3] as EditorFileReadScope + } +} diff --git a/src/shared/runtime-file-contracts.ts b/src/shared/runtime-file-contracts.ts index 17ffe6d2c3a..5867f101901 100644 --- a/src/shared/runtime-file-contracts.ts +++ b/src/shared/runtime-file-contracts.ts @@ -28,6 +28,8 @@ export type RuntimeFileReadResult = { content: string truncated: boolean byteLength: number + /** Budget that produced `truncated`. Absent on hosts predating this field. */ + maxByteLength?: number } export type RuntimeTerminalPathOpenTarget =