From a48981f1447c7ca7deb684ac9ea1228261a9e270 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Sat, 10 Jan 2026 08:57:02 +0000 Subject: [PATCH 1/8] First pass at settings search --- .../settings/AutoApproveSettings.tsx | 12 +- .../components/settings/BrowserSettings.tsx | 8 +- .../settings/CheckpointSettings.tsx | 4 +- .../settings/ContextManagementSettings.tsx | 42 +- .../settings/CustomToolsSettings.tsx | 2 +- .../settings/ExperimentalFeature.tsx | 2 +- .../settings/ImageGenerationSettings.tsx | 2 +- .../components/settings/LanguageSettings.tsx | 34 +- .../settings/NotificationSettings.tsx | 8 +- .../settings/SettingsSearchInput.tsx | 41 ++ .../settings/SettingsSearchResults.tsx | 114 +++++ .../src/components/settings/SettingsView.tsx | 55 ++- .../components/settings/TerminalSettings.tsx | 24 +- .../src/components/settings/UISettings.tsx | 4 +- .../__tests__/SettingsSearchInput.spec.tsx | 210 +++++++++ .../__tests__/SettingsSearchResults.spec.tsx | 436 ++++++++++++++++++ .../SettingsView.change-detection.spec.tsx | 3 + .../SettingsView.unsaved-changes.spec.tsx | 3 + .../hooks/__tests__/useSettingsSearch.spec.ts | 302 ++++++++++++ webview-ui/src/hooks/useSettingsSearch.ts | 90 ++++ webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/index.css | 15 + .../__tests__/parseSettingsI18nKeys.spec.ts | 242 ++++++++++ webview-ui/src/utils/parseSettingsI18nKeys.ts | 282 +++++++++++ 24 files changed, 1868 insertions(+), 71 deletions(-) create mode 100644 webview-ui/src/components/settings/SettingsSearchInput.tsx create mode 100644 webview-ui/src/components/settings/SettingsSearchResults.tsx create mode 100644 webview-ui/src/components/settings/__tests__/SettingsSearchInput.spec.tsx create mode 100644 webview-ui/src/components/settings/__tests__/SettingsSearchResults.spec.tsx create mode 100644 webview-ui/src/hooks/__tests__/useSettingsSearch.spec.ts create mode 100644 webview-ui/src/hooks/useSettingsSearch.ts create mode 100644 webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts create mode 100644 webview-ui/src/utils/parseSettingsI18nKeys.ts diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 335a616a3df..a2e7c31bdb8 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -179,7 +179,7 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.readOnly.label")}
-
+
@@ -203,7 +203,7 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.write.label")}
-
+
@@ -218,7 +218,7 @@ export const AutoApproveSettings = ({ {t("settings:autoApprove.write.outsideWorkspace.description")}
-
+
@@ -240,7 +240,7 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.followupQuestions.label")}
-
+
{t("settings:autoApprove.execute.label")}
-
+
@@ -320,7 +320,7 @@ export const AutoApproveSettings = ({
{/* Denied Commands Section */} -
+
diff --git a/webview-ui/src/components/settings/BrowserSettings.tsx b/webview-ui/src/components/settings/BrowserSettings.tsx index 69cf9cf59e6..742c7fef14e 100644 --- a/webview-ui/src/components/settings/BrowserSettings.tsx +++ b/webview-ui/src/components/settings/BrowserSettings.tsx @@ -116,7 +116,7 @@ export const BrowserSettings = ({
-
+
setCachedStateField("browserToolEnabled", e.target.checked)}> @@ -135,7 +135,7 @@ export const BrowserSettings = ({ {browserToolEnabled && (
-
+
setCachedStateField("language", value as Language)}> - - - - - - {Object.entries(LANGUAGES).map(([code, name]) => ( - - {name} - ({code}) - - ))} - - - +
+ +
) diff --git a/webview-ui/src/components/settings/NotificationSettings.tsx b/webview-ui/src/components/settings/NotificationSettings.tsx index 9610cabad8b..29f4a3dc185 100644 --- a/webview-ui/src/components/settings/NotificationSettings.tsx +++ b/webview-ui/src/components/settings/NotificationSettings.tsx @@ -35,7 +35,7 @@ export const NotificationSettings = ({
-
+
setCachedStateField("ttsEnabled", e.target.checked)} @@ -49,7 +49,7 @@ export const NotificationSettings = ({ {ttsEnabled && (
-
+
@@ -68,7 +68,7 @@ export const NotificationSettings = ({
)} -
+
setCachedStateField("soundEnabled", e.target.checked)} @@ -82,7 +82,7 @@ export const NotificationSettings = ({ {soundEnabled && (
-
+
diff --git a/webview-ui/src/components/settings/SettingsSearchInput.tsx b/webview-ui/src/components/settings/SettingsSearchInput.tsx new file mode 100644 index 00000000000..c53366961fb --- /dev/null +++ b/webview-ui/src/components/settings/SettingsSearchInput.tsx @@ -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 ( +
+ + onChange(e.target.value)} + onFocus={onFocus} + onBlur={onBlur} + placeholder={t("settings:search.placeholder")} + className={cn("pl-8", value && "pr-8")} + /> + {value && ( + + )} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsSearchResults.tsx b/webview-ui/src/components/settings/SettingsSearchResults.tsx new file mode 100644 index 00000000000..f1764319e7e --- /dev/null +++ b/webview-ui/src/components/settings/SettingsSearchResults.tsx @@ -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 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) ? ( + + {part} + + ) : ( + {part} + ), + )} + + ) +} + +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, + ) + }, [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 ( +
+ {t("settings:search.noResults", { query })} +
+ ) + } + + return ( +
+ {Object.entries(groupedResults).map(([tab, tabResults]) => { + const Icon = sectionIconMap.get(tab as SectionName) + + return ( +
+ {/* Tab header */} +
+ {Icon && } + {t(`settings:sections.${tab}`)} +
+ + {/* Result items */} + {tabResults.map((result) => ( + + ))} +
+ ) + })} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 6331f13edf9..5c5584c7dea 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -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(({ onDone, t ? (targetSection as SectionName) : "providers", ) + const [searchQuery, setSearchQuery] = useState("") + const [isSearchFocused, setIsSearchFocused] = useState(false) const scrollPositions = useRef>( Object.fromEntries(sectionNames.map((s) => [s, 0])) as Record, @@ -216,6 +221,9 @@ const SettingsView = forwardRef(({ 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(({ 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 ( -
-

{t("settings:header.title")}

+
+

{t("settings:header.title")}

+
+ setIsSearchFocused(true)} + onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} + /> + {searchQuery && isSearchFocused && ( +
+ +
+ )} +
-
+
-
+
@@ -132,7 +132,7 @@ export const TerminalSettings = ({
-
+
@@ -162,7 +162,7 @@ export const TerminalSettings = ({
-
+
@@ -199,7 +199,7 @@ export const TerminalSettings = ({
-
+
@@ -225,7 +225,7 @@ export const TerminalSettings = ({ {!terminalShellIntegrationDisabled && ( <> -
+
{ @@ -253,7 +253,7 @@ export const TerminalSettings = ({
-
+
@@ -288,7 +288,7 @@ export const TerminalSettings = ({
-
+
@@ -321,7 +321,7 @@ export const TerminalSettings = ({
-
+
@@ -346,7 +346,7 @@ export const TerminalSettings = ({
-
+
@@ -371,7 +371,7 @@ export const TerminalSettings = ({
-
+
setCachedStateField("terminalZshOhMy", e.target.checked)} @@ -392,7 +392,7 @@ export const TerminalSettings = ({
-
+
setCachedStateField("terminalZshP10k", e.target.checked)} @@ -413,7 +413,7 @@ export const TerminalSettings = ({
-
+
setCachedStateField("terminalZdotdir", e.target.checked)} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index b4e5a4e861a..b416869f731 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -60,7 +60,7 @@ export const UISettings = ({
{/* Collapse Thinking Messages Setting */} -
+
handleReasoningBlockCollapsedChange(e.target.checked)} @@ -73,7 +73,7 @@ export const UISettings = ({
{/* Enter Key Behavior Setting */} -
+
handleEnterBehaviorChange(e.target.checked)} diff --git a/webview-ui/src/components/settings/__tests__/SettingsSearchInput.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsSearchInput.spec.tsx new file mode 100644 index 00000000000..c528bce8fc7 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsSearchInput.spec.tsx @@ -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) =>
, + X: ({ className, ...props }: any) =>
, +})) + +describe("SettingsSearchInput", () => { + describe("rendering", () => { + it("should render input with placeholder text", () => { + const onChange = vi.fn() + render() + + 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() + + const searchIcon = screen.getByTestId("search-icon") + expect(searchIcon).toBeInTheDocument() + }) + + it("should render input with correct type", () => { + const onChange = vi.fn() + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + const input = screen.getByTestId("settings-search-input") + expect(input).toHaveValue("test value") + }) + + it("should call onChange when user types", () => { + const onChange = vi.fn() + render() + + 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() + + const input = screen.getByTestId("settings-search-input") + expect(input).toHaveValue("initial") + + rerender() + 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() + + 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() + + 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() + + const input = screen.getByTestId("settings-search-input") + expect(() => fireEvent.focus(input)).not.toThrow() + }) + + it("should work without onBlur callback", () => { + const onChange = vi.fn() + render() + + 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() + + 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() + + // 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() + + 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) + }) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsSearchResults.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsSearchResults.spec.tsx new file mode 100644 index 00000000000..4e658ad072a --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsSearchResults.spec.tsx @@ -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) => { + const translations: Record = { + "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 }) => ( +
+)) 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + 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( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + 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( + , + ) + + // 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( + , + ) + + 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( + , + ) + + // Check for 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( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + 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( + , + ) + + // 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) + }) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 494070a1350..7a21a858ddc 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -42,6 +42,9 @@ vi.mock("@src/components/ui", () => ({ ), StandardTooltip: ({ children }: any) => <>{children}, + Input: React.forwardRef(({ className, ...props }, ref) => ( + + )), })) // Mock Tab components diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index b40e908f642..35f2edd8652 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -47,6 +47,9 @@ vi.mock("@src/components/ui", () => ({ TooltipProvider: ({ children }: any) => <>{children}, TooltipTrigger: ({ children }: any) => <>{children}, StandardTooltip: ({ children, content }: any) =>
{children}
, + Input: React.forwardRef(({ className, ...props }, ref) => ( + + )), })) // Mock Tab components diff --git a/webview-ui/src/hooks/__tests__/useSettingsSearch.spec.ts b/webview-ui/src/hooks/__tests__/useSettingsSearch.spec.ts new file mode 100644 index 00000000000..d1f0a1709af --- /dev/null +++ b/webview-ui/src/hooks/__tests__/useSettingsSearch.spec.ts @@ -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 = { + "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([]) + }) + }) +}) diff --git a/webview-ui/src/hooks/useSettingsSearch.ts b/webview-ui/src/hooks/useSettingsSearch.ts new file mode 100644 index 00000000000..3ae82b6db57 --- /dev/null +++ b/webview-ui/src/hooks/useSettingsSearch.ts @@ -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]) +} diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index e13a97af479..d136475e8aa 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1,4 +1,8 @@ { + "search": { + "placeholder": "Search settings...", + "noResults": "No results found for \"{{query}}\"" + }, "common": { "save": "Save", "done": "Done", diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index b696261f91f..6d55c1a882d 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -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; + } +} diff --git a/webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts b/webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts new file mode 100644 index 00000000000..3843b601a57 --- /dev/null +++ b/webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts @@ -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) + }) + }) +}) diff --git a/webview-ui/src/utils/parseSettingsI18nKeys.ts b/webview-ui/src/utils/parseSettingsI18nKeys.ts new file mode 100644 index 00000000000..8cfd8684a1a --- /dev/null +++ b/webview-ui/src/utils/parseSettingsI18nKeys.ts @@ -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 = { + // 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(sectionNames) + +/** + * Checks if a value is a plain object (not null, not array). + */ +function isPlainObject(value: unknown): value is Record { + 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): 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, + 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, + 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 +} From 461494a3cc05972688477c800c032c65e8be79ad Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Sat, 10 Jan 2026 09:19:01 +0000 Subject: [PATCH 2/8] Settings header restyling --- .../settings/SettingsSearchInput.tsx | 13 +++-- .../settings/SettingsSearchResults.tsx | 2 +- .../src/components/settings/SettingsView.tsx | 49 ++++++++++--------- webview-ui/src/components/ui/input.tsx | 2 +- webview-ui/src/i18n/locales/en/settings.json | 2 +- 5 files changed, 36 insertions(+), 32 deletions(-) diff --git a/webview-ui/src/components/settings/SettingsSearchInput.tsx b/webview-ui/src/components/settings/SettingsSearchInput.tsx index c53366961fb..c2b19060698 100644 --- a/webview-ui/src/components/settings/SettingsSearchInput.tsx +++ b/webview-ui/src/components/settings/SettingsSearchInput.tsx @@ -15,8 +15,8 @@ export function SettingsSearchInput({ value, onChange, onFocus, onBlur }: Settin const { t } = useAppTranslation() return ( -
- +
+ {value && ( )}
diff --git a/webview-ui/src/components/settings/SettingsSearchResults.tsx b/webview-ui/src/components/settings/SettingsSearchResults.tsx index f1764319e7e..c287335b551 100644 --- a/webview-ui/src/components/settings/SettingsSearchResults.tsx +++ b/webview-ui/src/components/settings/SettingsSearchResults.tsx @@ -33,7 +33,7 @@ function HighlightMatch({ text, query }: HighlightMatchProps) { <> {parts.map((part, index) => regex.test(part) ? ( - + {part} ) : ( diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5c5584c7dea..c7f9e7ad7a4 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -28,6 +28,7 @@ import { Plug, Server, Users2, + ArrowLeft, } from "lucide-react" import { @@ -599,26 +600,31 @@ const SettingsView = forwardRef(({ onDone, t return ( -
+
+ + +

{t("settings:header.title")}

-
- setIsSearchFocused(true)} - onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} - /> - {searchQuery && isSearchFocused && ( -
- -
- )} -
+
+
+ setIsSearchFocused(true)} + onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} + /> + {searchQuery && isSearchFocused && ( +
+ +
+ )}
(({ onDone, t {t("settings:common.save")} - - -
diff --git a/webview-ui/src/components/ui/input.tsx b/webview-ui/src/components/ui/input.tsx index 77bea85dad6..169871dfa5d 100644 --- a/webview-ui/src/components/ui/input.tsx +++ b/webview-ui/src/components/ui/input.tsx @@ -8,7 +8,7 @@ const Input = React.forwardRef>( Date: Sat, 10 Jan 2026 09:44:11 +0000 Subject: [PATCH 3/8] UI improvements --- .../settings/SettingsSearchInput.tsx | 8 +- .../settings/SettingsSearchResults.tsx | 59 +++-- .../src/components/settings/SettingsView.tsx | 76 +++++- .../__tests__/SettingsView.search.spec.tsx | 245 ++++++++++++++++++ 4 files changed, 363 insertions(+), 25 deletions(-) create mode 100644 webview-ui/src/components/settings/__tests__/SettingsView.search.spec.tsx diff --git a/webview-ui/src/components/settings/SettingsSearchInput.tsx b/webview-ui/src/components/settings/SettingsSearchInput.tsx index c2b19060698..c0b7165470b 100644 --- a/webview-ui/src/components/settings/SettingsSearchInput.tsx +++ b/webview-ui/src/components/settings/SettingsSearchInput.tsx @@ -9,9 +9,10 @@ export interface SettingsSearchInputProps { onChange: (value: string) => void onFocus?: () => void onBlur?: () => void + onKeyDown?: React.KeyboardEventHandler } -export function SettingsSearchInput({ value, onChange, onFocus, onBlur }: SettingsSearchInputProps) { +export function SettingsSearchInput({ value, onChange, onFocus, onBlur, onKeyDown }: SettingsSearchInputProps) { const { t } = useAppTranslation() return ( @@ -24,10 +25,11 @@ export function SettingsSearchInput({ value, onChange, onFocus, onBlur }: Settin onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} + onKeyDown={onKeyDown} placeholder={t("settings:search.placeholder")} className={cn( - "pl-6 w-[0px] focus:pl-8 focus:min-w-[130px] focus:w-full active:w-full", - value && "pr-8", + "pl-6 w-[0px] border-none focus:border-vscode-input-border focus:pl-8 focus:min-w-[130px] focus:w-full", + value && "pr-4 min-w-[150px]", )} /> {value && ( diff --git a/webview-ui/src/components/settings/SettingsSearchResults.tsx b/webview-ui/src/components/settings/SettingsSearchResults.tsx index c287335b551..66e041787fc 100644 --- a/webview-ui/src/components/settings/SettingsSearchResults.tsx +++ b/webview-ui/src/components/settings/SettingsSearchResults.tsx @@ -4,12 +4,14 @@ import type { LucideIcon } from "lucide-react" import { useAppTranslation } from "@/i18n/TranslationContext" import type { SearchResult } from "@/hooks/useSettingsSearch" import type { SectionName } from "@/utils/parseSettingsI18nKeys" +import { cn } from "@/lib/utils" export interface SettingsSearchResultsProps { results: SearchResult[] query: string onSelectResult: (result: SearchResult) => void sections: { id: SectionName; icon: LucideIcon }[] + highlightedResultId?: string } interface HighlightMatchProps { @@ -44,7 +46,13 @@ function HighlightMatch({ text, query }: HighlightMatchProps) { ) } -export function SettingsSearchResults({ results, query, onSelectResult, sections }: SettingsSearchResultsProps) { +export function SettingsSearchResults({ + results, + query, + onSelectResult, + sections, + highlightedResultId, +}: SettingsSearchResultsProps) { const { t } = useAppTranslation() // Group results by tab @@ -70,42 +78,55 @@ export function SettingsSearchResults({ results, query, onSelectResult, sections // If no results, show a message if (results.length === 0) { return ( -
+
{t("settings:search.noResults", { query })}
) } return ( -
+
{Object.entries(groupedResults).map(([tab, tabResults]) => { const Icon = sectionIconMap.get(tab as SectionName) return (
{/* Tab header */} -
+
{Icon && } {t(`settings:sections.${tab}`)}
{/* Result items */} - {tabResults.map((result) => ( - - ))} + {result.translatedDescription && ( +
+ +
+ )} + + ) + })}
) })} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index c7f9e7ad7a4..1f9b7d32b43 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -136,6 +136,7 @@ const SettingsView = forwardRef(({ onDone, t ) const [searchQuery, setSearchQuery] = useState("") const [isSearchFocused, setIsSearchFocused] = useState(false) + const [highlightedResultId, setHighlightedResultId] = useState(undefined) const scrollPositions = useRef>( Object.fromEntries(sectionNames.map((s) => [s, 0])) as Record, @@ -590,6 +591,7 @@ const SettingsView = forwardRef(({ onDone, t (result: SearchResult) => { setSearchQuery("") setIsSearchFocused(false) + setHighlightedResultId(undefined) handleTabChange(result.tab) // Small delay to allow tab switch and render setTimeout(() => scrollToSetting(result.id), 150) @@ -597,10 +599,76 @@ const SettingsView = forwardRef(({ onDone, t [handleTabChange, scrollToSetting], ) + // Keyboard navigation inside search results + const moveHighlight = useCallback( + (direction: 1 | -1) => { + if (!searchResults.length) return + const flatIds = searchResults.map((r) => r.id) + const currentIndex = highlightedResultId ? flatIds.indexOf(highlightedResultId) : -1 + const nextIndex = (currentIndex + direction + flatIds.length) % flatIds.length + setHighlightedResultId(flatIds[nextIndex]) + }, + [highlightedResultId, searchResults], + ) + + const handleSearchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!searchResults.length) return + + if (event.key === "ArrowDown") { + event.preventDefault() + moveHighlight(1) + return + } + + if (event.key === "ArrowUp") { + event.preventDefault() + moveHighlight(-1) + return + } + + if (event.key === "Enter" && highlightedResultId) { + event.preventDefault() + const selected = searchResults.find((r) => r.id === highlightedResultId) + if (selected) { + handleSelectResult(selected) + } + return + } + + if (event.key === "Escape") { + setIsSearchFocused(false) + setHighlightedResultId(undefined) + return + } + }, + [handleSelectResult, highlightedResultId, moveHighlight, searchResults], + ) + + // Reset highlight based on focus and available results + useEffect(() => { + if (!isSearchFocused || !searchResults.length) { + setHighlightedResultId(undefined) + return + } + + setHighlightedResultId((current) => + current && searchResults.some((r) => r.id === current) ? current : searchResults[0]?.id, + ) + }, [isSearchFocused, searchResults]) + + // Ensure highlighted search result stays visible within dropdown + useEffect(() => { + if (!highlightedResultId || !isSearchFocused) return + + const element = document.getElementById(`settings-search-result-${highlightedResultId}`) + element?.scrollIntoView({ block: "nearest" }) + }, [highlightedResultId, isSearchFocused]) + return ( -
+
+ ), + StandardTooltip: ({ children }: any) => <>{children}, + Input: ({ value, onChange, onFocus, onBlur, onKeyDown, "data-testid": dataTestId }: any) => ( + + ), + AlertDialog: ({ children }: any) =>
{children}
, + AlertDialogContent: ({ children }: any) =>
{children}
, + AlertDialogTitle: ({ children }: any) =>
{children}
, + AlertDialogDescription: ({ children }: any) =>
{children}
, + AlertDialogCancel: ({ children, onClick }: any) => , + AlertDialogAction: ({ children, onClick }: any) => , + AlertDialogHeader: ({ children }: any) =>
{children}
, + AlertDialogFooter: ({ children }: any) =>
{children}
, + Tooltip: ({ children }: any) => <>{children}, + TooltipContent: ({ children }: any) =>
{children}
, + TooltipProvider: ({ children }: any) => <>{children}, + TooltipTrigger: ({ children, onClick }: any) =>
{children}
, +})) + +vi.mock("../../common/Tab", () => ({ + Tab: ({ children }: any) =>
{children}
, + TabHeader: ({ children }: any) =>
{children}
, + TabContent: ({ children }: any) =>
{children}
, + TabList: ({ children }: any) =>
{children}
, + TabTrigger: ({ children, onClick }: any) => , +})) + +vi.mock("../ApiConfigManager", () => ({ + __esModule: true, + default: () =>
ApiConfigManager
, +})) + +vi.mock("../ApiOptions", () => ({ + __esModule: true, + default: () =>
ApiOptions
, +})) + +// Mock all settings subsections to inert components +vi.mock("../AutoApproveSettings", () => ({ AutoApproveSettings: () =>
AutoApproveSettings
})) +vi.mock("../BrowserSettings", () => ({ BrowserSettings: () =>
BrowserSettings
})) +vi.mock("../CheckpointSettings", () => ({ CheckpointSettings: () =>
CheckpointSettings
})) +vi.mock("../NotificationSettings", () => ({ NotificationSettings: () =>
NotificationSettings
})) +vi.mock("../ContextManagementSettings", () => ({ + ContextManagementSettings: () =>
ContextManagementSettings
, +})) +vi.mock("../TerminalSettings", () => ({ TerminalSettings: () =>
TerminalSettings
})) +vi.mock("../ExperimentalSettings", () => ({ ExperimentalSettings: () =>
ExperimentalSettings
})) +vi.mock("../LanguageSettings", () => ({ LanguageSettings: () =>
LanguageSettings
})) +vi.mock("../About", () => ({ About: () =>
About
})) +vi.mock("../PromptsSettings", () => ({ __esModule: true, default: () =>
PromptsSettings
})) +vi.mock("../SlashCommandsSettings", () => ({ SlashCommandsSettings: () =>
SlashCommandsSettings
})) +vi.mock("../UISettings", () => ({ UISettings: () =>
UISettings
})) + +describe("SettingsView search interactions", () => { + beforeEach(() => { + ;(global as any).ResizeObserver = (global as any).ResizeObserver || ResizeObserverPolyfill + mockUseExtensionState.mockReturnValue(defaultExtensionState) + mockUseSettingsSearch.mockImplementation(() => mockSearchResults) + }) + + it("allows clicking a search result without closing before selection", async () => { + render() + + const input = screen.getByTestId("settings-search-input") as HTMLInputElement + fireEvent.focus(input) + fireEvent.change(input, { target: { value: "browser" } }) + + const listbox = await screen.findByRole("listbox") + expect(listbox).toBeInTheDocument() + + const options = screen.getAllByRole("option") + fireEvent.mouseDown(options[0]) + fireEvent.click(options[0]) + + expect(input.value).toBe("") + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + }) + + it("supports keyboard navigation and enter selection from search input", async () => { + render() + + const input = screen.getByTestId("settings-search-input") as HTMLInputElement + fireEvent.focus(input) + fireEvent.change(input, { target: { value: "browser" } }) + + await screen.findByRole("listbox") + let options = screen.getAllByRole("option") + expect(options[0]).toHaveAttribute("aria-selected", "true") + + fireEvent.keyDown(input, { key: "ArrowDown" }) + options = screen.getAllByRole("option") + expect(options[1]).toHaveAttribute("aria-selected", "true") + + fireEvent.keyDown(input, { key: "Enter" }) + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + expect(input.value).toBe("") + }) +}) From d34f136590a445a091ca901b1293e3f7a2ade190 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Sat, 10 Jan 2026 09:54:47 +0000 Subject: [PATCH 4/8] Fix --- .../settings/SettingsSearchInput.tsx | 16 ++++++-- .../src/components/settings/SettingsView.tsx | 6 ++- .../__tests__/SettingsView.search.spec.tsx | 40 ++++++++++++++----- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/webview-ui/src/components/settings/SettingsSearchInput.tsx b/webview-ui/src/components/settings/SettingsSearchInput.tsx index c0b7165470b..5f27128612a 100644 --- a/webview-ui/src/components/settings/SettingsSearchInput.tsx +++ b/webview-ui/src/components/settings/SettingsSearchInput.tsx @@ -1,3 +1,4 @@ +import { type RefObject } from "react" import { Search, X } from "lucide-react" import { cn } from "@/lib/utils" @@ -10,15 +11,24 @@ export interface SettingsSearchInputProps { onFocus?: () => void onBlur?: () => void onKeyDown?: React.KeyboardEventHandler + inputRef?: RefObject } -export function SettingsSearchInput({ value, onChange, onFocus, onBlur, onKeyDown }: SettingsSearchInputProps) { +export function SettingsSearchInput({ + value, + onChange, + onFocus, + onBlur, + onKeyDown, + inputRef, +}: SettingsSearchInputProps) { const { t } = useAppTranslation() return (
{value && ( diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 1f9b7d32b43..ded37de7566 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -136,6 +136,7 @@ const SettingsView = forwardRef(({ onDone, t ) const [searchQuery, setSearchQuery] = useState("") const [isSearchFocused, setIsSearchFocused] = useState(false) + const searchInputRef = useRef(null) const [highlightedResultId, setHighlightedResultId] = useState(undefined) const scrollPositions = useRef>( @@ -590,9 +591,11 @@ const SettingsView = forwardRef(({ onDone, t const handleSelectResult = useCallback( (result: SearchResult) => { setSearchQuery("") - setIsSearchFocused(false) setHighlightedResultId(undefined) handleTabChange(result.tab) + // Keep focus in the input so dropdown remains open for follow-up search + setIsSearchFocused(true) + requestAnimationFrame(() => searchInputRef.current?.focus()) // Small delay to allow tab switch and render setTimeout(() => scrollToSetting(result.id), 150) }, @@ -683,6 +686,7 @@ const SettingsView = forwardRef(({ onDone, t onFocus={() => setIsSearchFocused(true)} onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} onKeyDown={handleSearchKeyDown} + inputRef={searchInputRef} /> {searchQuery && isSearchFocused && (
diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.search.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.search.spec.tsx index 70cdac05c66..d2a7156f96a 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.search.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.search.spec.tsx @@ -1,3 +1,4 @@ +import { forwardRef } from "react" import { fireEvent, render, screen } from "@testing-library/react" import { vi, describe, it, beforeEach } from "vitest" @@ -140,15 +141,18 @@ vi.mock("@/components/ui", () => ({ ), StandardTooltip: ({ children }: any) => <>{children}, - Input: ({ value, onChange, onFocus, onBlur, onKeyDown, "data-testid": dataTestId }: any) => ( - + Input: forwardRef( + ({ value, onChange, onFocus, onBlur, onKeyDown, "data-testid": dataTestId }, ref) => ( + + ), ), AlertDialog: ({ children }: any) =>
{children}
, AlertDialogContent: ({ children }: any) =>
{children}
, @@ -242,4 +246,22 @@ describe("SettingsView search interactions", () => { expect(screen.queryByRole("listbox")).not.toBeInTheDocument() expect(input.value).toBe("") }) + + it("keeps input focused after selecting a result to allow immediate follow-up search", async () => { + render() + + const input = screen.getByTestId("settings-search-input") as HTMLInputElement + fireEvent.focus(input) + fireEvent.change(input, { target: { value: "browser" } }) + + await screen.findByRole("listbox") + const options = screen.getAllByRole("option") + fireEvent.mouseDown(options[0]) + fireEvent.click(options[0]) + + // Second search still produces results + fireEvent.change(input, { target: { value: "browser" } }) + const secondListbox = await screen.findByRole("listbox") + expect(secondListbox).toBeInTheDocument() + }) }) From 99b1f9eacd10fc9b319e94a4beb64773786c8161 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Sat, 10 Jan 2026 10:01:14 +0000 Subject: [PATCH 5/8] Back arrow headers elsewhere --- .../src/components/history/HistoryView.tsx | 42 +++++++++++-------- .../history/__tests__/HistoryView.spec.tsx | 6 +-- .../marketplace/MarketplaceView.tsx | 25 ++++++----- .../settings/SettingsSearchInput.tsx | 4 +- 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 21b083a7b99..6a8a7686d62 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -1,4 +1,5 @@ import React, { memo, useState } from "react" +import { ArrowLeft } from "lucide-react" import { DeleteTaskDialog } from "./DeleteTaskDialog" import { BatchDeleteTaskDialog } from "./BatchDeleteTaskDialog" import { Virtuoso } from "react-virtuoso" @@ -81,27 +82,34 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { return ( -
-

{t("history:history")}

-
- +
+
+ - +

{t("history:history")}

+ + +
{ // Check for main UI elements expect(screen.getByText("history:history")).toBeInTheDocument() - expect(screen.getByText("history:done")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "history:done" })).toBeInTheDocument() expect(screen.getByPlaceholderText("history:searchPlaceholder")).toBeInTheDocument() }) - it("calls onDone when done button is clicked", () => { + it("calls onDone when back button is clicked", () => { const onDone = vi.fn() render() - const doneButton = screen.getByText("history:done") + const doneButton = screen.getByRole("button", { name: "history:done" }) fireEvent.click(doneButton) expect(onDone).toHaveBeenCalled() diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx index 0d66be48afd..2aff6fc1e34 100644 --- a/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useMemo, useContext } from "react" import { Button } from "@/components/ui/button" +import { StandardTooltip } from "@/components/ui" +import { ArrowLeft } from "lucide-react" import { Tab, TabContent, TabHeader } from "../common/Tab" import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" import { useStateManager } from "./useStateManager" @@ -99,16 +101,19 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace -
-

{t("marketplace:title")}

-
- +
+
+ + + +

{t("marketplace:title")}

diff --git a/webview-ui/src/components/settings/SettingsSearchInput.tsx b/webview-ui/src/components/settings/SettingsSearchInput.tsx index 5f27128612a..ed30c362a8a 100644 --- a/webview-ui/src/components/settings/SettingsSearchInput.tsx +++ b/webview-ui/src/components/settings/SettingsSearchInput.tsx @@ -38,8 +38,8 @@ export function SettingsSearchInput({ onKeyDown={onKeyDown} placeholder={t("settings:search.placeholder")} className={cn( - "pl-6 w-[0px] border-none focus:border-vscode-input-border focus:pl-8 focus:min-w-[132px] focus:w-full", - value && "pr-4 min-w-[148px]", + "pl-6 w-[0px] border-none focus:border-vscode-input-border focus:pl-8 focus:min-w-[110px] focus:w-full", + value && "pr-4 pl-8 min-w-[108px]", )} /> {value && ( From 60cb90230d8d91e14985613da128ce87f3a6c7c2 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Sat, 10 Jan 2026 10:38:33 +0000 Subject: [PATCH 6/8] headers and i18n --- src/i18n/locales/en/marketplace.json | 1 - .../src/components/history/HistoryView.tsx | 17 +++++++---------- .../marketplace/MarketplaceView.tsx | 19 ++++++++----------- .../settings/SettingsSearchInput.tsx | 8 ++++---- .../src/components/settings/SettingsView.tsx | 2 +- webview-ui/src/i18n/locales/ca/settings.json | 5 +++++ webview-ui/src/i18n/locales/de/settings.json | 5 +++++ webview-ui/src/i18n/locales/en/history.json | 1 - webview-ui/src/i18n/locales/en/settings.json | 4 ++-- webview-ui/src/i18n/locales/es/settings.json | 5 +++++ webview-ui/src/i18n/locales/fr/settings.json | 5 +++++ webview-ui/src/i18n/locales/hi/settings.json | 5 +++++ webview-ui/src/i18n/locales/id/settings.json | 5 +++++ webview-ui/src/i18n/locales/it/settings.json | 5 +++++ webview-ui/src/i18n/locales/ja/settings.json | 5 +++++ webview-ui/src/i18n/locales/ko/settings.json | 5 +++++ webview-ui/src/i18n/locales/nl/settings.json | 5 +++++ webview-ui/src/i18n/locales/pl/settings.json | 5 +++++ .../src/i18n/locales/pt-BR/settings.json | 5 +++++ webview-ui/src/i18n/locales/ru/settings.json | 5 +++++ webview-ui/src/i18n/locales/tr/settings.json | 5 +++++ webview-ui/src/i18n/locales/vi/settings.json | 5 +++++ .../src/i18n/locales/zh-CN/settings.json | 5 +++++ .../src/i18n/locales/zh-TW/settings.json | 5 +++++ 24 files changed, 107 insertions(+), 30 deletions(-) diff --git a/src/i18n/locales/en/marketplace.json b/src/i18n/locales/en/marketplace.json index 17ae20078a4..a8ffcaa8f6e 100644 --- a/src/i18n/locales/en/marketplace.json +++ b/src/i18n/locales/en/marketplace.json @@ -46,7 +46,6 @@ }, "title": "Marketplace" }, - "done": "Done", "tabs": { "installed": "Installed", "browse": "Browse", diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 6a8a7686d62..d08041eda01 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -84,16 +84,13 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
- - - +

{t("history:history")}

- - - +

{t("marketplace:title")}

diff --git a/webview-ui/src/components/settings/SettingsSearchInput.tsx b/webview-ui/src/components/settings/SettingsSearchInput.tsx index ed30c362a8a..8ed20fb4975 100644 --- a/webview-ui/src/components/settings/SettingsSearchInput.tsx +++ b/webview-ui/src/components/settings/SettingsSearchInput.tsx @@ -38,17 +38,17 @@ export function SettingsSearchInput({ onKeyDown={onKeyDown} placeholder={t("settings:search.placeholder")} className={cn( - "pl-6 w-[0px] border-none focus:border-vscode-input-border focus:pl-8 focus:min-w-[110px] focus:w-full", - value && "pr-4 pl-8 min-w-[108px]", + "pl-6 focus:pl-8 w-[0px] border-none focus:border-vscode-input-border focus:min-w-[90px] focus:w-full", + value && "pl-8 pr-8 min-w-[50px] w-full", )} /> {value && ( )}
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index ded37de7566..a47443757d4 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -671,7 +671,7 @@ const SettingsView = forwardRef(({ onDone, t return ( -
+

{t("history:history")}

diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx index 312736a8df8..1e5e60878e4 100644 --- a/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -128,12 +128,12 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace />