mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
First pass at settings search
This commit is contained in:
parent
907b94bc40
commit
a48981f144
24 changed files with 1868 additions and 71 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
41
webview-ui/src/components/settings/SettingsSearchInput.tsx
Normal file
41
webview-ui/src/components/settings/SettingsSearchInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
webview-ui/src/components/settings/SettingsSearchResults.tsx
Normal file
114
webview-ui/src/components/settings/SettingsSearchResults.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
302
webview-ui/src/hooks/__tests__/useSettingsSearch.spec.ts
Normal file
302
webview-ui/src/hooks/__tests__/useSettingsSearch.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
90
webview-ui/src/hooks/useSettingsSearch.ts
Normal file
90
webview-ui/src/hooks/useSettingsSearch.ts
Normal 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])
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
{
|
||||
"search": {
|
||||
"placeholder": "Search settings...",
|
||||
"noResults": "No results found for \"{{query}}\""
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"done": "Done",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
242
webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts
Normal file
242
webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
282
webview-ui/src/utils/parseSettingsI18nKeys.ts
Normal file
282
webview-ui/src/utils/parseSettingsI18nKeys.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue