feat(ui): 添加 IDE 组件(文件浏览器 + 代码编辑器)

- 新增 CodeEditor 组件,基于 CodeMirror 实现多标签代码编辑
- 新增 FileExplorer 组件,支持文件树展开/折叠和文件选择
- 新增 IDE 组件,整合文件浏览器和代码编辑器
- 新增 SessionPanel 组件,用于会话管理
- 添加文件写入 API(PUT /api/files/write)
- 优化布局:IDE 始终显示,移除文件切换按钮
- 工作目录路径显示在文件浏览器标题栏,支持悬浮显示完整路径
This commit is contained in:
2025-12-17 16:55:22 +08:00
parent ddec356117
commit 250d2cb4b5
11 changed files with 1376 additions and 113 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* IDE Component
*
* 整合文件浏览器和代码编辑器的 IDE 组件
*/
import { useState, useCallback, useEffect } 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';
interface IDEProps {
className?: string;
/** 文件浏览器宽度 */
sidebarWidth?: number;
}
export function IDE({ className, sidebarWidth = 256 }: IDEProps) {
const [tabs, setTabs] = useState<EditorTab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [workingDirectory, setWorkingDirectory] = useState<string>('');
// 获取工作目录
useEffect(() => {
getWorkingDirectory()
.then((result) => {
if (result?.data?.workingDirectory) {
setWorkingDirectory(result.data.workingDirectory);
}
})
.catch(() => {
// 忽略错误
});
}, []);
// 打开文件
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
)
);
}, []);
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>
{/* 代码编辑器 */}
<div className="flex-1 min-w-0">
<CodeEditor
tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onTabClose={handleTabClose}
onContentChange={handleContentChange}
onSave={handleSave}
/>
</div>
</div>
);
}