/** * Content Browser - 内容浏览器 * 用于浏览和管理项目资产 */ import { useState, useEffect, useRef, useCallback } from 'react'; import { Plus, Download, Save, ChevronRight, ChevronDown, Search, SlidersHorizontal, LayoutGrid, List, FolderClosed, FolderOpen, Folder, File, FileCode, FileJson, FileImage, FileText, Copy, Trash2, Edit3, ExternalLink, PanelRightClose } from 'lucide-react'; import { Core } from '@esengine/ecs-framework'; import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core'; import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; import { PromptDialog } from './PromptDialog'; import '../styles/ContentBrowser.css'; interface AssetItem { name: string; path: string; type: 'file' | 'folder'; extension?: string; size?: number; modified?: number; } interface FolderNode { name: string; path: string; children: FolderNode[]; isExpanded: boolean; } interface ContentBrowserProps { projectPath: string | null; locale?: string; onOpenScene?: (scenePath: string) => void; isDrawer?: boolean; onDockInLayout?: () => void; revealPath?: string | null; } // 获取资产类型显示名称 function getAssetTypeName(asset: AssetItem): string { if (asset.type === 'folder') return 'Folder'; // Check for compound extensions first const name = asset.name.toLowerCase(); if (name.endsWith('.tilemap.json') || name.endsWith('.tilemap')) return 'Tilemap'; if (name.endsWith('.tileset.json') || name.endsWith('.tileset')) return 'Tileset'; const ext = asset.extension?.toLowerCase(); switch (ext) { case 'ecs': return 'Scene'; case 'btree': return 'Behavior Tree'; case 'png': case 'jpg': case 'jpeg': case 'webp': return 'Texture'; case 'ts': case 'tsx': return 'TypeScript'; case 'js': case 'jsx': return 'JavaScript'; case 'json': return 'JSON'; case 'prefab': return 'Prefab'; case 'mat': return 'Material'; case 'anim': return 'Animation'; default: return ext?.toUpperCase() || 'File'; } } export function ContentBrowser({ projectPath, locale = 'en', onOpenScene, isDrawer = false, onDockInLayout, revealPath }: ContentBrowserProps) { const messageHub = Core.services.resolve(MessageHub); const fileActionRegistry = Core.services.resolve(FileActionRegistry); // State const [currentPath, setCurrentPath] = useState(null); const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [lastSelectedPath, setLastSelectedPath] = useState(null); const [assets, setAssets] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); // Folder tree state const [folderTree, setFolderTree] = useState(null); const [expandedFolders, setExpandedFolders] = useState>(new Set()); // Sections collapse state const [favoritesExpanded, setFavoritesExpanded] = useState(true); const [collectionsExpanded, setCollectionsExpanded] = useState(true); // Favorites (stored paths) const [favorites] = useState([]); // Dialog states const [contextMenu, setContextMenu] = useState<{ position: { x: number; y: number }; asset: AssetItem | null; isBackground?: boolean; } | null>(null); const [renameDialog, setRenameDialog] = useState<{ asset: AssetItem; newName: string; } | null>(null); const [deleteConfirmDialog, setDeleteConfirmDialog] = useState(null); const [createFileDialog, setCreateFileDialog] = useState<{ parentPath: string; template: FileCreationTemplate; } | null>(null); const t = { en: { favorites: 'Favorites', collections: 'Collections', add: 'Add', import: 'Import', saveAll: 'Save All', search: 'Search', items: 'items', dockInLayout: 'Dock in Layout', noProject: 'No project loaded', empty: 'This folder is empty', newFolder: 'New Folder' }, zh: { favorites: '收藏夹', collections: '收藏集', add: '添加', import: '导入', saveAll: '全部保存', search: '搜索', items: '项', dockInLayout: '停靠到布局', noProject: '未加载项目', empty: '文件夹为空', newFolder: '新建文件夹' } }[locale] || { favorites: 'Favorites', collections: 'Collections', add: 'Add', import: 'Import', saveAll: 'Save All', search: 'Search', items: 'items', dockInLayout: 'Dock in Layout', noProject: 'No project loaded', empty: 'This folder is empty', newFolder: 'New Folder' }; // Build folder tree - use ref to avoid dependency cycle const expandedFoldersRef = useRef(expandedFolders); expandedFoldersRef.current = expandedFolders; const buildFolderTree = useCallback(async (rootPath: string): Promise => { const currentExpanded = expandedFoldersRef.current; const buildNode = async (path: string, name: string): Promise => { const node: FolderNode = { name, path, children: [], isExpanded: currentExpanded.has(path) }; try { const entries = await TauriAPI.listDirectory(path); const folders = entries .filter((e: DirectoryEntry) => e.is_dir && !e.name.startsWith('.')) .sort((a: DirectoryEntry, b: DirectoryEntry) => a.name.localeCompare(b.name)); for (const folder of folders) { if (currentExpanded.has(path)) { node.children.push(await buildNode(folder.path, folder.name)); } else { node.children.push({ name: folder.name, path: folder.path, children: [], isExpanded: false }); } } } catch (error) { console.error('Failed to build folder tree:', error); } return node; }; return buildNode(rootPath, 'All'); }, []); // Load assets const loadAssets = useCallback(async (path: string) => { setLoading(true); try { const entries = await TauriAPI.listDirectory(path); const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => ({ name: entry.name, path: entry.path, type: entry.is_dir ? 'folder' as const : 'file' as const, extension: entry.is_dir ? undefined : entry.name.split('.').pop(), size: entry.size, modified: entry.modified })); setAssets(assetItems.sort((a, b) => { if (a.type === b.type) return a.name.localeCompare(b.name); return a.type === 'folder' ? -1 : 1; })); } catch (error) { console.error('Failed to load assets:', error); setAssets([]); } finally { setLoading(false); } }, []); // Initialize on mount useEffect(() => { if (projectPath) { setCurrentPath(projectPath); setExpandedFolders(new Set([projectPath])); loadAssets(projectPath); buildFolderTree(projectPath).then(setFolderTree); } // Only run on mount, not on every projectPath change }, []); // Handle projectPath change after initial mount const prevProjectPath = useRef(projectPath); useEffect(() => { if (projectPath && projectPath !== prevProjectPath.current) { prevProjectPath.current = projectPath; setCurrentPath(projectPath); setExpandedFolders(new Set([projectPath])); loadAssets(projectPath); buildFolderTree(projectPath).then(setFolderTree); } }, [projectPath, loadAssets, buildFolderTree]); // Rebuild tree when expanded folders change const expandedFoldersVersion = useRef(0); useEffect(() => { // Skip first render (handled by initialization) if (expandedFoldersVersion.current === 0) { expandedFoldersVersion.current = 1; return; } if (projectPath) { buildFolderTree(projectPath).then(setFolderTree); } }, [expandedFolders, projectPath, buildFolderTree]); // Handle reveal path - navigate to folder and select file const prevRevealPath = useRef(null); useEffect(() => { if (revealPath && revealPath !== prevRevealPath.current && projectPath) { prevRevealPath.current = revealPath; // Remove timestamp query if present const cleanPath = revealPath.split('?')[0] || revealPath; // Get full path const fullPath = cleanPath.startsWith('/') || cleanPath.includes(':') ? cleanPath : `${projectPath}/${cleanPath}`; // Get parent directory const pathParts = fullPath.replace(/\\/g, '/').split('/'); pathParts.pop(); // Remove filename const parentDir = pathParts.join('/'); // Expand all parent folders const foldersToExpand = new Set(); let currentFolder = parentDir; while (currentFolder && currentFolder.length >= (projectPath?.length || 0)) { foldersToExpand.add(currentFolder); const parts = currentFolder.split('/'); parts.pop(); currentFolder = parts.join('/'); } // Update expanded folders and navigate setExpandedFolders((prev) => { const next = new Set(prev); foldersToExpand.forEach((f) => next.add(f)); return next; }); // Navigate to parent folder and select the file setCurrentPath(parentDir); loadAssets(parentDir).then(() => { // Select the file after assets are loaded setSelectedPaths(new Set([fullPath])); setLastSelectedPath(fullPath); }); } }, [revealPath, projectPath, loadAssets]); // Handle folder selection in tree const handleFolderSelect = useCallback((path: string) => { setCurrentPath(path); loadAssets(path); }, [loadAssets]); // Toggle folder expansion const toggleFolderExpand = useCallback((path: string, e: React.MouseEvent) => { e.stopPropagation(); setExpandedFolders(prev => { const next = new Set(prev); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }, []); // Handle asset click const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => { if (e.shiftKey && lastSelectedPath) { const lastIndex = assets.findIndex(a => a.path === lastSelectedPath); const currentIndex = assets.findIndex(a => a.path === asset.path); if (lastIndex !== -1 && currentIndex !== -1) { const start = Math.min(lastIndex, currentIndex); const end = Math.max(lastIndex, currentIndex); const rangePaths = assets.slice(start, end + 1).map(a => a.path); setSelectedPaths(new Set(rangePaths)); } } else if (e.ctrlKey || e.metaKey) { const newSelected = new Set(selectedPaths); if (newSelected.has(asset.path)) { newSelected.delete(asset.path); } else { newSelected.add(asset.path); } setSelectedPaths(newSelected); setLastSelectedPath(asset.path); } else { setSelectedPaths(new Set([asset.path])); setLastSelectedPath(asset.path); } messageHub?.publish('asset-file:selected', { fileInfo: { name: asset.name, path: asset.path, extension: asset.extension, isDirectory: asset.type === 'folder' } }); }, [assets, lastSelectedPath, selectedPaths, messageHub]); // Handle asset double click const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => { if (asset.type === 'folder') { setCurrentPath(asset.path); loadAssets(asset.path); setExpandedFolders(prev => new Set([...prev, asset.path])); } else { const ext = asset.extension?.toLowerCase(); if (ext === 'ecs' && onOpenScene) { onOpenScene(asset.path); return; } if (fileActionRegistry) { const handled = await fileActionRegistry.handleDoubleClick(asset.path); if (handled) return; } try { await TauriAPI.openFileWithSystemApp(asset.path); } catch (error) { console.error('Failed to open file:', error); } } }, [loadAssets, onOpenScene, fileActionRegistry]); // Handle context menu const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => { e.preventDefault(); setContextMenu({ position: { x: e.clientX, y: e.clientY }, asset: asset || null, isBackground: !asset }); }, []); // Handle rename const handleRename = useCallback(async (asset: AssetItem, newName: string) => { if (!newName.trim() || newName === asset.name) { setRenameDialog(null); return; } try { const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\')); const parentPath = asset.path.substring(0, lastSlash); const newPath = `${parentPath}/${newName}`; await TauriAPI.renameFileOrFolder(asset.path, newPath); if (currentPath) { await loadAssets(currentPath); } setRenameDialog(null); } catch (error) { console.error('Failed to rename:', error); } }, [currentPath, loadAssets]); // Handle delete const handleDelete = useCallback(async (asset: AssetItem) => { try { if (asset.type === 'folder') { await TauriAPI.deleteFolder(asset.path); } else { await TauriAPI.deleteFile(asset.path); } if (currentPath) { await loadAssets(currentPath); } setDeleteConfirmDialog(null); } catch (error) { console.error('Failed to delete:', error); } }, [currentPath, loadAssets]); // Get breadcrumbs const getBreadcrumbs = useCallback(() => { if (!currentPath || !projectPath) return []; const relative = currentPath.replace(projectPath, ''); const parts = relative.split(/[/\\]/).filter(p => p); const crumbs = [{ name: 'All', path: projectPath }]; crumbs.push({ name: 'Content', path: projectPath }); let accPath = projectPath; for (const part of parts) { accPath = `${accPath}/${part}`; crumbs.push({ name: part, path: accPath }); } return crumbs; }, [currentPath, projectPath]); // Get file icon const getFileIcon = useCallback((asset: AssetItem, size: number = 48) => { if (asset.type === 'folder') { return ; } const ext = asset.extension?.toLowerCase(); switch (ext) { case 'ecs': return ; case 'btree': return ; case 'ts': case 'tsx': case 'js': case 'jsx': return ; case 'json': return ; case 'png': case 'jpg': case 'jpeg': case 'gif': case 'webp': return ; default: return ; } }, []); // Get context menu items const getContextMenuItems = useCallback((asset: AssetItem | null): ContextMenuItem[] => { const items: ContextMenuItem[] = []; if (!asset) { // Background context menu items.push({ label: t.newFolder, icon: , onClick: async () => { if (!currentPath) return; const folderName = `New Folder`; const folderPath = `${currentPath}/${folderName}`; try { await TauriAPI.createDirectory(folderPath); await loadAssets(currentPath); } catch (error) { console.error('Failed to create folder:', error); } } }); if (fileActionRegistry) { const templates = fileActionRegistry.getCreationTemplates(); if (templates.length > 0) { items.push({ label: '', separator: true, onClick: () => {} }); for (const template of templates) { items.push({ label: `New ${template.label}`, onClick: () => { setContextMenu(null); if (currentPath) { setCreateFileDialog({ parentPath: currentPath, template }); } } }); } } } return items; } // Asset context menu if (asset.type === 'file') { items.push({ label: locale === 'zh' ? '打开' : 'Open', icon: , onClick: () => handleAssetDoubleClick(asset) }); items.push({ label: '', separator: true, onClick: () => {} }); } items.push({ label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer', icon: , onClick: async () => { try { await TauriAPI.showInFolder(asset.path); } catch (error) { console.error('Failed to show in folder:', error); } } }); items.push({ label: locale === 'zh' ? '复制路径' : 'Copy Path', icon: , onClick: () => navigator.clipboard.writeText(asset.path) }); items.push({ label: '', separator: true, onClick: () => {} }); items.push({ label: locale === 'zh' ? '重命名' : 'Rename', icon: , onClick: () => { setRenameDialog({ asset, newName: asset.name }); setContextMenu(null); } }); items.push({ label: locale === 'zh' ? '删除' : 'Delete', icon: , onClick: () => { setDeleteConfirmDialog(asset); setContextMenu(null); } }); return items; }, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder]); // Render folder tree node const renderFolderNode = useCallback((node: FolderNode, depth: number = 0) => { const isSelected = currentPath === node.path; const isExpanded = expandedFolders.has(node.path); const hasChildren = node.children.length > 0; return (
handleFolderSelect(node.path)} > toggleFolderExpand(node.path, e)} > {hasChildren ? ( isExpanded ? : ) : ( )} {isExpanded ? : } {node.name}
{isExpanded && node.children.map(child => renderFolderNode(child, depth + 1))}
); }, [currentPath, expandedFolders, handleFolderSelect, toggleFolderExpand]); // Filter assets by search const filteredAssets = searchQuery.trim() ? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase())) : assets; const breadcrumbs = getBreadcrumbs(); if (!projectPath) { return (

