2025-10-15 10:08:15 +08:00
|
|
|
import { useState, useEffect } from 'react';
|
2025-11-04 18:29:28 +08:00
|
|
|
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, ChevronsDown, ChevronsUp } from 'lucide-react';
|
2025-10-15 10:08:15 +08:00
|
|
|
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
2025-11-04 18:29:28 +08:00
|
|
|
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
|
|
|
|
import { Core } from '@esengine/ecs-framework';
|
|
|
|
|
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
|
|
|
|
import { ConfirmDialog } from './ConfirmDialog';
|
|
|
|
|
import { PromptDialog } from './PromptDialog';
|
2025-10-15 10:08:15 +08:00
|
|
|
import '../styles/FileTree.css';
|
|
|
|
|
|
|
|
|
|
interface TreeNode {
|
|
|
|
|
name: string;
|
|
|
|
|
path: string;
|
2025-11-04 18:29:28 +08:00
|
|
|
type: 'folder' | 'file';
|
|
|
|
|
size?: number;
|
|
|
|
|
modified?: number;
|
2025-10-15 10:08:15 +08:00
|
|
|
children?: TreeNode[];
|
|
|
|
|
expanded?: boolean;
|
|
|
|
|
loaded?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FileTreeProps {
|
|
|
|
|
rootPath: string | null;
|
|
|
|
|
onSelectFile?: (path: string) => void;
|
|
|
|
|
selectedPath?: string | null;
|
2025-11-04 18:29:28 +08:00
|
|
|
messageHub?: MessageHub;
|
|
|
|
|
searchQuery?: string;
|
|
|
|
|
showFiles?: boolean;
|
2025-10-15 10:08:15 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-04 18:29:28 +08:00
|
|
|
export function FileTree({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }: FileTreeProps) {
|
2025-11-02 23:50:41 +08:00
|
|
|
const [tree, setTree] = useState<TreeNode[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
2025-11-04 18:29:28 +08:00
|
|
|
const [internalSelectedPath, setInternalSelectedPath] = useState<string | null>(null);
|
|
|
|
|
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;
|
|
|
|
|
templateContent?: (fileName: string) => Promise<string>;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
|
|
|
|
|
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
2025-10-15 10:08:15 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (rootPath) {
|
|
|
|
|
loadRootDirectory(rootPath);
|
2025-10-15 10:08:15 +08:00
|
|
|
} else {
|
2025-11-02 23:50:41 +08:00
|
|
|
setTree([]);
|
|
|
|
|
}
|
|
|
|
|
}, [rootPath]);
|
|
|
|
|
|
2025-11-04 18:29:28 +08:00
|
|
|
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]);
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
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);
|
2025-10-15 10:08:15 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
|
|
|
|
|
return entries
|
2025-11-04 18:29:28 +08:00
|
|
|
.sort((a, b) => {
|
|
|
|
|
if (a.is_dir === b.is_dir) return a.name.localeCompare(b.name);
|
|
|
|
|
return a.is_dir ? -1 : 1;
|
|
|
|
|
})
|
2025-11-02 23:50:41 +08:00
|
|
|
.map((entry) => ({
|
|
|
|
|
name: entry.name,
|
|
|
|
|
path: entry.path,
|
2025-11-04 18:29:28 +08:00
|
|
|
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
|
|
|
|
size: entry.size,
|
|
|
|
|
modified: entry.modified,
|
|
|
|
|
children: entry.is_dir ? [] : undefined,
|
2025-11-02 23:50:41 +08:00
|
|
|
expanded: false,
|
2025-11-04 18:29:28 +08:00
|
|
|
loaded: entry.is_dir ? false : undefined
|
2025-11-02 23:50:41 +08:00
|
|
|
}));
|
|
|
|
|
};
|
2025-10-15 10:08:15 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
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 [];
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-10-15 10:08:15 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
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);
|
|
|
|
|
};
|
2025-10-15 10:08:15 +08:00
|
|
|
|
2025-11-04 18:29:28 +08:00
|
|
|
const refreshTree = async () => {
|
|
|
|
|
if (rootPath) {
|
|
|
|
|
await loadRootDirectory(rootPath);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 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);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
templateContent: template.createContent
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePromptConfirm = async (value: string) => {
|
|
|
|
|
if (!promptDialog) return;
|
|
|
|
|
|
|
|
|
|
const { type, parentPath, templateExtension, templateContent } = 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 && templateContent) {
|
|
|
|
|
if (!fileName.endsWith(`.${templateExtension}`)) {
|
|
|
|
|
fileName = `${fileName}.${templateExtension}`;
|
|
|
|
|
targetPath = `${parentPath}/${fileName}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = await templateContent(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: template.icon,
|
|
|
|
|
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: '重命名',
|
|
|
|
|
icon: <Edit3 size={16} />,
|
|
|
|
|
onClick: () => {
|
|
|
|
|
setRenamingNode(node.path);
|
|
|
|
|
setNewName(node.name);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
items.push({
|
|
|
|
|
label: '删除',
|
|
|
|
|
icon: <Trash2 size={16} />,
|
|
|
|
|
onClick: () => handleDeleteClick(node)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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: template.icon,
|
|
|
|
|
onClick: () => handleCreateTemplateFileClick(node.path, template)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items.push({ label: '', separator: true, onClick: () => {} });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items.push({
|
|
|
|
|
label: '在文件管理器中显示',
|
|
|
|
|
icon: <FolderOpen size={16} />,
|
|
|
|
|
onClick: async () => {
|
|
|
|
|
try {
|
|
|
|
|
await TauriAPI.showInFolder(node.path);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to show in folder:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
items.push({
|
|
|
|
|
label: '复制路径',
|
|
|
|
|
icon: <Copy size={16} />,
|
|
|
|
|
onClick: () => {
|
|
|
|
|
navigator.clipboard.writeText(node.path);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return items;
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
const handleNodeClick = (node: TreeNode) => {
|
2025-11-04 18:29:28 +08:00
|
|
|
if (node.type === 'folder') {
|
|
|
|
|
setInternalSelectedPath(node.path);
|
|
|
|
|
onSelectFile?.(node.path);
|
|
|
|
|
toggleNode(node.path);
|
|
|
|
|
} else {
|
|
|
|
|
setInternalSelectedPath(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') {
|
|
|
|
|
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
|
|
|
|
|
});
|
2025-11-02 23:50:41 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderNode = (node: TreeNode, level: number = 0) => {
|
2025-11-04 18:29:28 +08:00
|
|
|
const isSelected = (internalSelectedPath || selectedPath) === node.path;
|
|
|
|
|
const isRenaming = renamingNode === node.path;
|
2025-11-02 23:50:41 +08:00
|
|
|
const indent = level * 16;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={node.path}>
|
|
|
|
|
<div
|
|
|
|
|
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
|
|
|
|
style={{ paddingLeft: `${indent}px` }}
|
2025-11-04 18:29:28 +08:00
|
|
|
onClick={() => !isRenaming && handleNodeClick(node)}
|
|
|
|
|
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
|
|
|
|
|
onContextMenu={(e) => handleContextMenu(e, node)}
|
2025-11-02 23:50:41 +08:00
|
|
|
>
|
|
|
|
|
<span className="tree-arrow">
|
2025-11-04 18:29:28 +08:00
|
|
|
{node.type === 'folder' ? (
|
|
|
|
|
node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
|
|
|
|
) : (
|
|
|
|
|
<span style={{ width: '14px', display: 'inline-block' }} />
|
|
|
|
|
)}
|
2025-11-02 23:50:41 +08:00
|
|
|
</span>
|
|
|
|
|
<span className="tree-icon">
|
2025-11-04 18:29:28 +08:00
|
|
|
{node.type === 'folder' ? (
|
|
|
|
|
<Folder size={16} style={{ color: '#ffa726' }} />
|
|
|
|
|
) : (
|
|
|
|
|
<File size={16} style={{ color: '#90caf9' }} />
|
|
|
|
|
)}
|
2025-11-02 23:50:41 +08:00
|
|
|
</span>
|
2025-11-04 18:29:28 +08:00
|
|
|
{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>
|
|
|
|
|
)}
|
2025-11-02 23:50:41 +08:00
|
|
|
</div>
|
2025-11-04 18:29:28 +08:00
|
|
|
{node.type === 'folder' && node.expanded && node.children && (
|
2025-11-02 23:50:41 +08:00
|
|
|
<div className="tree-children">
|
|
|
|
|
{node.children.map((child) => renderNode(child, level + 1))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
2025-10-15 10:08:15 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
if (loading) {
|
|
|
|
|
return <div className="file-tree loading">Loading...</div>;
|
|
|
|
|
}
|
2025-10-15 10:08:15 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
if (!rootPath || tree.length === 0) {
|
|
|
|
|
return <div className="file-tree empty">No folders</div>;
|
|
|
|
|
}
|
2025-10-15 10:08:15 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
return (
|
2025-11-04 18:29:28 +08:00
|
|
|
<>
|
|
|
|
|
<div className="file-tree-toolbar">
|
|
|
|
|
<button
|
|
|
|
|
className="file-tree-toolbar-btn"
|
|
|
|
|
onClick={expandAll}
|
|
|
|
|
title="展开全部文件夹"
|
|
|
|
|
>
|
|
|
|
|
<ChevronsDown size={14} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="file-tree-toolbar-btn"
|
|
|
|
|
onClick={collapseAll}
|
|
|
|
|
title="收缩全部文件夹"
|
|
|
|
|
>
|
|
|
|
|
<ChevronsUp size={14} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<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)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2025-11-02 23:50:41 +08:00
|
|
|
);
|
2025-10-15 10:08:15 +08:00
|
|
|
}
|