From a5816a67650345124d794c3a4d71c081a1f2bed4 Mon Sep 17 00:00:00 2001 From: Christine Betts Date: Wed, 11 Feb 2026 14:28:05 -0500 Subject: [PATCH] Add generic searchable list to back settings and extensions --- .../cli/src/ui/components/SettingsDialog.tsx | 121 +------------- .../components/shared/SearchableList.test.tsx | 158 ++++++++++++++++++ .../ui/components/shared/SearchableList.tsx | 154 +++++++++++++++++ 3 files changed, 317 insertions(+), 116 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/SearchableList.test.tsx create mode 100644 packages/cli/src/ui/components/shared/SearchableList.tsx diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index fe3acbd1f1..9eeb92e444 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -7,7 +7,6 @@ import type React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { Text } from 'ink'; -import { AsyncFzf } from 'fzf'; import type { Key } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import type { @@ -32,27 +31,14 @@ import { getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; -import { getCachedStringWidth } from '../utils/textUtils.js'; import { type SettingsValue, TOGGLE_TYPES, } from '../../config/settingsSchema.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { useTextBuffer } from './shared/text-buffer.js'; -import { - BaseSettingsDialog, - type SettingsDialogItem, -} from './shared/BaseSettingsDialog.js'; - -interface FzfResult { - item: string; - start: number; - end: number; - score: number; - positions?: number[]; -} +import { type SettingsDialogItem } from './shared/BaseSettingsDialog.js'; +import { SearchableList } from './shared/SearchableList.js'; interface SettingsDialogProps { settings: LoadedSettings; @@ -81,60 +67,6 @@ export function SettingsDialog({ const [showRestartPrompt, setShowRestartPrompt] = useState(false); - // Search state - const [searchQuery, setSearchQuery] = useState(''); - const [filteredKeys, setFilteredKeys] = useState(() => - getDialogSettingKeys(), - ); - const { fzfInstance, searchMap } = useMemo(() => { - const keys = getDialogSettingKeys(); - const map = new Map(); - const searchItems: string[] = []; - - keys.forEach((key) => { - const def = getSettingDefinition(key); - if (def?.label) { - searchItems.push(def.label); - map.set(def.label.toLowerCase(), key); - } - }); - - const fzf = new AsyncFzf(searchItems, { - fuzzy: 'v2', - casing: 'case-insensitive', - }); - return { fzfInstance: fzf, searchMap: map }; - }, []); - - // Perform search - useEffect(() => { - let active = true; - if (!searchQuery.trim() || !fzfInstance) { - setFilteredKeys(getDialogSettingKeys()); - return; - } - - const doSearch = async () => { - const results = await fzfInstance.find(searchQuery); - - if (!active) return; - - const matchedKeys = new Set(); - results.forEach((res: FzfResult) => { - const key = searchMap.get(res.item.toLowerCase()); - if (key) matchedKeys.add(key); - }); - setFilteredKeys(Array.from(matchedKeys)); - }; - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - doSearch(); - - return () => { - active = false; - }; - }, [searchQuery, fzfInstance, searchMap]); - // Local pending settings state for the selected scope const [pendingSettings, setPendingSettings] = useState(() => // Deep clone to avoid mutation @@ -182,49 +114,8 @@ export function SettingsDialog({ setShowRestartPrompt(newRestartRequired.size > 0); }, [selectedScope, settings, globalPendingChanges]); - // Calculate max width for the left column (Label/Description) to keep values aligned or close - const maxLabelOrDescriptionWidth = useMemo(() => { - const allKeys = getDialogSettingKeys(); - let max = 0; - for (const key of allKeys) { - const def = getSettingDefinition(key); - if (!def) continue; - - const scopeMessage = getScopeMessageForSetting( - key, - selectedScope, - settings, - ); - const label = def.label || key; - const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : ''); - const lWidth = getCachedStringWidth(labelFull); - const dWidth = def.description - ? getCachedStringWidth(def.description) - : 0; - - max = Math.max(max, lWidth, dWidth); - } - return max; - }, [selectedScope, settings]); - - // Get mainAreaWidth for search buffer viewport - const { mainAreaWidth } = useUIState(); - const viewportWidth = mainAreaWidth - 8; - - // Search input buffer - const searchBuffer = useTextBuffer({ - initialText: '', - initialCursorOffset: 0, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), - }); - - // Generate items for BaseSettingsDialog - const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys(); + // Generate items for SearchableList + const settingKeys = getDialogSettingKeys(); const items: SettingsDialogItem[] = useMemo(() => { const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; @@ -691,17 +582,15 @@ export function SettingsDialog({ ) : null; return ( - ({ + useUIState: () => ({ + mainAreaWidth: 100, + }), +})); + +const createMockItems = (): SettingsDialogItem[] => [ + { + key: 'boolean-setting', + label: 'Boolean Setting', + description: 'A boolean setting for testing', + displayValue: 'true', + rawValue: true, + type: 'boolean', + }, + { + key: 'string-setting', + label: 'String Setting', + description: 'A string setting for testing', + displayValue: 'test-value', + rawValue: 'test-value', + type: 'string', + }, + { + key: 'number-setting', + label: 'Number Setting', + description: 'A number setting for testing', + displayValue: '42', + rawValue: 42, + type: 'number', + }, +]; + +describe('SearchableList', () => { + let mockOnItemToggle: ReturnType; + let mockOnEditCommit: ReturnType; + let mockOnItemClear: ReturnType; + let mockOnClose: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnItemToggle = vi.fn(); + mockOnEditCommit = vi.fn(); + mockOnItemClear = vi.fn(); + mockOnClose = vi.fn(); + }); + + const renderList = (props: Partial = {}) => { + const defaultProps: SearchableListProps = { + title: 'Test List', + items: createMockItems(), + selectedScope: SettingScope.User, + maxItemsToShow: 8, + onItemToggle: mockOnItemToggle, + onEditCommit: mockOnEditCommit, + onItemClear: mockOnItemClear, + onClose: mockOnClose, + ...props, + }; + + return render( + + + , + ); + }; + + it('should render all items initially', () => { + const { lastFrame } = renderList(); + const frame = lastFrame(); + expect(frame).toContain('Boolean Setting'); + expect(frame).toContain('String Setting'); + expect(frame).toContain('Number Setting'); + }); + + it('should filter items based on search query', async () => { + const { lastFrame, stdin } = renderList(); + + // Type "bool" into search + await act(async () => { + stdin.write('bool'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Boolean Setting'); + expect(frame).not.toContain('String Setting'); + expect(frame).not.toContain('Number Setting'); + }); + }); + + it('should show "No matches found." when no items match', async () => { + const { lastFrame, stdin } = renderList(); + + // Type something that won't match + await act(async () => { + stdin.write('xyz123'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('No matches found.'); + }); + }); + + it('should call onSearch callback when query changes', async () => { + const mockOnSearch = vi.fn(); + const { stdin } = renderList({ onSearch: mockOnSearch }); + + await act(async () => { + stdin.write('a'); + }); + + await waitFor(() => { + expect(mockOnSearch).toHaveBeenCalledWith('a'); + }); + }); + + it('should handle clearing the search query', async () => { + const { lastFrame, stdin } = renderList(); + + // Search for something + await act(async () => { + stdin.write('bool'); + }); + + await waitFor(() => { + expect(lastFrame()).not.toContain('String Setting'); + }); + + // Clear search (Backspace 4 times) + await act(async () => { + stdin.write('\u0008\u0008\u0008\u0008'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Boolean Setting'); + expect(frame).toContain('String Setting'); + expect(frame).toContain('Number Setting'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx new file mode 100644 index 0000000000..c67ae33d64 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { AsyncFzf } from 'fzf'; +import { + BaseSettingsDialog, + type SettingsDialogItem, + type BaseSettingsDialogProps, +} from './BaseSettingsDialog.js'; +import { useTextBuffer } from './text-buffer.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; + +import { getCachedStringWidth } from '../../utils/textUtils.js'; + +interface FzfResult { + item: string; + start: number; + end: number; + score: number; + positions?: number[]; +} + +/** + * SearchableListProps extends BaseSettingsDialogProps but removes props that are handled internally + * or derived from the items and search state. + */ +export interface SearchableListProps + extends Omit< + BaseSettingsDialogProps, + 'searchBuffer' | 'items' | 'maxLabelWidth' + > { + /** All available items */ + items: SettingsDialogItem[]; + /** Optional custom search query handler */ + onSearch?: (query: string) => void; + /** Initial search query */ + initialSearchQuery?: string; +} + +/** + * A generic searchable list component that wraps BaseSettingsDialog. + * It handles fuzzy searching and filtering of items. + */ +export function SearchableList({ + items, + onSearch, + initialSearchQuery = '', + ...baseProps +}: SearchableListProps): React.JSX.Element { + // Search state + const [searchQuery, setSearchQuery] = useState(initialSearchQuery); + const [filteredKeys, setFilteredKeys] = useState(() => + items.map((i) => i.key), + ); + + // FZF instance for fuzzy searching + const { fzfInstance, searchMap } = useMemo(() => { + const map = new Map(); + const searchItems: string[] = []; + + items.forEach((item) => { + searchItems.push(item.label); + map.set(item.label.toLowerCase(), item.key); + }); + + const fzf = new AsyncFzf(searchItems, { + fuzzy: 'v2', + casing: 'case-insensitive', + }); + return { fzfInstance: fzf, searchMap: map }; + }, [items]); + + // Perform search + useEffect(() => { + let active = true; + if (!searchQuery.trim() || !fzfInstance) { + setFilteredKeys(items.map((i) => i.key)); + return; + } + + const doSearch = async () => { + const results = await fzfInstance.find(searchQuery); + + if (!active) return; + + const matchedKeys = new Set(); + results.forEach((res: FzfResult) => { + const key = searchMap.get(res.item.toLowerCase()); + if (key) matchedKeys.add(key); + }); + setFilteredKeys(Array.from(matchedKeys)); + onSearch?.(searchQuery); + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSearch(); + + return () => { + active = false; + }; + }, [searchQuery, fzfInstance, searchMap, items, onSearch]); + + // Get mainAreaWidth for search buffer viewport from UIState + const { mainAreaWidth } = useUIState(); + const viewportWidth = Math.max(20, mainAreaWidth - 8); + + // Search input buffer + const searchBuffer = useTextBuffer({ + initialText: searchQuery, + initialCursorOffset: searchQuery.length, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange: (text) => setSearchQuery(text), + }); + + // Filtered items to display + const displayItems = useMemo(() => { + if (!searchQuery) return items; + return items.filter((item) => filteredKeys.includes(item.key)); + }, [items, filteredKeys, searchQuery]); + + // Calculate max label width for alignment + const maxLabelWidth = useMemo(() => { + let max = 0; + // We use all items for consistent alignment even when filtered + items.forEach((item) => { + const labelFull = + item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : ''); + const lWidth = getCachedStringWidth(labelFull); + const dWidth = item.description + ? getCachedStringWidth(item.description) + : 0; + max = Math.max(max, lWidth, dWidth); + }); + return max; + }, [items]); + + return ( + + ); +}