From e0dfe47dd27a61bb6111c180263fd34e172e928d Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:17:27 +0530 Subject: [PATCH 1/2] feat(file-tree): add performant virtualized file tree --- bun.lock | 3 + package-lock.json | 11 + package.json | 1 + src/components/fileTree/index.js | 862 +++++++++++------- src/components/fileTree/style.scss | 146 +-- src/components/virtualList/index.js | 391 +++++--- src/components/virtualList/style.scss | 15 +- src/lib/fileList.js | 119 ++- src/lib/openFolder.js | 186 ++-- .../ftp/src/android/com/foxdebug/ftp/Ftp.java | 81 +- src/test/sanity.tests.js | 83 ++ 11 files changed, 1214 insertions(+), 684 deletions(-) diff --git a/bun.lock b/bun.lock index 9a60acd1c..ee4c82a12 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.2.3", "@lezer/lr": "^1.4.7", + "@tanstack/virtual-core": "^3.17.6", "@ungap/custom-elements": "^1.3.0", "@xterm/addon-attach": "^0.12.0", "@xterm/addon-fit": "^0.11.0", @@ -620,6 +621,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@1.1.2", "", { "dependencies": { "defer-to-connect": "^1.0.1" } }, "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.6", "", {}, "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw=="], + "@tootallnate/once": ["@tootallnate/once@1.1.2", "", {}, "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="], "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], diff --git a/package-lock.json b/package-lock.json index 50540a937..b01980f80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.2.3", "@lezer/lr": "^1.4.7", + "@tanstack/virtual-core": "^3.17.6", "@ungap/custom-elements": "^1.3.0", "@xterm/addon-attach": "^0.12.0", "@xterm/addon-fit": "^0.11.0", @@ -5415,6 +5416,16 @@ "node": ">=6" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz", + "integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", diff --git a/package.json b/package.json index 39fc2f685..6f722d76d 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.2.3", "@lezer/lr": "^1.4.7", + "@tanstack/virtual-core": "^3.17.6", "@ungap/custom-elements": "^1.3.0", "@xterm/addon-attach": "^0.12.0", "@xterm/addon-fit": "^0.11.0", diff --git a/src/components/fileTree/index.js b/src/components/fileTree/index.js index f6154d026..7e94207da 100644 --- a/src/components/fileTree/index.js +++ b/src/components/fileTree/index.js @@ -2,23 +2,31 @@ import "./style.scss"; import tile from "components/tile"; import VirtualList from "components/virtualList"; import tag from "html-tag-js"; +import { animate } from "motion"; import helpers from "utils/helpers"; import Path from "utils/Path"; -const VIRTUALIZATION_THRESHOLD = Number.POSITIVE_INFINITY; // FIX: temporary due to some scrolling issues in VirtualList const ITEM_HEIGHT = 30; +const IDLE_OVERSCAN_ROWS = 32; +const ACTIVE_OVERSCAN_ROWS = 64; +const MAX_FLING_OVERSCAN_ROWS = 96; /** * @typedef {object} FileTreeOptions - * @property {function(string): Promise} getEntries - Function to get directory entries - * @property {function(string, string): void} [onFileClick] - File click handler - * @property {function(string, string, string, HTMLElement): void} [onContextMenu] - Context menu handler - * @property {Object} [expandedState] - Map of expanded folder URLs - * @property {function(string, boolean): void} [onExpandedChange] - Called when folder expanded state changes + * @property {function(string): Promise} getEntries + * @property {function(string, string): void} [onFileClick] + * @property {function(string, string, string, HTMLElement): void} [onContextMenu] + * @property {Object|Map} [expandedState] + * @property {function(string, boolean): void} [onExpandedChange] + * @property {function(any): void} [onError] */ /** - * FileTree component for rendering folder contents with virtual scrolling + * A lazy, fully virtualized file tree. + * + * Directory data stays hierarchical, but rendering uses one flattened array of + * visible nodes. Consequently every row has a stable fixed offset regardless of + * nesting, and only the viewport plus a small overscan window exists in the DOM. */ export default class FileTree { /** @@ -27,418 +35,608 @@ export default class FileTree { */ constructor(container, options = {}) { this.container = container; - this.container.classList.add("file-tree"); - this.options = options; - this.virtualList = null; this.entries = []; - this.isLoading = false; - this.childTrees = new Map(); // Track child trees for cleanup - this.depth = options._depth || 0; // Internal: nesting depth + this.visibleEntries = []; + this.visibleIndexByUrl = new Map(); + this.nodes = new Map(); + this.currentUrl = null; + this.focusedUrl = null; + this.selectedUrl = null; + this.cutUrl = null; + this.loadGeneration = 0; + this.pendingDirectoryLoads = []; + this.activeDirectoryLoads = 0; + this.maxConcurrentDirectoryLoads = 4; + this.iconAnimations = new WeakMap(); + this.destroyed = false; + + this.container.classList.add("file-tree", "virtual-scroll"); + this.container.setAttribute("role", "tree"); + this.container.setAttribute("aria-label", "Files"); + this.container._fileTree = this; + + this.onClickBound = this.onClick.bind(this); + this.onContextMenuBound = this.onContextMenu.bind(this); + this.onKeyDownBound = this.onKeyDown.bind(this); + this.container.addEventListener("click", this.onClickBound); + this.container.addEventListener("contextmenu", this.onContextMenuBound); + this.container.addEventListener("keydown", this.onKeyDownBound); + + this.virtualList = new VirtualList(this.container, { + itemHeight: ITEM_HEIGHT, + overscan: IDLE_OVERSCAN_ROWS, + activeOverscan: ACTIVE_OVERSCAN_ROWS, + maxOverscan: MAX_FLING_OVERSCAN_ROWS, + getItemKey: (entry) => entry.url, + renderItem: (entry, recycledEl) => this.renderEntry(entry, recycledEl), + }); + this.virtualList.scrollSurface.setAttribute("role", "presentation"); + this.virtualList.itemContainer.setAttribute("role", "presentation"); } - /** - * Load and render entries for a directory - * @param {string} url - Directory URL - */ + /** Load the root directory. @param {string} url */ async load(url) { - if (this.isLoading) return; - this.isLoading = true; + const generation = ++this.loadGeneration; + this.cancelPendingDirectoryLoads(); this.currentUrl = url; + // FTP and SFTP clients use a single stateful connection. Concurrent list + // commands can corrupt the data channel and return a null native array. + this.maxConcurrentDirectoryLoads = /^(ftp|sftp):/i.test(url) ? 1 : 4; + this.container.scrollTop = 0; + this.resetData(); + this.container.setAttribute("aria-busy", "true"); try { - this.clear(); - const entries = await this.options.getEntries(url); - this.entries = helpers.sortDir(entries, { - sortByName: true, - showHiddenFiles: true, - }); - - if (this.entries.length > VIRTUALIZATION_THRESHOLD) { - this.renderVirtualized(); - } else { - this.renderWithFragment(); - } + if (this.destroyed || generation !== this.loadGeneration) return; + this.entries = this.reconcileChildren(url, 0, entries, []); + this.rebuildVisibleEntries(); + this.loadRestoredFolders(this.entries); } finally { - this.isLoading = false; + if (!this.destroyed && generation === this.loadGeneration) { + this.container.removeAttribute("aria-busy"); + } } } - /** - * Render using DocumentFragment for batch DOM updates (small folders) - */ - renderWithFragment() { - const fragment = document.createDocumentFragment(); - - for (const entry of this.entries) { - const $el = this.createEntryElement(entry); - fragment.appendChild($el); - } + /** Sort directory entries using the same ordering as the rest of Acode. */ + sortEntries(entries) { + if (!Array.isArray(entries)) return []; + return helpers.sortDir(entries, { + sortByName: true, + showHiddenFiles: true, + }); + } - this.container.appendChild(fragment); + /** Read persisted expanded state from either a Map or a plain object. */ + isPersistedExpanded(url) { + const state = this.options.expandedState; + return state instanceof Map + ? state.get(url) === true + : state?.[url] === true; } /** - * Render using virtual scrolling (large folders) + * Reconcile a directory listing while retaining loaded subtrees for unchanged + * folders. This makes refreshes cheap and keeps expansion stable. */ - renderVirtualized() { - this.container.classList.add("virtual-scroll"); + reconcileChildren(parentUrl, depth, rawEntries, previousChildren) { + const previousByUrl = new Map( + (previousChildren || []).map((entry) => [entry.url, entry]), + ); + const nextChildren = this.sortEntries(rawEntries).map((entry) => { + const url = entry.url; + const name = entry.name || Path.basename(url); + const isDirectory = Boolean(entry.isDirectory); + let node = previousByUrl.get(url); + + if (node && node.isDirectory === isDirectory) { + node.name = name; + node.depth = depth; + node.parentUrl = parentUrl; + previousByUrl.delete(url); + } else { + if (node) this.removeNode(node); + node = { + name, + url, + isDirectory, + isFile: !isDirectory, + parentUrl, + depth, + children: null, + expanded: isDirectory && this.isPersistedExpanded(url), + loading: false, + loadGeneration: 0, + }; + } - this.virtualList = new VirtualList(this.container, { - itemHeight: ITEM_HEIGHT, - buffer: 15, - renderItem: (entry, recycledEl) => - this.createEntryElement(entry, recycledEl), + this.nodes.set(url, node); + return node; }); - this.virtualList.setItems(this.entries); - } + for (const removedNode of previousByUrl.values()) { + this.removeNode(removedNode); + } - /** - * Create DOM element for a file/folder entry - * @param {object} entry - * @param {HTMLElement} [recycledEl] - Optional recycled element for reuse - * @returns {HTMLElement} - */ - createEntryElement(entry, recycledEl) { - const name = entry.name || Path.basename(entry.url); + nextChildren.forEach((node, index) => { + node.position = index + 1; + node.setSize = nextChildren.length; + }); + return nextChildren; + } - if (entry.isDirectory) { - return this.createFolderElement(name, entry.url, recycledEl); - } else { - return this.createFileElement(name, entry.url, recycledEl); - } + /** Remove a node and every retained descendant from the model. */ + removeNode(node) { + node.loadGeneration++; + for (const child of node.children || []) this.removeNode(child); + this.nodes.delete(node.url); } - /** - * Create folder element (collapsible) - * @param {string} name - * @param {string} url - * @param {HTMLElement} [recycledEl] - Optional recycled element for reuse - * @returns {HTMLElement} - */ - createFolderElement(name, url, recycledEl) { - // Try to recycle existing folder element - if (recycledEl && recycledEl.classList.contains("collapsible")) { - const $title = recycledEl.$title; - if ($title) { - $title.dataset.url = url; - $title.dataset.name = name; - const textEl = $title.querySelector(".text"); - if (textEl) textEl.textContent = name; - - // Collapse if expanded and clear children - if (!recycledEl.classList.contains("hidden")) { - recycledEl.classList.add("hidden"); - const childTree = this.childTrees.get(recycledEl._folderUrl); - if (childTree) { - childTree.destroy(); - this.childTrees.delete(recycledEl._folderUrl); - } - recycledEl.$ul.innerHTML = ""; + /** Flatten only expanded nodes into the render list. */ + rebuildVisibleEntries() { + const oldIndex = Math.floor(this.container.scrollTop / ITEM_HEIGHT); + const anchor = this.visibleEntries[oldIndex]; + const anchorOffset = this.container.scrollTop - oldIndex * ITEM_HEIGHT; + const visible = []; + const visibleIndexByUrl = new Map(); + + const append = (entries) => { + for (const entry of entries) { + visibleIndexByUrl.set(entry.url, visible.length); + visible.push(entry); + if (entry.isDirectory && entry.expanded && entry.children) { + append(entry.children); } - - recycledEl._folderUrl = url; - return recycledEl; } + }; + append(this.entries); + this.visibleEntries = visible; + this.visibleIndexByUrl = visibleIndexByUrl; + if (!visibleIndexByUrl.has(this.focusedUrl)) { + this.focusedUrl = visible[0]?.url || null; } + this.virtualList.setItems(visible); + + // Keep the first visible row anchored when async children are inserted above + // it. This prevents the jump that is especially noticeable during touch scroll. + if (anchor) { + const nextIndex = visibleIndexByUrl.get(anchor.url); + if (nextIndex !== undefined && nextIndex !== oldIndex) { + this.container.scrollTop = nextIndex * ITEM_HEIGHT + anchorOffset; + this.virtualList.invalidate(); + } + } + } - const $wrapper = tag("div", { - className: "list collapsible hidden", - }); - $wrapper._folderUrl = url; - - const $indicator = tag("span", { className: "icon folder" }); - - const $title = tile({ - lead: $indicator, - type: "div", - text: name, - }); + /** Begin lazy loading folders restored as expanded. */ + loadRestoredFolders(entries) { + for (const entry of entries) { + if (entry.isDirectory && entry.expanded) this.ensureChildren(entry); + } + } - $title.classList.add("light"); - $title.dataset.url = url; - $title.dataset.name = name; - $title.dataset.type = "dir"; + /** Load a directory once and reveal its children when ready. */ + async ensureChildren(node, priority = false) { + if (!node?.isDirectory || node.children || node.loading || this.destroyed) { + return; + } - const $content = tag("ul", { className: "scroll folder-content" }); - $wrapper.append($title, $content); + const generation = ++node.loadGeneration; + node.loading = true; + this.virtualList.invalidate(); - // Child file tree for nested folders - let childTree = null; - $content._fileTree = null; + try { + const entries = await this.requestDirectoryEntries(node.url, priority); + if ( + this.destroyed || + generation !== node.loadGeneration || + this.nodes.get(node.url) !== node + ) { + return; + } - const toggle = async () => { - const isExpanded = !$wrapper.classList.contains("hidden"); + node.children = this.reconcileChildren( + node.url, + node.depth + 1, + entries, + [], + ); + node.loading = false; + this.rebuildVisibleEntries(); + this.loadRestoredFolders(node.children); + } catch (error) { + if (generation !== node.loadGeneration || this.destroyed) return; + node.loading = false; + node.expanded = false; + this.options.onExpandedChange?.(node.url, false); + this.rebuildVisibleEntries(); + this.options.onError?.(error); + } + } - if (isExpanded) { - // Collapse - $wrapper.classList.add("hidden"); + /** + * Bound directory reads so restoring a large saved tree cannot flood Cordova's + * bridge. Interactive expansions are placed ahead of background restoration. + */ + requestDirectoryEntries(url, priority = false) { + return new Promise((resolve, reject) => { + const job = { url, resolve, reject }; + if (priority) this.pendingDirectoryLoads.unshift(job); + else this.pendingDirectoryLoads.push(job); + this.pumpDirectoryLoads(); + }); + } - if (childTree) { - childTree.destroy(); - this.childTrees.delete(url); - childTree = null; - $content._fileTree = null; - } - this.options.onExpandedChange?.(url, false); - } else { - // Expand - $wrapper.classList.remove("hidden"); - $title.classList.add("loading"); - - // Create child tree with incremented depth - childTree = new FileTree($content, { - ...this.options, - _depth: this.depth + 1, + pumpDirectoryLoads() { + while ( + !this.destroyed && + this.activeDirectoryLoads < this.maxConcurrentDirectoryLoads && + this.pendingDirectoryLoads.length + ) { + const job = this.pendingDirectoryLoads.shift(); + this.activeDirectoryLoads++; + Promise.resolve() + .then(() => this.options.getEntries(job.url)) + .then(job.resolve, job.reject) + .finally(() => { + this.activeDirectoryLoads--; + this.pumpDirectoryLoads(); }); - this.childTrees.set(url, childTree); - $content._fileTree = childTree; - try { - await childTree.load(url); - } finally { - $title.classList.remove("loading"); - } - this.options.onExpandedChange?.(url, true); - } - }; + } + } - $title.addEventListener("click", (e) => { - e.stopPropagation(); - toggle(); - }); + cancelPendingDirectoryLoads() { + for (const job of this.pendingDirectoryLoads.splice(0)) { + job.resolve(null); + } + } - $title.addEventListener("contextmenu", (e) => { - e.stopPropagation(); - this.options.onContextMenu?.("dir", url, name, $title); - }); + /** @param {object} entry @param {HTMLElement} [recycledEl] */ + renderEntry(entry, recycledEl) { + const row = recycledEl || this.createRow(); + const icon = row.firstElementChild; + const text = row.children[1]; + this.resetIconAnimation(icon); + + row.dataset.url = entry.url; + row.dataset.name = entry.name; + row.dataset.type = entry.isDirectory ? "dir" : "file"; + row.style.setProperty("--file-tree-depth", entry.depth); + row.setAttribute("aria-level", String(entry.depth + 1)); + row.setAttribute("aria-posinset", String(entry.position)); + row.setAttribute("aria-setsize", String(entry.setSize)); + row.setAttribute("aria-selected", String(this.selectedUrl === entry.url)); + row.tabIndex = this.focusedUrl === entry.url ? 0 : -1; + row.classList.toggle("loading", entry.loading); + row.classList.toggle("cut", this.cutUrl === entry.url); - // Check if folder should be expanded from saved state - if (this.options.expandedState?.[url]) { - queueMicrotask(() => toggle()); + if (entry.isDirectory) { + row.setAttribute("aria-expanded", String(entry.expanded)); + row.setAttribute("aria-busy", String(entry.loading)); + icon.className = "icon folder"; + } else { + row.removeAttribute("aria-expanded"); + row.removeAttribute("aria-busy"); + icon.className = helpers.getIconForFile(entry.name); } - const defineCollapsibleAccessors = ($el, { includeTitle = true } = {}) => { - const properties = { - collapsed: { get: () => $wrapper.classList.contains("hidden") }, - expanded: { get: () => !$wrapper.classList.contains("hidden") }, - unclasped: { get: () => !$wrapper.classList.contains("hidden") }, // Legacy compatibility - $ul: { get: () => $content }, - fileTree: { get: () => childTree }, - refresh: { - value: () => childTree?.refresh(), - }, - expand: { - value: () => !$wrapper.classList.contains("hidden") || toggle(), - }, - collapse: { - value: () => $wrapper.classList.contains("hidden") || toggle(), - }, - }; - - if (includeTitle) { - properties.$title = { get: () => $title }; - } + text.textContent = entry.name; + return row; + } - Object.defineProperties($el, properties); + /** Stop an animation before its recycled icon is rebound to another entry. */ + resetIconAnimation(icon) { + const animation = this.iconAnimations.get(icon); + if (animation) { + animation.cancel?.(); + this.iconAnimations.delete(icon); + } + icon.style.removeProperty("opacity"); + icon.style.removeProperty("transform"); + } + + /** Animate only the small folder glyph, using compositor-only properties. */ + animateFolderToggle(url) { + if (document.body.classList.contains("no-animation")) return; + const icon = this.findElement(url)?.firstElementChild; + if (!icon) return; + + this.resetIconAnimation(icon); + const animation = animate( + icon, + { + opacity: [0.65, 1], + transform: ["scale(0.88)", "scale(1)"], + }, + { duration: 0.14, ease: [0.2, 0, 0, 1] }, + ); + this.iconAnimations.set(icon, animation); + const cleanup = () => { + if (this.iconAnimations.get(icon) !== animation) return; + this.iconAnimations.delete(icon); + icon.style.removeProperty("opacity"); + icon.style.removeProperty("transform"); }; + animation.then(cleanup).catch(cleanup); + } - // Keep nested folders compatible with the legacy collapsableList API. - defineCollapsibleAccessors($wrapper); - defineCollapsibleAccessors($title, { includeTitle: false }); + createRow() { + const row = tile({ + lead: tag("span", { className: "icon" }), + type: "li", + text: "", + }); + row.classList.add("file-tree-row"); + row.setAttribute("role", "treeitem"); + return row; + } - return $wrapper; + /** Resolve a delegated event to its mounted row. */ + getEventRow(event) { + const target = event.target; + if (!(target instanceof Element)) return null; + const row = target.closest(".file-tree-row"); + return row && this.container.contains(row) ? row : null; } - /** - * Create file element (tile) - * @param {string} name - * @param {string} url - * @param {HTMLElement} [recycledEl] - Optional recycled element for reuse - * @returns {HTMLElement} - */ - createFileElement(name, url, recycledEl) { - const iconClass = helpers.getIconForFile(name); - - // Try to recycle existing element - if (recycledEl && recycledEl.dataset.type === "file") { - recycledEl.dataset.url = url; - recycledEl.dataset.name = name; - const textEl = recycledEl.querySelector(".text"); - const iconEl = recycledEl.querySelector("span:first-child"); - if (textEl) textEl.textContent = name; - if (iconEl) iconEl.className = iconClass; - return recycledEl; + onClick(event) { + const row = this.getEventRow(event); + if (!row) return; + event.stopPropagation(); + this.focusedUrl = row.dataset.url; + const entry = this.nodes.get(row.dataset.url); + if (!entry) return; + + if (entry.isDirectory) { + this.toggle(entry.url); + } else { + this.selectedUrl = entry.url; + this.virtualList.invalidate(); + this.options.onFileClick?.(entry.url, entry.name); } + } - const $tile = tile({ - lead: tag("span", { className: iconClass }), - text: name, - }); + onContextMenu(event) { + const row = this.getEventRow(event); + if (!row) return; + event.preventDefault(); + event.stopPropagation(); + const entry = this.nodes.get(row.dataset.url); + if (!entry) return; + this.options.onContextMenu?.( + entry.isDirectory ? "dir" : "file", + entry.url, + entry.name, + row, + ); + } - $tile.dataset.url = url; - $tile.dataset.name = name; - $tile.dataset.type = "file"; + onKeyDown(event) { + const row = this.getEventRow(event); + if (!row) return; + const index = this.visibleIndexByUrl.get(row.dataset.url); + if (index === undefined) return; + const entry = this.visibleEntries[index]; + let destination = -1; + + switch (event.key) { + case "ArrowDown": + destination = Math.min(index + 1, this.visibleEntries.length - 1); + break; + case "ArrowUp": + destination = Math.max(index - 1, 0); + break; + case "ArrowRight": + if (entry.isDirectory && !entry.expanded) this.toggle(entry.url, true); + else if (entry.children?.length) destination = index + 1; + break; + case "ArrowLeft": + if (entry.isDirectory && entry.expanded) this.toggle(entry.url, false); + else if (entry.parentUrl !== this.currentUrl) { + destination = this.visibleIndexByUrl.get(entry.parentUrl) ?? -1; + } + break; + case "Enter": + case " ": + if (entry.isDirectory) this.toggle(entry.url); + else this.options.onFileClick?.(entry.url, entry.name); + break; + default: + return; + } - $tile.addEventListener("click", (e) => { - e.stopPropagation(); - this.options.onFileClick?.(url, name); - }); + event.preventDefault(); + event.stopPropagation(); + if (destination !== -1) this.focusEntryAt(destination); + } - $tile.addEventListener("contextmenu", (e) => { - e.stopPropagation(); - this.options.onContextMenu?.("file", url, name, $tile); + focusEntryAt(index) { + const entry = this.visibleEntries[index]; + if (!entry) return; + this.focusedUrl = entry.url; + this.virtualList.scrollToIndex(index); + requestAnimationFrame(() => { + const row = this.findElement(entry.url); + if (!row) return; + try { + row.focus({ preventScroll: true }); + } catch { + row.focus(); + } }); - - return $tile; } - /** - * Clear all rendered content - */ - clear() { - this.destroyChildTrees(); + /** Expand or collapse one directory. */ + toggle(url, force) { + const entry = this.nodes.get(url); + if (!entry?.isDirectory) return; + const expanded = force ?? !entry.expanded; + if (entry.expanded === expanded) return; + entry.expanded = expanded; + this.options.onExpandedChange?.(url, expanded); + this.rebuildVisibleEntries(); + if (expanded) this.ensureChildren(entry, true); + this.animateFolderToggle(url); + } - if (this.virtualList) { - this.virtualList.destroy(); - this.virtualList = null; + findElement(url) { + for (const row of this.virtualList.itemContainer.children) { + if (row.dataset.url === url) return row; } - this.container.innerHTML = ""; - this.container.classList.remove("virtual-scroll"); - this.entries = []; + return null; } - /** - * Destroy the file tree and cleanup - */ - destroy() { - this.clear(); - this.container.classList.remove("file-tree"); + hasEntry(url) { + return url === this.currentUrl || this.nodes.has(url); } - /** - * Find an entry element by URL - * @param {string} url - * @returns {HTMLElement|null} - */ - findElement(url) { - return this.container.querySelector(`[data-url="${CSS.escape(url)}"]`); + isExpanded(url) { + if (url === this.currentUrl) return true; + return this.nodes.get(url)?.expanded === true; + } + + setCut(url, isCut) { + this.cutUrl = isCut ? url : this.cutUrl === url ? null : this.cutUrl; + this.virtualList.invalidate(); } - /** - * Refresh the current directory - */ async refresh() { - if (this.currentUrl) { - await this.load(this.currentUrl); + if (!this.currentUrl) return; + const url = this.currentUrl; + const generation = ++this.loadGeneration; + const entries = await this.requestDirectoryEntries(url, true); + if ( + this.destroyed || + generation !== this.loadGeneration || + url !== this.currentUrl + ) { + return; } + this.entries = this.reconcileChildren(url, 0, entries, this.entries); + this.rebuildVisibleEntries(); + this.loadRestoredFolders(this.entries); } - /** - * Refresh a loaded folder in this tree or one of its expanded child trees. - * @param {string} url - * @param {(a: string, b: string) => boolean} [isSameUrl] - * @returns {Promise} - */ + /** Refresh a directory that has already been loaded in this model. */ async refreshFolder(url, isSameUrl = (a, b) => a === b) { if (this.currentUrl && isSameUrl(this.currentUrl, url)) { await this.refresh(); return true; } - for (const childTree of this.childTrees.values()) { - if (await childTree.refreshFolder(url, isSameUrl)) { - return true; + const exactNode = this.nodes.get(url); + const node = exactNode?.isDirectory + ? exactNode + : Array.from(this.nodes.values()).find( + (entry) => entry.isDirectory && isSameUrl(entry.url, url), + ); + if (!node || (!node.children && !node.expanded)) return false; + + const generation = ++node.loadGeneration; + node.loading = true; + this.virtualList.invalidate(); + try { + const entries = await this.requestDirectoryEntries(node.url, true); + if (this.destroyed || generation !== node.loadGeneration) return false; + node.children = this.reconcileChildren( + node.url, + node.depth + 1, + entries, + node.children || [], + ); + this.loadRestoredFolders(node.children); + return true; + } finally { + if (!this.destroyed && generation === node.loadGeneration) { + node.loading = false; + this.rebuildVisibleEntries(); } } - - return false; } /** - * Destroy all expanded child trees and clear their references. + * Append an entry to a loaded directory. + * @returns {boolean} Whether the directory was present and loaded */ - destroyChildTrees() { - for (const childTree of this.childTrees.values()) { - childTree.destroy(); + appendEntry(parentUrl, name, url, isDirectory) { + // Backward compatibility with the old root-only signature. + if (arguments.length === 3) { + isDirectory = url; + url = name; + name = parentUrl; + parentUrl = this.currentUrl; } - this.childTrees.clear(); - } - /** - * Append a new entry to the tree - * @param {string} name - * @param {string} url - * @param {boolean} isDirectory - */ - appendEntry(name, url, isDirectory) { - const entry = { name, url, isDirectory, isFile: !isDirectory }; - - // Insert in sorted position - if (isDirectory) { - // Find first file or end of dirs - const insertIndex = this.entries.findIndex((e) => !e.isDirectory); - if (insertIndex === -1) { - this.entries.push(entry); - } else { - this.entries.splice(insertIndex, 0, entry); - } - } else { - this.entries.push(entry); + let children; + let parentNode = null; + if (parentUrl === this.currentUrl) children = this.entries; + else { + parentNode = this.nodes.get(parentUrl); + children = parentNode?.children; } + if (!children || children.some((entry) => entry.url === url)) return false; + + const rawEntries = children.map((entry) => ({ ...entry })); + rawEntries.push({ name, url, isDirectory, isFile: !isDirectory }); + const reconciled = this.reconcileChildren( + parentUrl, + parentNode ? parentNode.depth + 1 : 0, + rawEntries, + children, + ); + if (parentNode) parentNode.children = reconciled; + else this.entries = reconciled; + this.rebuildVisibleEntries(); + return true; + } - // Re-sort entries - this.entries = helpers.sortDir(this.entries, { - sortByName: true, - showHiddenFiles: true, + /** Remove an entry from whichever loaded directory owns it. */ + removeEntry(url) { + const node = this.nodes.get(url); + if (!node) return false; + const parent = this.nodes.get(node.parentUrl); + const siblings = parent ? parent.children : this.entries; + const index = siblings?.findIndex((entry) => entry.url === url) ?? -1; + if (index === -1) return false; + siblings.splice(index, 1); + this.removeNode(node); + siblings.forEach((entry, siblingIndex) => { + entry.position = siblingIndex + 1; + entry.setSize = siblings.length; }); - - // Update rendering based on mode - if (this.virtualList) { - // Virtual list mode: update items - this.virtualList.setItems(this.entries); - } else { - // Fragment mode: re-render - this.destroyChildTrees(); - this.container.innerHTML = ""; - this.renderWithFragment(); - } + this.rebuildVisibleEntries(); + return true; } - /** - * Remove an entry from the tree - * @param {string} url - */ - removeEntry(url) { - // Update data first - const index = this.entries.findIndex((e) => e.url === url); - if (index === -1) return; - - // Clean up child tree if folder - const entry = this.entries[index]; - if (entry.isDirectory && this.childTrees.has(url)) { - this.childTrees.get(url).destroy(); - this.childTrees.delete(url); - } + resetData() { + for (const entry of this.entries) this.removeNode(entry); + this.entries = []; + this.visibleEntries = []; + this.visibleIndexByUrl.clear(); + this.nodes.clear(); + this.virtualList.setItems([]); + } - // Remove from entries - this.entries.splice(index, 1); + clear() { + this.loadGeneration++; + this.cancelPendingDirectoryLoads(); + this.resetData(); + } - // Update rendering based on mode - if (this.virtualList) { - // Virtual list mode: update items - this.virtualList.setItems(this.entries); - } else { - // Fragment mode: remove element directly - const $el = this.findElement(url); - if ($el) { - if ($el.dataset.type === "dir") { - $el.closest(".list.collapsible")?.remove(); - } else { - $el.remove(); - } - } - } + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.clear(); + this.virtualList.destroy(); + this.container.removeEventListener("click", this.onClickBound); + this.container.removeEventListener("contextmenu", this.onContextMenuBound); + this.container.removeEventListener("keydown", this.onKeyDownBound); + this.container.classList.remove("file-tree", "virtual-scroll"); + this.container.removeAttribute("role"); + this.container.removeAttribute("aria-label"); + this.container.removeAttribute("aria-busy"); + if (this.container._fileTree === this) this.container._fileTree = null; } } diff --git a/src/components/fileTree/style.scss b/src/components/fileTree/style.scss index 37bdb603f..f3f5659b0 100644 --- a/src/components/fileTree/style.scss +++ b/src/components/fileTree/style.scss @@ -1,75 +1,97 @@ @use "../../styles/mixins.scss"; .file-tree { - --file-tree-indent: 24px; + --file-tree-indent: 22px; + --file-tree-row-height: 30px; - min-width: 100%; - width: max-content; + position: relative; + box-sizing: border-box; + width: 100% !important; + min-width: 100%; + padding: 0 !important; + overflow: auto !important; + overflow-anchor: none; + overscroll-behavior: contain; + touch-action: pan-x pan-y; + -webkit-overflow-scrolling: touch; + contain: layout paint style; - &.virtual-scroll { - position: relative; - overflow-y: auto; - will-change: transform; - } + .virtual-list-surface, + .virtual-list-items { + min-width: 100%; + width: max-content; + } - // Collapsible folder structure - .list.collapsible { - &.hidden>ul { - display: none; - } + .file-tree-row { + position: relative; + display: flex; + box-sizing: border-box; + height: var(--file-tree-row-height) !important; + min-height: var(--file-tree-row-height); + min-width: 100%; + width: max-content !important; + padding-left: calc(var(--file-tree-depth) * var(--file-tree-indent)); + background-color: transparent; + cursor: default; + outline: none; + contain: layout style; - >.tile { - cursor: pointer; + &::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--file-tree-depth) * var(--file-tree-indent)); + pointer-events: none; + opacity: 0.55; + background-image: repeating-linear-gradient( + to right, + transparent 0, + transparent 10px, + var(--border-color) 10px, + var(--border-color) 11px, + transparent 11px, + transparent var(--file-tree-indent) + ); + } - &.loading { - @include mixins.bar-loader(30%, 2px); - } - } - } + &:active, + &:focus, + &[aria-selected="true"] { + background-color: rgba(0, 0, 0, 0.16); + } - // Folder content with indent - ul.folder-content { - padding-left: var(--file-tree-indent); - position: relative; - margin-left: 0; - min-width: 100%; - width: max-content; - overflow-x: visible !important; - max-width: none; - } + &.loading { + @include mixins.bar-loader(30%, 2px); + } - // Indent guides - &.show-indent-guide { - >.list.collapsible>ul.folder-content { - &::before { - content: ""; - position: absolute; - left: calc(var(--file-tree-indent) / 2); - top: 0; - height: 100%; - width: 1px; - background: var(--border-color); - z-index: 0; - } - } - } + >.icon { + height: var(--file-tree-row-height) !important; + width: var(--file-tree-row-height) !important; + min-width: var(--file-tree-row-height) !important; + font-size: 1.08em !important; + line-height: var(--file-tree-row-height) !important; + background-size: 27px !important; + } - .tile { - min-width: 100%; - width: max-content; + >.folder::before { + content: "\e92c"; + } - >.text { - white-space: nowrap !important; - overflow: visible !important; - width: max-content !important; - text-overflow: clip !important; - } - } + &[aria-expanded="true"]>.folder::before { + content: "\e92d"; + } - // File items - li, - [data-type="file"] { - min-width: 100%; - width: max-content; - } -} \ No newline at end of file + >.text { + width: max-content !important; + min-width: max-content; + padding-right: 14px; + white-space: nowrap !important; + overflow: visible !important; + text-overflow: clip !important; + } + + >:last-child { + display: none; + } + } +} diff --git a/src/components/virtualList/index.js b/src/components/virtualList/index.js index bf7b7db4b..6012fbd00 100644 --- a/src/components/virtualList/index.js +++ b/src/components/virtualList/index.js @@ -1,16 +1,28 @@ -import "./style.scss"; +import { + elementScroll, + observeElementOffset, + observeElementRect, + Virtualizer, +} from "@tanstack/virtual-core"; import tag from "html-tag-js"; +import "./style.scss"; /** * @typedef {object} VirtualListOptions - * @property {number} [itemHeight=30] - Height of each item in pixels - * @property {number} [buffer=10] - Extra items to render above/below viewport - * @property {function(any, HTMLElement?): HTMLElement} renderItem - Function to render an item + * @property {number} [itemHeight=30] Fixed row height in pixels + * @property {number} [overscan=10] Extra rows rendered above and below the viewport + * @property {number} [activeOverscan] Rows rendered per side during interaction + * @property {number} [maxOverscan] Maximum rows rendered per side during a fling + * @property {function(any, number): string|number|bigint} [getItemKey] Stable item key + * @property {function(any, HTMLElement?, number): HTMLElement} renderItem Row renderer */ /** - * Virtual scrolling list component - * Only renders items visible in viewport + buffer for performance + * Small DOM adapter around TanStack Virtual's framework-agnostic core. + * + * TanStack owns the scroll observation and visible-range calculation. The DOM + * adapter keeps a single stable scroll surface and incrementally recycles only + * rows that leave the range, which is important for low-memory Android WebViews. */ export default class VirtualList { /** @@ -20,147 +32,318 @@ export default class VirtualList { constructor(container, options = {}) { this.container = container; this.itemHeight = options.itemHeight || 30; - this.buffer = options.buffer || 10; + this.overscan = options.overscan ?? 10; + this.activeOverscan = Math.max( + this.overscan, + options.activeOverscan ?? this.overscan, + ); + this.maxOverscan = Math.max( + this.activeOverscan, + options.maxOverscan ?? this.activeOverscan, + ); + this.getItemKey = options.getItemKey || ((_item, index) => index); this.renderItem = options.renderItem || - ((item) => { - return tag("div", { textContent: String(item) }); - }); - + ((item) => tag("div", { textContent: String(item) })); this.items = []; + this.pool = []; this.renderedRange = { start: -1, end: -1 }; - this.pool = []; // DOM recycling pool + this.renderedTotalSize = -1; + this.dynamicOverscan = this.overscan; + this.lastScrollOffset = this.container.scrollTop; + this.lastScrollTime = this.now(); + this.touching = false; + this.releaseTimer = 0; + this.destroyed = false; - // Create structure using html-tag-js - this.topSpacer = tag("div", { - className: "virtual-spacer virtual-spacer-top", + this.scrollSurface = tag("div", { + className: "virtual-list-surface", }); - this.bottomSpacer = tag("div", { - className: "virtual-spacer virtual-spacer-bottom", + this.itemContainer = tag("div", { + className: "virtual-list-items", }); - this.itemContainer = tag("div", { className: "virtual-items" }); + this.scrollSurface.append(this.itemContainer); + this.container.append(this.scrollSurface); - this.container.append( - this.topSpacer, - this.itemContainer, - this.bottomSpacer, + this.virtualizer = new Virtualizer( + this.createVirtualizerOptions(this.items), ); - - // Bind scroll handler with RAF throttling - this.rafId = null; - this.onScrollBound = this.onScroll.bind(this); - this.container.addEventListener("scroll", this.onScrollBound, { + this.destroyVirtualizer = this.virtualizer._didMount(); + this.virtualizer._willUpdate(); + this.onTouchStartBound = this.onTouchStart.bind(this); + this.onTouchEndBound = this.onTouchEnd.bind(this); + this.container.addEventListener("touchstart", this.onTouchStartBound, { + passive: true, + }); + this.container.addEventListener("touchend", this.onTouchEndBound, { passive: true, }); + this.container.addEventListener("touchcancel", this.onTouchEndBound, { + passive: true, + }); + this.render(); + } + + /** Build a complete options object because core setOptions replaces options. */ + createVirtualizerOptions(items) { + return { + count: items.length, + getScrollElement: () => this.container, + estimateSize: () => this.itemHeight, + getItemKey: (index) => this.getItemKey(items[index], index), + overscan: this.overscan, + rangeExtractor: (range) => this.extractRange(range), + initialOffset: () => this.container.scrollTop, + initialRect: { + width: this.container.clientWidth, + height: this.container.clientHeight || this.itemHeight * 10, + }, + onChange: (virtualizer, isScrolling) => + this.onVirtualizerChange(virtualizer, isScrolling), + observeElementRect, + observeElementOffset, + scrollToFn: elementScroll, + // Some Android WebViews briefly pause scroll events during momentum. + // Retain the velocity guard across those gaps instead of declaring the + // fling settled and shrinking the painted range too early. + isScrollingResetDelay: 300, + // Android WebView can report ResizeObserver loop warnings when a resize + // callback and virtual-surface mutation share a rendering step. This option + // only defers resize processing; scroll-offset updates remain synchronous. + useAnimationFrameWithResizeObserver: true, + }; + } + + /** Current high-resolution timestamp without assuming a global performance. */ + now() { + return ( + this.container.ownerDocument.defaultView?.performance?.now?.() ?? + Date.now() + ); + } + + /** Refresh TanStack's memoized range after the guard size changes. */ + setDynamicOverscan(overscan) { + const nextOverscan = Math.max( + this.overscan, + Math.min(this.maxOverscan, overscan), + ); + if (nextOverscan === this.dynamicOverscan) return; + this.dynamicOverscan = nextOverscan; + if (!this.virtualizer || this.destroyed) return; + this.virtualizer.setOptions(this.createVirtualizerOptions(this.items)); + this.virtualizer._willUpdate(); + this.render(this.virtualizer); + } + + /** Paint the interaction guard before WebView begins compositor scrolling. */ + onTouchStart() { + this.clearReleaseTimer(); + this.touching = true; + this.setDynamicOverscan(this.activeOverscan); + } + + /** Momentum scroll events keep postponing release after the finger is lifted. */ + onTouchEnd() { + this.touching = false; + this.scheduleRelease(); + } + + scheduleRelease() { + this.clearReleaseTimer(); + const targetWindow = this.container.ownerDocument.defaultView; + this.releaseTimer = targetWindow?.setTimeout(() => { + this.releaseTimer = 0; + if (this.destroyed || this.touching) return; + this.setDynamicOverscan(this.overscan); + }, 500); + } + + clearReleaseTimer() { + if (!this.releaseTimer) return; + this.container.ownerDocument.defaultView?.clearTimeout(this.releaseTimer); + this.releaseTimer = 0; } /** - * Set the items to render - * @param {Array} items + * Keep a velocity-sized paint guard on both sides. The two-sided buffer matters + * because WebView can reverse compositor direction before JavaScript receives + * the corresponding scroll event. */ + extractRange(range) { + const start = Math.max(0, range.startIndex - this.dynamicOverscan); + const end = Math.min( + range.count - 1, + range.endIndex + this.dynamicOverscan, + ); + const indexes = new Array(Math.max(0, end - start + 1)); + for (let index = start; index <= end; index++) { + indexes[index - start] = index; + } + return indexes; + } + + /** Update predictive overscan before asking TanStack for virtual items. */ + onVirtualizerChange(virtualizer, isScrolling) { + if (this.destroyed) return; + + const offset = virtualizer.scrollOffset ?? this.container.scrollTop; + const time = this.now(); + if (isScrolling) { + this.clearReleaseTimer(); + const elapsed = Math.max(8, time - this.lastScrollTime); + const distance = Math.abs(offset - this.lastScrollOffset); + // Project roughly two frames at the current velocity. The two-sided cap + // keeps a violent fling below about 200 mounted rows, not the full tree. + const projectedRows = Math.ceil( + (distance / this.itemHeight) * (32 / elapsed), + ); + this.dynamicOverscan = Math.min( + this.maxOverscan, + this.activeOverscan + projectedRows, + ); + this.scheduleRelease(); + } + + this.lastScrollOffset = offset; + this.lastScrollTime = time; + this.render(virtualizer); + } + + /** @param {Array|null|undefined} items */ setItems(items) { - this.items = items; + this.items = Array.isArray(items) ? items : []; + this.virtualizer.setOptions(this.createVirtualizerOptions(this.items)); + this.virtualizer._willUpdate(); + this.invalidate(); + } + + /** Force visible rows to be repainted. */ + invalidate() { this.renderedRange = { start: -1, end: -1 }; this.render(); } - /** - * Handle scroll events with RAF throttling - */ - onScroll() { - if (this.rafId) return; - this.rafId = requestAnimationFrame(() => { - this.rafId = null; - this.render(); - }); - } + /** Render TanStack's current viewport range plus overscan. */ + render(virtualizer = this.virtualizer) { + if (this.destroyed || !virtualizer) return; - /** - * Render visible items - */ - render() { - if (!this.items.length) { - this.topSpacer.style.height = "0px"; - this.bottomSpacer.style.height = "0px"; + const itemCount = this.items.length; + const totalSize = itemCount ? virtualizer.getTotalSize() : 0; + if (totalSize !== this.renderedTotalSize) { + this.renderedTotalSize = totalSize; + this.scrollSurface.style.height = `${totalSize}px`; + } + + if (!itemCount) { + this.recycleRows(); + this.itemContainer.style.transform = "translateY(0)"; + this.renderedRange = { start: 0, end: 0 }; return; } - const scrollTop = this.container.scrollTop; - const viewportHeight = this.container.clientHeight; + const virtualItems = virtualizer.getVirtualItems(); + // A hidden/collapsed pane can momentarily measure as zero. Retain the last + // useful rows instead of flashing an empty layer; ResizeObserver will render + // the correct range as soon as the pane has a size again. + if (!virtualItems.length) return; - // Calculate visible range - const startIndex = Math.max( - 0, - Math.floor(scrollTop / this.itemHeight) - this.buffer, - ); - const endIndex = Math.min( - this.items.length, - Math.ceil((scrollTop + viewportHeight) / this.itemHeight) + this.buffer, - ); + const firstItem = virtualItems[0]; + const lastItem = virtualItems[virtualItems.length - 1]; + const start = firstItem.index; + const end = lastItem.index + 1; - // Skip if range hasn't changed - if ( - startIndex === this.renderedRange.start && - endIndex === this.renderedRange.end - ) { + if (start === this.renderedRange.start && end === this.renderedRange.end) { return; } - // Update spacers - this.topSpacer.style.height = `${startIndex * this.itemHeight}px`; - this.bottomSpacer.style.height = `${Math.max(0, (this.items.length - endIndex) * this.itemHeight)}px`; + let oldStart = this.renderedRange.start; + let oldEnd = this.renderedRange.end; + const rangesOverlap = oldStart < end && start < oldEnd; - // Recycle existing elements - while (this.itemContainer.firstChild) { - const child = this.itemContainer.removeChild( - this.itemContainer.firstChild, - ); - this.pool.push(child); - } + if (oldStart < 0 || !rangesOverlap) { + this.recycleRows(); + const fragment = document.createDocumentFragment(); + for (let index = start; index < end; index++) { + fragment.append(this.createRenderedRow(index)); + } + this.itemContainer.append(fragment); + } else { + while (oldStart < start) { + this.recycleRow(this.itemContainer.firstElementChild); + oldStart++; + } + while (oldEnd > end) { + this.recycleRow(this.itemContainer.lastElementChild); + oldEnd--; + } - // Render visible items using DocumentFragment - const fragment = document.createDocumentFragment(); + if (start < oldStart) { + const leadingRows = document.createDocumentFragment(); + for (let index = start; index < oldStart; index++) { + leadingRows.append(this.createRenderedRow(index)); + } + this.itemContainer.insertBefore( + leadingRows, + this.itemContainer.firstElementChild, + ); + } - for (let i = startIndex; i < endIndex; i++) { - const item = this.items[i]; - const recycledEl = this.pool.pop(); - const el = this.renderItem(item, recycledEl); - el.style.height = `${this.itemHeight}px`; - fragment.appendChild(el); + if (end > oldEnd) { + const trailingRows = document.createDocumentFragment(); + for (let index = oldEnd; index < end; index++) { + trailingRows.append(this.createRenderedRow(index)); + } + this.itemContainer.append(trailingRows); + } } - this.itemContainer.appendChild(fragment); - this.renderedRange = { start: startIndex, end: endIndex }; + // Translate the rendered block once, rather than positioning every row. + // This keeps layout work small and preserves the browser's native momentum. + this.itemContainer.style.transform = `translateY(${firstItem.start}px)`; + this.renderedRange = { start, end }; } - /** - * Scroll to a specific item index - * @param {number} index - */ - scrollToIndex(index) { - this.container.scrollTop = index * this.itemHeight; + /** Render one row, reusing a pooled element when possible. */ + createRenderedRow(index) { + const recycled = this.pool.pop(); + const row = this.renderItem(this.items[index], recycled, index); + row.style.height = `${this.itemHeight}px`; + return row; + } + + /** Detach one mounted row and return it to the pool. */ + recycleRow(row) { + if (!row) return; + row.remove(); + this.pool.push(row); + } + + /** Move mounted rows into the recycling pool. */ + recycleRows() { + while (this.itemContainer.firstElementChild) { + this.recycleRow(this.itemContainer.firstElementChild); + } + } + + /** @param {number} index @param {"auto"|"start"|"center"} [align] */ + scrollToIndex(index, align = "auto") { + if (!this.items.length) return; + this.virtualizer.scrollToIndex(index, { align, behavior: "auto" }); } - /** - * Get the currently visible range - * @returns {{start: number, end: number}} - */ getVisibleRange() { return { ...this.renderedRange }; } - /** - * Destroy and cleanup - */ destroy() { - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } - this.container.removeEventListener("scroll", this.onScrollBound); - this.topSpacer.remove(); - this.bottomSpacer.remove(); - this.itemContainer.remove(); + this.destroyed = true; + this.clearReleaseTimer(); + this.container.removeEventListener("touchstart", this.onTouchStartBound); + this.container.removeEventListener("touchend", this.onTouchEndBound); + this.container.removeEventListener("touchcancel", this.onTouchEndBound); + this.destroyVirtualizer?.(); + this.scrollSurface.remove(); this.pool = []; this.items = []; } diff --git a/src/components/virtualList/style.scss b/src/components/virtualList/style.scss index 9af4e0df0..c9d3ecbf6 100644 --- a/src/components/virtualList/style.scss +++ b/src/components/virtualList/style.scss @@ -1,9 +1,14 @@ -.virtual-spacer { - width: 100%; - pointer-events: none; +.virtual-list-surface { + position: relative; + min-width: 100%; + width: max-content; + overflow-anchor: none; } -.virtual-items { - position: relative; +.virtual-list-items { + position: absolute; + top: 0; + left: 0; min-width: 100%; + width: max-content; } diff --git a/src/lib/fileList.js b/src/lib/fileList.js index 39f4807de..2f5d185b4 100644 --- a/src/lib/fileList.js +++ b/src/lib/fileList.js @@ -11,6 +11,8 @@ import settings from "./settings"; const filesTree = {}; const pendingScans = new Set(); +const activeNativeScans = new Map(); +let initialized = false; const events = { "add-file": [], "push-file": [], @@ -25,9 +27,24 @@ export function initFileList() { editorManager.activeFile.on("loadend", initFileList); return; } + if (initialized) return; + initialized = true; // editorManager.on('add-folder', onAddFolder); editorManager.on("remove-folder", onRemoveFolder); settings.on("update:excludeFolders:after", refresh); + settings.on("update:fileBrowser:after", onFileBrowserSettingsChange); +} + +/** Stop background indexing immediately when automatic file listing is disabled. */ +function onFileBrowserSettingsChange(fileBrowser) { + if (fileBrowser?.listFiles !== false) return; + for (const cancel of activeNativeScans.values()) cancel(); +} + +function isFileListingEnabled(rootUrl) { + if (settings.value.fileBrowser?.listFiles === false) return false; + const folder = addedFolder.find(({ url }) => url === rootUrl); + return folder?.listFiles !== false; } /** @@ -83,6 +100,7 @@ export async function refresh() { await Promise.all( addedFolder.map(async ({ url, title }) => { + if (!isFileListingEnabled(url)) return; const tree = await Tree.createRoot(url, title); filesTree[url] = tree; trackScan(getAllFiles(tree)); @@ -263,10 +281,23 @@ function onRemoveFolder({ url }) { */ async function getAllFiles(parent, root, options = {}) { root = root || parent.root; - if (!parent.children || !root.isConnected) return; + if ( + !parent.children || + !root.isConnected || + !isFileListingEnabled(root.url) + ) { + return; + } if (supportsNativeWorkspace(root.url)) { - return getAllFilesNative(parent, root, options); + try { + await getAllFilesNative(parent, root, options); + } catch (error) { + // Never fall back to recursive JavaScript enumeration here. Walking a large + // workspace on WebView's main thread makes the entire app unresponsive. + window.log("warn", "Native workspace scan stopped", error); + } + return; } try { @@ -312,48 +343,57 @@ async function getAllFilesNative(parent, root, options = {}) { const finish = (fn, value) => { if (settled) return; settled = true; + activeNativeScans.delete(id); fn(value); }; - - const cancelIfDisconnected = () => { - if (root.isConnected) return false; + const cancel = () => { try { sdcard.workspaceCancel(id); } catch (_) { - // ignore cancellation failures + // The scan can already have completed on the native thread. } finish(resolve); + }; + activeNativeScans.set(id, cancel); + + const cancelIfDisconnected = () => { + if (root.isConnected) return false; + cancel(); return true; }; - sdcard.workspaceScan( - { - id, - rootUrl: parent.url, - title: parent.name, - excludeFolders: settings.value.excludeFolders, - showHiddenFiles: !!settings.value.fileBrowser?.showHiddenFiles, - defaultEncoding: settings.value.defaultFileEncoding, - indexContent: !!options.indexContent, - }, - (event) => { - if (cancelIfDisconnected()) return; - switch (event?.type || event?.action) { - case "batch": - addNativeEntries(root, event.entries || []); - break; - case "done": - finish(resolve); - break; - case "error": - finish(reject, new Error(event.error || "Native scan failed")); - break; - } - }, - (error) => { - finish(reject, error); - }, - ); + try { + sdcard.workspaceScan( + { + id, + rootUrl: parent.url, + title: parent.name, + excludeFolders: settings.value.excludeFolders, + showHiddenFiles: !!settings.value.fileBrowser?.showHiddenFiles, + defaultEncoding: settings.value.defaultFileEncoding, + indexContent: !!options.indexContent, + }, + (event) => { + if (cancelIfDisconnected()) return; + switch (event?.type || event?.action) { + case "batch": + addNativeEntries(root, event.entries || []); + break; + case "done": + finish(resolve); + break; + case "error": + finish(reject, new Error(event.error || "Native scan failed")); + break; + } + }, + (error) => { + finish(reject, error); + }, + ); + } catch (error) { + finish(reject, error); + } }); } @@ -395,7 +435,12 @@ function emit(event, ...args) { function trackScan(scan) { pendingScans.add(scan); - scan.finally(() => pendingScans.delete(scan)); + // `finally()` creates a second rejecting promise. Nothing observes that promise, + // so a handled native scan failure was still reported as "Uncaught (in promise)". + scan.then( + () => pendingScans.delete(scan), + () => pendingScans.delete(scan), + ); return scan; } @@ -406,7 +451,7 @@ function trackScan(scan) { * @param {Tree} root */ async function createChildTree(parent, item, root) { - if (!root.isConnected) return; + if (!root.isConnected || !isFileListingEnabled(root.url)) return; const { name, url, isDirectory, mime, type, size, modifiedDate } = item; const exists = parent.children.findIndex((child) => child.url === url); if (exists > -1) { @@ -421,7 +466,7 @@ async function createChildTree(parent, item, root) { size, modifiedDate, ); - if (!root.isConnected) return; + if (!root.isConnected || !isFileListingEnabled(root.url)) return; const existingTree = getTree(Object.values(filesTree), file.url); diff --git a/src/lib/openFolder.js b/src/lib/openFolder.js index 21e21b4f8..998b3c04a 100644 --- a/src/lib/openFolder.js +++ b/src/lib/openFolder.js @@ -153,11 +153,9 @@ function openFolder(_path, opts = {}) { $root.$title.dataset.url = _path; $root.$title.dataset.name = title; - $root.$ul.onclick = - $root.$ul.oncontextmenu = - $root.$title.onclick = - $root.$title.oncontextmenu = - handleItems; + // The root title uses the legacy handler. Rows inside the FileTree use event + // delegation owned by FileTree so recycled rows never retain stale closures. + $root.$title.onclick = $root.$title.oncontextmenu = handleItems; recents.addFolder(_path, opts); sidebarApps.get("files").append($root); @@ -200,7 +198,10 @@ function openFolder(_path, opts = {}) { }); } - if (listState[_path]) { + if ( + (listState instanceof Map ? listState.get(_path) : listState[_path]) === + true + ) { $root.expand(); } @@ -211,6 +212,10 @@ function openFolder(_path, opts = {}) { e.stopImmediatePropagation(); } + if ($root.$ul?._fileTree) { + $root.$ul._fileTree.destroy(); + $root.$ul._fileTree = null; + } if ($root.parentElement) { $root.remove(); } @@ -245,7 +250,7 @@ async function expandList($list) { } $ul.innerHTML = ""; - if (saveState) listState[url] = $list.unclasped; + if (saveState) setListStateEntry(listState, url, $list.unclasped); if (!$list.unclasped) return; try { @@ -255,7 +260,7 @@ async function expandList($list) { getEntries: (dirUrl) => fsOperation(dirUrl).lsDir(), expandedState: listState, onExpandedChange: (folderUrl, isExpanded) => { - if (saveState) listState[folderUrl] = isExpanded; + if (saveState) setListStateEntry(listState, folderUrl, isExpanded); }, onFileClick: (fileUrl) => { handleClick("file", fileUrl); @@ -263,6 +268,7 @@ async function expandList($list) { onContextMenu: (type, itemUrl, name, $target) => { handleContextmenu(type, itemUrl, name, $target); }, + onError: helpers.error, }); await fileTree.load(url); @@ -280,22 +286,6 @@ async function expandList($list) { } } -/** - * Gets weather the folder is collapsed or not - * @param {HTMLElement} $el - * @param {boolean} isFile - * @returns - */ -function collapsed($el, isFile) { - if (!$el.isConnected) return true; - $el = $el.parentElement; - if (!isFile) { - $el = $el.parentElement; - } - - return $el.previousElementSibling.collapsed; -} - /** * Handle click event * @param {Event} e @@ -716,7 +706,7 @@ function execOperation(type, action, url, $target, name) { } // Prevent pasting a folder into itself or its subdirectories - if (helpers.isDir(clipBoard.$el.dataset.type)) { + if (helpers.isDir(clipBoard.type)) { const sourceUrl = Url.parse(clipBoard.url).url; const targetUrl = Url.parse(url).url; @@ -736,16 +726,9 @@ function execOperation(type, action, url, $target, name) { } } - let CASE = ""; - const $src = clipBoard.$el; - const srcType = $src.dataset.type; + const srcType = clipBoard.type; const IS_FILE = helpers.isFile(srcType); const IS_DIR = helpers.isDir(srcType); - const srcCollapsed = collapsed($src, IS_FILE); - - CASE += IS_FILE ? 1 : 0; - CASE += srcCollapsed ? 1 : 0; - CASE += $target.collapsed ? 1 : 0; startLoading(); try { @@ -801,90 +784,21 @@ function execOperation(type, action, url, $target, name) { } else { newUrl = await fs.copyTo(url); } - const { name: newName } = await fsOperation(newUrl).stat(); stopLoading(); - /** - * CASES: - * CASE 111: src is file and parent is collapsed where target is also collapsed - * CASE 110: src is file and parent is collapsed where target is unclasped - * CASE 101: src is file and parent is unclasped where target is collapsed - * CASE 100: src is file and parent is unclasped where target is also unclasped - * CASE 011: src is directory and parent is collapsed where target is also collapsed - * CASE 001: src is directory and parent is unclasped where target is also collapsed - * CASE 010: src is directory and parent is collapsed where target is also unclasped - * CASE 000: src is directory and parent is unclasped where target is also unclasped - */ if (clipBoard.action === "cut") { - //move - if (IS_FILE) { const file = editorManager.getFile(clipBoard.url, "uri"); if (file) file.uri = newUrl; } else if (IS_DIR) { helpers.updateUriOfAllActiveFiles(clipBoard.url, newUrl); + migrateOpenFolderStateUrls(clipBoard.url, newUrl); } - - switch (CASE) { - case "111": - case "011": - break; - - case "110": - appendTile($target, createFileTile(newName, newUrl)); - break; - - case "101": - $src.remove(); - break; - - case "100": - appendTile($target, createFileTile(newName, newUrl)); - $src.remove(); - break; - - case "001": - $src.parentElement.remove(); - break; - - case "010": - appendList($target, createFolderTile(newName, newUrl)); - break; - - case "000": - appendList($target, createFolderTile(newName, newUrl)); - $src.parentElement.remove(); - break; - - default: - break; - } + removeEntryFromOpenFolder(clipBoard.url); FileList.remove(clipBoard.url); - } else { - //copy - - switch (CASE) { - case "111": - case "101": - case "011": - case "001": - break; - - case "110": - case "100": - appendTile($target, createFileTile(newName, newUrl)); - break; - - case "010": - case "000": - appendList($target, createFolderTile(newName, newUrl)); - break; - - default: - break; - } } + appendEntryToOpenFolder(url, newUrl, IS_DIR ? "folder" : "file"); FileList.append(url, newUrl); toast(strings.success); clearClipboard(); @@ -908,7 +822,7 @@ function execOperation(type, action, url, $target, name) { sourceStats.name, data, ); - appendTile($target, createFileTile(sourceStats.name, insertedFile)); + appendEntryToOpenFolder(url, insertedFile, "file"); FileList.append(url, insertedFile); } catch (error) { } finally { @@ -917,12 +831,13 @@ function execOperation(type, action, url, $target, name) { } async function clipBoardAction() { + setOpenFolderEntryCut(clipBoard.url, false); clipBoard.url = url; clipBoard.action = action; + clipBoard.type = type; clipBoard.$el = $target; - if (action === "cut") $target.classList.add("cut"); - else $target.classList.remove("cut"); + setOpenFolderEntryCut(url, action === "cut"); } async function open() { @@ -934,14 +849,15 @@ function execOperation(type, action, url, $target, name) { } function cancelAction() { - clipBoard.$el.classList.remove("cut"); clearClipboard(); } function clearClipboard() { + setOpenFolderEntryCut(clipBoard.url, false); clipBoard.$el = null; clipBoard.url = null; clipBoard.action = null; + clipBoard.type = null; } } @@ -987,10 +903,29 @@ function appendList($target, $list) { */ function getLoadedFileTree($el) { return ( - $el?.$ul?._fileTree || $el?.fileTree || $el?.nextElementSibling?._fileTree + $el?.$ul?._fileTree || + $el?.fileTree || + $el?.nextElementSibling?._fileTree || + $el?.closest?.(".file-tree")?._fileTree ); } +/** Return every currently mounted root FileTree once. */ +function getOpenFileTrees() { + const trees = new Set(); + addedFolder.forEach(({ $node }) => { + const tree = getLoadedFileTree($node?.$title); + if (tree) trees.add(tree); + }); + return Array.from(trees); +} + +/** Keep cut styling in model state so it survives DOM row recycling. */ +function setOpenFolderEntryCut(url, isCut) { + if (!url) return; + getOpenFileTrees().forEach((tree) => tree.setCut(url, isCut)); +} + function normalizeUrlPathKey(url) { if (!url) return url; const { url: parsedUrl } = Url.parse(url); @@ -1106,6 +1041,13 @@ function getParentUrl(url) { * @param {string} entryUrl */ function removeEntryFromOpenFolder(entryUrl) { + let handledByTree = false; + getOpenFileTrees().forEach((tree) => { + handledByTree = tree.removeEntry(entryUrl) || handledByTree; + }); + if (handledByTree) return; + + // Compatibility fallback for a legacy list that may be mounted by a plugin. const filesApp = sidebarApps.get("files"); const $els = Array.from( filesApp.getAll(`[data-url="${CSS.escape(entryUrl)}"]`), @@ -1137,10 +1079,18 @@ function removeEntryFromOpenFolder(entryUrl) { * @param {"file"|"folder"} type */ function appendEntryToOpenFolder(parentUrl, entryUrl, type) { - const filesApp = sidebarApps.get("files"); - const $els = filesApp.getAll(`[data-url="${parentUrl}"]`); const isDirectory = type === "folder"; const name = Url.basename(entryUrl); + let handledByTree = false; + getOpenFileTrees().forEach((tree) => { + handledByTree = + tree.appendEntry(parentUrl, name, entryUrl, isDirectory) || handledByTree; + }); + if (handledByTree) return; + + // Compatibility fallback for non-virtual legacy folder lists. + const filesApp = sidebarApps.get("files"); + const $els = filesApp.getAll(`[data-url="${parentUrl}"]`); Array.from($els).forEach(($el) => { if (!(helpers.isDir($el.dataset.type) || $el.dataset.type === "root")) { @@ -1168,13 +1118,11 @@ function appendEntryToOpenFolder(parentUrl, entryUrl, type) { * @param {string} folderUrl */ async function refreshOpenFolder(folderUrl) { - const folder = openFolder.find(folderUrl); - if (!folder) return; - - const fileTree = getLoadedFileTree(folder.$node.$title); - if (!fileTree) return; - - await fileTree.refreshFolder(folderUrl, areSameOpenFolderUrl); + await Promise.all( + getOpenFileTrees().map((fileTree) => + fileTree.refreshFolder(folderUrl, areSameOpenFolderUrl), + ), + ); } /** diff --git a/src/plugins/ftp/src/android/com/foxdebug/ftp/Ftp.java b/src/plugins/ftp/src/android/com/foxdebug/ftp/Ftp.java index b2de25446..433c4e215 100644 --- a/src/plugins/ftp/src/android/com/foxdebug/ftp/Ftp.java +++ b/src/plugins/ftp/src/android/com/foxdebug/ftp/Ftp.java @@ -227,11 +227,29 @@ public void run() { return; } - FTPFile[] files = ftp.listFiles(path); + FTPFile[] files; + synchronized (ftp) { + files = ftp.listFiles(path); + } Log.d( "FTP", "FTPClient (" + ftpId + ") Listing files in " + path ); + + if (files == null) { + String reply = ftp.getReplyString(); + String message = "FTP server returned no directory listing"; + if (reply != null && !reply.trim().isEmpty()) { + message += ": " + reply.trim(); + } + Log.e( + "FTP", + "FTPClient (" + ftpId + ") path: " + path + " - " + message + ); + callback.error(message); + return; + } + Log.d( "FTP", "FTPClient (" + ftpId + ") Found " + files.length + " files." @@ -258,8 +276,11 @@ public void run() { ? linkTarget : joinPath(path, linkTarget); try { - FTPFile[] targetFiles = ftp.listFiles(linkPath); - if (targetFiles.length > 0) { + FTPFile[] targetFiles; + synchronized (ftp) { + targetFiles = ftp.listFiles(linkPath); + } + if (targetFiles != null && targetFiles.length > 0) { FTPFile targetFile = targetFiles[0]; jsonFile.put("isFile", targetFile.isFile()); jsonFile.put("isDirectory", targetFile.isDirectory()); @@ -340,8 +361,11 @@ public void run() { } // check if file or directory exists - FTPFile[] ftpFiles = ftp.listFiles(path); - if (ftpFiles.length > 0) { + FTPFile[] ftpFiles; + synchronized (ftp) { + ftpFiles = ftp.listFiles(path); + } + if (ftpFiles != null && ftpFiles.length > 0) { callback.success(1); } else { callback.success(0); @@ -506,33 +530,39 @@ public void run() { // get list of files in the parent directory String parentPath = getParentPath(oldPath); - FTPFile[] ftpFiles = ftp.listFiles(parentPath); + synchronized (ftp) { + FTPFile[] ftpFiles = ftp.listFiles(parentPath); + if (ftpFiles == null) ftpFiles = new FTPFile[0]; - Log.d("FTP", "Renaming " + oldPath + " to " + newPath); - ftp.rename(oldPath, newPath); + Log.d("FTP", "Renaming " + oldPath + " to " + newPath); + ftp.rename(oldPath, newPath); + + // check if file is renamed successfully + FTPFile[] newFile = ftp.listFiles(newPath); + if (newFile != null && newFile.length > 0) { + callback.success(newPath); + return; + } - // check if file is renamed successfully - FTPFile[] newFile = ftp.listFiles(newPath); - if (newFile.length > 0) { - callback.success(newPath); - } else { // get latest list of files in the parent directory FTPFile[] latestFtpFiles = ftp.listFiles(parentPath); // some time src file is renamed and not moved to destination // check if for changed file and rename it original name FTPFile changedFile = null; - for (FTPFile file : latestFtpFiles) { - boolean found = false; - for (FTPFile oldFile : ftpFiles) { - if (oldFile.getName().equals(file.getName())) { - found = true; + if (latestFtpFiles != null) { + for (FTPFile file : latestFtpFiles) { + boolean found = false; + for (FTPFile oldFile : ftpFiles) { + if (oldFile.getName().equals(file.getName())) { + found = true; + break; + } + } + if (!found) { + changedFile = file; break; } } - if (!found) { - changedFile = file; - break; - } } if (changedFile != null) { @@ -542,10 +572,8 @@ public void run() { ); ftp.rename(changedFilePath, oldPath); } - callback.error("Failed to rename file"); } - - callback.success(); + callback.error("Failed to rename file"); } catch (FTPConnectionClosedException e) { callback.error(e.getMessage()); } catch (IOException e) { @@ -1078,6 +1106,9 @@ private String errMessage(Exception e) { private void emptyDirectory(String directory, FTPClient client) throws FTPConnectionClosedException, IOException { FTPFile[] files = client.listFiles(directory); + if (files == null) { + throw new IOException("FTP server returned no directory listing for " + directory); + } for (FTPFile file : files) { String filename = file.getName(); if (filename.equals(".") || filename.equals("..")) { diff --git a/src/test/sanity.tests.js b/src/test/sanity.tests.js index 8fc86fca5..039fcf02e 100644 --- a/src/test/sanity.tests.js +++ b/src/test/sanity.tests.js @@ -1,3 +1,4 @@ +import FileTree from "../components/fileTree"; import { clearModifierState, clearQuickToolsButtonFeedback, @@ -54,6 +55,88 @@ export async function runSanityTests(writeOutput) { test.assertEqual(result, "done", "Async function should work correctly"); }); + runner.test( + "File tree virtualizes one flattened expanded view", + async (test) => { + const rootUrl = "memory://workspace"; + const sourceUrl = `${rootUrl}/src`; + const sourceEntries = Array.from({ length: 500 }, (_, index) => ({ + name: `file-${String(index).padStart(3, "0")}.js`, + url: `${sourceUrl}/file-${index}.js`, + isFile: true, + isDirectory: false, + })); + const listings = new Map([ + [ + rootUrl, + [ + { + name: "src", + url: sourceUrl, + isFile: false, + isDirectory: true, + }, + { + name: "README.md", + url: `${rootUrl}/README.md`, + isFile: true, + isDirectory: false, + }, + ], + ], + [sourceUrl, sourceEntries], + ]); + const container = document.createElement("ul"); + container.style.cssText = + "position:fixed;left:-10000px;top:0;width:280px;height:300px;overflow:auto"; + document.body.append(container); + const tree = new FileTree(container, { + getEntries: async (url) => listings.get(url) || [], + }); + + try { + await tree.load(rootUrl); + test.assertEqual(tree.visibleEntries.length, 2); + tree.toggle(sourceUrl, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + test.assertEqual(tree.visibleEntries.length, 502); + test.assert( + tree.virtualList.itemContainer.children.length < 100, + "Only the viewport and overscan rows should be mounted", + ); + const idleRows = tree.virtualList.itemContainer.children.length; + container.dispatchEvent(new Event("touchstart")); + const interactionRows = tree.virtualList.itemContainer.children.length; + test.assert( + interactionRows > idleRows, + "Touch should pre-paint the Android interaction guard", + ); + const retainedUrl = tree.visibleEntries[10].url; + const retainedRow = tree.findElement(retainedUrl); + container.scrollTop = 13 * 30; + container.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(resolve)); + test.assertEqual( + tree.findElement(retainedUrl), + retainedRow, + "Overlapping rows should stay mounted during incremental scrolling", + ); + test.assert( + tree.appendEntry(sourceUrl, "new.js", `${sourceUrl}/new.js`, false), + ); + test.assertEqual(tree.visibleEntries.length, 503); + test.assert(tree.removeEntry(`${sourceUrl}/new.js`)); + test.assertEqual(tree.visibleEntries.length, 502); + + tree.toggle(sourceUrl, false); + test.assertEqual(tree.visibleEntries.length, 2); + } finally { + tree.destroy(); + container.remove(); + } + }, + ); + // Test 7: Error handling runner.test("Error handling", (test) => { try { From fcfb948fd61f33d4439f8838515af838b30ee65b Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:28:29 +0530 Subject: [PATCH 2/2] free --- src/lib/openFolder.js | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/lib/openFolder.js b/src/lib/openFolder.js index 998b3c04a..d804a39fb 100644 --- a/src/lib/openFolder.js +++ b/src/lib/openFolder.js @@ -1041,11 +1041,9 @@ function getParentUrl(url) { * @param {string} entryUrl */ function removeEntryFromOpenFolder(entryUrl) { - let handledByTree = false; getOpenFileTrees().forEach((tree) => { - handledByTree = tree.removeEntry(entryUrl) || handledByTree; + tree.removeEntry(entryUrl); }); - if (handledByTree) return; // Compatibility fallback for a legacy list that may be mounted by a plugin. const filesApp = sidebarApps.get("files"); @@ -1054,11 +1052,12 @@ function removeEntryFromOpenFolder(entryUrl) { ); $els.forEach(($el) => { - const ownerTree = - $el?.parentElement?._fileTree || - $el?.parentElement?.parentElement?._fileTree; + const ownerTree = getLoadedFileTree($el); - if (ownerTree) { + // Every mounted FileTree was already updated through its model above. Never + // mutate a recycled virtual row directly, even when its parent was unloaded. + if (ownerTree instanceof FileTree) return; + if (ownerTree?.removeEntry) { ownerTree.removeEntry(entryUrl); return; } @@ -1081,12 +1080,9 @@ function removeEntryFromOpenFolder(entryUrl) { function appendEntryToOpenFolder(parentUrl, entryUrl, type) { const isDirectory = type === "folder"; const name = Url.basename(entryUrl); - let handledByTree = false; getOpenFileTrees().forEach((tree) => { - handledByTree = - tree.appendEntry(parentUrl, name, entryUrl, isDirectory) || handledByTree; + tree.appendEntry(parentUrl, name, entryUrl, isDirectory); }); - if (handledByTree) return; // Compatibility fallback for non-virtual legacy folder lists. const filesApp = sidebarApps.get("files"); @@ -1099,9 +1095,12 @@ function appendEntryToOpenFolder(parentUrl, entryUrl, type) { if (!$el.unclasped) return; - const fileTree = getLoadedFileTree($el); - if (fileTree) { - fileTree.appendEntry(name, entryUrl, isDirectory); + const ownerTree = getLoadedFileTree($el); + // An unloaded virtual destination will be read from disk when expanded. + // Calling the legacy root-only signature here would corrupt that model. + if (ownerTree instanceof FileTree) return; + if (ownerTree?.appendEntry) { + ownerTree.appendEntry(name, entryUrl, isDirectory); return; }