Skip to content

Commit d1f877b

Browse files
committed
feat: Add FileTree component with virtual scrolling
- Extract FileTree component for folder content rendering from openFolder - Add VirtualList component for large folder performance
1 parent eee8475 commit d1f877b

File tree

5 files changed

+616
-22
lines changed

5 files changed

+616
-22
lines changed

src/components/fileTree/index.js

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
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);
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+
* @returns {HTMLElement}
119+
*/
120+
createFolderElement(name, url) {
121+
const $wrapper = tag("div", {
122+
className: "list collapsible hidden",
123+
});
124+
125+
const $indicator = tag("span", { className: "icon folder" });
126+
127+
const $title = tile({
128+
lead: $indicator,
129+
type: "div",
130+
text: name,
131+
});
132+
133+
$title.classList.add("light");
134+
$title.dataset.url = url;
135+
$title.dataset.name = name;
136+
$title.dataset.type = "dir";
137+
138+
const $content = tag("ul", { className: "scroll folder-content" });
139+
$wrapper.append($title, $content);
140+
141+
// Child file tree for nested folders
142+
let childTree = null;
143+
144+
const toggle = async () => {
145+
const isExpanded = !$wrapper.classList.contains("hidden");
146+
147+
if (isExpanded) {
148+
// Collapse
149+
$wrapper.classList.add("hidden");
150+
151+
if (childTree) {
152+
childTree.destroy();
153+
this.childTrees.delete(url);
154+
childTree = null;
155+
}
156+
this.options.onExpandedChange?.(url, false);
157+
} else {
158+
// Expand
159+
$wrapper.classList.remove("hidden");
160+
$title.classList.add("loading");
161+
162+
// Create child tree with incremented depth
163+
childTree = new FileTree($content, {
164+
...this.options,
165+
_depth: this.depth + 1,
166+
});
167+
this.childTrees.set(url, childTree);
168+
try {
169+
await childTree.load(url);
170+
} finally {
171+
$title.classList.remove("loading");
172+
}
173+
this.options.onExpandedChange?.(url, true);
174+
}
175+
};
176+
177+
$title.addEventListener("click", (e) => {
178+
e.stopPropagation();
179+
toggle();
180+
});
181+
182+
$title.addEventListener("contextmenu", (e) => {
183+
e.stopPropagation();
184+
this.options.onContextMenu?.("dir", url, name, $title);
185+
});
186+
187+
// Check if folder should be expanded from saved state
188+
if (this.options.expandedState?.[url]) {
189+
queueMicrotask(() => toggle());
190+
}
191+
192+
// Add properties for external access
193+
Object.defineProperties($wrapper, {
194+
collapsed: { get: () => $wrapper.classList.contains("hidden") },
195+
unclasped: { get: () => !$wrapper.classList.contains("hidden") },
196+
$title: { get: () => $title },
197+
$ul: { get: () => $content },
198+
expand: {
199+
value: () => !$wrapper.classList.contains("hidden") || toggle(),
200+
},
201+
collapse: {
202+
value: () => $wrapper.classList.contains("hidden") || toggle(),
203+
},
204+
});
205+
206+
return $wrapper;
207+
}
208+
209+
/**
210+
* Create file element (tile)
211+
* @param {string} name
212+
* @param {string} url
213+
* @param {HTMLElement} [recycledEl] - Optional recycled element for reuse
214+
* @returns {HTMLElement}
215+
*/
216+
createFileElement(name, url, recycledEl) {
217+
const iconClass = helpers.getIconForFile(name);
218+
219+
// Try to recycle existing element
220+
if (recycledEl && recycledEl.dataset.type === "file") {
221+
recycledEl.dataset.url = url;
222+
recycledEl.dataset.name = name;
223+
const textEl = recycledEl.querySelector(".text");
224+
const iconEl = recycledEl.querySelector("span:first-child");
225+
if (textEl) textEl.textContent = name;
226+
if (iconEl) iconEl.className = iconClass;
227+
return recycledEl;
228+
}
229+
230+
const $tile = tile({
231+
lead: tag("span", { className: iconClass }),
232+
text: name,
233+
});
234+
235+
$tile.dataset.url = url;
236+
$tile.dataset.name = name;
237+
$tile.dataset.type = "file";
238+
239+
$tile.addEventListener("click", (e) => {
240+
e.stopPropagation();
241+
this.options.onFileClick?.(url, name);
242+
});
243+
244+
$tile.addEventListener("contextmenu", (e) => {
245+
e.stopPropagation();
246+
this.options.onContextMenu?.("file", url, name, $tile);
247+
});
248+
249+
return $tile;
250+
}
251+
252+
/**
253+
* Clear all rendered content
254+
*/
255+
clear() {
256+
// Destroy all child trees
257+
for (const childTree of this.childTrees.values()) {
258+
childTree.destroy();
259+
}
260+
this.childTrees.clear();
261+
262+
if (this.virtualList) {
263+
this.virtualList.destroy();
264+
this.virtualList = null;
265+
}
266+
this.container.innerHTML = "";
267+
this.container.classList.remove("virtual-scroll");
268+
this.entries = [];
269+
}
270+
271+
/**
272+
* Destroy the file tree and cleanup
273+
*/
274+
destroy() {
275+
this.clear();
276+
this.container.classList.remove("file-tree");
277+
}
278+
279+
/**
280+
* Find an entry element by URL
281+
* @param {string} url
282+
* @returns {HTMLElement|null}
283+
*/
284+
findElement(url) {
285+
return this.container.querySelector(`[data-url="${CSS.escape(url)}"]`);
286+
}
287+
288+
/**
289+
* Refresh the current directory
290+
*/
291+
async refresh() {
292+
if (this.currentUrl) {
293+
await this.load(this.currentUrl);
294+
}
295+
}
296+
297+
/**
298+
* Append a new entry to the tree
299+
* @param {string} name
300+
* @param {string} url
301+
* @param {boolean} isDirectory
302+
*/
303+
appendEntry(name, url, isDirectory) {
304+
const entry = { name, url, isDirectory, isFile: !isDirectory };
305+
const $el = this.createEntryElement(entry);
306+
307+
if (isDirectory) {
308+
// Insert at beginning (before files)
309+
const firstFile = this.container.querySelector('[data-type="file"]');
310+
if (firstFile) {
311+
this.container.insertBefore($el, firstFile);
312+
} else {
313+
this.container.appendChild($el);
314+
}
315+
} else {
316+
// Append at end
317+
this.container.appendChild($el);
318+
}
319+
320+
this.entries.push(entry);
321+
}
322+
323+
/**
324+
* Remove an entry from the tree
325+
* @param {string} url
326+
*/
327+
removeEntry(url) {
328+
const $el = this.findElement(url);
329+
if ($el) {
330+
// For folders, remove the wrapper div
331+
if ($el.dataset.type === "dir") {
332+
$el.closest(".list.collapsible")?.remove();
333+
} else {
334+
$el.remove();
335+
}
336+
this.entries = this.entries.filter((e) => e.url !== url);
337+
}
338+
}
339+
}

0 commit comments

Comments
 (0)