feat(ui): 添加 IDE 组件(文件浏览器 + 代码编辑器)
- 新增 CodeEditor 组件,基于 CodeMirror 实现多标签代码编辑 - 新增 FileExplorer 组件,支持文件树展开/折叠和文件选择 - 新增 IDE 组件,整合文件浏览器和代码编辑器 - 新增 SessionPanel 组件,用于会话管理 - 添加文件写入 API(PUT /api/files/write) - 优化布局:IDE 始终显示,移除文件切换按钮 - 工作目录路径显示在文件浏览器标题栏,支持悬浮显示完整路径
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* FileExplorer Component
|
||||
*
|
||||
* 文件树组件,支持展开/折叠和文件选择
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ChevronRight, ChevronDown, File, Folder, FolderOpen, RefreshCw } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '../utils/cn.js';
|
||||
import { getFileTree, type FileTreeNode } from '../api/client.js';
|
||||
|
||||
interface FileExplorerProps {
|
||||
onFileSelect?: (path: string, name: string) => void;
|
||||
className?: string;
|
||||
/** 工作目录路径,显示在标题栏 */
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
// 文件图标颜色映射
|
||||
const fileIconColors: Record<string, string> = {
|
||||
ts: 'text-blue-400',
|
||||
tsx: 'text-blue-400',
|
||||
js: 'text-yellow-300',
|
||||
jsx: 'text-yellow-300',
|
||||
json: 'text-yellow-500',
|
||||
md: 'text-fg-muted',
|
||||
css: 'text-pink-400',
|
||||
scss: 'text-pink-400',
|
||||
html: 'text-orange-400',
|
||||
py: 'text-green-400',
|
||||
go: 'text-cyan-400',
|
||||
rs: 'text-orange-500',
|
||||
java: 'text-red-400',
|
||||
c: 'text-blue-500',
|
||||
cpp: 'text-blue-500',
|
||||
h: 'text-purple-400',
|
||||
};
|
||||
|
||||
function getFileIconColor(name: string): string {
|
||||
const ext = name.split('.').pop()?.toLowerCase() || '';
|
||||
return fileIconColors[ext] || 'text-fg-muted';
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: FileTreeNode;
|
||||
depth: number;
|
||||
onFileSelect?: (path: string, name: string) => void;
|
||||
expandedPaths: Set<string>;
|
||||
onToggleExpand: (path: string) => void;
|
||||
}
|
||||
|
||||
function TreeNode({ node, depth, onFileSelect, expandedPaths, onToggleExpand }: TreeNodeProps) {
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isDirectory = node.type === 'directory';
|
||||
const hasChildren = isDirectory && node.children && node.children.length > 0;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDirectory) {
|
||||
onToggleExpand(node.path);
|
||||
} else {
|
||||
onFileSelect?.(node.path, node.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn(
|
||||
'flex items-center gap-1 py-1 px-2 cursor-pointer rounded',
|
||||
'hover:bg-surface-muted transition-colors',
|
||||
'text-sm'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 展开/折叠图标 */}
|
||||
{isDirectory ? (
|
||||
<span className="w-4 h-4 flex items-center justify-center text-fg-muted">
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
|
||||
{/* 文件/文件夹图标 */}
|
||||
{isDirectory ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen size={16} className="text-yellow-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder size={16} className="text-yellow-400 flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<File size={16} className={cn('flex-shrink-0', getFileIconColor(node.name))} />
|
||||
)}
|
||||
|
||||
{/* 文件名 */}
|
||||
<span className="truncate text-fg">{node.name}</span>
|
||||
</motion.div>
|
||||
|
||||
{/* 子节点 */}
|
||||
<AnimatePresence>
|
||||
{isDirectory && isExpanded && node.children && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onFileSelect={onFileSelect}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileExplorer({ onFileSelect, className, workingDirectory }: FileExplorerProps) {
|
||||
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
|
||||
// 加载文件树
|
||||
const loadTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getFileTree('.', 4);
|
||||
if (result.success) {
|
||||
setTree(result.data.tree);
|
||||
// 默认展开第一层
|
||||
const firstLevel = new Set(
|
||||
result.data.tree
|
||||
.filter((n) => n.type === 'directory')
|
||||
.map((n) => n.path)
|
||||
);
|
||||
setExpandedPaths(firstLevel);
|
||||
} else {
|
||||
setError('Failed to load file tree');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load file tree');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
}, [loadTree]);
|
||||
|
||||
const handleToggleExpand = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center h-full', className)}>
|
||||
<div className="w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center h-full gap-3 p-4', className)}>
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
<button
|
||||
onClick={loadTree}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-surface-muted hover:bg-surface-emphasis rounded text-sm text-fg-secondary transition-colors"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-surface-subtle', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 group relative">
|
||||
<Folder size={14} className="flex-shrink-0 text-fg-muted" />
|
||||
<span className="text-sm font-mono text-fg-muted truncate">
|
||||
{workingDirectory || 'Files'}
|
||||
</span>
|
||||
{/* Tooltip - 悬浮时显示完整路径 */}
|
||||
{workingDirectory && (
|
||||
<div className="absolute left-0 top-full mt-1 z-50 hidden group-hover:block">
|
||||
<div className="bg-surface-emphasis border border-line rounded-md shadow-lg px-3 py-2 max-w-[400px]">
|
||||
<p className="text-xs font-mono text-fg break-all whitespace-pre-wrap">
|
||||
{workingDirectory}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadTree}
|
||||
className="p-1 hover:bg-surface-muted rounded transition-colors flex-shrink-0"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className="text-fg-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{tree.length === 0 ? (
|
||||
<p className="text-fg-muted text-sm text-center py-4">No files found</p>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
onFileSelect={onFileSelect}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user