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:
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user