From d2a5f0cac72bb5d316cd20fcb4eb53d8006a9a43 Mon Sep 17 00:00:00 2001 From: Reece Date: Wed, 15 Jul 2026 17:45:46 +0100 Subject: [PATCH] feat(search): surface settings content matches in the super search Extract the settings modal search's translation-content technique (section prefix map, string flattening, match snippets) into a shared component-free module. The super search's settings source now falls back to content matching, so terms that only appear in a section's copy ("SMTP", a field label) find the section in both the editor and portal bars, ranked below label matches with a context snippet. The in-modal SettingsSearchBar builds on the same module. --- .../shared/config/SettingsSearchBar.tsx | 109 +----------- .../src/core/data/settingsContentSearch.ts | 157 ++++++++++++++++++ .../editor/src/core/hooks/useSuperSearch.ts | 38 ++++- 3 files changed, 197 insertions(+), 107 deletions(-) create mode 100644 frontend/editor/src/core/data/settingsContentSearch.ts diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx index ea1819dfb2..61a13f32b5 100644 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx +++ b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx @@ -4,6 +4,10 @@ import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; +import { + buildMatchSnippet, + getSettingsSectionContent, +} from "@app/data/settingsContentSearch"; import type { ConfigNavSection, ConfigNavItem, @@ -24,102 +28,6 @@ interface SettingsSearchOption { matchedContext?: string; } -const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial> = - { - general: ["settings.general"], - hotkeys: ["settings.hotkeys"], - account: ["account"], - people: ["settings.workspace"], - teams: ["settings.workspace", "settings.team"], - "api-keys": ["settings.developer"], - connectionMode: ["settings.connection"], - planBilling: ["settings.planBilling"], - adminGeneral: ["admin.settings.general"], - adminFeatures: ["admin.settings.features"], - adminEndpoints: ["admin.settings.endpoints"], - adminDatabase: ["admin.settings.database"], - adminAdvanced: ["admin.settings.advanced"], - adminSecurity: ["admin.settings.security"], - adminMcp: ["admin.settings.mcp"], - adminConnections: [ - "admin.settings.connections", - "admin.settings.mail", - "admin.settings.security", - "admin.settings.telegram", - "admin.settings.premium", - "admin.settings.general", - "settings.securityAuth", - "settings.connection", - ], - adminPlan: [ - "settings.planBilling", - "admin.settings.premium", - "settings.licensingAnalytics", - ], - adminAudit: ["settings.licensingAnalytics"], - adminUsage: ["settings.licensingAnalytics"], - adminLegal: ["admin.settings.legal"], - adminPrivacy: ["admin.settings.privacy"], - }; - -const getTranslationPrefixesForNavKey = (key: string): string[] => { - const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? []; - - const inferredPrefixes: string[] = []; - - if (key.startsWith("admin")) { - const adminSuffix = key.replace(/^admin/, ""); - const normalizedAdminSuffix = - adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1); - inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`); - } else { - inferredPrefixes.push(`settings.${key}`); - } - - return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes])); -}; - -const flattenTranslationStrings = (value: unknown): string[] => { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - - if (Array.isArray(value)) { - return value.flatMap(flattenTranslationStrings); - } - - if (value && typeof value === "object") { - return Object.values(value as Record).flatMap( - flattenTranslationStrings, - ); - } - - return []; -}; - -const buildMatchSnippet = (text: string, query: string): string => { - const normalizedText = text.toLocaleLowerCase(); - const normalizedQuery = query.toLocaleLowerCase(); - const matchIndex = normalizedText.indexOf(normalizedQuery); - - if (matchIndex === -1) { - return text; - } - - const maxLength = 84; - const contextPadding = 28; - const start = Math.max(0, matchIndex - contextPadding); - const end = Math.min(text.length, matchIndex + query.length + contextPadding); - const snippet = text.slice(start, end); - - if (snippet.length <= maxLength) { - return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`; - } - - return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`; -}; - export const SettingsSearchBar: React.FC = ({ configNavSections, onNavigate, @@ -135,19 +43,12 @@ export const SettingsSearchBar: React.FC = ({ section.items .filter((item: ConfigNavItem) => !item.disabled) .map((item: ConfigNavItem) => { - const translationPrefixes = getTranslationPrefixesForNavKey(item.key); - const translationContent = translationPrefixes.flatMap((prefix) => - flattenTranslationStrings( - t(prefix, { returnObjects: true, defaultValue: {} } as any), - ), - ); - const searchableContent = Array.from( new Set([ item.label, section.title, `/settings/${item.key}`, - ...translationContent, + ...getSettingsSectionContent(item.key, t), ]), ); diff --git a/frontend/editor/src/core/data/settingsContentSearch.ts b/frontend/editor/src/core/data/settingsContentSearch.ts new file mode 100644 index 0000000000..983187b743 --- /dev/null +++ b/frontend/editor/src/core/data/settingsContentSearch.ts @@ -0,0 +1,157 @@ +import i18n from "i18next"; +import type { TFunction } from "i18next"; + +/** + * Content-level settings search: matches a query against every translation + * string a settings section renders, so a term that appears anywhere on a + * settings page ("SMTP", "OCR", a field label…) finds that section without a + * curated keyword. + * + * This is the single source for the technique — the in-modal settings search + * bar and the global super search both build on it. It is intentionally + * component-free (translation subtrees only) so the always-mounted super + * search bar can use it without pulling the lazy settings modal into the main + * bundle. + */ + +/** + * Translation subtrees whose strings appear on each settings section, for the + * sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing + * here fall back to the inferred `settings.` / `admin.settings.` + * prefix. + */ +const SECTION_TRANSLATION_PREFIXES: Partial> = { + general: ["settings.general"], + hotkeys: ["settings.hotkeys"], + account: ["account"], + people: ["settings.workspace"], + teams: ["settings.workspace", "settings.team"], + "api-keys": ["settings.developer"], + connectionMode: ["settings.connection"], + planBilling: ["settings.planBilling"], + adminGeneral: ["admin.settings.general"], + adminFeatures: ["admin.settings.features"], + adminEndpoints: ["admin.settings.endpoints"], + adminDatabase: ["admin.settings.database"], + adminAdvanced: ["admin.settings.advanced"], + adminSecurity: ["admin.settings.security"], + adminMcp: ["admin.settings.mcp"], + adminConnections: [ + "admin.settings.connections", + "admin.settings.mail", + "admin.settings.security", + "admin.settings.telegram", + "admin.settings.premium", + "admin.settings.general", + "settings.securityAuth", + "settings.connection", + ], + adminPlan: [ + "settings.planBilling", + "admin.settings.premium", + "settings.licensingAnalytics", + ], + adminAudit: ["settings.licensingAnalytics"], + adminUsage: ["settings.licensingAnalytics"], + adminLegal: ["admin.settings.legal"], + adminPrivacy: ["admin.settings.privacy"], +}; + +export const getTranslationPrefixesForNavKey = (key: string): string[] => { + const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? []; + + const inferredPrefixes: string[] = []; + + if (key.startsWith("admin")) { + const adminSuffix = key.replace(/^admin/, ""); + const normalizedAdminSuffix = + adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1); + inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`); + } else { + inferredPrefixes.push(`settings.${key}`); + } + + return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes])); +}; + +export const flattenTranslationStrings = (value: unknown): string[] => { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + if (Array.isArray(value)) { + return value.flatMap(flattenTranslationStrings); + } + + if (value && typeof value === "object") { + return Object.values(value as Record).flatMap( + flattenTranslationStrings, + ); + } + + return []; +}; + +/** Trims a matched string to a short snippet centred on the query hit. */ +export const buildMatchSnippet = (text: string, query: string): string => { + const normalizedText = text.toLocaleLowerCase(); + const normalizedQuery = query.toLocaleLowerCase(); + const matchIndex = normalizedText.indexOf(normalizedQuery); + + if (matchIndex === -1) { + return text; + } + + const maxLength = 84; + const contextPadding = 28; + const start = Math.max(0, matchIndex - contextPadding); + const end = Math.min(text.length, matchIndex + query.length + contextPadding); + const snippet = text.slice(start, end); + + if (snippet.length <= maxLength) { + return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`; + } + + return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`; +}; + +// Flattening every subtree on each keystroke would be wasteful; sections' +// content is static per language, so cache it and drop the cache on switch. +const contentCache = new Map(); +let contentCacheLanguage: string | undefined; + +export function getSettingsSectionContent(key: string, t: TFunction): string[] { + if (contentCacheLanguage !== i18n.language) { + contentCache.clear(); + contentCacheLanguage = i18n.language; + } + const cached = contentCache.get(key); + if (cached) return cached; + + const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) => + flattenTranslationStrings( + t(prefix, { returnObjects: true, defaultValue: {} }), + ), + ); + contentCache.set(key, content); + return content; +} + +/** + * First content string of the section containing the query + * (case-insensitive), or null. Substring only — fuzzy matching across whole + * paragraphs of copy produces junk hits. + */ +export function findSettingsContentMatch( + key: string, + query: string, + t: TFunction, +): string | null { + const normalizedQuery = query.toLocaleLowerCase(); + return ( + getSettingsSectionContent(key, t).find((text) => + text.toLocaleLowerCase().includes(normalizedQuery), + ) ?? null + ); +} diff --git a/frontend/editor/src/core/hooks/useSuperSearch.ts b/frontend/editor/src/core/hooks/useSuperSearch.ts index a3245572bf..66a31977fb 100644 --- a/frontend/editor/src/core/hooks/useSuperSearch.ts +++ b/frontend/editor/src/core/hooks/useSuperSearch.ts @@ -23,6 +23,10 @@ import type { ToolId } from "@app/types/toolId"; import type { ToolRegistry } from "@app/data/toolsTaxonomy"; import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex"; import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry"; +import { + buildMatchSnippet, + findSettingsContentMatch, +} from "@app/data/settingsContentSearch"; import { PROCESSOR_SEARCH_INDEX, type ProcessorSearchEntry, @@ -195,11 +199,12 @@ export function rankSettingsResults( if (s.adminArea && !(isAdmin || !loginEnabled)) return false; return true; }); - const sections = rankByFuzzy(visibleSections, trimmed, [ + const sectionMatches = rankByFuzzy(visibleSections, trimmed, [ (s) => t(s.labelKey, s.labelFallback), (s) => s.labelFallback, (s) => s.keywords?.join(" ") ?? "", - ]).map(({ item, score }) => ({ + ]); + const sections = sectionMatches.map(({ item, score }) => ({ key: `setting-section:${item.key}`, group: "settings", title: t(item.labelKey, item.labelFallback), @@ -208,7 +213,34 @@ export function rankSettingsResults( onSelect: () => openSettings(item.key), })); - return [...rows, ...sections] + // Content matches: sections whose rendered copy contains the query (the + // in-modal settings search technique), so terms with no curated keyword + // ("SMTP", a field label) still find their section. Ranked below every + // label/keyword match; 3+ chars so a single letter doesn't match half the + // modal. + const labelMatchedKeys = new Set(sectionMatches.map(({ item }) => item.key)); + const contentMatches = + trimmed.length < 3 + ? [] + : visibleSections + .filter((s) => !labelMatchedKeys.has(s.key)) + .flatMap((s) => { + const match = findSettingsContentMatch(s.key, trimmed, t); + if (!match) return []; + return [ + { + key: `setting-content:${s.key}`, + group: "settings", + title: t(s.labelKey, s.labelFallback), + subtitle: buildMatchSnippet(match, trimmed), + iconName: "settings-rounded", + score: 20, // below the fuzzy thresholds (30/40) + onSelect: () => openSettings(s.key), + }, + ]; + }); + + return [...rows, ...sections, ...contentMatches] .sort((a, b) => b.score - a.score) .slice(0, GROUP_LIMIT); }