Skip to content

Commit 97db37d

Browse files
committed
feat: lazily render file tree on client, faster backend and snappier UX
1 parent 046cc4f commit 97db37d

2 files changed

Lines changed: 378 additions & 169 deletions

File tree

src/lib/fileTreeBuilder.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2025 NumFOCUS
3+
4+
/**
5+
* File tree builder for lazy-loading file browser
6+
* Builds tree structure client-side from flat file array and renders on demand
7+
*/
8+
9+
// Types
10+
export interface TreeNode {
11+
name: string;
12+
path: string;
13+
type: 'file' | 'folder';
14+
url?: string;
15+
size?: string;
16+
id?: string;
17+
hasChildren?: boolean;
18+
}
19+
20+
export interface CodeFile {
21+
title: string;
22+
url: string;
23+
id?: string;
24+
extra?: {
25+
size_bytes?: number;
26+
};
27+
}
28+
29+
export interface TreeStructure {
30+
root: TreeNode[];
31+
map: Map<string, TreeNode[]>;
32+
}
33+
34+
// Format bytes helper
35+
export function formatBytes(bytes: number): string {
36+
if (bytes === 0) return '0 B';
37+
const k = 1024;
38+
const sizes = ['B', 'KB', 'MB', 'GB'];
39+
const i = Math.floor(Math.log(bytes) / Math.log(k));
40+
return `${Math.round(bytes / Math.pow(k, i) * 10) / 10} ${sizes[i]}`;
41+
}
42+
43+
// Get file icon based on extension
44+
export function getFileIcon(filename: string): string {
45+
const ext = filename.toLowerCase();
46+
47+
// Medical imaging formats
48+
const medicalExts = ['.nii', '.nii.gz', '.dcm', '.nrrd', '.mha', '.mhd', '.vtk', '.vti', '.stl', '.ply', '.mz3', '.off', '.obj'];
49+
if (medicalExts.some(e => ext.endsWith(e))) return 'file-zipper';
50+
51+
// Images
52+
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif'];
53+
if (imageExts.some(e => ext.endsWith(e))) return 'file-image';
54+
55+
// Code files
56+
const codeIconMap: Record<string, string> = {
57+
'.py': 'file-code', '.cpp': 'file-code', '.cxx': 'file-code', '.h': 'file-code',
58+
'.hxx': 'file-code', '.txx': 'file-code', '.hpp': 'file-code', '.c': 'file-code',
59+
'.js': 'file-code', '.ts': 'file-code', '.java': 'file-code', '.cs': 'file-code',
60+
'.cmake': 'file-code', '.json': 'file-code', '.xml': 'file-code', '.yaml': 'file-code',
61+
'.yml': 'file-code', '.md': 'file-lines', '.txt': 'file-lines',
62+
};
63+
64+
for (const [extension, icon] of Object.entries(codeIconMap)) {
65+
if (ext.endsWith(extension)) return icon;
66+
}
67+
68+
return 'file';
69+
}
70+
71+
// Build tree structure from flat codeFiles array
72+
export function buildTreeStructure(files: CodeFile[]): TreeStructure {
73+
const root: TreeNode[] = [];
74+
const folderMap = new Map<string, TreeNode>();
75+
const childrenMap = new Map<string, TreeNode[]>();
76+
77+
// Initialize root children
78+
childrenMap.set('', []);
79+
80+
files.forEach((file) => {
81+
const relativePath = file.title.replace('root/code/', '');
82+
const parts = relativePath.split('/');
83+
const fileName = parts[parts.length - 1];
84+
85+
// Build folder structure
86+
let currentPath = '';
87+
for (let i = 0; i < parts.length - 1; i++) {
88+
const folderName = parts[i];
89+
const parentPath = currentPath;
90+
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
91+
92+
if (!folderMap.has(currentPath)) {
93+
const folderNode: TreeNode = {
94+
name: folderName,
95+
path: currentPath,
96+
type: 'folder',
97+
hasChildren: true
98+
};
99+
folderMap.set(currentPath, folderNode);
100+
childrenMap.set(currentPath, []);
101+
102+
// Add to parent's children
103+
const parentChildren = childrenMap.get(parentPath) || [];
104+
parentChildren.push(folderNode);
105+
childrenMap.set(parentPath, parentChildren);
106+
}
107+
}
108+
109+
// Add file to its parent folder
110+
const fileNode: TreeNode = {
111+
name: fileName,
112+
path: relativePath,
113+
type: 'file',
114+
url: file.url,
115+
size: file.extra?.size_bytes ? formatBytes(file.extra.size_bytes) : undefined,
116+
id: file.id
117+
};
118+
119+
const parentPath = parts.length === 1 ? '' : parts.slice(0, -1).join('/');
120+
const parentChildren = childrenMap.get(parentPath) || [];
121+
parentChildren.push(fileNode);
122+
childrenMap.set(parentPath, parentChildren);
123+
});
124+
125+
return { root: childrenMap.get('') || [], map: childrenMap };
126+
}
127+
128+
// Create a single tree item element (without children for folders)
129+
export function createTreeItem(node: TreeNode, isLazy: boolean = true): HTMLElement {
130+
const item = document.createElement('wa-tree-item');
131+
item.setAttribute('data-path', node.path);
132+
item.setAttribute('data-type', node.type);
133+
134+
if (node.type === 'folder') {
135+
// Mark as lazy-loadable if it has children
136+
if (isLazy && node.hasChildren) {
137+
item.setAttribute('lazy', '');
138+
item.setAttribute('data-children-loaded', 'false');
139+
}
140+
141+
const icon = document.createElement('wa-icon');
142+
icon.setAttribute('name', 'folder');
143+
icon.setAttribute('variant', 'regular');
144+
item.appendChild(icon);
145+
item.appendChild(document.createTextNode(node.name));
146+
} else {
147+
item.setAttribute('data-url', node.url || '');
148+
item.setAttribute('data-cid', node.id || '');
149+
150+
const icon = document.createElement('wa-icon');
151+
icon.setAttribute('name', getFileIcon(node.name));
152+
icon.setAttribute('variant', 'regular');
153+
item.appendChild(icon);
154+
item.appendChild(document.createTextNode(node.name));
155+
156+
if (node.size) {
157+
const actions = document.createElement('span');
158+
actions.setAttribute('slot', 'end');
159+
actions.className = 'file-actions';
160+
161+
const sizeSpan = document.createElement('span');
162+
sizeSpan.className = 'file-size';
163+
sizeSpan.textContent = node.size;
164+
actions.appendChild(sizeSpan);
165+
166+
const copyIcon = document.createElement('wa-icon');
167+
copyIcon.setAttribute('name', 'copy');
168+
copyIcon.setAttribute('variant', 'regular');
169+
copyIcon.className = 'action-icon copy-cid-icon';
170+
copyIcon.setAttribute('data-cid', node.id || '');
171+
copyIcon.setAttribute('title', 'Copy CID');
172+
actions.appendChild(copyIcon);
173+
174+
const downloadIcon = document.createElement('wa-icon');
175+
downloadIcon.setAttribute('name', 'download');
176+
downloadIcon.className = 'action-icon download-icon';
177+
downloadIcon.setAttribute('data-url', node.url || '');
178+
downloadIcon.setAttribute('title', 'Download file');
179+
actions.appendChild(downloadIcon);
180+
181+
item.appendChild(actions);
182+
}
183+
}
184+
185+
return item;
186+
}
187+
188+
/**
189+
* FileTreeManager handles lazy-loading file tree initialization and expansion
190+
*/
191+
export class FileTreeManager {
192+
private treeDataMap: Map<string, TreeNode[]> = new Map();
193+
private initialized = false;
194+
195+
/**
196+
* Initialize the file tree from raw code files data
197+
* Only renders top-level items; children are loaded on folder expand
198+
*/
199+
initialize(codeFiles: CodeFile[]): boolean {
200+
if (this.initialized) return false;
201+
202+
const fileTreeEl = document.getElementById('file-tree');
203+
const loadingEl = document.getElementById('file-tree-loading');
204+
205+
if (!fileTreeEl || !codeFiles || codeFiles.length === 0) {
206+
if (loadingEl) loadingEl.style.display = 'none';
207+
return false;
208+
}
209+
210+
console.log(`Building tree from ${codeFiles.length} files...`);
211+
const startTime = performance.now();
212+
213+
// Build tree structure client-side
214+
const { root, map } = buildTreeStructure(codeFiles);
215+
this.treeDataMap = map;
216+
217+
// Only render top-level items
218+
for (const node of root) {
219+
fileTreeEl.appendChild(createTreeItem(node));
220+
}
221+
222+
// Set up lazy loading on folder expand
223+
fileTreeEl.addEventListener('wa-lazy-load', (event: Event) => {
224+
const item = event.target as HTMLElement;
225+
if (item.getAttribute('data-type') === 'folder') {
226+
this.loadFolderChildren(item);
227+
}
228+
});
229+
230+
// Also listen for wa-expand as fallback
231+
fileTreeEl.addEventListener('wa-expand', (event: Event) => {
232+
const item = event.target as HTMLElement;
233+
if (item.getAttribute('data-type') === 'folder' && item.getAttribute('data-children-loaded') !== 'true') {
234+
this.loadFolderChildren(item);
235+
}
236+
});
237+
238+
if (loadingEl) {
239+
loadingEl.style.display = 'none';
240+
}
241+
242+
const elapsed = performance.now() - startTime;
243+
console.log(`File tree built in ${elapsed.toFixed(0)}ms (${root.length} top-level items)`);
244+
this.initialized = true;
245+
return true;
246+
}
247+
248+
/**
249+
* Load children for a folder when expanded
250+
*/
251+
loadFolderChildren(folderItem: HTMLElement): void {
252+
const folderPath = folderItem.getAttribute('data-path');
253+
if (!folderPath || folderItem.getAttribute('data-children-loaded') === 'true') return;
254+
255+
const children = this.treeDataMap.get(folderPath);
256+
if (!children) return;
257+
258+
// Create and append child items
259+
for (const child of children) {
260+
folderItem.appendChild(createTreeItem(child));
261+
}
262+
263+
folderItem.setAttribute('data-children-loaded', 'true');
264+
folderItem.removeAttribute('lazy');
265+
}
266+
267+
/**
268+
* Check if tree has been initialized
269+
*/
270+
isInitialized(): boolean {
271+
return this.initialized;
272+
}
273+
}

0 commit comments

Comments
 (0)