Limit large git diff payloads in main process before IPC transfer (#5469)

* Limit large git diff payloads in main process before IPC transfer

Move the large diff rendering limit check to a shared module so that
the main process can evaluate diff sizes before transferring them. If
a diff exceeds the limits, its text content is dropped prior to IPC
serialization, and only the limit metadata is sent. This prevents the
application from freezing or crashing when loading massive diff files.

* Gracefully handle and prune oversized files in diff viewer

Prevent UI freezes and out-of-memory errors when viewing or editing
extremely large diffs or files. Working-tree files above 10MB and git
buffer overflows are treated as binary. Text diffs exceeding safe
rendering limits have their contents pruned before IPC transport, and
the UI is updated to show fallback states and disable invalid saves.

* Document save action check for large diffs and fix test import

Explain why saveContentAvailable is required for oversized diffs, as
stripped text bodies before IPC prevent complete saves. Also update the
large-diff-render-limit import in E2E tests to use the shared path.
This commit is contained in:
Jinjing 2026-06-15 22:13:46 -07:00 committed by GitHub
parent e3bf7d8614
commit aee2c0a6a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 802 additions and 305 deletions

View File

@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: git status/discard/chunking behavior is verified together here to keep the command contract readable in one place. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'path'
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../shared/large-diff-render-limit'
const {
gitExecFileAsyncMock,
@ -356,6 +357,46 @@ describe('getDiff', () => {
expect(result.modifiedContent).toBe('')
})
it('omits over-limit text bodies before returning the diff payload', async () => {
const oversizedText = 'a'.repeat(MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1)
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
statMock.mockResolvedValueOnce({
isFile: () => true,
size: oversizedText.length
})
readFileMock.mockResolvedValue(Buffer.from(oversizedText))
const result = await getDiff('/repo', 'dist/large.log', false)
expect(result.kind).toBe('text')
if (result.kind !== 'text') {
throw new Error('expected text diff result')
}
expect(result.originalContent).toBe('')
expect(result.modifiedContent).toBe('')
expect(result.largeDiffRenderLimit?.limited).toBe(true)
if (result.largeDiffRenderLimit?.limited !== true) {
throw new Error('expected large diff render limit')
}
expect(result.largeDiffRenderLimit.reason).toBe('character-count')
expect(result.largeDiffRenderLimit.characterCount).toBe(
oversizedText.length + 'index-content\n'.length
)
})
it('marks git blobs that overflow maxBuffer as binary instead of pretending they are missing', async () => {
gitExecFileAsyncBufferMock.mockRejectedValueOnce(
Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' })
)
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.txt', false)
expect(result.kind).toBe('binary')
expect(result.originalIsBinary).toBe(true)
expect(result.originalContent).toBe('')
})
it('includes preview metadata for pdf diffs', async () => {
const pdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00])
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pdfBuffer })

View File

@ -44,6 +44,7 @@ import {
} from '../../shared/git-discard-path-safety'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
@ -979,7 +980,10 @@ async function readGitBlobAtIndexPath(
})
return { ...bufferToBlob(stdout, filePath), exists: true }
} catch {
} catch (error) {
if (isMaxBufferOverflowError(error)) {
return { content: '', isBinary: true, exists: true }
}
return { content: '', isBinary: false, exists: false }
}
}
@ -1001,7 +1005,10 @@ async function readGitBlobAtOidPath(
)
return { ...bufferToBlob(stdout, filePath), exists: true }
} catch {
} catch (error) {
if (isMaxBufferOverflowError(error)) {
return { content: '', isBinary: true, exists: true }
}
return { content: '', isBinary: false, exists: false }
}
}
@ -1065,6 +1072,18 @@ function buildDiffResult(
} as GitDiffResult
}
const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent })
if (largeDiffRenderLimit.limited) {
return {
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false,
largeDiffRenderLimit
}
}
return {
kind: 'text',
originalContent,

View File

@ -0,0 +1,12 @@
export function isGitBufferOverflowError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false
}
const maybeError = error as { code?: unknown; message?: unknown }
if (maybeError.code === 'ENOBUFS') {
return true
}
return typeof maybeError.message === 'string' && /\bmaxBuffer\b/i.test(maybeError.message)
}

View File

@ -0,0 +1,44 @@
import * as path from 'path'
import { getLargeDiffRenderLimit } from '../shared/large-diff-render-limit'
import { PREVIEWABLE_MIME } from './git-handler-utils'
export function buildDiffResult(
originalContent: string,
modifiedContent: string,
originalIsBinary: boolean,
modifiedIsBinary: boolean,
filePath?: string
) {
if (originalIsBinary || modifiedIsBinary) {
const ext = filePath ? path.extname(filePath).toLowerCase() : ''
const mimeType = PREVIEWABLE_MIME[ext]
return {
kind: 'binary' as const,
originalContent,
modifiedContent,
originalIsBinary,
modifiedIsBinary,
...(mimeType ? { isImage: true, mimeType } : {})
}
}
const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent })
if (largeDiffRenderLimit.limited) {
return {
kind: 'text' as const,
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false,
largeDiffRenderLimit
}
}
return {
kind: 'text' as const,
originalContent,
modifiedContent,
originalIsBinary: false,
modifiedIsBinary: false
}
}

View File

