feat: add sub-result rows and highlight to settings search

This commit is contained in:
Linus Rath
2026-05-06 18:36:00 +02:00
parent 128d7d0401
commit 2dc8537780
3 changed files with 221 additions and 58 deletions
+210 -57
View File
@@ -143,15 +143,20 @@ const tabIcons: Record<Tab, LucideIcon> = {
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced']; const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
// Translation paths to flatten for fulltext search per tab. Multiple tabs may // Translation paths per tab. Tabs that share a namespace (email_behavior,
// share a namespace (e.g. email_behavior is split across reading/composing/ // appearance) explicitly list the subkeys they actually render so sub-results
// content_senders); a query that hits a shared namespace will surface all of // are attributed to the correct tab. Tabs with their own namespace just point
// them, which is acceptable since the user picks the right one. // at the namespace root.
const tabSearchPaths: Record<Tab, string[]> = { const tabSearchPaths: Record<Tab, string[]> = {
account: ['settings.account'], account: ['settings.account'],
language: ['settings.language_region', 'settings.appearance.language'], language: ['settings.appearance.language', 'settings.language_region'],
notifications: ['settings.notifications'], notifications: ['settings.notifications'],
appearance: ['settings.appearance'], appearance: [
'settings.appearance.theme',
'settings.appearance.font_size',
'settings.appearance.list_density',
'settings.appearance.animations',
],
layout: [ layout: [
'settings.appearance.toolbar_position', 'settings.appearance.toolbar_position',
'settings.appearance.toolbar_labels', 'settings.appearance.toolbar_labels',
@@ -161,8 +166,27 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.appearance.colorful_sidebar_icons', 'settings.appearance.colorful_sidebar_icons',
'settings.email_behavior.mail_layout', 'settings.email_behavior.mail_layout',
], ],
reading: ['settings.email_behavior'], reading: [
composing: ['settings.email_behavior'], 'settings.email_behavior.mark_read',
'settings.email_behavior.archive_mode',
'settings.email_behavior.delete_action',
'settings.email_behavior.attachment_click_action',
'settings.email_behavior.attachment_image_previews',
'settings.email_behavior.attachment_position',
'settings.email_behavior.disable_threading',
'settings.email_behavior.emails_per_page',
'settings.email_behavior.hide_inline_image_attachments',
'settings.email_behavior.hover_actions',
'settings.email_behavior.permanently_delete_junk',
'settings.email_behavior.show_preview',
'settings.email_behavior.plain_text_mode',
],
composing: [
'settings.email_behavior.attachment_reminder',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.default_mail_program',
'settings.email_behavior.sub_address_delimiter',
],
identities: ['settings.identities'], identities: ['settings.identities'],
vacation: ['settings.vacation'], vacation: ['settings.vacation'],
filters: ['settings.filters'], filters: ['settings.filters'],
@@ -171,7 +195,11 @@ const tabSearchPaths: Record<Tab, string[]> = {
keywords: ['settings.keywords'], keywords: ['settings.keywords'],
security: ['settings.security'], security: ['settings.security'],
encryption: ['smime'], encryption: ['smime'],
content_senders: ['settings.email_behavior'], content_senders: [
'settings.email_behavior.always_light_mode',
'settings.email_behavior.external_content',
'settings.email_behavior.trusted_senders',
],
calendar: ['calendar.settings', 'calendar.management'], calendar: ['calendar.settings', 'calendar.management'],
contacts: ['settings.contacts', 'contacts'], contacts: ['settings.contacts', 'contacts'],
files: ['settings.files'], files: ['settings.files'],
@@ -225,6 +253,31 @@ function flattenStrings(node: unknown, sink: string[]): void {
} }
} }
interface SubResult {
label: string;
description?: string;
}
// Walk a translation subtree and emit one sub-result per object that has a
// `label` (or, at the root, a `title`) string. Each emitted setting is then
// shown as a clickable sub-row under its tab in the search results.
function collectSubResults(node: unknown, sink: SubResult[]): void {
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
const obj = node as Record<string, unknown>;
const label = typeof obj.label === 'string' ? obj.label : (typeof obj.title === 'string' ? obj.title : undefined);
if (label) {
sink.push({
label,
description: typeof obj.description === 'string' ? obj.description : undefined,
});
}
for (const value of Object.values(obj)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
collectSubResults(value, sink);
}
}
}
function getByPath(obj: unknown, path: string): unknown { function getByPath(obj: unknown, path: string): unknown {
let cur: unknown = obj; let cur: unknown = obj;
for (const key of path.split('.')) { for (const key of path.split('.')) {
@@ -271,6 +324,7 @@ export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab); const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [mobileShowContent, setMobileShowContent] = useState(false); const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string } | null>(null);
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
const messages = useMessages() as Record<string, unknown>; const messages = useMessages() as Record<string, unknown>;
@@ -278,32 +332,56 @@ export default function SettingsPage() {
const installedThemes = useThemeStore((s) => s.installedThemes); const installedThemes = useThemeStore((s) => s.installedThemes);
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps); const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
// Build a per-tab haystack for fulltext search: tab id + curated English // Build a per-tab haystack for fulltext search and a list of sub-results
// keywords + flattened translation strings under each tab's namespaces + // (individual settings) per tab. Sub-results come from translation entries
// dynamic content (installed plugins/themes/sidebar apps). // that have a `label`/`title` field, plus dynamic content (installed
const tabSearchHaystacks = useMemo(() => { // plugins/themes/sidebar apps).
const result: Partial<Record<Tab, string>> = {}; const { tabSearchHaystacks, tabSubResults } = useMemo(() => {
const haystacks: Partial<Record<Tab, string>> = {};
const subs: Partial<Record<Tab, SubResult[]>> = {};
const tabIds = Object.keys(tabSearchPaths) as Tab[]; const tabIds = Object.keys(tabSearchPaths) as Tab[];
for (const tabId of tabIds) { for (const tabId of tabIds) {
const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? '']; const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? ''];
const list: SubResult[] = [];
for (const path of tabSearchPaths[tabId]) { for (const path of tabSearchPaths[tabId]) {
flattenStrings(getByPath(messages, path), strings); const node = getByPath(messages, path);
flattenStrings(node, strings);
collectSubResults(node, list);
} }
result[tabId] = strings.join(' ').toLowerCase(); // Dedupe sub-results by label
const seen = new Set<string>();
subs[tabId] = list.filter((r) => {
if (seen.has(r.label)) return false;
seen.add(r.label);
return true;
});
haystacks[tabId] = strings.join(' ').toLowerCase();
} }
if (installedPlugins.length) { if (installedPlugins.length) {
const text = installedPlugins.map((p) => `${p.name} ${p.description} ${p.author}`).join(' '); const text = installedPlugins.map((p) => `${p.name} ${p.description} ${p.author}`).join(' ');
result.plugins = `${result.plugins ?? ''} ${text}`.toLowerCase(); haystacks.plugins = `${haystacks.plugins ?? ''} ${text}`.toLowerCase();
subs.plugins = [
...(subs.plugins ?? []),
...installedPlugins.map((p) => ({ label: p.name, description: p.description })),
];
} }
if (installedThemes.length) { if (installedThemes.length) {
const text = installedThemes.map((th) => `${th.name} ${th.description} ${th.author}`).join(' '); const text = installedThemes.map((th) => `${th.name} ${th.description} ${th.author}`).join(' ');
result.themes = `${result.themes ?? ''} ${text}`.toLowerCase(); haystacks.themes = `${haystacks.themes ?? ''} ${text}`.toLowerCase();
subs.themes = [
...(subs.themes ?? []),
...installedThemes.map((th) => ({ label: th.name, description: th.description })),
];
} }
if (sidebarAppsList.length) { if (sidebarAppsList.length) {
const text = sidebarAppsList.map((a) => `${a.name} ${a.url}`).join(' '); const text = sidebarAppsList.map((a) => `${a.name} ${a.url}`).join(' ');
result.sidebar_apps = `${result.sidebar_apps ?? ''} ${text}`.toLowerCase(); haystacks.sidebar_apps = `${haystacks.sidebar_apps ?? ''} ${text}`.toLowerCase();
subs.sidebar_apps = [
...(subs.sidebar_apps ?? []),
...sidebarAppsList.map((a) => ({ label: a.name, description: a.url })),
];
} }
return result; return { tabSearchHaystacks: haystacks, tabSubResults: subs };
}, [messages, installedPlugins, installedThemes, sidebarAppsList]); }, [messages, installedPlugins, installedThemes, sidebarAppsList]);
// Sidebar resize state // Sidebar resize state
@@ -363,6 +441,43 @@ export default function SettingsPage() {
return () => window.removeEventListener('popstate', handlePop); return () => window.removeEventListener('popstate', handlePop);
}, [isDesktop, mobileShowContent]); }, [isDesktop, mobileShowContent]);
// After clicking a search sub-result, scroll the matching setting into view
// and add a temporary highlight class. Two RAFs to wait for the tab content
// to mount and lay out before querying the DOM.
useEffect(() => {
if (!pendingHighlight) return;
if (pendingHighlight.tab !== activeTab) return;
if (typeof window === 'undefined') return;
let cancelled = false;
let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
let highlightedEl: HTMLElement | null = null;
const r1 = window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (cancelled) return;
const escaped = pendingHighlight.label.replace(/"/g, '\\"');
const el = document.querySelector<HTMLElement>(`[data-search-label="${escaped}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('settings-search-highlight');
highlightedEl = el;
cleanupTimer = setTimeout(() => {
el.classList.remove('settings-search-highlight');
}, 2000);
}
setPendingHighlight(null);
});
});
return () => {
cancelled = true;
window.cancelAnimationFrame(r1);
if (cleanupTimer) clearTimeout(cleanupTimer);
if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight');
};
}, [pendingHighlight, activeTab]);
if (!isAuthenticated) { if (!isAuthenticated) {
return null; return null;
} }
@@ -426,6 +541,17 @@ export default function SettingsPage() {
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false; return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
}; };
const subResultsForTab = (tabId: Tab): SubResult[] => {
if (!trimmedQuery) return [];
const list = tabSubResults[tabId] ?? [];
return list
.filter((r) =>
r.label.toLowerCase().includes(trimmedQuery) ||
(r.description?.toLowerCase().includes(trimmedQuery) ?? false)
)
.slice(0, 6);
};
const filteredGroupedTabs = trimmedQuery const filteredGroupedTabs = trimmedQuery
? groupedTabs ? groupedTabs
.map((g) => ({ ...g, items: g.items.filter(matchesQuery) })) .map((g) => ({ ...g, items: g.items.filter(matchesQuery) }))
@@ -444,6 +570,11 @@ export default function SettingsPage() {
} }
}; };
const handleSubResultSelect = (tabId: Tab, label: string) => {
handleTabSelect(tabId);
setPendingHighlight({ tab: tabId, label });
};
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => ( const renderTabContent = () => (
@@ -565,23 +696,34 @@ export default function SettingsPage() {
</div> </div>
{group.items.map((tab) => { {group.items.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const subs = subResultsForTab(tab.id);
return ( return (
<button <div key={tab.id}>
key={tab.id} <button
onClick={() => handleTabSelect(tab.id)} onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150" className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
> >
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" /> <Icon className="w-4 h-4 text-muted-foreground" />
{tab.label} {tab.label}
{tab.experimental && ( {tab.experimental && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning"> <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning">
Experimental Experimental
</span> </span>
)} )}
</span> </span>
<ChevronRight className="w-4 h-4 text-muted-foreground" /> <ChevronRight className="w-4 h-4 text-muted-foreground" />
</button> </button>
{subs.map((sub) => (
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub.label)}
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
>
<span className="truncate">{sub.label}</span>
</button>
))}
</div>
); );
})} })}
</div> </div>
@@ -691,28 +833,39 @@ export default function SettingsPage() {
</div> </div>
{group.items.map((tab) => { {group.items.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const subs = subResultsForTab(tab.id);
return ( return (
<button <div key={tab.id}>
key={tab.id} <button
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={cn( className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5', 'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
effectiveActiveTab === tab.id effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium' ? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground' : 'hover:bg-muted text-foreground'
)} )}
> >
<Icon className={cn( <Icon className={cn(
'w-4 h-4 shrink-0', 'w-4 h-4 shrink-0',
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground' effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} /> )} />
{tab.label} {tab.label}
{tab.experimental && ( {tab.experimental && (
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0"> <span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0">
Experimental Experimental
</span> </span>
)} )}
</button> </button>
{subs.map((sub) => (
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub.label)}
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
>
<span className="truncate block">{sub.label}</span>
</button>
))}
</div>
); );
})} })}
</div> </div>
+7
View File
@@ -765,3 +765,10 @@ body {
.tiptap.resize-cursor { .tiptap.resize-cursor {
cursor: col-resize; cursor: col-resize;
} }
/* Brief flash applied to a setting row when the user clicks a sub-result in
the settings search. The page removes the class after ~2s. */
.settings-search-highlight {
background-color: color-mix(in srgb, var(--color-primary) 18%, transparent);
box-shadow: 0 0 0 2px var(--color-primary);
}
+4 -1
View File
@@ -44,7 +44,10 @@ interface SettingItemProps {
export function SettingItem({ label, description, children, locked }: SettingItemProps) { export function SettingItem({ label, description, children, locked }: SettingItemProps) {
return ( return (
<div className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}> <div
data-search-label={label}
className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0 transition-colors duration-300 rounded-md -mx-2 px-2", locked && "opacity-60")}
>
<div className="flex-1 min-w-0 sm:pr-4"> <div className="flex-1 min-w-0 sm:pr-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label> <label className="text-sm font-medium text-foreground">{label}</label>