Limit large git diff payloads in main process before IPC transfer (#5469)
* Limit large git diff payloads in main process before IPC transfer Move the large diff rendering limit check to a shared module so that the main process can evaluate diff sizes before transferring them. If a diff exceeds the limits, its text content is dropped prior to IPC serialization, and only the limit metadata is sent. This prevents the application from freezing or crashing when loading massive diff files. * Gracefully handle and prune oversized files in diff viewer Prevent UI freezes and out-of-memory errors when viewing or editing extremely large diffs or files. Working-tree files above 10MB and git buffer overflows are treated as binary. Text diffs exceeding safe rendering limits have their contents pruned before IPC transport, and the UI is updated to show fallback states and disable invalid saves. * Document save action check for large diffs and fix test import Explain why saveContentAvailable is required for oversized diffs, as stripped text bodies before IPC prevent complete saves. Also update the large-diff-render-limit import in E2E tests to use the shared path.
This commit is contained in:
parent
e3bf7d8614
commit
aee2c0a6a4
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,18 @@ describe('git blob readers', () => {
|
|||
expect(result.content).toBe('head-content')
|
||||
})
|
||||
|
||||
it('marks OID blobs that overflow maxBuffer as binary', async () => {
|
||||
const gitBuffer = vi
|
||||
.fn<GitBufferExec>()
|
||||
.mockRejectedValue(
|
||||
Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' })
|
||||
)
|
||||
|
||||
const result = await readBlobAtOid(gitBuffer, '/repo', 'HEAD', 'large.log')
|
||||
|
||||
expect(result).toEqual({ content: '', isBinary: true })
|
||||
})
|
||||
|
||||
it('normalizes Windows separators before reading index blobs', async () => {
|
||||
const gitBuffer = vi.fn<GitBufferExec>().mockResolvedValue(Buffer.from('index-content'))
|
||||
|
||||
|
|
@ -22,4 +34,16 @@ describe('git blob readers', () => {
|
|||
expect(gitBuffer).toHaveBeenCalledWith(['show', '--end-of-options', ':src/file.ts'], '/repo')
|
||||
expect(result.content).toBe('index-content')
|
||||
})
|
||||
|
||||
it('marks index blobs that overflow maxBuffer as binary', async () => {
|
||||
const gitBuffer = vi
|
||||
.fn<GitBufferExec>()
|
||||
.mockRejectedValue(
|
||||
Object.assign(new Error('git stdout exceeded maxBuffer.'), { code: 'ENOBUFS' })
|
||||
)
|
||||
|
||||
const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log')
|
||||
|
||||
expect(result).toEqual({ content: '', isBinary: true })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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})$/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { Suspense } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { DiffViewerProps } from './diff-viewer-props'
|
||||
import {
|
||||
MAX_RENDERED_DIFF_COMBINED_CHARACTERS,
|
||||
MAX_RENDERED_DIFF_LINES_PER_SIDE,
|
||||
type LargeDiffRenderLimit
|
||||
} from './large-diff-render-limit'
|
||||
|
||||
const diffViewerMock = vi.hoisted(() => ({
|
||||
latestProps: null as DiffViewerProps | null
|
||||
}))
|
||||
|
||||
vi.mock('./DiffViewer', () => ({
|
||||
default: (props: DiffViewerProps) => {
|
||||
diffViewerMock.latestProps = props
|
||||
return <div data-testid="diff-viewer-probe" />
|
||||
}
|
||||
}))
|
||||
|
||||
import { ChangesModeView } from './ChangesModeView'
|
||||
|
||||
function createOpenFile(): OpenFile {
|
||||
return {
|
||||
id: 'file-1',
|
||||
filePath: '/repo/large.txt',
|
||||
relativePath: 'large.txt',
|
||||
worktreeId: 'repo::/repo',
|
||||
language: 'plaintext',
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
} as OpenFile
|
||||
}
|
||||
|
||||
function createLargeDiffRenderLimit(): LargeDiffRenderLimit {
|
||||
return {
|
||||
limited: true,
|
||||
reason: 'character-count',
|
||||
lineCounts: null,
|
||||
characterCount: MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1,
|
||||
limits: {
|
||||
maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE,
|
||||
maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ChangesModeView', () => {
|
||||
let container: HTMLDivElement | null = null
|
||||
let root: Root | null = null
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
container = null
|
||||
root = null
|
||||
diffViewerMock.latestProps = null
|
||||
})
|
||||
|
||||
it('passes pruned diff limits through and suppresses the identical-content banner', async () => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
const largeDiffRenderLimit = createLargeDiffRenderLimit()
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<Suspense fallback={null}>
|
||||
<ChangesModeView
|
||||
activeFile={createOpenFile()}
|
||||
dc={{
|
||||
kind: 'text',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false,
|
||||
largeDiffRenderLimit
|
||||
}}
|
||||
modifiedContent=""
|
||||
activeConflictEntry={null}
|
||||
resolvedLanguage="plaintext"
|
||||
sideBySide={false}
|
||||
viewStateScopeId="file-1"
|
||||
diffViewStateKey="file-1:changes"
|
||||
onContentChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(diffViewerMock.latestProps).not.toBeNull())
|
||||
expect(diffViewerMock.latestProps?.largeDiffRenderLimit).toBe(largeDiffRenderLimit)
|
||||
expect(container.textContent).not.toContain('No uncommitted changes.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -22,33 +22,7 @@ import { LargeDiffFallback } from './LargeDiffFallback'
|
|||
import { getLargeDiffRenderLimit } from './large-diff-render-limit'
|
||||
import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecycle'
|
||||
import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action'
|
||||
|
||||
type DiffViewerProps = {
|
||||
modelKey: string
|
||||
originalModelKey?: string
|
||||
modifiedModelKey?: string
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
language: string
|
||||
filePath: string
|
||||
relativePath: string
|
||||
sideBySide: boolean
|
||||
editable?: boolean
|
||||
// Why: optional because DiffViewer is also used by GitHubItemDialog for PR
|
||||
// review, where there is no local worktree to attach comments to. When
|
||||
// omitted, the per-line comment decorator is skipped.
|
||||
worktreeId?: string
|
||||
onAddLineComment?: (args: {
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
body: string
|
||||
}) => Promise<boolean>
|
||||
commentableLineNumbers?: readonly number[]
|
||||
addLineCommentLabel?: string
|
||||
addLineCommentPlaceholder?: string
|
||||
onContentChange?: (content: string) => void
|
||||
onSave?: (content: string) => void
|
||||
}
|
||||
import type { DiffViewerProps } from './diff-viewer-props'
|
||||
|
||||
export default function DiffViewer({
|
||||
modelKey,
|
||||
|
|
@ -67,7 +41,9 @@ export default function DiffViewer({
|
|||
addLineCommentLabel,
|
||||
addLineCommentPlaceholder,
|
||||
onContentChange,
|
||||
onSave
|
||||
onSave,
|
||||
largeDiffRenderLimit,
|
||||
largeDiffSaveContentAvailable
|
||||
}: DiffViewerProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
|
||||
|
|
@ -106,8 +82,8 @@ export default function DiffViewer({
|
|||
} | null>(null)
|
||||
|
||||
const renderLimit = useMemo(
|
||||
() => getLargeDiffRenderLimit({ originalContent, modifiedContent }),
|
||||
[originalContent, modifiedContent]
|
||||
() => largeDiffRenderLimit ?? getLargeDiffRenderLimit({ originalContent, modifiedContent }),
|
||||
[largeDiffRenderLimit, originalContent, modifiedContent]
|
||||
)
|
||||
const hasLineCommentAction = Boolean(worktreeId || onAddLineComment)
|
||||
|
||||
|
|
@ -437,7 +413,12 @@ export default function DiffViewer({
|
|||
<LargeDiffFallback
|
||||
filePath={relativePath}
|
||||
renderLimit={renderLimit}
|
||||
action={getDiffViewerLargeDiffSaveAction({ editable, modifiedContent, onSave })}
|
||||
action={getDiffViewerLargeDiffSaveAction({
|
||||
editable,
|
||||
modifiedContent,
|
||||
onSave,
|
||||
saveContentAvailable: largeDiffSaveContentAvailable
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<DiffEditor
|
||||
|
|
|
|||
|
|
@ -869,8 +869,14 @@ export function EditorContent({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
const modifiedDiffContent = editBuffers[activeFile.id] ?? dc.modifiedContent
|
||||
if (isMarkdown && mdViewMode === 'preview') {
|
||||
const modifiedDiffBuffer = editBuffers[activeFile.id]
|
||||
const modifiedDiffContent = modifiedDiffBuffer ?? dc.modifiedContent
|
||||
const largeDiffSaveContentAvailable = !(
|
||||
dc.largeDiffRenderLimit?.limited === true &&
|
||||
modifiedDiffBuffer === undefined &&
|
||||
dc.modifiedContent.length === 0
|
||||
)
|
||||
if (isMarkdown && mdViewMode === 'preview' && dc.largeDiffRenderLimit?.limited !== true) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="border-b border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
|
|
@ -915,6 +921,8 @@ export function EditorContent({
|
|||
modifiedModelKey={modifiedModelKey}
|
||||
originalContent={dc.originalContent}
|
||||
modifiedContent={modifiedDiffContent}
|
||||
largeDiffRenderLimit={dc.largeDiffRenderLimit}
|
||||
largeDiffSaveContentAvailable={largeDiffSaveContentAvailable}
|
||||
language={monacoLanguage}
|
||||
filePath={activeFile.filePath}
|
||||
relativePath={activeFile.relativePath}
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import type { LargeDiffRenderLimit } from './large-diff-render-limit'
|
||||
|
||||
export type DiffViewerProps = {
|
||||
modelKey: string
|
||||
originalModelKey?: string
|
||||
modifiedModelKey?: string
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
language: string
|
||||
filePath: string
|
||||
relativePath: string
|
||||
sideBySide: boolean
|
||||
editable?: boolean
|
||||
// Why: optional because DiffViewer is also used by GitHubItemDialog for PR
|
||||
// review, where there is no local worktree to attach comments to.
|
||||
worktreeId?: string
|
||||
onAddLineComment?: (args: {
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
body: string
|
||||
}) => Promise<boolean>
|
||||
commentableLineNumbers?: readonly number[]
|
||||
addLineCommentLabel?: string
|
||||
addLineCommentPlaceholder?: string
|
||||
onContentChange?: (content: string) => void
|
||||
onSave?: (content: string) => void
|
||||
largeDiffRenderLimit?: LargeDiffRenderLimit
|
||||
// Why: main-process limited diffs intentionally blank text bodies before IPC;
|
||||
// the fallback must not treat that placeholder as a saveable draft.
|
||||
largeDiffSaveContentAvailable?: boolean
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue