Skip to content

Commit 385d982

Browse files
committed
refactor(file): 重构 diff 生成逻辑并优化显示
将 diff 生成函数提取到共享模块 diffUtils.ts 中,供 Edit 和 Write 工具共用 优化 DiffRenderer 组件,增加折叠功能并移除冗余信息 在 Write 工具中添加文件变更的 diff 显示支持
1 parent 0be12b3 commit 385d982

5 files changed

Lines changed: 179 additions & 70 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* 差异生成工具函数
3+
* 提供 Edit 和 Write 工具共享的 diff 生成能力
4+
*/
5+
6+
import * as Diff from 'diff';
7+
8+
/**
9+
* 生成差异片段(使用 unified diff 格式,显示替换前后的代码上下文)
10+
*
11+
* @param oldContent 旧文件内容
12+
* @param newContent 新文件内容
13+
* @param contextLines 上下文行数(默认 4 行)
14+
* @returns diff 片段(JSON 格式,包含 patch 和行号信息)或 null
15+
*/
16+
export function generateDiffSnippet(
17+
oldContent: string,
18+
newContent: string,
19+
contextLines: number = 4
20+
): string | null {
21+
// 如果内容完全相同,不生成 diff
22+
if (oldContent === newContent) {
23+
return null;
24+
}
25+
26+
// 使用 diff 库生成 unified diff
27+
const patch = Diff.createPatch('file', oldContent, newContent, '', '', {
28+
context: contextLines,
29+
});
30+
31+
// 计算第一个变更位置的行号
32+
const lines = patch.split('\n');
33+
let matchLine = 1;
34+
for (const line of lines) {
35+
const hunkMatch = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
36+
if (hunkMatch) {
37+
matchLine = parseInt(hunkMatch[1], 10);
38+
break;
39+
}
40+
}
41+
42+
// 返回特殊格式,包含 patch 和行号信息
43+
// 使用特殊分隔符 <<<DIFF>>>,方便 MessageRenderer 识别为 diff 内容
44+
return `\n<<<DIFF>>>\n${JSON.stringify({
45+
patch,
46+
startLine: Math.max(1, matchLine - contextLines),
47+
matchLine,
48+
})}\n<<</DIFF>>>\n`;
49+
}
50+
51+
/**
52+
* 生成带有特定替换位置的差异片段(用于 Edit 工具)
53+
*
54+
* @param oldContent 旧文件内容
55+
* @param newContent 新文件内容
56+
* @param oldString 被替换的字符串
57+
* @param newString 替换后的字符串
58+
* @param contextLines 上下文行数(默认 4 行)
59+
* @returns diff 片段或 null
60+
*/
61+
export function generateDiffSnippetWithMatch(
62+
oldContent: string,
63+
newContent: string,
64+
oldString: string,
65+
newString: string,
66+
contextLines: number = 4
67+
): string | null {
68+
// 找到第一个替换位置
69+
const firstMatchIndex = oldContent.indexOf(oldString);
70+
if (firstMatchIndex === -1) return null;
71+
72+
// 计算替换位置的行号
73+
const beforeLines = oldContent.substring(0, firstMatchIndex).split('\n');
74+
const matchLine = beforeLines.length - 1;
75+
76+
// 分割旧内容和新内容为行数组
77+
const oldLines = oldContent.split('\n');
78+
const newLines = newContent.split('\n');
79+
80+
// 计算显示范围(考虑替换可能改变行数)
81+
const oldStringLines = oldString.split('\n');
82+
const newStringLines = newString.split('\n');
83+
const startLine = Math.max(0, matchLine - contextLines);
84+
const oldEndLine = Math.min(
85+
oldLines.length,
86+
matchLine + oldStringLines.length + contextLines
87+
);
88+
const newEndLine = Math.min(
89+
newLines.length,
90+
matchLine + newStringLines.length + contextLines
91+
);
92+
93+
// 提取上下文片段
94+
const oldSnippet = oldLines.slice(startLine, oldEndLine).join('\n');
95+
const newSnippet = newLines.slice(startLine, newEndLine).join('\n');
96+
97+
// 使用 diff 库生成 unified diff
98+
const patch = Diff.createPatch('file', oldSnippet, newSnippet, '', '', {
99+
context: contextLines,
100+
});
101+
102+
// 返回特殊格式,包含 patch 和行号信息
103+
return `\n<<<DIFF>>>\n${JSON.stringify({
104+
patch,
105+
startLine: startLine + 1,
106+
matchLine: matchLine + 1,
107+
})}\n<<</DIFF>>>\n`;
108+
}

