Files
esengine/packages/editor-app/src/components/FileTree.tsx
YHH 3617f40309 feat(asset): 统一资产引用使用 GUID 替代路径 (#287)
* feat(world-streaming): 添加世界流式加载系统

实现基于区块的世界流式加载系统,支持开放世界游戏:

运行时包 (@esengine/world-streaming):
- ChunkComponent: 区块实体组件,包含坐标、边界、状态
- StreamingAnchorComponent: 流式锚点组件(玩家/摄像机)
- ChunkLoaderComponent: 流式加载配置组件
- ChunkStreamingSystem: 区块加载/卸载调度系统
- ChunkCullingSystem: 区块可见性剔除系统
- ChunkManager: 区块生命周期管理服务
- SpatialHashGrid: 空间哈希网格
- ChunkSerializer: 区块序列化

编辑器包 (@esengine/world-streaming-editor):
- ChunkVisualizer: 区块可视化覆盖层
- ChunkLoaderInspectorProvider: 区块加载器检视器
- StreamingAnchorInspectorProvider: 流式锚点检视器
- WorldStreamingPlugin: 完整插件导出

* feat(asset): 统一资产引用使用 GUID 替代路径

将所有组件的资产引用字段从路径改为 GUID:
- SpriteComponent: texture -> textureGuid, material -> materialGuid
- SpriteAnimatorComponent: AnimationFrame.texture -> textureGuid
- UIRenderComponent: texture -> textureGuid
- UIButtonComponent: normalTexture -> normalTextureGuid 等
- AudioSourceComponent: clip -> clipGuid
- ParticleSystemComponent: 已使用 textureGuid

修复 AssetRegistryService 注册问题和路径规范化,
添加渲染系统的 GUID 解析支持。

* fix(sprite-editor): 更新 material 为 materialGuid

* fix(editor-app): 更新 AnimationFrame.texture 为 textureGuid
2025-12-06 14:08:48 +08:00

1140 lines
43 KiB
TypeScript

import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import * as LucideIcons from 'lucide-react';
import {
Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus,
Save, Tag, Link, FileSearch, Globe, Package, Clipboard, RefreshCw, Settings
} from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { MessageHub, FileActionRegistry, AssetRegistryService } from '@esengine/editor-core';
import { SettingsService } from '../services/SettingsService';
import { Core } from '@esengine/ecs-framework';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
import { ConfirmDialog } from './ConfirmDialog';
import { PromptDialog } from './PromptDialog';
import '../styles/FileTree.css';
/**
* 根据图标名称获取 Lucide 图标组件
*/
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
if (!iconName) return <Plus size={size} />;
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComponent = icons[iconName];
if (IconComponent) {
return <IconComponent size={size} />;
}
return <Plus size={size} />;
}
interface TreeNode {
name: string;
path: string;
type: 'folder' | 'file';
size?: number;
modified?: number;
children?: TreeNode[];
expanded?: boolean;
loaded?: boolean;
}
interface FileTreeProps {
rootPath: string | null;
onSelectFile?: (path: string) => void;
onSelectFiles?: (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => void;
selectedPath?: string | null;
selectedPaths?: Set<string>;
messageHub?: MessageHub;
searchQuery?: string;
showFiles?: boolean;
onOpenScene?: (scenePath: string) => void;
}
export interface FileTreeHandle {
collapseAll: () => void;
refresh: () => void;
revealPath: (targetPath: string) => Promise<void>;
}
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, onSelectFiles, selectedPath, selectedPaths, messageHub, searchQuery, showFiles = true, onOpenScene }, ref) => {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [internalSelectedPath, setInternalSelectedPath] = useState<string | null>(null);
const [lastSelectedFilePath, setLastSelectedFilePath] = useState<string | null>(null);
// Flatten visible file nodes for range selection
const getVisibleFilePaths = (nodes: TreeNode[]): string[] => {
const paths: string[] = [];
const traverse = (nodeList: TreeNode[]) => {
for (const node of nodeList) {
if (node.type === 'file') {
paths.push(node.path);
} else if (node.type === 'folder' && node.expanded && node.children) {
traverse(node.children);
}
}
};
traverse(nodes);
return paths;
};
const [contextMenu, setContextMenu] = useState<{
position: { x: number; y: number };
node: TreeNode | null;
} | null>(null);
const [renamingNode, setRenamingNode] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [deleteDialog, setDeleteDialog] = useState<{ node: TreeNode } | null>(null);
const [promptDialog, setPromptDialog] = useState<{
type: 'create-file' | 'create-folder' | 'create-template';
parentPath: string;
templateExtension?: string;
templateGetContent?: (fileName: string) => string | Promise<string>;
} | null>(null);
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
const collapseAll = () => {
const collapseNode = (node: TreeNode): TreeNode => {
if (node.type === 'folder') {
return {
...node,
expanded: false,
children: node.children ? node.children.map(collapseNode) : node.children
};
}
return node;
};
const collapsedTree = tree.map((node) => collapseNode(node));
setTree(collapsedTree);
};
// Expand tree to reveal a specific file path
const revealPath = async (targetPath: string) => {
if (!rootPath) return;
// Normalize paths to use forward slashes for comparison
const normalizedTargetPath = targetPath.replace(/\\/g, '/');
const normalizedRootPath = rootPath.replace(/\\/g, '/');
if (!normalizedTargetPath.startsWith(normalizedRootPath)) return;
// Get path segments between root and target
const relativePath = normalizedTargetPath.substring(normalizedRootPath.length).replace(/^[/\\]/, '');
const segments = relativePath.split(/[/\\]/);
// Build list of folder paths to expand
const pathsToExpand: string[] = [];
let currentPath = rootPath;
for (let i = 0; i < segments.length - 1; i++) {
currentPath = `${currentPath}/${segments[i]}`;
pathsToExpand.push(currentPath.replace(/\//g, '\\'));
}
// Recursively expand nodes and load children
const expandToPath = async (nodes: TreeNode[], pathSet: Set<string>): Promise<TreeNode[]> => {
const result: TreeNode[] = [];
for (const node of nodes) {
const normalizedPath = node.path.replace(/\//g, '\\');
if (node.type === 'folder' && pathSet.has(normalizedPath)) {
// Load children if not loaded
let children = node.children;
if (!node.loaded || !children) {
try {
const entries = await TauriAPI.listDirectory(node.path);
children = entries.map((entry: DirectoryEntry) => ({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
size: entry.size,
modified: entry.modified,
expanded: false,
loaded: false
})).sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
} catch (error) {
children = [];
}
}
// Recursively expand children
const expandedChildren = await expandToPath(children, pathSet);
result.push({
...node,
expanded: true,
loaded: true,
children: expandedChildren
});
} else if (node.type === 'folder' && node.children) {
// Keep existing state for non-target folders
result.push({
...node,
children: await expandToPath(node.children, pathSet)
});
} else {
result.push(node);
}
}
return result;
};
const pathSet = new Set(pathsToExpand);
const expandedTree = await expandToPath(tree, pathSet);
setTree(expandedTree);
setInternalSelectedPath(targetPath);
};
useImperativeHandle(ref, () => ({
collapseAll,
refresh: refreshTree,
revealPath
}));
useEffect(() => {
if (rootPath) {
loadRootDirectory(rootPath);
} else {
setTree([]);
}
}, [rootPath]);
useEffect(() => {
if (selectedPath) {
setInternalSelectedPath(selectedPath);
}
}, [selectedPath]);
useEffect(() => {
const performSearch = async () => {
const filterByFileType = (nodes: TreeNode[]): TreeNode[] => {
return nodes
.filter((node) => showFiles || node.type === 'folder')
.map((node) => ({
...node,
children: node.children ? filterByFileType(node.children) : node.children
}));
};
let result = filterByFileType(tree);
if (searchQuery && searchQuery.trim()) {
const query = searchQuery.toLowerCase();
const loadAndFilterTree = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
const filtered: TreeNode[] = [];
for (const node of nodes) {
const nameMatches = node.name.toLowerCase().includes(query);
let filteredChildren: TreeNode[] = [];
if (node.type === 'folder') {
let childrenToSearch = node.children || [];
if (!node.loaded) {
try {
const entries = await TauriAPI.listDirectory(node.path);
childrenToSearch = entriesToNodes(entries);
} catch (error) {
console.error('Failed to load children for search:', error);
}
}
if (childrenToSearch.length > 0) {
filteredChildren = await loadAndFilterTree(childrenToSearch);
}
}
if (nameMatches || filteredChildren.length > 0) {
filtered.push({
...node,
expanded: filteredChildren.length > 0,
loaded: true,
children: filteredChildren.length > 0 ? filteredChildren : (node.type === 'folder' ? [] : undefined)
});
}
}
return filtered;
};
result = await loadAndFilterTree(result);
}
setFilteredTree(result);
};
performSearch();
}, [searchQuery, tree, showFiles]);
const loadRootDirectory = async (path: string) => {
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const children = entriesToNodes(entries);
// 创建根节点
const rootName = path.split(/[/\\]/).filter((p) => p).pop() || 'Project';
const rootNode: TreeNode = {
name: rootName,
path: path,
type: 'folder',
children: children,
expanded: true,
loaded: true
};
setTree([rootNode]);
} catch (error) {
console.error('Failed to load directory:', error);
setTree([]);
} finally {
setLoading(false);
}
};
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
return entries
.sort((a, b) => {
if (a.is_dir === b.is_dir) return a.name.localeCompare(b.name);
return a.is_dir ? -1 : 1;
})
.map((entry) => ({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
size: entry.size,
modified: entry.modified,
children: entry.is_dir ? [] : undefined,
expanded: false,
loaded: entry.is_dir ? false : undefined
}));
};
const loadChildren = async (node: TreeNode): Promise<TreeNode[]> => {
try {
const entries = await TauriAPI.listDirectory(node.path);
return entriesToNodes(entries);
} catch (error) {
console.error('Failed to load children:', error);
return [];
}
};
const toggleNode = async (nodePath: string) => {
const updateTree = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
const newNodes: TreeNode[] = [];
for (const node of nodes) {
if (node.path === nodePath) {
if (!node.loaded) {
const children = await loadChildren(node);
newNodes.push({
...node,
expanded: true,
loaded: true,
children
});
} else {
newNodes.push({
...node,
expanded: !node.expanded
});
}
} else if (node.children) {
newNodes.push({
...node,
children: await updateTree(node.children)
});
} else {
newNodes.push(node);
}
}
return newNodes;
};
const newTree = await updateTree(tree);
setTree(newTree);
};
const refreshTree = async () => {
if (!rootPath) return;
// 保存当前展开状态
const expandedPaths = new Set<string>();
const collectExpandedPaths = (nodes: TreeNode[]) => {
for (const node of nodes) {
if (node.type === 'folder' && node.expanded) {
expandedPaths.add(node.path);
if (node.children) {
collectExpandedPaths(node.children);
}
}
}
};
collectExpandedPaths(tree);
// 重新加载根目录,获取最新的文件结构
try {
const entries = await TauriAPI.listDirectory(rootPath);
const children = entriesToNodes(entries);
const rootName = rootPath.split(/[/\\]/).filter((p) => p).pop() || 'Project';
let rootNode: TreeNode = {
name: rootName,
path: rootPath,
type: 'folder',
children: children,
expanded: true,
loaded: true
};
// 恢复展开状态
if (expandedPaths.size > 0) {
const restoreExpandedState = async (node: TreeNode): Promise<TreeNode> => {
if (node.type === 'folder' && expandedPaths.has(node.path)) {
let children = node.children || [];
if (!node.loaded && node.children) {
children = await loadChildren(node);
}
const restoredChildren = await Promise.all(
children.map((child) => restoreExpandedState(child))
);
return {
...node,
expanded: true,
loaded: true,
children: restoredChildren
};
} else if (node.type === 'folder' && node.children) {
const restoredChildren = await Promise.all(
node.children.map((child) => restoreExpandedState(child))
);
return {
...node,
children: restoredChildren
};
}
return node;
};
rootNode = await restoreExpandedState(rootNode);
}
setTree([rootNode]);
} catch (error) {
console.error('Failed to refresh directory:', error);
}
};
const expandAll = async () => {
const expandNode = async (node: TreeNode): Promise<TreeNode> => {
if (node.type === 'folder') {
let children = node.children || [];
if (!node.loaded) {
try {
const entries = await TauriAPI.listDirectory(node.path);
children = entriesToNodes(entries);
} catch (error) {
console.error('Failed to load children:', error);
children = [];
}
}
const expandedChildren = await Promise.all(
children.map((child) => expandNode(child))
);
return {
...node,
expanded: true,
loaded: true,
children: expandedChildren
};
}
return node;
};
const expandedTree = await Promise.all(tree.map((node) => expandNode(node)));
setTree(expandedTree);
};
const handleRename = async (node: TreeNode) => {
if (!newName || newName === node.name) {
setRenamingNode(null);
return;
}
const pathParts = node.path.split(/[/\\]/);
pathParts[pathParts.length - 1] = newName;
const newPath = pathParts.join('/');
try {
await TauriAPI.renameFileOrFolder(node.path, newPath);
await refreshTree();
setRenamingNode(null);
setNewName('');
} catch (error) {
console.error('Failed to rename:', error);
alert(`重命名失败: ${error}`);
}
};
const handleDeleteClick = (node: TreeNode) => {
setContextMenu(null);
setDeleteDialog({ node });
};
const handleDeleteConfirm = async () => {
if (!deleteDialog) return;
const node = deleteDialog.node;
setDeleteDialog(null);
try {
if (node.type === 'folder') {
await TauriAPI.deleteFolder(node.path);
} else {
await TauriAPI.deleteFile(node.path);
}
await refreshTree();
} catch (error) {
console.error('Failed to delete:', error);
alert(`删除失败: ${error}`);
}
};
const handleCreateFileClick = (parentPath: string) => {
setContextMenu(null);
setPromptDialog({ type: 'create-file', parentPath });
};
const handleCreateFolderClick = (parentPath: string) => {
setContextMenu(null);
setPromptDialog({ type: 'create-folder', parentPath });
};
const handleCreateTemplateFileClick = (parentPath: string, template: any) => {
setContextMenu(null);
setPromptDialog({
type: 'create-template',
parentPath,
templateExtension: template.extension,
templateGetContent: template.getContent
});
};
const handlePromptConfirm = async (value: string) => {
if (!promptDialog) return;
const { type, parentPath, templateExtension, templateGetContent } = promptDialog;
setPromptDialog(null);
let fileName = value;
let targetPath = `${parentPath}/${value}`;
try {
if (type === 'create-file') {
await TauriAPI.createFile(targetPath);
} else if (type === 'create-folder') {
await TauriAPI.createDirectory(targetPath);
} else if (type === 'create-template' && templateExtension && templateGetContent) {
if (!fileName.endsWith(`.${templateExtension}`)) {
fileName = `${fileName}.${templateExtension}`;
targetPath = `${parentPath}/${fileName}`;
}
// 获取内容并通过后端 API 写入文件
const content = await templateGetContent(fileName);
await TauriAPI.writeFileContent(targetPath, content);
}
await refreshTree();
} catch (error) {
console.error(`Failed to ${type}:`, error);
alert(`${type === 'create-file' ? '创建文件' : type === 'create-folder' ? '创建文件夹' : '创建模板文件'}失败: ${error}`);
}
};
const getContextMenuItems = (node: TreeNode | null): ContextMenuItem[] => {
if (!node) {
const baseItems: ContextMenuItem[] = [
{
label: '新建文件',
icon: <FileText size={16} />,
onClick: () => rootPath && handleCreateFileClick(rootPath)
},
{
label: '新建文件夹',
icon: <FolderPlus size={16} />,
onClick: () => rootPath && handleCreateFolderClick(rootPath)
}
];
if (fileActionRegistry && rootPath) {
const templates = fileActionRegistry.getCreationTemplates();
if (templates.length > 0) {
baseItems.push({ label: '', separator: true, onClick: () => {} });
for (const template of templates) {
baseItems.push({
label: template.label,
icon: getIconComponent(template.icon, 16),
onClick: () => handleCreateTemplateFileClick(rootPath, template)
});
}
}
}
return baseItems;
}
const items: ContextMenuItem[] = [];
if (node.type === 'file') {
items.push({
label: '打开文件',
icon: <File size={16} />,
onClick: async () => {
try {
await TauriAPI.openFileWithSystemApp(node.path);
} catch (error) {
console.error('Failed to open file:', error);
}
}
});
if (fileActionRegistry) {
const handlers = fileActionRegistry.getHandlersForFile(node.path);
for (const handler of handlers) {
if (handler.getContextMenuItems) {
const parentPath = node.path.substring(0, node.path.lastIndexOf('/'));
const pluginItems = handler.getContextMenuItems(node.path, parentPath);
for (const pluginItem of pluginItems) {
items.push({
label: pluginItem.label,
icon: pluginItem.icon,
onClick: () => pluginItem.onClick(node.path, parentPath),
disabled: pluginItem.disabled,
separator: pluginItem.separator
});
}
}
}
}
items.push({ label: '', separator: true, onClick: () => {} });
// 文件操作菜单项
items.push({
label: '保存',
icon: <Save size={16} />,
shortcut: 'Ctrl+S',
onClick: () => {
// TODO: 实现保存功能
console.log('Save file:', node.path);
}
});
}
items.push({
label: '重命名',
icon: <Edit3 size={16} />,
shortcut: 'F2',
onClick: () => {
setRenamingNode(node.path);
setNewName(node.name);
}
});
items.push({
label: '批量重命名',
icon: <Edit3 size={16} />,
shortcut: 'Shift+F2',
disabled: true, // TODO: 实现批量重命名
onClick: () => {
console.log('Batch rename');
}
});
items.push({
label: '复制',
icon: <Clipboard size={16} />,
shortcut: 'Ctrl+D',
onClick: () => {
// TODO: 实现复制功能
console.log('Duplicate:', node.path);
}
});
items.push({
label: '删除',
icon: <Trash2 size={16} />,
shortcut: 'Delete',
onClick: () => handleDeleteClick(node)
});
items.push({ label: '', separator: true, onClick: () => {} });
// 资产操作子菜单
items.push({
label: '资产操作',
icon: <Settings size={16} />,
onClick: () => {},
children: [
{
label: '重新导入',
icon: <RefreshCw size={16} />,
onClick: () => {
console.log('Reimport asset:', node.path);
}
},
{
label: '导出...',
icon: <Package size={16} />,
onClick: () => {
console.log('Export asset:', node.path);
}
},
{ label: '', separator: true, onClick: () => {} },
{
label: '迁移资产',
icon: <Folder size={16} />,
onClick: () => {
console.log('Migrate asset:', node.path);
}
}
]
});
// 资产本地化子菜单
items.push({
label: '资产本地化',
icon: <Globe size={16} />,
onClick: () => {},
children: [
{
label: '创建本地化资产',
onClick: () => {
console.log('Create localized asset:', node.path);
}
},
{
label: '导入翻译',
onClick: () => {
console.log('Import translation:', node.path);
}
},
{
label: '导出翻译',
onClick: () => {
console.log('Export translation:', node.path);
}
}
]
});
items.push({ label: '', separator: true, onClick: () => {} });
// 标签和引用
items.push({
label: '管理标签',
icon: <Tag size={16} />,
shortcut: 'Ctrl+T',
onClick: () => {
console.log('Manage tags:', node.path);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 路径复制选项
items.push({
label: '复制引用',
icon: <Link size={16} />,
shortcut: 'Ctrl+C',
onClick: () => {
navigator.clipboard.writeText(node.path);
}
});
items.push({
label: '拷贝Object路径',
icon: <Copy size={16} />,
shortcut: 'Ctrl+Shift+C',
onClick: () => {
// 生成对象路径格式
const objectPath = node.path.replace(/\\/g, '/');
navigator.clipboard.writeText(objectPath);
}
});
items.push({
label: '拷贝包路径',
icon: <Package size={16} />,
shortcut: 'Ctrl+Alt+C',
onClick: () => {
// 生成包路径格式
const packagePath = '/' + node.path.replace(/\\/g, '/').split('/').slice(-2).join('/');
navigator.clipboard.writeText(packagePath);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 引用查看器
items.push({
label: '引用查看器',
icon: <FileSearch size={16} />,
shortcut: 'Alt+Shift+R',
onClick: () => {
console.log('Open reference viewer:', node.path);
}
});
items.push({
label: '尺寸信息图',
icon: <FileSearch size={16} />,
shortcut: 'Alt+Shift+D',
onClick: () => {
console.log('Show size map:', node.path);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
if (node.type === 'folder') {
items.push({
label: '新建文件',
icon: <FileText size={16} />,
onClick: () => handleCreateFileClick(node.path)
});
items.push({
label: '新建文件夹',
icon: <FolderPlus size={16} />,
onClick: () => handleCreateFolderClick(node.path)
});
if (fileActionRegistry) {
const templates = fileActionRegistry.getCreationTemplates();
if (templates.length > 0) {
items.push({ label: '', separator: true, onClick: () => {} });
for (const template of templates) {
items.push({
label: template.label,
icon: getIconComponent(template.icon, 16),
onClick: () => handleCreateTemplateFileClick(node.path, template)
});
}
}
}
items.push({ label: '', separator: true, onClick: () => {} });
}
items.push({
label: '在文件管理器中显示',
icon: <FolderOpen size={16} />,
onClick: async () => {
try {
console.log('[FileTree] showInFolder path:', node.path);
await TauriAPI.showInFolder(node.path);
} catch (error) {
console.error('Failed to show in folder:', error, 'Path:', node.path);
}
}
});
return items;
};
const handleNodeClick = (node: TreeNode, e: React.MouseEvent) => {
if (node.type === 'folder') {
setInternalSelectedPath(node.path);
onSelectFile?.(node.path);
toggleNode(node.path);
} else {
setInternalSelectedPath(node.path);
// Support multi-select with Ctrl/Cmd or Shift
if (onSelectFiles) {
if (e.shiftKey && lastSelectedFilePath) {
// Range select with Shift
const treeToUse = searchQuery ? filteredTree : tree;
const visiblePaths = getVisibleFilePaths(treeToUse);
const lastIndex = visiblePaths.indexOf(lastSelectedFilePath);
const currentIndex = visiblePaths.indexOf(node.path);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const rangePaths = visiblePaths.slice(start, end + 1);
onSelectFiles(rangePaths, { ctrlKey: false, shiftKey: true });
} else {
onSelectFiles([node.path], { ctrlKey: false, shiftKey: false });
setLastSelectedFilePath(node.path);
}
} else {
onSelectFiles([node.path], { ctrlKey: e.ctrlKey || e.metaKey, shiftKey: false });
setLastSelectedFilePath(node.path);
}
} else {
setLastSelectedFilePath(node.path);
}
const extension = node.name.includes('.') ? node.name.split('.').pop() : undefined;
messageHub?.publish('asset-file:selected', {
fileInfo: {
name: node.name,
path: node.path,
extension,
size: node.size,
modified: node.modified,
isDirectory: false
}
});
}
};
const handleNodeDoubleClick = async (node: TreeNode) => {
if (node.type === 'file') {
// Handle .ecs scene files
const ext = node.name.split('.').pop()?.toLowerCase();
if (ext === 'ecs' && onOpenScene) {
onOpenScene(node.path);
return;
}
// 脚本文件使用配置的编辑器打开
// Open script files with configured editor
if (ext === 'ts' || ext === 'tsx' || ext === 'js' || ext === 'jsx') {
const settings = SettingsService.getInstance();
const editorCommand = settings.getScriptEditorCommand();
if (editorCommand) {
// 使用项目路径,如果没有则使用文件所在目录
// Use project path, or file's parent directory if not available
const workingDir = rootPath || node.path.substring(0, node.path.lastIndexOf('\\')) || node.path.substring(0, node.path.lastIndexOf('/'));
try {
await TauriAPI.openWithEditor(workingDir, editorCommand, node.path);
return;
} catch (error) {
console.error('Failed to open with editor:', error);
// 如果失败,回退到系统默认应用
// Fall back to system default app if failed
}
}
}
if (fileActionRegistry) {
const handled = await fileActionRegistry.handleDoubleClick(node.path);
if (handled) {
return;
}
}
try {
await TauriAPI.openFileWithSystemApp(node.path);
} catch (error) {
console.error('Failed to open file:', error);
}
}
};
const handleContextMenu = (e: React.MouseEvent, node: TreeNode | null) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
position: { x: e.clientX, y: e.clientY },
node
});
};
const renderNode = (node: TreeNode, level: number = 0) => {
// Normalize paths for comparison (handle forward/backward slashes)
const normalizedNodePath = node.path.replace(/\\/g, '/');
const normalizedInternalPath = internalSelectedPath?.replace(/\\/g, '/');
const normalizedSelectedPath = selectedPath?.replace(/\\/g, '/');
// Check if this node is selected, normalizing paths for comparison
let isSelected = false;
if (selectedPaths) {
// Check both original path and normalized path in selectedPaths set
isSelected = selectedPaths.has(node.path) || selectedPaths.has(normalizedNodePath);
} else {
isSelected = (normalizedInternalPath || normalizedSelectedPath) === normalizedNodePath;
}
const isRenaming = renamingNode === node.path;
const indent = level * 16;
return (
<div key={node.path}>
<div
className={`tree-node ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${indent}px`, cursor: node.type === 'file' ? 'grab' : 'pointer' }}
onClick={(e) => !isRenaming && handleNodeClick(node, e)}
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
onContextMenu={(e) => handleContextMenu(e, node)}
draggable={node.type === 'file' && !isRenaming}
onDragStart={(e) => {
if (node.type === 'file' && !isRenaming) {
e.dataTransfer.effectAllowed = 'copy';
// Get all selected files for multi-file drag
const selectedFiles = selectedPaths && selectedPaths.has(node.path) && selectedPaths.size > 1
? Array.from(selectedPaths).map((p) => {
const name = p.split(/[/\\]/).pop() || '';
const ext = name.includes('.') ? name.split('.').pop() : '';
return { type: 'file', path: p, name, extension: ext };
})
: [{
type: 'file',
path: node.path,
name: node.name,
extension: node.name.includes('.') ? node.name.split('.').pop() : ''
}];
// Set drag data as JSON array for multi-file support
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
e.dataTransfer.setData('asset-path', node.path);
e.dataTransfer.setData('asset-name', node.name);
const ext = node.name.includes('.') ? node.name.split('.').pop() : '';
e.dataTransfer.setData('asset-extension', ext || '');
e.dataTransfer.setData('text/plain', node.path);
// Add GUID for new asset reference system
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
if (assetRegistry) {
// Convert absolute path to relative path for GUID lookup
const relativePath = assetRegistry.absoluteToRelative(node.path);
if (relativePath) {
const guid = assetRegistry.getGuidByPath(relativePath);
if (guid) {
e.dataTransfer.setData('asset-guid', guid);
}
}
}
// 添加视觉反馈
e.currentTarget.style.opacity = '0.5';
}
}}
onDragEnd={(e) => {
// 恢复透明度
e.currentTarget.style.opacity = '1';
}}
>
<span className="tree-arrow">
{node.type === 'folder' ? (
node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
) : (
<span style={{ width: '14px', display: 'inline-block' }} />
)}
</span>
<span className="tree-icon">
{node.type === 'folder' ? (
node.name.toLowerCase() === 'plugins' || node.name.toLowerCase() === '.ecs' ? (
<Folder size={16} className="system-folder-icon" style={{ color: '#42a5f5' }} />
) : (
<Folder size={16} style={{ color: '#ffa726' }} />
)
) : (
<File size={16} style={{ color: '#90caf9' }} />
)}
</span>
{isRenaming ? (
<input
type="text"
className="tree-rename-input"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={() => handleRename(node)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename(node);
} else if (e.key === 'Escape') {
setRenamingNode(null);
}
}}
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="tree-label">{node.name}</span>
)}
</div>
{node.type === 'folder' && node.expanded && node.children && (
<div className="tree-children">
{node.children.map((child) => renderNode(child, level + 1))}
</div>
)}
</div>
);
};
if (loading) {
return <div className="file-tree loading">Loading...</div>;
}
if (!rootPath || tree.length === 0) {
return <div className="file-tree empty">No folders</div>;
}
return (
<>
<div
className="file-tree"
onContextMenu={(e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('file-tree')) {
handleContextMenu(e, null);
}
}}
>
{filteredTree.map((node) => renderNode(node))}
</div>
{contextMenu && (
<ContextMenu
items={getContextMenuItems(contextMenu.node)}
position={contextMenu.position}
onClose={() => setContextMenu(null)}
/>
)}
{deleteDialog && (
<ConfirmDialog
title="确认删除"
message={
deleteDialog.node.type === 'folder'
? `确定要删除文件夹 "${deleteDialog.node.name}" 及其所有内容吗?\n此操作无法撤销。`
: `确定要删除文件 "${deleteDialog.node.name}" 吗?\n此操作无法撤销。`
}
confirmText="删除"
cancelText="取消"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteDialog(null)}
/>
)}
{promptDialog && (
<PromptDialog
title={
promptDialog.type === 'create-file' ? '新建文件' :
promptDialog.type === 'create-folder' ? '新建文件夹' :
'新建文件'
}
message={
promptDialog.type === 'create-file' ? '请输入文件名:' :
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
}
placeholder={
promptDialog.type === 'create-file' ? '例如: config.json' :
promptDialog.type === 'create-folder' ? '例如: assets' :
'例如: MyFile'
}
confirmText="创建"
cancelText="取消"
onConfirm={handlePromptConfirm}
onCancel={() => setPromptDialog(null)}
/>
)}
</>
);
});