mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user