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`;