@ -14,6 +14,18 @@ describe('git blob readers', () => {
expect(result.content).toBe('head-content')
})
it('marks OID blobs that overflow maxBuffer as binary', async () => {
const gitBuffer = vi
.fn<GitBufferExec>()
.mockRejectedValue(
Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' })
)
const result = await readBlobAtOid(gitBuffer, '/repo', 'HEAD', 'large.log')
expect(result).toEqual({ content: '', isBinary: true })
})
it('normalizes Windows separators before reading index blobs', async () => {
const gitBuffer = vi.fn<GitBufferExec>().mockResolvedValue(Buffer.from('index-content'))
@ -22,4 +34,16 @@ describe('git blob readers', () => {
expect(gitBuffer).toHaveBeenCalledWith(['show', '--end-of-options', ':src/file.ts'], '/repo')
expect(result.content).toBe('index-content')
})
it('marks index blobs that overflow maxBuffer as binary', async () => {
const gitBuffer = vi
.fn<GitBufferExec>()
.mockRejectedValue(
Object.assign(new Error('git stdout exceeded maxBuffer.'), { code: 'ENOBUFS' })
)
const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log')
expect(result).toEqual({ content: '', isBinary: true })
})
})

View File

@ -1,5 +1,6 @@
import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops'
import { buildDiffResult, parseBranchDiff } from './git-handler-utils'
import { parseBranchDiff } from './git-handler-utils'
import { buildDiffResult } from './git-diff-result'
import { parseNumstat } from '../shared/git-uncommitted-line-stats'
const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/

View File

@ -6,8 +6,10 @@
* remain decoupled from the GitHandler class.
*/
import * as path from 'path'
import { readFile } from 'fs/promises'
import { bufferToBlob, buildDiffResult, parseBranchDiff } from './git-handler-utils'
import { bufferToBlob, parseBranchDiff } from './git-handler-utils'
import { buildDiffResult } from './git-diff-result'
import { isGitBufferOverflowError } from './git-buffer-overflow'
import { readWorkingDiffFile } from './git-working-file-read'
// ─── Executor types ──────────────────────────────────────────────────
@ -32,7 +34,10 @@ export async function readBlobAtOid(
try {
const buf = await gitBuffer(['show', '--end-of-options', `${oid}:${gitPath}`], cwd)
return bufferToBlob(buf, filePath)
} catch {
} catch (error) {
if (isGitBufferOverflowError(error)) {
return { content: '', isBinary: true }
}
return { content: '', isBinary: false }
}
}
@ -47,7 +52,10 @@ export async function readBlobAtIndex(
try {
const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd)
return bufferToBlob(buf, filePath)
} catch {
} catch (error) {
if (isGitBufferOverflowError(error)) {
return { content: '', isBinary: true }
}
return { content: '', isBinary: false }
}
}
@ -64,17 +72,6 @@ export async function readUnstagedLeft(
return readBlobAtOid(gitBuffer, cwd, 'HEAD', filePath)
}
export async function readWorkingFile(
absPath: string
): Promise<{ content: string; isBinary: boolean }> {
try {
const buffer = await readFile(absPath)
return bufferToBlob(buffer)
} catch {
return { content: '', isBinary: false }
}
}
// ─── Diff ────────────────────────────────────────────────────────────
export async function computeDiff(
@ -105,7 +102,7 @@ export async function computeDiff(
originalContent = left.content
originalIsBinary = left.isBinary
const right = await readWorkingFile(path.join(worktreePath, filePath))
const right = await readWorkingDiffFile(path.join(worktreePath, filePath))
modifiedContent = right.content
modifiedIsBinary = right.isBinary
}

View File

@ -5,8 +5,8 @@
* These functions have no side-effects and depend only on their arguments,
* making them easy to test independently.
*/
import * as path from 'path'
import { existsSync } from 'fs'
import * as path from 'path'
import { isBinaryBuffer } from '../shared/binary-buffer'
import type { GitLineStats } from '../shared/git-uncommitted-line-stats'
@ -253,35 +253,3 @@ export function bufferToBlob(
}
return { content: buffer.toString('utf-8'), isBinary: false }
}
/**
* Build a diff result object from original/modified content.
* Used by both working-tree diffs and branch diffs.
*/
export function buildDiffResult(
originalContent: string,
modifiedContent: string,
originalIsBinary: boolean,
modifiedIsBinary: boolean,
filePath?: string
) {
if (originalIsBinary || modifiedIsBinary) {
const ext = filePath ? path.extname(filePath).toLowerCase() : ''
const mimeType = PREVIEWABLE_MIME[ext]
return {
kind: 'binary' as const,
originalContent,
modifiedContent,
originalIsBinary,
modifiedIsBinary,
...(mimeType ? { isImage: true, mimeType } : {})
}
}
return {
kind: 'text' as const,
originalContent,
modifiedContent,
originalIsBinary: false,
modifiedIsBinary: false
}
}

View File

@ -10,6 +10,7 @@ import * as path from 'path'
import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { execFileSync } from 'child_process'
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../shared/large-diff-render-limit'
import {
createMockDispatcher,
gitInit,
@ -492,6 +493,34 @@ describe('GitHandler', () => {
expect(result.modifiedContent).toBe('staged-content')
})
it('omits over-limit text bodies before returning diff payloads', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'original')
gitCommit(tmpDir, 'initial')
const oversizedText = 'a'.repeat(MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1)
writeFileSync(path.join(tmpDir, 'file.txt'), oversizedText)
const result = (await dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'file.txt',
staged: false
})) as {
kind: string
originalContent: string
modifiedContent: string
largeDiffRenderLimit?: { limited: boolean; reason?: string; characterCount?: number }
}
expect(result.kind).toBe('text')
expect(result.originalContent).toBe('')
expect(result.modifiedContent).toBe('')
expect(result.largeDiffRenderLimit?.limited).toBe(true)
expect(result.largeDiffRenderLimit?.reason).toBe('character-count')
expect(result.largeDiffRenderLimit?.characterCount).toBe(
oversizedText.length + 'original'.length
)
})
it('returns diff for tracked files in valid dot-dot-prefixed directories', async () => {
gitInit(tmpDir)
mkdirSync(path.join(tmpDir, '..fixtures'))

View File

@ -0,0 +1,38 @@
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import * as path from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { readWorkingDiffFile } from './git-working-file-read'
describe('readWorkingDiffFile', () => {
let tmpDir: string | null = null
afterEach(async () => {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true })
}
tmpDir = null
})
it('reads normal text working-tree files', async () => {
tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-'))
const filePath = path.join(tmpDir, 'file.txt')
await writeFile(filePath, 'hello')
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
content: 'hello',
isBinary: false
})
})
it('marks oversized working-tree files as binary before diffing', async () => {
tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-'))
const filePath = path.join(tmpDir, 'large.log')
await writeFile(filePath, Buffer.alloc(10 * 1024 * 1024 + 1, 'a'))
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
content: '',
isBinary: true
})
})
})

