Files
ai-terminal-assistant/packages/ui/src/components/IDE.tsx
T
kurihada fea5442d53 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 回调
2025-12-17 21:11:44 +08:00

327 lines
9.2 KiB
TypeScript

/**
* IDE Component
*
* 整合文件浏览器和代码编辑器的 IDE 组件
*/
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 { DiffEditor } from './DiffEditor.js';
import type { ActiveFileInfo, FileDiffInfo } from '../api/types.js';
// localStorage 键名
const STORAGE_KEY_TABS = 'ai-assistant-editor-tabs';
const STORAGE_KEY_ACTIVE_TAB = 'ai-assistant-editor-active-tab';
// 保存的标签页格式(只保存路径,不保存内容)
interface SavedTab {
path: string;
name: string;
}
interface IDEProps {
className?: string;
/** 文件浏览器宽度 */
sidebarWidth?: number;
/** 当前活动文件变化回调 */
onActiveFileChange?: (file: ActiveFileInfo | null) => void;
/** 要显示的 Diff 信息(外部传入) */
pendingDiff?: FileDiffInfo | null;
/** Diff 关闭回调 */
onDiffClose?: () => void;
}
/** 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);
// 获取工作目录
useEffect(() => {
getWorkingDirectory()
.then((result) => {
if (result?.data?.workingDirectory) {
setWorkingDirectory(result.data.workingDirectory);
}
})
.catch(() => {
// 忽略错误
});
}, []);
// 从 localStorage 恢复标签页
useEffect(() => {
if (hasRestoredTabs.current) return;
hasRestoredTabs.current = true;
const restoreTabs = async () => {
try {
const savedTabsJson = localStorage.getItem(STORAGE_KEY_TABS);
const savedActivePath = localStorage.getItem(STORAGE_KEY_ACTIVE_TAB);
if (!savedTabsJson) return;
const savedTabs: SavedTab[] = JSON.parse(savedTabsJson);
if (savedTabs.length === 0) return;
isRestoringTabs.current = true;
// 逐个恢复标签页
const restoredTabs: EditorTab[] = [];
for (const savedTab of savedTabs) {
try {
const result = await readFile(savedTab.path);
if (result.success && result.data.encoding === 'utf-8') {
restoredTabs.push({
id: `tab-${Date.now()}-${restoredTabs.length}`,
path: savedTab.path,
name: savedTab.name,
content: result.data.content,
originalContent: result.data.content,
language: getLanguageFromFilename(savedTab.name),
});
}
} catch {
// 文件可能已删除,跳过
}
}
if (restoredTabs.length > 0) {
setTabs(restoredTabs);
// 恢复活动标签
if (savedActivePath) {
const activeTab = restoredTabs.find((tab) => tab.path === savedActivePath);
if (activeTab) {
setActiveTabId(activeTab.id);
} else {
setActiveTabId(restoredTabs[0].id);
}
} else {
setActiveTabId(restoredTabs[0].id);
}
}
isRestoringTabs.current = false;
} catch {
isRestoringTabs.current = false;
}
};
restoreTabs();
}, []);
// 保存标签页到 localStorage
useEffect(() => {
// 恢复过程中不保存
if (isRestoringTabs.current) return;
const tabsToSave: SavedTab[] = tabs.map((tab) => ({
path: tab.path,
name: tab.name,
}));
localStorage.setItem(STORAGE_KEY_TABS, JSON.stringify(tabsToSave));
}, [tabs]);
// 保存活动标签到 localStorage
useEffect(() => {
// 恢复过程中不保存
if (isRestoringTabs.current) return;
const activeTab = tabs.find((tab) => tab.id === activeTabId);
if (activeTab) {
localStorage.setItem(STORAGE_KEY_ACTIVE_TAB, activeTab.path);
} else {
localStorage.removeItem(STORAGE_KEY_ACTIVE_TAB);
}
}, [activeTabId, tabs]);
// 通知父组件活动文件变化
useEffect(() => {
if (!onActiveFileChange) return;
const activeTab = tabs.find((tab) => tab.id === activeTabId);
if (activeTab) {
onActiveFileChange({
path: activeTab.path,
name: activeTab.name,
content: activeTab.content,
language: activeTab.language,
});
} else {
onActiveFileChange(null);
}
}, [activeTabId, tabs, onActiveFileChange]);
// 打开文件
const handleFileSelect = useCallback(async (path: string, name: string) => {
// 检查是否已打开
const existingTab = tabs.find((tab) => tab.path === path);
if (existingTab) {
setActiveTabId(existingTab.id);
return;
}
// 读取文件内容
try {
const result = await readFile(path);
if (result.success && result.data.encoding === 'utf-8') {
const newTab: EditorTab = {
id: `tab-${Date.now()}`,
path,
name,
content: result.data.content,
originalContent: result.data.content,
language: getLanguageFromFilename(name),
};
setTabs((prev) => [...prev, newTab]);
setActiveTabId(newTab.id);
} else if (result.data.encoding === 'base64') {
toast.error('Cannot edit binary files');
} else {
toast.error('Failed to open file');
}
} catch (error) {
console.error('Failed to open file:', error);
toast.error('Failed to open file');
}
}, [tabs]);
// 切换标签
const handleTabChange = useCallback((tabId: string) => {
setActiveTabId(tabId);
}, []);
// 关闭标签
const handleTabClose = useCallback((tabId: string) => {
const tabIndex = tabs.findIndex((tab) => tab.id === tabId);
const tab = tabs[tabIndex];
// 检查是否有未保存的更改
if (tab && tab.content !== tab.originalContent) {
const confirmed = window.confirm(`${tab.name} has unsaved changes. Close anyway?`);
if (!confirmed) return;
}
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
// 如果关闭的是当前标签,切换到相邻标签
if (activeTabId === tabId) {
if (tabs.length > 1) {
const newIndex = tabIndex === 0 ? 1 : tabIndex - 1;
setActiveTabId(tabs[newIndex].id);
} else {
setActiveTabId(null);
}
}
}, [tabs, activeTabId]);
// 内容更改
const handleContentChange = useCallback((tabId: string, content: string) => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === tabId ? { ...tab, content } : tab
)
);
}, []);
// 保存后更新原始内容
const handleSave = useCallback((tabId: string, _path: string, content: string) => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === tabId ? { ...tab, originalContent: content } : tab
)
);
}, []);
// 显示 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)}>
{/* 文件浏览器 */}
<div
className="flex-shrink-0 border-r border-line"
style={{ width: sidebarWidth }}
>
<FileExplorer onFileSelect={handleFileSelect} workingDirectory={workingDirectory} />
</div>
{/* 代码编辑器 / Diff 视图 */}
<div className="flex-1 min-w-0">
{showDiffView ? (
<DiffEditor
diff={currentDiff}
onClose={closeDiff}
/>
) : (
<CodeEditor
tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onTabClose={handleTabClose}
onContentChange={handleContentChange}
onSave={handleSave}
/>
)}
</div>
</div>
);
});