Skip to content

Commit cebd646

Browse files
committed
feat(webview-ui): normalize diffs to unified format and enhance DiffView rendering
Add diffUtils with unified diff normalization: - extractUnifiedDiff, convertSearchReplaceToUnifiedDiff, convertNewFileToUnifiedDiff BatchDiffApproval: - normalize tool output to unified diff via extractUnifiedDiff - compute unified diff stats - remove CDATA handling and legacy conversion paths DiffView: - add compact gap rows between hunks - dedicated +/- column - improved gutter/background and layout tweaks ChatRow: - integrate unified diff normalization pipeline
1 parent e3f09e3 commit cebd646

File tree

4 files changed

+290
-138
lines changed

4 files changed

+290
-138
lines changed

webview-ui/src/components/chat/BatchDiffApproval.tsx

Lines changed: 19 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { memo, useState } from "react"
2-
import { structuredPatch } from "diff"
32
import CodeAccordian from "../common/CodeAccordian"
3+
import { extractUnifiedDiff } from "../../utils/diffUtils"
44

55
interface FileDiff {
66
path: string
@@ -18,63 +18,27 @@ interface BatchDiffApprovalProps {
1818
ts: number
1919
}
2020

21-
/**
22-
* Converts Roo's SEARCH/REPLACE format to unified diff format for better readability
23-
*/
24-
function convertSearchReplaceToUnifiedDiff(content: string, filePath?: string): string {
25-
const blockRegex =
26-
/<<<<<<?\s*SEARCH[\s\S]*?(?:^:start_line:.*\n)?(?:^:end_line:.*\n)?(?:^-------\s*\n)?([\s\S]*?)^(?:=======\s*\n)([\s\S]*?)^(?:>>>>>>> REPLACE)/gim
27-
28-
let hasBlocks = false
29-
let combinedDiff = ""
30-
const fileName = filePath || "file"
31-
32-
let match: RegExpExecArray | null
33-
while ((match = blockRegex.exec(content)) !== null) {
34-
hasBlocks = true
35-
const searchContent = (match[1] ?? "").replace(/\n$/, "") // Remove trailing newline
36-
const replaceContent = (match[2] ?? "").replace(/\n$/, "")
37-
38-
// Use the diff library to create a proper unified diff
39-
const patch = structuredPatch(fileName, fileName, searchContent, replaceContent, "", "", { context: 3 })
40-
41-
// Convert to unified diff format
42-
if (patch.hunks.length > 0) {
43-
for (const hunk of patch.hunks) {
44-
combinedDiff += `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`
45-
combinedDiff += hunk.lines.join("\n") + "\n"
46-
}
47-
}
48-
}
49-
50-
return hasBlocks ? combinedDiff : content
51-
}
52-
53-
function computeDiffStats(diff?: string): { added: number; removed: number } | null {
21+
/** Compute +/− from a unified diff (ignores headers/hunk lines) */
22+
function computeUnifiedStats(diff?: string): { added: number; removed: number } | null {
5423
if (!diff) return null
55-
5624
let added = 0
5725
let removed = 0
58-
let sawPlusMinus = false
59-
26+
let saw = false
6027
for (const line of diff.split("\n")) {
6128
if (line.startsWith("+++ ") || line.startsWith("--- ") || line.startsWith("@@")) continue
6229
if (line.startsWith("+")) {
6330
added++
64-
sawPlusMinus = true
31+
saw = true
6532
} else if (line.startsWith("-")) {
6633
removed++
67-
sawPlusMinus = true
34+
saw = true
6835
}
6936
}
70-
71-
if (sawPlusMinus && (added > 0 || removed > 0)) {
72-
return { added, removed }
73-
}
74-
75-
return null
37+
return saw && (added > 0 || removed > 0) ? { added, removed } : null
7638
}
7739

40+
/* keep placeholder (legacy) – replaced by computeUnifiedStats after normalization */
41+
7842
export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProps) => {
7943
const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>({})
8044

@@ -93,25 +57,21 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp
9357
<div className="pt-[5px]">
9458
<div className="flex flex-col gap-0 border border-border rounded-md p-1">
9559
{files.map((file) => {
96-
// Combine all diffs into a single diff string for this file
97-
const rawCombinedDiff = file.diffs?.map((diff) => diff.content).join("\n\n") || file.content
98-
99-
// Remove CDATA markers
100-
const withoutCData = rawCombinedDiff.replace(/<!\[CDATA\[/g, "").replace(/\]\]>/g, "")
101-
102-
// Convert SEARCH/REPLACE to unified diff if needed
103-
const cleanDiff = /<<<<<<<?\s*SEARCH/i.test(withoutCData)
104-
? convertSearchReplaceToUnifiedDiff(withoutCData, file.path)
105-
: withoutCData
106-
107-
// Compute stats for display
108-
const stats = computeDiffStats(cleanDiff)
60+
// Normalize to unified diff and compute stats
61+
const rawCombined = file.diffs?.map((d) => d.content).join("\n\n") || file.content
62+
const unified = extractUnifiedDiff({
63+
toolName: "appliedDiff",
64+
path: file.path,
65+
diff: rawCombined,
66+
content: undefined,
67+
})
68+
const stats = computeUnifiedStats(unified)
10969

11070
return (
11171
<div key={`${file.path}-${ts}`}>
11272
<CodeAccordian
11373
path={file.path}
114-
code={cleanDiff}
74+
code={unified}
11575
language="diff"
11676
isExpanded={expandedFiles[file.path] || false}
11777
onToggleExpand={() => handleToggleExpand(file.path)}

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 18 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useSize } from "react-use"
33
import { useTranslation, Trans } from "react-i18next"
44
import deepEqual from "fast-deep-equal"
55
import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react"
6-
import { structuredPatch } from "diff"
76

87
import type { ClineMessage, FollowUpData, SuggestionItem } from "@roo-code/types"
98
import { Mode } from "@roo/modes"
@@ -25,6 +24,7 @@ import { ReasoningBlock } from "./ReasoningBlock"
2524
import Thumbnails from "../common/Thumbnails"
2625
import ImageBlock from "../common/ImageBlock"
2726
import ErrorRow from "./ErrorRow"
27+
import { extractUnifiedDiff } from "../../utils/diffUtils"
2828

2929
import McpResourceRow from "../mcp/McpResourceRow"
3030

@@ -194,38 +194,6 @@ function convertNewFileToUnifiedDiff(content: string, filePath?: string): string
194194
return diff
195195
}
196196

197-
/**
198-
* Converts Roo's SEARCH/REPLACE format to unified diff format for better readability
199-
*/
200-
function convertSearchReplaceToUnifiedDiff(content: string, filePath?: string): string {
201-
const blockRegex =
202-
/<<<<<<?\s*SEARCH[\s\S]*?(?:^:start_line:.*\n)?(?:^:end_line:.*\n)?(?:^-------\s*\n)?([\s\S]*?)^(?:=======\s*\n)([\s\S]*?)^(?:>>>>>>> REPLACE)/gim
203-
204-
let hasBlocks = false
205-
let combinedDiff = ""
206-
const fileName = filePath || "file"
207-
208-
let match: RegExpExecArray | null
209-
while ((match = blockRegex.exec(content)) !== null) {
210-
hasBlocks = true
211-
const searchContent = (match[1] ?? "").replace(/\n$/, "") // Remove trailing newline
212-
const replaceContent = (match[2] ?? "").replace(/\n$/, "")
213-
214-
// Use the diff library to create a proper unified diff
215-
const patch = structuredPatch(fileName, fileName, searchContent, replaceContent, "", "", { context: 3 })
216-
217-
// Convert to unified diff format
218-
if (patch.hunks.length > 0) {
219-
for (const hunk of patch.hunks) {
220-
combinedDiff += `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`
221-
combinedDiff += hunk.lines.join("\n") + "\n"
222-
}
223-
}
224-
}
225-
226-
return hasBlocks ? combinedDiff : content
227-
}
228-
229197
export const ChatRowContent = ({
230198
message,
231199
lastModifiedMessage,
@@ -448,47 +416,31 @@ export const ChatRowContent = ({
448416
// Inline diff stats for edit/apply_diff/insert/search-replace/newFile asks
449417
const diffTextForStats = useMemo(() => {
450418
if (!tool) return ""
451-
let content = ""
452-
switch (tool.tool) {
453-
case "editedExistingFile":
454-
case "appliedDiff":
455-
content = (tool.content ?? tool.diff) || ""
456-
break
457-
case "insertContent":
458-
case "searchAndReplace":
459-
content = tool.diff || ""
460-
break
461-
case "newFileCreated":
462-
// For new files, convert to unified diff format
463-
const newFileContent = tool.content || ""
464-
content = convertNewFileToUnifiedDiff(newFileContent, tool.path)
465-
break
466-
default:
467-
return ""
468-
}
469-
// Strip CDATA markers for proper parsing
470-
return content.replace(/<!\[CDATA\[/g, "").replace(/\]\]>/g, "")
419+
// Normalize to unified diff using frontend-only capture/surmise helper
420+
return (
421+
extractUnifiedDiff({
422+
toolName: tool.tool as string,
423+
path: tool.path,
424+
diff: (tool as any).diff,
425+
content: (tool as any).content,
426+
}) || ""
427+
)
471428
}, [tool])
472429

473430
const diffStatsForInline = useMemo(() => {
474431
return computeDiffStats(diffTextForStats)
475432
}, [diffTextForStats])
476433

477-
// Clean diff content for display (remove CDATA markers and convert to unified diff)
434+
// Clean diff content for display (normalize to unified diff)
478435
const cleanDiffContent = useMemo(() => {
479436
if (!tool) return undefined
480-
const raw = (tool as any).content ?? (tool as any).diff
481-
if (!raw) return undefined
482-
483-
// Remove CDATA markers
484-
const withoutCData = raw.replace(/<!\[CDATA\[/g, "").replace(/\]\]>/g, "")
485-
486-
// Check if it's SEARCH/REPLACE format and convert to unified diff
487-
if (/<<<<<<<?\s*SEARCH/i.test(withoutCData)) {
488-
return convertSearchReplaceToUnifiedDiff(withoutCData, tool.path)
489-
}
490-
491-
return withoutCData
437+
const unified = extractUnifiedDiff({
438+
toolName: tool.tool as string,
439+
path: tool.path,
440+
diff: (tool as any).diff,
441+
content: (tool as any).content,
442+
})
443+
return unified || undefined
492444
}, [tool])
493445

494446
const followUpData = useMemo(() => {

0 commit comments

Comments
 (0)