From ddc54584513643d6c8032073361c9c8a625c5e00 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 19 Feb 2026 16:06:37 -0500 Subject: [PATCH] =?UTF-8?q?Revert=20"Add=20generic=20searchable=20list=20t?= =?UTF-8?q?o=20back=20settings=20and=20extensions=20(=E2=80=A6=20(#19434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: jacob314 --- .../cli/src/ui/components/SettingsDialog.tsx | 122 ++++++++++- .../components/shared/BaseSettingsDialog.tsx | 42 ++-- .../components/shared/SearchableList.test.tsx | 157 --------------- .../ui/components/shared/SearchableList.tsx | 189 ------------------ packages/cli/src/ui/hooks/useFuzzyList.ts | 151 -------------- 5 files changed, 132 insertions(+), 529 deletions(-) delete mode 100644 packages/cli/src/ui/components/shared/SearchableList.test.tsx delete mode 100644 packages/cli/src/ui/components/shared/SearchableList.tsx delete mode 100644 packages/cli/src/ui/hooks/useFuzzyList.ts diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 2bfbe7a9fa..fe3acbd1f1 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -7,6 +7,7 @@ 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 { @@ -31,17 +32,27 @@ 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 { - type SettingsDialogItem, BaseSettingsDialog, + type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; -import { useFuzzyList } from '../hooks/useFuzzyList.js'; + +interface FzfResult { + item: string; + start: number; + end: number; + score: number; + positions?: number[]; +} interface SettingsDialogProps { settings: LoadedSettings; @@ -70,6 +81,60 @@ 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 @@ -117,8 +182,49 @@ export function SettingsDialog({ setShowRestartPrompt(newRestartRequired.size > 0); }, [selectedScope, settings, globalPendingChanges]); - // Generate items for SearchableList - const settingKeys = useMemo(() => getDialogSettingKeys(), []); + // 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(); const items: SettingsDialogItem[] = useMemo(() => { const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; @@ -164,10 +270,6 @@ export function SettingsDialog({ }); }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); - const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ - items, - }); - // Scope selection handler const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); @@ -594,12 +696,12 @@ export function SettingsDialog({ borderColor={showRestartPrompt ? theme.status.warning : undefined} searchEnabled={showSearch} searchBuffer={searchBuffer} - items={filteredItems} + items={items} showScopeSelector={showScopeSelection} selectedScope={selectedScope} onScopeChange={handleScopeChange} maxItemsToShow={effectiveMaxItemsToShow} - maxLabelWidth={maxLabelWidth} + maxLabelWidth={maxLabelOrDescriptionWidth} onItemToggle={handleItemToggle} onEditCommit={handleEditCommit} onItemClear={handleItemClear} diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index e257600188..29592b479b 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -144,30 +144,28 @@ export function BaseSettingsDialog({ useEffect(() => { const prevItems = prevItemsRef.current; if (prevItems !== items) { - if (items.length === 0) { + const prevActiveItem = prevItems[activeIndex]; + if (prevActiveItem) { + const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); + if (newIndex !== -1) { + // Item still exists in the filtered list, keep focus on it + setActiveIndex(newIndex); + // Adjust scroll offset to ensure the item is visible + let newScroll = scrollOffset; + if (newIndex < scrollOffset) newScroll = newIndex; + else if (newIndex >= scrollOffset + maxItemsToShow) + newScroll = newIndex - maxItemsToShow + 1; + + const maxScroll = Math.max(0, items.length - maxItemsToShow); + setScrollOffset(Math.min(newScroll, maxScroll)); + } else { + // Item was filtered out, reset to the top + setActiveIndex(0); + setScrollOffset(0); + } + } else { setActiveIndex(0); setScrollOffset(0); - } else { - const prevActiveItem = prevItems[activeIndex]; - if (prevActiveItem) { - const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); - if (newIndex !== -1) { - // Item still exists in the filtered list, keep focus on it - setActiveIndex(newIndex); - // Adjust scroll offset to ensure the item is visible - let newScroll = scrollOffset; - if (newIndex < scrollOffset) newScroll = newIndex; - else if (newIndex >= scrollOffset + maxItemsToShow) - newScroll = newIndex - maxItemsToShow + 1; - - const maxScroll = Math.max(0, items.length - maxItemsToShow); - setScrollOffset(Math.min(newScroll, maxScroll)); - } else { - // Item was filtered out, reset to the top - setActiveIndex(0); - setScrollOffset(0); - } - } } prevItemsRef.current = items; } diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx deleted file mode 100644 index fa20352a8b..0000000000 --- a/packages/cli/src/ui/components/shared/SearchableList.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from '../../../test-utils/render.js'; -import { waitFor } from '../../../test-utils/async.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SearchableList, type SearchableListProps } from './SearchableList.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; -import { type GenericListItem } from '../../hooks/useFuzzyList.js'; - -// Mock UI State -vi.mock('../../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - -const mockItems: GenericListItem[] = [ - { - key: 'item-1', - label: 'Item One', - description: 'Description for item one', - }, - { - key: 'item-2', - label: 'Item Two', - description: 'Description for item two', - }, - { - key: 'item-3', - label: 'Item Three', - description: 'Description for item three', - }, -]; - -describe('SearchableList', () => { - let mockOnSelect: ReturnType; - let mockOnClose: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockOnSelect = vi.fn(); - mockOnClose = vi.fn(); - }); - - const renderList = ( - props: Partial> = {}, - ) => { - const defaultProps: SearchableListProps = { - title: 'Test List', - items: mockItems, - onSelect: mockOnSelect, - onClose: mockOnClose, - ...props, - }; - - return render( - - - , - ); - }; - - it('should render all items initially', async () => { - const { lastFrame, waitUntilReady } = renderList(); - await waitUntilReady(); - const frame = lastFrame(); - - // Check for title - expect(frame).toContain('Test List'); - - // Check for items - expect(frame).toContain('Item One'); - expect(frame).toContain('Item Two'); - expect(frame).toContain('Item Three'); - - // Check for descriptions - expect(frame).toContain('Description for item one'); - }); - - it('should filter items based on search query', async () => { - const { lastFrame, stdin } = renderList(); - - // Type "Two" into search - await React.act(async () => { - stdin.write('Two'); - }); - - await waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('Item Two'); - expect(frame).not.toContain('Item One'); - expect(frame).not.toContain('Item Three'); - }); - }); - - it('should show "No items found." when no items match', async () => { - const { lastFrame, stdin } = renderList(); - - // Type something that won't match - await React.act(async () => { - stdin.write('xyz123'); - }); - - await waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('No items found.'); - }); - }); - - it('should handle selection with Enter', async () => { - const { stdin } = renderList(); - - // Select first item (default active) - await React.act(async () => { - stdin.write('\r'); // Enter - }); - - await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]); - }); - }); - - it('should handle navigation and selection', async () => { - const { stdin } = renderList(); - - // Navigate down to second item - await React.act(async () => { - stdin.write('\u001B[B'); // Down Arrow - }); - - // Select second item - await React.act(async () => { - stdin.write('\r'); // Enter - }); - - await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]); - }); - }); - - it('should handle close with Esc', async () => { - const { stdin } = renderList(); - - await React.act(async () => { - stdin.write('\u001B'); // Esc - }); - - await waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx deleted file mode 100644 index 07720ce5d6..0000000000 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { useState, useEffect } from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { TextInput } from './TextInput.js'; -import { useKeypress, type Key } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; -import { - useFuzzyList, - type GenericListItem, -} from '../../hooks/useFuzzyList.js'; - -export interface SearchableListProps { - /** List title */ - title?: string; - /** Available items */ - items: T[]; - /** Callback when an item is selected */ - onSelect: (item: T) => void; - /** Callback when the list is closed (e.g. via Esc) */ - onClose?: () => void; - /** Initial search query */ - initialSearchQuery?: string; - /** Placeholder for search input */ - searchPlaceholder?: string; - /** Max items to show at once */ - maxItemsToShow?: number; -} - -/** - * A generic searchable list component. - */ -export function SearchableList({ - title, - items, - onSelect, - onClose, - initialSearchQuery = '', - searchPlaceholder = 'Search...', - maxItemsToShow = 10, -}: SearchableListProps): React.JSX.Element { - const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ - items, - initialQuery: initialSearchQuery, - }); - - const [activeIndex, setActiveIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - - // Reset selection when filtered items change - useEffect(() => { - setActiveIndex(0); - setScrollOffset(0); - }, [filteredItems]); - - // Calculate visible items - const visibleItems = filteredItems.slice( - scrollOffset, - scrollOffset + maxItemsToShow, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = scrollOffset + maxItemsToShow < filteredItems.length; - - useKeypress( - (key: Key) => { - // Navigation - if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { - const newIndex = - activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1; - setActiveIndex(newIndex); - if (newIndex === filteredItems.length - 1) { - setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow)); - } else if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - return; - } - if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { - const newIndex = - activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0; - setActiveIndex(newIndex); - if (newIndex === 0) { - setScrollOffset(0); - } else if (newIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newIndex - maxItemsToShow + 1); - } - return; - } - - // Selection - if (keyMatchers[Command.RETURN](key)) { - const item = filteredItems[activeIndex]; - if (item) { - onSelect(item); - } - return; - } - - // Close - if (keyMatchers[Command.ESCAPE](key)) { - onClose?.(); - return; - } - }, - { isActive: true }, - ); - - return ( - - {/* Header */} - {title && ( - - {title} - - )} - - {/* Search Input */} - {searchBuffer && ( - - - - )} - - {/* List */} - - {visibleItems.length === 0 ? ( - No items found. - ) : ( - visibleItems.map((item, idx) => { - const index = scrollOffset + idx; - const isActive = index === activeIndex; - - return ( - - - {isActive ? '> ' : ' '} - - - - {item.label} - - - {item.description && ( - {item.description} - )} - - ); - }) - )} - - - {/* Footer/Scroll Indicators */} - {(showScrollUp || showScrollDown) && ( - - - {showScrollUp ? '▲ ' : ' '} - {filteredItems.length} items - {showScrollDown ? ' ▼' : ' '} - - - )} - - ); -} diff --git a/packages/cli/src/ui/hooks/useFuzzyList.ts b/packages/cli/src/ui/hooks/useFuzzyList.ts deleted file mode 100644 index 6d07b0ea75..0000000000 --- a/packages/cli/src/ui/hooks/useFuzzyList.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useMemo, useEffect } from 'react'; -import { AsyncFzf } from 'fzf'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { - useTextBuffer, - type TextBuffer, -} from '../components/shared/text-buffer.js'; -import { getCachedStringWidth } from '../utils/textUtils.js'; - -interface FzfResult { - item: string; - start: number; - end: number; - score: number; - positions?: number[]; -} - -export interface GenericListItem { - key: string; - label: string; - description?: string; - scopeMessage?: string; -} - -export interface UseFuzzyListProps { - items: T[]; - initialQuery?: string; - onSearch?: (query: string) => void; -} - -export interface UseFuzzyListResult { - filteredItems: T[]; - searchBuffer: TextBuffer | undefined; - searchQuery: string; - setSearchQuery: (query: string) => void; - maxLabelWidth: number; -} - -export function useFuzzyList({ - items, - initialQuery = '', - onSearch, -}: UseFuzzyListProps): UseFuzzyListResult { - // Search state - const [searchQuery, setSearchQuery] = useState(initialQuery); - 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); - }; - - void doSearch().catch((error) => { - // eslint-disable-next-line no-console - console.error('Search failed:', error); - setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error - }); - - 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 filteredItems = 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 { - filteredItems, - searchBuffer, - searchQuery, - setSearchQuery, - maxLabelWidth, - }; -}