/** * Content Browser - 内容浏览器 * 用于浏览和管理项目资产 */ import { useState, useEffect, useRef, useCallback } from 'react'; import * as LucideIcons from 'lucide-react'; import { Plus, Download, Save, ChevronRight, ChevronDown, Search, SlidersHorizontal, LayoutGrid, List, FolderClosed, FolderOpen, Folder, File, FileCode, FileJson, FileImage, FileText, Copy, Trash2, Edit3, ExternalLink, PanelRightClose, Tag, Link, FileSearch, Globe, Package, Clipboard, RefreshCw, Settings, Database, AlertTriangle } from 'lucide-react'; import { Core } from '@esengine/ecs-framework'; import { MessageHub, FileActionRegistry, AssetRegistryService, type FileCreationTemplate } from '@esengine/editor-core'; import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { SettingsService } from '../services/SettingsService'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; import { PromptDialog } from './PromptDialog'; import '../styles/ContentBrowser.css'; /** * Directories managed by asset registry (GUID system) * 被资产注册表(GUID 系统)管理的目录 * * Note: This is duplicated from AssetRegistryService to avoid build dependency issues. * Keep in sync with MANAGED_ASSET_DIRECTORIES in AssetRegistryService.ts */ const MANAGED_ASSET_DIRECTORIES = ['assets', 'scripts', 'scenes'] as const; 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; } /** * 根据图标名获取 Lucide 图标组件 */ function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode { if (!iconName) return ; const icons = LucideIcons as unknown as Record>; const IconComponent = icons[iconName]; if (IconComponent) { return ; } return ; } /** * Check if path is within a managed asset directory * 检查路径是否在被管理的资产目录中 */ function isPathInManagedDirectory(path: string, projectPath: string | null): boolean { if (!projectPath || !path) return false; const normalizedPath = path.replace(/\\/g, '/'); const normalizedProject = projectPath.replace(/\\/g, '/'); for (const dir of MANAGED_ASSET_DIRECTORIES) { const managedAbsPath = `${normalizedProject}/${dir}`; if (normalizedPath.startsWith(`${managedAbsPath}/`) || normalizedPath === managedAbsPath) { return true; } } return false; } /** * Check if folder is a root managed directory (assets, scripts, scenes) * 检查文件夹是否是根级被管理目录 */ function isRootManagedDirectory(folderPath: string, projectPath: string | null): boolean { if (!projectPath || !folderPath) return false; const normalizedPath = folderPath.replace(/\\/g, '/'); const normalizedProject = projectPath.replace(/\\/g, '/'); for (const dir of MANAGED_ASSET_DIRECTORIES) { const managedAbsPath = `${normalizedProject}/${dir}`; if (normalizedPath === managedAbsPath) { return true; } } return false; } // 获取资产类型显示名称 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); // Refs const containerRef = useRef(null); // 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); // Folder tree context menu (separate from asset context menu) const [folderTreeContextMenu, setFolderTreeContextMenu] = useState<{ position: { x: number; y: number }; items: ContextMenuItem[]; } | 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); // 文件创建模板列表(需要状态跟踪以便插件安装后刷新) // File creation templates list (need state tracking to refresh after plugin installation) const [fileCreationTemplates, setFileCreationTemplates] = useState([]); // Drag and drop state for file moving const [dragOverFolder, setDragOverFolder] = useState(null); // 初始化和监听插件安装事件以更新模板列表 // Initialize and listen for plugin installation events to update template list useEffect(() => { const updateTemplates = () => { if (fileActionRegistry) { const templates = fileActionRegistry.getCreationTemplates(); setFileCreationTemplates([...templates]); } }; // 初始加载 updateTemplates(); // 监听插件安装/卸载事件 if (messageHub) { const unsubInstall = messageHub.subscribe('plugin:installed', updateTemplates); const unsubUninstall = messageHub.subscribe('plugin:uninstalled', updateTemplates); return () => { unsubInstall(); unsubUninstall(); }; } }, [fileActionRegistry, messageHub]); 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', newPrefix: 'New', managedDirectoryTooltip: 'GUID-managed directory - Assets here get unique IDs for references', unmanagedWarning: 'This folder is not managed by GUID system. Assets created here cannot be referenced by GUID.', unmanagedWarningTitle: 'Unmanaged Directory', rename: 'Rename', delete: 'Delete', openInExplorer: 'Show in Explorer', copyPath: 'Copy Path', newSubfolder: 'New Subfolder', deleteConfirmTitle: 'Confirm Delete', deleteConfirmMessage: 'Are you sure you want to delete', cannotDeleteRoot: 'Cannot delete root directory' }, zh: { favorites: '收藏夹', collections: '收藏集', add: '添加', import: '导入', saveAll: '全部保存', search: '搜索', items: '项', dockInLayout: '停靠到布局', noProject: '未加载项目', empty: '文件夹为空', newFolder: '新建文件夹', newPrefix: '新建', managedDirectoryTooltip: 'GUID 管理的目录 - 此处的资产会获得唯一 ID 以便引用', unmanagedWarning: '此文件夹不受 GUID 系统管理。在此创建的资产无法通过 GUID 引用。', unmanagedWarningTitle: '非托管目录', rename: '重命名', delete: '删除', openInExplorer: '在资源管理器中显示', copyPath: '复制路径', newSubfolder: '新建子文件夹', deleteConfirmTitle: '确认删除', deleteConfirmMessage: '确定要删除', cannotDeleteRoot: '无法删除根目录' } }[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', newPrefix: 'New', managedDirectoryTooltip: 'GUID-managed directory - Assets here get unique IDs for references', unmanagedWarning: 'This folder is not managed by GUID system. Assets created here cannot be referenced by GUID.', unmanagedWarningTitle: 'Unmanaged Directory', rename: 'Rename', delete: 'Delete', openInExplorer: 'Show in Explorer', copyPath: 'Copy Path', newSubfolder: 'New Subfolder', deleteConfirmTitle: 'Confirm Delete', deleteConfirmMessage: 'Are you sure you want to delete', cannotDeleteRoot: 'Cannot delete root directory' }; // 文件创建模板的 label 本地化映射 const templateLabels: Record = { 'Material': { en: 'Material', zh: '材质' }, 'Shader': { en: 'Shader', zh: '着色器' }, 'Tilemap': { en: 'Tilemap', zh: '瓦片地图' }, 'Tileset': { en: 'Tileset', zh: '瓦片集' }, 'Component': { en: 'Component', zh: '组件' }, 'System': { en: 'System', zh: '系统' }, 'TypeScript': { en: 'TypeScript', zh: 'TypeScript' }, }; // 注册内置的 TypeScript 文件创建模板 // Register built-in TypeScript file creation templates useEffect(() => { if (!fileActionRegistry) return; const builtinTemplates: FileCreationTemplate[] = [ { id: 'ts-component', label: 'Component', extension: '.ts', icon: 'FileCode', category: 'Script', getContent: (fileName: string) => { const className = fileName.replace(/\.ts$/, ''); return `import { Component, ECSComponent, Property, Serialize, Serializable } from '@esengine/ecs-framework'; /** * ${className} */ @ECSComponent('${className}') @Serializable({ version: 1, typeId: '${className}' }) export class ${className} extends Component { // 在这里添加组件属性 // Add component properties here @Serialize() @Property({ type: 'number', label: 'Example Property' }) public exampleProperty: number = 0; /** * 组件添加到实体时调用 * Called when component is added to entity */ onAddedToEntity(): void { console.log('${className} added to entity'); } /** * 组件从实体移除时调用 * Called when component is removed from entity */ onRemovedFromEntity(): void { console.log('${className} removed from entity'); } } `; } }, { id: 'ts-system', label: 'System', extension: '.ts', icon: 'FileCode', category: 'Script', getContent: (fileName: string) => { const className = fileName.replace(/\.ts$/, ''); return `import { EntitySystem, Matcher, ECSSystem, type Entity } from '@esengine/ecs-framework'; /** * ${className} */ @ECSSystem('${className}') export class ${className} extends EntitySystem { constructor() { // 定义系统处理的组件类型 | Define component types this system processes // super(Matcher.all(SomeComponent)); super(Matcher.empty()); } protected updateEntity(entity: Entity, deltaTime: number): void { // 处理每个实体 | Process each entity } } `; } }, { id: 'ts-script', label: 'TypeScript', extension: '.ts', icon: 'FileCode', category: 'Script', getContent: (fileName: string) => { const name = fileName.replace(/\.ts$/, ''); return `/** * ${name} */ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void { // 在这里编写代码 // Write your code here } `; } }, { id: 'ts-inspector', label: 'Inspector', extension: '.ts', icon: 'FileCode', category: 'Editor', getContent: (fileName: string) => { const className = fileName.replace(/\.ts$/, ''); return `import React from 'react'; import type { Component } from '@esengine/ecs-framework'; import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core'; /** * ${className} * * 自定义组件检查器 | Custom component inspector * 放置在 scripts/editor/ 目录下 | Place in scripts/editor/ directory */ export class ${className} implements IComponentInspector { readonly id = '${className.toLowerCase()}'; readonly name = '${className}'; readonly priority = 10; // 目标组件类型名称 | Target component type names readonly targetComponents = ['YourComponent']; canHandle(component: Component): boolean { return this.targetComponents.includes(component.constructor.name); } render(context: ComponentInspectorContext): React.ReactElement { const { component } = context; return React.createElement('div', { className: 'custom-inspector' }, React.createElement('h4', null, '${className}'), React.createElement('pre', null, JSON.stringify(component, null, 2)) ); } } `; } }, { id: 'ts-gizmo', label: 'Gizmo', extension: '.ts', icon: 'FileCode', category: 'Editor', getContent: (fileName: string) => { const className = fileName.replace(/\.ts$/, ''); return `import type { Component, Entity } from '@esengine/ecs-framework'; import type { IGizmoRenderData } from '@esengine/editor-core'; /** * ${className} * * 自定义 Gizmo 提供者 | Custom Gizmo provider * 放置在 scripts/editor/ 目录下 | Place in scripts/editor/ directory */ export class ${className} { // 目标组件类型 | Target component type // 需要替换为实际的组件类 | Replace with actual component class readonly targetComponent = null; // YourComponent draw(component: Component, entity: Entity, isSelected: boolean): IGizmoRenderData[] { // 返回要绘制的 Gizmo 数据 | Return gizmo data to draw return [ { type: 'circle', x: 0, y: 0, radius: 10, strokeColor: isSelected ? '#00ff00' : '#ffffff', strokeWidth: 2 } ]; } } `; } } ]; // 注册模板 for (const template of builtinTemplates) { fileActionRegistry.registerCreationTemplate(template); } // 清理函数 return () => { for (const template of builtinTemplates) { fileActionRegistry.unregisterCreationTemplate(template); } }; }, [fileActionRegistry]); // 键盘快捷键处理 | Keyboard shortcuts handling useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // 如果正在输入或有对话框打开,不处理快捷键 // Skip shortcuts if typing or dialog is open if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || renameDialog || deleteConfirmDialog || createFileDialog ) { return; } // 只在内容浏览器区域处理快捷键 // Only handle shortcuts when content browser has focus if (!containerRef.current?.contains(document.activeElement) && document.activeElement !== containerRef.current) { return; } // F2 - 重命名 | Rename if (e.key === 'F2' && selectedPaths.size === 1) { e.preventDefault(); const selectedPath = Array.from(selectedPaths)[0]; const asset = assets.find(a => a.path === selectedPath); if (asset) { setRenameDialog({ asset, newName: asset.name }); } } // Delete - 删除 | Delete if (e.key === 'Delete' && selectedPaths.size === 1) { e.preventDefault(); const selectedPath = Array.from(selectedPaths)[0]; const asset = assets.find(a => a.path === selectedPath); if (asset) { setDeleteConfirmDialog(asset); } } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [selectedPaths, assets, renameDialog, deleteConfirmDialog, createFileDialog]); const getTemplateLabel = (label: string): string => { const mapping = templateLabels[label]; if (mapping) { return locale === 'zh' ? mapping.zh : mapping.en; } return label; }; // 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); } }, []); /** * Refresh both assets view and folder tree * 同时刷新资产视图和文件夹树 * * Call this after any file system modification (create, delete, rename, move) * to keep both the right panel (assets) and left panel (folder tree) in sync. */ const refreshAll = useCallback(async () => { if (currentPath) { await loadAssets(currentPath); } if (projectPath) { buildFolderTree(projectPath).then(setFolderTree); } }, [currentPath, projectPath, loadAssets, buildFolderTree]); // 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 moving file/folder to a new location const handleMoveAsset = useCallback(async (sourcePath: string, targetFolderPath: string) => { if (!sourcePath || !targetFolderPath) return; // Get file name from source path const fileName = sourcePath.split(/[\\/]/).pop(); if (!fileName) return; // Build destination path const sep = targetFolderPath.includes('\\') ? '\\' : '/'; const destPath = `${targetFolderPath}${sep}${fileName}`; // Don't move to same location - normalize paths for comparison const normalizedSource = sourcePath.replace(/\\/g, '/'); const normalizedTarget = targetFolderPath.replace(/\\/g, '/'); const sourceFolder = normalizedSource.substring(0, normalizedSource.lastIndexOf('/')); if (sourceFolder === normalizedTarget) return; // Don't move folder into itself if (normalizedTarget.startsWith(normalizedSource + '/')) { console.warn('Cannot move folder into itself'); return; } // Check if source is a file by looking in the current assets list // 通过查看当前资产列表来检查源是否是文件 const sourceAsset = assets.find(a => a.path === sourcePath); const isFile = sourceAsset ? sourceAsset.type === 'file' : !fileName.includes('.') === false; const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; try { // Check if file has a .meta file // 检查文件是否有 .meta 文件 const metaPath = `${sourcePath}.meta`; const destMetaPath = `${destPath}.meta`; let hasMetaFile = false; try { hasMetaFile = await TauriAPI.pathExists(metaPath); } catch { // Ignore } // Move the file/folder first // 首先移动文件/文件夹 await TauriAPI.renameFileOrFolder(sourcePath, destPath); // Move .meta file if exists // 如果存在则移动 .meta 文件 if (hasMetaFile) { try { await TauriAPI.renameFileOrFolder(metaPath, destMetaPath); } catch { // Meta file might have been moved already or failed } } // For files: Update asset registry // 对于文件:更新资产注册表 if (isFile && assetRegistry) { // Update metaManager's internal cache (path mapping) // 更新 metaManager 的内部缓存(路径映射) if (hasMetaFile) { // The meta file was moved, now update the in-memory cache // meta 文件已移动,现在更新内存缓存 try { // Clear old path from cache and re-register at new path // 从缓存中清除旧路径并在新路径重新注册 await assetRegistry.unregisterAsset(sourcePath); await assetRegistry.registerAsset(destPath); } catch (e) { console.warn('Failed to update asset registry after move:', e); } } else { // No meta file - check if destination is managed, generate .meta if so // 没有 meta 文件 - 检查目标是否在被管理的目录中,如果是则生成 .meta const isDestManaged = isPathInManagedDirectory(destPath, projectPath); if (isDestManaged) { // Register asset at new location - generates .meta if needed // 在新位置注册资产 - 如果需要会生成 .meta await assetRegistry.registerAsset(destPath); } } } // Refresh current view if (currentPath) { await loadAssets(currentPath); } // Refresh folder tree if (projectPath) { buildFolderTree(projectPath).then(setFolderTree); } console.log(`Moved ${sourcePath} to ${destPath}`); } catch (error) { console.error('Failed to move file:', error); } }, [currentPath, projectPath, loadAssets, buildFolderTree, assets]); // Folder drag handlers const handleFolderDragOver = useCallback((e: React.DragEvent, folderPath: string) => { e.preventDefault(); e.stopPropagation(); setDragOverFolder(folderPath); }, []); const handleFolderDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOverFolder(null); }, []); const handleFolderDrop = useCallback(async (e: React.DragEvent, targetFolderPath: string) => { e.preventDefault(); e.stopPropagation(); setDragOverFolder(null); const sourcePath = e.dataTransfer.getData('asset-path'); if (sourcePath) { await handleMoveAsset(sourcePath, targetFolderPath); } }, [handleMoveAsset]); // Handle asset click const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => { // 聚焦容器以启用键盘快捷键 | Focus container to enable keyboard shortcuts containerRef.current?.focus(); 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; } // 脚本文件使用配置的编辑器打开 // Open script files with configured editor if (ext === 'ts' || ext === 'tsx' || ext === 'js' || ext === 'jsx') { const settings = SettingsService.getInstance(); const editorCommand = settings.getScriptEditorCommand(); if (editorCommand) { // 使用项目路径,如果没有则使用文件所在目录 // Use project path, or file's parent directory if not available const workingDir = projectPath || asset.path.substring(0, asset.path.lastIndexOf('\\')) || asset.path.substring(0, asset.path.lastIndexOf('/')); try { await TauriAPI.openWithEditor(workingDir, editorCommand, asset.path); return; } catch (error) { console.error('Failed to open with editor:', error); // 如果失败,回退到系统默认应用 // Fall back to system default app if failed } } } 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, projectPath]); // 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}`; // Update AssetMetaManager to preserve GUID | 更新 AssetMetaManager 以保持 GUID 不变 const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; if (assetRegistry && asset.type !== 'folder') { await assetRegistry.metaManager.handleAssetRename(asset.path, newPath); } await TauriAPI.renameFileOrFolder(asset.path, newPath); // Refresh asset registry | 刷新资产注册表 if (assetRegistry && asset.type !== 'folder') { await assetRegistry.refreshAsset(newPath); } // Refresh both assets view and folder tree await refreshAll(); setRenameDialog(null); } catch (error) { console.error('Failed to rename:', error); } }, [refreshAll]); // Handle delete const handleDelete = useCallback(async (asset: AssetItem) => { try { const deletedPath = asset.path; if (asset.type === 'folder') { await TauriAPI.deleteFolder(asset.path); // Also delete folder meta file if exists | 同时删除文件夹的 meta 文件 try { await TauriAPI.deleteFile(`${asset.path}.meta`); } catch { // Meta file may not exist, ignore | meta 文件可能不存在,忽略 } } else { await TauriAPI.deleteFile(asset.path); // Also delete corresponding meta file if exists | 同时删除对应的 meta 文件 try { await TauriAPI.deleteFile(`${asset.path}.meta`); } catch { // Meta file may not exist, ignore | meta 文件可能不存在,忽略 } } // Refresh both assets view and folder tree await refreshAll(); // Notify that a file was deleted | 通知文件已删除 messageHub?.publish('file:deleted', { path: deletedPath }); setDeleteConfirmDialog(null); } catch (error) { console.error('Failed to delete:', error); } }, [refreshAll, messageHub]); // 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[] = []; const isCurrentPathManaged = isPathInManagedDirectory(currentPath || '', projectPath); if (!asset) { // Show warning header if current path is not managed if (!isCurrentPathManaged && currentPath) { items.push({ label: t.unmanagedWarningTitle, icon: , disabled: true, onClick: () => {} }); items.push({ label: '', separator: true, onClick: () => {} }); } items.push({ label: t.newFolder, icon: , onClick: async () => { if (!currentPath) return; const folderName = `New Folder`; const folderPath = `${currentPath}/${folderName}`; try { await TauriAPI.createDirectory(folderPath); // Refresh both assets view and folder tree await refreshAll(); } catch (error) { console.error('Failed to create folder:', error); } } }); if (fileCreationTemplates.length > 0) { items.push({ label: '', separator: true, onClick: () => {} }); for (const template of fileCreationTemplates) { const localizedLabel = getTemplateLabel(template.label); // Add warning indicator for unmanaged directories const warningIcon = !isCurrentPathManaged ? ( {getIconComponent(template.icon, 16)} ) : getIconComponent(template.icon, 16); items.push({ label: localizedLabel, icon: warningIcon, onClick: () => { setContextMenu(null); if (currentPath) { setCreateFileDialog({ parentPath: currentPath, template }); } } }); } } items.push({ label: '', separator: true, onClick: () => {} }); items.push({ label: locale === 'zh' ? '在资源管理器中显示' : 'Show in Explorer', icon: , onClick: async () => { if (currentPath) { try { await TauriAPI.showInFolder(currentPath); } catch (error) { console.error('Failed to show in folder:', error); } } setContextMenu(null); } }); items.push({ label: locale === 'zh' ? '刷新' : 'Refresh', icon: , onClick: async () => { if (currentPath) { await loadAssets(currentPath); } setContextMenu(null); } }); 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' ? '保存' : 'Save', icon: , shortcut: 'Ctrl+S', onClick: () => { console.log('Save file:', asset.path); } }); } // 重命名 items.push({ label: locale === 'zh' ? '重命名' : 'Rename', icon: , shortcut: 'F2', onClick: () => { setRenameDialog({ asset, newName: asset.name }); setContextMenu(null); } }); // 批量重命名 items.push({ label: locale === 'zh' ? '批量重命名' : 'Batch Rename', icon: , shortcut: 'Shift+F2', disabled: true, onClick: () => { console.log('Batch rename'); } }); // 复制 items.push({ label: locale === 'zh' ? '复制' : 'Duplicate', icon: , shortcut: 'Ctrl+D', onClick: () => { console.log('Duplicate:', asset.path); } }); // 删除 items.push({ label: locale === 'zh' ? '删除' : 'Delete', icon: , shortcut: 'Delete', onClick: () => { setDeleteConfirmDialog(asset); setContextMenu(null); } }); items.push({ label: '', separator: true, onClick: () => {} }); // 资产操作子菜单 items.push({ label: locale === 'zh' ? '资产操作' : 'Asset Actions', icon: , onClick: () => {}, children: [ { label: locale === 'zh' ? '重新导入' : 'Reimport', icon: , onClick: () => { console.log('Reimport asset:', asset.path); } }, { label: locale === 'zh' ? '导出...' : 'Export...', icon: , onClick: () => { console.log('Export asset:', asset.path); } }, { label: '', separator: true, onClick: () => {} }, { label: locale === 'zh' ? '迁移资产' : 'Migrate Asset', icon: , onClick: () => { console.log('Migrate asset:', asset.path); } } ] }); // 资产本地化子菜单 items.push({ label: locale === 'zh' ? '资产本地化' : 'Asset Localization', icon: , onClick: () => {}, children: [ { label: locale === 'zh' ? '创建本地化资产' : 'Create Localized Asset', onClick: () => { console.log('Create localized asset:', asset.path); } }, { label: locale === 'zh' ? '导入翻译' : 'Import Translation', onClick: () => { console.log('Import translation:', asset.path); } }, { label: locale === 'zh' ? '导出翻译' : 'Export Translation', onClick: () => { console.log('Export translation:', asset.path); } } ] }); items.push({ label: '', separator: true, onClick: () => {} }); // 标签管理 items.push({ label: locale === 'zh' ? '管理标签' : 'Manage Tags', icon: , shortcut: 'Ctrl+T', onClick: () => { console.log('Manage tags:', asset.path); } }); items.push({ label: '', separator: true, onClick: () => {} }); // 路径复制选项 items.push({ label: locale === 'zh' ? '复制引用' : 'Copy Reference', icon: , shortcut: 'Ctrl+C', onClick: () => { navigator.clipboard.writeText(asset.path); } }); items.push({ label: locale === 'zh' ? '拷贝Object路径' : 'Copy Object Path', icon: , shortcut: 'Ctrl+Shift+C', onClick: () => { const objectPath = asset.path.replace(/\\/g, '/'); navigator.clipboard.writeText(objectPath); } }); items.push({ label: locale === 'zh' ? '拷贝包路径' : 'Copy Package Path', icon: , shortcut: 'Ctrl+Alt+C', onClick: () => { const packagePath = '/' + asset.path.replace(/\\/g, '/').split('/').slice(-2).join('/'); navigator.clipboard.writeText(packagePath); } }); items.push({ label: '', separator: true, onClick: () => {} }); // 引用查看器 items.push({ label: locale === 'zh' ? '引用查看器' : 'Reference Viewer', icon: , shortcut: 'Alt+Shift+R', onClick: () => { console.log('Open reference viewer:', asset.path); } }); items.push({ label: locale === 'zh' ? '尺寸信息图' : 'Size Map', icon: , shortcut: 'Alt+Shift+D', onClick: () => { console.log('Show size map:', asset.path); } }); items.push({ label: '', separator: true, onClick: () => {} }); // 在文件管理器中显示 items.push({ label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer', icon: , onClick: async () => { try { console.log('[ContentBrowser] showInFolder path:', asset.path); await TauriAPI.showInFolder(asset.path); } catch (error) { console.error('Failed to show in folder:', error, 'Path:', asset.path); } } }); return items; }, [currentPath, fileCreationTemplates, handleAssetDoubleClick, loadAssets, locale, t.newFolder, t.newPrefix, t.unmanagedWarningTitle, setRenameDialog, setDeleteConfirmDialog, setContextMenu, setCreateFileDialog, projectPath]); /** * Handle folder tree context menu * 处理文件夹树右键菜单 */ const handleFolderTreeContextMenu = useCallback((e: React.MouseEvent, node: FolderNode) => { e.preventDefault(); e.stopPropagation(); const isRoot = node.path === projectPath; const folderName = node.name === 'All' ? (projectPath?.split(/[/\\]/).pop() || 'Project') : node.name; const items: ContextMenuItem[] = []; // New subfolder items.push({ label: t.newSubfolder, icon: , onClick: async () => { const folderPath = `${node.path}/New Folder`; try { await TauriAPI.createDirectory(folderPath); // Expand the parent folder to show the new subfolder setExpandedFolders(prev => new Set([...prev, node.path])); await refreshAll(); } catch (error) { console.error('Failed to create subfolder:', error); } } }); items.push({ label: '', separator: true, onClick: () => {} }); // Rename (not for root) if (!isRoot) { items.push({ label: t.rename, icon: , onClick: () => { setRenameDialog({ asset: { name: folderName, path: node.path, type: 'folder' }, newName: folderName }); } }); } // Delete (not for root) if (!isRoot) { items.push({ label: t.delete, icon: , onClick: () => { setDeleteConfirmDialog({ name: folderName, path: node.path, type: 'folder' }); } }); } else { items.push({ label: t.cannotDeleteRoot, icon: , disabled: true, onClick: () => {} }); } items.push({ label: '', separator: true, onClick: () => {} }); // Copy path items.push({ label: t.copyPath, icon: , onClick: async () => { try { await navigator.clipboard.writeText(node.path); } catch (error) { console.error('Failed to copy path:', error); } } }); // Show in explorer items.push({ label: t.openInExplorer, icon: , onClick: async () => { try { await TauriAPI.showInFolder(node.path); } catch (error) { console.error('Failed to show in explorer:', error); } } }); setFolderTreeContextMenu({ position: { x: e.clientX, y: e.clientY }, items }); }, [projectPath, t, refreshAll, setRenameDialog, setDeleteConfirmDialog, setFolderTreeContextMenu, setExpandedFolders]); // 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; const isRootManaged = isRootManagedDirectory(node.path, projectPath); const isInManaged = isPathInManagedDirectory(node.path, projectPath); const isDragOver = dragOverFolder === node.path; return ( handleFolderSelect(node.path)} onContextMenu={(e) => handleFolderTreeContextMenu(e, node)} title={isRootManaged ? t.managedDirectoryTooltip : undefined} onDragOver={(e) => handleFolderDragOver(e, node.path)} onDragLeave={handleFolderDragLeave} onDrop={(e) => handleFolderDrop(e, node.path)} > toggleFolderExpand(node.path, e)} > {hasChildren ? ( isExpanded ? : ) : ( )} {isRootManaged ? ( ) : ( isExpanded ? : )} {node.name} {isRootManaged && ( GUID )} {isExpanded && node.children.map(child => renderFolderNode(child, depth + 1))} ); }, [currentPath, expandedFolders, handleFolderSelect, handleFolderTreeContextMenu, toggleFolderExpand, projectPath, t.managedDirectoryTooltip, dragOverFolder, handleFolderDragOver, handleFolderDragLeave, handleFolderDrop]); // 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} e.stopPropagation()}> {favoritesExpanded && ( {favorites.length === 0 ? ( {/* Empty favorites */} ) : ( favorites.map(fav => ( {fav.split('/').pop()} )) )} )} {/* Folder Tree */} {folderTree && renderFolderNode(folderTree)} {/* Collections Section */} setCollectionsExpanded(!collectionsExpanded)} > {collectionsExpanded ? : } {t.collections} e.stopPropagation()}> e.stopPropagation()}> {collectionsExpanded && ( {/* Collections list */} )} {/* Right Panel - Content Area */} {/* Top Toolbar */} {t.add} {t.import} {t.saveAll} {/* Breadcrumb Navigation */} {breadcrumbs.map((crumb, index) => ( {index > 0 && } handleFolderSelect(crumb.path)} > {crumb.name} ))} {isDrawer && onDockInLayout && ( {t.dockInLayout} )} {/* Search Bar */} setSearchQuery(e.target.value)} /> setViewMode('grid')} > setViewMode('list')} > {/* Asset Grid */} handleContextMenu(e)} > {loading ? ( Loading... ) : filteredAssets.length === 0 ? ( {t.empty} ) : ( filteredAssets.map(asset => { const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path; return ( handleAssetClick(asset, e)} onDoubleClick={() => handleAssetDoubleClick(asset)} onContextMenu={(e) => { e.stopPropagation(); handleContextMenu(e, asset); }} draggable onDragStart={(e) => { e.dataTransfer.setData('asset-path', asset.path); e.dataTransfer.setData('text/plain', asset.path); // Add GUID for files if (asset.type === 'file') { const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; if (assetRegistry) { const relativePath = assetRegistry.absoluteToRelative(asset.path); if (relativePath) { const guid = assetRegistry.getGuidByPath(relativePath); if (guid) { e.dataTransfer.setData('asset-guid', guid); } } } } }} onDragOver={(e) => { if (asset.type === 'folder') { handleFolderDragOver(e, asset.path); } }} onDragLeave={(e) => { if (asset.type === 'folder') { handleFolderDragLeave(e); } }} onDrop={(e) => { if (asset.type === 'folder') { handleFolderDrop(e, asset.path); } }} > {getFileIcon(asset)} {asset.name} {getAssetTypeName(asset)} ); }) )} {/* Status Bar */} {filteredAssets.length} {t.items} {/* Context Menu */} {contextMenu && ( setContextMenu(null)} /> )} {/* Folder Tree Context Menu */} {folderTreeContextMenu && ( setFolderTreeContextMenu(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 /> setRenameDialog(null)}> {locale === 'zh' ? '取消' : 'Cancel'} handleRename(renameDialog.asset, renameDialog.newName)} > {locale === 'zh' ? '确定' : 'OK'} )} {/* Delete Confirm Dialog */} {deleteConfirmDialog && ( setDeleteConfirmDialog(null)}> e.stopPropagation()}> {locale === 'zh' ? '确认删除' : 'Confirm Delete'} {locale === 'zh' ? `确定要删除 "${deleteConfirmDialog.name}" 吗?` : `Delete "${deleteConfirmDialog.name}"?`} setDeleteConfirmDialog(null)}> {locale === 'zh' ? '取消' : 'Cancel'} handleDelete(deleteConfirmDialog)} > {locale === 'zh' ? '删除' : 'Delete'} )} {/* Create File Dialog */} {createFileDialog && (() => { // 规范化扩展名(确保有点号前缀) // Normalize extension (ensure dot prefix) const ext = createFileDialog.template.extension.startsWith('.') ? createFileDialog.template.extension : `.${createFileDialog.template.extension}`; return ( { const { parentPath, template } = createFileDialog; setCreateFileDialog(null); let fileName = value; if (!fileName.endsWith(ext)) { fileName = `${fileName}${ext}`; } const filePath = `${parentPath}/${fileName}`; try { const content = await template.getContent(fileName); await TauriAPI.writeFileContent(filePath, content); // Refresh both assets view and folder tree await refreshAll(); // Notify that a file was created | 通知文件已创建 messageHub?.publish('file:created', { path: filePath }); } catch (error) { console.error('Failed to create file:', error); } }} onCancel={() => setCreateFileDialog(null)} /> ); })()} ); }
{t.noProject}
{locale === 'zh' ? `确定要删除 "${deleteConfirmDialog.name}" 吗?` : `Delete "${deleteConfirmDialog.name}"?`}