支持树形资源管理器

This commit is contained in:
YHH
2025-10-15 10:08:15 +08:00
parent 00fc6dfd67
commit b69b81f63a
4 changed files with 475 additions and 95 deletions

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { FileTree } from './FileTree';
import { ResizablePanel } from './ResizablePanel';
import '../styles/AssetBrowser.css'; import '../styles/AssetBrowser.css';
interface AssetItem { interface AssetItem {
@@ -14,10 +16,13 @@ interface AssetBrowserProps {
locale: string; locale: string;
} }
type ViewMode = 'tree-split' | 'tree-only';
export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) { export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
const [currentPath, setCurrentPath] = useState<string>(''); const [viewMode, setViewMode] = useState<ViewMode>('tree-split');
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<AssetItem[]>([]); const [assets, setAssets] = useState<AssetItem[]>([]);
const [selectedAsset, setSelectedAsset] = useState<string | null>(null); const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const translations = { const translations = {
@@ -26,22 +31,26 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
noProject: 'No project loaded', noProject: 'No project loaded',
loading: 'Loading...', loading: 'Loading...',
empty: 'No assets found', empty: 'No assets found',
search: 'Search...',
viewTreeSplit: 'Tree + List',
viewTreeOnly: 'Tree Only',
name: 'Name', name: 'Name',
type: 'Type', type: 'Type',
file: 'File', file: 'File',
folder: 'Folder', folder: 'Folder'
backToParent: 'Back to parent folder'
}, },
zh: { zh: {
title: '资产', title: '资产',
noProject: '没有加载项目', noProject: '没有加载项目',
loading: '加载中...', loading: '加载中...',
empty: '没有找到资产', empty: '没有找到资产',
search: '搜索...',
viewTreeSplit: '树形+列表',
viewTreeOnly: '纯树形',
name: '名称', name: '名称',
type: '类型', type: '类型',
file: '文件', file: '文件',
folder: '文件夹', folder: '文件夹'
backToParent: '返回上一级'
} }
}; };
@@ -49,13 +58,14 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
useEffect(() => { useEffect(() => {
if (projectPath) { if (projectPath) {
setCurrentPath(projectPath); if (viewMode === 'tree-split') {
loadAssets(projectPath); loadAssets(projectPath);
}
} else { } else {
setAssets([]); setAssets([]);
setCurrentPath(''); setSelectedPath(null);
} }
}, [projectPath]); }, [projectPath, viewMode]);
const loadAssets = async (path: string) => { const loadAssets = async (path: string) => {
setLoading(true); setLoading(true);
@@ -83,28 +93,26 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
} }
}; };
const handleAssetClick = (asset: AssetItem) => { const handleTreeSelect = (path: string) => {
setSelectedAsset(asset.path); setSelectedPath(path);
if (asset.type === 'folder') { if (viewMode === 'tree-split') {
setCurrentPath(asset.path); loadAssets(path);
loadAssets(asset.path);
} }
}; };
const handleAssetClick = (asset: AssetItem) => {
setSelectedPath(asset.path);
};
const handleAssetDoubleClick = (asset: AssetItem) => { const handleAssetDoubleClick = (asset: AssetItem) => {
console.log('Open asset:', asset); console.log('Open asset:', asset);
}; };
const handleBackToParent = () => { const filteredAssets = searchQuery
if (!currentPath || !projectPath) return; ? assets.filter(asset =>
if (currentPath === projectPath) return; asset.type === 'file' && asset.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const parentPath = currentPath.split(/[\\/]/).slice(0, -1).join(currentPath.includes('\\') ? '\\' : '/'); : assets.filter(asset => asset.type === 'file');
setCurrentPath(parentPath);
loadAssets(parentPath);
};
const canGoBack = currentPath && projectPath && currentPath !== projectPath;
const getFileIcon = (extension?: string) => { const getFileIcon = (extension?: string) => {
switch (extension?.toLowerCase()) { switch (extension?.toLowerCase()) {
@@ -147,12 +155,6 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
} }
}; };
const getFolderIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon folder">
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
</svg>
);
if (!projectPath) { if (!projectPath) {
return ( return (
<div className="asset-browser"> <div className="asset-browser">
@@ -166,69 +168,114 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
); );
} }
return ( const renderListView = () => (
<div className="asset-browser"> <div className="asset-browser-list">
<div className="asset-browser-header"> <div className="asset-browser-toolbar">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}> <input
<h3 style={{ margin: 0 }}>{t.title}</h3> type="text"
{canGoBack && ( className="asset-search"
<button placeholder={t.search}
onClick={handleBackToParent} value={searchQuery}
className="back-button" onChange={(e) => setSearchQuery(e.target.value)}
title={t.backToParent} />
style={{
padding: '4px 8px',
background: '#0e639c',
color: '#fff',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</button>
)}
</div> </div>
<div className="asset-path">{currentPath}</div>
</div>
{loading ? ( {loading ? (
<div className="asset-browser-loading"> <div className="asset-browser-loading">
<p>{t.loading}</p> <p>{t.loading}</p>
</div> </div>
) : assets.length === 0 ? ( ) : filteredAssets.length === 0 ? (
<div className="asset-browser-empty"> <div className="asset-browser-empty">
<p>{t.empty}</p> <p>{t.empty}</p>
</div> </div>
) : ( ) : (
<div className="asset-browser-content">
<div className="asset-list"> <div className="asset-list">
{assets.map((asset, index) => ( {filteredAssets.map((asset, index) => (
<div <div
key={index} key={index}
className={`asset-item ${selectedAsset === asset.path ? 'selected' : ''}`} className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
onClick={() => handleAssetClick(asset)} onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)} onDoubleClick={() => handleAssetDoubleClick(asset)}
> >
{asset.type === 'folder' ? getFolderIcon() : getFileIcon(asset.extension)} {getFileIcon(asset.extension)}
<div className="asset-name" title={asset.name}> <div className="asset-name" title={asset.name}>
{asset.name} {asset.name}
</div> </div>
<div className="asset-type"> <div className="asset-type">
{asset.type === 'folder' ? t.folder : asset.extension || t.file} {asset.extension || t.file}
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div>
)} )}
</div> </div>
); );
return (
<div className="asset-browser">
<div className="asset-browser-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<h3 style={{ margin: 0 }}>{t.title}</h3>
<div className="view-mode-buttons">
<button
className={`view-mode-btn ${viewMode === 'tree-split' ? 'active' : ''}`}
onClick={() => setViewMode('tree-split')}
title={t.viewTreeSplit}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="18"/>
<rect x="14" y="3" width="7" height="18"/>
</svg>
</button>
<button
className={`view-mode-btn ${viewMode === 'tree-only' ? 'active' : ''}`}
onClick={() => setViewMode('tree-only')}
title={t.viewTreeOnly}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18"/>
</svg>
</button>
</div>
</div>
</div>
<div className="asset-browser-content">
{viewMode === 'tree-only' ? (
<div className="asset-browser-tree-only">
<div className="asset-browser-toolbar">
<input
type="text"
className="asset-search"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<FileTree
rootPath={projectPath}
onSelectFile={handleTreeSelect}
selectedPath={selectedPath}
/>
</div>
) : (
<ResizablePanel
direction="horizontal"
defaultSize={200}
minSize={150}
maxSize={400}
leftOrTop={
<div className="asset-browser-tree">
<FileTree
rootPath={projectPath}
onSelectFile={handleTreeSelect}
selectedPath={selectedPath}
/>
</div>
}
rightOrBottom={renderListView()}
/>
)}
</div>
</div>
);
} }

View File

@@ -0,0 +1,175 @@
import { useState, useEffect } from 'react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import '../styles/FileTree.css';
interface TreeNode {
name: string;
path: string;
type: 'file' | 'folder';
extension?: string;
children?: TreeNode[];
expanded?: boolean;
loaded?: boolean;
}
interface FileTreeProps {
rootPath: string | null;
onSelectFile?: (path: string) => void;
selectedPath?: string | null;
}
export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps) {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (rootPath) {
loadRootDirectory(rootPath);
} else {
setTree([]);
}
}, [rootPath]);
const loadRootDirectory = async (path: string) => {
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const nodes = entriesToNodes(entries);
setTree(nodes);
} catch (error) {
console.error('Failed to load directory:', error);
setTree([]);
} finally {
setLoading(false);
}
};
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
return entries.map(entry => ({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' : 'file',
extension: !entry.is_dir && entry.name.includes('.')
? entry.name.split('.').pop()
: undefined,
children: entry.is_dir ? [] : undefined,
expanded: false,
loaded: false
}));
};
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 && node.type === 'folder') {
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 handleNodeClick = (node: TreeNode) => {
onSelectFile?.(node.path);
if (node.type === 'folder') {
toggleNode(node.path);
}
};
const getFileIcon = (extension?: string) => {
switch (extension?.toLowerCase()) {
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
return '📄';
case 'json':
return '📋';
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
return '🖼️';
default:
return '📄';
}
};
const renderNode = (node: TreeNode, level: number = 0) => {
const isSelected = selectedPath === node.path;
const indent = level * 16;
return (
<div key={node.path}>
<div
className={`tree-node ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${indent}px` }}
onClick={() => handleNodeClick(node)}
>
{node.type === 'folder' && (
<span className="tree-arrow">
{node.expanded ? '▼' : '▶'}
</span>
)}
<span className="tree-icon">
{node.type === 'folder' ? '📁' : getFileIcon(node.extension)}
</span>
<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 files</div>;
}
return (
<div className="file-tree">
{tree.map(node => renderNode(node))}
</div>
);
}

