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/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 490d0f2d683..1bb873db076 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -35,7 +35,7 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => { hideAnnouncement() } }}> - + {t("chat:announcement.title", { version: Package.version })} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 21b083a7b99..d8ee4315938 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,33 @@ 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..1e5e60878e4 100644 --- a/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useContext } from "react" import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" import { Tab, TabContent, TabHeader } from "../common/Tab" import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" import { useStateManager } from "./useStateManager" @@ -99,16 +100,17 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace -
-

{t("marketplace:title")}

-
+
+
+

{t("marketplace:title")}

@@ -126,12 +128,12 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace />
+ )} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsSearchResults.tsx b/webview-ui/src/components/settings/SettingsSearchResults.tsx new file mode 100644 index 00000000000..22ba46c299b --- /dev/null +++ b/webview-ui/src/components/settings/SettingsSearchResults.tsx @@ -0,0 +1,135 @@ +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" +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 { + 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, + highlightedResultId, +}: 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) => { + const isHighlighted = highlightedResultId === result.id + const resultDomId = `settings-search-result-${result.id}` + + return ( + + ) + })} +
+ ) + })} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 6331f13edf9..9e26b4c7860 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -12,7 +12,6 @@ import React, { import { CheckCheck, SquareMousePointer, - Webhook, GitBranch, Bell, Database, @@ -28,6 +27,7 @@ import { Plug, Server, Users2, + ArrowLeft, } from "lucide-react" import { @@ -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,10 @@ const SettingsView = forwardRef(({ onDone, t ? (targetSection as SectionName) : "providers", ) + const [searchQuery, setSearchQuery] = useState("") + const [isSearchFocused, setIsSearchFocused] = useState(false) + const searchInputRef = useRef(null) + const [highlightedResultId, setHighlightedResultId] = useState(undefined) const scrollPositions = useRef>( Object.fromEntries(sectionNames.map((s) => [s, 0])) as Record, @@ -216,6 +223,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 +575,132 @@ 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("") + 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) + }, + [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 ( -
-

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

+
+ + + +

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

+
+
+ setIsSearchFocused(true)} + onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} + onKeyDown={handleSearchKeyDown} + inputRef={searchInputRef} + /> + {searchQuery && isSearchFocused && ( +
+ +
+ )}
-
+
(({ onDone, t {t("settings:common.save")} - - -
@@ -660,12 +784,7 @@ const SettingsView = forwardRef(({ onDone, t {/* Providers Section */} {activeTab === "providers" && (
- -
- -
{t("settings:sections.providers")}
-
-
+ {t("settings:sections.providers")}
{ return (
- -
- -
{t("settings:sections.slashCommands")}
-
-
+ {t("settings:sections.slashCommands")}
{/* Description section */} diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index c647344c088..ca55ef2c444 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -1,7 +1,6 @@ import { HTMLAttributes, useState, useCallback } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { vscode } from "@/utils/vscode" -import { SquareTerminal } from "lucide-react" import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" import { buildDocLink } from "@src/utils/docLinks" @@ -87,12 +86,7 @@ export const TerminalSettings = ({ return (
- -
- -
{t("settings:sections.terminal")}
-
-
+ {t("settings:sections.terminal")}
{/* Basic Settings */} @@ -104,7 +98,7 @@ export const TerminalSettings = ({
-
+
@@ -132,7 +126,7 @@ export const TerminalSettings = ({
-
+
@@ -162,7 +156,7 @@ export const TerminalSettings = ({
-
+
@@ -199,7 +193,7 @@ export const TerminalSettings = ({
-
+
@@ -225,7 +219,7 @@ export const TerminalSettings = ({ {!terminalShellIntegrationDisabled && ( <> -
+
{ @@ -253,7 +247,7 @@ export const TerminalSettings = ({
-
+
@@ -288,7 +282,7 @@ export const TerminalSettings = ({
-
+
@@ -321,7 +315,7 @@ export const TerminalSettings = ({
-
+
@@ -346,7 +340,7 @@ export const TerminalSettings = ({
-
+
@@ -371,7 +365,7 @@ export const TerminalSettings = ({
-
+
setCachedStateField("terminalZshOhMy", e.target.checked)} @@ -392,7 +386,7 @@ export const TerminalSettings = ({
-
+
setCachedStateField("terminalZshP10k", e.target.checked)} @@ -413,7 +407,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..3e69850a8ed 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -1,7 +1,6 @@ import { HTMLAttributes, useMemo } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" -import { Glasses } from "lucide-react" import { telemetryClient } from "@/utils/TelemetryClient" import { SetCachedStateField } from "./types" @@ -50,17 +49,12 @@ export const UISettings = ({ return (
- -
- -
{t("settings:sections.ui")}
-
-
+ {t("settings:sections.ui")}
{/* Collapse Thinking Messages Setting */} -
+
handleReasoningBlockCollapsedChange(e.target.checked)} @@ -73,7 +67,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..2e1756e0ea5 --- /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 options = screen.queryAllByRole("option") + expect(options).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 option exists + const checkpointButton = screen.getByRole("option") + 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 listbox options", () => { + const onSelectResult = vi.fn() + render( + , + ) + + const options = screen.getAllByRole("option") + expect(options.length).toBe(mockBrowserResults.length) + }) + }) + + describe("clicking results", () => { + it("should call onSelectResult with the result when clicked", () => { + const onSelectResult = vi.fn() + render( + , + ) + + // Click the first option (the result item itself is a button with role option) + const options = screen.getAllByRole("option") + fireEvent.click(options[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 options = screen.getAllByRole("option") + + // Click first result + fireEvent.click(options[0]) + expect(onSelectResult).toHaveBeenLastCalledWith(mockBrowserResults[0]) + + // Click second result + fireEvent.click(options[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 options = screen.getAllByRole("option") + expect(options).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.search.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.search.spec.tsx new file mode 100644 index 00000000000..d2a7156f96a --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsView.search.spec.tsx @@ -0,0 +1,267 @@ +import { forwardRef } from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import { vi, describe, it, beforeEach } from "vitest" + +import SettingsView from "../SettingsView" +import type { SearchResult } from "@/hooks/useSettingsSearch" +import type { SectionName } from "@/utils/parseSettingsI18nKeys" + +const mockUseExtensionState = vi.fn() +const mockUseSettingsSearch = vi.fn<(query: string) => SearchResult[]>() + +// Minimal ResizeObserver polyfill for jsdom +class ResizeObserverPolyfill { + callback: ResizeObserverCallback + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe() { + // no-op + } + disconnect() { + // no-op + } +} + +const defaultExtensionState = { + currentApiConfigName: "default", + listApiConfigMeta: [], + uriScheme: "vscode", + settingsImportedAt: undefined as number | undefined, + apiConfiguration: {}, + alwaysAllowReadOnly: false, + alwaysAllowReadOnlyOutsideWorkspace: false, + allowedCommands: [] as string[], + deniedCommands: [] as string[], + allowedMaxRequests: undefined as number | undefined, + allowedMaxCost: undefined as number | undefined, + language: "en", + alwaysAllowBrowser: false, + alwaysAllowExecute: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + autoCondenseContext: false, + autoCondenseContextPercent: 50, + browserToolEnabled: true, + browserViewportSize: "900x600", + enableCheckpoints: false, + checkpointTimeout: 15, + diffEnabled: true, + experiments: {}, + fuzzyMatchThreshold: 1, + maxOpenTabsContext: 20, + maxWorkspaceFiles: 200, + mcpEnabled: false, + remoteBrowserHost: "", + screenshotQuality: 75, + soundEnabled: false, + ttsEnabled: false, + ttsSpeed: 1, + soundVolume: 0.5, + telemetrySetting: "unset" as const, + terminalOutputLineLimit: 500, + terminalOutputCharacterLimit: 50000, + terminalShellIntegrationTimeout: 3000, + terminalShellIntegrationDisabled: false, + terminalCommandDelay: 0, + terminalPowershellCounter: false, + terminalZshClearEolMark: false, + terminalZshOhMy: false, + terminalZshP10k: false, + terminalZdotdir: false, + writeDelayMs: 0, + showRooIgnoredFiles: true, + enableSubfolderRules: false, + remoteBrowserEnabled: false, + maxReadFileLine: -1, + maxImageFileSize: 5, + maxTotalImageSize: 20, + terminalCompressProgressBar: false, + maxConcurrentFileReads: 5, + condensingApiConfigId: "", + customCondensingPrompt: "", + customSupportPrompts: {}, + profileThresholds: {}, + alwaysAllowFollowupQuestions: false, + followupAutoApproveTimeoutMs: undefined as number | undefined, + includeDiagnosticMessages: true, + maxDiagnosticMessages: 50, + includeTaskHistoryInEnhance: true, + imageGenerationProvider: "openrouter", + openRouterImageApiKey: "", + openRouterImageGenerationSelectedModel: "", + reasoningBlockCollapsed: true, + enterBehavior: "send" as const, + includeCurrentTime: true, + includeCurrentCost: true, + maxGitStatusFiles: 0, +} + +const mockSearchResults: SearchResult[] = [ + { + id: "browser.enable", + tab: "browser" as SectionName, + labelKey: "settings:browser.enable.label", + descriptionKey: "settings:browser.enable.description", + translatedLabel: "Enable browser tool", + translatedDescription: "Allows Roo to use a browser", + matchScore: 10, + }, + { + id: "browser.viewport", + tab: "browser" as SectionName, + labelKey: "settings:browser.viewport.label", + descriptionKey: "settings:browser.viewport.description", + translatedLabel: "Browser viewport", + translatedDescription: "Configure viewport size", + matchScore: 9, + }, +] + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => mockUseExtensionState(), +})) + +vi.mock("@/hooks/useSettingsSearch", () => ({ + useSettingsSearch: (query: string) => mockUseSettingsSearch(query), +})) + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@/components/ui", () => ({ + Button: ({ children, onClick, disabled, "data-testid": dataTestId }: any) => ( + + ), + StandardTooltip: ({ children }: any) => <>{children}, + Input: forwardRef( + ({ value, onChange, onFocus, onBlur, onKeyDown, "data-testid": dataTestId }, ref) => ( + + ), + ), + 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("") + }) + + 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() + }) +}) 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/components/ui/alert-dialog.tsx b/webview-ui/src/components/ui/alert-dialog.tsx index b1178683214..08e933ab8c5 100644 --- a/webview-ui/src/components/ui/alert-dialog.tsx +++ b/webview-ui/src/components/ui/alert-dialog.tsx @@ -36,7 +36,7 @@ function AlertDialogContent({ className, ...props }: React.ComponentProps) function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
) } @@ -63,10 +59,7 @@ function AlertDialogTitle({ className, ...props }: React.ComponentProps ) @@ -79,7 +72,7 @@ function AlertDialogDescription({ return ( ) diff --git a/webview-ui/src/components/ui/dialog.tsx b/webview-ui/src/components/ui/dialog.tsx index 83efd07e9ee..6c4b8c5ccef 100644 --- a/webview-ui/src/components/ui/dialog.tsx +++ b/webview-ui/src/components/ui/dialog.tsx @@ -40,7 +40,7 @@ function DialogContent({ className, children, ...props }: React.ComponentProps @@ -78,7 +78,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps ) 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>( ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock settings data to include excluded paths and normal settings +vi.mock("@/i18n/locales/en/settings.json", () => ({ + default: { + modelInfo: { + inputPrice: "Input price", + outputPrice: "Output price", + }, + validation: { + apiKey: "You must provide a valid API key", + }, + browser: { + enable: { + label: "Enable browser tool", + description: "Allows Roo to use a browser", + }, + }, + }, +})) + +describe("useSettingsSearch - exclusions", () => { + it("does not return excluded modelInfo entries", () => { + const { result } = renderHook(() => useSettingsSearch("price")) + + const modelInfoResults = result.current.filter((r) => r.id.startsWith("modelInfo.")) + expect(modelInfoResults).toHaveLength(0) + }) + + it("does not return excluded validation entries", () => { + const { result } = renderHook(() => useSettingsSearch("api key")) + + const validationResults = result.current.filter((r) => r.id.startsWith("validation.")) + expect(validationResults).toHaveLength(0) + }) + + it("still returns actionable settings", () => { + const { result } = renderHook(() => useSettingsSearch("browser")) + + const browserResult = result.current.find((r) => r.id === "browser.enable") + expect(browserResult).toBeDefined() + }) +}) 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..3d437522c0c --- /dev/null +++ b/webview-ui/src/hooks/__tests__/useSettingsSearch.spec.ts @@ -0,0 +1,335 @@ +// 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", + }, + remote: { + label: "Use remote browser", + testButton: "Test Connection", + }, + }, + notifications: { + sound: { + label: "Sound effects", + description: "Play sound when Roo needs attention", + }, + }, + checkpoints: { + timeout: { + label: "Checkpoint timeout", + }, + }, + footer: { + settings: { + import: "Import settings", + export: "Export settings", + reset: "Reset settings", + }, + }, + }, +})) + +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:browser.remote.label": "Use remote browser", + "settings:browser.remote.testButton": "Test Connection", + "settings:notifications.sound.label": "Sound effects", + "settings:notifications.sound.description": "Play sound when Roo needs attention", + "settings:checkpoints.timeout.label": "Checkpoint timeout", + "settings:footer.settings.import": "Import settings", + "settings:footer.settings.export": "Export settings", + "settings:footer.settings.reset": "Reset settings", + } + + 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 calculate matchScore when matching extra text like buttons", () => { + const { result } = renderHook(() => useSettingsSearch("test connection")) + + const buttonResult = result.current.find((r) => r.id === "browser.remote") + expect(buttonResult).toBeDefined() + expect(buttonResult?.matchScore).toBe(4) // extra match only + }) + + it("should return standalone footer strings with label match score", () => { + const { result } = renderHook(() => useSettingsSearch("export")) + + const exportResult = result.current.find((r) => r.id === "footer.settings.export") + expect(exportResult).toBeDefined() + expect(exportResult?.tab).toBe("about") + expect(exportResult?.matchScore).toBe(10) + }) + + 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..fb010a1664e --- /dev/null +++ b/webview-ui/src/hooks/useSettingsSearch.ts @@ -0,0 +1,100 @@ +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 + /** Translated extra texts (e.g., button labels) for the setting */ + translatedExtraTexts?: 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 + // Get translated extra texts (e.g., button labels) if they exist + const translatedExtraTexts = setting.extraTextKeys?.map((key) => t(key)).filter(Boolean) + + // Check for matches (case-insensitive) + const labelMatch = translatedLabel.toLowerCase().includes(normalizedQuery) + const descriptionMatch = translatedDescription + ? translatedDescription.toLowerCase().includes(normalizedQuery) + : false + const extraMatch = + translatedExtraTexts?.some((text) => text.toLowerCase().includes(normalizedQuery)) ?? false + + // If no match, return null + if (!labelMatch && !descriptionMatch && !extraMatch) { + return null + } + + // Calculate match score: +10 for label match, +5 for description match + let matchScore = 0 + if (labelMatch) { + matchScore += 10 + } + if (descriptionMatch) { + matchScore += 5 + } + if (extraMatch) { + matchScore += 4 + } + + return { + ...setting, + translatedLabel, + translatedDescription, + translatedExtraTexts, + 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/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index a78775eee13..f08730f1722 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -1,4 +1,9 @@ { + "back": "Torna a la vista de tasques", + "search": { + "placeholder": "Troba un ajust...", + "noResults": "No s'han trobat resultats per \"{{query}}\"" + }, "common": { "save": "Desar", "done": "Fet", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 276fda10c29..2d9548a7023 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -1,4 +1,9 @@ { + "back": "Zur Aufgabenansicht zurück", + "search": { + "placeholder": "Einstellung finden...", + "noResults": "Keine Treffer für \"{{query}}\"" + }, "common": { "save": "Speichern", "done": "Fertig", diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 608be93140c..923f2901b0b 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -3,7 +3,6 @@ "history": "History", "exitSelectionMode": "Exit Selection Mode", "enterSelectionMode": "Enter Selection Mode", - "done": "Done", "searchPlaceholder": "Fuzzy search history...", "newest": "Newest", "oldest": "Oldest", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index e13a97af479..9c3414c422c 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1,7 +1,11 @@ { + "back": "Back to tasks view", + "search": { + "placeholder": "Find setting...", + "noResults": "No results found for \"{{query}}\"" + }, "common": { "save": "Save", - "done": "Done", "cancel": "Cancel", "reset": "Reset", "select": "Select", @@ -12,7 +16,7 @@ "title": "Settings", "saveButtonTooltip": "Save changes", "nothingChangedTooltip": "Nothing changed", - "doneButtonTooltip": "Discard unsaved changes and close settings panel" + "doneButtonTooltip": "Discard unsaved changes and go back to tasks view" }, "unsavedChangesDialog": { "title": "Unsaved Changes", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 9102adf812f..832af471cc3 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -1,4 +1,9 @@ { + "back": "Volver a la vista de tareas", + "search": { + "placeholder": "Buscar ajuste...", + "noResults": "No se encontraron resultados para \"{{query}}\"" + }, "common": { "save": "Guardar", "done": "Hecho", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 0a7746a5791..7f965e23973 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -1,4 +1,9 @@ { + "back": "Retour à la vue des tâches", + "search": { + "placeholder": "Trouver un réglage...", + "noResults": "Aucun résultat pour \"{{query}}\"" + }, "common": { "save": "Enregistrer", "done": "Terminé", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 1003c5ad482..084f9a89916 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -1,4 +1,9 @@ { + "back": "टास्क व्यू पर वापस जाओ", + "search": { + "placeholder": "सेटिंग खोजें...", + "noResults": "\"{{query}}\" के लिए कोई परिणाम नहीं मिला" + }, "common": { "save": "सहेजें", "done": "पूर्ण", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 2790c922229..8738c48bdce 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -1,4 +1,9 @@ { + "back": "Kembali ke tampilan tugas", + "search": { + "placeholder": "Cari pengaturan...", + "noResults": "Tidak ada hasil untuk \"{{query}}\"" + }, "common": { "save": "Simpan", "done": "Selesai", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 315ad3f664c..fa72f56b600 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -1,4 +1,9 @@ { + "back": "Torna alla vista attività", + "search": { + "placeholder": "Trova impostazione...", + "noResults": "Nessun risultato per \"{{query}}\"" + }, "common": { "save": "Salva", "done": "Fatto", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index a72baa5674a..c97e5512682 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -1,4 +1,9 @@ { + "back": "タスクビューに戻る", + "search": { + "placeholder": "設定を検索...", + "noResults": "\"{{query}}\" の結果はありません" + }, "common": { "save": "保存", "done": "完了", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 47278eec7f7..a6d1c15ba04 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -1,4 +1,9 @@ { + "back": "작업 보기로 돌아가기", + "search": { + "placeholder": "설정 찾기...", + "noResults": "\"{{query}}\"에 대한 결과가 없습니다" + }, "common": { "save": "저장", "done": "완료", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 569099cddb2..2010f7a5903 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -1,4 +1,9 @@ { + "back": "Terug naar takenoverzicht", + "search": { + "placeholder": "Instelling zoeken...", + "noResults": "Geen resultaten voor \"{{query}}\"" + }, "common": { "save": "Opslaan", "done": "Gereed", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 3907ac765da..dc1e11756ca 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -1,4 +1,9 @@ { + "back": "Wróć do widoku zadań", + "search": { + "placeholder": "Znajdź ustawienie...", + "noResults": "Brak wyników dla \"{{query}}\"" + }, "common": { "save": "Zapisz", "done": "Gotowe", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 1b5cc8fcf3d..143b7eb7208 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -1,4 +1,9 @@ { + "back": "Voltar para a visualização de tarefas", + "search": { + "placeholder": "Encontrar configuração...", + "noResults": "Nenhum resultado para \"{{query}}\"" + }, "common": { "save": "Salvar", "done": "Concluído", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index a30fed3395f..cff93a860fa 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -1,4 +1,9 @@ { + "back": "Назад к списку задач", + "search": { + "placeholder": "Найти настройку...", + "noResults": "Нет результатов для \"{{query}}\"" + }, "common": { "save": "Сохранить", "done": "Готово", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 951bc29183c..1144d101a85 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -1,4 +1,9 @@ { + "back": "Görev görünümüne dön", + "search": { + "placeholder": "Ayar bul...", + "noResults": "\"{{query}}\" için sonuç yok" + }, "common": { "save": "Kaydet", "done": "Tamamlandı", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4468b307f49..93a0486d74c 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -1,4 +1,9 @@ { + "back": "Quay lại chế độ xem nhiệm vụ", + "search": { + "placeholder": "Tìm cài đặt...", + "noResults": "Không tìm thấy kết quả cho \"{{query}}\"" + }, "common": { "save": "Lưu", "done": "Hoàn thành", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index b2952120b4f..88942659a44 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -1,4 +1,9 @@ { + "back": "返回任务视图", + "search": { + "placeholder": "查找设置...", + "noResults": "未找到与 \"{{query}}\" 匹配的结果" + }, "common": { "save": "保存", "done": "完成", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 01ca8e8d5e7..6fca989df1d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1,4 +1,9 @@ { + "back": "返回工作檢視", + "search": { + "placeholder": "搜尋設定...", + "noResults": "沒有找到與 \"{{query}}\" 相符的結果" + }, "common": { "save": "儲存", "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..0b0f7ccebd2 --- /dev/null +++ b/webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts @@ -0,0 +1,329 @@ +import { parseSettingsI18nKeys, type SectionName, sectionNames } from "../parseSettingsI18nKeys" + +describe("parseSettingsI18nKeys", () => { + it("should exclude display-only and helper entries", () => { + const translations = { + modelInfo: { + inputPrice: "Input price", + }, + validation: { + apiKey: "You must provide an API key", + }, + placeholders: { + apiKey: "Enter API Key", + }, + browser: { + enable: { + label: "Enable browser tool", + }, + }, + } + + const results = parseSettingsI18nKeys(translations) + + // Excluded categories + expect(results.find((r) => r.id.startsWith("modelInfo."))).toBeUndefined() + expect(results.find((r) => r.id.startsWith("validation."))).toBeUndefined() + expect(results.find((r) => r.id.startsWith("placeholders."))).toBeUndefined() + + // Included actionable setting + expect(results.find((r) => r.id === "browser.enable")).toBeDefined() + }) + + 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", + }) + }) + + it("should collect extra searchable text keys like button labels", () => { + const translations = { + browser: { + remote: { + label: "Use remote browser", + testButton: "Test Connection", + }, + }, + } + + const results = parseSettingsI18nKeys(translations) + + expect(results).toContainEqual({ + id: "browser.remote", + tab: "browser", + labelKey: "settings:browser.remote.label", + descriptionKey: undefined, + extraTextKeys: ["settings:browser.remote.testButton"], + }) + }) + + it("should create standalone entries for footer settings string leaves in about tab", () => { + const translations = { + footer: { + settings: { + import: "Import settings", + export: "Export settings", + reset: "Reset settings", + }, + }, + } + + const results = parseSettingsI18nKeys(translations) + + expect(results).toEqual( + expect.arrayContaining([ + { + id: "footer.settings.import", + tab: "about", + labelKey: "settings:footer.settings.import", + descriptionKey: undefined, + }, + { + id: "footer.settings.export", + tab: "about", + labelKey: "settings:footer.settings.export", + descriptionKey: undefined, + }, + { + id: "footer.settings.reset", + tab: "about", + labelKey: "settings:footer.settings.reset", + descriptionKey: undefined, + }, + ]), + ) + }) + }) + + 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/__tests__/settingsSearchExclusions.spec.ts b/webview-ui/src/utils/__tests__/settingsSearchExclusions.spec.ts new file mode 100644 index 00000000000..6407c6cdd3b --- /dev/null +++ b/webview-ui/src/utils/__tests__/settingsSearchExclusions.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest" + +import { getExclusionReason, shouldExcludeFromSearch } from "../settingsSearchExclusions" + +describe("settingsSearchExclusions", () => { + describe("shouldExcludeFromSearch", () => { + it("excludes modelInfo display fields", () => { + expect(shouldExcludeFromSearch("modelInfo.inputPrice")).toBe(true) + expect(shouldExcludeFromSearch("modelInfo.outputPrice")).toBe(true) + expect(shouldExcludeFromSearch("modelInfo.contextWindow")).toBe(true) + }) + + it("excludes validation messages", () => { + expect(shouldExcludeFromSearch("validation.apiKey")).toBe(true) + expect(shouldExcludeFromSearch("validation.modelId")).toBe(true) + }) + + it("excludes placeholders", () => { + expect(shouldExcludeFromSearch("placeholders.apiKey")).toBe(true) + expect(shouldExcludeFromSearch("placeholders.baseUrl")).toBe(true) + }) + + it("excludes custom model pricing", () => { + expect(shouldExcludeFromSearch("providers.customModel.pricing.input")).toBe(true) + expect(shouldExcludeFromSearch("providers.customModel.pricing.output")).toBe(true) + }) + + it("excludes service tier display-only entries", () => { + expect(shouldExcludeFromSearch("serviceTier.columns.tier")).toBe(true) + expect(shouldExcludeFromSearch("serviceTier.pricingTableTitle")).toBe(true) + }) + + it("does not exclude actionable settings", () => { + expect(shouldExcludeFromSearch("browser.enable")).toBe(false) + expect(shouldExcludeFromSearch("providers.apiProvider")).toBe(false) + expect(shouldExcludeFromSearch("terminal.outputLineLimit")).toBe(false) + }) + + it("does not exclude settings that merely contain keywords", () => { + expect(shouldExcludeFromSearch("providers.enablePromptCaching")).toBe(false) + }) + }) + + describe("getExclusionReason", () => { + it("returns reason for excluded ids", () => { + const reason = getExclusionReason("modelInfo.inputPrice") + expect(reason).toBeDefined() + expect(reason?.length).toBeGreaterThan(0) + }) + + it("returns undefined for included ids", () => { + expect(getExclusionReason("browser.enable")).toBeUndefined() + }) + }) +}) diff --git a/webview-ui/src/utils/parseSettingsI18nKeys.ts b/webview-ui/src/utils/parseSettingsI18nKeys.ts new file mode 100644 index 00000000000..f7fadc21038 --- /dev/null +++ b/webview-ui/src/utils/parseSettingsI18nKeys.ts @@ -0,0 +1,392 @@ +import { shouldExcludeFromSearch } from "./settingsSearchExclusions" + +/** + * 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 + /** Additional i18n keys within the same setting (e.g., button labels) to include in search */ + extraTextKeys?: 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 + footer: "about", // footer controls live in About tab +} + +/** + * 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" +} + +/** + * Collects additional string keys within a setting that should be searchable (e.g., button labels). + * Skips nested setting objects (those with their own labels) so keys stay scoped to the current setting. + */ +function collectSearchableTextKeys(obj: Record, path: string[], namespace: string): string[] { + const collected: string[] = [] + + const walk = (current: Record, currentPath: string[]) => { + for (const [key, value] of Object.entries(current)) { + if (key === "label" || key === "description") { + continue + } + + if (isPlainObject(value)) { + // If this nested object represents its own setting, don't collect from it here + if (isSettingObject(value)) { + continue + } + walk(value, [...currentPath, key]) + continue + } + + if (typeof value === "string") { + collected.push(`${namespace}:${[...currentPath, key].join(".")}`) + } + } + } + + walk(obj, path) + return collected +} + +/** + * Roots that should generate standalone searchable entries for string leaves, even when they are + * not full "settings" objects (i.e., they lack a label/description). This helps surface buttons + * like Import/Export/Reset in the About tab's footer controls. + */ +const standaloneStringRoots: Record = { + "footer.settings": "about", +} + +function collectStandaloneStringEntries( + obj: unknown, + basePath: string[], + namespace: string, + tab: SectionName, + existingIds: Set, + results: ParsedSetting[], +): void { + const walk = (value: unknown, currentPath: string[]) => { + if (typeof value === "string") { + const id = currentPath.join(".") + if (!existingIds.has(id)) { + results.push({ + id, + tab, + labelKey: `${namespace}:${id}`, + descriptionKey: undefined, + }) + existingIds.add(id) + } + return + } + + if (!isPlainObject(value)) { + return + } + + // Don't recurse into nested setting objects to avoid duplicating their own entries + if (isSettingObject(value)) { + return + } + + for (const [key, child] of Object.entries(value)) { + walk(child, [...currentPath, key]) + } + } + + walk(obj, basePath) +} + +/** + * 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, + ...(() => { + const keys = collectSearchableTextKeys(obj, path, namespace) + return keys.length ? { extraTextKeys: keys } : {} + })(), + }) + } + + // 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) + } + + // Add standalone string leaves for specific roots (e.g., footer.settings.import/export/reset) + const existingIds = new Set(results.map((r) => r.id)) + for (const [rootPath, tab] of Object.entries(standaloneStringRoots)) { + const parts = rootPath.split(".") + let current: unknown = translations + for (const part of parts) { + if (!isPlainObject(current) || !(part in current)) { + current = undefined + break + } + current = (current as Record)[part] + } + + if (current !== undefined) { + collectStandaloneStringEntries(current, parts, namespace, tab, existingIds, results) + } + } + + // Collect tabs that already have settings from parsing (including standalone entries) + 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) + } + } + + const filteredResults = results.filter((setting) => !shouldExcludeFromSearch(setting.id)) + + return filteredResults +} diff --git a/webview-ui/src/utils/settingsSearchExclusions.ts b/webview-ui/src/utils/settingsSearchExclusions.ts new file mode 100644 index 00000000000..ebbf3b1a1b4 --- /dev/null +++ b/webview-ui/src/utils/settingsSearchExclusions.ts @@ -0,0 +1,85 @@ +export interface ExclusionRule { + /** Pattern to match against a parsed setting id */ + pattern: string | RegExp + /** Human-readable reason for exclusion */ + reason: string + /** Example ids matched by this rule */ + examples?: string[] +} + +const SEARCH_EXCLUSIONS: ExclusionRule[] = [ + { + pattern: /^modelInfo\./, + reason: "Model information is display-only and not configurable", + examples: ["modelInfo.inputPrice", "modelInfo.outputPrice", "modelInfo.contextWindow"], + }, + { + pattern: /^providers\.customModel\.pricing\./, + reason: "Custom model pricing fields are display-focused and should not clutter search", + examples: ["providers.customModel.pricing.input", "providers.customModel.pricing.output"], + }, + { + pattern: /^validation\./, + reason: "Validation messages are error text, not settings", + examples: ["validation.apiKey", "validation.modelId"], + }, + { + pattern: /^placeholders\./, + reason: "Placeholder text is helper content, not a setting", + examples: ["placeholders.apiKey", "placeholders.baseUrl"], + }, + { + pattern: /^defaults\./, + reason: "Default value descriptions are informational only", + examples: ["defaults.ollamaUrl", "defaults.lmStudioUrl"], + }, + { + pattern: /^labels\./, + reason: "Generic labels are helper text, not settings", + examples: ["labels.customArn", "labels.useCustomArn"], + }, + { + pattern: /^thinkingBudget\./, + reason: "Thinking budget entries are display-only", + examples: ["thinkingBudget.maxTokens", "thinkingBudget.maxThinkingTokens"], + }, + { + pattern: /^serviceTier\.columns\./, + reason: "Service tier column headers are display-only", + examples: ["serviceTier.columns.tier", "serviceTier.columns.input"], + }, + { + pattern: /^serviceTier\.pricingTableTitle$/, + reason: "Service tier table title is display-only", + examples: ["serviceTier.pricingTableTitle"], + }, + { + pattern: /^modelPicker\.simplifiedExplanation$/, + reason: "Model picker helper text is informational", + examples: ["modelPicker.simplifiedExplanation"], + }, + { + pattern: /^modelInfo\.gemini\.(freeRequests|pricingDetails|billingEstimate)$/, + reason: "Gemini pricing notes are display-only", + examples: ["modelInfo.gemini.freeRequests", "modelInfo.gemini.pricingDetails"], + }, +] + +function matchesRule(settingId: string, rule: ExclusionRule): boolean { + if (typeof rule.pattern === "string") { + return settingId === rule.pattern + } + + return rule.pattern.test(settingId) +} + +export function shouldExcludeFromSearch(settingId: string): boolean { + return SEARCH_EXCLUSIONS.some((rule) => matchesRule(settingId, rule)) +} + +export function getExclusionReason(settingId: string): string | undefined { + const rule = SEARCH_EXCLUSIONS.find((candidate) => matchesRule(settingId, candidate)) + return rule?.reason +} + +export { SEARCH_EXCLUSIONS }