diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index d17e00ddb..d97771e09 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -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 }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 6fe58ea54..8582c1d99 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -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, diff --git a/src/relay/git-buffer-overflow.ts b/src/relay/git-buffer-overflow.ts new file mode 100644 index 000000000..3e86c435d --- /dev/null +++ b/src/relay/git-buffer-overflow.ts @@ -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) +} diff --git a/src/relay/git-diff-result.ts b/src/relay/git-diff-result.ts new file mode 100644 index 000000000..e25c08ef3 --- /dev/null +++ b/src/relay/git-diff-result.ts @@ -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 + } +} diff --git a/src/relay/git-handler-blob-readers.test.ts b/src/relay/git-handler-blob-readers.test.ts index 8eea74a68..3a32eccae 100644 --- a/src/relay/git-handler-blob-readers.test.ts +++ b/src/relay/git-handler-blob-readers.test.ts @@ -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() + .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().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() + .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 }) + }) }) diff --git a/src/relay/git-handler-commit-diff-ops.ts b/src/relay/git-handler-commit-diff-ops.ts index c24eee3ba..780e40d37 100644 --- a/src/relay/git-handler-commit-diff-ops.ts +++ b/src/relay/git-handler-commit-diff-ops.ts @@ -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})$/ diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 504130f81..6241e7192 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -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 } diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index e51e99a12..140dd4d57 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -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 - } -} diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 6141ddfcb..4c6901d1d 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -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')) diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts new file mode 100644 index 000000000..2120080cc --- /dev/null +++ b/src/relay/git-working-file-read.test.ts @@ -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 + }) + }) +}) diff --git a/src/relay/git-working-file-read.ts b/src/relay/git-working-file-read.ts new file mode 100644 index 000000000..7c4536fb7 --- /dev/null +++ b/src/relay/git-working-file-read.ts @@ -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 } + } +} diff --git a/src/renderer/src/components/editor/ChangesModeView.test.tsx b/src/renderer/src/components/editor/ChangesModeView.test.tsx new file mode 100644 index 000000000..d8b41e4b3 --- /dev/null +++ b/src/renderer/src/components/editor/ChangesModeView.test.tsx @@ -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
+ } +})) + +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( + + + + ) + }) + + await vi.waitFor(() => expect(diffViewerMock.latestProps).not.toBeNull()) + expect(diffViewerMock.latestProps?.largeDiffRenderLimit).toBe(largeDiffRenderLimit) + expect(container.textContent).not.toContain('No uncommitted changes.') + }) +}) diff --git a/src/renderer/src/components/editor/ChangesModeView.tsx b/src/renderer/src/components/editor/ChangesModeView.tsx index 6facf1833..aa472de07 100644 --- a/src/renderer/src/components/editor/ChangesModeView.tsx +++ b/src/renderer/src/components/editor/ChangesModeView.tsx @@ -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} diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx index ab6d2211d..db8dbcade 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx @@ -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 } } diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 69bc1e376..ebd4f46b6 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -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) diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index c7b78a2cf..21d03c28e 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -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 - 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({ ) : ( ) } - 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 (
@@ -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} diff --git a/src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts b/src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts new file mode 100644 index 000000000..5a99890aa --- /dev/null +++ b/src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts @@ -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('') + }) +}) diff --git a/src/renderer/src/components/editor/diff-viewer-large-diff-save-action.ts b/src/renderer/src/components/editor/diff-viewer-large-diff-save-action.ts index 3f5c1502a..da935e6e7 100644 --- a/src/renderer/src/components/editor/diff-viewer-large-diff-save-action.ts +++ b/src/renderer/src/components/editor/diff-viewer-large-diff-save-action.ts @@ -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 } diff --git a/src/renderer/src/components/editor/diff-viewer-props.ts b/src/renderer/src/components/editor/diff-viewer-props.ts new file mode 100644 index 000000000..12be38d99 --- /dev/null +++ b/src/renderer/src/components/editor/diff-viewer-props.ts @@ -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 + 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 +} diff --git a/src/renderer/src/components/editor/large-diff-render-limit.ts b/src/renderer/src/components/editor/large-diff-render-limit.ts index 211a8f07b..915b062c2 100644 --- a/src/renderer/src/components/editor/large-diff-render-limit.ts +++ b/src/renderer/src/components/editor/large-diff-render-limit.ts @@ -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' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a3f29ca93..0ab33c134 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index de005271a..d76b7d7a9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7983da1a3..34d10683a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 607d64392..95e69335e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b0af89bf1..cd9911948 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/shared/large-diff-render-limit.ts b/src/shared/large-diff-render-limit.ts new file mode 100644 index 000000000..211a8f07b --- /dev/null +++ b/src/shared/large-diff-render-limit.ts @@ -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 + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 8f07baea3..8c16cdae1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 = { diff --git a/tests/e2e/large-diff-freeze-repro.spec.ts b/tests/e2e/large-diff-freeze-repro.spec.ts index afa5351e4..2696e45e7 100644 --- a/tests/e2e/large-diff-freeze-repro.spec.ts +++ b/tests/e2e/large-diff-freeze-repro.spec.ts @@ -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