View File

@@ -13,24 +13,87 @@
} }
.asset-browser-header h3 { .asset-browser-header h3 {
margin: 0 0 8px 0; margin: 0;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #cccccc; color: #cccccc;
} }
.asset-path { .view-mode-buttons {
font-size: 11px; display: flex;
color: #858585; gap: 4px;
overflow: hidden; }
text-overflow: ellipsis;
white-space: nowrap; .view-mode-btn {
padding: 4px 8px;
background: transparent;
border: 1px solid #3e3e3e;
border-radius: 3px;
color: #cccccc;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.view-mode-btn:hover {
background: #2a2d2e;
border-color: #007acc;
}
.view-mode-btn.active {
background: #0e639c;
border-color: #0e639c;
color: #ffffff;
} }
.asset-browser-content { .asset-browser-content {
flex: 1; flex: 1;
overflow-y: auto; overflow: hidden;
padding: 10px; display: flex;
flex-direction: column;
}
.asset-browser-tree,
.asset-browser-tree-only {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.asset-browser-list {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.asset-browser-toolbar {
padding: 8px;
background: #252526;
border-bottom: 1px solid #3e3e3e;
}
.asset-search {
width: 100%;
padding: 6px 10px;
background: #3c3c3c;
border: 1px solid #3e3e3e;
border-radius: 3px;
color: #cccccc;
font-size: 13px;
outline: none;
}
.asset-search:focus {
border-color: #007acc;
background: #3e3e3e;
}
.asset-search::placeholder {
color: #858585;
} }
.asset-browser-loading, .asset-browser-loading,
@@ -44,9 +107,13 @@
} }
.asset-list { .asset-list {
flex: 1;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px; gap: 10px;
padding: 10px;
overflow-y: auto;
align-content: start;
} }
.asset-item { .asset-item {
@@ -101,20 +168,33 @@
text-transform: uppercase; text-transform: uppercase;
} }
.asset-browser-content::-webkit-scrollbar { .asset-browser-list,
.asset-list {
overflow-y: auto;
}
.asset-browser-list::-webkit-scrollbar,
.asset-list::-webkit-scrollbar,
.file-tree::-webkit-scrollbar {
width: 10px; width: 10px;
} }
.asset-browser-content::-webkit-scrollbar-track { .asset-browser-list::-webkit-scrollbar-track,
.asset-list::-webkit-scrollbar-track,
.file-tree::-webkit-scrollbar-track {
background: #1e1e1e; background: #1e1e1e;
} }
.asset-browser-content::-webkit-scrollbar-thumb { .asset-browser-list::-webkit-scrollbar-thumb,
.asset-list::-webkit-scrollbar-thumb,
.file-tree::-webkit-scrollbar-thumb {
background: #424242; background: #424242;
border-radius: 5px; border-radius: 5px;
} }
.asset-browser-content::-webkit-scrollbar-thumb:hover { .asset-browser-list::-webkit-scrollbar-thumb:hover,
.asset-list::-webkit-scrollbar-thumb:hover,
.file-tree::-webkit-scrollbar-thumb:hover {
background: #4e4e4e; background: #4e4e4e;
} }

View File

@@ -0,0 +1,78 @@
.file-tree {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: #1e1e1e;
color: #cccccc;
user-select: none;
}
.file-tree::-webkit-scrollbar {
width: 10px;
}
.file-tree::-webkit-scrollbar-track {
background: #1e1e1e;
}
.file-tree::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 5px;
}
.file-tree::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
.file-tree.loading,
.file-tree.empty {
display: flex;
align-items: center;
justify-content: center;
color: #858585;
font-size: 12px;
}
.tree-node {
display: flex;
align-items: center;
padding: 4px 8px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
transition: background 0.1s ease;
}
.tree-node:hover {
background: #2a2d2e;
}
.tree-node.selected {
background: #37373d;
}
.tree-arrow {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 4px;
font-size: 10px;
color: #cccccc;
}
.tree-icon {
margin-right: 6px;
font-size: 14px;
}
.tree-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-children {
/* Children are indented via inline style */
}