diff --git a/src/pages/fileBrowser/NavStack.js b/src/pages/fileBrowser/NavStack.js new file mode 100644 index 000000000..4da17d706 --- /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.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..a13a2c8f1 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -31,18 +31,19 @@ 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"; /** - * @typedef {{url: String, name: String}} Location + * @typedef {{url: string, name: string}} Location */ /** * @typedef Storage - * @property {String} name - * @property {String} uuid - * @property {String} url + * @property {string} name + * @property {string} uuid + * @property {string} url * @property {'dir'} type * @property {'permission'|'ftp'|'sftp'|'sd'} storageType */ @@ -55,13 +56,12 @@ 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); @@ -72,13 +72,12 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { 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 @@ -166,6 +165,9 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { list: [], scroll: 0, }; + /** @type {AbortController|null} */ + let _rndrAbortCtrl; + /** * @type {HTMLButtonElement} */ @@ -224,35 +226,32 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $fbMenu.onclick = function (e) { $fbMenu.hide(); - const action = e.target.getAttribute("action"); - if (action === "settings") { - filesSettings().show(); - const onshow = () => { - $page.off("show", onshow); + switch (e.target.getAttribute("action")) { + case "settings": { + filesSettings().show(); + const onshow = () => { + $page.off("show", onshow); + reload(); + }; + $page.on("show", onshow); + break; + } + case "reload": { + const { url } = currentDir; + if (url in cachedDir) delete cachedDir[url]; reload(); - }; - $page.on("show", onshow); - return; - } - - if (action === "reload") { - const { url } = currentDir; - if (url in cachedDir) delete cachedDir[url]; - reload(); - return; - } - - if (action === "refresh") { - ftp.disconnect( - () => {}, - () => {}, - ); - sftp.close( - () => {}, - () => {}, - ); - toast(strings.success); - return; + break; + } + case "refresh": + ftp.disconnect( + () => {}, + () => {}, + ); + sftp.close( + () => {}, + () => {}, + ); + toast(strings.success); } }; @@ -282,12 +281,8 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { case "import-project-zip": { let zipFile = await new Promise((resolve, reject) => { sdcard.openDocumentFile( - (res) => { - resolve(res.uri); - }, - (err) => { - reject(err); - }, + (res) => resolve(res.uri), + (err) => reject(err), "application/zip", ); }); @@ -300,9 +295,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { "Importing zip file...", { timeout: 10000, - oncancel: () => { - isCancelled = true; - }, + oncancel: () => (isCancelled = true), }, ); @@ -331,9 +324,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { dir, shouldBeDirAtEnd, ) => { - if (isCancelled) { - throw new Error("Cancelled"); - } + if (isCancelled) throw new Error("Cancelled"); let wantDirEnd = !!shouldBeDirAtEnd; let parts; if (typeof dir === "string") { @@ -348,21 +339,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (!cd) return; const newParent = Url.join(parent, cd); - const isLast = parts.length === 0; - const needDir = !isLast || wantDirEnd; - if (!(await fsOperation(newParent).exists())) { - if (needDir) { - try { - await fsOperation(parent).createDirectory(cd); - } catch (e) { - if (!(await fsOperation(newParent).exists())) throw e; - } - } else { - try { - await fsOperation(parent).createFile(cd); - } catch (e) { - if (!(await fsOperation(newParent).exists())) throw e; - } + const needDir = parts.length || wantDirEnd; + const fs1 = fsOperation(newParent); + if (!(await fs1.exists())) { + try { + const fs2 = fsOperation(parent); + await fs2[`create${needDir ? "Directory" : "File"}`](cd); + } catch (e) { + if (!(await fs1.exists())) throw e; } } if (parts.length) { @@ -372,21 +356,18 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const sanitizeZipPath = (p, isDir) => { if (!p) return ""; - let path = String(p); - path = path.replace(/\\/g, "/"); - path = path.replace(/^[a-zA-Z]+:\/\//, ""); - path = path.replace(/^\/+/, ""); - path = path.replace(/^[A-Za-z]:\//, ""); + const path = String(p) + .replace(/\\/g, "/") + .replace(/^[a-zA-Z]+:\/\//, "") + .replace(/^\/+/, "") + .replace(/^[A-Za-z]:\//, ""); const parts = path.split("/"); const stack = []; for (const part of parts) { if (!part || part === ".") continue; - if (part === "..") { - if (stack.length) stack.pop(); - continue; - } - stack.push(part); + if (part !== "..") stack.push(part); + else if (stack.length) stack.pop(); } let safe = stack.join("/"); if (isDir && safe && !safe.endsWith("/")) safe += "/"; @@ -403,9 +384,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 +396,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 +416,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); @@ -479,11 +450,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { case "addSftp": { const storage = await remoteStorage[action](); updateStorage(storage); - break; } - - default: - break; } }; @@ -491,13 +458,11 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $selectionMenu.hide(); const $target = e.target; const action = $target.getAttribute("action"); - if (!action) return; + if (!action || currentDir.url === "/") return; switch (action) { case "copy": - if (currentDir.url === "/" || !selectedItems.size) { - break; - } + if (!selectedItems.size) break; copiedItems = Array.from(selectedItems); toast(strings.success); @@ -507,10 +472,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; case "compress": - if (currentDir.url === "/") { - break; - } - const zip = new JSZip(); let loadingLoader = loader.create( strings["loading"], @@ -557,13 +518,12 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { type: "arraybuffer", }); const zipName = "archive_" + Date.now() + ".zip"; - const zipPath = Url.join(currentDir.url, zipName); + const { url } = currentDir; + const zipPath = Url.join(url, zipName); const shortPath = - currentDir.url.length > 40 - ? currentDir.url.substring(0, 37) + "..." - : currentDir.url; + url.length > 40 ? url.substring(0, 37) + "..." : url; loadingLoader.setMessage(`Saving ${zipName} to ${shortPath}`); - await fsOperation(currentDir.url).createFile(zipName, zipContent); + await fsOperation(url).createFile(zipName, zipContent); loadingLoader.destroy(); toast(strings.success); isSelectionMode = !isSelectionMode; @@ -577,69 +537,32 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; case "delete": { - if (currentDir.url === "/") { - break; - } - + const selectionCount = selectedItems.size; + const plural = selectionCount !== 1; // Show confirmation dialog - const confirmMessage = - selectedItems.size === 1 - ? strings["delete entry"].replace( - "{name}", - Array.from(selectedItems)[0].split("/").pop(), - ) - : strings["delete entries"].replace( - "{count}", - selectedItems.size, - ); + let cMsg = strings[`delete entr${plural ? "ies" : "y"}`]; + if (plural) cMsg = cMsg.replace("{count}", selectionCount); + else { + const url = Array.from(selectedItems)[0]; + cMsg = cMsg.replace("{name}", url.split("/").pop()); + } - const confirmation = await confirm(strings.warning, confirmMessage); + const confirmation = await confirm(strings.warning, cMsg); if (!confirmation) break; const loadingDialog = loader.create( strings.loading, - strings["deleting items"].replace("{count}", selectedItems.size), + strings["deleting items"].replace("{count}", selectionCount), { timeout: 3000 }, ); try { + let i = selectionCount; + const promises = new Array(i--); for (const url of selectedItems) { - if ((await fsOperation(url).stat()).isDirectory) { - if (url.startsWith("content://com.termux.documents/tree/")) { - const fs = fsOperation(url); - const entries = await fs.lsDir(); - if (entries.length === 0) { - await fs.delete(); - } else { - const deleteRecursively = async (currentUrl) => { - const currentFs = fsOperation(currentUrl); - const currentEntries = await currentFs.lsDir(); - for (const entry of currentEntries) { - if (entry.isDirectory) { - await deleteRecursively(entry.url); - } else { - await fsOperation(entry.url).delete(); - } - } - await currentFs.delete(); - }; - await deleteRecursively(url); - } - } else { - await fsOperation(url).delete(); - } - helpers.updateUriOfAllActiveFiles(url); - recents.removeFolder(url); - } else { - const fs = fsOperation(url); - await fs.delete(); - const openedFile = editorManager.getFile(url, "uri"); - if (openedFile) openedFile.uri = null; - } - recents.removeFile(url); - openFolder.removeItem(url); - delete cachedDir[url]; + promises[i--] = deleteDirOrFile(url); } + await Promise.all(promises); toast(strings.success); reload(); isSelectionMode = false; @@ -650,11 +573,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } finally { loadingDialog.destroy(); } - break; } - - default: - break; } }; @@ -664,6 +583,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { }; $page.onhide = function () { + _rndrAbortCtrl?.abort(); hideSearchBar(); hideAd(); actionStack.clearFromMark(); @@ -673,7 +593,29 @@ 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?.(); + let action; + const prevDir = navStack.get(-2); + if (prevDir) { + const { url, name } = prevDir; + action = () => navigate(url, name); + } + const dir = ev.detail; + pushToNavbar(dir.name, dir.url, action); + }); + if (doesOpenLast && storedState.length) { + navStack.push({ url: "/", name: "/" }); loadStates(storedState); return; } @@ -688,10 +630,57 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $page.hide(); } - function updateSelectionCount($count) { - if ($count) { - $count.textContent = `${selectedItems.size} items selected`; + /** + * @param {string} url + */ + function isTermuxUrl(url) { + url = `${url ?? ""}`; + return url.startsWith("content://com.termux.documents/tree/"); + } + + /** + * @param {string} url + * @param {string} [type] + */ + async function deleteDirOrFile(url, type) { + const fs = fsOperation(url); + const isDir = type ? helpers.isDir(type) : (await fs.stat()).isDirectory; + + if (isDir) { + if (!isTermuxUrl(url)) await fs.delete(); + else { + const entries = await fs.lsDir(); + if (!entries.length) await fs.delete(); + else { + const deleteRecursively = async (currentFs) => { + const currentEntries = await currentFs.lsDir(); + const promises = []; + for (const entry of currentEntries) { + const fs = fsOperation(entry.url); + promises.push( + entry.isDirectory ? deleteRecursively(fs) : fs.delete(), + ); + } + await Promise.all(promises); + await currentFs.delete(); + }; + await deleteRecursively(fs); + } + } + helpers.updateUriOfAllActiveFiles(url); + recents.removeFolder(url); + } else { + await fs.delete(); + const openedFile = editorManager.getFile(url, "uri"); + if (openedFile) openedFile.uri = null; } + recents.removeFile(url); + openFolder.removeItem(url); + delete cachedDir[url]; + } + + function updateSelectionCount($count) { + if ($count) $count.textContent = `${selectedItems.size} items selected`; } function updatePasteToggler() { @@ -737,9 +726,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const doesExist = await fsOperation(possibleConflictUrl).exists(); if (doesExist) { - if (Url.areSame(url, possibleConflictUrl)) { - continue; - } + if (Url.areSame(url, possibleConflictUrl)) continue; const targetStat = await fsOperation(possibleConflictUrl).stat(); if (stat.isDirectory || targetStat.isDirectory) { @@ -750,13 +737,11 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { continue; } + const msg = strings["file already exists force named"]; const confirmation = await confirm( strings.warning, - strings["file already exists force named"] - ? strings["file already exists force named"].replace( - "{name}", - name, - ) + msg + ? msg.replace("{name}", name) : `"${name}" already exists in this location.`, ); if (!confirmation) continue; @@ -781,14 +766,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } async function copyEntry(sourceUrl, targetDirUrl, name, sourceStat) { - const fs = fsOperation(sourceUrl); - const stat = sourceStat || (await fs.stat()); + const fs1 = fsOperation(sourceUrl); + const stat = sourceStat || (await fs1.stat()); const entryName = name || stat.name || Url.basename(sourceUrl); + const fs2 = fsOperation(targetDirUrl); if (stat.isDirectory) { - const newDirUrl = - await fsOperation(targetDirUrl).createDirectory(entryName); - const entries = await fs.lsDir(); + const newDirUrl = await fs2.createDirectory(entryName); + const entries = await fs1.lsDir(); for (const entry of entries) { await copyEntry( @@ -802,8 +787,8 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { return newDirUrl; } - const content = await fs.readFile(); - return fsOperation(targetDirUrl).createFile(entryName, content); + const content = await fs1.readFile(); + return fs2.createFile(entryName, content); } function isInsideDirectory(sourceUrl, targetUrl) { @@ -837,18 +822,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { // Handle select all functionality selectAllCheckbox.onclick = () => { const checked = selectAllCheckbox.checked; + if (!checked) selectedItems.clear(); const items = $list.querySelectorAll(".tile:not(.selection-header)"); items.forEach((item) => { const checkbox = item.querySelector(".input-checkbox"); - if (checkbox) { - checkbox.checked = checked; - const url = item.querySelector("data-url").textContent; - if (checked) { - selectedItems.add(url); - } else { - selectedItems.delete(url); - } - } + if (!checkbox) return; + checkbox.checked = checked; + if (!checked) return; + selectedItems.add(item.querySelector("data-url").textContent); }); updateSelectionCount($count); }; @@ -860,14 +841,11 @@ 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; - if (checkbox.checked) { - selectedItems.add(url); - } else { - selectedItems.delete(url); - } + selectedItems[checkbox.checked ? "add" : "delete"](url); updateSelectionCount($count); }; item.prepend(checkbox); @@ -876,13 +854,18 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $addMenuToggler.style.display = "none"; $menuToggler.style.display = "none"; $selectionMenuToggler.style.display = ""; - updatePasteToggler(); - - // Disable floating button in selection mode - if ($openFolder) { - $openFolder.disabled = true; + if (!actionStack.has("fbSelection")) { + actionStack.push({ + id: "fbSelection", + action: () => { + isSelectionMode = false; + toggleSelectionMode(false); + }, + }); } } else { + actionStack.remove("fbSelection"); + $list.classList.remove("selection-mode"); $list.querySelector(".selection-header")?.remove(); $list.querySelectorAll(".input-checkbox").forEach((cb) => cb.remove()); @@ -891,13 +874,11 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $addMenuToggler.style.display = ""; $menuToggler.style.display = ""; $selectionMenuToggler.style.display = "none"; - updatePasteToggler(); - - // Re-enable floating button when exiting selection mode - if ($openFolder) { - $openFolder.disabled = false; - } } + updatePasteToggler(); + + // Disable floating button when entering selection mode, and vice versa + if ($openFolder) $openFolder.disabled = active; } /** @@ -912,55 +893,51 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const $el = e.target; if (isSelectionMode) { - const checkbox = $el.closest(".tile")?.querySelector(".input-checkbox"); + const $el2 = $el.closest(".tile"); + if ($el2?.dataset.notSelectable != null) return; + const checkbox = $el2?.querySelector(".input-checkbox"); if (checkbox && !$el.closest(".selection-header")) { - checkbox.checked = !checkbox.checked; - const url = $el - .closest(".tile") - .querySelector("data-url").textContent; - if (checkbox.checked) { - selectedItems.add(url); - } else { - selectedItems.delete(url); - } + const checked = !checkbox.checked; + checkbox.checked = checked; + const url = $el2.querySelector("data-url").textContent; + selectedItems[checked ? "add" : "delete"](url); const $count = $content.querySelector(".selection-count"); updateSelectionCount($count); } return; } - let action = $el.getAttribute("action") || $el.dataset.action; + let action = $el.dataset.action; if (!action) return; let url = $el.dataset.url; - let name = $el.dataset.name || $el.getAttribute("name"); - const idOpenDoc = $el.hasAttribute("open-doc"); - const uuid = $el.getAttribute("uuid"); - const type = $el.getAttribute("type"); - const storageType = $el.getAttribute("storageType"); - const home = $el.getAttribute("home"); + let name = $el.dataset.name; + const isOneDirUp = $el.dataset.oneDirUp != null; + const isOpenDoc = $el.dataset.openDoc != null; + const uuid = $el.dataset.uuid; + const type = $el.dataset.type; + const storageType = $el.dataset.storageType; + const home = $el.dataset.home; const isDir = ["dir", "directory", "folder"].includes(type); if (!url) { const $url = $el.get("data-url"); - if ($url) { - url = $url.textContent; - } + if ($url) url = $url.textContent; } if (storageType === "notification") { - switch (uuid) { - case "addstorage": - addStorage(); - break; - - default: - break; - } + if (uuid === "addstorage") addStorage(); 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 +952,8 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } if (isContextMenu) action = "contextmenu"; - else if (idOpenDoc) action = "open-doc"; + else if (isOpenDoc) action = "openDoc"; + else if (isOneDirUp) action = "oneDirUp"; switch (action) { case "navigation": @@ -988,18 +966,18 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (isDir) folder(); else if (!$el.hasAttribute("disabled")) file(); break; - case "open-doc": + case "openDoc": openDoc(); break; + case "oneDirUp": { + const dir = navStack.get(-2); + if (dir) navigate(dir.url, dir.name); + } } async function folder() { - if (home) { - navigateToHome(); - return; - } - - navigate(url, name); + if (home) navigateToHome(); + else navigate(url, name); } function navigateToHome() { @@ -1032,24 +1010,21 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { if (!fileUrl) return null; try { const fs = fsOperation(fileUrl); - if (/^s?ftp:/.test(fileUrl)) { - return fs.localName; - } - const stat = await fs.stat(); - return stat?.url || null; - } catch (error) { + if (/^s?ftp:/.test(fileUrl)) return fs.localName; + return (await fs.stat())?.url || null; + } catch { return null; } } async function contextMenuHandler() { + if (isOneDirUp || isOpenDoc) return; if (appSettings.value.vibrateOnTap) { navigator.vibrate(config.VIBRATION_TIME); } - if ($el.getAttribute("open-doc") === "true") return; const deleteText = - currentDir.url === "/" ? strings.remove : strings.delete; + strings[currentDir.url === "/" ? "remove" : "delete"]; const options = [ ["delete", deleteText, "delete"], ["rename", strings.rename, "text_format"], @@ -1071,16 +1046,13 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const option = await select(strings["select"], options); switch (option) { case "delete": { - let deleteFunction = removeFile; - let message = strings["delete entry"].replace("{name}", name); - if (uuid) { - deleteFunction = removeStorage; - message = strings["remove entry"].replace("{name}", name); - } + const deleteFunction = uuid ? removeStorage : removeFile; + const message = strings[ + `${uuid ? "remove" : "delete"} entry` + ].replace("{name}", name); const confirmation = await confirm(strings.warning, message); - if (!confirmation) break; - deleteFunction(); + if (confirmation) deleteFunction(); break; } @@ -1090,10 +1062,9 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { }); newname = helpers.fixFilename(newname); - if (!newname || newname === name) break; - - if (uuid) renameStorage(newname); - else renameFile(newname); + if (newname && newname !== name) { + (uuid ? renameStorage : renameFile)(newname); + } break; } @@ -1146,45 +1117,29 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { console.error(error); toast(strings.error); } - break; } } async function renameFile(newname) { - if (url.startsWith("content://com.termux.documents/tree/")) { - if (helpers.isDir(type)) { - alert(strings.warning, strings["rename not supported"]); - return; - } else { - // Special handling for Termux content files - const fs = fsOperation(url); - try { + let newUrl; + try { + if (isTermuxUrl(url)) { + if (helpers.isDir(type)) { + alert(strings.warning, strings["rename not supported"]); + return; + } else { + // Special handling for Termux content files + const fs = fsOperation(url); const content = await fs.readFile(); - const newUrl = Url.join(Url.dirname(url), newname); - await fsOperation(Url.dirname(url)).createFile(newname, content); + const dirname = Url.dirname(url); + newUrl = Url.join(dirname, newname); + await fsOperation(dirname).createFile(newname, content); await fs.delete(); - - recents.removeFile(url); - recents.addFile(newUrl); - const file = editorManager.getFile(url, "uri"); - if (file) { - file.uri = newUrl; - file.filename = newname; - } - openFolder.renameItem(url, newUrl, newname); - toast(strings.success); - reload(); - return; - } catch (err) { - window.log("error", err); - helpers.error(err); - return; } + } else { + const fs = fsOperation(url); + newUrl = await fs.renameTo(newname); } - } - const fs = fsOperation(url); - try { - const newUrl = await fs.renameTo(newname); recents.removeFile(url); recents.addFile(newUrl); const file = editorManager.getFile(url, "uri"); @@ -1203,42 +1158,8 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { async function removeFile() { try { - if (helpers.isDir(type)) { - if (url.startsWith("content://com.termux.documents/tree/")) { - const fs = fsOperation(url); - const entries = await fs.lsDir(); - if (entries.length === 0) { - await fs.delete(); - } else { - const deleteRecursively = async (currentUrl) => { - const currentFs = fsOperation(currentUrl); - const currentEntries = await currentFs.lsDir(); - for (const entry of currentEntries) { - if (entry.isDirectory) { - await deleteRecursively(entry.url); - } else { - await fsOperation(entry.url).delete(); - } - } - await currentFs.delete(); - }; - await deleteRecursively(url); - } - } else { - await fsOperation(url).delete(); - } - helpers.updateUriOfAllActiveFiles(url); - recents.removeFolder(url); - } else { - const fs = fsOperation(url); - await fs.delete(); - const openedFile = editorManager.getFile(url, "uri"); - if (openedFile) openedFile.uri = null; - } - recents.removeFile(url); - openFolder.removeItem(url); + await deleteDirOrFile(url, type); toast(strings.success); - delete cachedDir[url]; reload(); } catch (err) { window.log("error", err); @@ -1252,18 +1173,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { recents.removeFile(url); } storageList = storageList.filter((storage) => { - if (storage.uuid !== uuid) { - return true; - } + if (storage.uuid !== uuid) return true; if (storage.url) { const parsedUrl = URLParse(storage.url, true); const keyFile = decodeURIComponent( parsedUrl.query["keyFile"] || "", ); - if (keyFile) { - fsOperation(keyFile).delete(); - } + if (keyFile) fsOperation(keyFile).delete(); } return false; }); @@ -1293,9 +1210,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { }); $page.hide(); }, - (err) => { - helpers.error(err); - }, + (err) => helpers.error(err), ); } } @@ -1394,12 +1309,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, + openDoc: true, + notSelectable: true, }); } @@ -1408,123 +1325,39 @@ 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); - + async function getDirList(url) { + let list; + if (url === "/") list = await listAllStorages(); + else { + try { const fs = fsOperation(url); - try { - list = (await fs.lsDir()) ?? []; - } catch (err) { - if (progress[id]) { - helpers.error(err, url); - } else { - console.error(err); - } - } - - error = !progress[id]; - - delete progress[id]; - clearTimeout(timeout); - loader.destroy(); + list = await fs.lsDir(); + } catch (err) { + helpers.error(err, url); } - if (error) return null; - return { - url, - name, - scroll: 0, - list: helpers.sortDir(list, fileBrowser, mode), - }; } + + if (list?.length) { + const { fileBrowser } = appSettings.value; + list = helpers.sortDir(list, fileBrowser, mode); + } + + return list; } /** * Navigates to specific directory - * @param {String} url - * @param {String} name + * @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) { + const inStack = navStack.has(url); + if (inStack) navStack.popUntil(url); + else navStack.push({ url, name }); + renderCurrentDir(); } /** @@ -1541,55 +1374,52 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { let project = ""; let newUrl; - if (arg === "file" || arg === "folder") { - let title = strings["enter folder name"]; - if (arg === "file") { - title = strings["enter file name"]; - } + switch (arg) { + case "file": + case "folder": { + const title = strings[`enter ${arg} name`]; - let entryName = await prompt(title, "", "filename", { - match: config.FILE_NAME_REGEX, - required: true, - }); + let entryName = await prompt(title, "", "filename", { + match: config.FILE_NAME_REGEX, + required: true, + }); - if (!entryName) return; - entryName = helpers.fixFilename(entryName); + 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", + ); + return newUrl.created ? newUrl.uri : null; } - if (!newUrl.created) return; - return newUrl.uri; - } - - if (arg === "project") { - projects.list().map((project) => { - const { name, icon } = project; - options.push([name, name, icon]); - }); + case "project": { + projects.list().map((project) => { + const { name, icon } = project; + options.push([name, name, icon]); + }); - project = await select(strings["new project"], options); - loader.create(project, strings.loading + "..."); - projectFiles = await projects.get(project).files(); - loader.destroy(); - projectName = await prompt(strings["project name"], project, "text", { - required: true, - match: config.FILE_NAME_REGEX, - }); + project = await select(strings["new project"], options); + loader.create(project, strings.loading + "..."); + projectFiles = await projects.get(project).files(); + loader.destroy(); + projectName = await prompt(strings["project name"], project, "text", { + required: true, + match: config.FILE_NAME_REGEX, + }); - if (!projectName) return; - loader.create(projectName, strings.loading + "..."); - const fs = fsOperation(url); - const files = Object.keys(projectFiles); // All project files + if (!projectName) return; + loader.create(projectName, strings.loading + "..."); + const fs = fsOperation(url); + const files = Object.keys(projectFiles); // All project files - newUrl = await fs.createDirectory(projectName); - projectLocation = Url.join(url, projectName, "/"); - await createProject(files); // Creating project - loader.destroy(); - return newUrl; + newUrl = await fs.createDirectory(projectName); + projectLocation = Url.join(url, projectName, "/"); + await createProject(files); // Creating project + loader.destroy(); + return newUrl; + } } async function createProject(files) { @@ -1633,9 +1463,9 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { /** * Pushes a navigation button to navbar - * @param {String} id - * @param {String} name - * @param {String} url + * @param {string} id + * @param {string} name + * @param {string} url */ function pushToNavbar(name, url, action) { if (!url) return; @@ -1646,7 +1476,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { className="nav" data-url={url} data-name={displayName} - attr-action="navigation" + data-action="navigation" attr-text={displayName} tabIndex={-1} >, @@ -1667,42 +1497,21 @@ 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 }; + currentDir = navStack.get(-1); + const { url, name } = currentDir; navigate(url, name); } /** * - * @param {String} url + * @param {string} url */ function getNavId(url) { return `nav_${url.hashCode()}`; @@ -1711,7 +1520,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { /** * * @param {Storage} storage - * @param {Boolean} doesReload + * @param {boolean} doesReload */ function updateStorage(storage, doesReload = true) { if (storage.uuid) { @@ -1720,47 +1529,86 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { storage.uuid = helpers.uuid(); } - if (!storage.type) { - storage.type = "dir"; - } + storage.type ||= "dir"; - if (!storage.storageType) { - storage.storageType = storage.type; - } + storage.storageType ||= storage.type; storageList.push(storage); localStorage.storageList = JSON.stringify(storageList); if (doesReload) reload(); } - function render(dir) { - const { list, scroll } = dir; - const $list = helpers.parseHTML( - mustache.render(_list, { - msg: strings["empty folder message"], - list, - }), - ); + function createListEl() { + return
    ; + } - if (document.getElementById("search-bar")) { - hideSearchBar(); - } + function createListItemEl(obj) { + return helpers.parseHTML(mustache.render(_listItem, obj)); + } + + async function renderCurrentDir() { + _rndrAbortCtrl?.abort(); + const rndrAbortCtrl = new AbortController(); + const abortSignal = rndrAbortCtrl.signal; + _rndrAbortCtrl = rndrAbortCtrl; + + if (document.getElementById("search-bar")) hideSearchBar(); + + const { url, name } = navStack.get(-1) ?? {}; + if (IS_FOLDER_MODE) $openFolder.disabled = url === "/"; const $oldList = $content.get("#list"); - if ($oldList) { - const { url } = currentDir; - if (url && cachedDir[url]) { - cachedDir[url].scroll = $oldList.scrollTop; - } - $oldList.remove(); + const $list = createListEl(); + + const oneDirUp = + navStack.length >= 2 && + createListItemEl({ + name: "..", + oneDirUp: true, + notSelectable: true, + }); + + 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(); + const dir = (url && cachedDir[url]) || { url, name, scroll: 0 }; currentDir = dir; - cachedDir[dir.url] = dir; + updatePasteToggler(); + + 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); + el.querySelector(".text span").style.width = + `calc((100% - 5px) * ${Math.random()})`; + fg.append(el); + } + $list.append(fg); + list = await getDirList(url); + if (abortSignal.aborted) return; + dir.list = list; + } + + 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])); + } + $list.replaceChildren(fg); + + if (!list) return; + $list.scrollTop = +dir.scroll || 0; + cachedDir[url] = dir; } function reload() { @@ -1769,27 +1617,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/fileBrowser.scss b/src/pages/fileBrowser/fileBrowser.scss index 8e64a9622..103eac6f4 100644 --- a/src/pages/fileBrowser/fileBrowser.scss +++ b/src/pages/fileBrowser/fileBrowser.scss @@ -4,7 +4,7 @@ } .tile { - &[storageType="notification"] { + &[data-storage-type="notification"] { background-color: rgb(153, 153, 255); background-color: var(--primary-color); color: rgb(255, 255, 255); @@ -22,7 +22,7 @@ position: relative; color: rgb(65, 85, 133); - &[storageType]::after { + &[data-storage-type]::after { position: absolute; top: 50%; left: 50%; @@ -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..4440d48e8 --- /dev/null +++ b/src/pages/fileBrowser/listItem.hbs @@ -0,0 +1,25 @@ +
  • + + +
    + {{name}} +
    + {{url}} +