/** * 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, FileCode, MousePointerClick, Code2 } 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 = { 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(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]); // 获取文件图标颜色 const getFileIconColor = (language: string) => { switch (language) { case 'typescript': case 'tsx': return 'text-blue-500'; case 'javascript': case 'jsx': return 'text-yellow-500'; case 'python': return 'text-green-500'; case 'json': return 'text-orange-500'; case 'html': return 'text-red-500'; case 'css': case 'scss': case 'less': return 'text-purple-500'; case 'markdown': return 'text-cyan-500'; default: return 'text-fg-muted'; } }; if (tabs.length === 0) { return (

No files open

Select a file from the explorer to view and edit its contents

Click a file to open
); } return (
{/* Tab Bar */}
{tabs.map((tab) => { const isActive = tab.id === activeTabId; const isModified = hasUnsavedChanges(tab); const isSaving = saving === tab.id; const iconColor = getFileIconColor(tab.language); return ( onTabChange(tab.id)} > {/* 活动标签顶部指示条 */} {isActive && ( )} {/* 文件图标 */} {/* 文件名 */} {tab.name} {/* 修改指示器 / 保存中 / 关闭按钮 */}
{isSaving ? (
) : isModified ? ( ) : null} {/* 关闭按钮 */}
); })} {/* 保存按钮 */} {activeTab && hasUnsavedChanges(activeTab) && ( handleSave(activeTab)} disabled={saving === activeTab.id} className="ml-auto mr-3 px-3 py-1.5 rounded-md bg-primary-500/10 hover:bg-primary-500/20 transition-colors flex items-center gap-1.5" title="Save (Cmd+S)" > Save )}
{/* Editor */}
{activeTab && ( 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" /> )}
{/* Status Bar */} {activeTab && (
{/* 语言 */} {activeTab.language} {/* 行数 */} {activeTab.content.split('\n').length} lines
{/* 编码 */} UTF-8 {/* 路径 */} {activeTab.path}
)}
); }