View File

@ -0,0 +1,23 @@
import { readFile, stat } from 'fs/promises'
import { bufferToBlob } from './git-handler-utils'
const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024
export async function readWorkingDiffFile(
absPath: string
): Promise<{ content: string; isBinary: boolean }> {
try {
const fileStat = await stat(absPath)
if (!fileStat.isFile()) {
return { content: '', isBinary: false }
}
if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) {
// Why: mirror local git diff reads, which cap blob transfer at 10MB.
return { content: '', isBinary: true }
}
const buffer = await readFile(absPath)
return bufferToBlob(buffer)
} catch {
return { content: '', isBinary: false }
}
}

View File

@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { Suspense } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { DiffViewerProps } from './diff-viewer-props'
import {
MAX_RENDERED_DIFF_COMBINED_CHARACTERS,
MAX_RENDERED_DIFF_LINES_PER_SIDE,
type LargeDiffRenderLimit
} from './large-diff-render-limit'
const diffViewerMock = vi.hoisted(() => ({
latestProps: null as DiffViewerProps | null
}))
vi.mock('./DiffViewer', () => ({
default: (props: DiffViewerProps) => {
diffViewerMock.latestProps = props
return <div data-testid="diff-viewer-probe" />
}
}))
import { ChangesModeView } from './ChangesModeView'
function createOpenFile(): OpenFile {
return {
id: 'file-1',
filePath: '/repo/large.txt',
relativePath: 'large.txt',
worktreeId: 'repo::/repo',
language: 'plaintext',
isDirty: false,
mode: 'edit'
} as OpenFile
}
function createLargeDiffRenderLimit(): LargeDiffRenderLimit {
return {
limited: true,
reason: 'character-count',
lineCounts: null,
characterCount: MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1,
limits: {
maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE,
maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS
}
}
}
describe('ChangesModeView', () => {
let container: HTMLDivElement | null = null
let root: Root | null = null
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
container = null
root = null
diffViewerMock.latestProps = null
})
it('passes pruned diff limits through and suppresses the identical-content banner', async () => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
const largeDiffRenderLimit = createLargeDiffRenderLimit()
await act(async () => {
root?.render(
<Suspense fallback={null}>
<ChangesModeView
activeFile={createOpenFile()}
dc={{
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false,
largeDiffRenderLimit
}}
modifiedContent=""
activeConflictEntry={null}
resolvedLanguage="plaintext"
sideBySide={false}
viewStateScopeId="file-1"
diffViewStateKey="file-1:changes"
onContentChange={vi.fn()}
onSave={vi.fn()}
/>
</Suspense>
)
})
await vi.waitFor(() => expect(diffViewerMock.latestProps).not.toBeNull())
expect(diffViewerMock.latestProps?.largeDiffRenderLimit).toBe(largeDiffRenderLimit)
expect(container.textContent).not.toContain('No uncommitted changes.')
})
})

View File

