1eb05132f1
build / setup (compute matrix) (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
73 lines
3.6 KiB
TypeScript
73 lines
3.6 KiB
TypeScript
import type {ModelEntry} from './types';
|
|
|
|
export interface ProjectTreeFile {path:string;size:number;}
|
|
|
|
export interface ProjectTreeNode {
|
|
name:string;
|
|
path:string;
|
|
kind:'directory'|'file';
|
|
size?:number;
|
|
children?:ProjectTreeNode[];
|
|
}
|
|
|
|
interface MutableDirectory {
|
|
name:string;
|
|
path:string;
|
|
directories:Map<string,MutableDirectory>;
|
|
files:ProjectTreeNode[];
|
|
}
|
|
|
|
function compareNodes(a:ProjectTreeNode,b:ProjectTreeNode):number {
|
|
if(a.kind!==b.kind)return a.kind==='directory'?-1:1;
|
|
return a.name.localeCompare(b.name,'zh-CN',{numeric:true,sensitivity:'base'});
|
|
}
|
|
|
|
/** 将规范化后的工程路径转换为“目录优先、名称排序”的资源树。 */
|
|
// 同文件导出纯函数是为了让资源树的数据转换可独立测试。
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export function buildProjectTree(files:ProjectTreeFile[]):ProjectTreeNode[] {
|
|
const root:MutableDirectory={name:'',path:'',directories:new Map(),files:[]};
|
|
for(const file of files){
|
|
const parts=file.path.split('/').filter(Boolean);
|
|
if(!parts.length)continue;
|
|
let parent=root;
|
|
for(const part of parts.slice(0,-1)){
|
|
const path=parent.path?`${parent.path}/${part}`:part;
|
|
let directory=parent.directories.get(part);
|
|
if(!directory){directory={name:part,path,directories:new Map(),files:[]};parent.directories.set(part,directory);}
|
|
parent=directory;
|
|
}
|
|
parent.files.push({name:parts.at(-1)!,path:file.path,kind:'file',size:file.size});
|
|
}
|
|
const finish=(directory:MutableDirectory):ProjectTreeNode[]=>[
|
|
...Array.from(directory.directories.values(),child=>({name:child.name,path:child.path,kind:'directory' as const,children:finish(child)})),
|
|
...directory.files,
|
|
].sort(compareNodes);
|
|
return finish(root);
|
|
}
|
|
|
|
function formatSize(bytes:number):string {
|
|
if(bytes<1024)return `${bytes} B`;
|
|
if(bytes<1024*1024)return `${(bytes/1024).toFixed(bytes<10*1024?1:0)} KB`;
|
|
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function TreeNodes({nodes,entryFormats,selectedEntry}:{nodes:ProjectTreeNode[];entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string}){
|
|
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">
|
|
{nodes.map(node=>node.kind==='directory'?<li key={`d:${node.path}`}>
|
|
<details open>
|
|
<summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary>
|
|
<TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry}/>
|
|
</details>
|
|
</li>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}>
|
|
<span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span>
|
|
</li>)}
|
|
</ul>;
|
|
}
|
|
|
|
export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){
|
|
const nodes=buildProjectTree(files);
|
|
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
|
|
return <nav aria-label="工程文件树"><TreeNodes nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry}/></nav>;
|
|
}
|