c6dd3695e5
- 添加 CodeMirror 编辑器全局样式(字体、行号、高亮、搜索面板等) - 添加 Diff 编辑器样式(删除/新增行高亮、行内变更等) - CodeEditor 优化:Tab 栏动画、文件图标着色、底部状态栏 - DiffEditor 优化:头部布局、变更统计、操作类型标签
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
/**
|
|
* 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<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]);
|
|
|
|
// 获取文件图标颜色
|
|
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 (
|
|
<div className={cn('flex flex-col items-center justify-center h-full bg-surface-base', className)}>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="text-center max-w-xs"
|
|
>
|
|
<div className="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-surface-subtle to-surface-muted flex items-center justify-center shadow-inner">
|
|
<Code2 size={36} className="text-fg-subtle" />
|
|
</div>
|
|
<h3 className="text-fg font-semibold text-lg mb-2">No files open</h3>
|
|
<p className="text-fg-muted text-sm mb-5 leading-relaxed">
|
|
Select a file from the explorer to view and edit its contents
|
|
</p>
|
|
<div className="flex items-center justify-center gap-2 text-xs text-fg-subtle bg-surface-subtle px-4 py-2 rounded-full">
|
|
<MousePointerClick size={14} />
|
|
<span>Click a file to open</span>
|
|
</div>
|
|
</motion.div>
|
|
</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/80 backdrop-blur-sm overflow-x-auto scrollbar-hide">
|
|
<AnimatePresence mode="popLayout">
|
|
{tabs.map((tab) => {
|
|
const isActive = tab.id === activeTabId;
|
|
const isModified = hasUnsavedChanges(tab);
|
|
const isSaving = saving === tab.id;
|
|
const iconColor = getFileIconColor(tab.language);
|
|
|
|
return (
|
|
<motion.div
|
|
key={tab.id}
|
|
layout
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -10, scale: 0.95 }}
|
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
|
className={cn(
|
|
'relative flex items-center gap-2 px-3.5 py-2.5 cursor-pointer group min-w-0',
|
|
'hover:bg-surface-muted/50 transition-all duration-150',
|
|
isActive
|
|
? 'bg-surface-base shadow-sm'
|
|
: 'border-r border-line/50'
|
|
)}
|
|
onClick={() => onTabChange(tab.id)}
|
|
>
|
|
{/* 活动标签顶部指示条 */}
|
|
{isActive && (
|
|
<motion.div
|
|
layoutId="activeTabIndicator"
|
|
className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-primary-500 to-primary-400"
|
|
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
|
/>
|
|
)}
|
|
|
|
{/* 文件图标 */}
|
|
<FileCode size={14} className={cn('flex-shrink-0', isActive ? iconColor : 'text-fg-subtle')} />
|
|
|
|
{/* 文件名 */}
|
|
<span
|
|
className={cn(
|
|
'text-sm truncate max-w-[140px] transition-colors',
|
|
isActive ? 'text-fg font-medium' : 'text-fg-muted'
|
|
)}
|
|
title={tab.path}
|
|
>
|
|
{tab.name}
|
|
</span>
|
|
|
|
{/* 修改指示器 / 保存中 / 关闭按钮 */}
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{isSaving ? (
|
|
<div className="w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
|
) : isModified ? (
|
|
<Circle size={8} className="fill-orange-400 text-orange-400" />
|
|
) : null}
|
|
|
|
{/* 关闭按钮 */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onTabClose(tab.id);
|
|
}}
|
|
className={cn(
|
|
'p-1 rounded-md hover:bg-surface-emphasis/80 transition-all duration-150 flex-shrink-0',
|
|
'opacity-0 group-hover:opacity-100',
|
|
isActive && 'opacity-60 hover:opacity-100'
|
|
)}
|
|
>
|
|
<X size={12} className="text-fg-muted" />
|
|
</button>
|
|
</div>
|
|
</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-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 size={14} className="text-primary-500" />
|
|
<span className="text-xs text-primary-500 font-medium">Save</span>
|
|
</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"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Bar */}
|
|
{activeTab && (
|
|
<div className="flex items-center justify-between px-4 py-1.5 border-t border-line bg-surface-subtle/60 text-xs">
|
|
<div className="flex items-center gap-4">
|
|
{/* 语言 */}
|
|
<span className="text-fg-muted capitalize">{activeTab.language}</span>
|
|
{/* 行数 */}
|
|
<span className="text-fg-subtle">
|
|
{activeTab.content.split('\n').length} lines
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
{/* 编码 */}
|
|
<span className="text-fg-subtle">UTF-8</span>
|
|
{/* 路径 */}
|
|
<span className="text-fg-subtle truncate max-w-[300px]" title={activeTab.path}>
|
|
{activeTab.path}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|