|
| 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