feat(ui): 添加文件 Diff 查看功能

当 AI 执行 write_file 或 edit_file 工具时,在工具结果中显示 View Diff 按钮,
点击后在 IDE 面板中显示文件修改前后的对比视图。

主要改动:
- core: edit_file/write_file 工具返回 fileDiff 元数据
- ui: 新增 DiffEditor 组件用于显示文件差异
- ui: ChatMessage 添加 View Diff 按钮
- ui: IDE 组件支持 Diff 视图切换
- ui: useChat hook 处理 fileDiff 回调
This commit is contained in:
2025-12-17 21:11:44 +08:00
parent 3320a2a5ba
commit fea5442d53
13 changed files with 470 additions and 22 deletions
+28 -4
View File
@@ -14,6 +14,7 @@ import {
AlertCircle,
CheckCircle2,
Loader2,
GitCompare,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, forwardRef } from 'react';
@@ -22,7 +23,7 @@ import { fadeInUp, smoothTransition } from '../utils/animations';
import { getAgentDisplayName } from '../utils/agent';
import { Markdown } from './Markdown';
import { FileMentionText } from './FileMentionTag';
import type { Message, ToolCallInfo, ToolStatus, ToolMessagePart, QuestionMessagePart } from '../api/types.js';
import type { Message, ToolCallInfo, ToolStatus, ToolMessagePart, QuestionMessagePart, FileDiffInfo } from '../api/types.js';
import { AskUserQuestion } from './AskUserQuestion.js';
interface ChatMessageProps {
@@ -31,10 +32,12 @@ interface ChatMessageProps {
isStreaming?: boolean;
/** 回答问题的回调(用于 ask_user_question 工具) */
onAnswerQuestion?: (questionPartId: string, answers: string[]) => void;
/** 查看文件 Diff 的回调 */
onViewDiff?: (diff: FileDiffInfo) => void;
}
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
({ message, isStreaming = false, onAnswerQuestion }, ref) => {
({ message, isStreaming = false, onAnswerQuestion, onViewDiff }, ref) => {
const isUser = message.role === 'user';
const [copied, setCopied] = useState(false);
@@ -83,7 +86,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
</div>
);
case 'tool':
return <ToolPartItem key={part.id} part={part} />;
return <ToolPartItem key={part.id} part={part} onViewDiff={onViewDiff} />;
case 'question': {
// 问题组件:即使在流式输出时也允许用户回答(除非已回答)
const questionPart = part as QuestionMessagePart;
@@ -250,15 +253,21 @@ export function TypingIndicator({ agentName }: TypingIndicatorProps) {
*/
interface ToolPartItemProps {
part: ToolMessagePart;
/** 查看文件 Diff 的回调 */
onViewDiff?: (diff: FileDiffInfo) => void;
}
function ToolPartItem({ part }: ToolPartItemProps) {
function ToolPartItem({ part, onViewDiff }: ToolPartItemProps) {
const [expanded, setExpanded] = useState(false);
const hasDetails =
Object.keys(part.arguments).length > 0 ||
part.result !== undefined ||
part.error !== undefined;
// 判断是否为文件操作工具且有 diff 数据
const isFileOperation = part.toolName === 'write_file' || part.toolName === 'edit_file';
const hasDiff = isFileOperation && part.fileDiff && part.status === 'completed';
return (
<div className="border border-line rounded-lg overflow-hidden bg-surface-subtle/30">
{/* 头部:工具名称、状态、时长 */}
@@ -278,6 +287,21 @@ function ToolPartItem({ part }: ToolPartItemProps) {
{part.duration && (
<span className="text-xs text-fg-subtle">{formatDuration(part.duration)}</span>
)}
{/* View Diff 按钮 */}
{hasDiff && onViewDiff && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => {
e.stopPropagation();
onViewDiff(part.fileDiff!);
}}
className="px-2 py-0.5 text-xs bg-primary-500/10 text-primary-400 rounded hover:bg-primary-500/20 transition-colors"
>
<GitCompare size={12} className="inline mr-1" />
View Diff
</motion.button>
)}
{hasDetails && (
<span className="text-fg-subtle">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
+223
View File
@@ -0,0 +1,223 @@
/**
* DiffEditor Component
*
* 基于 CodeMirror Merge View 的 Diff 编辑器
* 用于显示文件修改前后的对比
*/
import { useEffect, useRef, useMemo } from 'react';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { MergeView } from '@codemirror/merge';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { json } from '@codemirror/lang-json';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { X, FileCode, GitCompare } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '../utils/cn.js';
import { useTheme } from '../hooks/useTheme.js';
import type { FileDiffInfo } from '../api/types.js';
interface DiffEditorProps {
/** Diff 信息 */
diff: FileDiffInfo;
/** 关闭回调 */
onClose?: () => void;
/** 容器类名 */
className?: string;
}
// 根据文件扩展名获取语言
function getLanguageExtension(filename: string) {
const ext = filename.split('.').pop()?.toLowerCase() || '';
switch (ext) {
case 'js':
case 'jsx':
return javascript({ jsx: true });
case 'ts':
case 'tsx':
return javascript({ jsx: true, typescript: true });
case 'py':
return python();
case 'json':
return json();
case 'html':
case 'htm':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'md':
case 'markdown':
return markdown();
default:
return [];
}
}
// 计算变更统计
function computeChangeStats(original: string, modified: string) {
const originalLines = original.split('\n');
const modifiedLines = modified.split('\n');
// 简单统计:计算行数差异
const additions = Math.max(0, modifiedLines.length - originalLines.length);
const deletions = Math.max(0, originalLines.length - modifiedLines.length);
// 更精确的统计需要实际的 diff 算法
// 这里用简化版本
let changed = 0;
const minLen = Math.min(originalLines.length, modifiedLines.length);
for (let i = 0; i < minLen; i++) {
if (originalLines[i] !== modifiedLines[i]) {
changed++;
}
}
return {
additions: additions + changed,
deletions: deletions + changed,
totalLines: modifiedLines.length,
};
}
export function DiffEditor({ diff, onClose, className }: DiffEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mergeViewRef = useRef<MergeView | null>(null);
const { resolvedTheme } = useTheme();
// 语言扩展
const languageExtension = useMemo(
() => getLanguageExtension(diff.name),
[diff.name]
);
// 变更统计
const stats = useMemo(
() => computeChangeStats(diff.originalContent, diff.newContent),
[diff.originalContent, diff.newContent]
);
// ESC 键关闭 Diff 视图
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onClose) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// 创建 MergeView
useEffect(() => {
if (!containerRef.current) return;
// 清理之前的实例
if (mergeViewRef.current) {
mergeViewRef.current.destroy();
}
// 创建基础扩展
const extensions = [
basicSetup,
EditorView.editable.of(false), // 只读模式
EditorState.readOnly.of(true),
...(Array.isArray(languageExtension) ? languageExtension : [languageExtension]),
];
// 深色主题
if (resolvedTheme === 'dark') {
extensions.push(oneDark);
}
// 创建 MergeView
const mergeView = new MergeView({
a: {
doc: diff.originalContent,
extensions,
},
b: {
doc: diff.newContent,
extensions,
},
parent: containerRef.current,
collapseUnchanged: { margin: 3, minSize: 4 },
gutter: true,
highlightChanges: true,
});
mergeViewRef.current = mergeView;
return () => {
mergeView.destroy();
};
}, [diff.originalContent, diff.newContent, languageExtension, resolvedTheme]);
return (
<div className={cn('flex flex-col h-full bg-surface-base', className)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-line bg-surface-subtle">
<div className="flex items-center gap-3">
{/* 文件图标和名称 */}
<div className="flex items-center gap-2">
<GitCompare size={16} className="text-primary-500" />
<span className="font-medium text-fg">{diff.name}</span>
<span className="text-xs text-fg-muted">({diff.operation === 'write' ? 'Write' : 'Edit'})</span>
</div>
{/* 变更统计 */}
<div className="flex items-center gap-2 text-xs">
<span className="text-green-500">+{stats.additions}</span>
<span className="text-red-500">-{stats.deletions}</span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2">
{/* 关闭按钮 */}
{onClose && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
className="p-1.5 rounded hover:bg-surface-muted transition-colors"
title="关闭 Diff 视图"
>
<X size={16} className="text-fg-muted" />
</motion.button>
)}
</div>
</div>
{/* 标签栏 */}
<div className="flex border-b border-line bg-surface-subtle">
<div className="flex-1 px-4 py-2 text-sm text-fg-muted border-r border-line">
<FileCode size={14} className="inline mr-2" />
Original
</div>
<div className="flex-1 px-4 py-2 text-sm text-fg-muted">
<FileCode size={14} className="inline mr-2" />
Modified
</div>
</div>
{/* Diff 视图 */}
<div
ref={containerRef}
className="flex-1 overflow-auto"
style={{ minHeight: 0 }}
/>
{/* 路径提示 */}
<div className="px-4 py-1.5 border-t border-line bg-surface-subtle text-xs text-fg-muted truncate">
{diff.path}
</div>
</div>
);
}
+78 -13
View File
@@ -4,13 +4,14 @@
* 整合文件浏览器和代码编辑器的 IDE 组件
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
import { toast } from 'sonner';
import { cn } from '../utils/cn.js';
import { readFile, getWorkingDirectory } from '../api/client.js';
import { FileExplorer } from './FileExplorer.js';
import { CodeEditor, getLanguageFromFilename, type EditorTab } from './CodeEditor.js';
import type { ActiveFileInfo } from '../api/types.js';
import { DiffEditor } from './DiffEditor.js';
import type { ActiveFileInfo, FileDiffInfo } from '../api/types.js';
// localStorage 键名
const STORAGE_KEY_TABS = 'ai-assistant-editor-tabs';
@@ -28,12 +29,28 @@ interface IDEProps {
sidebarWidth?: number;
/** 当前活动文件变化回调 */
onActiveFileChange?: (file: ActiveFileInfo | null) => void;
/** 要显示的 Diff 信息(外部传入) */
pendingDiff?: FileDiffInfo | null;
/** Diff 关闭回调 */
onDiffClose?: () => void;
}
export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEProps) {
/** IDE 组件暴露的方法 */
export interface IDEHandle {
/** 显示文件 diff */
showDiff: (diff: FileDiffInfo) => void;
/** 关闭 diff 视图 */
closeDiff: () => void;
}
export const IDE = forwardRef<IDEHandle, IDEProps>(function IDE(
{ className, sidebarWidth = 256, onActiveFileChange, pendingDiff, onDiffClose },
ref
) {
const [tabs, setTabs] = useState<EditorTab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [workingDirectory, setWorkingDirectory] = useState<string>('');
const [currentDiff, setCurrentDiff] = useState<FileDiffInfo | null>(null);
const isRestoringTabs = useRef(false);
const hasRestoredTabs = useRef(false);
@@ -235,6 +252,47 @@ export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEPr
);
}, []);
// 显示 diff 视图
const showDiff = useCallback((diff: FileDiffInfo) => {
setCurrentDiff(diff);
// 如果文件已在编辑器中打开,更新其内容
setTabs((prev) =>
prev.map((tab) => {
if (tab.path === diff.path) {
return {
...tab,
content: diff.newContent,
originalContent: diff.newContent,
};
}
return tab;
})
);
}, []);
// 关闭 diff 视图
const closeDiff = useCallback(() => {
setCurrentDiff(null);
onDiffClose?.();
}, [onDiffClose]);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
showDiff,
closeDiff,
}), [showDiff, closeDiff]);
// 处理外部传入的 pendingDiff
useEffect(() => {
if (pendingDiff) {
showDiff(pendingDiff);
}
}, [pendingDiff, showDiff]);
// 判断是否显示 diff 视图
const showDiffView = currentDiff !== null;
return (
<div className={cn('flex h-full', className)}>
{/* 文件浏览器 */}
@@ -245,17 +303,24 @@ export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEPr
<FileExplorer onFileSelect={handleFileSelect} workingDirectory={workingDirectory} />
</div>
{/* 代码编辑器 */}
{/* 代码编辑器 / Diff 视图 */}
<div className="flex-1 min-w-0">
<CodeEditor
tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onTabClose={handleTabClose}
onContentChange={handleContentChange}
onSave={handleSave}
/>
{showDiffView ? (
<DiffEditor
diff={currentDiff}
onClose={closeDiff}
/>
) : (
<CodeEditor
tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onTabClose={handleTabClose}
onContentChange={handleContentChange}
onSave={handleSave}
/>
)}
</div>
</div>
);
}
});