Files
ai-terminal-assistant/packages/ui/src/components/FileExplorer.tsx
T
kurihada 4fc6b61134 feat(ui): 添加编辑器和文件浏览器状态持久化
- FileExplorer: 保存展开的目录路径到 localStorage
- IDE: 保存打开的标签页和活动标签,刷新后自动恢复
- App: 调整 IDE 面板默认宽度为 70%
2025-12-17 18:18:06 +08:00

260 lines
7.8 KiB
TypeScript

/**
* 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>
);
}
const STORAGE_KEY_EXPANDED = 'ai-assistant-file-explorer-expanded';
export function FileExplorer({ onFileSelect, className, workingDirectory }: FileExplorerProps) {
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 从 localStorage 恢复展开状态
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_EXPANDED);
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 加载文件树
const loadTree = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await getFileTree('.', 4);
if (result.success) {
setTree(result.data.tree);
// 保持已保存的展开状态(不重置)
} 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);
}
// 保存到 localStorage
localStorage.setItem(STORAGE_KEY_EXPANDED, JSON.stringify([...next]));
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>
);
}