From aee2c0a6a4dd637126cfaab9451f923b71ca7327 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:13:46 -0700 Subject: [PATCH] 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. --- src/main/git/status.test.ts | 41 ++++ src/main/git/status.ts | 23 +- src/relay/git-buffer-overflow.ts | 12 ++ src/relay/git-diff-result.ts | 44 ++++ src/relay/git-handler-blob-readers.test.ts | 24 +++ src/relay/git-handler-commit-diff-ops.ts | 3 +- src/relay/git-handler-ops.ts | 29 ++- src/relay/git-handler-utils.ts | 34 +-- src/relay/git-handler.test.ts | 29 +++ src/relay/git-working-file-read.test.ts | 38 ++++ src/relay/git-working-file-read.ts | 23 ++ .../editor/ChangesModeView.test.tsx | 102 +++++++++ .../src/components/editor/ChangesModeView.tsx | 4 +- .../editor/CheckRunDetailsPanel.tsx | 2 - .../components/editor/CombinedDiffViewer.tsx | 5 +- .../src/components/editor/DiffViewer.tsx | 43 ++-- .../src/components/editor/EditorContent.tsx | 12 +- ...diff-viewer-large-diff-save-action.test.ts | 28 +++ .../diff-viewer-large-diff-save-action.ts | 8 +- .../components/editor/diff-viewer-props.ts | 31 +++ .../editor/large-diff-render-limit.ts | 197 +----------------- src/renderer/src/i18n/locales/en.json | 31 ++- src/renderer/src/i18n/locales/es.json | 34 ++- src/renderer/src/i18n/locales/ja.json | 34 ++- src/renderer/src/i18n/locales/ko.json | 34 ++- src/renderer/src/i18n/locales/zh.json | 34 ++- src/shared/large-diff-render-limit.ts | 196 +++++++++++++++++ src/shared/types.ts | 2 + tests/e2e/large-diff-freeze-repro.spec.ts | 10 +- 29 files changed, 802 insertions(+), 305 deletions(-) create mode 100644 src/relay/git-buffer-overflow.ts create mode 100644 src/relay/git-diff-result.ts create mode 100644 src/relay/git-working-file-read.test.ts create mode 100644 src/relay/git-working-file-read.ts create mode 100644 src/renderer/src/components/editor/ChangesModeView.test.tsx create mode 100644 src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts create mode 100644 src/renderer/src/components/editor/diff-viewer-props.ts create mode 100644 src/shared/large-diff-render-limit.ts 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