feat: let a new search look only in the open folder #1485

This commit is contained in:
Linus Rath
2026-11-01 17:43:51 +01:00
parent aaafe428fe
commit 3c2eb9bffa
6 changed files with 231 additions and 26 deletions
+30 -1
View File
@@ -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('');
});
});
+11
View File
@@ -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';
}
+13 -5
View File
@@ -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 {
@@ -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<typeof vi.fn>).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;
+42 -19
View File
@@ -1406,6 +1406,11 @@ function mailboxesInView(state: Pick<EmailStore, 'viewingAccountId' | 'accountMa
return state.mailboxes;
}
/** The search scope `openMailboxId` starts with under the user's "Search in" setting (#1485). */
function currentSearchScopeDefault(mailboxes: Mailbox[], openMailboxId: string | null | undefined): string {
return defaultSearchScopeFor(mailboxes, openMailboxId, useSettingsStore.getState().searchDefaultScope);
}
function resolveActionMailboxes(): Mailbox[] {
return mailboxesInView(useEmailStore.getState());
}
@@ -3362,7 +3367,7 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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,
+11
View File
@@ -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<SettingsState>()(
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<SettingsState>()(
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<SettingsState>()(
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);