@ -62,7 +62,8 @@ export function ChangesModeView({
// Why: Monaco renders an empty diff when the two sides match, which reads as
// a broken view. Surface an inline banner so the user knows Changes mode is
// active but there is simply nothing to diff right now.
const isIdentical = dc.originalContent === modifiedContent
const isDiffBodyPruned = dc.largeDiffRenderLimit?.limited === true
const isIdentical = !isDiffBodyPruned && dc.originalContent === modifiedContent
// Why: after a terminal commit/pull/rebase, Changes mode refreshes the
// HEAD-side blob in React state, but Monaco can keep painting the previous
// diff if we reuse the same kept model identities. Rotate only the
@ -88,6 +89,7 @@ export function ChangesModeView({
originalModelKey={originalModelKey}
originalContent={dc.originalContent}
modifiedContent={modifiedContent}
largeDiffRenderLimit={dc.largeDiffRenderLimit}
language={resolvedLanguage}
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}

View File

@ -39,8 +39,6 @@ function getCheckStatusLabel(check: PRCheckDetail): string {
return translate('auto.components.editor.CheckRunDetailsPanel.5a1c8e3d67', 'Neutral')
case 'pending':
return translate('auto.components.editor.CheckRunDetailsPanel.3d9f2b8e14', 'Pending')
default:
return conclusion
}
}

View File

@ -596,10 +596,11 @@ export default function CombinedDiffViewer({
const largeDiffRenderLimit =
!error && result.kind === 'text'
? getLargeDiffRenderLimit({
? (result.largeDiffRenderLimit ??
getLargeDiffRenderLimit({
originalContent: result.originalContent,
modifiedContent: result.modifiedContent
})
}))
: null
loadingIndicesRef.current.delete(index)

View File

@ -22,33 +22,7 @@ import { LargeDiffFallback } from './LargeDiffFallback'
import { getLargeDiffRenderLimit } from './large-diff-render-limit'
import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecycle'
import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action'
type DiffViewerProps = {
modelKey: string
originalModelKey?: string
modifiedModelKey?: string
originalContent: string
modifiedContent: string
language: string
filePath: string
relativePath: string
sideBySide: boolean
editable?: boolean
// Why: optional because DiffViewer is also used by GitHubItemDialog for PR
// review, where there is no local worktree to attach comments to. When
// omitted, the per-line comment decorator is skipped.
worktreeId?: string
onAddLineComment?: (args: {
lineNumber: number
startLine?: number
body: string
}) => Promise<boolean>
commentableLineNumbers?: readonly number[]
addLineCommentLabel?: string
addLineCommentPlaceholder?: string
onContentChange?: (content: string) => void
onSave?: (content: string) => void
}
import type { DiffViewerProps } from './diff-viewer-props'
export default function DiffViewer({
modelKey,
@ -67,7 +41,9 @@ export default function DiffViewer({
addLineCommentLabel,
addLineCommentPlaceholder,
onContentChange,
onSave
onSave,
largeDiffRenderLimit,
largeDiffSaveContentAvailable
}: DiffViewerProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
@ -106,8 +82,8 @@ export default function DiffViewer({
} | null>(null)
const renderLimit = useMemo(
() => getLargeDiffRenderLimit({ originalContent, modifiedContent }),
[originalContent, modifiedContent]
() => largeDiffRenderLimit ?? getLargeDiffRenderLimit({ originalContent, modifiedContent }),
[largeDiffRenderLimit, originalContent, modifiedContent]
)
const hasLineCommentAction = Boolean(worktreeId || onAddLineComment)
@ -437,7 +413,12 @@ export default function DiffViewer({
<LargeDiffFallback
filePath={relativePath}
renderLimit={renderLimit}
action={getDiffViewerLargeDiffSaveAction({ editable, modifiedContent, onSave })}
action={getDiffViewerLargeDiffSaveAction({
editable,
modifiedContent,
onSave,
saveContentAvailable: largeDiffSaveContentAvailable
})}
/>
) : (
<DiffEditor

View File

@ -869,8 +869,14 @@ export function EditorContent({
</div>
)
}
const modifiedDiffContent = editBuffers[activeFile.id] ?? dc.modifiedContent
if (isMarkdown && mdViewMode === 'preview') {
const modifiedDiffBuffer = editBuffers[activeFile.id]
const modifiedDiffContent = modifiedDiffBuffer ?? dc.modifiedContent
const largeDiffSaveContentAvailable = !(
dc.largeDiffRenderLimit?.limited === true &&
modifiedDiffBuffer === undefined &&
dc.modifiedContent.length === 0
)
if (isMarkdown && mdViewMode === 'preview' && dc.largeDiffRenderLimit?.limited !== true) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
@ -915,6 +921,8 @@ export function EditorContent({
modifiedModelKey={modifiedModelKey}
originalContent={dc.originalContent}
modifiedContent={modifiedDiffContent}
largeDiffRenderLimit={dc.largeDiffRenderLimit}
largeDiffSaveContentAvailable={largeDiffSaveContentAvailable}
language={monacoLanguage}
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}

View File

@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest'
import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action'
describe('getDiffViewerLargeDiffSaveAction', () => {
it('does not offer save when the displayed large-diff content was pruned', () => {
const action = getDiffViewerLargeDiffSaveAction({
editable: true,
modifiedContent: '',
onSave: vi.fn(),
saveContentAvailable: false
})
expect(action).toBeUndefined()
})
it('can save an intentionally empty draft when content is available', () => {
const onSave = vi.fn()
const action = getDiffViewerLargeDiffSaveAction({
editable: true,
modifiedContent: '',
onSave
})
action?.onClick()
expect(onSave).toHaveBeenCalledWith('')
})
})

View File

@ -4,16 +4,20 @@ type DiffViewerLargeDiffSaveActionInput = {
editable?: boolean
modifiedContent: string
onSave?: (content: string) => void
saveContentAvailable?: boolean
}
export function getDiffViewerLargeDiffSaveAction({
editable,
modifiedContent,
onSave
onSave,
saveContentAvailable = true
}: DiffViewerLargeDiffSaveActionInput):
| { label: string; description: string; onClick: () => void }
| undefined {
if (!editable || !onSave) {
// Why: oversized diffs can arrive with text bodies stripped before IPC;
// fallback saves only when the modified content is known to be complete.
if (!editable || !onSave || !saveContentAvailable) {
return undefined
}

View File

@ -0,0 +1,31 @@
import type { LargeDiffRenderLimit } from './large-diff-render-limit'
export type DiffViewerProps = {
modelKey: string
originalModelKey?: string
modifiedModelKey?: string
originalContent: string
modifiedContent: string
language: string
filePath: string
relativePath: string
sideBySide: boolean
editable?: boolean
// Why: optional because DiffViewer is also used by GitHubItemDialog for PR
// review, where there is no local worktree to attach comments to.
worktreeId?: string
onAddLineComment?: (args: {
lineNumber: number
startLine?: number
body: string
}) => Promise<boolean>
commentableLineNumbers?: readonly number[]
addLineCommentLabel?: string
addLineCommentPlaceholder?: string
onContentChange?: (content: string) => void
onSave?: (content: string) => void
largeDiffRenderLimit?: LargeDiffRenderLimit
// Why: main-process limited diffs intentionally blank text bodies before IPC;
// the fallback must not treat that placeholder as a saveable draft.
largeDiffSaveContentAvailable?: boolean
}

View File

@ -1,196 +1 @@
export const MAX_RENDERED_DIFF_LINES_PER_SIDE = 120_000
export const MAX_RENDERED_DIFF_COMBINED_CHARACTERS = 6_000_000
export type LargeDiffRenderLimitReason = 'line-count' | 'character-count'
export type DiffLineCounts = {
original: number
modified: number
}
export type DiffLineCountMinimums = {
original: boolean
modified: boolean
}
export type LargeDiffRenderLimit =
| {
limited: false
lineCounts: DiffLineCounts
characterCount: number
}
| {
limited: true
reason: LargeDiffRenderLimitReason
lineCounts: DiffLineCounts | null
lineCountsAreMinimum?: DiffLineCountMinimums
characterCount: number
limits: {
maxLinesPerSide: number
maxCombinedCharacters: number
}
}
export function countLinesEmptyAsZero(content: string): number {
if (content.length === 0) {
return 0
}
let lineCount = 1
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) === 10) {
lineCount += 1
}
}
return lineCount
}
type BoundedLineCount = {
count: number
exceeded: boolean
}
export function countLinesEmptyAsZeroUpToLimit(
content: string,
maxLines: number
): BoundedLineCount {
if (content.length === 0) {
return { count: 0, exceeded: false }
}
let lineCount = 1
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) !== 10) {
continue
}
lineCount += 1
if (lineCount > maxLines) {
return { count: lineCount, exceeded: true }
}
}
return { count: lineCount, exceeded: false }
}
export function countLinesLikeSplit(content: string): number {
let lineCount = 1
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) === 10) {
lineCount += 1
}
}
return lineCount
}
type LargeDiffRenderLimitInput = {
originalContent: string
modifiedContent: string
}
type LargeDiffRenderLimitCountsInput = {
originalLineCount: number
modifiedLineCount: number
originalCharacterCount: number
modifiedCharacterCount: number
}
export function getLargeDiffRenderLimitFromCounts({
originalLineCount,
modifiedLineCount,
originalCharacterCount,
modifiedCharacterCount
}: LargeDiffRenderLimitCountsInput): LargeDiffRenderLimit {
const lineCounts = {
original: originalLineCount,
modified: modifiedLineCount
}
const characterCount = originalCharacterCount + modifiedCharacterCount
const limits = {
maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE,
maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS
}
if (
lineCounts.original > MAX_RENDERED_DIFF_LINES_PER_SIDE ||
lineCounts.modified > MAX_RENDERED_DIFF_LINES_PER_SIDE
) {
return {
limited: true,
reason: 'line-count',
lineCounts,
characterCount,
limits
}
}
if (characterCount > MAX_RENDERED_DIFF_COMBINED_CHARACTERS) {
return {
limited: true,
reason: 'character-count',
lineCounts,
characterCount,
limits
}
}
return {
limited: false,
lineCounts,
characterCount
}
}
export function getLargeDiffRenderLimit({
originalContent,
modifiedContent
}: LargeDiffRenderLimitInput): LargeDiffRenderLimit {
const characterCount = originalContent.length + modifiedContent.length
const limits = {
maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE,
maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS
}
if (characterCount > MAX_RENDERED_DIFF_COMBINED_CHARACTERS) {
return {
limited: true,
reason: 'character-count',
lineCounts: null,
characterCount,
limits
}
}
const originalLineCount = countLinesEmptyAsZeroUpToLimit(
originalContent,
MAX_RENDERED_DIFF_LINES_PER_SIDE
)
const modifiedLineCount = countLinesEmptyAsZeroUpToLimit(
modifiedContent,
MAX_RENDERED_DIFF_LINES_PER_SIDE
)
if (originalLineCount.exceeded || modifiedLineCount.exceeded) {
return {
limited: true,
reason: 'line-count',
lineCounts: {
original: originalLineCount.count,
modified: modifiedLineCount.count
},
lineCountsAreMinimum: {
original: originalLineCount.exceeded,
modified: modifiedLineCount.exceeded
},
characterCount,
limits
}
}
return {
limited: false,
lineCounts: {
original: originalLineCount.count,
modified: modifiedLineCount.count
},
characterCount
}
}
export * from '../../../../shared/large-diff-render-limit'

View File

@ -165,7 +165,9 @@
"editor": {
"dcb521ed29": "This file is in a conflict state, but no working-tree file is available to edit.",
"51f15c37d3": "Cannot open directory: {{value0}}",
"f2e00db373": "File not found: {{value0}}"
"f2e00db373": "File not found: {{value0}}",
"checkRunDetailsUnavailable": "No details are available for this check.",
"checkRunDetailsLoadFailed": "Failed to load check details."
},
"github": {
"f129c42773": "GitHub did not return the new comment.",
@ -9955,7 +9957,8 @@
"8a0898ae4c": "Text diff is unavailable for this file.",
"3c6e71df22": "Text diff is unavailable for this file in branch compare.",
"d07e4b8553": "branch",
"d16e037f40": "rich"
"d16e037f40": "rich",
"6c4f1a8d2e": "Check details are unavailable."
},
"EditorPanelHeader": {
"fb8331694e": "Open Preview to the Side",
@ -10319,6 +10322,30 @@
"DiffViewer": {
"b5675b0694": "Save",
"593f2193f6": "This draft crossed the safe display limit, but it can still be saved."
},
"CheckRunDetailsPanel": {
"8f2d0f5a91": "Passed",
"4c8e1b2d73": "Failed",
"91a4c7e2b0": "Cancelled",
"2f6d8a1c45": "Timed out",
"7b3e9d4f12": "Skipped",
"5a1c8e3d67": "Neutral",
"3d9f2b8e14": "Pending",
"b7f5e2c91a": "Refresh",
"a54ae21c6f": "Status:",
"fd46a70f1a": "Started",
"00e1c1658a": "Completed",
"aa8494ae3c": "check #",
"2dd5ddabc4": "workflow #",
"1f2b980522": "Loading check details…",
"d098e5529a": "Output",
"f2fe8a4e8f": "Annotations",
"cdbfda4dec": "Annotation",
"066fedd446": "Failed jobs",
"49731703ea": "Jobs",
"ee07b33924": "unknown",
"07eccfa397": "No details are available for this check.",
"a916648574": "Open details"
}
},
"diff": {

View File

@ -165,7 +165,9 @@
"editor": {
"dcb521ed29": "Este archivo se encuentra en estado de conflicto, pero no hay ningún archivo de árbol de trabajo disponible para editar.",
"51f15c37d3": "No se puede abrir el directorio: {{value0}}",
"f2e00db373": "Archivo no encontrado: {{value0}}"
"f2e00db373": "Archivo no encontrado: {{value0}}",
"checkRunDetailsUnavailable": "No details are available for this check.",
"checkRunDetailsLoadFailed": "Failed to load check details."
},
"github": {
"f129c42773": "GitHub no devolvió el nuevo comentario.",
@ -9955,7 +9957,8 @@
"8a0898ae4c": "La diferenciación de texto no está disponible para este archivo.",
"3c6e71df22": "La diferencia de texto no está disponible para este archivo en la comparación de ramas.",
"d07e4b8553": "rama",
"d16e037f40": "rico"
"d16e037f40": "rico",
"6c4f1a8d2e": "Check details are unavailable."
},
"EditorPanelHeader": {
"fb8331694e": "Abrir vista previa al lado",
@ -10069,7 +10072,8 @@
"06357eea60": "Tabla de contenido",
"27d0a9c49a": "Tabla de contenido",
"65b036a6c8": "Expandir {{value0}}",
"97ad46f11f": "Contraer {{value0}}"
"97ad46f11f": "Contraer {{value0}}",
"8f4d2c1a9b": "Resize table of contents"
},
"MarkdownTemplatePicker": {
"22cd94426f": "sin título.md",
@ -10318,6 +10322,30 @@
"DiffViewer": {
"b5675b0694": "Guardar",
"593f2193f6": "Este borrador superó el límite seguro de visualización, pero aún se puede guardar."
},
"CheckRunDetailsPanel": {
"8f2d0f5a91": "Passed",
"4c8e1b2d73": "Failed",
"91a4c7e2b0": "Cancelled",
"2f6d8a1c45": "Timed out",
"7b3e9d4f12": "Skipped",
"5a1c8e3d67": "Neutral",
"3d9f2b8e14": "Pending",
"b7f5e2c91a": "Refresh",
"a54ae21c6f": "Status:",
"fd46a70f1a": "Started",
"00e1c1658a": "Completed",
"aa8494ae3c": "check #",
"2dd5ddabc4": "workflow #",
"1f2b980522": "Loading check details…",
"d098e5529a": "Output",
"f2fe8a4e8f": "Annotations",
"cdbfda4dec": "Annotation",
"066fedd446": "Failed jobs",
"49731703ea": "Jobs",
"ee07b33924": "unknown",
"07eccfa397": "No details are available for this check.",
"a916648574": "Open details"
}
},
"diff": {

View File

@ -165,7 +165,9 @@
"editor": {
"dcb521ed29": "このファイルは競合状態にありますが、編集できる作業ツリー ファイルがありません。",
"51f15c37d3": "ディレクトリを開けません: {{value0}}",
"f2e00db373": "ファイルが見つかりません: {{value0}}"
"f2e00db373": "ファイルが見つかりません: {{value0}}",
"checkRunDetailsUnavailable": "No details are available for this check.",
"checkRunDetailsLoadFailed": "Failed to load check details."
},
"github": {
"f129c42773": "GitHub は新規コメントを返しませんでした。",
@ -9955,7 +9957,8 @@
"8a0898ae4c": "このファイルではテキストの差分を使用できません。",
"3c6e71df22": "ブランチ比較では、このファイルのテキスト差分は使用できません。",
"d07e4b8553": "ブランチ",
"d16e037f40": "リッチ"
"d16e037f40": "リッチ",
"6c4f1a8d2e": "Check details are unavailable."
},
"EditorPanelHeader": {
"fb8331694e": "プレビューを横に開く",
@ -10069,7 +10072,8 @@
"06357eea60": "目次",
"27d0a9c49a": "目次",
"65b036a6c8": "{{value0}} を展開します",
"97ad46f11f": "{{value0}} を折りたたむ"
"97ad46f11f": "{{value0}} を折りたたむ",
"8f4d2c1a9b": "Resize table of contents"
},
"MarkdownTemplatePicker": {
"22cd94426f": "無題.md",
@ -10318,6 +10322,30 @@
"DiffViewer": {
"b5675b0694": "保存",
"593f2193f6": "この下書きは安全に表示できる上限を超えましたが、保存はできます。"
},
"CheckRunDetailsPanel": {
"8f2d0f5a91": "Passed",
"4c8e1b2d73": "Failed",
"91a4c7e2b0": "Cancelled",
"2f6d8a1c45": "Timed out",
"7b3e9d4f12": "Skipped",
"5a1c8e3d67": "Neutral",
"3d9f2b8e14": "Pending",
"b7f5e2c91a": "Refresh",
"a54ae21c6f": "Status:",
"fd46a70f1a": "Started",
"00e1c1658a": "Completed",
"aa8494ae3c": "check #",
"2dd5ddabc4": "workflow #",
"1f2b980522": "Loading check details…",
"d098e5529a": "Output",
"f2fe8a4e8f": "Annotations",
"cdbfda4dec": "Annotation",
"066fedd446": "Failed jobs",
"49731703ea": "Jobs",
"ee07b33924": "unknown",
"07eccfa397": "No details are available for this check.",
"a916648574": "Open details"
}
},
"diff": {

View File

@ -165,7 +165,9 @@
"editor": {
"dcb521ed29": "이 파일은 충돌 상태에 있지만 편집할 수 있는 작업 트리 파일이 없습니다.",
"51f15c37d3": "디렉터리를 열 수 없습니다: {{value0}}",
"f2e00db373": "파일을 찾을 수 없습니다: {{value0}}"
"f2e00db373": "파일을 찾을 수 없습니다: {{value0}}",
"checkRunDetailsUnavailable": "No details are available for this check.",
"checkRunDetailsLoadFailed": "Failed to load check details."
},
"github": {
"f129c42773": "GitHub가 새 댓글을 반환하지 않았습니다.",
@ -9955,7 +9957,8 @@
"8a0898ae4c": "이 파일에는 텍스트 비교를 사용할 수 없습니다.",
"3c6e71df22": "브랜치 비교에서는 이 파일에 대해 텍스트 비교를 사용할 수 없습니다.",
"d07e4b8553": "브랜치",
"d16e037f40": "부자"
"d16e037f40": "부자",
"6c4f1a8d2e": "Check details are unavailable."
},
"EditorPanelHeader": {
"fb8331694e": "측면으로 미리보기 열기",
@ -10069,7 +10072,8 @@
"06357eea60": "목차",
"27d0a9c49a": "목차",
"65b036a6c8": "{{value0}} 확장",
"97ad46f11f": "{{value0}} 접기"
"97ad46f11f": "{{value0}} 접기",
"8f4d2c1a9b": "Resize table of contents"
},
"MarkdownTemplatePicker": {
"22cd94426f": "제목없음.md",
@ -10318,6 +10322,30 @@
"DiffViewer": {
"b5675b0694": "저장",
"593f2193f6": "이 초안은 안전한 표시 한도를 초과했지만 계속 저장할 수 있습니다."
},
"CheckRunDetailsPanel": {
"8f2d0f5a91": "Passed",
"4c8e1b2d73": "Failed",
"91a4c7e2b0": "Cancelled",
"2f6d8a1c45": "Timed out",
"7b3e9d4f12": "Skipped",
"5a1c8e3d67": "Neutral",
"3d9f2b8e14": "Pending",
"b7f5e2c91a": "Refresh",
"a54ae21c6f": "Status:",
"fd46a70f1a": "Started",
"00e1c1658a": "Completed",
"aa8494ae3c": "check #",
"2dd5ddabc4": "workflow #",
"1f2b980522": "Loading check details…",
"d098e5529a": "Output",
"f2fe8a4e8f": "Annotations",
"cdbfda4dec": "Annotation",
"066fedd446": "Failed jobs",
"49731703ea": "Jobs",
"ee07b33924": "unknown",
"07eccfa397": "No details are available for this check.",
"a916648574": "Open details"
}
},
"diff": {

View File

@ -165,7 +165,9 @@
"editor": {
"dcb521ed29": "该文件处于冲突状态,但没有可编辑的工作树文件。",
"51f15c37d3": "无法打开目录:{{value0}}",
"f2e00db373": "未找到文件:{{value0}}"
"f2e00db373": "未找到文件:{{value0}}",
"checkRunDetailsUnavailable": "No details are available for this check.",
"checkRunDetailsLoadFailed": "Failed to load check details."
},
"github": {
"f129c42773": "GitHub 没有回复新评论。",
@ -9955,7 +9957,8 @@
"8a0898ae4c": "此文件的文本差异不可用。",
"3c6e71df22": "分支比较中此文件的文本差异不可用。",
"d07e4b8553": "分支",
"d16e037f40": "富有的"
"d16e037f40": "富有的",
"6c4f1a8d2e": "Check details are unavailable."
},
"EditorPanelHeader": {
"fb8331694e": "打开侧面预览",
@ -10069,7 +10072,8 @@
"06357eea60": "目录",
"27d0a9c49a": "目录",
"65b036a6c8": "展开 {{value0}}",
"97ad46f11f": "折叠 {{value0}}"
"97ad46f11f": "折叠 {{value0}}",
"8f4d2c1a9b": "Resize table of contents"
},
"MarkdownTemplatePicker": {
"22cd94426f": "无标题.md",
@ -10318,6 +10322,30 @@
"DiffViewer": {
"b5675b0694": "保存",
"593f2193f6": "此草稿已超过安全显示限制,但仍可保存。"
},
"CheckRunDetailsPanel": {
"8f2d0f5a91": "Passed",
"4c8e1b2d73": "Failed",
"91a4c7e2b0": "Cancelled",
"2f6d8a1c45": "Timed out",
"7b3e9d4f12": "Skipped",
"5a1c8e3d67": "Neutral",
"3d9f2b8e14": "Pending",
"b7f5e2c91a": "Refresh",
"a54ae21c6f": "Status:",
"fd46a70f1a": "Started",
"00e1c1658a": "Completed",
"aa8494ae3c": "check #",
"2dd5ddabc4": "workflow #",
"1f2b980522": "Loading check details…",
"d098e5529a": "Output",
"f2fe8a4e8f": "Annotations",
"cdbfda4dec": "Annotation",
"066fedd446": "Failed jobs",
"49731703ea": "Jobs",
"ee07b33924": "unknown",
"07eccfa397": "No details are available for this check.",
"a916648574": "Open details"
}
},
"diff": {

View File

@ -0,0 +1,196 @@
export const MAX_RENDERED_DIFF_LINES_PER_SIDE = 120_000
export const MAX_RENDERED_DIFF_COMBINED_CHARACTERS = 6_000_000
export type LargeDiffRenderLimitReason = 'line-count' | 'character-count'
export type DiffLineCounts = {
original: number
modified: number
}
export type DiffLineCountMinimums = {
original: boolean
modified: boolean
}
export type LargeDiffRenderLimit =
| {
limited: false
lineCounts: DiffLineCounts
characterCount: number
}
| {
limited: true
reason: LargeDiffRenderLimitReason
lineCounts: DiffLineCounts | null
lineCountsAreMinimum?: DiffLineCountMinimums
characterCount: number
limits: {
maxLinesPerSide: number
maxCombinedCharacters: number
}
}
export function countLinesEmptyAsZero(content: string): number {
if (content.length === 0) {
return 0
}
let lineCount = 1
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) === 10) {
lineCount += 1
}
}
return lineCount
}
type BoundedLineCount = {
count: number
exceeded: boolean
}
export function countLinesEmptyAsZeroUpToLimit(
content: string,
maxLines: number
): BoundedLineCount {
if (content.length === 0) {
return { count: 0, exceeded: false }
}
let lineCount = 1
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) !== 10) {
continue
}
lineCount += 1
if (lineCount > maxLines) {
return { count: lineCount, exceeded: true }
}
}
return { count: lineCount, exceeded: false }
}
export function countLinesLikeSplit(content: string): number {
let lineCount = 1
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) === 10) {
lineCount += 1
}
}
return lineCount
}
type LargeDiffRenderLimitInput = {
originalContent: string
modifiedContent: string
}
type LargeDiffRenderLimitCountsInput = {
originalLineCount: number
modifiedLineCount: number
originalCharacterCount: number
modifiedCharacterCount: number
}
export function getLargeDiffRenderLimitFromCounts({
originalLineCount,
modifiedLineCount,
originalCharacterCount,
modifiedCharacterCount
}: LargeDiffRenderLimitCountsInput): LargeDiffRenderLimit {
const lineCounts = {
original: originalLineCount,
modified: modifiedLineCount
}
const characterCount = originalCharacterCount + modifiedCharacterCount
const limits = {
maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE,
maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS
}
if (
lineCounts.original > MAX_RENDERED_DIFF_LINES_PER_SIDE ||
lineCounts.modified > MAX_RENDERED_DIFF_LINES_PER_SIDE
) {
return {
limited: true,
reason: 'line-count',
lineCounts,
characterCount,
limits
}
}
if (characterCount > MAX_RENDERED_DIFF_COMBINED_CHARACTERS) {
return {
limited: true,
reason: 'character-count',
lineCounts,
characterCount,
limits
}
}
return {
limited: false,
lineCounts,
characterCount
}
}
export function getLargeDiffRenderLimit({
originalContent,
modifiedContent
}: LargeDiffRenderLimitInput): LargeDiffRenderLimit {
const characterCount = originalContent.length + modifiedContent.length
const limits = {
maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE,
maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS
}
if (characterCount > MAX_RENDERED_DIFF_COMBINED_CHARACTERS) {
return {
limited: true,
reason: 'character-count',
lineCounts: null,
characterCount,
limits
}
}
const originalLineCount = countLinesEmptyAsZeroUpToLimit(
originalContent,
MAX_RENDERED_DIFF_LINES_PER_SIDE
)
const modifiedLineCount = countLinesEmptyAsZeroUpToLimit(
modifiedContent,
MAX_RENDERED_DIFF_LINES_PER_SIDE
)
if (originalLineCount.exceeded || modifiedLineCount.exceeded) {
return {
limited: true,
reason: 'line-count',
lineCounts: {
original: originalLineCount.count,
modified: modifiedLineCount.count
},
lineCountsAreMinimum: {
original: originalLineCount.exceeded,
modified: modifiedLineCount.exceeded
},
characterCount,
limits
}
}
return {
limited: false,
lineCounts: {
original: originalLineCount.count,
modified: modifiedLineCount.count
},
characterCount
}
}

View File

@ -11,6 +11,7 @@ import type {
} from './agent-status-types'
import type { VoiceSettings } from './speech-types'
import type { WorkspaceCleanupUIState } from './workspace-cleanup'
import type { LargeDiffRenderLimit } from './large-diff-render-limit'
import type { GitLabProjectSettings } from './gitlab-types'
import type { TaskProvider } from './task-providers'
import type { FeatureTipId } from './feature-tips'
@ -3320,6 +3321,7 @@ export type GitDiffTextResult = {
modifiedContent: string
originalIsBinary: false
modifiedIsBinary: false
largeDiffRenderLimit?: LargeDiffRenderLimit
}
export type GitDiffBinaryResult = {

View File

@ -6,7 +6,7 @@ import { randomUUID } from 'crypto'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { MAX_RENDERED_DIFF_LINES_PER_SIDE } from '../../src/renderer/src/components/editor/large-diff-render-limit'
import { getLargeDiffRenderLimit } from '../../src/shared/large-diff-render-limit'
type IsolatedLargeDiffRepo = {
repoPath: string
@ -112,11 +112,15 @@ test.describe('Large diff freeze repro', () => {
`Invalid ORCA_LARGE_DIFF_REPRO_LINES: ${process.env.ORCA_LARGE_DIFF_REPRO_LINES}`
)
}
const expectFallback = lineCount > MAX_RENDERED_DIFF_LINES_PER_SIDE
const modifiedContent = buildLargeTypeScriptFile(lineCount)
const expectFallback = getLargeDiffRenderLimit({
originalContent: 'export const seed = 1\n',
modifiedContent
}).limited
try {
const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath)
writeFileSync(fixture.absolutePath, buildLargeTypeScriptFile(lineCount))
writeFileSync(fixture.absolutePath, modifiedContent)
const measurement = await orcaPage.evaluate(
async ({ wId, absolutePath, relativePath, expectFallback }) => {
const store = window.__store