From e8223e67820fa19dadd16d22ba0c3008b8542897 Mon Sep 17 00:00:00 2001 From: AuDevTist1C <114492072+AuDevTist1C@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:25:51 +0200 Subject: [PATCH 1/4] refactor: overhaul navigation state management and adopt granular list item rendering with skeleton loading Overhaul navigation architecture and list view rendering performance by decoupling history tracking into an event-driven `NavStack` class and replacing full-list re-renders with granular element construction and visual skeleton states. * **Navigation Stack Module (`src/pages/fileBrowser/NavStack.js`):** * Created a dedicated `NavStack` class extending `EventTarget` to manage navigation history stack depth (`#arr`), unique path tracking (`#urlSet`), and traversal boundaries. * Emits typed `push` and `pop` custom DOM events to loosely couple navigation state changes with UI updates and action stack synchronization. * **Modular Item Rendering (`src/pages/fileBrowser/listItem.hbs`):** * Replaced monolithic `list.hbs` template with a modular `listItem.hbs` partial template. * Dynamically constructs DOM list items per record via `createListItemEl()`, avoiding full-list template re-compilation. * **Directory Cache & Async Fetching (`src/pages/fileBrowser/fileBrowser.js`):** * Refactored `cachedDir` state from a plain object to a `Map` instance. * Extracted `getDirList(url)` using `Promise.withResolvers()` and `Promise.race()` with a 10-second timeout on `fsOperation(url).lsDir()` (supporting local storage, content URIs, FTP, and SFTP directory listings). * **Skeleton Placeholder Loading (`src/pages/fileBrowser/fileBrowser.scss` & `fileBrowser.js`):** * Added visual CSS placeholder styles (`.placeholder`) to display animated skeleton loaders while directory listings resolve asynchronously. (AI generated commit message) --- src/pages/fileBrowser/NavStack.js | 107 +++++++++ src/pages/fileBrowser/fileBrowser.js | 309 +++++++++++-------------- src/pages/fileBrowser/fileBrowser.scss | 16 ++ src/pages/fileBrowser/list.hbs | 28 --- src/pages/fileBrowser/listItem.hbs | 23 ++ 5 files changed, 275 insertions(+), 208 deletions(-) create mode 100644 src/pages/fileBrowser/NavStack.js delete mode 100644 src/pages/fileBrowser/list.hbs create mode 100644 src/pages/fileBrowser/listItem.hbs diff --git a/src/pages/fileBrowser/NavStack.js b/src/pages/fileBrowser/NavStack.js new file mode 100644 index 000000000..4884f23ac --- /dev/null +++ b/src/pages/fileBrowser/NavStack.js @@ -0,0 +1,107 @@ +import Url from "utils/Url"; + +/** + * @typedef {import("./fileBrowser.js").Location} Location + */ + +export default class NavStack extends EventTarget { + static { + Object.defineProperty(this.prototype, Symbol.toStringTag, { + value: "NavStack", + configurable: true, + }); + } + + /** @type {Set} */ + #urlSet = new Set(); + /** @type {Array} */ + #arr = []; + /** + * @param {{ url: string, name?: string }} + */ + push(url, name) { + if (typeof url === "object") ({ url, name } = url); + if (!(url = `${url ?? ""}`)) { + throw new TypeError( + "NavStack.prototype.push({ url: string, name?: string }): \n" + + '"url" is either missing, null or undefined, or resolves to an empty string.', + ); + } + const urlSet = this.#urlSet; + if (urlSet.has(url)) return; + urlSet.add(url); + name = `${name ?? ""}` || Url.basename(url) || url; + this.#arr.push({ url, name }); + this.dispatchEvent( + new CustomEvent("push", { + detail: { url, name }, + }), + ); + } + /** + * @param {string} [url] + */ + #popUntil(url) { + const urlSet = this.#urlSet; + const arr = this.#arr; + for (let i = arr.length - 1; i >= 0; i--) { + const item = arr[i]; + const url2 = item.url; + if (url && url === url2) return; + this.#urlSet.delete(url2); + arr.length = i; + this.dispatchEvent( + new CustomEvent("pop", { + detail: { url: url2, name: item.name }, + }), + ); + if (!url) return; + } + } + /** + * @param {string} url + */ + popUntil(url) { + if ((url = `${url ?? ""}`)) return this.#popUntil(url); + throw new TypeError( + "NavStack.prototype.popUntil(url: string): \n" + + '"url" is either missing, null or undefined, or resolves to an empty string.', + ); + } + pop() { + return this.#popUntil(); + } + /** + * @param {number} i + * @returns {Location} + */ + get(i) { + if ((i = +i) !== i) { + throw new TypeError( + 'NavStack.prototype.get(i: number): "i" is either missing or resolves to NaN.', + ); + } + const arr = this.#arr; + const l = arr.length; + if (i < 0) i += l; + if (i < 0 || i > l - 1) return; + return { ...arr[i] }; + } + has(url) { + return this.#urlSet.has(`${url ?? ""}`); + } + /** @returns {number} */ + get length() { + return this.#arr.length; + } + /** @returns {Array} */ + toJSON() { + return this.#arr.map((obj) => ({ ...obj })); + } + on() { + return this.addEventListener(...arguments); + } + off() { + return this.removeEventListener(...arguments); + } +} diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index 570cacee3..a0ac380e4 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -31,7 +31,8 @@ import Url from "utils/Url"; import _addMenu from "./add-menu.hbs"; import _addMenuHome from "./add-menu-home.hbs"; import _template from "./fileBrowser.hbs"; -import _list from "./list.hbs"; +import _listItem from "./listItem.hbs"; +import NavStack from "./NavStack"; import util from "./util"; /** @@ -57,11 +58,10 @@ import util from "./util"; function FileBrowserInclude(mode, info, doesOpenLast = true) { mode = mode || "file"; + const navStack = new NavStack(); const IS_FOLDER_MODE = ["folder", "both"].includes(mode); const IS_FILE_MODE = ["file", "both"].includes(mode); const storedState = helpers.parseJSON(localStorage.fileBrowserState) || []; - /**@type {Array} */ - const state = []; /**@type {Array} */ const allStorages = []; let storageList = helpers.parseJSON(localStorage.storageList); @@ -159,7 +159,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $selectionMenuToggler.style.display = "none"; $pasteToggler.style.display = "none"; const progress = {}; - let cachedDir = {}; + let cachedDir = new Map(); let currentDir = { url: null, name: null, @@ -236,8 +236,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } if (action === "reload") { - const { url } = currentDir; - if (url in cachedDir) delete cachedDir[url]; reload(); return; } @@ -673,7 +671,30 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { document.removeEventListener("resume", reload); }; + const saveFileBrowserState = doesOpenLast + ? () => (localStorage.fileBrowserState = JSON.stringify(navStack)) + : null; + navStack.addEventListener("pop", (ev) => { + saveFileBrowserState?.(); + const { url } = ev.detail; + actionStack.remove(url); + tag.get(`#${getNavId(url)}`)?.remove(); + }); + navStack.addEventListener("push", (ev) => { + saveFileBrowserState?.(); + const { url, name } = ev.detail; + const prevDir = navStack.get(-2); + if (prevDir && !actionStack.has(url)) { + actionStack.push({ + id: url, + action: () => navigate(prevDir), + }); + } + pushToNavbar(url, name); + }); + if (doesOpenLast && storedState.length) { + navStack.push("/", "/"); loadStates(storedState); return; } @@ -1408,59 +1429,35 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { /** * Gets directory for given url for rendering - * @param {String} url - * @param {String} name - * @returns {Promise<{name: String, url: String, list: [], scroll: Number}>} + * @param {string} url + * @returns {Promise} */ - async function getDir(url, name) { - const { fileBrowser } = appSettings.value; - let list = []; - let error = false; - - if (url in cachedDir) { - return cachedDir[url]; - } else { - if (url === "/") { - list = await listAllStorages(); - } else { - const id = helpers.uuid(); - - progress[id] = true; - const timeout = setTimeout(() => { - loader.create(name, strings.loading + "...", { - timeout: 10000, - callback() { - loader.destroy(); - navigate("/", "/"); - progress[id] = false; - }, - }); - }, 100); - - const fs = fsOperation(url); + async function getDirList(url) { + try { + let list; + if (url === "/") list = await listAllStorages(); + else { + const p1 = fsOperation(url).lsDir(); + const { promise: p2, reject } = Promise.withResolvers(); + const tm = setTimeout( + () => reject("Directory loading timed out."), + 10000, + ); try { - list = (await fs.lsDir()) ?? []; - } catch (err) { - if (progress[id]) { - helpers.error(err, url); - } else { - console.error(err); - } + list = await Promise.race([p1, p2]); + } finally { + clearTimeout(tm); } + } - error = !progress[id]; - - delete progress[id]; - clearTimeout(timeout); - loader.destroy(); + if (list?.length) { + const { fileBrowser } = appSettings.value; + list = helpers.sortDir(list, fileBrowser, mode); } - if (error) return null; - return { - url, - name, - scroll: 0, - list: helpers.sortDir(list, fileBrowser, mode), - }; + + return list ?? []; + } catch (err) { + helpers.error(err, url); } } @@ -1469,62 +1466,12 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { * @param {String} url * @param {String} name */ - async function navigate(url, name, assignBackButton = true) { - if (document.getElementById("search-bar")) { - hideSearchBar(); - } - if (!url) { - throw new Error('navigate(url, name): "url" is required.'); - } - - if (!name) { - throw new Error('navigate(url, name): "name" is required.'); - } - - if (url === "/") { - if (IS_FOLDER_MODE) $openFolder.disabled = true; - } else { - if (IS_FOLDER_MODE) $openFolder.disabled = false; - } - - const $nav = tag.get(`#${getNavId(url)}`); - - //If navigate to previous directories, clear the rest navigation - if ($nav) { - let $topNav; - while (($topNav = $navigation.lastChild) !== $nav) { - const url = $topNav.dataset.url; - actionStack.remove(url); - $topNav.remove(); - } - - while (1) { - const location = state.slice(-1)[0]; - if (!location || location.url === url) break; - state.pop(); - } - localStorage.fileBrowserState = JSON.stringify(state); - - const dir = await getDir(url, name); - if (dir) { - render(dir); - } - return; - } - - const dir = await getDir(url, name); - if (dir) { - const { url: curl, name: cname } = currentDir; - let action; - if (doesOpenLast) pushState({ name, url }); - if (curl && cname && assignBackButton) { - action = () => { - navigate(curl, cname, false); - }; - } - pushToNavbar(name, url, action); - render(dir); - } + function navigate(url, name) { + if (typeof url === "object") ({ url, name } = url); + const inStack = navStack.has(url); + if (inStack) navStack.popUntil(url); + else navStack.push(url, name); + renderCurrentDir(); } /** @@ -1632,12 +1579,11 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } /** - * Pushes a navigation button to navbar - * @param {String} id - * @param {String} name - * @param {String} url + * Pushes a navigation button to navbar + * @param {string} url + * @param {string} name */ - function pushToNavbar(name, url, action) { + function pushToNavbar(url, name) { if (!url) return; const displayName = name || Url.basename(url) || url; $navigation.append( @@ -1652,13 +1598,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { >, ); $navigation.scrollLeft = $navigation.scrollWidth; - - if (action && !actionStack.has(url)) { - actionStack.push({ - id: url, - action, - }); - } } /** @@ -1667,37 +1606,15 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { */ function loadStates(states) { if (!Array.isArray(states) || !states.length) return; - - const backNavigation = []; - const lastState = states.pop(); - if (!lastState || !lastState.url) return; - const { url } = lastState; - const name = lastState.name || Url.basename(url) || url; - let { url: lastUrl, name: lastName } = currentDir; - while (states.length) { - const location = states.splice(0, 1)[0]; - if (!location || !location.url) { - continue; - } - const { url, name } = location; - let action; - - if (doesOpenLast) pushState({ name, url }); - if (lastUrl && lastName) { - backNavigation.push([lastUrl, lastName]); - action = () => { - const [url, name] = backNavigation.pop(); - navigate(url, name, false); - }; + try { + navStack.push(states.shift()); + } catch (err) { + console.error(err); } - pushToNavbar(name, url, action); - lastUrl = url; - lastName = name; } - - currentDir = { url: lastUrl, name: lastName }; - navigate(url, name); + const dir = navStack.get(-1); + if (dir) navigate(dir); } /** @@ -1733,47 +1650,79 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (doesReload) reload(); } - function render(dir) { - const { list, scroll } = dir; - const $list = helpers.parseHTML( - mustache.render(_list, { - msg: strings["empty folder message"], - list, - }), - ); + /** + * @returns {HTMLUListElement} + */ + function createListEl() { + return
    ; + } - if (document.getElementById("search-bar")) { - hideSearchBar(); - } + /** + * @param {object} obj + * @returns {HTMLLIElement} + */ + function createListItemEl(obj) { + return helpers.parseHTML(mustache.render(_listItem, obj)); + } + + /** + * @param {boolean} force + */ + async function renderCurrentDir(force) { + if (document.getElementById("search-bar")) hideSearchBar(); + + const { url, name } = navStack.get(-1) ?? {}; + if (IS_FOLDER_MODE) $openFolder.disabled = !url || url === "/"; const $oldList = $content.get("#list"); - if ($oldList) { - const { url } = currentDir; - if (url && cachedDir[url]) { - cachedDir[url].scroll = $oldList.scrollTop; - } - $oldList.remove(); + if (!url) return void $oldList?.remove(); + + const $list = createListEl(); + + if (!$oldList) $content.append($list); + else { + const dir = currentDir; + if (dir) dir.scroll = $oldList.scrollTop; + $content.replaceChild($list, $oldList); } - $content.append($list); - $list.scrollTop = scroll; $list.focus(); + if (force) cachedDir.delete(url); + const dir = (!force && url && cachedDir.get(url)) || { + url, + name, + scroll: 0, + }; currentDir = dir; - cachedDir[dir.url] = dir; + updatePasteToggler(); - } - function reload() { - const { url, name } = currentDir; - delete cachedDir[url]; - navigate(url, name); + let { list } = dir; + const fg = new DocumentFragment(); + if (!list) { + let plh = { placeholder: true }; + for (let i = Math.ceil($list.clientHeight / 45); i-- > 0; ) { + const el = createListItemEl(plh); + el.querySelector(".text span").style.width = + `calc((100% - 5px) * ${Math.random()})`; + fg.append(el); + } + $list.append(fg); + dir.list = list = await getDirList(url); + } + + for (let l = list?.length, i = 0; i < l; i++) { + fg.append(createListItemEl(list[i])); + } + $list.replaceChildren(fg); + + if (!list) return; + $list.scrollTop = +dir.scroll || 0; + cachedDir.set(url, dir); } - function pushState({ url, name }) { - if (!url || !name) return; - if (state.find((l) => l.url === url)) return; - state.push({ url, name }); - localStorage.fileBrowserState = JSON.stringify(state); + function reload() { + renderCurrentDir(true); } /** diff --git a/src/pages/fileBrowser/fileBrowser.scss b/src/pages/fileBrowser/fileBrowser.scss index 8e64a9622..d096bff50 100644 --- a/src/pages/fileBrowser/fileBrowser.scss +++ b/src/pages/fileBrowser/fileBrowser.scss @@ -107,6 +107,22 @@ margin: auto 15px; } } + + &.placeholder { + pointer-events: none; + .icon { + background-color: #8886; + border-radius: 50%; + scale: 0.8; + } + .text span { + display: block; + background-color: #8886; + border-radius: calc(1px / 0); + height: 1em; + min-width: 1em; + } + } } } .selection-header { diff --git a/src/pages/fileBrowser/list.hbs b/src/pages/fileBrowser/list.hbs deleted file mode 100644 index a4ae77297..000000000 --- a/src/pages/fileBrowser/list.hbs +++ /dev/null @@ -1,28 +0,0 @@ -
      {{#list}} - {{#.}} -
    • - - -
      - {{name}} -
      - {{url}} -
    • - {{/.}} - {{/list}} -
    diff --git a/src/pages/fileBrowser/listItem.hbs b/src/pages/fileBrowser/listItem.hbs new file mode 100644 index 000000000..d0cd28aa0 --- /dev/null +++ b/src/pages/fileBrowser/listItem.hbs @@ -0,0 +1,23 @@ +
  • + + +
    + {{name}} +
    + {{url}} +
  • From 26196960a4ec4c052178ba6ee8b571974fe78592 Mon Sep 17 00:00:00 2001 From: AuDevTist1C <114492072+AuDevTist1C@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:44:15 +0200 Subject: [PATCH 2/4] fix: prevent UI race conditions during rapid directory switching using AbortController Introduce cancellation checking via `AbortController` in `renderCurrentDir()` to prevent stale asynchronous directory reads from overwriting active UI state during rapid user navigation. When navigating quickly between folders or toggling back/forward, concurrent asynchronous `getDirList()` calls can resolve out of order. Slower filesystem reads could resolve after a subsequent directory navigation, overwriting the view with outdated contents. * **Render Cancellation Tracking (`src/pages/fileBrowser/fileBrowser.js`):** * Maintained a scoped `_rndrAbortCtrl` instance within `FileBrowserInclude` to track active render tasks. * Aborts any existing render controller when `renderCurrentDir()` is invoked again or when `$page.onhide` fires. * **Race Condition Guarding:** * Checks `abortSignal.aborted` immediately after `await getDirList(url)` resolves to discard stale directory contents before updating state or mutating the DOM container. (AI generated commit message) --- src/pages/fileBrowser/fileBrowser.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index a0ac380e4..1ea8534b9 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -166,6 +166,8 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { list: [], scroll: 0, }; + /** @type {AbortController | null} */ + let _rndrAbortCtrl; /** * @type {HTMLButtonElement} */ @@ -662,6 +664,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { }; $page.onhide = function () { + _rndrAbortCtrl?.abort(); hideSearchBar(); hideAd(); actionStack.clearFromMark(); @@ -1669,6 +1672,11 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { * @param {boolean} force */ async function renderCurrentDir(force) { + _rndrAbortCtrl?.abort(); + const rndrAbortCtrl = new AbortController(); + const abortSignal = rndrAbortCtrl.signal; + _rndrAbortCtrl = rndrAbortCtrl; + if (document.getElementById("search-bar")) hideSearchBar(); const { url, name } = navStack.get(-1) ?? {}; @@ -1708,9 +1716,13 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { fg.append(el); } $list.append(fg); - dir.list = list = await getDirList(url); + list = await getDirList(url); + if (abortSignal.aborted) return; + dir.list = list; } + if (_rndrAbortCtrl === rndrAbortCtrl) _rndrAbortCtrl = null; + for (let l = list?.length, i = 0; i < l; i++) { fg.append(createListItemEl(list[i])); } From 8972a8ae274a4e391bb6e063497d47e60995965b Mon Sep 17 00:00:00 2001 From: AuDevTist1C <114492072+AuDevTist1C@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:58:16 +0200 Subject: [PATCH 3/4] feat: render interactive parent directory tile for rapid upward navigation Automatically prepend a dedicated parent directory (`..`) navigation tile at the top of directory listings when navigating deep within a directory hierarchy. * **Template Integration (`src/pages/fileBrowser/listItem.hbs`):** * Added template attribute support for `{{#oneDirUp}}data-one-dir-up{{/oneDirUp}}` to identify parent navigation elements. * **Dynamic Item Prepending (`src/pages/fileBrowser/fileBrowser.js`):** * Evaluates navigation stack depth in `renderCurrentDir()`: when `navStack.length >= 2`, automatically prepends a parent folder tile (`..`) to the top of list DOM fragments during both skeleton loading and directory rendering. * **Navigation & Action Handling:** * Intercepts tap/click events on items with `data-one-dir-up` to execute upward navigation to `navStack.get(-2)`. * Excludes parent directory tiles from context menu triggers (`contextMenuHandler`) and storage creation prompts. (AI generated commit message) --- src/pages/fileBrowser/fileBrowser.js | 28 ++++++++++++++++++++++++++-- src/pages/fileBrowser/listItem.hbs | 1 + 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index 1ea8534b9..9101c42df 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -958,7 +958,8 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { let url = $el.dataset.url; let name = $el.dataset.name || $el.getAttribute("name"); - const idOpenDoc = $el.hasAttribute("open-doc"); + const isOneDirUp = $el.dataset.oneDirUp != null; + const isOpenDoc = $el.hasAttribute("open-doc"); const uuid = $el.getAttribute("uuid"); const type = $el.getAttribute("type"); const storageType = $el.getAttribute("storageType"); @@ -984,7 +985,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { return; } - if (!url && action === "open" && isDir && !idOpenDoc && !isContextMenu) { + if ( + !url && + action === "open" && + isDir && + !isOpenDoc && + !isOneDirUp && + !isContextMenu + ) { loader.hide(); util.addPath(name, uuid).then((res) => { const storage = allStorages.find((storage) => storage.uuid === uuid); @@ -1000,6 +1008,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (isContextMenu) action = "contextmenu"; else if (idOpenDoc) action = "open-doc"; + else if (isOneDirUp) action = "oneDirUp"; switch (action) { case "navigation": @@ -1015,6 +1024,10 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { case "open-doc": openDoc(); break; + case "oneDirUp": { + const dir = navStack.get(-2); + if (dir) navigate(dir.url, dir.name); + } } async function folder() { @@ -1067,6 +1080,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } async function contextMenuHandler() { + if (isOneDirUp) return; if (appSettings.value.vibrateOnTap) { navigator.vibrate(config.VIBRATION_TIME); } @@ -1687,6 +1701,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const $list = createListEl(); + const oneDirUp = + navStack.length >= 2 && + createListItemEl({ + name: "..", + oneDirUp: true, + notSelectable: true, + }); + if (!$oldList) $content.append($list); else { const dir = currentDir; @@ -1708,6 +1730,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { let { list } = dir; const fg = new DocumentFragment(); if (!list) { + if (oneDirUp) fg.append(oneDirUp); let plh = { placeholder: true }; for (let i = Math.ceil($list.clientHeight / 45); i-- > 0; ) { const el = createListItemEl(plh); @@ -1723,6 +1746,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (_rndrAbortCtrl === rndrAbortCtrl) _rndrAbortCtrl = null; + if (oneDirUp) fg.append(oneDirUp); for (let l = list?.length, i = 0; i < l; i++) { fg.append(createListItemEl(list[i])); } diff --git a/src/pages/fileBrowser/listItem.hbs b/src/pages/fileBrowser/listItem.hbs index d0cd28aa0..51066a3ac 100644 --- a/src/pages/fileBrowser/listItem.hbs +++ b/src/pages/fileBrowser/listItem.hbs @@ -5,6 +5,7 @@ type="{{type}}" name="{{name}}" {{#home}}home="{{.}}"{{/home}} + {{#oneDirUp}}data-one-dir-up{{/oneDirUp}} {{#open-doc}}open-doc="true"{{/open-doc}} {{#ftp-account}}ftp-account{{/ftp-account}} {{#disabled}}disabled{{/disabled}} From 38b64425187206d06d63aac8cb42afb6567fdc10 Mon Sep 17 00:00:00 2001 From: AuDevTist1C <114492072+AuDevTist1C@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:13:27 +0200 Subject: [PATCH 4/4] feat: render explicit empty directory and error placeholder elements Provide clear visual feedback for empty directories and filesystem errors by programmatically constructing and styling a centered placeholder element. * **Placeholder DOM Construction (`src/pages/fileBrowser/fileBrowser.js`):** * Added `createPlaceholderEl()` helper to generate an `#error-or-empty-dir` placeholder element. * Shifted error handling from inside `getDirList()` into `renderCurrentDir()`, allowing filesystem errors (including local, content URI, and FTP/SFTP `lsDir` failures) to be caught and displayed directly inside the directory view. * Formats virtual path errors and displays either a localized empty directory message or a formatted error string when directory listings return no items. * **Layout & CSS Flexbox Alignment (`src/pages/fileBrowser/fileBrowser.scss`):** * Added CSS flexbox rules for `#error-or-empty-dir` to center placeholder text horizontally and vertically. * Utilized CSS `:has(> [data-one-dir-up])` selector to offset placeholder height (`calc(100% - 45px)`) when the interactive parent directory tile is rendered above it. (AI generated commit message) --- src/pages/fileBrowser/fileBrowser.js | 76 +++++++++++++++++--------- src/pages/fileBrowser/fileBrowser.scss | 13 +++++ 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index 9101c42df..f591c230f 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -1450,32 +1450,28 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { * @returns {Promise} */ async function getDirList(url) { - try { - let list; - if (url === "/") list = await listAllStorages(); - else { - const p1 = fsOperation(url).lsDir(); - const { promise: p2, reject } = Promise.withResolvers(); - const tm = setTimeout( - () => reject("Directory loading timed out."), - 10000, - ); - try { - list = await Promise.race([p1, p2]); - } finally { - clearTimeout(tm); - } - } - - if (list?.length) { - const { fileBrowser } = appSettings.value; - list = helpers.sortDir(list, fileBrowser, mode); + let list; + if (url === "/") list = await listAllStorages(); + else { + const p1 = fsOperation(url).lsDir(); + const { promise: p2, reject } = Promise.withResolvers(); + const tm = setTimeout( + () => reject("Directory loading timed out."), + 10000, + ); + try { + list = await Promise.race([p1, p2]); + } finally { + clearTimeout(tm); } + } - return list ?? []; - } catch (err) { - helpers.error(err, url); + if (list?.length) { + const { fileBrowser } = appSettings.value; + list = helpers.sortDir(list, fileBrowser, mode); } + + return list ?? []; } /** @@ -1667,6 +1663,15 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (doesReload) reload(); } + /** + * @param {string} [msg] + * @returns {HTMLDivElement | null} + */ + function createPlaceholderEl(msg) { + if (!(msg = `${msg ?? ""}`)) return; + return
    {msg}
    ; + } + /** * @returns {HTMLUListElement} */ @@ -1727,6 +1732,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { updatePasteToggler(); + let error; let { list } = dir; const fg = new DocumentFragment(); if (!list) { @@ -1739,7 +1745,19 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { fg.append(el); } $list.append(fg); - list = await getDirList(url); + try { + list = await getDirList(url); + } catch (err) { + const url2 = /^(content|file|s?ftp|https?):/.test(url) + ? helpers.getVirtualPath(url) + : url; + console.group("Error reading directory:", url2); + console.error(err); + console.groupEnd(); + let msg = `${err ?? ""}`; + if (!msg.startsWith("Error:")) msg = `Error: ${msg}`; + error = msg.trim(); + } if (abortSignal.aborted) return; dir.list = list; } @@ -1747,8 +1765,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (_rndrAbortCtrl === rndrAbortCtrl) _rndrAbortCtrl = null; if (oneDirUp) fg.append(oneDirUp); - for (let l = list?.length, i = 0; i < l; i++) { - fg.append(createListItemEl(list[i])); + const l = list?.length; + if (!l) { + const el = createPlaceholderEl( + list ? strings["empty folder message"] : error, + ); + if (el) fg.append(el); + } else { + for (let i = 0; i < l; ) fg.append(createListItemEl(list[i++])); } $list.replaceChildren(fg); diff --git a/src/pages/fileBrowser/fileBrowser.scss b/src/pages/fileBrowser/fileBrowser.scss index d096bff50..d2af0b54a 100644 --- a/src/pages/fileBrowser/fileBrowser.scss +++ b/src/pages/fileBrowser/fileBrowser.scss @@ -89,6 +89,19 @@ height: calc(100% - 60px); overflow-y: auto; + > #error-or-empty-dir { + width: 100%; + height: 100%; + font-size: 1.2em; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + } + &:has(> [data-one-dir-up]) > #error-or-empty-dir { + height: calc(100% - 45px); + } + .tile { &[disabled] { .text {