Skip to content

Commit 32b2b2f

Browse files
committed
feat(chat): improve diff appearance in main chat view
- Add DiffView with line numbers, +/- column, wrapping - Show inline +/− counts in headers - Convert SEARCH/REPLACE to unified diffs - Remove Undo mechanism (UI/backend) - Update tests and deps (diff, @types/diff)
1 parent 8187a8e commit 32b2b2f

File tree

8 files changed

+625
-17
lines changed

8 files changed

+625
-17
lines changed

pnpm-lock.yaml

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"cmdk": "^1.0.0",
4242
"date-fns": "^4.1.0",
4343
"debounce": "^2.1.1",
44+
"diff": "^5.2.0",
4445
"fast-deep-equal": "^3.1.3",
4546
"fzf": "^0.5.2",
4647
"hast-util-to-jsx-runtime": "^2.3.6",
@@ -87,6 +88,7 @@
8788
"@testing-library/jest-dom": "^6.6.3",
8889
"@testing-library/react": "^16.2.0",
8990
"@testing-library/user-event": "^14.6.1",
91+
"@types/diff": "^5.2.1",
9092
"@types/jest": "^29.0.0",
9193
"@types/katex": "^0.16.7",
9294
"@types/node": "20.x",

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

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { memo, useState } from "react"
2+
import { structuredPatch } from "diff"
23
import CodeAccordian from "../common/CodeAccordian"
34

