Skip to content

Commit daf2f81

Browse files
committed
fix: harden toolcall diff preview rendering
1 parent 481d40d commit daf2f81

File tree

3 files changed

+158
-48
lines changed

3 files changed

+158
-48
lines changed
Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,33 @@
11
import type { MessageType } from "../message";
2-
import { parseDiff, Diff, Hunk } from "react-diff-view";
3-
import "react-diff-view/style/index.css";
4-
import { createPatch } from "diff";
2+
import { EditDiffPreview } from "./edit-diff-preview";
3+
4+
const formatFilePath = (value: unknown) => {
5+
if (typeof value !== "string") {
6+
return ""
7+
}
8+
9+
return value.replace(/[\r\n\t]+/g, " ").trim()
10+
}
511

612
export const renderTitle = (message: MessageType) => {
7-
return `修改文件 "${message.data.rawInput?.file_path || message.data._meta?.claudeCode?.toolResponse?.filePath}"`
13+
const filePath = formatFilePath(message.data.rawInput?.file_path || message.data._meta?.claudeCode?.toolResponse?.filePath)
14+
return `修改文件${filePath ? ` "${filePath}"` : ""}`
815
}
916

1017
export const renderDetail = (message: MessageType) => {
11-
const oldString = message.data.rawInput?.old_string || message.data._meta?.claudeCode?.toolResponse?.oldString || "";
12-
const newString = message.data.rawInput?.new_string || message.data.rawInput?.content || message.data._meta?.claudeCode?.toolResponse?.newString || message.data._meta?.claudeCode?.toolResponse?.content || "";
13-
const filePath = message.data.rawInput?.file_path;
14-
15-
let diffText = createPatch(filePath || "", oldString || "", newString || "", "", "", {
16-
headerOptions: {
17-
includeIndex: false,
18-
includeUnderline: false,
19-
includeFileHeaders: true,
20-
}
21-
})
22-
23-
const files = diffText ? parseDiff(diffText) : [];
18+
const oldString = message.data.rawInput?.old_string ?? message.data._meta?.claudeCode?.toolResponse?.oldString
19+
const newString = message.data.rawInput?.new_string
20+
?? message.data.rawInput?.content
21+
?? message.data._meta?.claudeCode?.toolResponse?.newString
22+
?? message.data._meta?.claudeCode?.toolResponse?.content
23+
const filePath = message.data.rawInput?.file_path ?? message.data._meta?.claudeCode?.toolResponse?.filePath
2424

2525
return (
2626
<div
2727
className="text-xs p-3"
2828
style={{ '--diff-font-family': 'var(--font-google-sans-code)' } as React.CSSProperties}
2929
>
30-
{files.map((file, index) => (
31-
<Diff key={index} viewType={!oldString ? "unified" : "split"} diffType={file.type} hunks={file.hunks} gutterType="none" >
32-
{(hunks) => hunks.map(hunk => <Hunk key={hunk.content} hunk={hunk} />)}
33-
</Diff>
34-
))}
30+
<EditDiffPreview filePath={filePath} oldValue={oldString} newValue={newString} padded />
3531
</div>
36-
);
37-
}
32+
)
33+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { createPatch } from "diff"
2+
import { Diff, Hunk, parseDiff } from "react-diff-view"
3+
import "react-diff-view/style/index.css"
4+
5+
const PATCH_OPTIONS = {
6+
headerOptions: {
7+
includeIndex: false,
8+
includeUnderline: false,
9+
includeFileHeaders: true,
10+
},
11+
} as const
12+
13+
const sanitizeDiffPath = (value: unknown) => {
14+
if (typeof value !== "string") {
15+
return ""
16+
}
17+
18+
return value.replace(/[\r\n\t]+/g, " ").trim()
19+
}
20+
21+
const coerceDiffText = (value: unknown) => {
22+
if (typeof value === "string") {
23+
return value
24+
}
25+
26+
if (value === null || value === undefined) {
27+
return ""
28+
}
29+
30+
if (typeof value === "object") {
31+
try {
32+
return JSON.stringify(value, null, 2)
33+
} catch {
34+
return String(value)
35+
}
36+
}
37+
38+
return String(value)
39+
}
40+
41+
const buildPreview = (filePath: unknown, oldValue: unknown, newValue: unknown) => {
42+
const safePath = sanitizeDiffPath(filePath) || "untitled"
43+
const oldText = coerceDiffText(oldValue)
44+
const newText = coerceDiffText(newValue)
45+
46+
try {
47+
const diffText = createPatch(safePath, oldText, newText, "", "", PATCH_OPTIONS)
48+
const files = diffText ? parseDiff(diffText) : []
49+
50+
return {
51+
error: null,
52+
files,
53+
oldText,
54+
newText,
55+
safePath,
56+
}
57+
} catch (error) {
58+
return {
59+
error: error instanceof Error ? error.message : "diff 解析失败",
60+
files: [],
61+
oldText,
62+
newText,
63+
safePath,
64+
}
65+
}
66+
}
67+
68+
export const EditDiffPreview = ({
69+
filePath,
70+
oldValue,
71+
newValue,
72+
hunkClassName,
73+
padded = false,
74+
}: {
75+
filePath: unknown
76+
oldValue: unknown
77+
newValue: unknown
78+
hunkClassName?: string
79+
padded?: boolean
80+
}) => {
81+
const { error, files, oldText, newText, safePath } = buildPreview(filePath, oldValue, newValue)
82+
const hasChanges = oldText !== newText
83+
84+
if (error) {
85+
return (
86+
<div className={padded ? "space-y-3 p-3" : "space-y-3"}>
87+
<div className="text-muted-foreground">
88+
Diff 预览失败,已回退为文本展示。
89+
</div>
90+
<pre className="overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted/40 p-3 text-[11px] leading-5 text-muted-foreground">
91+
{`文件: ${safePath}\n\n--- old ---\n${oldText || "(empty)"}\n\n--- new ---\n${newText || "(empty)"}\n\n[parse error] ${error}`}
92+
</pre>
93+
</div>
94+
)
95+
}
96+
97+
if (files.length === 0 || files.every((file) => file.hunks.length === 0)) {
98+
return (
99+
<div className={padded ? "p-3 text-muted-foreground" : "text-muted-foreground"}>
100+
{hasChanges ? "未生成可展示的 diff" : "未检测到文本差异"}
101+
</div>
102+
)
103+
}
104+
105+
return (
106+
<>
107+
{files.map((file, index) => (
108+
<Diff
109+
key={`${safePath}-${index}`}
110+
viewType={!oldText ? "unified" : "split"}
111+
diffType={file.type}
112+
hunks={file.hunks}
113+
gutterType="none"
114+
hunkClassName={hunkClassName}
115+
>
116+
{(hunks) => hunks.map((hunk) => <Hunk key={hunk.content} hunk={hunk} />)}
117+
</Diff>
118+
))}
119+
</>
120+
)
121+
}
Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
11
import type { MessageType } from "../message";
2-
import { parseDiff, Diff, Hunk } from "react-diff-view";
3-
import "react-diff-view/style/index.css";
4-
import { createPatch } from "diff";
2+
import { EditDiffPreview } from "./edit-diff-preview";
3+
4+
const formatFilePath = (value: unknown) => {
5+
if (typeof value !== "string") {
6+
return ""
7+
}
8+
9+
return value.replace(/[\r\n\t]+/g, " ").trim()
10+
}
511

