Merge branch 'main' into cline

This commit is contained in:
xintaofei
2026-03-28 20:15:21 +08:00
28 changed files with 1272 additions and 545 deletions

View File

@@ -12,6 +12,7 @@ export interface PermissionFileChange {
oldText: string
newText: string
unifiedDiff?: string
startLine?: number
}
export interface PermissionPlanEntry {
@@ -111,7 +112,8 @@ function buildCompactDiffFromTexts(
path: string,
oldText: string,
newText: string,
contextLines: number = 2
contextLines: number = 2,
startLine: number = 1
): string | null {
const oldLines = splitNormalizedLines(oldText)
const newLines = splitNormalizedLines(newText)
@@ -145,7 +147,15 @@ function buildCompactDiffFromTexts(
Math.min(oldLines.length, oldLines.length - suffix + contextLines)
)
const parts: string[] = [`--- ${path}`, `+++ ${path}`]
const oldStart = Math.max(1, startLine + prefix - before.length)
const oldCount = before.length + removed.length + after.length
const newCount = before.length + added.length + after.length
const parts: string[] = [
`--- ${path}`,
`+++ ${path}`,
`@@ -${oldStart},${oldCount} +${oldStart},${newCount} @@`,
]
for (const line of before) parts.push(` ${line}`)
for (const line of removed) parts.push(`-${line}`)
for (const line of added) parts.push(`+${line}`)
@@ -189,7 +199,13 @@ function buildDiffPreviewFromChanges(
typeof change.unifiedDiff === "string" &&
change.unifiedDiff.trim().length > 0
? change.unifiedDiff.trim()
: buildCompactDiffFromTexts(change.path, change.oldText, change.newText)
: buildCompactDiffFromTexts(
change.path,
change.oldText,
change.newText,
2,
change.startLine ?? 1
)
if (!block) continue
for (const line of block.split("\n")) {
@@ -350,11 +366,18 @@ function parseChangeRecord(
pickString(record, ["unifiedDiff", "unified_diff", "diff", "patch"]) ??
undefined
const rawStartLine = record._start_line ?? record.start_line
const startLine =
typeof rawStartLine === "number" && rawStartLine > 0
? rawStartLine
: undefined
return {
path: normalizedPath,
oldText,
newText,
unifiedDiff,
startLine,
}
}
@@ -402,11 +425,13 @@ function extractRawInputFileChanges(
]) ?? ""
if (oldText || newText || changes.length === 0) {
const rawSl = rawInputObj._start_line ?? rawInputObj.start_line
changes.push({
path: directPath,
oldText,
newText,
unifiedDiff: undefined,
startLine: typeof rawSl === "number" && rawSl > 0 ? rawSl : undefined,
})
}
}
@@ -495,7 +520,8 @@ function mergeFileChanges(
const oldText = prev.oldText || change.oldText
const newText = prev.newText || change.newText
const unifiedDiff = prev.unifiedDiff || change.unifiedDiff
merged.set(path, { path, oldText, newText, unifiedDiff })
const startLine = prev.startLine ?? change.startLine
merged.set(path, { path, oldText, newText, unifiedDiff, startLine })
}
return Array.from(merged.values())
}

View File

@@ -1,5 +1,7 @@
import type { MessageTurn } from "./types"
import type { ContentBlock, MessageTurn } from "./types"
import { normalizeToolName } from "./tool-call-normalization"
import { estimateChangedLineStats } from "./line-change-stats"
import { generateUnifiedDiff } from "./unified-diff-generator"
export type FileOperation = "read" | "edit" | "write" | "apply_patch"
@@ -266,32 +268,12 @@ function countDiffLines(text: string): DiffStat {
return { additions, deletions }
}
function createHunkHeader(oldLineCount: number, newLineCount: number): string {
const oldStart = oldLineCount === 0 ? 0 : 1
const newStart = newLineCount === 0 ? 0 : 1
return `@@ -${oldStart},${oldLineCount} +${newStart},${newLineCount} @@`
}
function buildUnifiedDiff(
filePath: string,
oldText: string,
newText: string
): string | null {
if (!oldText && !newText) return null
const oldLines = oldText ? oldText.split("\n") : []
const newLines = newText ? newText.split("\n") : []
const lines: string[] = [
`--- a/${filePath}`,
`+++ b/${filePath}`,
createHunkHeader(oldLines.length, newLines.length),
]
for (const line of oldLines) lines.push(`-${line}`)
for (const line of newLines) lines.push(`+${line}`)
return lines.join("\n")
return generateUnifiedDiff(oldText, newText, filePath)
}
function parseEditChangeValue(value: unknown): EditChangePreview | null {
@@ -645,8 +627,12 @@ function computeLineDiff(
continue
}
additions += countLines(change.newText)
deletions += countLines(change.oldText)
const estimated = estimateChangedLineStats(
change.oldText,
change.newText
)
additions += estimated.additions
deletions += estimated.deletions
}
return { additions, deletions }
@@ -662,10 +648,7 @@ function computeLineDiff(
if (!oldStr && !newStr) return null
return {
additions: countLines(newStr),
deletions: countLines(oldStr),
}
return estimateChangedLineStats(oldStr, newStr)
}
if (op === "write") {
@@ -774,10 +757,12 @@ export function extractSessionFilesGrouped(
block.input_preview,
normalizedPath
)
const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id)
const diffChunk = buildDiffChunk(
normalized,
block.input_preview,
normalizedPath
normalizedPath,
toolOutput
)
currentFiles.push({
@@ -836,10 +821,12 @@ export function buildSessionFileDiff(
)
if (!blockPaths.includes(normalizedTargetPath)) continue
const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id)
const chunk = buildDiffChunk(
normalized,
block.input_preview,
normalizedTargetPath
normalizedTargetPath,
toolOutput
)
if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim())
}
@@ -852,10 +839,30 @@ export function buildSessionFileDiff(
return chunks.join("\n\n")
}
/** Find the tool_result output matching a tool_use_id within the same turn. */
function findToolResultOutput(
blocks: ContentBlock[],
toolUseId: string | null
): string | null {
if (!toolUseId) return null
for (const block of blocks) {
if (
block.type === "tool_result" &&
block.tool_use_id === toolUseId &&
block.output_preview &&
!block.is_error
) {
return block.output_preview
}
}
return null
}
function buildDiffChunk(
op: string,
inputPreview: string | null,
filePath: string
filePath: string,
toolOutput?: string | null
): string | null {
if (!inputPreview) return null
@@ -877,6 +884,11 @@ function buildDiffChunk(
}
}
// Prefer tool output if backend injected a real diff with line numbers
if (toolOutput && /^@@ /m.test(toolOutput)) {
return toolOutput.trim()
}
if (!parsed) return null
const oldStr =
@@ -884,7 +896,15 @@ function buildDiffChunk(
const newStr =
typeof parsed.new_string === "string" ? parsed.new_string : ""
return buildUnifiedDiff(filePath, oldStr, newStr)
const diff = buildUnifiedDiff(filePath, oldStr, newStr)
if (!diff) return null
const startLine =
typeof parsed._start_line === "number" ? parsed._start_line : 0
if (startLine <= 1) return diff
return diff.replace(
/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/gm,
(_, _o, oc, _n, nc) => `@@ -${startLine},${oc} +${startLine},${nc} @@`
)
}
if (op === "write") {

View File

@@ -0,0 +1,180 @@
import { computeLineDiff, type DiffHunk } from "@/components/merge/merge-diff"
/**
* Maximum product of line counts before falling back to naive diff.
* Avoids O(n*m) LCS blowup for very large inputs.
*/
const LCS_PAIR_BUDGET = 200_000
/**
* Generate a unified diff string from old and new text.
*
* Uses LCS-based line diff when within budget, falls back to
* simple "all deletions then all additions" for very large inputs.
*/
export function generateUnifiedDiff(
oldText: string,
newText: string,
filePath?: string,
contextLines: number = 3
): string | null {
if (!oldText && !newText) return null
if (oldText === newText) return null
const oldLines = oldText ? splitLines(oldText) : []
const newLines = newText ? splitLines(newText) : []
const path = filePath ?? "file"
const header = `--- a/${path}\n+++ b/${path}`
// Performance gate: fall back to naive diff for large inputs
if (oldLines.length * newLines.length > LCS_PAIR_BUDGET) {
return buildNaiveDiff(header, oldLines, newLines)
}
const hunks = computeLineDiff(oldLines, newLines)
if (hunks.length === 0) return null
const unifiedHunks = buildUnifiedHunks(oldLines, hunks, contextLines)
return `${header}\n${unifiedHunks}`
}
function splitLines(text: string): string[] {
const lines = text.split("\n")
// Remove trailing empty line from trailing newline
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop()
}
return lines
}
/**
* Naive diff: all deletions first, then all additions, with a single hunk header.
* Used as fallback when inputs are too large for LCS.
*/
function buildNaiveDiff(
header: string,
oldLines: string[],
newLines: string[]
): string {
const oldStart = oldLines.length === 0 ? 0 : 1
const newStart = newLines.length === 0 ? 0 : 1
const hunkHeader = `@@ -${oldStart},${oldLines.length} +${newStart},${newLines.length} @@`
const parts = [header, hunkHeader]
for (const line of oldLines) parts.push(`-${line}`)
for (const line of newLines) parts.push(`+${line}`)
return parts.join("\n")
}
/**
* Convert DiffHunk[] into unified diff text with context lines and hunk headers.
*
* Groups nearby hunks that overlap in their context windows into a single
* unified hunk, producing output similar to `diff -u`.
*/
function buildUnifiedHunks(
oldLines: string[],
hunks: DiffHunk[],
contextLines: number
): string {
// Build "change regions" with context, then merge overlapping ones
const regions = hunks.map((hunk) => ({
// Context-expanded range in old lines
ctxOldStart: Math.max(0, hunk.baseStart - contextLines),
ctxOldEnd: Math.min(
oldLines.length,
hunk.baseStart + hunk.baseCount + contextLines
),
hunk,
}))
// Merge overlapping regions
const merged: {
ctxOldStart: number
ctxOldEnd: number
hunks: DiffHunk[]
}[] = []
for (const region of regions) {
const last = merged[merged.length - 1]
if (last && region.ctxOldStart <= last.ctxOldEnd) {
// Overlapping — extend and add hunk
last.ctxOldEnd = Math.max(last.ctxOldEnd, region.ctxOldEnd)
last.hunks.push(region.hunk)
} else {
merged.push({
ctxOldStart: region.ctxOldStart,
ctxOldEnd: region.ctxOldEnd,
hunks: [region.hunk],
})
}
}
// Render each merged region as a unified hunk
const output: string[] = []
for (const group of merged) {
const lines: string[] = []
let oldCursor = group.ctxOldStart
let newLineCount = 0
const oldLineCount = group.ctxOldEnd - group.ctxOldStart
for (const hunk of group.hunks) {
// Context lines before this change
while (oldCursor < hunk.baseStart) {
lines.push(` ${oldLines[oldCursor]}`)
newLineCount++
oldCursor++
}
// Deleted lines
for (let i = 0; i < hunk.baseCount; i++) {
lines.push(`-${oldLines[hunk.baseStart + i]}`)
oldCursor++
}
// Added lines
for (const newLine of hunk.newLines) {
lines.push(`+${newLine}`)
newLineCount++
}
}
// Trailing context
while (oldCursor < group.ctxOldEnd) {
lines.push(` ${oldLines[oldCursor]}`)
newLineCount++
oldCursor++
}
// Compute hunk header
const oldStart = oldLineCount === 0 ? 0 : group.ctxOldStart + 1
const newStart =
newLineCount === 0
? 0
: group.ctxOldStart +
1 +
computeNewOffset(group.hunks, group.ctxOldStart)
output.push(
`@@ -${oldStart},${oldLineCount} +${newStart},${newLineCount} @@`
)
output.push(...lines)
}
return output.join("\n")
}
/**
* Compute the offset applied to new-line numbering by hunks before a given position.
*/
function computeNewOffset(hunks: DiffHunk[], beforeOldLine: number): number {
let offset = 0
for (const hunk of hunks) {
if (hunk.baseStart >= beforeOldLine) break
offset += hunk.newLines.length - hunk.baseCount
}
return offset
}