45
interface FileDiff {
@@ -17,6 +18,63 @@ interface BatchDiffApprovalProps {
1718
ts: number
1819
}
1920

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 {
54+
if (!diff) return null
55+
56+
let added = 0
57+
let removed = 0
58+
let sawPlusMinus = false
59+
60+
for (const line of diff.split("\n")) {
61+
if (line.startsWith("+++ ") || line.startsWith("--- ") || line.startsWith("@@")) continue
62+
if (line.startsWith("+")) {
63+
added++
64+
sawPlusMinus = true
65+
} else if (line.startsWith("-")) {
66+
removed++
67+
sawPlusMinus = true
68+
}
69+
}
70+
71+
if (sawPlusMinus && (added > 0 || removed > 0)) {
72+
return { added, removed }
73+
}
74+
75+
return null
76+
}
77+
2078
export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProps) => {
2179
const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>({})
2280

@@ -36,16 +94,28 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp
3694
<div className="flex flex-col gap-0 border border-border rounded-md p-1">
3795
{files.map((file) => {
3896
// Combine all diffs into a single diff string for this file
39-
const combinedDiff = file.diffs?.map((diff) => diff.content).join("\n\n") || file.content
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)
40109

41110
return (
42111
<div key={`${file.path}-${ts}`}>
43112
<CodeAccordian
44113
path={file.path}
45-
code={combinedDiff}
114+
code={cleanDiff}
46115
language="diff"
47116
isExpanded={expandedFiles[file.path] || false}
48117
onToggleExpand={() => handleToggleExpand(file.path)}
118+
diffStats={stats ?? undefined}
49119
/>
50120
</div>
51121
)

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

Lines changed: 173 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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"
67

78
import type { ClineMessage, FollowUpData, SuggestionItem } from "@roo-code/types"
89
import { Mode } from "@roo/modes"
@@ -15,7 +16,6 @@ import { useExtensionState } from "@src/context/ExtensionStateContext"
1516
import { findMatchingResourceOrTemplate } from "@src/utils/mcp"
1617
import { vscode } from "@src/utils/vscode"
1718
import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric"
18-
import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"
1919

2020
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
2121
import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock"
@@ -115,6 +115,116 @@ const ChatRow = memo(
115115

116116
export default ChatRow
117117

118+
function computeDiffStats(diff?: string): { added: number; removed: number } | null {
119+
if (!diff) return null
120+
121+
// Strategy 1: Unified diff (+/- lines)
122+
let added = 0
123+
let removed = 0
124+
let sawPlusMinus = false
125+
for (const line of diff.split("\n")) {
126+
if (line.startsWith("+++ ") || line.startsWith("--- ") || line.startsWith("@@")) continue
127+
if (line.startsWith("+")) {
128+
added++
129+
sawPlusMinus = true
130+
} else if (line.startsWith("-")) {
131+
removed++
132+
sawPlusMinus = true
133+
}
134+
}
135+
if (sawPlusMinus) {
136+
if (added === 0 && removed === 0) return null
137+
return { added, removed }
138+
}
139+
140+
// Strategy 2: Roo multi-search-replace blocks
141+
// Count lines in SEARCH vs REPLACE sections across all blocks
142+
// Matches optional metadata lines and optional '-------' line
143+
const blockRegex =
144+
/<<<<<<?\s*SEARCH[\s\S]*?(?:^:start_line:.*\n)?(?:^:end_line:.*\n)?(?:^-------\s*\n)?([\s\S]*?)^(?:=======\s*\n)([\s\S]*?)^(?:>>>>>>> REPLACE)/gim
145+
146+
let hasBlocks = false
147+
added = 0
148+
removed = 0
149+
150+
const asLines = (s: string) => {
151+
// Normalize Windows newlines and trim trailing newline so counts reflect real lines
152+
const norm = s.replace(/\r\n/g, "\n")
153+
if (norm === "") return 0
154+
// Split, drop potential trailing empty caused by final newline
155+
const parts = norm.split("\n")
156+
return parts[parts.length - 1] === "" ? parts.length - 1 : parts.length
157+
}
158+
159+
let match: RegExpExecArray | null
160+
while ((match = blockRegex.exec(diff)) !== null) {
161+
hasBlocks = true
162+
const searchContent = match[1] ?? ""
163+
const replaceContent = match[2] ?? ""
164+
const searchCount = asLines(searchContent)
165+
const replaceCount = asLines(replaceContent)
166+
if (replaceCount > searchCount) added += replaceCount - searchCount
167+
else if (searchCount > replaceCount) removed += searchCount - replaceCount
168+
}
169+
170+
if (hasBlocks) {
171+
if (added === 0 && removed === 0) return null
172+
return { added, removed }
173+
}
174+
175+
return null
176+
}
177+
178+
/**
179+
* Converts new file content to unified diff format (all lines as additions)
180+
*/
181+
function convertNewFileToUnifiedDiff(content: string, filePath?: string): string {
182+
const fileName = filePath || "file"
183+
const lines = content.split("\n")
184+
185+
let diff = `--- /dev/null\n`
186+
diff += `+++ ${fileName}\n`
187+
diff += `@@ -0,0 +1,${lines.length} @@\n`
188+
189+
for (const line of lines) {
190+
diff += `+${line}\n`
191+
}
192+
193+
return diff
194+
}
195+
196+
/**
197+
* Converts Roo's SEARCH/REPLACE format to unified diff format for better readability
198+
*/
199+
function convertSearchReplaceToUnifiedDiff(content: string, filePath?: string): string {
200+
const blockRegex =
201+
/<<<<<<?\s*SEARCH[\s\S]*?(?:^:start_line:.*\n)?(?:^:end_line:.*\n)?(?:^-------\s*\n)?([\s\S]*?)^(?:=======\s*\n)([\s\S]*?)^(?:>>>>>>> REPLACE)/gim
202+
203+
let hasBlocks = false
204+
let combinedDiff = ""
205+
const fileName = filePath || "file"
206+
207+
let match: RegExpExecArray | null
208+
while ((match = blockRegex.exec(content)) !== null) {
209+
hasBlocks = true
210+
const searchContent = (match[1] ?? "").replace(/\n$/, "") // Remove trailing newline
211+
const replaceContent = (match[2] ?? "").replace(/\n$/, "")
212+
213+
// Use the diff library to create a proper unified diff
214+
const patch = structuredPatch(fileName, fileName, searchContent, replaceContent, "", "", { context: 3 })
215+
216+
// Convert to unified diff format
217+
if (patch.hunks.length > 0) {
218+
for (const hunk of patch.hunks) {
219+
combinedDiff += `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`
220+
combinedDiff += hunk.lines.join("\n") + "\n"
221+
}
222+
}
223+
}
224+
225+
return hasBlocks ? combinedDiff : content
226+
}
227+
118228
export const ChatRowContent = ({
119229
message,
120230
lastModifiedMessage,
@@ -334,6 +444,59 @@ export const ChatRowContent = ({
334444
[message.ask, message.text],
335445
)
336446

447+
// Inline diff stats for edit/apply_diff/insert/search-replace/newFile asks
448+
const diffTextForStats = useMemo(() => {
449+
if (!tool) return ""
450+
let content = ""
451+
switch (tool.tool) {
452+
case "editedExistingFile":
453+
case "appliedDiff":
454+
content = (tool.content ?? tool.diff) || ""
455+
break
456+
case "insertContent":
457+
case "searchAndReplace":
458+
content = tool.diff || ""
459+
break
460+
case "newFileCreated":
461+
// For new files, convert to unified diff format
462+
const newFileContent = tool.content || ""
463+
content = convertNewFileToUnifiedDiff(newFileContent, tool.path)
464+
break
465+
default:
466+
return ""
467+
}
468+
// Strip CDATA markers for proper parsing
469+
return content.replace(/<!\[CDATA\[/g, "").replace(/\]\]>/g, "")
470+
}, [tool])
471+
472+
const diffStatsForInline = useMemo(() => {
473+
if (tool?.tool === "newFileCreated") {
474+
// For new files, count all lines as additions
475+
const content = diffTextForStats
476+
if (!content) return null
477+
const lines = content.split("\n").length
478+
return { added: lines, removed: 0 }
479+
}
480+
return computeDiffStats(diffTextForStats)
481+
}, [diffTextForStats, tool])
482+
483+
// Clean diff content for display (remove CDATA markers and convert to unified diff)
484+
const cleanDiffContent = useMemo(() => {
485+
if (!tool) return undefined
486+
const raw = (tool as any).content ?? (tool as any).diff
487+
if (!raw) return undefined
488+
489+
// Remove CDATA markers
490+
const withoutCData = raw.replace(/<!\[CDATA\[/g, "").replace(/\]\]>/g, "")
491+
492+
// Check if it's SEARCH/REPLACE format and convert to unified diff
493+
if (/<<<<<<<?\s*SEARCH/i.test(withoutCData)) {
494+
return convertSearchReplaceToUnifiedDiff(withoutCData, tool.path)
495+
}
496+
497+
return withoutCData
498+
}, [tool])
499+
337500
const followUpData = useMemo(() => {
338501
if (message.type === "ask" && message.ask === "followup" && !message.partial) {
339502
return safeJsonParse<FollowUpData>(message.text)
@@ -389,12 +552,13 @@ export const ChatRowContent = ({
389552
<div className="pl-6">
390553
<CodeAccordian
391554
path={tool.path}
392-
code={tool.content ?? tool.diff}
555+
code={cleanDiffContent ?? tool.content ?? tool.diff}
393556
language="diff"
394557
progressStatus={message.progressStatus}
395558
isLoading={message.partial}
396559
isExpanded={isExpanded}
397560
onToggleExpand={handleToggleExpand}
561+
diffStats={diffStatsForInline ?? undefined}
398562
/>
399563
</div>
400564
</>
@@ -426,12 +590,13 @@ export const ChatRowContent = ({
426590
<div className="pl-6">
427591
<CodeAccordian
428592
path={tool.path}
429-
code={tool.diff}
593+
code={cleanDiffContent ?? tool.diff}
430594
language="diff"
431595
progressStatus={message.progressStatus}
432596
isLoading={message.partial}
433597
isExpanded={isExpanded}
434598
onToggleExpand={handleToggleExpand}
599+
diffStats={diffStatsForInline ?? undefined}
435600
/>
436601
</div>
437602
</>
@@ -459,12 +624,13 @@ export const ChatRowContent = ({
459624
<div className="pl-6">
460625
<CodeAccordian
461626
path={tool.path}
462-
code={tool.diff}
627+
code={cleanDiffContent ?? tool.diff}
463628
language="diff"
464629
progressStatus={message.progressStatus}
465630
isLoading={message.partial}
466631
isExpanded={isExpanded}
467632
onToggleExpand={handleToggleExpand}
633+
diffStats={diffStatsForInline ?? undefined}
468634
/>
469635
</div>
470636
</>
@@ -527,12 +693,13 @@ export const ChatRowContent = ({
527693
<div className="pl-6">
528694
<CodeAccordian
529695
path={tool.path}
530-
code={tool.content}
531-
language={getLanguageFromPath(tool.path || "") || "log"}
696+
code={convertNewFileToUnifiedDiff(tool.content || "", tool.path)}
697+
language="diff"
532698
isLoading={message.partial}
533699
isExpanded={isExpanded}
534700
onToggleExpand={handleToggleExpand}
535701
onJumpToFile={() => vscode.postMessage({ type: "openFile", text: "./" + tool.path })}
702+
diffStats={diffStatsForInline ?? undefined}
536703
/>
537704
</div>
538705
</>

0 commit comments

Comments
 (0)