From 3c2eb9bffaa134ccbbb04138c51fbb059237b915 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:43:51 +0100 Subject: [PATCH] feat: let a new search look only in the open folder #1485 --- lib/__tests__/search-scope-folders.test.ts | 31 ++++- lib/search-default-scope.ts | 11 ++ lib/search-scope-folders.ts | 18 ++- .../email-store-search-scope.test.ts | 125 +++++++++++++++++- stores/email-store.ts | 61 ++++++--- stores/settings-store.ts | 11 ++ 6 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 lib/search-default-scope.ts diff --git a/lib/__tests__/search-scope-folders.test.ts b/lib/__tests__/search-scope-folders.test.ts index e274ba671..a6c8ad21b 100644 --- a/lib/__tests__/search-scope-folders.test.ts +++ b/lib/__tests__/search-scope-folders.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Mailbox } from '@/lib/jmap/types'; -import { groupSearchScopeFolders } from '@/lib/search-scope-folders'; +import { defaultSearchScopeFor, 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), @@ -79,3 +79,32 @@ describe('groupSearchScopeFolders', () => { expect(shared).toEqual([]); }); }); + +describe('defaultSearchScopeFor', () => { + const folders = [ + mb({ id: 'inbox', role: 'inbox' }), + mb({ id: 'projects' }), + mb({ id: 'trash', role: 'trash' }), + mb({ id: 'junk', role: 'junk' }), + ]; + + it('searches all folders but Spam and Trash from any other folder by default', () => { + expect(defaultSearchScopeFor(folders, 'inbox', 'all')).toBe(''); + expect(defaultSearchScopeFor(folders, 'projects', 'all')).toBe(''); + expect(defaultSearchScopeFor(folders, 'trash', 'all')).toBe('trash'); + expect(defaultSearchScopeFor(folders, 'junk', 'all')).toBe('junk'); + }); + + // #1485 + it('searches the open folder when "Search in" is set to it', () => { + expect(defaultSearchScopeFor(folders, 'inbox', 'folder')).toBe('inbox'); + expect(defaultSearchScopeFor(folders, 'projects', 'folder')).toBe('projects'); + expect(defaultSearchScopeFor(folders, 'trash', 'folder')).toBe('trash'); + }); + + it('searches all folders from a view that is not a folder', () => { + expect(defaultSearchScopeFor(folders, '__unified_inbox', 'folder')).toBe(''); + expect(defaultSearchScopeFor(folders, '', 'folder')).toBe(''); + expect(defaultSearchScopeFor(folders, null, 'folder')).toBe(''); + }); +}); diff --git a/lib/search-default-scope.ts b/lib/search-default-scope.ts new file mode 100644 index 000000000..784700fea --- /dev/null +++ b/lib/search-default-scope.ts @@ -0,0 +1,11 @@ +/** + * The "Search in" setting (#1485): whether a new search looks in all folders + * or only in the folder that is open. Its own module so the settings store + * can check values without importing the folder helpers (they import the + * store back through lib/utils). + */ +export type SearchDefaultScope = 'all' | 'folder'; + +export function isSearchDefaultScope(value: unknown): value is SearchDefaultScope { + return value === 'all' || value === 'folder'; +} diff --git a/lib/search-scope-folders.ts b/lib/search-scope-folders.ts index c30747402..14367b8fe 100644 --- a/lib/search-scope-folders.ts +++ b/lib/search-scope-folders.ts @@ -1,5 +1,6 @@ import type { Mailbox } from '@/lib/jmap/types'; import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils'; +import type { SearchDefaultScope } from '@/lib/search-default-scope'; /** * `searchMailboxId` of the "All folders" search scope: every folder of every @@ -18,13 +19,20 @@ export function isAllFoldersSearchScope(searchMailboxId: string): boolean { } /** - * The search scope a folder starts with: Spam and Trash search themselves, - * since that is what a search from inside them looks for; every other folder - * searches all folders except Spam and Trash (""). + * The search scope a folder starts with. With `defaultScope` 'folder' that + * is the open folder. Otherwise Spam and Trash search themselves, since that + * is what a search from inside them looks for, and every other folder + * searches all folders except Spam and Trash (""). A view that is not a + * folder of `mailboxes` (unified views, tags) always starts with "". */ -export function defaultSearchScopeFor(mailboxes: Mailbox[], openMailboxId: string | null | undefined): string { +export function defaultSearchScopeFor( + mailboxes: Mailbox[], + openMailboxId: string | null | undefined, + defaultScope: SearchDefaultScope, +): string { const open = openMailboxId ? mailboxes.find((mb) => mb.id === openMailboxId) : undefined; - return open && (open.role === 'trash' || open.role === 'junk') ? open.id : ''; + if (!open) return ''; + return defaultScope === 'folder' || open.role === 'trash' || open.role === 'junk' ? open.id : ''; } export interface SearchScopeFolderGroup { diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts index 47025a8c7..3ef44c02b 100644 --- a/stores/__tests__/email-store-search-scope.test.ts +++ b/stores/__tests__/email-store-search-scope.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useEmailStore } from '../email-store'; import { useAuthStore } from '../auth-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -193,6 +193,129 @@ describe('search folder scope (#788)', () => { }); }); +// #1485: "Search in" set to the open folder makes a new search look only in +// the folder that is open, without picking it in the filters first. +describe('"Search in: the open folder" setting (#1485)', () => { + let client: IJMAPClient; + + beforeEach(() => { + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'account-a', + getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never, + } as never); + useSettingsStore.setState({ emailsPerPage: 50, searchDefaultScope: 'folder' } as never); + useEmailStore.setState({ + isUnifiedView: false, + unifiedRole: null, + crossView: null, + viewingAccountId: null, + selectedKeyword: null, + selectedMailbox: 'inbox', + searchMailboxId: 'inbox', + searchScopeFollowsFolder: false, + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + searchAbortController: null, + emails: [], + mailboxes: [ + makeMailbox({ id: 'inbox', role: 'inbox' }), + makeMailbox({ id: 'sent', name: 'Sent Items', role: 'sent' }), + makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }), + ], + accountMailboxes: {}, + }); + }); + + afterEach(() => { + useSettingsStore.setState({ searchDefaultScope: 'all' } as never); + }); + + it('quick search looks only in the folder that is open', async () => { + useEmailStore.getState().selectMailbox('sent'); + expect(useEmailStore.getState().searchMailboxId).toBe('sent'); + + await useEmailStore.getState().searchEmails(client, 'invoice'); + + expect(client.searchEmails).toHaveBeenCalledTimes(1); + expect(client.searchEmails).toHaveBeenCalledWith('invoice', 'sent', undefined, 50, 0, { deferSnippets: true }); + }); + + it('searches a shared folder in its owner account', async () => { + useEmailStore.getState().selectMailbox('owner-x:x-inbox'); + useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'bob@example.com' } }); + + await useEmailStore.getState().advancedSearch(client); + + const calls = (client.advancedSearchEmails as ReturnType).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0][0]).toEqual({ operator: 'AND', conditions: [{ from: 'bob@example.com' }, { inMailbox: 'x-inbox' }] }); + expect(calls[0][1]).toBe('owner-x'); + }); + + it('keeps a scope picked in the filters when another folder is opened', () => { + useEmailStore.getState().setSearchMailboxId(''); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); + + it('clearing the filters goes back to the open folder', () => { + useEmailStore.getState().setSearchMailboxId('*'); + useEmailStore.getState().clearSearchFilters(); + + expect(useEmailStore.getState().searchMailboxId).toBe('inbox'); + }); + + it('a view that is not a folder searches all folders', () => { + useEmailStore.getState().selectMailbox('__unified_inbox'); + + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); + + it('changing the setting moves the open folder\'s scope with it', () => { + useSettingsStore.setState({ searchDefaultScope: 'all' } as never); + expect(useEmailStore.getState().searchMailboxId).toBe(''); + + useSettingsStore.setState({ searchDefaultScope: 'folder' } as never); + expect(useEmailStore.getState().searchMailboxId).toBe('inbox'); + + // Then it follows the folders the user opens. + useEmailStore.getState().selectMailbox('sent'); + expect(useEmailStore.getState().searchMailboxId).toBe('sent'); + }); + + it('changing the setting leaves a scope picked in the filters alone', () => { + useEmailStore.getState().setSearchMailboxId('sent'); + useSettingsStore.setState({ searchDefaultScope: 'all' } as never); + + expect(useEmailStore.getState().searchMailboxId).toBe('sent'); + }); + + it('scopes the inbox the app opens with once the folder list arrives', async () => { + useEmailStore.setState({ mailboxes: [], selectedMailbox: '', searchMailboxId: '' }); + const folders = [makeMailbox({ id: 'inbox', role: 'inbox' }), makeMailbox({ id: 'sent', role: 'sent' })]; + const listClient = { ...makeClient(), getAllMailboxes: vi.fn().mockResolvedValue(folders) } as unknown as IJMAPClient; + + await useEmailStore.getState().fetchMailboxes(listClient); + + expect(useEmailStore.getState().selectedMailbox).toBe('inbox'); + expect(useEmailStore.getState().searchMailboxId).toBe('inbox'); + }); + + it('scopes a folder opened before the folder list arrived', async () => { + useEmailStore.setState({ mailboxes: [], selectedMailbox: '', searchMailboxId: '' }); + useEmailStore.getState().selectMailbox('sent'); + const folders = [makeMailbox({ id: 'inbox', role: 'inbox' }), makeMailbox({ id: 'sent', role: 'sent' })]; + const listClient = { ...makeClient(), getAllMailboxes: vi.fn().mockResolvedValue(folders) } as unknown as IJMAPClient; + + await useEmailStore.getState().fetchMailboxes(listClient); + + expect(useEmailStore.getState().selectedMailbox).toBe('sent'); + expect(useEmailStore.getState().searchMailboxId).toBe('sent'); + }); +}); + describe('unread badge scope', () => { let client: IJMAPClient; diff --git a/stores/email-store.ts b/stores/email-store.ts index 9fd4c63e2..64847a039 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1406,6 +1406,11 @@ function mailboxesInView(state: Pick((set, get) => ({ // the open folder moves to the new one. searchMailboxId: state.searchScopeFollowsFolder ? mailboxId - : defaultSearchScopeFor( + : currentSearchScopeDefault( accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes, mailboxId, ), @@ -3509,13 +3514,13 @@ export const useEmailStore = create((set, get) => ({ // The folder's default search scope follows it (Spam and Trash search // themselves); a scope the user picked in the dropdown stays. The // unread badge's scope follows any real folder. - const scopeIsDefault = state.searchMailboxId === defaultSearchScopeFor(mailboxes, state.selectedMailbox); + const scopeIsDefault = state.searchMailboxId === currentSearchScopeDefault(mailboxes, state.selectedMailbox); const scopeFollows = state.searchScopeFollowsFolder && mailboxes.some(m => m.id === mailboxId); return { selectedMailbox: mailboxId, ...(scopeFollows ? { searchMailboxId: mailboxId } - : scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}), + : scopeIsDefault ? { searchMailboxId: currentSearchScopeDefault(mailboxes, mailboxId) } : {}), isLoadingMore: false, selectedEmail: null, selectedEmailKeys: new Set(), @@ -3683,22 +3688,27 @@ export const useEmailStore = create((set, get) => ({ const loadingPatch = isInitialLoad && mailboxes.length === 0 ? { isLoading: false, mailboxSyncStates: fetched.states } : { mailboxSyncStates: fetched.states }; + // Find inbox from PRIMARY account (not shared accounts) + const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared); + const nextSelected = selectionValid ? currentSelectedMailbox : inboxMailbox?.id ?? ''; + // A default search scope moves to the default of the folder now open: + // a folder opened before the list arrived, or the inbox picked here, + // only gets its scope once it is in the list (#1485). A scope naming a + // folder that is gone (e.g. the Spam/Trash the default scope followed) + // falls back to the default as well. + const { searchMailboxId: scope, searchScopeFollowsFolder } = get(); + const nextInView = mailboxesInView({ ...get(), mailboxes }); + const nextDefault = currentSearchScopeDefault(nextInView, nextSelected); + const scopeGone = !isAllFoldersSearchScope(scope) && !nextInView.some(m => m.id === scope); + const scopeWasDefault = !searchScopeFollowsFolder + && scope === currentSearchScopeDefault(mailboxesInView(get()), currentSelectedMailbox); + const scopePatch = (scopeGone || scopeWasDefault) && scope !== nextDefault + ? { searchMailboxId: nextDefault } + : {}; if (!selectionValid) { - // A search scope naming a folder that is gone (e.g. the Spam/Trash - // the default scope followed) falls back to the default. - const scope = get().searchMailboxId; - const scopePatch = !isAllFoldersSearchScope(scope) && !mailboxes.some(m => m.id === scope) - ? { searchMailboxId: '' } - : {}; - // Find inbox from PRIMARY account (not shared accounts) - const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared); - if (inboxMailbox) { - set({ mailboxes, selectedMailbox: inboxMailbox.id, ...scopePatch, ...loadingPatch }); - } else { - set({ mailboxes, selectedMailbox: '', ...scopePatch, ...loadingPatch }); - } + set({ mailboxes, selectedMailbox: nextSelected, ...scopePatch, ...loadingPatch }); } else { - set({ mailboxes, ...loadingPatch }); + set({ mailboxes, ...scopePatch, ...loadingPatch }); } } catch (error) { if (stale()) return; @@ -5541,7 +5551,7 @@ export const useEmailStore = create((set, get) => ({ clearSearchFilters: () => { set((state) => ({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, - searchMailboxId: defaultSearchScopeFor(mailboxesInView(state), state.selectedMailbox), + searchMailboxId: currentSearchScopeDefault(mailboxesInView(state), state.selectedMailbox), searchScopeFollowsFolder: false, })); }, @@ -8492,6 +8502,19 @@ useEmailStore.subscribe((state, prev) => { state.holdListRow(listRowKey(email, state.isScheduledView)); }); +// Changing "Search in" moves the open folder's scope along with it, unless +// the user picked a scope in the search panel (#1485). Otherwise the old +// default would look like a picked scope and stick to every folder. +useSettingsStore.subscribe((settings, prevSettings) => { + if (settings.searchDefaultScope === prevSettings.searchDefaultScope) return; + const state = useEmailStore.getState(); + if (state.searchScopeFollowsFolder) return; + const mailboxes = mailboxesInView(state); + const before = defaultSearchScopeFor(mailboxes, state.selectedMailbox, prevSettings.searchDefaultScope); + const after = defaultSearchScopeFor(mailboxes, state.selectedMailbox, settings.searchDefaultScope); + if (state.searchMailboxId === before && before !== after) useEmailStore.setState({ searchMailboxId: after }); +}); + // A folder's remembered list follows what happens to it on screen - a message // read, archived or deleted here is shown that way when the folder is opened // again, rather than as it was when the folder was loaded. @@ -8575,7 +8598,7 @@ if (typeof window !== 'undefined') { useEmailStore.setState({ mailboxes: snap.mailboxes, selectedMailbox, - searchMailboxId: defaultSearchScopeFor(snap.mailboxes, selectedMailbox), + searchMailboxId: currentSearchScopeDefault(snap.mailboxes, selectedMailbox), ...(selectedMailbox === snapMailbox && Array.isArray(snap.emails) && snap.emails.length > 0 ? { emails: snap.emails, diff --git a/stores/settings-store.ts b/stores/settings-store.ts index d47af7bf8..2427a5965 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -15,6 +15,7 @@ import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync'; import type { FolderCountMode } from '@/lib/folder-counts'; import { DEFAULT_FILES_SETTINGS, normalizeFilesSettings, type FilesSettings } from '@/lib/files-settings'; import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order'; +import { isSearchDefaultScope, type SearchDefaultScope } from '@/lib/search-default-scope'; import { EMPTY_SIGNATURE_DETAILS, sanitizeSignatureDetails, type SignatureDetails } from '@/lib/org-signature'; import { ACCESSIBILITY_DEFAULTS, @@ -559,6 +560,7 @@ interface SettingsState extends AccessibilitySettings { confirmBulkDelete: boolean; // Ask before moving several selected emails to Trash (permanent deletes always ask) returnToListAfterAction: boolean; // After delete / archive / move / spam / mark-unread in an open message, return to the list instead of opening the next message clearSearchOnFolderChange: boolean; // Reset the search query + advanced filters when switching folders, instead of re-running the search in the newly selected folder (#553 keeps it applied when this is off) + searchDefaultScope: SearchDefaultScope; // Where a new search looks: all folders, or only the open one (#1485) showPreview: boolean; showVerificationCodes: boolean; // Offer the one-time code of a sign-in mail as a copy chip in the list and the reader mailLayout: MailLayout; @@ -898,6 +900,7 @@ const DEFAULT_SETTINGS = { confirmBulkDelete: true, returnToListAfterAction: true, clearSearchOnFolderChange: false, + searchDefaultScope: 'all' as SearchDefaultScope, showPreview: true, showVerificationCodes: true, mailLayout: 'split' as MailLayout, @@ -1133,6 +1136,7 @@ function isAllowedPolicyValue(key: string, value: unknown, restriction?: Setting if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(value as number)) return false; if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(value as string)) return false; if (key === 'messageListOrderScope' && value !== 'inbox' && value !== 'all') return false; + if (key === 'searchDefaultScope' && !isSearchDefaultScope(value)) return false; if (key === 'calendarMonthTimedEvents' && !MONTH_TIMED_EVENT_STYLES.includes(value as MonthTimedEventStyle)) return false; if (!isAllowedAccessibilityValue(key, value)) return false; if (restriction && typeof restriction === 'object') { @@ -1331,6 +1335,7 @@ export const useSettingsStore = create()( returnToListAfterAction: state.returnToListAfterAction, confirmBulkDelete: state.confirmBulkDelete, clearSearchOnFolderChange: state.clearSearchOnFolderChange, + searchDefaultScope: state.searchDefaultScope, showPreview: state.showPreview, showVerificationCodes: state.showVerificationCodes, mailLayout: state.mailLayout, @@ -1627,6 +1632,9 @@ export const useSettingsStore = create()( if (key === 'messageListOrderScope' && settings[key] !== 'inbox' && settings[key] !== 'all') { return; } + if (key === 'searchDefaultScope' && !isSearchDefaultScope(settings[key])) { + return; + } if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { return; } @@ -1986,6 +1994,9 @@ export const useSettingsStore = create()( if (state.messageListOrderScope !== 'inbox' && state.messageListOrderScope !== 'all') { state.messageListOrderScope = 'inbox'; } + if (!isSearchDefaultScope(state.searchDefaultScope)) { + state.searchDefaultScope = 'all'; + } applyAccessibility(state); applyDensity(state.density, state.interfaceLayout); applyInterfaceLayout(state.interfaceLayout, state.density);