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
+286
View File
@@ -0,0 +1,286 @@
/**
* CodeEditor Component
*
* 基于 CodeMirror 的代码编辑器,支持多标签页和保存功能
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
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, Save, Circle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn.js';
import { writeFile } from '../api/client.js';
import { useTheme } from '../hooks/useTheme.js';
export interface EditorTab {
id: string;
path: string;
name: string;
content: string;
originalContent: string;
language: string;
}
interface CodeEditorProps {
tabs: EditorTab[];
activeTabId: string | null;
onTabChange: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onContentChange: (tabId: string, content: string) => void;
onSave?: (tabId: string, path: string, content: string) => void;
className?: string;
}
// 根据文件扩展名获取语言
function getLanguageExtension(language: string) {
switch (language) {
case 'javascript':
case 'typescript':
case 'jsx':
case 'tsx':
return javascript({ jsx: true, typescript: language.includes('typescript') || language === 'tsx' });
case 'python':
return python();
case 'json':
return json();
case 'html':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'markdown':
case 'md':
return markdown();
default:
return [];
}
}
// 根据文件名获取语言类型
export function getLanguageFromFilename(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
const languageMap: Record<string, string> = {
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
py: 'python',
json: 'json',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
less: 'less',
md: 'markdown',
markdown: 'markdown',
};
return languageMap[ext] || 'text';
}
export function CodeEditor({
tabs,
activeTabId,
onTabChange,
onTabClose,
onContentChange,
onSave,
className,
}: CodeEditorProps) {
const { resolvedTheme } = useTheme();
const [saving, setSaving] = useState<string | null>(null);
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId),
[tabs, activeTabId]
);
// 检查标签是否有未保存的更改
const hasUnsavedChanges = useCallback(
(tab: EditorTab) => tab.content !== tab.originalContent,
[]
);
// 保存文件
const handleSave = useCallback(
async (tab: EditorTab) => {
if (!hasUnsavedChanges(tab)) return;
setSaving(tab.id);
try {
const result = await writeFile(tab.path, tab.content);
if (result.success) {
onSave?.(tab.id, tab.path, tab.content);
toast.success(`Saved ${tab.name}`);
} else {
toast.error('Failed to save file');
}
} catch (error) {
console.error('Failed to save file:', error);
toast.error('Failed to save file');
} finally {
setSaving(null);
}
},
[hasUnsavedChanges, onSave]
);
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (activeTab && hasUnsavedChanges(activeTab)) {
handleSave(activeTab);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeTab, hasUnsavedChanges, handleSave]);
// 编辑器扩展
const extensions = useMemo(() => {
if (!activeTab) return [];
return [getLanguageExtension(activeTab.language)].flat();
}, [activeTab]);
if (tabs.length === 0) {
return (
<div className={cn('flex items-center justify-center h-full bg-surface-base text-fg-muted', className)}>
<p>No files open</p>
</div>
);
}
return (
<div className={cn('flex flex-col h-full bg-surface-base', className)}>
{/* Tab Bar */}
<div className="flex items-center border-b border-line bg-surface-subtle overflow-x-auto">
<AnimatePresence mode="popLayout">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const isModified = hasUnsavedChanges(tab);
const isSaving = saving === tab.id;
return (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
className={cn(
'flex items-center gap-1.5 px-3 py-2 border-r border-line cursor-pointer group min-w-0',
'hover:bg-surface-muted transition-colors',
isActive && 'bg-surface-base border-b-2 border-b-primary-500'
)}
onClick={() => onTabChange(tab.id)}
>
{/* 修改指示器 */}
{isModified && !isSaving && (
<Circle size={8} className="fill-orange-400 text-orange-400 flex-shrink-0" />
)}
{isSaving && (
<div className="w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin flex-shrink-0" />
)}
{/* 文件名 */}
<span
className={cn(
'text-sm truncate max-w-[150px]',
isActive ? 'text-fg' : 'text-fg-muted'
)}
title={tab.path}
>
{tab.name}
</span>
{/* 关闭按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
className={cn(
'p-0.5 rounded hover:bg-surface-emphasis transition-colors flex-shrink-0',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-100'
)}
>
<X size={14} className="text-fg-muted" />
</button>
</motion.div>
);
})}
</AnimatePresence>
{/* 保存按钮 */}
{activeTab && hasUnsavedChanges(activeTab) && (
<motion.button
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleSave(activeTab)}
disabled={saving === activeTab.id}
className="ml-auto mr-2 p-1.5 rounded hover:bg-surface-muted transition-colors"
title="Save (Cmd+S)"
>
<Save size={16} className="text-fg-muted" />
</motion.button>
)}
</div>
{/* Editor */}
<div className="flex-1 overflow-hidden">
{activeTab && (
<CodeMirror
value={activeTab.content}
height="100%"
theme={resolvedTheme === 'dark' ? oneDark : undefined}
extensions={extensions}
onChange={(value) => onContentChange(activeTab.id, value)}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
defaultKeymap: true,
searchKeymap: true,
historyKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
className="h-full text-sm"
/>
)}
</div>
</div>
);
}