import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, ChevronsDown, ChevronsUp } from 'lucide-react'; import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { MessageHub, FileActionRegistry } from '@esengine/editor-core'; import { Core } from '@esengine/ecs-framework'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; import { ConfirmDialog } from './ConfirmDialog'; import { PromptDialog } from './PromptDialog'; import '../styles/FileTree.css'; interface TreeNode { name: string; path: string; type: 'folder' | 'file'; size?: number; modified?: number; children?: TreeNode[]; expanded?: boolean; loaded?: boolean; } interface FileTreeProps { rootPath: string | null; onSelectFile?: (path: string) => void; selectedPath?: string | null; messageHub?: MessageHub; searchQuery?: string; showFiles?: boolean; } export interface FileTreeHandle { collapseAll: () => void; refresh: () => void; } export const FileTree = forwardRef(({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }, ref) => { const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); const [internalSelectedPath, setInternalSelectedPath] = useState(null); const [contextMenu, setContextMenu] = useState<{ position: { x: number; y: number }; node: TreeNode | null; } | null>(null); const [renamingNode, setRenamingNode] = useState(null); const [newName, setNewName] = useState(''); const [deleteDialog, setDeleteDialog] = useState<{ node: TreeNode } | null>(null); const [promptDialog, setPromptDialog] = useState<{ type: 'create-file' | 'create-folder' | 'create-template'; parentPath: string; templateExtension?: string; templateContent?: (fileName: string) => Promise; } | null>(null); const [filteredTree, setFilteredTree] = useState([]); const fileActionRegistry = Core.services.resolve(FileActionRegistry); const collapseAll = () => { const collapseNode = (node: TreeNode): TreeNode => { if (node.type === 'folder') { return { ...node, expanded: false, children: node.children ? node.children.map(collapseNode) : node.children }; } return node; }; const collapsedTree = tree.map(node => collapseNode(node)); setTree(collapsedTree); }; useImperativeHandle(ref, () => ({ collapseAll, refresh: refreshTree })); useEffect(() => { if (rootPath) { loadRootDirectory(rootPath); } else { setTree([]); } }, [rootPath]); useEffect(() => { if (selectedPath) { setInternalSelectedPath(selectedPath); } }, [selectedPath]); useEffect(() => { const performSearch = async () => { const filterByFileType = (nodes: TreeNode[]): TreeNode[] => { return nodes .filter(node => showFiles || node.type === 'folder') .map(node => ({ ...node, children: node.children ? filterByFileType(node.children) : node.children })); }; let result = filterByFileType(tree); if (searchQuery && searchQuery.trim()) { const query = searchQuery.toLowerCase(); const loadAndFilterTree = async (nodes: TreeNode[]): Promise => { const filtered: TreeNode[] = []; for (const node of nodes) { const nameMatches = node.name.toLowerCase().includes(query); let filteredChildren: TreeNode[] = []; if (node.type === 'folder') { let childrenToSearch = node.children || []; if (!node.loaded) { try { const entries = await TauriAPI.listDirectory(node.path); childrenToSearch = entriesToNodes(entries); } catch (error) { console.error('Failed to load children for search:', error); } } if (childrenToSearch.length > 0) { filteredChildren = await loadAndFilterTree(childrenToSearch); } } if (nameMatches || filteredChildren.length > 0) { filtered.push({ ...node, expanded: filteredChildren.length > 0, loaded: true, children: filteredChildren.length > 0 ? filteredChildren : (node.type === 'folder' ? [] : undefined) }); } } return filtered; }; result = await loadAndFilterTree(result); } setFilteredTree(result); }; performSearch(); }, [searchQuery, tree, showFiles]); const loadRootDirectory = async (path: string) => { setLoading(true); try { const entries = await TauriAPI.listDirectory(path); const children = entriesToNodes(entries); // 创建根节点 const rootName = path.split(/[/\\]/).filter((p) => p).pop() || 'Project'; const rootNode: TreeNode = { name: rootName, path: path, type: 'folder', children: children, expanded: true, loaded: true }; setTree([rootNode]); } catch (error) { console.error('Failed to load directory:', error); setTree([]); } finally { setLoading(false); } }; const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => { return entries .sort((a, b) => { if (a.is_dir === b.is_dir) return a.name.localeCompare(b.name); return a.is_dir ? -1 : 1; }) .map((entry) => ({ name: entry.name, path: entry.path, type: entry.is_dir ? 'folder' as const : 'file' as const, size: entry.size, modified: entry.modified, children: entry.is_dir ? [] : undefined, expanded: false, loaded: entry.is_dir ? false : undefined })); }; const loadChildren = async (node: TreeNode): Promise => { try { const entries = await TauriAPI.listDirectory(node.path); return entriesToNodes(entries); } catch (error) { console.error('Failed to load children:', error); return []; } }; const toggleNode = async (nodePath: string) => { const updateTree = async (nodes: TreeNode[]): Promise => { const newNodes: TreeNode[] = []; for (const node of nodes) { if (node.path === nodePath) { if (!node.loaded) { const children = await loadChildren(node); newNodes.push({ ...node, expanded: true, loaded: true, children }); } else { newNodes.push({ ...node, expanded: !node.expanded }); } } else if (node.children) { newNodes.push({ ...node, children: await updateTree(node.children) }); } else { newNodes.push(node); } } return newNodes; }; const newTree = await updateTree(tree); setTree(newTree); }; const refreshTree = async () => { if (!rootPath) return; // 保存当前展开状态 const expandedPaths = new Set(); const collectExpandedPaths = (nodes: TreeNode[]) => { for (const node of nodes) { if (node.type === 'folder' && node.expanded) { expandedPaths.add(node.path); if (node.children) { collectExpandedPaths(node.children); } } } }; collectExpandedPaths(tree); // 重新加载根目录,获取最新的文件结构 try { const entries = await TauriAPI.listDirectory(rootPath); const children = entriesToNodes(entries); const rootName = rootPath.split(/[/\\]/).filter((p) => p).pop() || 'Project'; let rootNode: TreeNode = { name: rootName, path: rootPath, type: 'folder', children: children, expanded: true, loaded: true }; // 恢复展开状态 if (expandedPaths.size > 0) { const restoreExpandedState = async (node: TreeNode): Promise => { if (node.type === 'folder' && expandedPaths.has(node.path)) { let children = node.children || []; if (!node.loaded && node.children) { children = await loadChildren(node); } const restoredChildren = await Promise.all( children.map(child => restoreExpandedState(child)) ); return { ...node, expanded: true, loaded: true, children: restoredChildren }; } else if (node.type === 'folder' && node.children) { const restoredChildren = await Promise.all( node.children.map(child => restoreExpandedState(child)) ); return { ...node, children: restoredChildren }; } return node; }; rootNode = await restoreExpandedState(rootNode); } setTree([rootNode]); } catch (error) { console.error('Failed to refresh directory:', error); } }; const expandAll = async () => { const expandNode = async (node: TreeNode): Promise => { if (node.type === 'folder') { let children = node.children || []; if (!node.loaded) { try { const entries = await TauriAPI.listDirectory(node.path); children = entriesToNodes(entries); } catch (error) { console.error('Failed to load children:', error); children = []; } } const expandedChildren = await Promise.all( children.map(child => expandNode(child)) ); return { ...node, expanded: true, loaded: true, children: expandedChildren }; } return node; }; const expandedTree = await Promise.all(tree.map(node => expandNode(node))); setTree(expandedTree); }; const handleRename = async (node: TreeNode) => { if (!newName || newName === node.name) { setRenamingNode(null); return; } const pathParts = node.path.split(/[/\\]/); pathParts[pathParts.length - 1] = newName; const newPath = pathParts.join('/'); try { await TauriAPI.renameFileOrFolder(node.path, newPath); await refreshTree(); setRenamingNode(null); setNewName(''); } catch (error) { console.error('Failed to rename:', error); alert(`重命名失败: ${error}`); } }; const handleDeleteClick = (node: TreeNode) => { setContextMenu(null); setDeleteDialog({ node }); }; const handleDeleteConfirm = async () => { if (!deleteDialog) return; const node = deleteDialog.node; setDeleteDialog(null); try { if (node.type === 'folder') { await TauriAPI.deleteFolder(node.path); } else { await TauriAPI.deleteFile(node.path); } await refreshTree(); } catch (error) { console.error('Failed to delete:', error); alert(`删除失败: ${error}`); } }; const handleCreateFileClick = (parentPath: string) => { setContextMenu(null); setPromptDialog({ type: 'create-file', parentPath }); }; const handleCreateFolderClick = (parentPath: string) => { setContextMenu(null); setPromptDialog({ type: 'create-folder', parentPath }); }; const handleCreateTemplateFileClick = (parentPath: string, template: any) => { setContextMenu(null); setPromptDialog({ type: 'create-template', parentPath, templateExtension: template.extension, templateContent: template.createContent }); }; const handlePromptConfirm = async (value: string) => { if (!promptDialog) return; const { type, parentPath, templateExtension, templateContent } = promptDialog; setPromptDialog(null); let fileName = value; let targetPath = `${parentPath}/${value}`; try { if (type === 'create-file') { await TauriAPI.createFile(targetPath); } else if (type === 'create-folder') { await TauriAPI.createDirectory(targetPath); } else if (type === 'create-template' && templateExtension && templateContent) { if (!fileName.endsWith(`.${templateExtension}`)) { fileName = `${fileName}.${templateExtension}`; targetPath = `${parentPath}/${fileName}`; } const content = await templateContent(fileName); await TauriAPI.writeFileContent(targetPath, content); } await refreshTree(); } catch (error) { console.error(`Failed to ${type}:`, error); alert(`${type === 'create-file' ? '创建文件' : type === 'create-folder' ? '创建文件夹' : '创建模板文件'}失败: ${error}`); } }; const getContextMenuItems = (node: TreeNode | null): ContextMenuItem[] => { if (!node) { const baseItems: ContextMenuItem[] = [ { label: '新建文件', icon: , onClick: () => rootPath && handleCreateFileClick(rootPath) }, { label: '新建文件夹', icon: , onClick: () => rootPath && handleCreateFolderClick(rootPath) } ]; if (fileActionRegistry && rootPath) { const templates = fileActionRegistry.getCreationTemplates(); if (templates.length > 0) { baseItems.push({ label: '', separator: true, onClick: () => {} }); for (const template of templates) { baseItems.push({ label: template.label, icon: template.icon, onClick: () => handleCreateTemplateFileClick(rootPath, template) }); } } } return baseItems; } const items: ContextMenuItem[] = []; if (node.type === 'file') { items.push({ label: '打开文件', icon: , onClick: async () => { try { await TauriAPI.openFileWithSystemApp(node.path); } catch (error) { console.error('Failed to open file:', error); } } }); if (fileActionRegistry) { const handlers = fileActionRegistry.getHandlersForFile(node.path); for (const handler of handlers) { if (handler.getContextMenuItems) { const parentPath = node.path.substring(0, node.path.lastIndexOf('/')); const pluginItems = handler.getContextMenuItems(node.path, parentPath); for (const pluginItem of pluginItems) { items.push({ label: pluginItem.label, icon: pluginItem.icon, onClick: () => pluginItem.onClick(node.path, parentPath), disabled: pluginItem.disabled, separator: pluginItem.separator }); } } } } } items.push({ label: '重命名', icon: , onClick: () => { setRenamingNode(node.path); setNewName(node.name); } }); items.push({ label: '删除', icon: , onClick: () => handleDeleteClick(node) }); items.push({ label: '', separator: true, onClick: () => {} }); if (node.type === 'folder') { items.push({ label: '新建文件', icon: , onClick: () => handleCreateFileClick(node.path) }); items.push({ label: '新建文件夹', icon: , onClick: () => handleCreateFolderClick(node.path) }); if (fileActionRegistry) { const templates = fileActionRegistry.getCreationTemplates(); if (templates.length > 0) { items.push({ label: '', separator: true, onClick: () => {} }); for (const template of templates) { items.push({ label: template.label, icon: template.icon, onClick: () => handleCreateTemplateFileClick(node.path, template) }); } } } items.push({ label: '', separator: true, onClick: () => {} }); } items.push({ label: '在文件管理器中显示', icon: , onClick: async () => { try { await TauriAPI.showInFolder(node.path); } catch (error) { console.error('Failed to show in folder:', error); } } }); items.push({ label: '复制路径', icon: , onClick: () => { navigator.clipboard.writeText(node.path); } }); return items; }; const handleNodeClick = (node: TreeNode) => { if (node.type === 'folder') { setInternalSelectedPath(node.path); onSelectFile?.(node.path); toggleNode(node.path); } else { setInternalSelectedPath(node.path); const extension = node.name.includes('.') ? node.name.split('.').pop() : undefined; messageHub?.publish('asset-file:selected', { fileInfo: { name: node.name, path: node.path, extension, size: node.size, modified: node.modified, isDirectory: false } }); } }; const handleNodeDoubleClick = async (node: TreeNode) => { if (node.type === 'file') { if (fileActionRegistry) { const handled = await fileActionRegistry.handleDoubleClick(node.path); if (handled) { return; } } try { await TauriAPI.openFileWithSystemApp(node.path); } catch (error) { console.error('Failed to open file:', error); } } }; const handleContextMenu = (e: React.MouseEvent, node: TreeNode | null) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ position: { x: e.clientX, y: e.clientY }, node }); }; const renderNode = (node: TreeNode, level: number = 0) => { const isSelected = (internalSelectedPath || selectedPath) === node.path; const isRenaming = renamingNode === node.path; const indent = level * 16; return (
!isRenaming && handleNodeClick(node)} onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)} onContextMenu={(e) => handleContextMenu(e, node)} draggable={node.type === 'file' && !isRenaming} onDragStart={(e) => { if (node.type === 'file' && !isRenaming) { e.dataTransfer.effectAllowed = 'copy'; // 设置拖拽的数据 e.dataTransfer.setData('asset-path', node.path); e.dataTransfer.setData('asset-name', node.name); const ext = node.name.includes('.') ? node.name.split('.').pop() : ''; e.dataTransfer.setData('asset-extension', ext || ''); e.dataTransfer.setData('text/plain', node.path); // 添加视觉反馈 e.currentTarget.style.opacity = '0.5'; } }} onDragEnd={(e) => { // 恢复透明度 e.currentTarget.style.opacity = '1'; }} > {node.type === 'folder' ? ( node.expanded ? : ) : ( )} {node.type === 'folder' ? ( node.name.toLowerCase() === 'plugins' || node.name.toLowerCase() === '.ecs' ? ( ) : ( ) ) : ( )} {isRenaming ? ( setNewName(e.target.value)} onBlur={() => handleRename(node)} onKeyDown={(e) => { if (e.key === 'Enter') { handleRename(node); } else if (e.key === 'Escape') { setRenamingNode(null); } }} autoFocus onClick={(e) => e.stopPropagation()} /> ) : ( {node.name} )}
{node.type === 'folder' && node.expanded && node.children && (
{node.children.map((child) => renderNode(child, level + 1))}
)}
); }; if (loading) { return
Loading...
; } if (!rootPath || tree.length === 0) { return
No folders
; } return ( <>
{ const target = e.target as HTMLElement; if (target.classList.contains('file-tree')) { handleContextMenu(e, null); } }} > {filteredTree.map((node) => renderNode(node))}
{contextMenu && ( setContextMenu(null)} /> )} {deleteDialog && ( setDeleteDialog(null)} /> )} {promptDialog && ( setPromptDialog(null)} /> )} ); });