First pass at settings search

This commit is contained in:
Bruno Bergher 2026-01-10 08:57:02 +00:00
parent 907b94bc40
commit a48981f144
24 changed files with 1868 additions and 71 deletions

View file

@ -179,7 +179,7 @@ export const AutoApproveSettings = ({
<span className="codicon codicon-eye" />
<div>{t("settings:autoApprove.readOnly.label")}</div>
</div>
<div>
<div data-setting-id="autoApprove.readOnly.outsideWorkspace">
<VSCodeCheckbox
checked={alwaysAllowReadOnlyOutsideWorkspace}
onChange={(e: any) =>
@ -203,7 +203,7 @@ export const AutoApproveSettings = ({
<span className="codicon codicon-edit" />
<div>{t("settings:autoApprove.write.label")}</div>
</div>
<div>
<div data-setting-id="autoApprove.write.outsideWorkspace">
<VSCodeCheckbox
checked={alwaysAllowWriteOutsideWorkspace}
onChange={(e: any) =>
@ -218,7 +218,7 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.write.outsideWorkspace.description")}
</div>
</div>
<div>
<div data-setting-id="autoApprove.write.protected">
<VSCodeCheckbox
checked={alwaysAllowWriteProtected}
onChange={(e: any) =>
@ -240,7 +240,7 @@ export const AutoApproveSettings = ({
<span className="codicon codicon-question" />
<div>{t("settings:autoApprove.followupQuestions.label")}</div>
</div>
<div>
<div data-setting-id="autoApprove.followupQuestions.timeout">
<div className="flex items-center gap-2">
<Slider
min={1000}
@ -268,7 +268,7 @@ export const AutoApproveSettings = ({
<div>{t("settings:autoApprove.execute.label")}</div>
</div>
<div>
<div data-setting-id="autoApprove.execute.allowedCommands">
<label className="block font-medium mb-1" data-testid="allowed-commands-heading">
{t("settings:autoApprove.execute.allowedCommands")}
</label>
@ -320,7 +320,7 @@ export const AutoApproveSettings = ({
</div>
{/* Denied Commands Section */}
<div className="mt-6">
<div className="mt-6" data-setting-id="autoApprove.execute.deniedCommands">
<label className="block font-medium mb-1" data-testid="denied-commands-heading">
{t("settings:autoApprove.execute.deniedCommands")}
</label>

View file

@ -116,7 +116,7 @@ export const BrowserSettings = ({
</SectionHeader>
<Section>
<div>
<div data-setting-id="browser.enable">
<VSCodeCheckbox
checked={browserToolEnabled}
onChange={(e: any) => setCachedStateField("browserToolEnabled", e.target.checked)}>
@ -135,7 +135,7 @@ export const BrowserSettings = ({
{browserToolEnabled && (
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<div>
<div data-setting-id="browser.viewport">
<label className="block font-medium mb-1">{t("settings:browser.viewport.label")}</label>
<Select
value={browserViewportSize}
@ -158,7 +158,7 @@ export const BrowserSettings = ({
</div>
</div>
<div>
<div data-setting-id="browser.screenshotQuality">
<label className="block font-medium mb-1">
{t("settings:browser.screenshotQuality.label")}
</label>
@ -177,7 +177,7 @@ export const BrowserSettings = ({
</div>
</div>
<div>
<div data-setting-id="browser.remote">
<VSCodeCheckbox
checked={remoteBrowserEnabled}
onChange={(e: any) => {

View file

@ -38,7 +38,7 @@ export const CheckpointSettings = ({
</SectionHeader>
<Section>
<div>
<div data-setting-id="checkpoints.enable">
<VSCodeCheckbox
checked={enableCheckpoints}
onChange={(e: any) => {
@ -58,7 +58,7 @@ export const CheckpointSettings = ({
</div>
{enableCheckpoints && (
<div className="mt-4">
<div className="mt-4" data-setting-id="checkpoints.timeout">
<label className="block text-sm font-medium mb-2">
{t("settings:checkpoints.timeout.label")}
</label>

View file

@ -114,7 +114,7 @@ export const ContextManagementSettings = ({
</SectionHeader>
<Section>
<div>
<div data-setting-id="contextManagement.openTabs">
<span className="block font-medium mb-1">{t("settings:contextManagement.openTabs.label")}</span>
<div className="flex items-center gap-2">
<Slider
@ -132,7 +132,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.workspaceFiles">
<span className="block font-medium mb-1">
{t("settings:contextManagement.workspaceFiles.label")}
</span>
@ -152,7 +152,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.maxGitStatusFiles">
<span className="block font-medium mb-1">
{t("settings:contextManagement.maxGitStatusFiles.label")}
</span>
@ -172,7 +172,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.maxConcurrentFileReads">
<span className="block font-medium mb-1">
{t("settings:contextManagement.maxConcurrentFileReads.label")}
</span>
@ -192,7 +192,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.rooignore">
<VSCodeCheckbox
checked={showRooIgnoredFiles}
onChange={(e: any) => setCachedStateField("showRooIgnoredFiles", e.target.checked)}
@ -206,7 +206,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.enableSubfolderRules">
<VSCodeCheckbox
checked={enableSubfolderRules}
onChange={(e: any) => setCachedStateField("enableSubfolderRules", e.target.checked)}
@ -220,7 +220,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.maxReadFile">
<div className="flex flex-col gap-2">
<span className="font-medium">{t("settings:contextManagement.maxReadFile.label")}</span>
<div className="flex items-center gap-4">
@ -256,7 +256,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.maxImageFileSize">
<div className="flex flex-col gap-2">
<span className="font-medium">{t("settings:contextManagement.maxImageFileSize.label")}</span>
<div className="flex items-center gap-4">
@ -284,7 +284,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.maxTotalImageSize">
<div className="flex flex-col gap-2">
<span className="font-medium">{t("settings:contextManagement.maxTotalImageSize.label")}</span>
<div className="flex items-center gap-4">
@ -312,7 +312,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.diagnostics.includeMessages">
<VSCodeCheckbox
checked={includeDiagnosticMessages}
onChange={(e: any) => setCachedStateField("includeDiagnosticMessages", e.target.checked)}
@ -326,7 +326,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.diagnostics.maxMessages">
<span className="block font-medium mb-1">
{t("settings:contextManagement.diagnostics.maxMessages.label")}
</span>
@ -381,7 +381,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.diagnostics.delayAfterWrite">
<span className="block font-medium mb-1">
{t("settings:contextManagement.diagnostics.delayAfterWrite.label")}
</span>
@ -401,7 +401,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.includeCurrentTime">
<VSCodeCheckbox
checked={includeCurrentTime}
onChange={(e: any) => setCachedStateField("includeCurrentTime", e.target.checked)}
@ -415,7 +415,7 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<div data-setting-id="contextManagement.includeCurrentCost">
<VSCodeCheckbox
checked={includeCurrentCost}
onChange={(e: any) => setCachedStateField("includeCurrentCost", e.target.checked)}
@ -430,12 +430,14 @@ export const ContextManagementSettings = ({
</div>
</Section>
<Section className="pt-2">
<VSCodeCheckbox
checked={autoCondenseContext}
onChange={(e: any) => setCachedStateField("autoCondenseContext", e.target.checked)}
data-testid="auto-condense-context-checkbox">
<span className="font-medium">{t("settings:contextManagement.autoCondenseContext.name")}</span>
</VSCodeCheckbox>
<div data-setting-id="contextManagement.autoCondenseContext">
<VSCodeCheckbox
checked={autoCondenseContext}
onChange={(e: any) => setCachedStateField("autoCondenseContext", e.target.checked)}
data-testid="auto-condense-context-checkbox">
<span className="font-medium">{t("settings:contextManagement.autoCondenseContext.name")}</span>
</VSCodeCheckbox>
</div>
{autoCondenseContext && (
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<div className="flex items-center gap-4 font-bold">

View file

@ -83,7 +83,7 @@ export const CustomToolsSettings = ({ enabled, onChange }: CustomToolsSettingsPr
)
return (
<div className="space-y-4">
<div className="space-y-4" data-setting-id="experimental.CUSTOM_TOOLS">
<div>
<div className="flex items-center gap-2">
<VSCodeCheckbox checked={enabled} onChange={(e: any) => onChange(e.target.checked)}>

View file

@ -16,7 +16,7 @@ export const ExperimentalFeature = ({ enabled, onChange, experimentKey }: Experi
const descriptionKey = experimentKey ? `settings:experimental.${experimentKey}.description` : ""
return (
<div>
<div data-setting-id={experimentKey ? `experimental.${experimentKey}` : undefined}>
<div className="flex items-center gap-2">
<VSCodeCheckbox checked={enabled} onChange={(e: any) => onChange(e.target.checked)}>
<span className="font-medium">{t(nameKey)}</span>

View file

@ -91,7 +91,7 @@ export const ImageGenerationSettings = ({
const isConfigured = !requiresApiKey || (requiresApiKey && openRouterImageApiKey)
return (
<div className="space-y-4">
<div className="space-y-4" data-setting-id="experimental.IMAGE_GENERATION">
<div>
<div className="flex items-center gap-2">
<VSCodeCheckbox checked={enabled} onChange={(e: any) => onChange(e.target.checked)}>

View file

@ -31,21 +31,25 @@ export const LanguageSettings = ({ language, setCachedStateField, className, ...
</SectionHeader>
<Section>
<Select value={language} onValueChange={(value) => setCachedStateField("language", value as Language)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:common.select")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(LANGUAGES).map(([code, name]) => (
<SelectItem key={code} value={code}>
{name}
<span className="text-muted-foreground">({code})</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div data-setting-id="language">
<Select
value={language}
onValueChange={(value) => setCachedStateField("language", value as Language)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:common.select")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(LANGUAGES).map(([code, name]) => (
<SelectItem key={code} value={code}>
{name}
<span className="text-muted-foreground">({code})</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</Section>
</div>
)

View file

@ -35,7 +35,7 @@ export const NotificationSettings = ({
</SectionHeader>
<Section>
<div>
<div data-setting-id="notifications.tts">
<VSCodeCheckbox
checked={ttsEnabled}
onChange={(e: any) => setCachedStateField("ttsEnabled", e.target.checked)}
@ -49,7 +49,7 @@ export const NotificationSettings = ({
{ttsEnabled && (
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<div>
<div data-setting-id="notifications.tts.speed">
<label className="block font-medium mb-1">
{t("settings:notifications.tts.speedLabel")}
</label>
@ -68,7 +68,7 @@ export const NotificationSettings = ({
</div>
)}
<div>
<div data-setting-id="notifications.sound">
<VSCodeCheckbox
checked={soundEnabled}
onChange={(e: any) => setCachedStateField("soundEnabled", e.target.checked)}
@ -82,7 +82,7 @@ export const NotificationSettings = ({
{soundEnabled && (
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<div>
<div data-setting-id="notifications.sound.volume">
<label className="block font-medium mb-1">
{t("settings:notifications.sound.volumeLabel")}
</label>

View file

@ -0,0 +1,41 @@
import { Search, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Input } from "@/components/ui"
export interface SettingsSearchInputProps {
value: string
onChange: (value: string) => void
onFocus?: () => void
onBlur?: () => void
}
export function SettingsSearchInput({ value, onChange, onFocus, onBlur }: SettingsSearchInputProps) {
const { t } = useAppTranslation()
return (
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-vscode-descriptionForeground pointer-events-none" />
<Input
data-testid="settings-search-input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
placeholder={t("settings:search.placeholder")}
className={cn("pl-8", value && "pr-8")}
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-vscode-descriptionForeground hover:text-vscode-foreground focus:outline-none"
aria-label="Clear search">
<X className="h-4 w-4" />
</button>
)}
</div>
)
}

View file

@ -0,0 +1,114 @@
import { useMemo } from "react"
import type { LucideIcon } from "lucide-react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import type { SearchResult } from "@/hooks/useSettingsSearch"
import type { SectionName } from "@/utils/parseSettingsI18nKeys"
export interface SettingsSearchResultsProps {
results: SearchResult[]
query: string
onSelectResult: (result: SearchResult) => void
sections: { id: SectionName; icon: LucideIcon }[]
}
interface HighlightMatchProps {
text: string
query: string
}
/**
* Highlights matching parts of text by wrapping them in <mark> tags.
*/
function HighlightMatch({ text, query }: HighlightMatchProps) {
if (!query.trim()) {
return <>{text}</>
}
// Split text by query (case-insensitive) while keeping the matched parts
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi")
const parts = text.split(regex)
return (
<>
{parts.map((part, index) =>
regex.test(part) ? (
<mark key={index} className="bg-vscode-editor-findMatchHighlightBackground text-inherit">
{part}
</mark>
) : (
<span key={index}>{part}</span>
),
)}
</>
)
}
export function SettingsSearchResults({ results, query, onSelectResult, sections }: SettingsSearchResultsProps) {
const { t } = useAppTranslation()
// Group results by tab
const groupedResults = useMemo(() => {
return results.reduce(
(acc, result) => {
const tab = result.tab
if (!acc[tab]) {
acc[tab] = []
}
acc[tab].push(result)
return acc
},
{} as Record<SectionName, SearchResult[]>,
)
}, [results])
// Create a map of section id to icon for quick lookup
const sectionIconMap = useMemo(() => {
return new Map(sections.map((section) => [section.id, section.icon]))
}, [sections])
// If no results, show a message
if (results.length === 0) {
return (
<div className="max-h-80 overflow-y-auto p-4 text-vscode-descriptionForeground text-sm">
{t("settings:search.noResults", { query })}
</div>
)
}
return (
<div className="max-h-80 overflow-y-auto">
{Object.entries(groupedResults).map(([tab, tabResults]) => {
const Icon = sectionIconMap.get(tab as SectionName)
return (
<div key={tab}>
{/* Tab header */}
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-vscode-descriptionForeground bg-vscode-sideBar-background sticky top-0">
{Icon && <Icon className="h-3.5 w-3.5" />}
<span>{t(`settings:sections.${tab}`)}</span>
</div>
{/* Result items */}
{tabResults.map((result) => (
<button
key={result.id}
type="button"
onClick={() => onSelectResult(result)}
className="w-full text-left px-3 py-2 hover:bg-vscode-list-hoverBackground focus:bg-vscode-list-hoverBackground focus:outline-none">
<div className="text-sm text-vscode-foreground">
<HighlightMatch text={result.translatedLabel} query={query} />
</div>
{result.translatedDescription && (
<div className="text-xs text-vscode-descriptionForeground truncate mt-0.5">
<HighlightMatch text={result.translatedDescription} query={query} />
</div>
)}
</button>
))}
</div>
)
})}
</div>
)
}

View file

@ -58,6 +58,7 @@ import {
TooltipTrigger,
StandardTooltip,
} from "@src/components/ui"
import { useSettingsSearch, SearchResult } from "@src/hooks/useSettingsSearch"
import { Tab, TabContent, TabHeader, TabList, TabTrigger } from "../common/Tab"
import { SetCachedStateField, SetExperimentEnabled } from "./types"
@ -79,6 +80,8 @@ import { SlashCommandsSettings } from "./SlashCommandsSettings"
import { UISettings } from "./UISettings"
import ModesView from "../modes/ModesView"
import McpView from "../mcp/McpView"
import { SettingsSearchInput } from "./SettingsSearchInput"
import { SettingsSearchResults } from "./SettingsSearchResults"
export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden"
export const settingsTabList =
@ -130,6 +133,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
? (targetSection as SectionName)
: "providers",
)
const [searchQuery, setSearchQuery] = useState("")
const [isSearchFocused, setIsSearchFocused] = useState(false)
const scrollPositions = useRef<Record<SectionName, number>>(
Object.fromEntries(sectionNames.map((s) => [s, 0])) as Record<SectionName, number>,
@ -216,6 +221,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
// Settings search
const searchResults = useSettingsSearch(searchQuery)
useEffect(() => {
// Update only when currentApiConfigName is changed.
// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration.
@ -565,13 +573,54 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
}
}, [scrollToActiveTab])
// Scroll to and highlight a setting element
const scrollToSetting = useCallback((settingId: string) => {
const element = document.querySelector(`[data-setting-id="${settingId}"]`)
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" })
// Add temporary highlight class
element.classList.add("setting-highlight")
setTimeout(() => element.classList.remove("setting-highlight"), 2000)
}
}, [])
// Handle selection of a search result
const handleSelectResult = useCallback(
(result: SearchResult) => {
setSearchQuery("")
setIsSearchFocused(false)
handleTabChange(result.tab)
// Small delay to allow tab switch and render
setTimeout(() => scrollToSetting(result.id), 150)
},
[handleTabChange, scrollToSetting],
)
return (
<Tab>
<TabHeader className="flex justify-between items-center gap-2">
<div className="flex items-center gap-1">
<h3 className="text-vscode-foreground m-0">{t("settings:header.title")}</h3>
<div className="flex items-center gap-2">
<h3 className="text-vscode-foreground m-0 flex-shrink-0">{t("settings:header.title")}</h3>
<div className="relative flex-1 max-w-xs">
<SettingsSearchInput
value={searchQuery}
onChange={setSearchQuery}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)}
/>
{searchQuery && isSearchFocused && (
<div className="absolute top-full left-0 right-0 mt-1 bg-vscode-dropdown-background border border-vscode-dropdown-border rounded shadow-lg z-50">
<SettingsSearchResults
results={searchResults}
query={searchQuery}
onSelectResult={handleSelectResult}
sections={sections}
/>
</div>
)}
</div>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-shrink-0">
<StandardTooltip
content={
!isSettingValid

View file

@ -104,7 +104,7 @@ export const TerminalSettings = ({
</div>
</div>
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<div>
<div data-setting-id="terminal.outputLineLimit">
<label className="block font-medium mb-1">
{t("settings:terminal.outputLineLimit.label")}
</label>
@ -132,7 +132,7 @@ export const TerminalSettings = ({
</Trans>
</div>
</div>
<div>
<div data-setting-id="terminal.outputCharacterLimit">
<label className="block font-medium mb-1">
{t("settings:terminal.outputCharacterLimit.label")}
</label>
@ -162,7 +162,7 @@ export const TerminalSettings = ({
</Trans>
</div>
</div>
<div>
<div data-setting-id="terminal.compressProgressBar">
<VSCodeCheckbox
checked={terminalCompressProgressBar ?? true}
onChange={(e: any) =>
@ -199,7 +199,7 @@ export const TerminalSettings = ({
</div>
</div>
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<div>
<div data-setting-id="terminal.shellIntegrationDisabled">
<VSCodeCheckbox
checked={terminalShellIntegrationDisabled ?? true}
onChange={(e: any) =>
@ -225,7 +225,7 @@ export const TerminalSettings = ({
{!terminalShellIntegrationDisabled && (
<>
<div>
<div data-setting-id="terminal.inheritEnv">
<VSCodeCheckbox
checked={inheritEnv}
onChange={(e: any) => {
@ -253,7 +253,7 @@ export const TerminalSettings = ({
</div>
</div>
<div>
<div data-setting-id="terminal.shellIntegrationTimeout">
<label className="block font-medium mb-1">
{t("settings:terminal.shellIntegrationTimeout.label")}
</label>
@ -288,7 +288,7 @@ export const TerminalSettings = ({
</div>
</div>
<div>
<div data-setting-id="terminal.commandDelay">
<label className="block font-medium mb-1">
{t("settings:terminal.commandDelay.label")}
</label>
@ -321,7 +321,7 @@ export const TerminalSettings = ({
</div>
</div>
<div>
<div data-setting-id="terminal.powershellCounter">
<VSCodeCheckbox
checked={terminalPowershellCounter ?? false}
onChange={(e: any) =>
@ -346,7 +346,7 @@ export const TerminalSettings = ({
</div>
</div>
<div>
<div data-setting-id="terminal.zshClearEolMark">
<VSCodeCheckbox
checked={terminalZshClearEolMark ?? true}
onChange={(e: any) =>
@ -371,7 +371,7 @@ export const TerminalSettings = ({
</div>
</div>
<div>
<div data-setting-id="terminal.zshOhMy">
<VSCodeCheckbox
checked={terminalZshOhMy ?? false}
onChange={(e: any) => setCachedStateField("terminalZshOhMy", e.target.checked)}
@ -392,7 +392,7 @@ export const TerminalSettings = ({
</div>
</div>
<div>
<div data-setting-id="terminal.zshP10k">
<VSCodeCheckbox
checked={terminalZshP10k ?? false}
onChange={(e: any) => setCachedStateField("terminalZshP10k", e.target.checked)}
@ -413,7 +413,7 @@ export const TerminalSettings = ({
</div>
</div>
<div>
<div data-setting-id="terminal.zdotdir">
<VSCodeCheckbox
checked={terminalZdotdir ?? false}
onChange={(e: any) => setCachedStateField("terminalZdotdir", e.target.checked)}

View file

@ -60,7 +60,7 @@ export const UISettings = ({
<Section>
<div className="space-y-6">
{/* Collapse Thinking Messages Setting */}
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-setting-id="ui.collapseThinking">
<VSCodeCheckbox
checked={reasoningBlockCollapsed}
onChange={(e: any) => handleReasoningBlockCollapsedChange(e.target.checked)}
@ -73,7 +73,7 @@ export const UISettings = ({
</div>
{/* Enter Key Behavior Setting */}
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-setting-id="ui.requireCtrlEnterToSend">
<VSCodeCheckbox
checked={enterBehavior === "newline"}
onChange={(e: any) => handleEnterBehaviorChange(e.target.checked)}

View file

@ -0,0 +1,210 @@
// npx vitest run src/components/settings/__tests__/SettingsSearchInput.spec.tsx
import { render, screen, fireEvent } from "@/utils/test-utils"
import { SettingsSearchInput } from "../SettingsSearchInput"
// Mock useAppTranslation
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => {
if (key === "settings:search.placeholder") {
return "Search settings..."
}
return key
},
i18n: {},
}),
}))
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Search: ({ className, ...props }: any) => <div data-testid="search-icon" className={className} {...props} />,
X: ({ className, ...props }: any) => <div data-testid="x-icon" className={className} {...props} />,
}))
describe("SettingsSearchInput", () => {
describe("rendering", () => {
it("should render input with placeholder text", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} />)
const input = screen.getByTestId("settings-search-input")
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute("placeholder", "Search settings...")
})
it("should display search icon", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} />)
const searchIcon = screen.getByTestId("search-icon")
expect(searchIcon).toBeInTheDocument()
})
it("should render input with correct type", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} />)
const input = screen.getByTestId("settings-search-input")
expect(input).toHaveAttribute("type", "text")
})
})
describe("clear button", () => {
it("should hide clear button when input is empty", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} />)
const clearButton = screen.queryByRole("button", { name: /clear search/i })
expect(clearButton).not.toBeInTheDocument()
})
it("should show clear button when there is text", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="browser" onChange={onChange} />)
const clearButton = screen.getByRole("button", { name: /clear search/i })
expect(clearButton).toBeInTheDocument()
})
it("should display X icon in clear button", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="test" onChange={onChange} />)
const xIcon = screen.getByTestId("x-icon")
expect(xIcon).toBeInTheDocument()
})
it("should call onChange with empty string when clear button is clicked", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="browser" onChange={onChange} />)
const clearButton = screen.getByRole("button", { name: /clear search/i })
fireEvent.click(clearButton)
expect(onChange).toHaveBeenCalledWith("")
expect(onChange).toHaveBeenCalledTimes(1)
})
})
describe("controlled input", () => {
it("should display the value prop", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="test value" onChange={onChange} />)
const input = screen.getByTestId("settings-search-input")
expect(input).toHaveValue("test value")
})
it("should call onChange when user types", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} />)
const input = screen.getByTestId("settings-search-input")
fireEvent.change(input, { target: { value: "new text" } })
expect(onChange).toHaveBeenCalledWith("new text")
expect(onChange).toHaveBeenCalledTimes(1)
})
it("should update when value prop changes", () => {
const onChange = vi.fn()
const { rerender } = render(<SettingsSearchInput value="initial" onChange={onChange} />)
const input = screen.getByTestId("settings-search-input")
expect(input).toHaveValue("initial")
rerender(<SettingsSearchInput value="updated" onChange={onChange} />)
expect(input).toHaveValue("updated")
})
})
describe("focus and blur callbacks", () => {
it("should call onFocus when input is focused", () => {
const onChange = vi.fn()
const onFocus = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} onFocus={onFocus} />)
const input = screen.getByTestId("settings-search-input")
fireEvent.focus(input)
expect(onFocus).toHaveBeenCalledTimes(1)
})
it("should call onBlur when input loses focus", () => {
const onChange = vi.fn()
const onBlur = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} onBlur={onBlur} />)
const input = screen.getByTestId("settings-search-input")
fireEvent.focus(input)
fireEvent.blur(input)
expect(onBlur).toHaveBeenCalledTimes(1)
})
it("should work without onFocus callback", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} />)
const input = screen.getByTestId("settings-search-input")
expect(() => fireEvent.focus(input)).not.toThrow()
})
it("should work without onBlur callback", () => {
const onChange = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} />)
const input = screen.getByTestId("settings-search-input")
expect(() => {
fireEvent.focus(input)
fireEvent.blur(input)
}).not.toThrow()
})
})
describe("integration scenarios", () => {
it("should handle typing and clearing in sequence", () => {
const onChange = vi.fn()
const { rerender } = render(<SettingsSearchInput value="" onChange={onChange} />)
const input = screen.getByTestId("settings-search-input")
// Type something
fireEvent.change(input, { target: { value: "browser" } })
expect(onChange).toHaveBeenCalledWith("browser")
// Now render with the new value (simulating parent state update)
rerender(<SettingsSearchInput value="browser" onChange={onChange} />)
// Clear button should now be visible
const clearButton = screen.getByRole("button", { name: /clear search/i })
expect(clearButton).toBeInTheDocument()
// Click clear
fireEvent.click(clearButton)
expect(onChange).toHaveBeenCalledWith("")
})
it("should handle focus, type, and blur flow", () => {
const onChange = vi.fn()
const onFocus = vi.fn()
const onBlur = vi.fn()
render(<SettingsSearchInput value="" onChange={onChange} onFocus={onFocus} onBlur={onBlur} />)
const input = screen.getByTestId("settings-search-input")
// Focus
fireEvent.focus(input)
expect(onFocus).toHaveBeenCalledTimes(1)
// Type
fireEvent.change(input, { target: { value: "test" } })
expect(onChange).toHaveBeenCalledWith("test")
// Blur
fireEvent.blur(input)
expect(onBlur).toHaveBeenCalledTimes(1)
})
})
})

View file

@ -0,0 +1,436 @@
// npx vitest run src/components/settings/__tests__/SettingsSearchResults.spec.tsx
import { render, screen, fireEvent } from "@/utils/test-utils"
import type { LucideIcon } from "lucide-react"
import { SettingsSearchResults } from "../SettingsSearchResults"
import type { SearchResult } from "@/hooks/useSettingsSearch"
import type { SectionName } from "@/utils/parseSettingsI18nKeys"
// Mock useAppTranslation
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, options?: Record<string, any>) => {
const translations: Record<string, string> = {
"settings:sections.browser": "Browser",
"settings:sections.notifications": "Notifications",
"settings:sections.checkpoints": "Checkpoints",
"settings:search.noResults": `No results found for "${options?.query}"`,
}
return translations[key] || key
},
i18n: {},
}),
}))
// Mock icon component - cast to LucideIcon for type compatibility in tests
const MockIcon = (({ className, ...props }: { className?: string }) => (
<div data-testid="section-icon" className={className} {...props} />
)) as unknown as LucideIcon
describe("SettingsSearchResults", () => {
// Mock data
const mockBrowserResults: SearchResult[] = [
{
id: "browser.enable",
tab: "browser",
labelKey: "settings:browser.enable.label",
descriptionKey: "settings:browser.enable.description",
translatedLabel: "Enable browser tool",
translatedDescription: "Allows Roo to use a browser",
matchScore: 15,
},
{
id: "browser.viewport",
tab: "browser",
labelKey: "settings:browser.viewport.label",
descriptionKey: "settings:browser.viewport.description",
translatedLabel: "Browser viewport",
translatedDescription: "Configure the browser window size",
matchScore: 15,
},
]
const mockNotificationsResults: SearchResult[] = [
{
id: "notifications.sound",
tab: "notifications",
labelKey: "settings:notifications.sound.label",
descriptionKey: "settings:notifications.sound.description",
translatedLabel: "Sound effects",
translatedDescription: "Play sound when Roo needs attention",
matchScore: 10,
},
]
const mockCheckpointsResults: SearchResult[] = [
{
id: "checkpoints.timeout",
tab: "checkpoints",
labelKey: "settings:checkpoints.timeout.label",
descriptionKey: undefined,
translatedLabel: "Checkpoint timeout",
translatedDescription: undefined,
matchScore: 10,
},
]
const mockSections = [
{ id: "browser" as SectionName, icon: MockIcon },
{ id: "notifications" as SectionName, icon: MockIcon },
{ id: "checkpoints" as SectionName, icon: MockIcon },
]
describe("empty results", () => {
it('should show "no results" message when results array is empty', () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={[]}
query="nonexistent"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
const noResultsMessage = screen.getByText(/No results found for/i)
expect(noResultsMessage).toBeInTheDocument()
expect(noResultsMessage).toHaveTextContent('No results found for "nonexistent"')
})
it("should not render any result items when empty", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={[]}
query="test"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
const buttons = screen.queryAllByRole("button")
expect(buttons).toHaveLength(0)
})
})
describe("grouping by tab", () => {
it("should group results by tab", () => {
const onSelectResult = vi.fn()
const allResults = [...mockBrowserResults, ...mockNotificationsResults]
render(
<SettingsSearchResults
results={allResults}
query="test"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Check for tab headers
expect(screen.getByText("Browser")).toBeInTheDocument()
expect(screen.getByText("Notifications")).toBeInTheDocument()
})
it("should display results under their respective tabs", () => {
const onSelectResult = vi.fn()
const allResults = [...mockBrowserResults, ...mockNotificationsResults]
render(
<SettingsSearchResults
results={allResults}
query="test"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Browser results
expect(screen.getByText("Enable browser tool")).toBeInTheDocument()
expect(screen.getByText("Browser viewport")).toBeInTheDocument()
// Notifications results
expect(screen.getByText("Sound effects")).toBeInTheDocument()
})
})
describe("tab headers", () => {
it("should display tab headers with icons", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="browser"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Use getAllByText since "Browser" appears in both the tab header and highlighted in results
const browserElements = screen.getAllByText(/Browser/i)
expect(browserElements.length).toBeGreaterThan(0)
const icons = screen.getAllByTestId("section-icon")
expect(icons.length).toBeGreaterThan(0)
})
it("should display translated tab names", () => {
const onSelectResult = vi.fn()
const allResults = [...mockBrowserResults, ...mockNotificationsResults, ...mockCheckpointsResults]
render(
<SettingsSearchResults
results={allResults}
query="test"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
expect(screen.getByText("Browser")).toBeInTheDocument()
expect(screen.getByText("Notifications")).toBeInTheDocument()
expect(screen.getByText("Checkpoints")).toBeInTheDocument()
})
})
describe("result items", () => {
it("should display translated labels for each result", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="browser"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Use flexible matchers since HighlightMatch splits text across elements
expect(
screen.getByText((_, element) => {
return element?.textContent === "Enable browser tool"
}),
).toBeInTheDocument()
expect(
screen.getByText((_, element) => {
return element?.textContent === "Browser viewport"
}),
).toBeInTheDocument()
})
it("should display descriptions when available", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="browser"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Use flexible matchers since HighlightMatch splits text across elements
expect(
screen.getByText((_, element) => {
return element?.textContent === "Allows Roo to use a browser"
}),
).toBeInTheDocument()
expect(
screen.getByText((_, element) => {
return element?.textContent === "Configure the browser window size"
}),
).toBeInTheDocument()
})
it("should not display descriptions when not available", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockCheckpointsResults}
query="checkpoint"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Label should be present - check that button exists
const checkpointButton = screen.getByRole("button")
expect(checkpointButton).toBeInTheDocument()
expect(checkpointButton.textContent).toContain("Checkpoint timeout")
// Description should not be present (it's undefined for this setting)
const descriptionElements = checkpointButton.querySelectorAll(".text-xs.text-vscode-descriptionForeground")
expect(descriptionElements).toHaveLength(0)
})
it("should render results as clickable buttons", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="browser"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
const buttons = screen.getAllByRole("button")
expect(buttons.length).toBe(mockBrowserResults.length)
})
})
describe("clicking results", () => {
it("should call onSelectResult with the result when clicked", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="browser"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Click the first button (the result item itself is a button)
const buttons = screen.getAllByRole("button")
fireEvent.click(buttons[0])
expect(onSelectResult).toHaveBeenCalledTimes(1)
expect(onSelectResult).toHaveBeenCalledWith(mockBrowserResults[0])
})
it("should call onSelectResult with the correct result for each click", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="browser"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
const buttons = screen.getAllByRole("button")
// Click first result
fireEvent.click(buttons[0])
expect(onSelectResult).toHaveBeenLastCalledWith(mockBrowserResults[0])
// Click second result
fireEvent.click(buttons[1])
expect(onSelectResult).toHaveBeenLastCalledWith(mockBrowserResults[1])
expect(onSelectResult).toHaveBeenCalledTimes(2)
})
})
describe("HighlightMatch component", () => {
it("should highlight matching text in labels", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="browser"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Check for <mark> elements (used for highlighting)
const marks = screen.getAllByText((_content, element) => {
return element?.tagName.toLowerCase() === "mark"
})
expect(marks.length).toBeGreaterThan(0)
})
it("should highlight matching text in descriptions", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="browser"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Query "browser" appears in descriptions too
const marks = screen.getAllByText((_content, element) => {
return element?.tagName.toLowerCase() === "mark"
})
// Should have highlights in both labels and descriptions
expect(marks.length).toBeGreaterThan(mockBrowserResults.length)
})
it("should be case-insensitive when highlighting", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query="BROWSER"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// Should still highlight "browser" text even though query is uppercase
const marks = screen.getAllByText((_content, element) => {
return element?.tagName.toLowerCase() === "mark"
})
expect(marks.length).toBeGreaterThan(0)
})
it("should not highlight when query is empty", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
results={mockBrowserResults}
query=""
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
const marks = screen.queryAllByText((_content, element) => {
return element?.tagName.toLowerCase() === "mark"
})
expect(marks).toHaveLength(0)
})
})
describe("multiple tabs with mixed results", () => {
it("should handle results from multiple tabs correctly", () => {
const onSelectResult = vi.fn()
const allResults = [...mockBrowserResults, ...mockNotificationsResults, ...mockCheckpointsResults]
render(
<SettingsSearchResults
results={allResults}
query="test"
onSelectResult={onSelectResult}
sections={mockSections}
/>,
)
// All tab headers should be present
expect(screen.getByText("Browser")).toBeInTheDocument()
expect(screen.getByText("Notifications")).toBeInTheDocument()
expect(screen.getByText("Checkpoints")).toBeInTheDocument()
// All results should be present
expect(screen.getByText("Enable browser tool")).toBeInTheDocument()
expect(screen.getByText("Browser viewport")).toBeInTheDocument()
expect(screen.getByText("Sound effects")).toBeInTheDocument()
expect(screen.getByText("Checkpoint timeout")).toBeInTheDocument()
// Should have correct number of clickable results
const buttons = screen.getAllByRole("button")
expect(buttons).toHaveLength(allResults.length)
})
})
})

View file

@ -42,6 +42,9 @@ vi.mock("@src/components/ui", () => ({
</button>
),
StandardTooltip: ({ children }: any) => <>{children}</>,
Input: React.forwardRef<HTMLInputElement, any>(({ className, ...props }, ref) => (
<input ref={ref} className={className} {...props} />
)),
}))
// Mock Tab components

View file

@ -47,6 +47,9 @@ vi.mock("@src/components/ui", () => ({
TooltipProvider: ({ children }: any) => <>{children}</>,
TooltipTrigger: ({ children }: any) => <>{children}</>,
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
Input: React.forwardRef<HTMLInputElement, any>(({ className, ...props }, ref) => (
<input ref={ref} className={className} {...props} />
)),
}))
// Mock Tab components

View file

@ -0,0 +1,302 @@
// npx vitest run src/hooks/__tests__/useSettingsSearch.spec.ts
import { renderHook } from "@testing-library/react"
import type { Mock } from "vitest"
import { useSettingsSearch } from "../useSettingsSearch"
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(),
}))
// Mock the parseSettingsI18nKeys module to provide a controlled settingsIndex
vi.mock("@/utils/parseSettingsI18nKeys", async () => {
const actual = await vi.importActual("@/utils/parseSettingsI18nKeys")
return {
...actual,
}
})
// Mock settings data
vi.mock("@/i18n/locales/en/settings.json", () => ({
default: {
browser: {
enable: {
label: "Enable browser tool",
description: "Allows Roo to use a browser",
},
viewport: {
label: "Browser viewport",
description: "Configure the browser window size",
},
},
notifications: {
sound: {
label: "Sound effects",
description: "Play sound when Roo needs attention",
},
},
checkpoints: {
timeout: {
label: "Checkpoint timeout",
},
},
},
}))
import { useTranslation } from "react-i18next"
const mockUseTranslation = useTranslation as Mock
describe("useSettingsSearch", () => {
beforeEach(() => {
// Setup translation mock with a function that returns mock translations
const mockTranslations: Record<string, string> = {
"settings:browser.enable.label": "Enable browser tool",
"settings:browser.enable.description": "Allows Roo to use a browser",
"settings:browser.viewport.label": "Browser viewport",
"settings:browser.viewport.description": "Configure the browser window size",
"settings:notifications.sound.label": "Sound effects",
"settings:notifications.sound.description": "Play sound when Roo needs attention",
"settings:checkpoints.timeout.label": "Checkpoint timeout",
}
const mockT = (key: string) => mockTranslations[key] || key
mockUseTranslation.mockReturnValue({
t: mockT,
i18n: {},
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe("empty and whitespace queries", () => {
it("should return empty array for empty query", () => {
const { result } = renderHook(() => useSettingsSearch(""))
expect(result.current).toEqual([])
})
it("should return empty array for whitespace-only query", () => {
const { result } = renderHook(() => useSettingsSearch(" "))
expect(result.current).toEqual([])
})
it("should return empty array for query with tabs and newlines", () => {
const { result } = renderHook(() => useSettingsSearch("\t\n \n"))
expect(result.current).toEqual([])
})
})
describe("label matching", () => {
it("should match setting label (case-insensitive)", () => {
const { result } = renderHook(() => useSettingsSearch("browser"))
expect(result.current.length).toBeGreaterThan(0)
const browserResults = result.current.filter((r) => r.translatedLabel.toLowerCase().includes("browser"))
expect(browserResults.length).toBeGreaterThan(0)
})
it("should match with different case", () => {
const { result } = renderHook(() => useSettingsSearch("BROWSER"))
expect(result.current.length).toBeGreaterThan(0)
const browserResults = result.current.filter((r) => r.translatedLabel.toLowerCase().includes("browser"))
expect(browserResults.length).toBeGreaterThan(0)
})
it("should support partial word matching", () => {
const { result } = renderHook(() => useSettingsSearch("brow"))
expect(result.current.length).toBeGreaterThan(0)
const browserResults = result.current.filter((r) => r.translatedLabel.toLowerCase().includes("brow"))
expect(browserResults.length).toBeGreaterThan(0)
})
})
describe("description matching", () => {
it("should match setting description (case-insensitive)", () => {
const { result } = renderHook(() => useSettingsSearch("attention"))
expect(result.current.length).toBeGreaterThan(0)
const attentionResults = result.current.filter((r) =>
r.translatedDescription?.toLowerCase().includes("attention"),
)
expect(attentionResults.length).toBeGreaterThan(0)
})
it("should match description with different case", () => {
const { result } = renderHook(() => useSettingsSearch("ATTENTION"))
expect(result.current.length).toBeGreaterThan(0)
const attentionResults = result.current.filter((r) =>
r.translatedDescription?.toLowerCase().includes("attention"),
)
expect(attentionResults.length).toBeGreaterThan(0)
})
})
describe("result structure", () => {
it("should return translatedLabel and translatedDescription", () => {
const { result } = renderHook(() => useSettingsSearch("browser"))
expect(result.current.length).toBeGreaterThan(0)
result.current.forEach((searchResult) => {
expect(searchResult).toHaveProperty("translatedLabel")
expect(typeof searchResult.translatedLabel).toBe("string")
expect(searchResult.translatedLabel).not.toBe("")
// translatedDescription may be undefined for some settings
})
})
it("should include all ParsedSetting properties", () => {
const { result } = renderHook(() => useSettingsSearch("browser"))
expect(result.current.length).toBeGreaterThan(0)
result.current.forEach((searchResult) => {
expect(searchResult).toHaveProperty("id")
expect(searchResult).toHaveProperty("tab")
expect(searchResult).toHaveProperty("labelKey")
expect(searchResult).toHaveProperty("matchScore")
})
})
})
describe("match score calculation", () => {
it("should calculate matchScore correctly for label match only", () => {
const { result } = renderHook(() => useSettingsSearch("timeout"))
const timeoutResult = result.current.find((r) => r.translatedLabel.toLowerCase().includes("timeout"))
expect(timeoutResult).toBeDefined()
// "Checkpoint timeout" has no description, so only label match
expect(timeoutResult?.matchScore).toBe(10)
})
it("should calculate matchScore correctly for description match only", () => {
const { result } = renderHook(() => useSettingsSearch("attention"))
// "attention" only appears in description of "Sound effects"
const attentionResult = result.current.find(
(r) =>
r.translatedDescription?.toLowerCase().includes("attention") &&
!r.translatedLabel.toLowerCase().includes("attention"),
)
expect(attentionResult).toBeDefined()
expect(attentionResult?.matchScore).toBe(5)
})
it("should calculate matchScore correctly for both label and description match", () => {
const { result } = renderHook(() => useSettingsSearch("browser"))
// "browser" appears in both label and description for some settings
const browserResults = result.current.filter(
(r) =>
r.translatedLabel.toLowerCase().includes("browser") &&
r.translatedDescription?.toLowerCase().includes("browser"),
)
if (browserResults.length > 0) {
browserResults.forEach((result) => {
expect(result.matchScore).toBe(15) // 10 for label + 5 for description
})
}
})
it("should have higher matchScore for label+description than description only", () => {
const { result } = renderHook(() => useSettingsSearch("browser"))
const results = result.current
const labelAndDescMatch = results.find(
(r) =>
r.translatedLabel.toLowerCase().includes("browser") &&
r.translatedDescription?.toLowerCase().includes("browser"),
)
const descOnlyMatch = results.find(
(r) =>
!r.translatedLabel.toLowerCase().includes("browser") &&
r.translatedDescription?.toLowerCase().includes("browser"),
)
if (labelAndDescMatch && descOnlyMatch) {
expect(labelAndDescMatch.matchScore).toBeGreaterThan(descOnlyMatch.matchScore)
}
})
})
describe("sorting by match score", () => {
it("should sort results by matchScore in descending order", () => {
const { result } = renderHook(() => useSettingsSearch("browser"))
const results = result.current
expect(results.length).toBeGreaterThan(0)
// Verify results are sorted by matchScore descending
for (let i = 0; i < results.length - 1; i++) {
expect(results[i].matchScore).toBeGreaterThanOrEqual(results[i + 1].matchScore)
}
})
it("should rank label matches higher than description-only matches", () => {
const { result } = renderHook(() => useSettingsSearch("browser"))
const labelMatches = result.current.filter((r) => r.translatedLabel.toLowerCase().includes("browser"))
const descriptionOnlyMatches = result.current.filter(
(r) =>
!r.translatedLabel.toLowerCase().includes("browser") &&
r.translatedDescription?.toLowerCase().includes("browser"),
)
if (labelMatches.length > 0 && descriptionOnlyMatches.length > 0) {
const lowestLabelMatchScore = Math.min(...labelMatches.map((r) => r.matchScore))
const highestDescOnlyScore = Math.max(...descriptionOnlyMatches.map((r) => r.matchScore))
expect(lowestLabelMatchScore).toBeGreaterThanOrEqual(highestDescOnlyScore)
}
})
})
describe("no matches", () => {
it("should return empty array when no matches found", () => {
const { result } = renderHook(() => useSettingsSearch("xyznonexistent"))
expect(result.current).toEqual([])
})
})
describe("hook reactivity", () => {
it("should update results when query changes", () => {
const { result, rerender } = renderHook(({ query }) => useSettingsSearch(query), {
initialProps: { query: "browser" },
})
const browserResults = result.current
expect(browserResults.length).toBeGreaterThan(0)
// Change query
rerender({ query: "sound" })
const soundResults = result.current
expect(soundResults.length).toBeGreaterThan(0)
expect(soundResults).not.toEqual(browserResults)
})
it("should return empty array when query is cleared", () => {
const { result, rerender } = renderHook(({ query }) => useSettingsSearch(query), {
initialProps: { query: "browser" },
})
expect(result.current.length).toBeGreaterThan(0)
// Clear query
rerender({ query: "" })
expect(result.current).toEqual([])
})
})
})

View file

@ -0,0 +1,90 @@
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { ParsedSetting, parseSettingsI18nKeys } from "@/utils/parseSettingsI18nKeys"
import settingsEn from "@/i18n/locales/en/settings.json"
/**
* Represents a search result for a setting.
*/
export interface SearchResult extends ParsedSetting {
/** Translated label for the setting */
translatedLabel: string
/** Translated description for the setting (if available) */
translatedDescription?: string
/** Match score for sorting results (higher = better match) */
matchScore: number
}
/**
* Pre-parsed settings index, created once at module load.
*/
const settingsIndex: ParsedSetting[] = parseSettingsI18nKeys(settingsEn)
/**
* Custom hook that provides search functionality for settings.
*
* @param query - The search query string
* @returns Array of matching settings sorted by relevance (matchScore descending)
*
* @example
* ```typescript
* const results = useSettingsSearch("browser")
* // Returns settings where label or description contains "browser"
* ```
*/
export function useSettingsSearch(query: string): SearchResult[] {
const { t } = useTranslation()
return useMemo(() => {
// Return empty array if query is empty or whitespace
const trimmedQuery = query.trim()
if (!trimmedQuery) {
return []
}
// Normalize query to lowercase for case-insensitive matching
const normalizedQuery = trimmedQuery.toLowerCase()
// Search through all settings
const results = settingsIndex
.map((setting): SearchResult | null => {
// Get translated label
const translatedLabel = t(setting.labelKey)
// Get translated description if it exists
const translatedDescription = setting.descriptionKey ? t(setting.descriptionKey) : undefined
// Check for matches (case-insensitive)
const labelMatch = translatedLabel.toLowerCase().includes(normalizedQuery)
const descriptionMatch = translatedDescription
? translatedDescription.toLowerCase().includes(normalizedQuery)
: false
// If no match, return null
if (!labelMatch && !descriptionMatch) {
return null
}
// Calculate match score: +10 for label match, +5 for description match
let matchScore = 0
if (labelMatch) {
matchScore += 10
}
if (descriptionMatch) {
matchScore += 5
}
return {
...setting,
translatedLabel,
translatedDescription,
matchScore,
}
})
.filter((result): result is SearchResult => result !== null)
// Sort by matchScore descending
results.sort((a, b) => b.matchScore - a.matchScore)
return results
}, [query, t])
}

View file

@ -1,4 +1,8 @@
{
"search": {
"placeholder": "Search settings...",
"noResults": "No results found for \"{{query}}\""
},
"common": {
"save": "Save",
"done": "Done",

View file

@ -569,3 +569,18 @@ input[cmdk-input]:focus {
.animate-sun {
animation: sun 30s linear infinite;
}
/* Setting highlight animation for search navigation */
.setting-highlight {
animation: highlight-pulse 2s ease-out;
border-radius: 4px;
}
@keyframes highlight-pulse {
0% {
background-color: var(--vscode-editor-findMatchHighlightBackground);
}
100% {
background-color: transparent;
}
}

View file

@ -0,0 +1,242 @@
import { parseSettingsI18nKeys, type SectionName, sectionNames } from "../parseSettingsI18nKeys"
describe("parseSettingsI18nKeys", () => {
describe("basic parsing functionality", () => {
it("should parse settings with label property", () => {
const translations = {
browser: {
enable: {
label: "Enable browser tool",
description: "When enabled, Roo can use a browser",
},
},
}
const results = parseSettingsI18nKeys(translations)
expect(results).toContainEqual({
id: "browser.enable",
tab: "browser",
labelKey: "settings:browser.enable.label",
descriptionKey: "settings:browser.enable.description",
})
})
it("should handle settings without description", () => {
const translations = {
checkpoints: {
timeout: {
label: "Checkpoint timeout",
},
},
}
const results = parseSettingsI18nKeys(translations)
expect(results).toContainEqual({
id: "checkpoints.timeout",
tab: "checkpoints",
labelKey: "settings:checkpoints.timeout.label",
descriptionKey: undefined,
})
})
it("should skip non-setting sections", () => {
const translations = {
common: {
save: "Save",
},
header: {
title: "Settings",
},
sections: {
providers: "Providers",
},
}
const results = parseSettingsI18nKeys(translations)
// Should not include any results from skipped sections
expect(results.filter((r) => r.tab === ("common" as SectionName))).toHaveLength(0)
expect(results.filter((r) => r.tab === ("header" as SectionName))).toHaveLength(0)
})
it("should parse nested settings", () => {
const translations = {
autoApprove: {
readOnly: {
label: "Read",
description: "When enabled, Roo will automatically view directory contents",
outsideWorkspace: {
label: "Include files outside workspace",
description: "Allow Roo to read files outside the current workspace",
},
},
},
}
const results = parseSettingsI18nKeys(translations)
expect(results).toContainEqual({
id: "autoApprove.readOnly",
tab: "autoApprove",
labelKey: "settings:autoApprove.readOnly.label",
descriptionKey: "settings:autoApprove.readOnly.description",
})
expect(results).toContainEqual({
id: "autoApprove.readOnly.outsideWorkspace",
tab: "autoApprove",
labelKey: "settings:autoApprove.readOnly.outsideWorkspace.label",
descriptionKey: "settings:autoApprove.readOnly.outsideWorkspace.description",
})
})
})
describe("special tab entries", () => {
it("should include special entries for tabs without parsed settings", () => {
// Empty translations - no parsed settings
const translations = {}
const results = parseSettingsI18nKeys(translations)
// Should include special entries for modes, mcp, prompts, slashCommands, language, about, providers
const specialTabs = ["modes", "mcp", "prompts", "slashCommands", "language", "about", "providers"]
for (const tab of specialTabs) {
const entry = results.find((r) => r.id === tab && r.tab === tab)
expect(entry).toBeDefined()
expect(entry?.labelKey).toBe(`settings:sections.${tab}`)
}
})
it("should not duplicate special entries for tabs that have parsed settings", () => {
const translations = {
browser: {
enable: {
label: "Enable browser tool",
description: "When enabled, Roo can use a browser",
},
},
}
const results = parseSettingsI18nKeys(translations)
// Browser tab should have the parsed setting but not a special entry
const browserEntries = results.filter((r) => r.tab === "browser")
expect(browserEntries.length).toBe(1)
expect(browserEntries[0].id).toBe("browser.enable")
})
it("should add special entry for modes tab (no settings)", () => {
const results = parseSettingsI18nKeys({})
const modesEntry = results.find((r) => r.id === "modes" && r.tab === "modes")
expect(modesEntry).toBeDefined()
expect(modesEntry?.labelKey).toBe("settings:sections.modes")
expect(modesEntry?.descriptionKey).toBeUndefined()
})
it("should add special entry for mcp tab (no settings)", () => {
const results = parseSettingsI18nKeys({})
const mcpEntry = results.find((r) => r.id === "mcp" && r.tab === "mcp")
expect(mcpEntry).toBeDefined()
expect(mcpEntry?.labelKey).toBe("settings:sections.mcp")
expect(mcpEntry?.descriptionKey).toBeUndefined()
})
it("should add special entry for language tab (no settings)", () => {
const results = parseSettingsI18nKeys({})
const languageEntry = results.find((r) => r.id === "language" && r.tab === "language")
expect(languageEntry).toBeDefined()
expect(languageEntry?.labelKey).toBe("settings:sections.language")
expect(languageEntry?.descriptionKey).toBeUndefined()
})
it("should add special entry for prompts tab (description only)", () => {
const translations = {
prompts: {
description: "Configure support prompts...",
},
}
const results = parseSettingsI18nKeys(translations)
// Prompts should have special entry since it only has description, not labeled settings
const promptsEntry = results.find((r) => r.id === "prompts" && r.tab === "prompts")
expect(promptsEntry).toBeDefined()
expect(promptsEntry?.labelKey).toBe("settings:sections.prompts")
})
it("should add special entry for slashCommands tab (description only)", () => {
const translations = {
slashCommands: {
description: "Manage your slash commands...",
},
}
const results = parseSettingsI18nKeys(translations)
// slashCommands should have special entry since it only has description, not labeled settings
const slashCommandsEntry = results.find((r) => r.id === "slashCommands" && r.tab === "slashCommands")
expect(slashCommandsEntry).toBeDefined()
expect(slashCommandsEntry?.labelKey).toBe("settings:sections.slashCommands")
})
})
describe("namespace handling", () => {
it("should use default namespace 'settings'", () => {
const translations = {
browser: {
enable: {
label: "Enable browser tool",
},
},
}
const results = parseSettingsI18nKeys(translations)
expect(results[0]?.labelKey).toContain("settings:")
})
it("should allow custom namespace", () => {
const translations = {
browser: {
enable: {
label: "Enable browser tool",
},
},
}
const results = parseSettingsI18nKeys(translations, "customNamespace")
expect(results[0]?.labelKey).toBe("customNamespace:browser.enable.label")
})
})
describe("section names export", () => {
it("should export all valid section names", () => {
const expectedSections = [
"providers",
"autoApprove",
"slashCommands",
"browser",
"checkpoints",
"notifications",
"contextManagement",
"terminal",
"modes",
"mcp",
"prompts",
"ui",
"experimental",
"language",
"about",
]
expect(sectionNames).toEqual(expectedSections)
})
})
})

View file

@ -0,0 +1,282 @@
/**
* Utility for parsing i18n translation structure to extract searchable settings information.
*
* This module traverses the nested settings translation object and identifies
* settings by looking for objects with a 'label' property, extracting the
* section (first path segment) and full setting path.
*/
/**
* Valid section names that correspond to tabs in SettingsView.
* Defined locally to avoid circular dependencies with SettingsView.tsx.
*/
export const sectionNames = [
"providers",
"autoApprove",
"slashCommands",
"browser",
"checkpoints",
"notifications",
"contextManagement",
"terminal",
"modes",
"mcp",
"prompts",
"ui",
"experimental",
"language",
"about",
] as const
export type SectionName = (typeof sectionNames)[number]
/**
* Represents a parsed setting extracted from i18n translations.
*/
export interface ParsedSetting {
/** Unique identifier for the setting, e.g., 'browser.enable' */
id: string
/** The tab/section this setting belongs to, e.g., 'browser' */
tab: SectionName
/** i18n key for the label, e.g., 'settings:browser.enable.label' */
labelKey: string
/** i18n key for the description (optional), e.g., 'settings:browser.enable.description' */
descriptionKey?: string
}
/**
* Special entries for tabs that don't follow the standard settings:section.setting.label pattern.
* These entries allow users to search for tab names and navigate directly to those tabs.
*/
const specialTabEntries: ParsedSetting[] = [
{
id: "modes",
tab: "modes",
labelKey: "settings:sections.modes",
descriptionKey: undefined,
},
{
id: "mcp",
tab: "mcp",
labelKey: "settings:sections.mcp",
descriptionKey: undefined,
},
{
id: "providers",
tab: "providers",
labelKey: "settings:sections.providers",
descriptionKey: undefined,
},
{
id: "slashCommands",
tab: "slashCommands",
labelKey: "settings:sections.slashCommands",
descriptionKey: undefined,
},
{
id: "about",
tab: "about",
labelKey: "settings:sections.about",
descriptionKey: undefined,
},
{
id: "prompts",
tab: "prompts",
labelKey: "settings:sections.prompts",
descriptionKey: undefined,
},
{
id: "language",
tab: "language",
labelKey: "settings:sections.language",
descriptionKey: undefined,
},
]
/**
* Mapping from i18n section names to their corresponding tab names.
* Most sections map directly, but this provides flexibility for any differences.
*/
const sectionToTabMapping: Record<string, SectionName | undefined> = {
// Direct mappings - section name matches tab name
providers: "providers",
autoApprove: "autoApprove",
slashCommands: "slashCommands",
browser: "browser",
checkpoints: "checkpoints",
notifications: "notifications",
contextManagement: "contextManagement",
terminal: "terminal",
modes: "modes",
mcp: "mcp",
prompts: "prompts",
ui: "ui",
experimental: "experimental",
language: "language",
about: "about",
// Additional mappings for nested sections that should map to specific tabs
advanced: "providers", // advanced settings are part of providers tab
codeIndex: "experimental", // codebase indexing is in experimental
}
/**
* Set of section names that are valid tabs.
*/
const validTabs = new Set<string>(sectionNames)
/**
* Checks if a value is a plain object (not null, not array).
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
/**
* Checks if an object represents a setting (has a 'label' property that is a string).
*/
function isSettingObject(obj: Record<string, unknown>): boolean {
return typeof obj.label === "string"
}
/**
* Gets the tab for a given section name.
*/
function getTabForSection(section: string): SectionName | undefined {
// First check the explicit mapping
if (sectionToTabMapping[section]) {
return sectionToTabMapping[section]
}
// Fall back to direct match if section is a valid tab name
if (validTabs.has(section)) {
return section as SectionName
}
return undefined
}
/**
* Recursively traverses the translation object to find settings.
*
* @param obj - The current object being traversed
* @param path - Array of keys representing the current path
* @param namespace - The i18n namespace (e.g., 'settings')
* @param results - Array to collect parsed settings
* @param rootSection - The root section name (first path segment)
*/
function traverseTranslations(
obj: Record<string, unknown>,
path: string[],
namespace: string,
results: ParsedSetting[],
rootSection?: string,
): void {
// Determine the root section from the first path segment
const currentRootSection = rootSection ?? path[0]
// If this object has a 'label' property, it's a setting
if (isSettingObject(obj)) {
const tab = getTabForSection(currentRootSection)
// Skip if we can't map to a valid tab
if (!tab) {
return
}
// Build the setting ID from the path (excluding the 'label' part)
const settingId = path.join(".")
// Build the i18n keys
const labelKey = `${namespace}:${settingId}.label`
const descriptionKey = typeof obj.description === "string" ? `${namespace}:${settingId}.description` : undefined
results.push({
id: settingId,
tab,
labelKey,
descriptionKey,
})
}
// Continue traversing nested objects
for (const [key, value] of Object.entries(obj)) {
// Skip non-object values and special keys that are not settings
if (!isPlainObject(value)) {
continue
}
// Skip the 'label' and 'description' keys themselves as they are not nested settings
if (key === "label" || key === "description") {
continue
}
// Recurse into nested objects
traverseTranslations(value, [...path, key], namespace, results, currentRootSection)
}
}
/**
* Parses the i18n translation structure to extract searchable settings information.
*
* @param translations - The translations object (e.g., the content of settings.json)
* @param namespace - The i18n namespace, defaults to 'settings'
* @returns Array of parsed settings with their IDs, tabs, and i18n keys
*
* @example
* ```typescript
* import settingsTranslations from '@/i18n/locales/en/settings.json'
*
* const parsedSettings = parseSettingsI18nKeys(settingsTranslations)
* // Returns:
* // [
* // { id: 'browser.enable', tab: 'browser', labelKey: 'settings:browser.enable.label', descriptionKey: 'settings:browser.enable.description' },
* // { id: 'browser.viewport', tab: 'browser', labelKey: 'settings:browser.viewport.label', descriptionKey: 'settings:browser.viewport.description' },
* // ...
* // ]
* ```
*/
export function parseSettingsI18nKeys(
translations: Record<string, unknown>,
namespace: string = "settings",
): ParsedSetting[] {
const results: ParsedSetting[] = []
// Traverse each top-level section
for (const [sectionKey, sectionValue] of Object.entries(translations)) {
// Skip non-object sections (like 'common', etc. that don't contain settings)
if (!isPlainObject(sectionValue)) {
continue
}
// Skip sections that are clearly not settings containers
// These are sections that have simple string values, not nested setting objects
const skipSections = [
"common",
"header",
"unsavedChangesDialog",
"sections",
"validation",
"placeholders",
"defaults",
"labels",
"search",
]
if (skipSections.includes(sectionKey)) {
continue
}
// Traverse the section
traverseTranslations(sectionValue, [sectionKey], namespace, results, sectionKey)
}
// Collect tabs that already have settings from parsing
const tabsWithSettings = new Set(results.map((r) => r.tab))
// Add special tab entries for tabs that don't have any parsed settings
// This ensures users can search for tab names like "Modes" or "MCP" and navigate to those tabs
for (const entry of specialTabEntries) {
if (!tabsWithSettings.has(entry.tab)) {
results.push(entry)
}
}
return results
}