fix: offer "go to <page>" for every settings page in the command palette, found by the settings search words

This commit is contained in:
Linus Rath
2026-10-28 10:51:01 +01:00
parent 5abc4a5334
commit 9bb94ebf62
2 changed files with 128 additions and 4 deletions
@@ -0,0 +1,106 @@
import React from 'react';
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import en from '@/locales/en/common.json';
// #40 / SET-27: in Settings the palette offered only "Go to Settings", and
// "out of office" found nothing. Each page the list shows is a command now,
// found by the words the settings search knows.
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string, values?: Record<string, string>) =>
`${ns ? `${ns}.` : ''}${key}${values?.name ? `(${values.name})` : ''}`,
useMessages: () => en,
useLocale: () => 'en',
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
}));
vi.mock('@/hooks/use-media-query', () => ({
useIsDesktop: () => true,
useIsMobile: () => false,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: true }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => null }));
const { SettingsApp } = await import('../settings-app');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
const { useThemeStore } = await import('@/stores/theme-store');
const { useRegisteredCommandGroups, commandMatches } = await import('@/lib/command-registry');
type Groups = ReturnType<typeof useRegisteredCommandGroups>;
let groups: Groups = [];
function Probe() {
groups = useRegisteredCommandGroups();
return null;
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
// A server with Sieve and vacation, so Filters and Out of office are pages.
const client = new Proxy({}, {
get: (_t, prop) => {
if (prop === 'then') return undefined;
if (prop === 'getAccountId') return () => 'acc';
if (typeof prop === 'string' && prop.startsWith('supports')) return () => true;
return () => null;
},
});
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ }
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
function settingsCommands() {
const group = groups.find((g) => g.label === 'sidebar.settings');
return group?.commands ?? [];
}
function find(query: string) {
return settingsCommands().filter((c) => commandMatches(query, c.label, c.keywords));
}
describe('Settings commands in the palette', () => {
it('offers "Go to <page>" for the settings pages', async () => {
render(<><SettingsApp linkSegments={[]} /><Probe /></>);
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
const ids = settingsCommands().map((c) => c.id);
expect(ids).toEqual(expect.arrayContaining(['go-vacation', 'go-filters', 'go-security', 'go-account']));
expect(settingsCommands().find((c) => c.id === 'go-vacation')?.label).toBe('command_palette.go_to(settings.tabs.vacation)');
});
it('finds pages by the words the settings search knows', async () => {
render(<><SettingsApp linkSegments={[]} /><Probe /></>);
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
expect(find('out of office').map((c) => c.id)).toContain('go-vacation');
expect(find('app password').map((c) => c.id)).toContain('go-security');
expect(find('add rule').map((c) => c.id)).toContain('go-filters');
});
it('opens the page it names', async () => {
const tree = () => <><SettingsApp linkSegments={[]} /><Probe /></>;
const { rerender } = render(tree());
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
act(() => settingsCommands().find((c) => c.id === 'go-filters')!.run());
expect(localStorage.getItem('settings-active-tab')).toBe('filters');
// The palette reads the commands when it opens, after Settings drew
// the new page; a second render stands in for that here.
rerender(tree());
rerender(tree());
// The page that is open is not offered.
expect(settingsCommands().map((c) => c.id)).not.toContain('go-filters');
});
});
+22 -4
View File
@@ -27,6 +27,7 @@ import {
X,
type AppIcon,
} from '@/components/icons';
import { useRegisterCommands } from '@/lib/command-registry';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
@@ -262,6 +263,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const tPalette = useTranslations('command_palette');
useDocumentTitle(tSidebar('settings'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -656,10 +658,6 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
}
}, [activeTab]);
if (!isAuthenticated) {
return null;
}
const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
@@ -786,6 +784,26 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId, advanced: sub.advanced });
};
// Ctrl/⌘+K in Settings: one "Go to <page>" per page shown in the list,
// found by the same words as the settings search ("out of office", "app
// password", "add rule").
useRegisterCommands(
tSidebar('settings'),
visibleTabs.map((tab) => ({
id: `go-${tab.id}`,
label: tPalette('go_to', { name: tab.label }),
icon: tab.icon,
keywords: [tabSearchHaystacks[tab.id as Tab] ?? ''],
run: () => handleTabSelect(tab.id),
when: () => tab.id !== effectiveActiveTab,
})),
isAuthenticated,
);
if (!isAuthenticated) {
return null;
}
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (