feat(ui): 添加 IDE 组件(文件浏览器 + 代码编辑器)
- 新增 CodeEditor 组件,基于 CodeMirror 实现多标签代码编辑 - 新增 FileExplorer 组件,支持文件树展开/折叠和文件选择 - 新增 IDE 组件,整合文件浏览器和代码编辑器 - 新增 SessionPanel 组件,用于会话管理 - 添加文件写入 API(PUT /api/files/write) - 优化布局:IDE 始终显示,移除文件切换按钮 - 工作目录路径显示在文件浏览器标题栏,支持悬浮显示完整路径
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user