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) => ( {group.mailboxes.map((mb) => ( ))} 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 }; }