diff --git a/src/lib/__tests__/search-scope-folders.test.ts b/src/lib/__tests__/search-scope-folders.test.ts new file mode 100644 index 00000000..8243e08f --- /dev/null +++ b/src/lib/__tests__/search-scope-folders.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { searchScopeFolders } from '../search-scope-folders'; +import type { Mailbox } from '../../api/types'; + +// The search panel's Folder picker was a flat list cut at twelve folders, so +// deeper or later folders couldn't be searched on their own (webmail +// lib/search-scope-folders.ts lists them all as a tree). + +const box = (id: string, parentId: string | null = null, role: string | null = null) => + ({ id, name: id, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, isShared: false } as unknown as Mailbox); + +describe('search folder picker', () => { + it('lists every folder, each subfolder after its parent with its depth, without the open one', () => { + const boxes = [box('inbox', null, 'inbox'), ...Array.from({ length: 15 }, (_, i) => box(`f${String(i).padStart(2, '0')}`)), box('child', 'f03')]; + const list = searchScopeFolders(boxes, 'inbox'); + expect(list).toHaveLength(16); + const ids = list.map((m) => m.id); + expect(ids.indexOf('child')).toBe(ids.indexOf('f03') + 1); + expect(list.find((m) => m.id === 'child')?.depth).toBe(1); + expect(ids).not.toContain('inbox'); + }); + + it('is what the panel shows, uncut', () => { + const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + expect(list).not.toMatch(/\.slice\(0, 12\)/); + expect(list).toMatch(/searchScopeFolders\(scopedMailboxes, currentMailboxId\)\.map/); + }); +}); diff --git a/src/lib/search-scope-folders.ts b/src/lib/search-scope-folders.ts new file mode 100644 index 00000000..3342088c --- /dev/null +++ b/src/lib/search-scope-folders.ts @@ -0,0 +1,22 @@ +import type { Mailbox } from '../api/types'; +import { buildMailboxTree, flattenVisible, type MailboxNode } from './mailbox-tree'; + +/** + * The folders the search panel's Folder picker offers: every folder (it was + * cut at twelve), in sidebar order with each subfolder right after its parent + * and its `depth` for indenting (webmail lib/search-scope-folders.ts). The + * open folder is left out: "This folder" picks it. A shared account's + * grouping row is no folder and is left out too. + */ +export function searchScopeFolders(mailboxes: Mailbox[], currentMailboxId: string | null): MailboxNode[] { + const tree = buildMailboxTree(mailboxes); + const all = new Set(); + const collect = (nodes: MailboxNode[]) => { + for (const n of nodes) { + all.add(n.id); + collect(n.children); + } + }; + collect(tree); + return flattenVisible(tree, all).filter((n) => !n.isAccountNode && n.id !== currentMailboxId); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 32abe070..6e4ccfe8 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -64,6 +64,7 @@ import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm import { confirmMailRemoval } from '../lib/mail-removal-confirm'; import { moveSheetCurrentId } from '../lib/move-targets'; import { listViewKey } from '../lib/list-view-key'; +import { searchScopeFolders } from '../lib/search-scope-folders'; import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; import type { RootStackParamList } from '../navigation/types'; @@ -1854,17 +1855,18 @@ export default function EmailListScreen({ active={folderScope === 'current'} onPress={() => setFolderScope('current')} /> - {scopedMailboxes - .filter((m) => m.id !== currentMailboxId) - .slice(0, 12) - .map((m) => ( + + {/* Every folder, as a tree (it was a flat list cut at 12). */} + + {searchScopeFolders(scopedMailboxes, currentMailboxId).map((m) => ( + setFolderScope(m.id)} /> - ))} + + ))} @@ -2427,6 +2429,7 @@ function makeStyles(c: ThemePalette) { gap: spacing.sm, marginTop: 4, }, + scopeFolderList: { alignItems: 'flex-start', gap: spacing.xs, marginTop: spacing.sm }, triToggle: { flexDirection: 'row', alignItems: 'center',