Merge branch 'main' into cline
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
180
src/lib/unified-diff-generator.ts
Normal file
180
src/lib/unified-diff-generator.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user