src/tools/builtin/file/edit.ts

Lines changed: 3 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import * as Diff from 'diff';
21
import { promises as fs } from 'fs';
32
import { extname } from 'path';
43
import { z } from 'zod';
54
import { createTool } from '../../core/createTool.js';
65
import type { ExecutionContext, ToolResult } from '../../types/index.js';
76
import { ToolErrorType, ToolKind } from '../../types/index.js';
87
import { ToolSchemas } from '../../validation/zodSchemas.js';
8+
import { generateDiffSnippetWithMatch } from './diffUtils.js';
99
import { FileAccessTracker } from './FileAccessTracker.js';
1010
import { SnapshotManager } from './SnapshotManager.js';
1111

@@ -253,7 +253,7 @@ export const editTool = createTool({
253253
const stats = await fs.stat(file_path);
254254

255255
// 生成差异片段(仅显示第一个替换的上下文)
256-
const diffSnippet = generateDiffSnippet(
256+
const diffSnippet = generateDiffSnippetWithMatch(
257257
content,
258258
newContent,
259259
actualString,
@@ -406,58 +406,7 @@ function findMatches(content: string, searchString: string): number[] {
406406
return matches;
407407
}
408408

409-
/**
410-
* 生成差异片段(使用 unified diff 格式,显示替换前后的代码上下文)
411-
*/
412-
function generateDiffSnippet(
413-
oldContent: string,
414-
newContent: string,
415-
oldString: string,
416-
newString: string,
417-
contextLines: number = 4
418-
): string | null {
419-
// 找到第一个替换位置
420-
const firstMatchIndex = oldContent.indexOf(oldString);
421-
if (firstMatchIndex === -1) return null;
422-
423-
// 计算替换位置的行号
424-
const beforeLines = oldContent.substring(0, firstMatchIndex).split('\n');
425-
const matchLine = beforeLines.length - 1;
426-
427-
// 分割旧内容和新内容为行数组
428-
const oldLines = oldContent.split('\n');
429-
const newLines = newContent.split('\n');
430-
431-
// 计算显示范围(考虑替换可能改变行数)
432-
const oldStringLines = oldString.split('\n');
433-
const newStringLines = newString.split('\n');
434-
const startLine = Math.max(0, matchLine - contextLines);
435-
const oldEndLine = Math.min(
436-
oldLines.length,
437-
matchLine + oldStringLines.length + contextLines
438-
);
439-
const newEndLine = Math.min(
440-
newLines.length,
441-
matchLine + newStringLines.length + contextLines
442-
);
443-
444-
// 提取上下文片段
445-
const oldSnippet = oldLines.slice(startLine, oldEndLine).join('\n');
446-
const newSnippet = newLines.slice(startLine, newEndLine).join('\n');
447-
448-
// 使用 diff 库生成 unified diff
449-
const patch = Diff.createPatch('file', oldSnippet, newSnippet, '', '', {
450-
context: contextLines,
451-
});
452-
453-
// 返回特殊格式,包含 patch 和行号信息
454-
// 使用特殊分隔符,方便前端识别为 diff 内容
455-
return `\n<<<DIFF>>>\n${JSON.stringify({
456-
patch,
457-
startLine: startLine + 1,
458-
matchLine: matchLine + 1,
459-
})}\n<<</DIFF>>>\n`;
460-
}
409+
// diff 生成函数已移动到 diffUtils.ts,供 Edit 和 Write 工具共享
461410

462411
/**
463412
* 格式化显示消息

src/tools/builtin/file/write.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createTool } from '../../core/createTool.js';
55
import type { ExecutionContext, ToolResult } from '../../types/index.js';
66
import { ToolErrorType, ToolKind } from '../../types/index.js';
77
import { ToolSchemas } from '../../validation/zodSchemas.js';
8+
import { generateDiffSnippet } from './diffUtils.js';
89
import { FileAccessTracker } from './FileAccessTracker.js';
910
import { SnapshotManager } from './SnapshotManager.js';
1011

@@ -101,9 +102,18 @@ export const writeTool = createTool({
101102

102103
// 检查文件是否存在(用于后续验证和快照)
103104
let fileExists = false;
105+
let oldContent: string | null = null;
104106
try {
105107
await fs.access(file_path);
106108
fileExists = true;
109+
// 如果文件存在且是文本文件,读取旧内容用于生成 diff
110+
if (encoding === 'utf8') {
111+
try {
112+
oldContent = await fs.readFile(file_path, 'utf8');
113+
} catch (error) {
114+
console.warn('[WriteTool] 读取旧文件内容失败:', error);
115+
}
116+
}
107117
} catch {
108118
// 文件不存在
109119
}
@@ -170,6 +180,16 @@ export const writeTool = createTool({
170180
const lineCount = encoding === 'utf8' ? content.split('\n').length : 0;
171181
const fileName = file_path.split('/').pop() || file_path;
172182

183+
// 生成 diff(如果是覆盖现有文本文件)
184+
let diffSnippet: string | null = null;
185+
if (oldContent && encoding === 'utf8' && oldContent !== content) {
186+
// 文件大小限制:超过 1MB 跳过 diff 生成(避免性能问题)
187+
const MAX_DIFF_SIZE = 1024 * 1024; // 1MB
188+
if (oldContent.length < MAX_DIFF_SIZE && content.length < MAX_DIFF_SIZE) {
189+
diffSnippet = generateDiffSnippet(oldContent, content, 4);
190+
}
191+
}
192+
173193
const metadata: Record<string, any> = {
174194
file_path,
175195
content_size: content.length,
@@ -180,13 +200,19 @@ export const writeTool = createTool({
180200
session_id: sessionId,
181201
message_id: messageId,
182202
last_modified: stats.mtime.toISOString(),
203+
has_diff: !!diffSnippet, // 是否生成了 diff
183204
summary:
184205
encoding === 'utf8'
185206
? `写入 ${lineCount} 行到 ${fileName}`
186207
: `写入 ${formatFileSize(stats.size)}${fileName}`,
187208
};
188209

189-
const displayMessage = formatDisplayMessage(file_path, metadata, content);
210+
const displayMessage = formatDisplayMessage(
211+
file_path,
212+
metadata,
213+
content,
214+
diffSnippet
215+
);
190216

191217
return {
192218
success: true,
@@ -248,7 +274,8 @@ export const writeTool = createTool({
248274
function formatDisplayMessage(
249275
filePath: string,
250276
metadata: Record<string, any>,
251-
content?: string
277+
content?: string,
278+
diffSnippet?: string | null
252279
): string {
253280
let message = `✅ 成功写入文件: ${filePath}`;
254281

@@ -264,8 +291,13 @@ function formatDisplayMessage(
264291
message += `\n🔐 使用编码: ${metadata.encoding}`;
265292
}
266293

267-
// 添加内容预览(仅对文本文件)
268-
if (content && metadata.encoding === 'utf8') {
294+
// 优先显示 diff(如果有)
295+
if (diffSnippet) {
296+
message += diffSnippet;
297+
}
298+
299+
// 添加内容预览(仅对文本文件且没有 diff 时才显示完整预览)
300+
if (content && metadata.encoding === 'utf8' && !diffSnippet) {
269301
const preview = generateContentPreview(filePath, content);
270302
if (preview) {
271303
message += '\n\n' + preview;

src/ui/components/DiffRenderer.tsx

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import { themeManager } from '../themes/ThemeManager.js';
88

99
interface DiffRendererProps {
1010
patch: string; // unified diff 格式的 patch
11-
startLine: number; // 起始行号
12-
matchLine: number; // 变更所在行号
11+
startLine?: number; // 起始行号(保留用于向后兼容,但不再显示)
12+
matchLine?: number; // 变更所在行号(保留用于向后兼容,但不再显示)
1313
terminalWidth: number;
14+
maxLines?: number; // 默认显示的最大行数(默认 20 行)
1415
}
1516

1617
/**
@@ -97,6 +98,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
9798
startLine,
9899
matchLine,
99100
terminalWidth,
101+
maxLines = 20, // 默认显示 20 行
100102
}) => {
101103
const theme = themeManager.getTheme();
102104
const parsedLines = parsePatch(patch);
@@ -105,21 +107,31 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
105107
const maxLineNum = Math.max(...parsedLines.map((l) => l.lineNumber || 0));
106108
const lineNumWidth = maxLineNum.toString().length + 1;
107109

110+
// 判断是否需要折叠
111+
const totalLines = parsedLines.length;
112+
const needsCollapse = totalLines > maxLines;
113+
const displayLines = needsCollapse ? parsedLines.slice(0, maxLines) : parsedLines;
114+
const hiddenLines = totalLines - maxLines;
115+
108116
return (
109117
<Box flexDirection="column" marginTop={1} marginBottom={1}>
110118
{/* 分隔符 */}
111119
<Text color={theme.colors.muted}>{'─'.repeat(Math.min(60, terminalWidth))}</Text>
112120

113-
{/* 变更位置提示 */}
114-
<Text color={theme.colors.info}>
115-
📍 变更位置:第 {matchLine} 行(从第 {startLine} 行开始显示)
116-
</Text>
121+
{/* diff 统计信息 */}
122+
{needsCollapse && (
123+
<Text color={theme.colors.info}>
124+
📊 显示前 {maxLines} 行,共 {totalLines} 行 diff
125+
</Text>
126+
)}
117127

118-
{/* 分隔符 */}
119-
<Text color={theme.colors.muted}>{'─'.repeat(Math.min(60, terminalWidth))}</Text>
128+
{/* 分隔符(仅在有统计信息时显示) */}
129+
{needsCollapse && (
130+
<Text color={theme.colors.muted}>{'─'.repeat(Math.min(60, terminalWidth))}</Text>
131+
)}
120132

121133
{/* 渲染 diff 内容 */}
122-
{parsedLines.map((line, index) => {
134+
{displayLines.map((line, index) => {
123135
if (line.type === 'header') {
124136
return (
125137
<Text key={index} color={theme.colors.muted} dimColor>
@@ -147,7 +159,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
147159
fgColor = theme.colors.error;
148160
bgColor = undefined;
149161
} else {
150-
fgColor = theme.colors.text;
162+
fgColor = theme.colors.text.primary;
151163
}
152164

153165
// 截断过长的行(保留空间给行号和前缀)
@@ -166,6 +178,15 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
166178
);
167179
})}
168180

181+
{/* 折叠提示 */}
182+
{needsCollapse && (
183+
<Box marginTop={1}>
184+
<Text color={theme.colors.warning} dimColor>
185+
⚠️ 已隐藏剩余 {hiddenLines} 行 diff(总共 {totalLines} 行)
186+
</Text>
187+
</Box>
188+
)}
189+
169190
{/* 分隔符 */}
170191
<Text color={theme.colors.muted}>{'─'.repeat(Math.min(60, terminalWidth))}</Text>
171192
</Box>

src/ui/components/LoadingIndicator.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = React.memo(
130130
(prevProps, nextProps) => {
131131
// 精确比较,只在必要时重渲染
132132
return (
133-
prevProps.visible === nextProps.visible &&
134-
prevProps.message === nextProps.message
133+
prevProps.visible === nextProps.visible && prevProps.message === nextProps.message
135134
);
136135
}
137136
);

0 commit comments

Comments
 (0)