Skip to content

Commit 7fd0e1e

Browse files
authored
feat: Add FileTree component with virtual scrolling (Acode-Foundation#1774)
1 parent 2c926f4 commit 7fd0e1e

File tree

5 files changed

+676
-22
lines changed

5 files changed

+676
-22
lines changed

src/components/fileTree/index.js

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
import "./style.scss";
2+
import tile from "components/tile";
3+
import VirtualList from "components/virtualList";
4+
import tag from "html-tag-js";
5+
import helpers from "utils/helpers";
6+
import Path from "utils/Path";
7+
8+
const VIRTUALIZATION_THRESHOLD = 100;
9+
const ITEM_HEIGHT = 30;
10+
11+
/**
12+
* @typedef {object} FileTreeOptions
13+
* @property {function(string): Promise<Array>} getEntries - Function to get directory entries
14+
* @property {function(string, string): void} [onFileClick] - File click handler
15+
* @property {function(string, string, string, HTMLElement): void} [onContextMenu] - Context menu handler
16+
* @property {Object<string, boolean>} [expandedState] - Map of expanded folder URLs
17+
* @property {function(string, boolean): void} [onExpandedChange] - Called when folder expanded state changes
18+
*/
19+
20+
/**
21+
* FileTree component for rendering folder contents with virtual scrolling
22+
*/
23+
export default class FileTree {
24+
/**
25+
* @param {HTMLElement} container
26+
* @param {FileTreeOptions} options
27+
*/
28+
constructor(container, options = {}) {
29+
this.container = container;
30+
this.container.classList.add("file-tree");
31+
32+
this.options = options;
33+
this.virtualList = null;
34+
this.entries = [];
35+
this.isLoading = false;
36+
this.childTrees = new Map(); // Track child trees for cleanup
37+
this.depth = options._depth || 0; // Internal: nesting depth
38+
}
39+
40+
/**
41+
* Load and render entries for a directory
42+
* @param {string} url - Directory URL
43+
*/
44+
async load(url) {
45+
if (this.isLoading) return;
46+
this.isLoading = true;
47+
this.currentUrl = url;
48+
49+
try {
50+
this.clear();
51+
52+
const entries = await this.options.getEntries(url);
53+
this.entries = helpers.sortDir(entries, {
54+
sortByName: true,
55+
showHiddenFiles: true,
56+
});
57+
58+
if (this.entries.length > VIRTUALIZATION_THRESHOLD) {
59+
this.renderVirtualized();
60+
} else {
61+
this.renderWithFragment();
62+
}
63+
} finally {
64+
this.isLoading = false;
65+
}
66+
}
67+
68+
/**
69+
* Render using DocumentFragment for batch DOM updates (small folders)
70+
*/
71+
renderWithFragment() {
72+
const fragment = document.createDocumentFragment();
73+
74+
for (const entry of this.entries) {
75+
const $el = this.createEntryElement(entry);
76+
fragment.appendChild($el);
77+
}
78+
79+
this.container.appendChild(fragment);
80+
}
81+
82+
/**
83+
* Render using virtual scrolling (large folders)
84+
*/
85+
renderVirtualized() {
86+
this.container.classList.add("virtual-scroll");
87+
88+
this.virtualList = new VirtualList(this.container, {
89+
itemHeight: ITEM_HEIGHT,
90+
buffer: 15,
91+
renderItem: (entry, recycledEl) =>
92+
this.createEntryElement(entry, recycledEl),
93+
});
94+
95+
this.virtualList.setItems(this.entries);
96+
}
97+
98+
/**
99+
* Create DOM element for a file/folder entry
100+
* @param {object} entry
101+
* @param {HTMLElement} [recycledEl] - Optional recycled element for reuse
102+
* @returns {HTMLElement}
103+
*/
104+
createEntryElement(entry, recycledEl) {
105+
const name = entry.name || Path.basename(entry.url);
106+
107+
if (entry.isDirectory) {
108+
return this.createFolderElement(name, entry.url, recycledEl);
109+
} else {
110+
return this.createFileElement(name, entry.url, recycledEl);
111+
}
112+
}
113+
114+
/**
115+
* Create folder element (collapsible)
116+
* @param {string} name
117+
* @param {string} url
118+
* @param {HTMLElement} [recycledEl] - Optional recycled element for reuse
119+
* @returns {HTMLElement}
120+
*/
121+
createFolderElement(name, url, recycledEl) {
122+
// Try to recycle existing folder element
123+
if (recycledEl && recycledEl.classList.contains("collapsible")) {
124+
const $title = recycledEl.$title;
125+
if ($title) {
126+
$title.dataset.url = url;
127+
$title.dataset.name = name;
128+
const textEl = $title.querySelector(".text");
129+
if (textEl) textEl.textContent = name;
130+
131+
// Collapse if expanded and clear children
132+
if (!recycledEl.classList.contains("hidden")) {
133+
recycledEl.classList.add("hidden");
134+
const childTree = this.childTrees.get(recycledEl._folderUrl);
135+
if (childTree) {
136+
childTree.destroy();
137+
this.childTrees.delete(recycledEl._folderUrl);
138+
}
139+
recycledEl.$ul.innerHTML = "";
140+
}
141+
142+
recycledEl._folderUrl = url;
143+
return recycledEl;
144+
}
145+
}
146+
147+
const $wrapper = tag("div", {
148+
className: "list collapsible hidden",
149+
});
150+
$wrapper._folderUrl = url;
151+
152+
const $indicator = tag("span", { className: "icon folder" });
153+
154+
const $title = tile({
155+
lead: $indicator,
156+
type: "div",
157+
text: name,
158+
});
159+
160+
$title.classList.add("light");
161+
$title.dataset.url = url;
162+
$title.dataset.name = name;
163+
$title.dataset.type = "dir";
164+
165+
const $content = tag("ul", { className: "scroll folder-content" });
166+
$wrapper.append($title, $content);
167+
168+
// Child file tree for nested folders
169+
let childTree = null;
170+
171+
const toggle = async () => {
172+
const isExpanded = !$wrapper.classList.contains("hidden");
173+
174+
if (isExpanded) {
175+
// Collapse
176+
$wrapper.classList.add("hidden");
177+
178+
if (childTree) {
179+
childTree.destroy();
180+
this.childTrees.delete(url);
181+
childTree = null;
182+
}
183+
this.options.onExpandedChange?.(url, false);
184+
} else {
185+
// Expand
186+
$wrapper.classList.remove("hidden");
187+
$title.classList.add("loading");
188+
189+
// Create child tree with incremented depth
190+
childTree = new FileTree($content, {
191+
...this.options,
192+
_depth: this.depth + 1,
193+
});
194+
this.childTrees.set(url, childTree);
195+
try {
196+
await childTree.load(url);
197+
} finally {
198+
$title.classList.remove("loading");
199+
}
200+
this.options.onExpandedChange?.(url, true);
201+
}
202+
};
203+
204+
$title.addEventListener("click", (e) => {
205+
e.stopPropagation();
206+
toggle();
207+
});
208+
209+
$title.addEventListener("contextmenu", (e) => {
210+
e.stopPropagation();
211+
this.options.onContextMenu?.("dir", url, name, $title);
212+
});
213+
214+
// Check if folder should be expanded from saved state
215+
if (this.options.expandedState?.[url]) {
216+
queueMicrotask(() => toggle());
217+
}
218+
219+
// Add properties for external access (keep unclasped for collapsableList compatibility)
220+
Object.defineProperties($wrapper, {
221+
collapsed: { get: () => $wrapper.classList.contains("hidden") },
222+
expanded: { get: () => !$wrapper.classList.contains("hidden") },
223+
unclasped: { get: () => !$wrapper.classList.contains("hidden") }, // Legacy compatibility
224+
$title: { get: () => $title },
225+
$ul: { get: () => $content },
226+
expand: {
227+
value: () => !$wrapper.classList.contains("hidden") || toggle(),
228+
},
229+
collapse: {
230+
value: () => $wrapper.classList.contains("hidden") || toggle(),
231+
},
232+
});
233+
234+
return $wrapper;
235+
}
236+
237+
/**
238+
* Create file element (tile)
239+
* @param {string} name
240+
* @param {string} url
241+
* @param {HTMLElement} [recycledEl] - Optional recycled element for reuse
242+
* @returns {HTMLElement}
243+
*/
244+
createFileElement(name, url, recycledEl) {
245+
const iconClass = helpers.getIconForFile(name);
246+
247+
// Try to recycle existing element
248+
if (recycledEl && recycledEl.dataset.type === "file") {
249+
recycledEl.dataset.url = url;
250+
recycledEl.dataset.name = name;
251+
const textEl = recycledEl.querySelector(".text");
252+
const iconEl = recycledEl.querySelector("span:first-child");
253+
if (textEl) textEl.textContent = name;
254+
if (iconEl) iconEl.className = iconClass;
255+
return recycledEl;
256+
}
257+
258+
const $tile = tile({
259+
lead: tag("span", { className: iconClass }),
260+
text: name,
261+
});
262+
263+
$tile.dataset.url = url;
264+
$tile.dataset.name = name;
265+
$tile.dataset.type = "file";
266+
267+
$tile.addEventListener("click", (e) => {
268+
e.stopPropagation();
269+
this.options.onFileClick?.(url, name);
270+
});
271+
272+
$tile.addEventListener("contextmenu", (e) => {
273+
e.stopPropagation();
274+
this.options.onContextMenu?.("file", url, name, $tile);
275+
});
276+
277+
return $tile;
278+
}
279+
280+
/**
281+
* Clear all rendered content
282+
*/
283+
clear() {
284+
// Destroy all child trees
285+
for (const childTree of this.childTrees.values()) {
286+
childTree.destroy();
287+
}
288+
this.childTrees.clear();
289+
290+
if (this.virtualList) {
291+
this.virtualList.destroy();
292+
this.virtualList = null;
293+
}
294+
this.container.innerHTML = "";
295+
this.container.classList.remove("virtual-scroll");
296+
this.entries = [];
297+
}
298+
299+
/**
300+
* Destroy the file tree and cleanup
301+
*/
302+
destroy() {
303+
this.clear();
304+
this.container.classList.remove("file-tree");
305+
}
306+
307+
/**
308+
* Find an entry element by URL
309+
* @param {string} url
310+
* @returns {HTMLElement|null}
311+
*/
312+
findElement(url) {
313+
return this.container.querySelector(`[data-url="${CSS.escape(url)}"]`);
314+
}
315+
316+
/**
317+
* Refresh the current directory
318+
*/
319+
async refresh() {
320+
if (this.currentUrl) {
321+
await this.load(this.currentUrl);
322+
}
323+
}
324+
325+
/**
326+
* Append a new entry to the tree
327+
* @param {string} name
328+
* @param {string} url
329+
* @param {boolean} isDirectory
330+
*/
331+
appendEntry(name, url, isDirectory) {
332+
const entry = { name, url, isDirectory, isFile: !isDirectory };
333+
334+
// Insert in sorted position
335+
if (isDirectory) {
336+
// Find first file or end of dirs
337+
const insertIndex = this.entries.findIndex((e) => !e.isDirectory);
338+
if (insertIndex === -1) {
339+
this.entries.push(entry);
340+
} else {
341+
this.entries.splice(insertIndex, 0, entry);
342+
}
343+
} else {
344+
this.entries.push(entry);
345+
}
346+
347+
// Re-sort entries
348+
this.entries = helpers.sortDir(this.entries, {
349+
sortByName: true,
350+
showHiddenFiles: true,
351+
});
352+
353+
// Update rendering based on mode
354+
if (this.virtualList) {
355+
// Virtual list mode: update items
356+
this.virtualList.setItems(this.entries);
357+
} else {
358+
// Fragment mode: re-render
359+
this.container.innerHTML = "";
360+
this.renderWithFragment();
361+
}
362+
}
363+
364+
/**
365+
* Remove an entry from the tree
366+
* @param {string} url
367+
*/
368+
removeEntry(url) {
369+
// Update data first
370+
const index = this.entries.findIndex((e) => e.url === url);
371+
if (index === -1) return;
372+
373+
// Clean up child tree if folder
374+
const entry = this.entries[index];
375+
if (entry.isDirectory && this.childTrees.has(url)) {
376+
this.childTrees.get(url).destroy();
377+
this.childTrees.delete(url);
378+
}
379+
380+
// Remove from entries
381+
this.entries.splice(index, 1);
382+
383+
// Update rendering based on mode
384+
if (this.virtualList) {
385+
// Virtual list mode: update items
386+
this.virtualList.setItems(this.entries);
387+
} else {
388+
// Fragment mode: remove element directly
389+
const $el = this.findElement(url);
390+
if ($el) {
391+
if ($el.dataset.type === "dir") {
392+
$el.closest(".list.collapsible")?.remove();
393+
} else {
394+
$el.remove();
395+
}
396+
}
397+
}
398+
}
399+
}

0 commit comments

Comments
 (0)