From d92c2a7b664084edb2104ae3ae9a2d7ac367b33c Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Sun, 7 Dec 2025 20:26:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(asset):=20=E5=A2=9E=E5=BC=BA=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=E5=92=8C=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=20UI=20(#291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): 修复粒子实体创建和优化检视器 - 添加 effects 分类到右键菜单,修复粒子实体无法创建的问题 - 添加粒子效果的本地化标签 - 简化粒子组件检视器,优先显示资产文件选择 - 高级属性只在未选择资产时显示,且默认折叠 - 添加可折叠的属性分组提升用户体验 * fix(particle): 修复粒子系统在浏览器预览中的资产加载和渲染 - 添加粒子 Gizmo 支持,显示发射形状并响应 Transform 缩放/旋转 - 修复资产热重载:添加 reloadAsset() 方法和 assets:refresh 事件监听 - 修复 VectorFieldEditors 数值输入精度(step 改为 0.01) - 修复浏览器预览中粒子资产加载失败的问题: - 将相对路径转换为绝对路径以正确复制资产文件 - 使用原始 GUID 而非生成的 GUID 构建 asset catalog - 初始化全局 assetManager 单例的 catalog 和 loader - 在 GameRuntime 的 systemContext 中添加 engineIntegration - 公开 AssetManager.initializeFromCatalog 方法供运行时使用 * feat(asset): 增强资产管理系统和编辑器 UI 主要改动: - 添加 loaderType 字段支持显式指定加载器类型覆盖 - 添加 .particle 扩展名和类型映射 - 新增 MANAGED_ASSET_DIRECTORIES 常量和相关工具方法 - EngineService 使用全局 assetManager 并同步 AssetRegistry 数据 - 修复插件启用逻辑,defaultEnabled=true 的新插件不被旧配置禁用 - ContentBrowser 添加 GUID 管理目录指示和非托管目录警告 - AssetPickerDialog 和 AssetFileInspector UI 增强 --- .../src/meta/AssetMetaFile.ts | 14 +- .../src/components/ContentBrowser.tsx | 508 ++++++++++++++++-- .../components/dialogs/AssetPickerDialog.css | 37 ++ .../components/dialogs/AssetPickerDialog.tsx | 133 ++++- .../inspectors/views/AssetFileInspector.tsx | 130 ++++- .../editor-app/src/services/EngineService.ts | 117 +++- .../editor-app/src/styles/ContentBrowser.css | 55 ++ .../editor-core/src/Plugin/PluginManager.ts | 15 +- .../src/Services/AssetRegistryService.ts | 90 +++- 9 files changed, 1013 insertions(+), 86 deletions(-) diff --git a/packages/asset-system-editor/src/meta/AssetMetaFile.ts b/packages/asset-system-editor/src/meta/AssetMetaFile.ts index 46b6cb29..099139e5 100644 --- a/packages/asset-system-editor/src/meta/AssetMetaFile.ts +++ b/packages/asset-system-editor/src/meta/AssetMetaFile.ts @@ -24,6 +24,17 @@ export interface IAssetMeta { guid: AssetGUID; /** Asset type | 资产类型 */ type: AssetType; + /** + * Explicit loader type override + * 显式指定的加载器类型覆盖 + * + * When set, this type will be used instead of extension-based detection. + * Useful when file extension doesn't match the actual content type. + * + * 设置后,将使用此类型而非基于扩展名的检测。 + * 适用于文件扩展名与实际内容类型不匹配的情况。 + */ + loaderType?: string; /** Import settings | 导入设置 */ importSettings?: IImportSettings; /** User-defined labels | 用户定义的标签 */ @@ -133,7 +144,8 @@ export function inferAssetType(path: string): AssetType { tileset: 'tileset', btree: 'behavior-tree', bp: 'blueprint', - mat: 'material' + mat: 'material', + particle: 'particle' }; return typeMap[ext] || 'binary'; diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx index 47ab61c4..6270c593 100644 --- a/packages/editor-app/src/components/ContentBrowser.tsx +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -35,7 +35,9 @@ import { Package, Clipboard, RefreshCw, - Settings + Settings, + Database, + AlertTriangle } from 'lucide-react'; import { Core } from '@esengine/ecs-framework'; import { MessageHub, FileActionRegistry, AssetRegistryService, type FileCreationTemplate } from '@esengine/editor-core'; @@ -45,6 +47,15 @@ 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; @@ -85,6 +96,44 @@ function getIconComponent(iconName: string | undefined, size: number = 16): Reac 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'; @@ -154,6 +203,11 @@ export function ContentBrowser({ 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; @@ -168,6 +222,9 @@ export function ContentBrowser({ // 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(() => { @@ -205,7 +262,18 @@ export function ContentBrowser({ noProject: 'No project loaded', empty: 'This folder is empty', newFolder: 'New Folder', - newPrefix: 'New' + 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: '收藏夹', @@ -219,7 +287,18 @@ export function ContentBrowser({ noProject: '未加载项目', empty: '文件夹为空', newFolder: '新建文件夹', - newPrefix: '新建' + newPrefix: '新建', + managedDirectoryTooltip: 'GUID 管理的目录 - 此处的资产会获得唯一 ID 以便引用', + unmanagedWarning: '此文件夹不受 GUID 系统管理。在此创建的资产无法通过 GUID 引用。', + unmanagedWarningTitle: '非托管目录', + rename: '重命名', + delete: '删除', + openInExplorer: '在资源管理器中显示', + copyPath: '复制路径', + newSubfolder: '新建子文件夹', + deleteConfirmTitle: '确认删除', + deleteConfirmMessage: '确定要删除', + cannotDeleteRoot: '无法删除根目录' } }[locale] || { favorites: 'Favorites', @@ -233,7 +312,18 @@ export function ContentBrowser({ noProject: 'No project loaded', empty: 'This folder is empty', newFolder: 'New Folder', - newPrefix: 'New' + 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 本地化映射 @@ -561,6 +651,22 @@ export class ${className} { } }, []); + /** + * 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) { @@ -663,6 +769,130 @@ export class ${className} { }); }, []); + // 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 @@ -783,15 +1013,14 @@ export class ${className} { await assetRegistry.refreshAsset(newPath); } - if (currentPath) { - await loadAssets(currentPath); - } + // Refresh both assets view and folder tree + await refreshAll(); setRenameDialog(null); } catch (error) { console.error('Failed to rename:', error); } - }, [currentPath, loadAssets]); + }, [refreshAll]); // Handle delete const handleDelete = useCallback(async (asset: AssetItem) => { @@ -816,9 +1045,8 @@ export class ${className} { } } - if (currentPath) { - await loadAssets(currentPath); - } + // Refresh both assets view and folder tree + await refreshAll(); // Notify that a file was deleted | 通知文件已删除 messageHub?.publish('file:deleted', { path: deletedPath }); @@ -827,7 +1055,7 @@ export class ${className} { } catch (error) { console.error('Failed to delete:', error); } - }, [currentPath, loadAssets, messageHub]); + }, [refreshAll, messageHub]); // Get breadcrumbs const getBreadcrumbs = useCallback(() => { @@ -881,8 +1109,20 @@ export class ${className} { // 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: , @@ -892,7 +1132,8 @@ export class ${className} { const folderPath = `${currentPath}/${folderName}`; try { await TauriAPI.createDirectory(folderPath); - await loadAssets(currentPath); + // Refresh both assets view and folder tree + await refreshAll(); } catch (error) { console.error('Failed to create folder:', error); } @@ -904,9 +1145,17 @@ export class ${className} { 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: getIconComponent(template.icon, 16), + icon: warningIcon, onClick: () => { setContextMenu(null); if (currentPath) { @@ -1157,20 +1406,134 @@ export class ${className} { }); return items; - }, [currentPath, fileCreationTemplates, handleAssetDoubleClick, loadAssets, locale, t.newFolder, t.newPrefix, setRenameDialog, setDeleteConfirmDialog, setContextMenu, setCreateFileDialog]); + }, [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)} > - {isExpanded ? : } + {isRootManaged ? ( + + ) : ( + isExpanded ? : + )} {node.name} + {isRootManaged && ( + GUID + )}
{isExpanded && node.children.map(child => renderFolderNode(child, depth + 1))}
); - }, [currentPath, expandedFolders, handleFolderSelect, toggleFolderExpand]); + }, [currentPath, expandedFolders, handleFolderSelect, handleFolderTreeContextMenu, toggleFolderExpand, projectPath, t.managedDirectoryTooltip, dragOverFolder, handleFolderDragOver, handleFolderDragLeave, handleFolderDrop]); // Filter assets by search const filteredAssets = searchQuery.trim() @@ -1367,49 +1737,66 @@ export class ${className} { ) : filteredAssets.length === 0 ? (
{t.empty}
) : ( - filteredAssets.map(asset => ( -
handleAssetClick(asset, e)} - onDoubleClick={() => handleAssetDoubleClick(asset)} - onContextMenu={(e) => { - e.stopPropagation(); - handleContextMenu(e, asset); - }} - draggable={asset.type === 'file'} - onDragStart={(e) => { - if (asset.type === 'file') { + 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 new asset reference system - const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; - if (assetRegistry) { - // Convert absolute path to relative path for GUID lookup - const relativePath = assetRegistry.absoluteToRelative(asset.path); - if (relativePath) { - const guid = assetRegistry.getGuidByPath(relativePath); - if (guid) { - e.dataTransfer.setData('asset-guid', guid); + // 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); + } } } } - } - }} - > -
- {getFileIcon(asset)} -
-
-
- {asset.name} + }} + 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)}
-
- {getAssetTypeName(asset)} +
+
+ {asset.name} +
+
+ {getAssetTypeName(asset)} +
-
- )) + ); + }) )}
@@ -1428,6 +1815,15 @@ export class ${className} { /> )} + {/* Folder Tree Context Menu */} + {folderTreeContextMenu && ( + setFolderTreeContextMenu(null)} + /> + )} + {/* Rename Dialog */} {renameDialog && (
setRenameDialog(null)}> @@ -1518,9 +1914,9 @@ export class ${className} { try { const content = await template.getContent(fileName); await TauriAPI.writeFileContent(filePath, content); - if (currentPath) { - await loadAssets(currentPath); - } + + // Refresh both assets view and folder tree + await refreshAll(); // Notify that a file was created | 通知文件已创建 messageHub?.publish('file:created', { path: filePath }); diff --git a/packages/editor-app/src/components/dialogs/AssetPickerDialog.css b/packages/editor-app/src/components/dialogs/AssetPickerDialog.css index 55a6c91e..86e78de3 100644 --- a/packages/editor-app/src/components/dialogs/AssetPickerDialog.css +++ b/packages/editor-app/src/components/dialogs/AssetPickerDialog.css @@ -313,3 +313,40 @@ .asset-save-new-folder button:last-child:hover { background: #444; } + +/* ==================== Managed Directory Styles ==================== */ +.asset-picker-item.managed-root .asset-picker-item__icon { + color: #4fc1ff; +} + +.asset-picker-item.managed-root .managed-icon { + color: #4fc1ff; +} + +.asset-picker-item .managed-badge { + font-size: 9px; + padding: 1px 4px; + background: #4fc1ff22; + color: #4fc1ff; + border-radius: 3px; + margin-left: auto; + font-weight: 600; + letter-spacing: 0.5px; +} + +/* Disabled items (no GUID) */ +.asset-picker-item.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.asset-picker-item.disabled:hover { + background: transparent; +} + +.asset-picker-item .no-guid-badge { + margin-left: auto; + color: #f59e0b; + display: flex; + align-items: center; +} diff --git a/packages/editor-app/src/components/dialogs/AssetPickerDialog.tsx b/packages/editor-app/src/components/dialogs/AssetPickerDialog.tsx index 70d7a927..3d286a33 100644 --- a/packages/editor-app/src/components/dialogs/AssetPickerDialog.tsx +++ b/packages/editor-app/src/components/dialogs/AssetPickerDialog.tsx @@ -1,10 +1,18 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { X, Search, Folder, FolderOpen, File, Image, FileText, Music, Video } from 'lucide-react'; +import { X, Search, Folder, FolderOpen, File, Image, FileText, Music, Video, Database, AlertTriangle } from 'lucide-react'; import { Core } from '@esengine/ecs-framework'; -import { ProjectService } from '@esengine/editor-core'; +import { ProjectService, AssetRegistryService } from '@esengine/editor-core'; import { TauriFileSystemService } from '../../services/TauriFileSystemService'; import './AssetPickerDialog.css'; +/** + * Directories managed by asset registry (GUID system) + * Only files in these directories can be selected + * + * Note: Keep in sync with MANAGED_ASSET_DIRECTORIES in AssetRegistryService.ts + */ +const MANAGED_ASSET_DIRECTORIES = ['assets', 'scripts', 'scenes'] as const; + interface AssetPickerDialogProps { isOpen: boolean; onClose: () => void; @@ -19,6 +27,10 @@ interface FileNode { path: string; isDirectory: boolean; children?: FileNode[]; + /** Asset GUID (only for files with registered GUIDs) */ + guid?: string; + /** Whether this is a root managed directory */ + isRootManaged?: boolean; } export function AssetPickerDialog({ @@ -35,7 +47,12 @@ export function AssetPickerDialog({ const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); - // Load project assets + // Get AssetRegistryService for GUID lookup + const assetRegistry = useMemo(() => { + return Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + }, []); + + // Load project assets - ONLY from managed directories (assets, scripts, scenes) useEffect(() => { if (!isOpen) return; @@ -48,13 +65,44 @@ export function AssetPickerDialog({ const currentProject = projectService?.getCurrentProject(); if (projectService && currentProject) { const projectPath = currentProject.path; - const assetsPath = `${projectPath}/assets`; + const normalizedProjectPath = projectPath.replace(/\\/g, '/'); + + // 排除的目录名 | Excluded directory names + const excludedDirs = new Set([ + 'node_modules', '.git', '.idea', '.vscode', 'dist', 'build', + 'temp', 'tmp', '.cache', 'coverage', '__pycache__' + ]); + + // Helper to get relative path from absolute path + const getRelativePath = (absPath: string): string => { + const normalizedAbs = absPath.replace(/\\/g, '/'); + if (normalizedAbs.startsWith(normalizedProjectPath)) { + return normalizedAbs.substring(normalizedProjectPath.length + 1); + } + return absPath; + }; const buildTree = async (dirPath: string): Promise => { const entries = await fileSystem.listDirectory(dirPath); const nodes: FileNode[] = []; for (const entry of entries) { + // 跳过排除的目录 | Skip excluded directories + if (entry.isDirectory && excludedDirs.has(entry.name)) { + continue; + } + + // 跳过隐藏文件/目录(以.开头,除了当前目录) + // Skip hidden files/directories (starting with ., except current dir) + if (entry.name.startsWith('.') && entry.name !== '.') { + continue; + } + + // Skip .meta files + if (entry.name.endsWith('.meta')) { + continue; + } + const node: FileNode = { name: entry.name, path: entry.path, @@ -67,6 +115,15 @@ export function AssetPickerDialog({ } catch { node.children = []; } + } else { + // Try to get GUID for the file + if (assetRegistry) { + const relativePath = getRelativePath(entry.path); + const guid = assetRegistry.getGuidByPath(relativePath); + if (guid) { + node.guid = guid; + } + } } nodes.push(node); @@ -80,8 +137,33 @@ export function AssetPickerDialog({ }); }; - const tree = await buildTree(assetsPath); - setAssets(tree); + // Only load managed directories (assets, scripts, scenes) + const sep = projectPath.includes('\\') ? '\\' : '/'; + const managedNodes: FileNode[] = []; + + for (const dirName of MANAGED_ASSET_DIRECTORIES) { + const dirPath = `${projectPath}${sep}${dirName}`; + try { + const exists = await fileSystem.exists(dirPath); + if (exists) { + const children = await buildTree(dirPath); + managedNodes.push({ + name: dirName, + path: dirPath, + isDirectory: true, + children, + isRootManaged: true + }); + } + } catch { + // Directory doesn't exist, skip + } + } + + setAssets(managedNodes); + + // Auto-expand managed directories + setExpandedFolders(new Set(managedNodes.map(n => n.path))); } } catch (error) { console.error('Failed to load assets:', error); @@ -93,7 +175,7 @@ export function AssetPickerDialog({ loadAssets(); setSelectedPath(null); setSearchTerm(''); - }, [isOpen]); + }, [isOpen, assetRegistry]); // Filter assets based on search and file extensions const filteredAssets = useMemo(() => { @@ -141,11 +223,19 @@ export function AssetPickerDialog({ }); }, []); + // Track selected node (to check for GUID) + const [selectedNode, setSelectedNode] = useState(null); + const handleSelect = useCallback((node: FileNode) => { if (node.isDirectory) { toggleFolder(node.path); } else { - setSelectedPath(node.path); + // Only allow selecting files with GUID + if (node.guid) { + setSelectedPath(node.path); + setSelectedNode(node); + } + // Files without GUID cannot be selected } }, [toggleFolder]); @@ -172,11 +262,15 @@ export function AssetPickerDialog({ }, [selectedPath, onSelect, onClose, toRelativePath]); const handleDoubleClick = useCallback((node: FileNode) => { - if (!node.isDirectory) { + if (!node.isDirectory && node.guid) { + // Double-click on file with GUID selects it onSelect(toRelativePath(node.path)); onClose(); + } else if (node.isDirectory) { + // Double-click on folder toggles expansion + toggleFolder(node.path); } - }, [onSelect, onClose, toRelativePath]); + }, [onSelect, onClose, toRelativePath, toggleFolder]); const getFileIcon = (name: string) => { const ext = name.split('.').pop()?.toLowerCase(); @@ -206,23 +300,38 @@ export function AssetPickerDialog({ const renderNode = (node: FileNode, depth: number = 0) => { const isExpanded = expandedFolders.has(node.path); const isSelected = selectedPath === node.path; + const hasGuid = node.isDirectory || !!node.guid; + const isDisabled = !node.isDirectory && !node.guid; return (
handleSelect(node)} onDoubleClick={() => handleDoubleClick(node)} + title={isDisabled ? 'This file has no GUID and cannot be referenced' : undefined} > {node.isDirectory ? ( - isExpanded ? : + node.isRootManaged ? ( + + ) : ( + isExpanded ? : + ) ) : ( getFileIcon(node.name) )} {node.name} + {node.isRootManaged && ( + GUID + )} + {isDisabled && ( + + + + )}
{node.isDirectory && isExpanded && node.children && (
diff --git a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx index 3ad61178..2c73f350 100644 --- a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx +++ b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx @@ -1,5 +1,9 @@ -import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive } from 'lucide-react'; +import { useState, useEffect, useCallback } from 'react'; +import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2 } from 'lucide-react'; import { convertFileSrc } from '@tauri-apps/api/core'; +import { Core } from '@esengine/ecs-framework'; +import { AssetRegistryService } from '@esengine/editor-core'; +import { assetManager as globalAssetManager } from '@esengine/asset-system'; import { AssetFileInfo } from '../types'; import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common'; import '../../../styles/EntityInspector.css'; @@ -10,6 +14,18 @@ interface AssetFileInspectorProps { isImage?: boolean; } +/** + * Built-in loader types (always available) + * 内置加载器类型(始终可用) + */ +const BUILTIN_LOADER_TYPES = [ + 'texture', + 'audio', + 'json', + 'text', + 'binary' +]; + function formatFileSize(bytes?: number): string { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; @@ -38,6 +54,68 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon; const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9'; + // State for loader type selector + const [currentLoaderType, setCurrentLoaderType] = useState(null); + const [availableLoaderTypes, setAvailableLoaderTypes] = useState([]); + const [detectedType, setDetectedType] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); + + // Load meta info and available loader types + useEffect(() => { + if (fileInfo.isDirectory) return; + + const loadMetaInfo = async () => { + try { + const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + if (!assetRegistry?.isReady) return; + + const metaManager = assetRegistry.metaManager; + const meta = await metaManager.getOrCreateMeta(fileInfo.path); + + // Get current loader type from meta + setCurrentLoaderType(meta.loaderType || null); + setDetectedType(meta.type); + + // Get available loader types from assetManager + const loaderFactory = globalAssetManager.getLoaderFactory(); + const registeredTypes = loaderFactory?.getRegisteredTypes() || []; + + // Combine built-in types with registered types (deduplicated) + const allTypes = new Set([...BUILTIN_LOADER_TYPES, ...registeredTypes]); + setAvailableLoaderTypes(Array.from(allTypes).sort()); + } catch (error) { + console.warn('Failed to load meta info:', error); + } + }; + + loadMetaInfo(); + }, [fileInfo.path, fileInfo.isDirectory]); + + // Handle loader type change + const handleLoaderTypeChange = useCallback(async (newType: string) => { + if (fileInfo.isDirectory || isUpdating) return; + + setIsUpdating(true); + try { + const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + if (!assetRegistry?.isReady) return; + + const metaManager = assetRegistry.metaManager; + + // Update meta with new loader type + // Empty string means use auto-detection (remove override) + const loaderType = newType === '' ? undefined : newType; + await metaManager.updateMeta(fileInfo.path, { loaderType }); + + setCurrentLoaderType(loaderType || null); + console.log(`[AssetFileInspector] Updated loader type for ${fileInfo.name}: ${loaderType || '(auto)'}`); + } catch (error) { + console.error('Failed to update loader type:', error); + } finally { + setIsUpdating(false); + } + }, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]); + return (
@@ -92,6 +170,56 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
+ {/* Loader Type Section - only for files, not directories */} + {!fileInfo.isDirectory && availableLoaderTypes.length > 0 && ( +
+
+ + 加载设置 +
+
+ + +
+ {currentLoaderType && ( +
+ 已覆盖自动检测,使用 "{currentLoaderType}" 加载器 +
+ )} +
+ )} + {isImage && (
图片预览
diff --git a/packages/editor-app/src/services/EngineService.ts b/packages/editor-app/src/services/EngineService.ts index 1c96d0ef..5321b010 100644 --- a/packages/editor-app/src/services/EngineService.ts +++ b/packages/editor-app/src/services/EngineService.ts @@ -19,7 +19,9 @@ import { AssetPathResolver, AssetPlatform, globalPathResolver, - SceneResourceManager + SceneResourceManager, + assetManager as globalAssetManager, + AssetType } from '@esengine/asset-system'; import { GameRuntime, @@ -202,12 +204,18 @@ export class EngineService { engineBridge: this._runtime.bridge, renderSystem: this._runtime.renderSystem, assetManager: this._assetManager, - isEditor: true + engineIntegration: this._engineIntegration, + isEditor: true, + transformType: TransformComponent }; // 让插件为场景创建系统 pluginManager.createSystemsForScene(this._runtime.scene!, context); + // Re-sync assets after plugins registered their loaders + // 插件注册完加载器后,重新同步资产(确保类型正确) + await this._syncAssetRegistryToManager(); + // 同步系统引用到 GameRuntime 的 systemContext(用于 start/stop 时启用/禁用系统) this._runtime.updateSystemContext({ animatorSystem: context.animatorSystem, @@ -357,7 +365,9 @@ export class EngineService { */ private async _initializeAssetSystem(): Promise { try { - this._assetManager = new AssetManager(); + // Use global assetManager instance so all systems share the same manager + // 使用全局 assetManager 实例,以便所有系统共享同一个管理器 + this._assetManager = globalAssetManager; // Set up asset reader for Tauri environment. // 为 Tauri 环境设置资产读取器。 @@ -374,6 +384,10 @@ export class EngineService { } } + // Sync AssetRegistryService data to global assetManager's database + // 将 AssetRegistryService 的数据同步到全局 assetManager 的数据库 + await this._syncAssetRegistryToManager(); + const pathTransformerFn = (path: string) => { if (!path.startsWith('http://') && !path.startsWith('https://') && !path.startsWith('data:') && !path.startsWith('asset://')) { @@ -431,6 +445,97 @@ export class EngineService { } } + /** + * Sync AssetRegistryService data to AssetManager's database. + * 将 AssetRegistryService 的数据同步到 AssetManager 的数据库。 + * + * This enables GUID-based asset loading through the global assetManager. + * Components like ParticleSystemComponent use the global assetManager to load assets by GUID. + * + * Asset type resolution order: + * 1. loaderType from .meta file (explicit user override) + * 2. loaderFactory.getAssetTypeByPath (plugin-registered loaders) + * 3. Extension-based fallback (built-in types) + * + * 资产类型解析顺序: + * 1. .meta 文件中的 loaderType(用户显式覆盖) + * 2. loaderFactory.getAssetTypeByPath(插件注册的加载器) + * 3. 基于扩展名的回退(内置类型) + */ + private async _syncAssetRegistryToManager(): Promise { + if (!this._assetManager) return; + + const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + if (!assetRegistry || !assetRegistry.isReady) { + console.warn('[EngineService] AssetRegistryService not ready, skipping sync'); + return; + } + + const database = this._assetManager.getDatabase(); + const allAssets = assetRegistry.getAllAssets(); + const metaManager = assetRegistry.metaManager; + + console.log(`[EngineService] Syncing ${allAssets.length} assets from AssetRegistry to AssetManager`); + + // Use loaderFactory to determine asset type from path + // This allows plugins to register their own loaders and types + // 使用 loaderFactory 根据路径确定资产类型 + // 这允许插件注册自己的加载器和类型 + const loaderFactory = this._assetManager.getLoaderFactory(); + + for (const asset of allAssets) { + let assetType: string | null = null; + + // 1. Check for explicit loaderType in .meta file (user override) + // 1. 检查 .meta 文件中的显式 loaderType(用户覆盖) + const meta = metaManager.getMetaByGUID(asset.guid); + if (meta?.loaderType) { + assetType = meta.loaderType; + } + + // 2. Try to get type from registered loaders + // 2. 尝试从已注册的加载器获取类型 + if (!assetType) { + assetType = loaderFactory?.getAssetTypeByPath?.(asset.path) ?? null; + } + + // 3. Fallback: determine type from extension for basic types + // 3. 回退:根据扩展名确定基本类型 + if (!assetType) { + const ext = asset.path.substring(asset.path.lastIndexOf('.')).toLowerCase(); + if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)) { + assetType = AssetType.Texture; + } else if (['.mp3', '.wav', '.ogg', '.m4a'].includes(ext)) { + assetType = AssetType.Audio; + } else if (['.json'].includes(ext)) { + assetType = AssetType.Json; + } else if (['.txt', '.md', '.xml', '.yaml'].includes(ext)) { + assetType = AssetType.Text; + } else { + // Use Custom type - the plugin's loader should handle it + // 使用 Custom 类型 - 插件的加载器应该处理它 + assetType = AssetType.Custom; + } + } + + database.addAsset({ + guid: asset.guid, + path: asset.path, + type: assetType, + name: asset.name, + size: asset.size, + hash: asset.hash || '', + dependencies: [], + labels: [], + tags: new Map(), + lastModified: asset.lastModified, + version: 1 + }); + } + + console.log(`[EngineService] Asset sync complete`); + } + /** * Setup asset path resolver for EngineRenderSystem. * 为 EngineRenderSystem 设置资产路径解析器。 @@ -873,8 +978,12 @@ export class EngineService { dispose(): void { this.stop(); + // Don't dispose the global assetManager, just clear the reference + // 不要 dispose 全局 assetManager,只是清除引用 if (this._assetManager) { - this._assetManager.dispose(); + // Clear the database to free memory when switching projects + // 切换项目时清空数据库以释放内存 + this._assetManager.getDatabase().clear(); this._assetManager = null; } diff --git a/packages/editor-app/src/styles/ContentBrowser.css b/packages/editor-app/src/styles/ContentBrowser.css index 7555af69..2b6e7d5c 100644 --- a/packages/editor-app/src/styles/ContentBrowser.css +++ b/packages/editor-app/src/styles/ContentBrowser.css @@ -143,6 +143,61 @@ font-size: 12px; } +/* ==================== Managed Directory Indicators ==================== */ +.folder-tree-item.managed-root .folder-tree-icon { + color: #4fc1ff; +} + +.folder-tree-item.managed-root .managed-icon { + color: #4fc1ff; +} + +.managed-badge { + font-size: 9px; + padding: 1px 4px; + background: #4fc1ff22; + color: #4fc1ff; + border-radius: 3px; + margin-left: 6px; + font-weight: 600; + letter-spacing: 0.5px; +} + +/* Warning icons in context menu */ +.warning-icon { + color: #f59e0b !important; +} + +.menu-item-with-warning { + display: inline-flex; + align-items: center; + position: relative; +} + +.warning-badge { + position: absolute; + bottom: -2px; + right: -4px; + color: #f59e0b; +} + +/* Context menu disabled item for unmanaged warning */ +.context-menu-item.disabled .warning-icon { + color: #f59e0b !important; +} + +/* ==================== Drag and Drop Styles ==================== */ +.folder-tree-item.drag-over { + background: #1976d2 !important; + outline: 2px solid #42a5f5; +} + +.cb-asset-item.drag-over { + background: #1976d2 !important; + outline: 2px solid #42a5f5; + border-radius: 4px; +} + /* ==================== Right Panel - Content Area ==================== */ .content-browser-right { flex: 1; diff --git a/packages/editor-core/src/Plugin/PluginManager.ts b/packages/editor-core/src/Plugin/PluginManager.ts index afef63a5..9c0dcc26 100644 --- a/packages/editor-core/src/Plugin/PluginManager.ts +++ b/packages/editor-core/src/Plugin/PluginManager.ts @@ -1080,11 +1080,16 @@ export class PluginManager implements IService { const wasEnabled = plugin.enabled; const isDefaultEnabled = plugin.plugin.manifest.defaultEnabled; - // 如果插件在配置中明确列出,按配置来 - // 如果插件不在配置中但 defaultEnabled=true,保持启用(新插件不应被旧配置禁用) - // If plugin is explicitly in config, follow config - // If plugin is not in config but defaultEnabled=true, keep enabled (new plugins should not be disabled by old config) - const shouldBeEnabled = inConfig || (isDefaultEnabled && !enabledPlugins.some(p => p === id)); + // 逻辑: + // 1. 如果插件在配置中明确列出,启用它 + // 2. 如果插件不在配置中但 defaultEnabled=true,也启用它(新插件不应被旧配置禁用) + // 3. 只有在配置中明确不包含且 defaultEnabled=false 的插件才禁用 + // + // Logic: + // 1. If plugin is explicitly in config, enable it + // 2. If plugin is not in config but defaultEnabled=true, also enable it (new plugins should not be disabled by old config) + // 3. Only disable plugins that are not in config AND have defaultEnabled=false + const shouldBeEnabled = inConfig || isDefaultEnabled; if (shouldBeEnabled && !wasEnabled) { toEnable.push(id); diff --git a/packages/editor-core/src/Services/AssetRegistryService.ts b/packages/editor-core/src/Services/AssetRegistryService.ts index db8c32be..2e79b750 100644 --- a/packages/editor-core/src/Services/AssetRegistryService.ts +++ b/packages/editor-core/src/Services/AssetRegistryService.ts @@ -125,8 +125,17 @@ const EXTENSION_TYPE_MAP: Record = { '.prefab': 'prefab', '.tmx': 'tilemap', '.tsx': 'tileset', + // Particle system + '.particle': 'particle', }; +/** + * Directories managed by asset registry (GUID system) + * 被资产注册表(GUID 系统)管理的目录 + */ +export const MANAGED_ASSET_DIRECTORIES = ['assets', 'scripts', 'scenes'] as const; +export type ManagedAssetDirectory = typeof MANAGED_ASSET_DIRECTORIES[number]; + // 使用从 IFileSystem.ts 导入的标准接口 // Using standard interface imported from IFileSystem.ts @@ -482,13 +491,12 @@ export class AssetRegistryService { const sep = this._projectPath.includes('\\') ? '\\' : '/'; - // 扫描多个目录:assets, scripts, scenes - // Scan multiple directories: assets, scripts, scenes - const directoriesToScan = [ - { path: `${this._projectPath}${sep}assets`, name: 'assets' }, - { path: `${this._projectPath}${sep}scripts`, name: 'scripts' }, - { path: `${this._projectPath}${sep}scenes`, name: 'scenes' } - ]; + // 扫描多个目录:assets, scripts, scenes, ecs-scenes + // Scan multiple directories: assets, scripts, scenes, ecs-scenes + const directoriesToScan = MANAGED_ASSET_DIRECTORIES.map(name => ({ + path: `${this._projectPath}${sep}${name}`, + name + })); for (const dir of directoriesToScan) { try { @@ -789,6 +797,74 @@ export class AssetRegistryService { return this._projectPath; } + /** + * Get managed asset directories + * 获取被管理的资产目录 + */ + getManagedDirectories(): readonly string[] { + return MANAGED_ASSET_DIRECTORIES; + } + + /** + * Check if a path is within a managed directory + * 检查路径是否在被管理的目录中 + * + * @param pathToCheck - Absolute or relative path | 绝对或相对路径 + * @returns Whether the path is in a managed directory | 路径是否在被管理的目录中 + */ + isPathManaged(pathToCheck: string): boolean { + if (!pathToCheck) return false; + + // Normalize path + const normalizedPath = pathToCheck.replace(/\\/g, '/'); + + // Check if path starts with any managed directory + for (const dir of MANAGED_ASSET_DIRECTORIES) { + // Check relative path (e.g., "assets/textures/...") + if (normalizedPath.startsWith(`${dir}/`) || normalizedPath === dir) { + return true; + } + // Check absolute path (e.g., "C:/project/assets/...") + if (this._projectPath) { + const normalizedProject = this._projectPath.replace(/\\/g, '/'); + const managedAbsPath = `${normalizedProject}/${dir}`; + if (normalizedPath.startsWith(`${managedAbsPath}/`) || normalizedPath === managedAbsPath) { + return true; + } + } + } + + return false; + } + + /** + * Get the managed directory name for a path (if any) + * 获取路径所属的被管理目录名称(如果有) + * + * @param pathToCheck - Absolute or relative path | 绝对或相对路径 + * @returns The managed directory name or null | 被管理的目录名称或 null + */ + getManagedDirectoryForPath(pathToCheck: string): ManagedAssetDirectory | null { + if (!pathToCheck) return null; + + const normalizedPath = pathToCheck.replace(/\\/g, '/'); + + for (const dir of MANAGED_ASSET_DIRECTORIES) { + if (normalizedPath.startsWith(`${dir}/`) || normalizedPath === dir) { + return dir; + } + if (this._projectPath) { + const normalizedProject = this._projectPath.replace(/\\/g, '/'); + const managedAbsPath = `${normalizedProject}/${dir}`; + if (normalizedPath.startsWith(`${managedAbsPath}/`) || normalizedPath === managedAbsPath) { + return dir; + } + } + } + + return null; + } + /** * Dispose the service */