diff --git a/src/dialogs/select.js b/src/dialogs/select.js index 9503e3de5..8aeb1c6d6 100644 --- a/src/dialogs/select.js +++ b/src/dialogs/select.js @@ -11,13 +11,17 @@ import restoreTheme from "lib/restoreTheme"; * @property {string} [default] * @property {function():void} [onCancel] * @property {function():void} [onHide] + * @property {string} [className] */ /** * @typedef {object} SelectItem * @property {string} [value] * @property {string} [text] + * @property {string} [subText] * @property {string} [icon] + * @property {string} [className] + * @property {string} [title] * @property {boolean} [disabled] * @property {string} [letters] * @property {boolean} [checkbox] @@ -52,7 +56,7 @@ function select(title, items, options = {}) { {title} ) : null; const $select = ( -
+
{$titleSpan ? [$titleSpan, $list] : $list}
); @@ -72,6 +76,9 @@ function select(title, items, options = {}) { checkbox: null, tailElement: null, ontailclick: null, + subText: null, + className: null, + title: null, }; // init item options @@ -113,18 +120,35 @@ function select(title, items, options = {}) { }); } + const $text = ( + + ); + if (itemOptions.subText) { + $text.classList.add("has-sub-text"); + $text.append( + + + {itemOptions.subText} + + , + ); + } + const $item = tile({ lead, tail, - text: ( - - ), + text: $text, }); $item.tabIndex = "0"; + if (itemOptions.className) $item.classList.add(itemOptions.className); + if (itemOptions.title) { + $item.title = itemOptions.title; + $item.setAttribute("aria-label", itemOptions.title); + } if (itemOptions.disabled) $item.classList.add("disabled"); if (options.default === itemOptions.value) { $item.classList.add("selected"); diff --git a/src/dialogs/style.scss b/src/dialogs/style.scss index d3ddeb738..91fe9ab80 100644 --- a/src/dialogs/style.scss +++ b/src/dialogs/style.scss @@ -96,6 +96,79 @@ } } + &.select.recent-select { + width: calc(100vw - 32px); + min-width: 280px; + max-width: 420px; + + .title { + justify-content: flex-start; + min-height: 48px; + margin-inline: 18px; + } + + ul { + padding: 4px 10px 10px; + } + + .tile.recent-entry { + height: 60px; + justify-content: flex-start; + + > .icon { + width: 48px; + min-width: 48px; + height: 60px; + font-size: 1.45em; + } + + > .text.has-sub-text { + min-width: 0; + font-size: 1rem; + font-weight: 600; + line-height: 1.35; + + > .select-sub-text { + display: block; + overflow: hidden; + color: var(--secondary-text-color); + direction: rtl; + font-size: 0.72rem; + font-weight: 400; + opacity: 0.75; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + + > .select-sub-text-content { + direction: ltr; + unicode-bidi: isolate; + } + } + } + + > .clearclose { + width: 48px; + min-width: 48px; + height: 48px; + font-size: 1.2em; + opacity: 0.72; + } + } + + .tile.recent-clear { + height: 48px; + justify-content: flex-start; + + > .icon { + width: 48px; + min-width: 48px; + height: 48px; + font-size: 1.2em; + } + } + } + ul { overflow-y: auto; padding: 10px; diff --git a/src/lib/recents.js b/src/lib/recents.js index c3d18436f..816a374f5 100644 --- a/src/lib/recents.js +++ b/src/lib/recents.js @@ -1,6 +1,7 @@ import select from "dialogs/select"; import escapeStringRegexp from "escape-string-regexp"; import helpers from "utils/helpers"; +import Uri from "utils/Uri"; import Url from "utils/Url"; const recents = { @@ -78,20 +79,28 @@ const recents = { */ select(extra, type = "all", title = strings["open recent"]) { const all = []; - const MAX = 20; - const shortName = (name) => { - name = helpers.getVirtualPath(name); - - if (name.length > MAX) { - return "..." + name.substr(-MAX - 3); - } - return name; + const pathDetails = (url) => { + url = Url.parse(url).url; + const isSafUri = /^content:/.test(url); + const displayPath = isSafUri + ? Uri.getDisplayPath(url) + : helpers.getVirtualPath(url); + const documentPath = isSafUri ? Uri.getDisplayPath(url, []) : displayPath; + const name = Url.basename(displayPath) || Url.basename(documentPath); + const location = + Url.dirname(isSafUri ? documentPath : displayPath)?.replace( + /\/$/, + "", + ) || "/"; + + return { name, location, path: documentPath }; }; if (type === "dir" || type === "all") { let dirs = this.folders; for (let dir of dirs) { const { url } = dir; + const { name, location, path } = pathDetails(url); const dirValue = { type: "dir", @@ -107,8 +116,11 @@ const recents = { all.push({ value: dirValue, - text: shortName(url), + text: name, + subText: location, + title: path, icon: "folder", + className: "recent-entry", tailElement: tailElement, ontailclick: (e) => { const $item = e.currentTarget.closest(".tile"); @@ -123,7 +135,7 @@ const recents = { let files = this.files; for (let file of files) { if (!file) continue; - const name = shortName(Url.parse(file).url); + const { name, location, path } = pathDetails(Url.parse(file).url); const fileValue = { type: "file", @@ -139,7 +151,10 @@ const recents = { all.push({ value: fileValue, text: name, + subText: location, + title: path, icon: helpers.getIconForFile(name), + className: "recent-entry", tailElement: tailElement, ontailclick: (e) => { const $item = e.currentTarget.closest(".tile"); @@ -150,19 +165,22 @@ const recents = { } } - if (type === "all") all.push(["clear", strings.clear, "icon clearclose"]); - - if (extra) { - extra = extra.map((item) => { - item[1] = shortName(item[1]); - return item; + if (type === "all") { + all.push({ + value: "clear", + text: strings.clear, + icon: "clearclose", + className: "recent-clear", }); + } + if (extra) { all.push(...extra); } return select(title, all, { textTransform: false, + className: "recent-select", }); }, }; diff --git a/src/utils/Uri.js b/src/utils/Uri.js index 5ce203c79..36a9a124e 100644 --- a/src/utils/Uri.js +++ b/src/utils/Uri.js @@ -84,6 +84,87 @@ export default { if (docId) return [rootUri, docId].join("::"); else return rootUri; }, + /** + * Converts a SAF content URI into a readable path. When the URI belongs to + * an added storage, its configured name is used as the path root. + * + * @param {string} url + * @param {Array<{name?: string, uri?: string, url?: string}>} [storages] + * @returns {string} + */ + getDisplayPath(url, storages = parseStorageList()) { + try { + const { docId } = this.parse(url); + const document = splitDocId(docId); + let matchedStorage = null; + + for (const storage of storages) { + const storageUrl = storage.uri ?? storage.url; + if (!storageUrl) continue; + const isStorageRoot = url === storageUrl; + const isStorageDescendant = url.startsWith(`${storageUrl}::`); + if (!isStorageRoot && !isStorageDescendant) continue; + if (!matchedStorage || storageUrl.length > matchedStorage.url.length) { + matchedStorage = { storage, url: storageUrl }; + } + } + + if (matchedStorage) { + const root = splitDocId(this.parse(matchedStorage.url).docId); + let relativePath = document.path; + + if ( + document.volume === root.volume && + document.absolute === root.absolute + ) { + if (document.path === root.path) { + relativePath = ""; + } else if (root.path && document.path.startsWith(`${root.path}/`)) { + relativePath = document.path.slice(root.path.length + 1); + } + } + + return [matchedStorage.storage.name || document.volume, relativePath] + .filter(Boolean) + .join("/"); + } + + return formatDocumentPath(document) || url; + } catch (_) { + return url; + } + + function splitDocId(docId) { + if (docId.startsWith("/")) { + return { + absolute: true, + volume: "", + path: docId.replace(/^\/+/, ""), + }; + } + + const colonIndex = docId.indexOf(":"); + if (colonIndex >= 0) { + return { + absolute: false, + volume: docId.slice(0, colonIndex), + path: docId.slice(colonIndex + 1).replace(/^\/+/, ""), + }; + } + + const [volume = "", ...pathParts] = docId.split("/"); + return { + absolute: false, + volume, + path: pathParts.join("/"), + }; + } + + function formatDocumentPath(document) { + if (document.absolute) return `/${document.path}`; + return [document.volume, document.path].filter(Boolean).join("/"); + } + }, /** * Gets virtual address by replacing root with name i.e. added in file explorer * @param {string} url diff --git a/tests/unit/uri.test.js b/tests/unit/uri.test.js index dd7fbfd6b..c13c7d317 100644 --- a/tests/unit/uri.test.js +++ b/tests/unit/uri.test.js @@ -64,6 +64,66 @@ describe("Uri.getPrimaryAddress", () => { }); }); +describe("Uri.getDisplayPath", () => { + it("uses the storage name and a path relative to its SAF root", () => { + const url = `${TREE_URI}::primary:DCIM/Camera/photo.jpg`; + expect( + Uri.getDisplayPath(url, [{ name: "Pictures", url: TREE_URI }]), + ).toBe("Pictures/Camera/photo.jpg"); + }); + + it("does not match aliases from a prefix-sharing SAF root", () => { + const codesRoot = + "content://com.android.externalstorage.documents/tree/primary%3ACodes"; + const codesBackupRoot = + "content://com.android.externalstorage.documents/tree/primary%3ACodesBackup"; + + expect( + Uri.getDisplayPath(codesBackupRoot, [ + { name: "Codes", url: codesRoot }, + ]), + ).toBe("primary/CodesBackup"); + }); + + it("keeps direct children of a volume-root storage", () => { + const rootUri = + "content://com.android.externalstorage.documents/tree/primary%3A"; + const url = `${rootUri}::primary/file.txt`; + expect( + Uri.getDisplayPath(url, [{ name: "Internal", url: rootUri }]), + ).toBe("Internal/file.txt"); + }); + + it("falls back to a readable volume and document path", () => { + expect(Uri.getDisplayPath(SINGLE_URI, [])).toBe( + "primary/DCIM/file.txt", + ); + }); + + it("includes the selected folder in a bare primary tree path", () => { + const url = + "content://com.android.externalstorage.documents/tree/primary%3ACodes"; + expect(Uri.getDisplayPath(url, [])).toBe("primary/Codes"); + }); + + it("preserves an absolute Termux tree path", () => { + const url = + "content://com.termux.documents/tree/%2Fdata%2Fdata%2Fcom.termux%2Ffiles%2Fhome"; + expect(Uri.getDisplayPath(url, [])).toBe( + "/data/data/com.termux/files/home", + ); + }); + + it("preserves an absolute path appended to a Termux tree", () => { + const root = + "content://com.termux.documents/tree/%2Fdata%2Fdata%2Fcom.termux%2Ffiles%2Fhome"; + const url = `${root}::/data/data/com.termux/files/home/acode-site-ui`; + expect(Uri.getDisplayPath(url, [])).toBe( + "/data/data/com.termux/files/home/acode-site-ui", + ); + }); +}); + describe("Uri.getVirtualAddress", () => { it("returns the url unchanged when no storage list is available", () => { const url = `${TREE_URI}::primary:DCIM/foo`;