From bbfaee424bce32de3da2f34ff9da804100c6082d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:54:06 -0700 Subject: [PATCH] Rank Settings search results by relevance (#6050) * Rank settings search results by relevance * Keep Settings search content matching ranked entries * rm design doc --- .../src/components/settings/Settings.tsx | 78 ++++++--- .../settings/settings-search.test.ts | 100 ++++++++++++ .../components/settings/settings-search.ts | 151 ++++++++++++++++-- 3 files changed, 295 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index b1e0cd9dfa6..5019c5f755e 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -51,7 +51,7 @@ import { AdvancedPane } from './AdvancedPane' import { SettingsSidebar } from './SettingsSidebar' import { SettingsSetupGuidePane } from './SettingsSetupGuidePane' import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection' -import { matchesSettingsSearch } from './settings-search' +import { getSettingsSectionSearchEntries, rankSettingsSearchItems } from './settings-search' import { cn } from '@/lib/utils' import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import { registerWindowCloseGuard } from '../window-close-request-coordinator' @@ -135,6 +135,12 @@ const SETTINGS_NAV_GROUPS = [ } ] as const +type SettingsNavGroupDefinition = (typeof SETTINGS_NAV_GROUPS)[number] + +const SETTINGS_NAV_GROUP_BY_ID = new Map( + SETTINGS_NAV_GROUPS.map((group) => [group.id, group]) +) + const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm' const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200 @@ -149,6 +155,27 @@ function getFallbackVisibleSection(sections: SettingsNavSection[]): SettingsNavS return sections.at(0) } +function getSettingsNavGroupDefinitionsForSearch( + sections: readonly SettingsNavSection[], + query: string +): readonly SettingsNavGroupDefinition[] { + if (query.trim() === '') { + return SETTINGS_NAV_GROUPS + } + const seenGroupIds = new Set() + return sections.flatMap((section) => { + if (section.id.startsWith('repo-') || seenGroupIds.has(section.group)) { + return [] + } + const group = SETTINGS_NAV_GROUP_BY_ID.get(section.group) + if (!group) { + return [] + } + seenGroupIds.add(section.group) + return [group] + }) +} + function getSkillNavInstallStatus(skill: { installed: boolean loading: boolean @@ -632,21 +659,26 @@ function Settings(): React.JSX.Element { () => new Map(navSections.map((section) => [section.id, section] as const)), [navSections] ) - const getSectionSearchEntries = (sectionId: string) => - navSectionById.get(sectionId)?.searchEntries ?? [] + const getSectionSearchEntries = (sectionId: string) => { + const section = navSectionById.get(sectionId) + return section ? getSettingsSectionSearchEntries(section) : [] + } - const visibleNavSections = useMemo( - () => - navSections.filter((section) => - section.id === 'git' && hasUnsavedSourceControlAiPromptChanges - ? true - : matchesSettingsSearch(settingsSearchQuery, [ - { title: section.title, description: section.description }, - ...section.searchEntries - ]) - ), - [hasUnsavedSourceControlAiPromptChanges, navSections, settingsSearchQuery] - ) + const visibleNavSections = useMemo(() => { + const rankedSections = rankSettingsSearchItems( + settingsSearchQuery, + navSections, + getSettingsSectionSearchEntries + ).map(({ item }) => item) + if ( + !hasUnsavedSourceControlAiPromptChanges || + rankedSections.some((section) => section.id === 'git') + ) { + return rankedSections + } + const gitSection = navSectionById.get('git') + return gitSection ? [...rankedSections, gitSection] : rankedSections + }, [hasUnsavedSourceControlAiPromptChanges, navSectionById, navSections, settingsSearchQuery]) const visibleSectionIds = useMemo( () => new Set(visibleNavSections.map((section) => section.id)), [visibleNavSections] @@ -964,11 +996,17 @@ function Settings(): React.JSX.Element { } const generalNavSections = visibleNavSections.filter((section) => !section.id.startsWith('repo-')) - const generalNavGroups: SettingsNavGroup[] = SETTINGS_NAV_GROUPS.map((group) => ({ - id: group.id, - title: translate(group.titleKey, group.titleDefault), - sections: generalNavSections.filter((section) => section.group === group.id) - })).filter((group) => group.sections.length > 0 || group.id === 'setup') + const generalNavGroupDefinitions = getSettingsNavGroupDefinitionsForSearch( + visibleNavSections, + settingsSearchQuery + ) + const generalNavGroups: SettingsNavGroup[] = generalNavGroupDefinitions + .map((group) => ({ + id: group.id, + title: translate(group.titleKey, group.titleDefault), + sections: generalNavSections.filter((section) => section.group === group.id) + })) + .filter((group) => group.sections.length > 0 || group.id === 'setup') const repoNavSections = visibleNavSections .filter((section) => section.id.startsWith('repo-')) .map((section) => { diff --git a/src/renderer/src/components/settings/settings-search.test.ts b/src/renderer/src/components/settings/settings-search.test.ts index 4436b3f1514..0865b133c6a 100644 --- a/src/renderer/src/components/settings/settings-search.test.ts +++ b/src/renderer/src/components/settings/settings-search.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'vitest' import { SETTINGS_SEARCH_QUERY_MAX_BYTES, + getSettingsSectionSearchEntries, isSettingsSearchQueryTooLarge, matchesSettingsSearch, normalizeSettingsSearchQuery, + rankSettingsSearchItems, + scoreSettingsSearch, type SettingsSearchEntry } from './settings-search' @@ -28,6 +31,101 @@ describe('settings-search', () => { expect(matchesSettingsSearch(' ', { title: 'General' })).toBe(true) }) + it('scores pane title matches above entry, description, and keyword matches', () => { + const paneTitleMatch = [{ title: 'Keyboard Shortcuts' }] + const entryTitleMatch = [{ title: 'Interface' }, { title: 'Shortcuts' }] + const descriptionMatch = [{ title: 'Interface', description: 'Shortcuts' }] + const keywordMatch = [{ title: 'Interface', keywords: ['shortcuts'] }] + + expect(scoreSettingsSearch('shortcuts', paneTitleMatch)).toBeGreaterThan( + scoreSettingsSearch('shortcuts', entryTitleMatch) + ) + expect(scoreSettingsSearch('shortcuts', entryTitleMatch)).toBeGreaterThan( + scoreSettingsSearch('shortcuts', descriptionMatch) + ) + expect(scoreSettingsSearch('shortcuts', descriptionMatch)).toBeGreaterThan( + scoreSettingsSearch('shortcuts', keywordMatch) + ) + }) + + it('ranks Shortcuts above Task Sources for the shortcuts query', () => { + const sections = [ + { + id: 'tasks', + entries: [ + { title: 'Task Sources', description: 'Choose task providers.' }, + { + title: 'Task Providers', + description: + 'Choose which task providers appear in the Tasks page and sidebar shortcuts.' + } + ] + }, + { + id: 'shortcuts', + entries: [ + { title: 'Shortcuts', description: 'Keyboard shortcuts for common actions.' }, + { title: 'Shortcuts in Terminal', description: 'Choose terminal shortcut behavior.' } + ] + } + ] + + expect( + rankSettingsSearchItems('shortcuts', sections, (section) => section.entries).map( + (section) => section.item.id + ) + ).toEqual(['shortcuts', 'tasks']) + }) + + it('includes pane title entries for SettingsSection content filtering', () => { + const section = { + title: 'AI Provider Accounts', + description: 'Optional account switching.', + searchEntries: [{ title: 'Claude', description: 'Use the signed-in Claude account.' }] + } + + const entries = getSettingsSectionSearchEntries(section) + + expect(matchesSettingsSearch('accounts', entries)).toBe(true) + expect(matchesSettingsSearch('accounts', section.searchEntries)).toBe(false) + }) + + it('preserves source order for equally ranked matches', () => { + const sections = [ + { id: 'terminal', entries: [{ title: 'Terminal' }] }, + { id: 'terminal-advanced', entries: [{ title: 'Terminal Advanced' }] } + ] + + expect( + rankSettingsSearchItems('term', sections, (section) => section.entries).map( + (section) => section.item.id + ) + ).toEqual(['terminal', 'terminal-advanced']) + }) + + it('keeps empty search ranking in source order without reading entries', () => { + const sections = [ + { + id: 'general', + get entries(): SettingsSearchEntry[] { + throw new Error('empty settings searches must not scan entries') + } + }, + { + id: 'shortcuts', + get entries(): SettingsSearchEntry[] { + throw new Error('empty settings searches must not scan entries') + } + } + ] + + expect( + rankSettingsSearchItems(' ', sections, (section) => section.entries).map( + (section) => section.item.id + ) + ).toEqual(['general', 'shortcuts']) + }) + it('rejects oversized pasted searches before reading settings entries', () => { const oversizedQuery = 'secret-settings-search'.repeat(SETTINGS_SEARCH_QUERY_MAX_BYTES) const entry = { @@ -44,6 +142,8 @@ describe('settings-search', () => { expect(isSettingsSearchQueryTooLarge(oversizedQuery)).toBe(true) expect(matchesSettingsSearch(oversizedQuery, entry)).toBe(false) + expect(scoreSettingsSearch(oversizedQuery, entry)).toBe(0) + expect(rankSettingsSearchItems(oversizedQuery, [entry], (item) => item)).toEqual([]) }) it('rejects oversized whitespace before trimming settings searches', () => { diff --git a/src/renderer/src/components/settings/settings-search.ts b/src/renderer/src/components/settings/settings-search.ts index deae6e73b8b..cbbd8388f65 100644 --- a/src/renderer/src/components/settings/settings-search.ts +++ b/src/renderer/src/components/settings/settings-search.ts @@ -9,6 +9,49 @@ export type SettingsSearchEntry = { } export const SETTINGS_SEARCH_QUERY_MAX_BYTES = 2 * 1024 +const SETTINGS_SEARCH_NO_MATCH_SCORE = 0 +const SETTINGS_SEARCH_EMPTY_QUERY_SCORE = 1 + +type SettingsSearchScoreTier = { + exact: number + prefix: number + substring: number +} + +type SettingsSearchRankCandidate = { + item: T + index: number + score: number +} + +export type RankedSettingsSearchItem = { + item: T + score: number +} + +const PANE_TITLE_SCORE: SettingsSearchScoreTier = { + exact: 900, + prefix: 850, + substring: 800 +} + +const ENTRY_TITLE_SCORE: SettingsSearchScoreTier = { + exact: 700, + prefix: 650, + substring: 600 +} + +const DESCRIPTION_SCORE: SettingsSearchScoreTier = { + exact: 500, + prefix: 450, + substring: 400 +} + +const KEYWORD_SCORE: SettingsSearchScoreTier = { + exact: 300, + prefix: 250, + substring: 200 +} export function isSettingsSearchQueryTooLarge( query: string, @@ -21,22 +64,102 @@ export function normalizeSettingsSearchQuery(query: string): string { return query.trim().toLowerCase() } +function scoreSettingsSearchText( + normalizedQuery: string, + value: string | undefined, + tier: SettingsSearchScoreTier +): number { + if (!value) { + return SETTINGS_SEARCH_NO_MATCH_SCORE + } + const normalizedValue = value.toLowerCase() + if (normalizedValue === normalizedQuery) { + return tier.exact + } + if (normalizedValue.startsWith(normalizedQuery)) { + return tier.prefix + } + if (normalizedValue.includes(normalizedQuery)) { + return tier.substring + } + return SETTINGS_SEARCH_NO_MATCH_SCORE +} + +function scoreSettingsSearchValues( + normalizedQuery: string, + values: readonly string[] | undefined, + tier: SettingsSearchScoreTier +): number { + return (values ?? []).reduce( + (score, value) => Math.max(score, scoreSettingsSearchText(normalizedQuery, value, tier)), + SETTINGS_SEARCH_NO_MATCH_SCORE + ) +} + +export function scoreSettingsSearch( + query: string, + entries: SettingsSearchEntry | SettingsSearchEntry[] +): number { + if (isSettingsSearchQueryTooLarge(query)) { + return SETTINGS_SEARCH_NO_MATCH_SCORE + } + const normalizedQuery = normalizeSettingsSearchQuery(query) + if (!normalizedQuery) { + return SETTINGS_SEARCH_EMPTY_QUERY_SCORE + } + + const values = Array.isArray(entries) ? entries : [entries] + return values.reduce((score, entry, index) => { + // Why: Settings passes the pane entry first so pane-title hits outrank + // lower-level setting titles without adding a second search-entry shape. + const titleScore = index === 0 ? PANE_TITLE_SCORE : ENTRY_TITLE_SCORE + return Math.max( + score, + scoreSettingsSearchText(normalizedQuery, entry.title, titleScore), + scoreSettingsSearchText(normalizedQuery, entry.description, DESCRIPTION_SCORE), + scoreSettingsSearchValues(normalizedQuery, entry.keywords, KEYWORD_SCORE) + ) + }, SETTINGS_SEARCH_NO_MATCH_SCORE) +} + +export function getSettingsSectionSearchEntries(section: { + title: string + description: string + searchEntries: readonly SettingsSearchEntry[] +}): SettingsSearchEntry[] { + // Why: sidebar ranking and active content filtering must receive the same + // pane-level entry, otherwise pane-title-only hits can rank but render blank. + return [{ title: section.title, description: section.description }, ...section.searchEntries] +} + +export function rankSettingsSearchItems( + query: string, + items: readonly T[], + getEntries: (item: T) => SettingsSearchEntry | SettingsSearchEntry[] +): RankedSettingsSearchItem[] { + if (isSettingsSearchQueryTooLarge(query)) { + return [] + } + if (!normalizeSettingsSearchQuery(query)) { + return items.map((item) => ({ item, score: SETTINGS_SEARCH_EMPTY_QUERY_SCORE })) + } + + return items + .map( + (item, index): SettingsSearchRankCandidate => ({ + item, + index, + score: scoreSettingsSearch(query, getEntries(item)) + }) + ) + .filter((candidate) => candidate.score > SETTINGS_SEARCH_NO_MATCH_SCORE) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(({ item, score }) => ({ item, score })) +} + export function matchesSettingsSearch( query: string, entries: SettingsSearchEntry | SettingsSearchEntry[] ): boolean { - if (isSettingsSearchQueryTooLarge(query)) { - return false - } - const trimmedQuery = query.trim() - if (!trimmedQuery) { - return true - } - const normalizedQuery = trimmedQuery.toLowerCase() - - const values = Array.isArray(entries) ? entries : [entries] - return values.some((entry) => { - const haystack = [entry.title, entry.description ?? '', ...(entry.keywords ?? [])] - return haystack.some((value) => value.toLowerCase().includes(normalizedQuery)) - }) + return scoreSettingsSearch(query, entries) > SETTINGS_SEARCH_NO_MATCH_SCORE }