From 682762fd7b7eff2dd0d62e2022aa2fb61ef2cccb Mon Sep 17 00:00:00 2001 From: AuDevTist1C <114492072+AuDevTist1C@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:23:31 +0200 Subject: [PATCH] refactor(fileBrowser): rewrite navigation history layer with event-driven `NavStack` and implement parent directory navigation This commit completes an architectural refactor of Acode's file browser subsystem. It eliminates legacy technical debt centered around manual array manipulations, uncoupled state synchronization, and complex imperative DOM clearing loops. By replacing these workflows with a decoupled, object-oriented state engine, this change increases navigation performance, stabilizes platform state persistence, and improves UI safety across selection workflows. Additionally, this change addresses long-standing user experience requests by introducing a classic parent-directory ascension element ("..") to folder listings, standardizing administrative file listings, and providing automated safety flags to prevent destructive modifications during multi-selection activities. --- Historically, directory history within the file browser module was maintained via a plain JavaScript array (`state`). While functionally straightforward, this architecture coupled state manipulation directly to imperative DOM interactions. When users navigated deep into hierarchical folders or stepped backward using top breadcrumbs, the system relied on manual validation loops that concurrently modified the state index while explicitly destroying HTML fragments. This approach created code maintenance challenges and heightened the risk of race conditions, where mismatched state-to-DOM records could cause rendering errors during slow network or storage operations. To resolve this coupling, this commit introduces the `NavStack` state controller class, which extends the native `EventTarget` framework. By inheriting from `EventTarget`, `NavStack` functions as an isolated, event-driven micro-service dedicated solely to routing history. It maintains its data structures through private properties, utilizing a private `Set` (`#urlSet`) for immediate $O(1)$ duplicate validation alongside a private sequential record array (`#arr`). Instead of modifying external layout components directly, the state engine exposes high-level history actions (`push`, `pop`, `popUntil`) that dispatch explicit custom events containing state payloads. The core layout manager registers isolated event handlers reacting to these notifications. Consequently, state modifications automatically propagate to the browser interface and the storage synchronization hooks, standardizing data flows throughout the component's lifecycle. --- The `NavStack` engine is designed with strong parameter defensive validation and precise lifecycle tracking interfaces. Below is an overview of its core API structure: The `push({ url, name })` method serves as the entry gateway for navigation history tracker records. * **Defensive Guard**: It strictly ensures that incoming `url` arguments are resolved into non-empty strings. If a null, undefined, or empty value is passed, it halts processing immediately by throwing a descriptive `TypeError`. * **Idempotency Check**: It verifies the string against the internal `#urlSet`. If the URL has already been recorded in the active navigation history, the push operation returns early without altering the sequence, preventing duplicate navigation loops. * **Adaptive Normalization**: If the descriptive folder `name` parameter is missing or evaluates to an empty string, it dynamically generates an fallback string by calling `Url.basename(url)`, falling back to the raw URL if necessary. * **Reactive Dispatch**: Once pushed to the collection, it dispatches a new `CustomEvent("push")` carrying the immutable snapshot payload. Stack truncation is executed through a centralized private worker method (`#popUntil(url)`) that handles single entries as well as multi-level structural descents. * **Reverse Iteration Loop**: The routine scans backwards from the top index of the historical array. If an explicit target URL is provided, the loop terminates as soon as that folder record is reached, preserving the underlying path history. * **Dynamic Splicing & Purging**: For every entry traversed during truncation, the engine removes the unique record identifier from the validation `#urlSet`, cuts down the structural length of the underlying history array, and dispatches a dedicated `CustomEvent("pop")` detailing the removed layer. * **Public Aliasing**: The public interface splits this functionality into a single-step `pop()` operation and a validated `popUntil(url)` method. The latter includes runtime parameter guards that throw a `TypeError` if a blank query string is supplied. * **Safe Indexing (`get`)**: The `get(i)` method incorporates relative negative tracking notation (e.g., passing `-1` reads the top active record, while `-2` retrieves the immediate parent folder). It features rigorous numeric validation via validation checks against `NaN` parameters and returns shallow object copies to prevent external code from mutating internal state arrays. * **Persistence Serialization (`toJSON`)**: To allow clean integrations with local storage systems, the class exposes a native `toJSON()` interface. When passed into standard serialization mechanisms like `JSON.stringify()`, the instance automatically exports its private location list as a clean JSON structure, keeping its internal tracking mechanisms encapsulated. --- Following the integration of the `NavStack` architecture, the layout controller within `FileBrowserInclude` has been converted from an imperative architecture into a collection of reactive event listeners. The initialization loop registers a persistent serialization pipeline linked to the state engine's lifecycle hooks. When the application configuration allows state preservation (`doesOpenLast`), an automated serialization task attaches directly to the `"push"` and `"pop"` events. Any modification to the directory path instantly updates the `localStorage.fileBrowserState` value. This setup isolates data persistence tasks from folder navigation logic, ensuring the layout remains synchronized across application updates. Manually managed UI cleaning procedures inside the directory navigation module have been replaced with a clean subscriber pattern: * **The "pop" Interface Lifecycle**: When a location is evicted from history, the engine fires a pop handler. This event automatically searches for the exact DOM element via identifier queries (`tag.get('#' + getNavId(url))`) and removes it from the browser view. Concurrently, it automatically clears tracking indexes from the global `actionStack`. * **The "push" Interface Lifecycle**: When entering a new directory, a push event listener calculates the relative location step by querying the previous index via `navStack.get(-2)`. If found, it establishes a back-navigation callback link and appends a clean navigation breadcrumb item to the upper navbar layout. --- This commit introduces a native parent folder row ("..") to the internal directory file listings. This provides an alternative to the top breadcrumbs, offering a classic directory ascension model suitable for both touchscreen interactions and keyboard navigation. Within the core file rendering query engine (`getDir`), when a storage location successfully performs an asynchronous listing check (`fs.lsDir()`), a flag (`oneDirUp = true`) is initialized. Upon verification, the loader intercepts the folder result array and calls an ingestion routine using `util.pushFolder`. This creates a pseudo-directory node mapped directly to a parent directory configuration: ```javascript util.pushFolder(list, "..", null, { oneDirUp: true, notSelectable: true, }); ``` (AI generated commit message) --- src/pages/fileBrowser/NavStack.js | 106 ++++++++++++ src/pages/fileBrowser/fileBrowser.js | 244 +++++++++++---------------- src/pages/fileBrowser/list.hbs | 2 + 3 files changed, 206 insertions(+), 146 deletions(-) create mode 100644 src/pages/fileBrowser/NavStack.js diff --git a/src/pages/fileBrowser/NavStack.js b/src/pages/fileBrowser/NavStack.js new file mode 100644 index 000000000..8335fadc7 --- /dev/null +++ b/src/pages/fileBrowser/NavStack.js @@ -0,0 +1,106 @@ +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 (!(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.popUntil(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..5e397990a 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -32,6 +32,7 @@ import _addMenu from "./add-menu.hbs"; import _addMenuHome from "./add-menu-home.hbs"; import _template from "./fileBrowser.hbs"; import _list from "./list.hbs"; +import NavStack from "./NavStack"; import util from "./util"; /** @@ -55,30 +56,36 @@ import util from "./util"; * @returns {Promise} */ function FileBrowserInclude(mode, info, doesOpenLast = true) { - mode = mode || "file"; + 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); if (!Array.isArray(storageList)) storageList = []; + if (doesOpenLast) { + const saveFileBrowserState = () => { + localStorage.fileBrowserState = JSON.stringify(navStack); + }; + navStack.on("push", saveFileBrowserState); + navStack.on("pop", saveFileBrowserState); + } + let isSelectionMode = false; let isPasting = false; let selectedItems = new Set(); let copiedItems = []; - if (!info) { - if (mode !== "both") { - info = IS_FOLDER_MODE ? strings["open folder"] : strings["open file"]; - } else { - info = strings["file browser"]; - } - } + info ||= + strings[ + mode === "both" + ? "file browser" + : `open ${IS_FOLDER_MODE ? "folder" : "file"}` + ]; return new Promise((resolve, reject) => { //#region Declaration @@ -403,9 +410,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { }; for (const filePath of files) { - if (isCancelled) { - throw new Error("Cancelled"); - } + if (isCancelled) throw new Error("Cancelled"); const entry = zip.files[filePath]; current++; @@ -417,9 +422,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { let correctFile = filePath.replace(/\\/g, "/"); const isDirEntry = entry.dir || correctFile.endsWith("/"); - if (isUnsafeAbsolutePath(filePath)) { - continue; - } + if (isUnsafeAbsolutePath(filePath)) continue; correctFile = sanitizeZipPath(correctFile, isDirEntry); if (!correctFile) continue; @@ -439,19 +442,13 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { await createFileRecursive(extractDir, correctFile, false); - if (isCancelled) { - throw new Error("Cancelled"); - } + if (isCancelled) throw new Error("Cancelled"); const content = await entry.async("arraybuffer"); - if (isCancelled) { - throw new Error("Cancelled"); - } + if (isCancelled) throw new Error("Cancelled"); await fsOperation(fileUrl).writeFile(content); } - if (isCancelled) { - throw new Error("Cancelled"); - } + if (isCancelled) throw new Error("Cancelled"); loadingLoader.destroy(); toast(strings.success); @@ -495,9 +492,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { switch (action) { case "copy": - if (currentDir.url === "/" || !selectedItems.size) { - break; - } + if (currentDir.url === "/" || !selectedItems.size) break; copiedItems = Array.from(selectedItems); toast(strings.success); @@ -507,9 +502,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; case "compress": - if (currentDir.url === "/") { - break; - } + if (currentDir.url === "/") break; const zip = new JSZip(); let loadingLoader = loader.create( @@ -577,9 +570,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; case "delete": { - if (currentDir.url === "/") { - break; - } + if (currentDir.url === "/") break; // Show confirmation dialog const confirmMessage = @@ -673,6 +664,22 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { document.removeEventListener("resume", reload); }; + navStack.addEventListener("pop", (ev) => { + const { url } = ev.detail; + actionStack.remove(url); + tag.get(`#${getNavId(url)}`)?.remove(); + }); + navStack.addEventListener("push", (ev) => { + let action; + const prevDir = navStack.get(-2); + if (prevDir) { + const { name, url } = prevDir; + action = () => navigate(url, name); + } + const dir = ev.detail; + pushToNavbar(dir.name, dir.url, action); + }); + if (doesOpenLast && storedState.length) { loadStates(storedState); return; @@ -860,6 +867,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $list .querySelectorAll(".tile:not(.selection-header)") .forEach((item) => { + if (item.dataset.notSelectable != null) return; const checkbox = Checkbox("", false); checkbox.onclick = () => { const url = item.querySelector("data-url").textContent; @@ -877,11 +885,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $menuToggler.style.display = "none"; $selectionMenuToggler.style.display = ""; updatePasteToggler(); - - // Disable floating button in selection mode - if ($openFolder) { - $openFolder.disabled = true; - } } else { $list.classList.remove("selection-mode"); $list.querySelector(".selection-header")?.remove(); @@ -892,12 +895,10 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $menuToggler.style.display = ""; $selectionMenuToggler.style.display = "none"; updatePasteToggler(); - - // Re-enable floating button when exiting selection mode - if ($openFolder) { - $openFolder.disabled = false; - } } + + // Disable floating button when entering selection mode, and vice versa + if ($openFolder) $openFolder.disabled = active; } /** @@ -912,6 +913,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const $el = e.target; if (isSelectionMode) { + if ($el.dataset.notSelectable != null) return; const checkbox = $el.closest(".tile")?.querySelector(".input-checkbox"); if (checkbox && !$el.closest(".selection-header")) { checkbox.checked = !checkbox.checked; @@ -934,7 +936,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"); @@ -960,7 +963,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); @@ -975,7 +985,8 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } if (isContextMenu) action = "contextmenu"; - else if (idOpenDoc) action = "open-doc"; + else if (isOpenDoc) action = "open-doc"; + else if (isOneDirUp) action = "oneDirUp"; switch (action) { case "navigation": @@ -991,6 +1002,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() { @@ -1046,7 +1061,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (appSettings.value.vibrateOnTap) { navigator.vibrate(config.VIBRATION_TIME); } - if ($el.getAttribute("open-doc") === "true") return; + if (isOneDirUp || isOpenDoc) return; const deleteText = currentDir.url === "/" ? strings.remove : strings.delete; @@ -1394,12 +1409,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { util.pushFolder(allStorages, strings["add a storage"], "", { storageType: "notification", uuid: "addstorage", + notSelectable: true, }); } if (IS_FILE_MODE) { util.pushFolder(allStorages, "Select document", null, { "open-doc": true, + notSelectable: true, }); } @@ -1416,6 +1433,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const { fileBrowser } = appSettings.value; let list = []; let error = false; + let oneDirUp = false; if (url in cachedDir) { return cachedDir[url]; @@ -1440,6 +1458,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const fs = fsOperation(url); try { list = (await fs.lsDir()) ?? []; + oneDirUp = true; } catch (err) { if (progress[id]) { helpers.error(err, url); @@ -1455,11 +1474,19 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { loader.destroy(); } if (error) return null; + list = helpers.sortDir(list, fileBrowser, mode); + if (oneDirUp) { + util.pushFolder(list, "..", null, { + oneDirUp, + notSelectable: true, + }); + list.unshift(list.pop()); + } return { url, name, scroll: 0, - list: helpers.sortDir(list, fileBrowser, mode), + list, }; } } @@ -1469,7 +1496,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { * @param {String} url * @param {String} name */ - async function navigate(url, name, assignBackButton = true) { + async function navigate(url, name) { if (document.getElementById("search-bar")) { hideSearchBar(); } @@ -1481,48 +1508,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { 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; - } + if (IS_FOLDER_MODE) $openFolder.disabled = url === "/"; - 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 inStack = navStack.has(url); + if (inStack) navStack.popUntil(url); 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); + if (!inStack) navStack.push({ url, name }); render(dir); } } @@ -1542,10 +1535,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { let newUrl; if (arg === "file" || arg === "folder") { - let title = strings["enter folder name"]; - if (arg === "file") { - title = strings["enter file name"]; - } + const title = strings[`enter ${arg} name`]; let entryName = await prompt(title, "", "filename", { match: config.FILE_NAME_REGEX, @@ -1555,12 +1545,11 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (!entryName) return; entryName = helpers.fixFilename(entryName); - if (arg === "folder") { - newUrl = await helpers.createFileStructure(url, entryName, false); - } - if (arg === "file") { - newUrl = await helpers.createFileStructure(url, entryName); - } + newUrl = await helpers.createFileStructure( + url, + entryName, + arg === "file", + ); if (!newUrl.created) return; return newUrl.uri; } @@ -1667,36 +1656,9 @@ 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); - }; - } - pushToNavbar(name, url, action); - lastUrl = url; - lastName = name; - } - - currentDir = { url: lastUrl, name: lastName }; + while (states.length) navStack.push(states.shift()); + currentDir = navStack.get(-1); + const { url, name } = currentDir; navigate(url, name); } @@ -1769,27 +1731,17 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { navigate(url, name); } - 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); - } - /** * Adds a new storage and refresh location */ - function addStorage() { - util - .addPath() - .then((res) => { - storageList.push(res); - localStorage.storageList = JSON.stringify(storageList); - reload(); - }) - .catch((err) => { - helpers.error(err); - }); + async function addStorage() { + try { + storageList.push(await util.addPath()); + localStorage.storageList = JSON.stringify(storageList); + reload(); + } catch (err) { + helpers.error(err); + } } }); } diff --git a/src/pages/fileBrowser/list.hbs b/src/pages/fileBrowser/list.hbs index a4ae77297..6a3006923 100644 --- a/src/pages/fileBrowser/list.hbs +++ b/src/pages/fileBrowser/list.hbs @@ -7,6 +7,8 @@ type="{{type}}" name="{{name}}" {{#home}}home="{{.}}"{{/home}} + {{#notSelectable}}data-not-selectable{{/notSelectable}} + {{#oneDirUp}}data-one-dir-up{{/oneDirUp}} {{#open-doc}}open-doc="true"{{/open-doc}} {{#ftp-account}}ftp-account{{/ftp-account}} {{#disabled}}disabled{{/disabled}}