{t.noProject}

); } return (
{/* Left Panel - Folder Tree */}
{/* Favorites Section */}
setFavoritesExpanded(!favoritesExpanded)} > {favoritesExpanded ? : } {t.favorites}
{favoritesExpanded && (
{favorites.length === 0 ? (
{/* Empty favorites */}
) : ( favorites.map(fav => (
{fav.split('/').pop()}
)) )}
)}
{/* Folder Tree */}
{folderTree && renderFolderNode(folderTree)}
{/* Collections Section */}
setCollectionsExpanded(!collectionsExpanded)} > {collectionsExpanded ? : } {t.collections}
{collectionsExpanded && (
{/* Collections list */}
)}
{/* Right Panel - Content Area */}
{/* Top Toolbar */}
{/* Breadcrumb Navigation */}
{breadcrumbs.map((crumb, index) => ( {index > 0 && } handleFolderSelect(crumb.path)} > {crumb.name} ))}
{isDrawer && onDockInLayout && ( )}
{/* Search Bar */}
setSearchQuery(e.target.value)} />
{/* Asset Grid */}
handleContextMenu(e)} > {loading ? (
Loading...
) : filteredAssets.length === 0 ? (
{t.empty}
) : ( filteredAssets.map(asset => (
handleAssetClick(asset, e)} onDoubleClick={() => handleAssetDoubleClick(asset)} onContextMenu={(e) => handleContextMenu(e, asset)} draggable={asset.type === 'file'} onDragStart={(e) => { if (asset.type === 'file') { e.dataTransfer.setData('asset-path', asset.path); e.dataTransfer.setData('text/plain', asset.path); } }} >
{getFileIcon(asset)}
{asset.name}
{getAssetTypeName(asset)}
)) )}
{/* Status Bar */}
{filteredAssets.length} {t.items}
{/* Context Menu */} {contextMenu && ( setContextMenu(null)} /> )} {/* Rename Dialog */} {renameDialog && (
setRenameDialog(null)}>
e.stopPropagation()}>

{locale === 'zh' ? '重命名' : 'Rename'}

setRenameDialog({ ...renameDialog, newName: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') handleRename(renameDialog.asset, renameDialog.newName); if (e.key === 'Escape') setRenameDialog(null); }} autoFocus />
)} {/* Delete Confirm Dialog */} {deleteConfirmDialog && (
setDeleteConfirmDialog(null)}>
e.stopPropagation()}>

{locale === 'zh' ? '确认删除' : 'Confirm Delete'}

{locale === 'zh' ? `确定要删除 "${deleteConfirmDialog.name}" 吗?` : `Delete "${deleteConfirmDialog.name}"?`}

)} {/* Create File Dialog */} {createFileDialog && ( { const { parentPath, template } = createFileDialog; setCreateFileDialog(null); let fileName = value; if (!fileName.endsWith(`.${template.extension}`)) { fileName = `${fileName}.${template.extension}`; } const filePath = `${parentPath}/${fileName}`; try { const content = await template.getContent(fileName); await TauriAPI.writeFileContent(filePath, content); if (currentPath) { await loadAssets(currentPath); } } catch (error) { console.error('Failed to create file:', error); } }} onCancel={() => setCreateFileDialog(null)} /> )}
); }