fea5442d53
当 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 回调
327 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
});
|