From 3bccca715c7246cf2950f47db43ddae29d237e3b Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:15:14 +0200
Subject: [PATCH] feat: show the folder hierarchy in the search folder dropdown
---
components/mail/mail-app.tsx | 13 ++++--
lib/__tests__/search-scope-folders.test.ts | 53 ++++++++++++++++++----
lib/search-scope-folders.ts | 37 ++++++++-------
3 files changed, 73 insertions(+), 30 deletions(-)
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 367bc6acc..4d3446c46 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -4012,9 +4012,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
whatever the user picked. Both folder-less scopes span
the own AND the group/shared accounts' folders (#1082);
"All folders" also searches Trash and Junk. The shared
- folders are listed under their owner. The unified
- views already search across every account's folders,
- so it is hidden there. */}
+ folders are listed under their owner, and subfolders
+ are indented under their parent as in the sidebar
+ (a native select has no tree, so the indent is
+ non-breaking spaces, as in the filter rule modal).
+ The unified views already search across every
+ account's folders, so it is hidden there. */}
{!isUnifiedView && (
@@ -4032,14 +4035,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{searchScopeFolders.own.map((mb) => (
))}
{searchScopeFolders.shared.map((group) => (
diff --git a/lib/__tests__/search-scope-folders.test.ts b/lib/__tests__/search-scope-folders.test.ts
index d0f6f3574..e274ba671 100644
--- a/lib/__tests__/search-scope-folders.test.ts
+++ b/lib/__tests__/search-scope-folders.test.ts
@@ -3,17 +3,21 @@ import type { Mailbox } from '@/lib/jmap/types';
import { groupSearchScopeFolders } from '@/lib/search-scope-folders';
// The search panel's Folder dropdown lists the shared/group folders under
-// their owner account instead of as bare names among the own ones (#1082).
+// their owner account instead of as bare names among the own ones (#1082),
+// and shows the folder hierarchy: each subfolder right after its parent,
+// with its nesting depth for the indent.
const mb = (over: Partial & { id: string }): Mailbox =>
- ({ name: over.id, isShared: false, ...over } as Mailbox);
+ ({ name: over.id, isShared: false, sortOrder: 0, totalEmails: 0, unreadEmails: 0, ...over } as Mailbox);
+
+const rows = (list: { id: string; depth: number }[]) => list.map(({ id, depth }) => [id, depth]);
describe('groupSearchScopeFolders', () => {
- it('keeps the own folders flat and groups shared folders by owner', () => {
+ it('keeps the own folders ungrouped and groups shared folders by owner', () => {
const folders = [
- mb({ id: 'inbox', name: 'Inbox' }),
+ mb({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
mb({ id: 'g1:inbox', name: 'Inbox', isShared: true, accountId: 'g1', accountName: 'chamados@server.tld' }),
- mb({ id: 'sent', name: 'Sent' }),
+ mb({ id: 'sent', name: 'Sent', role: 'sent' }),
mb({ id: 'g2:inbox', name: 'Inbox', isShared: true, accountId: 'g2', accountName: 'vendas@server.tld' }),
mb({ id: 'g1:done', name: 'Done', isShared: true, accountId: 'g1', accountName: 'chamados@server.tld' }),
];
@@ -21,9 +25,42 @@ describe('groupSearchScopeFolders', () => {
const { own, shared } = groupSearchScopeFolders(folders);
expect(own.map((m) => m.id)).toEqual(['inbox', 'sent']);
- expect(shared).toEqual([
- { ownerId: 'g1', label: 'chamados@server.tld', mailboxes: [folders[1], folders[4]] },
- { ownerId: 'g2', label: 'vendas@server.tld', mailboxes: [folders[3]] },
+ expect(shared.map(({ ownerId, label, mailboxes }) => ({ ownerId, label, ids: mailboxes.map((m) => m.id) }))).toEqual([
+ { ownerId: 'g1', label: 'chamados@server.tld', ids: ['g1:done', 'g1:inbox'] },
+ { ownerId: 'g2', label: 'vendas@server.tld', ids: ['g2:inbox'] },
+ ]);
+ });
+
+ it('lists each subfolder right after its parent with its depth', () => {
+ const { own } = groupSearchScopeFolders([
+ mb({ id: 'projects', name: 'Projects' }),
+ mb({ id: 'client-b', name: 'Client B', parentId: 'projects' }),
+ mb({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
+ mb({ id: 'invoices', name: 'Invoices', parentId: 'client-b' }),
+ mb({ id: 'client-a', name: 'Client A', parentId: 'projects' }),
+ mb({ id: 'archive', name: 'Archive', role: 'archive' }),
+ ]);
+
+ expect(rows(own)).toEqual([
+ ['inbox', 0],
+ ['archive', 0],
+ ['projects', 0],
+ ['client-a', 1],
+ ['client-b', 1],
+ ['invoices', 2],
+ ]);
+ });
+
+ it('nests shared subfolders from the owner account top level', () => {
+ const { shared } = groupSearchScopeFolders([
+ mb({ id: 'g1:inbox', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'g1', accountName: 'team' }),
+ mb({ id: 'g1:open', name: 'Open', parentId: 'g1:inbox', isShared: true, accountId: 'g1', accountName: 'team' }),
+ ]);
+
+ expect(shared).toHaveLength(1);
+ expect(rows(shared[0].mailboxes)).toEqual([
+ ['g1:inbox', 0],
+ ['g1:open', 1],
]);
});
diff --git a/lib/search-scope-folders.ts b/lib/search-scope-folders.ts
index 7834744b7..c30747402 100644
--- a/lib/search-scope-folders.ts
+++ b/lib/search-scope-folders.ts
@@ -1,4 +1,5 @@
import type { Mailbox } from '@/lib/jmap/types';
+import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
/**
* `searchMailboxId` of the "All folders" search scope: every folder of every
@@ -31,13 +32,15 @@ export interface SearchScopeFolderGroup {
ownerId: string;
/** Owner account label (`accountName`, else the owner id). */
label: string;
- mailboxes: Mailbox[];
+ /** The owner's folders in sidebar order, `depth` counted from its top level. */
+ mailboxes: MailboxNode[];
}
/**
* Splits the sidebar's folder list into the login's own folders and the
* group/shared folders grouped by their owner account, for the search
- * panel's Folder dropdown.
+ * panel's Folder dropdown. Both come in sidebar order, each subfolder right
+ * after its parent with its nesting `depth`, so the dropdown can indent it.
*
* Shared folders carry their owner's name in the sidebar, but the flat
* dropdown listed them by bare name, so a group's "Inbox" was
@@ -45,21 +48,21 @@ export interface SearchScopeFolderGroup {
*/
export function groupSearchScopeFolders(
mailboxes: Mailbox[],
-): { own: Mailbox[]; shared: SearchScopeFolderGroup[] } {
- const own: Mailbox[] = [];
- const byOwner = new Map();
- for (const mailbox of mailboxes) {
- if (!mailbox.isShared) {
- own.push(mailbox);
- continue;
+): { own: MailboxNode[]; shared: SearchScopeFolderGroup[] } {
+ const ownRoots: MailboxNode[] = [];
+ const shared: SearchScopeFolderGroup[] = [];
+ for (const node of buildMailboxTree(mailboxes)) {
+ // buildMailboxTree wraps each shared account's folders in a virtual
+ // `shared-account-` node, the dropdown's optgroup.
+ if (node.id.startsWith('shared-account-')) {
+ shared.push({
+ ownerId: node.accountId ?? '',
+ label: node.name,
+ mailboxes: flattenMailboxTree(node.children),
+ });
+ } else {
+ ownRoots.push(node);
}
- const ownerId = mailbox.accountId ?? '';
- let group = byOwner.get(ownerId);
- if (!group) {
- group = { ownerId, label: mailbox.accountName || ownerId, mailboxes: [] };
- byOwner.set(ownerId, group);
- }
- group.mailboxes.push(mailbox);
}
- return { own, shared: Array.from(byOwner.values()) };
+ return { own: flattenMailboxTree(ownRoots), shared };
}