feat(asset): 增强资产管理系统和编辑器 UI (#291)

* 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 增强
This commit is contained in:
YHH
2025-12-07 20:26:03 +08:00
committed by GitHub
parent 568b327425
commit d92c2a7b66
9 changed files with 1013 additions and 86 deletions

View File

@@ -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';

View File

@@ -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 <File size={size} />;
}
/**
* 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<FileCreationTemplate[]>([]);
// Drag and drop state for file moving
const [dragOverFolder, setDragOverFolder] = useState<string | null>(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: <AlertTriangle size={16} className="warning-icon" />,
disabled: true,
onClick: () => {}
});
items.push({ label: '', separator: true, onClick: () => {} });
}
items.push({
label: t.newFolder,
icon: <FolderClosed size={16} />,
@@ -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 ? (
<span className="menu-item-with-warning">
{getIconComponent(template.icon, 16)}
<AlertTriangle size={10} className="warning-badge" />
</span>
) : 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: <FolderClosed size={16} />,
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: <Edit3 size={16} />,
onClick: () => {
setRenameDialog({
asset: {
name: folderName,
path: node.path,
type: 'folder'
},
newName: folderName
});
}
});
}
// Delete (not for root)
if (!isRoot) {
items.push({
label: t.delete,
icon: <Trash2 size={16} />,
onClick: () => {
setDeleteConfirmDialog({
name: folderName,
path: node.path,
type: 'folder'
});
}
});
} else {
items.push({
label: t.cannotDeleteRoot,
icon: <Trash2 size={16} />,
disabled: true,
onClick: () => {}
});
}
items.push({ label: '', separator: true, onClick: () => {} });
// Copy path
items.push({
label: t.copyPath,
icon: <Clipboard size={16} />,
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: <ExternalLink size={16} />,
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 (
<div key={node.path}>
<div
className={`folder-tree-item ${isSelected ? 'selected' : ''}`}
className={`folder-tree-item ${isSelected ? 'selected' : ''} ${isRootManaged ? 'managed-root' : ''} ${isDragOver ? 'drag-over' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => 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)}
>
<span
className="folder-tree-expand"
@@ -1183,14 +1546,21 @@ export class ${className} {
)}
</span>
<span className="folder-tree-icon">
{isExpanded ? <FolderOpen size={14} /> : <FolderClosed size={14} />}
{isRootManaged ? (
<Database size={14} className="managed-icon" />
) : (
isExpanded ? <FolderOpen size={14} /> : <FolderClosed size={14} />
)}
</span>
<span className="folder-tree-name">{node.name}</span>
{isRootManaged && (
<span className="managed-badge" title={t.managedDirectoryTooltip}>GUID</span>
)}
</div>
{isExpanded && node.children.map(child => renderFolderNode(child, depth + 1))}
</div>
);
}, [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,25 +1737,26 @@ export class ${className} {
) : filteredAssets.length === 0 ? (
<div className="cb-empty">{t.empty}</div>
) : (
filteredAssets.map(asset => (
filteredAssets.map(asset => {
const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path;
return (
<div
key={asset.path}
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''} ${isDragOverAsset ? 'drag-over' : ''}`}
onClick={(e) => handleAssetClick(asset, e)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => {
e.stopPropagation();
handleContextMenu(e, asset);
}}
draggable={asset.type === 'file'}
draggable
onDragStart={(e) => {
if (asset.type === 'file') {
e.dataTransfer.setData('asset-path', asset.path);
e.dataTransfer.setData('text/plain', asset.path);
// Add GUID for new asset reference system
// Add GUID for files
if (asset.type === 'file') {
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);
@@ -1396,6 +1767,21 @@ export class ${className} {
}
}
}}
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);
}
}}
>
<div className="cb-asset-thumbnail">
{getFileIcon(asset)}
@@ -1409,7 +1795,8 @@ export class ${className} {
</div>
</div>
</div>
))
);
})
)}
</div>
@@ -1428,6 +1815,15 @@ export class ${className} {
/>
)}
{/* Folder Tree Context Menu */}
{folderTreeContextMenu && (
<ContextMenu
items={folderTreeContextMenu.items}
position={folderTreeContextMenu.position}
onClose={() => setFolderTreeContextMenu(null)}
/>
)}
{/* Rename Dialog */}
{renameDialog && (
<div className="cb-dialog-overlay" onClick={() => 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 });

View File

@@ -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;
}

View File

@@ -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<FileNode[]>([]);
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<FileNode[]> => {
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<FileNode | null>(null);
const handleSelect = useCallback((node: FileNode) => {
if (node.isDirectory) {
toggleFolder(node.path);
} else {
// 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 (
<div key={node.path}>
<div
className={`asset-picker-item ${isSelected ? 'selected' : ''}`}
className={`asset-picker-item ${isSelected ? 'selected' : ''} ${node.isRootManaged ? 'managed-root' : ''} ${isDisabled ? 'disabled' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleSelect(node)}
onDoubleClick={() => handleDoubleClick(node)}
title={isDisabled ? 'This file has no GUID and cannot be referenced' : undefined}
>
<span className="asset-picker-item__icon">
{node.isDirectory ? (
node.isRootManaged ? (
<Database size={14} className="managed-icon" />
) : (
isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />
)
) : (
getFileIcon(node.name)
)}
</span>
<span className="asset-picker-item__name">{node.name}</span>
{node.isRootManaged && (
<span className="managed-badge">GUID</span>
)}
{isDisabled && (
<span className="no-guid-badge" title="No GUID - cannot be referenced">
<AlertTriangle size={12} />
</span>
)}
</div>
{node.isDirectory && isExpanded && node.children && (
<div className="asset-picker-children">

View File

@@ -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<string | null>(null);
const [availableLoaderTypes, setAvailableLoaderTypes] = useState<string[]>([]);
const [detectedType, setDetectedType] = useState<string | null>(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 (
<div className="entity-inspector">
<div className="inspector-header">
@@ -92,6 +170,56 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
</div>
</div>
{/* Loader Type Section - only for files, not directories */}
{!fileInfo.isDirectory && availableLoaderTypes.length > 0 && (
<div className="inspector-section">
<div className="section-title">
<Settings2 size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
</div>
<div className="property-field">
<label className="property-label"></label>
<select
className="property-select"
value={currentLoaderType || ''}
onChange={(e) => handleLoaderTypeChange(e.target.value)}
disabled={isUpdating}
style={{
flex: 1,
padding: '4px 8px',
fontSize: '12px',
borderRadius: '4px',
border: '1px solid #444',
backgroundColor: '#2a2a2a',
color: '#e0e0e0',
cursor: isUpdating ? 'wait' : 'pointer'
}}
>
<option value="">
{detectedType ? `(${detectedType})` : ''}
</option>
{availableLoaderTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
{currentLoaderType && (
<div
style={{
marginTop: '4px',
fontSize: '11px',
color: '#888',
fontStyle: 'italic'
}}
>
使 "{currentLoaderType}"
</div>
)}
</div>
)}
{isImage && (
<div className="inspector-section">
<div className="section-title"></div>

View File

@@ -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<void> {
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<void> {
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;
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -125,8 +125,17 @@ const EXTENSION_TYPE_MAP: Record<string, AssetRegistryType> = {
'.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
*/