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.
This commit is contained in:
Neil
2026-08-25 06:08:18 -07:00
parent 61c7b51c8c
commit 096ecac4c8
23 changed files with 571 additions and 42 deletions
+23 -1
View File
@@ -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()
})
+25 -16
View File
@@ -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<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
@@ -164,6 +169,11 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.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 }
+4 -2
View File
@@ -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
}
}
+7 -4
View File
@@ -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)
}
@@ -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()
})
})
+22 -12
View File
@@ -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<StreamMetadata> {
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)
+8 -5
View File
@@ -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
@@ -131,6 +131,7 @@ export function EditorConflictReviewSurface({
<div className={className}>
<EditorFileLoadErrorView
message={fileContent.loadError}
filePath={contentFile.filePath}
onRetry={() => reloadContent(contentFile)}
/>
</div>
@@ -185,6 +185,7 @@ export function EditorContent({
return (
<EditorFileLoadErrorView
message={fileContent.loadError}
filePath={activeFile.filePath}
onRetry={() => reloadContent(activeFile)}
/>
)
@@ -98,6 +98,7 @@ export function EditorEditFileSurface({
return (
<EditorFileLoadErrorView
message={fileContent.loadError}
filePath={activeFile.filePath}
onRetry={() => reloadContent(activeFile)}
/>
)
@@ -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(
<EditorFileLoadErrorView
message={tooLarge}
filePath="/repo/generated.json"
onRetry={vi.fn()}
/>
)
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(
<EditorFileLoadErrorView
message="EACCES: permission denied"
filePath="/repo/generated.json"
onRetry={vi.fn()}
/>
)
expect(html).not.toContain('data-testid="large-file-fallback"')
expect(html).toContain('Retry')
})
})
@@ -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 <LargeFileFallback filePath={filePath ?? ''} detail={tooLarge} />
}
return (
<div className="flex h-full items-center justify-center bg-editor-surface p-6 text-sm text-muted-foreground">
<div className="flex max-w-xl items-start gap-3 rounded-md border border-border bg-background p-4">
@@ -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 (
<div
data-testid="large-file-fallback"
className="flex h-full min-h-[120px] items-center justify-center border border-border bg-muted/10 px-4 py-6 text-muted-foreground"
>
<div className="max-w-xl space-y-3 text-center">
<div className="text-sm font-medium text-foreground">
{translate(
'auto.components.editor.LargeFileFallback.title',
'This file is too large to open in the editor.'
)}
</div>
<div className="break-all text-xs">{filePath}</div>
<div className="grid gap-1 text-xs sm:grid-cols-2 sm:text-left">
<div>
{translate('auto.components.editor.LargeFileFallback.size', 'File size')}:{' '}
{formatMegabytes(detail.byteLength)}
</div>
<div>
{translate('auto.components.editor.LargeFileFallback.limit', 'Read limit')}:{' '}
{formatMegabytes(detail.limitBytes)}
</div>
</div>
<div className="text-[11px]">{describeLimitOwner(detail.scope)}</div>
</div>
</div>
)
}
@@ -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,
@@ -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')
})
})
@@ -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'
}
@@ -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()
})
})
@@ -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<string>())
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])
}
+11 -1
View File
@@ -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."
@@ -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 }
}
+57
View File
@@ -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()
})
})
+64
View File
@@ -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<EditorFileReadScope, string> = {
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
}
}
+2
View File
@@ -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 =