612
export const renderTitle = (message: MessageType) => {
7-
return `修改文件${message.data.rawInput?.filePath ? ` "${message.data.rawInput?.filePath}"` : ''}`
13+
const filePath = formatFilePath(message.data.rawInput?.filePath)
14+
return `修改文件${filePath ? ` "${filePath}"` : ""}`
815
}
916

1017
export const renderDetail = (message: MessageType) => {
11-
const oldString = message.data.rawInput?.oldString || "";
12-
const newString = message.data.rawInput?.newString || message.data.rawInput?.content || "";
13-
const filePath = message.data.rawInput?.filePath;
14-
15-
let diffText = createPatch(filePath || "", oldString || "", newString || "", "", "", {
16-
headerOptions: {
17-
includeIndex: false,
18-
includeUnderline: false,
19-
includeFileHeaders: true,
20-
}
21-
})
22-
23-
const files = diffText ? parseDiff(diffText) : [];
18+
const oldString = message.data.rawInput?.oldString
19+
const newString = message.data.rawInput?.newString ?? message.data.rawInput?.content
20+
const filePath = message.data.rawInput?.filePath
2421

2522
return (
2623
<div
@@ -32,11 +29,7 @@ export const renderDetail = (message: MessageType) => {
3229
border-left: 1px var(--border) solid;
3330
}
3431
`}</style>
35-
{files.map((file, index) => (
36-
<Diff key={index} viewType={!oldString ? "unified" : "split"} diffType={file.type} hunks={file.hunks} gutterType="none" hunkClassName="user-diff-style" >
37-
{(hunks) => hunks.map(hunk => <Hunk key={hunk.content} hunk={hunk} />)}
38-
</Diff>
39-
))}
32+
<EditDiffPreview filePath={filePath} oldValue={oldString} newValue={newString} hunkClassName="user-diff-style" />
4033
</div>
41-
);
42-
}
34+
)
35+
}

0 commit comments

Comments
 (0)