mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
Add generic searchable list to back settings and extensions
This commit is contained in:
@@ -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<string[]>(() =>
|
||||
getDialogSettingKeys(),
|
||||
);
|
||||
const { fzfInstance, searchMap } = useMemo(() => {
|
||||
const keys = getDialogSettingKeys();
|
||||
const map = new Map<string, string>();
|
||||
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<string>();
|
||||
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<Settings>(() =>
|
||||
// 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 (
|
||||
<BaseSettingsDialog
|
||||
<SearchableList
|
||||
title="Settings"
|
||||
borderColor={showRestartPrompt ? theme.status.warning : undefined}
|
||||
searchEnabled={showSearch}
|
||||
searchBuffer={searchBuffer}
|
||||
items={items}
|
||||
showScopeSelector={showScopeSelection}
|
||||
selectedScope={selectedScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
maxItemsToShow={effectiveMaxItemsToShow}
|
||||
maxLabelWidth={maxLabelOrDescriptionWidth}
|
||||
onItemToggle={handleItemToggle}
|
||||
onEditCommit={handleEditCommit}
|
||||
onItemClear={handleItemClear}
|
||||
|
||||
158
packages/cli/src/ui/components/shared/SearchableList.test.tsx
Normal file
158
packages/cli/src/ui/components/shared/SearchableList.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { SearchableList, type SearchableListProps } from './SearchableList.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { SettingScope } from '../../../config/settings.js';
|
||||
import { type SettingsDialogItem } from './BaseSettingsDialog.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
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<typeof vi.fn>;
|
||||
let mockOnEditCommit: ReturnType<typeof vi.fn>;
|
||||
let mockOnItemClear: ReturnType<typeof vi.fn>;
|
||||
let mockOnClose: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnItemToggle = vi.fn();
|
||||
mockOnEditCommit = vi.fn();
|
||||
mockOnItemClear = vi.fn();
|
||||
mockOnClose = vi.fn();
|
||||
});
|
||||
|
||||
const renderList = (props: Partial<SearchableListProps> = {}) => {
|
||||
const defaultProps: SearchableListProps = {
|
||||
title: 'Test List',
|
||||
items: createMockItems(),
|
||||
selectedScope: SettingScope.User,
|
||||
maxItemsToShow: 8,
|
||||
onItemToggle: mockOnItemToggle,
|
||||
onEditCommit: mockOnEditCommit,
|
||||
onItemClear: mockOnItemClear,
|
||||
onClose: mockOnClose,
|
||||
...props,
|
||||
};
|
||||
|
||||
return render(
|
||||
<KeypressProvider>
|
||||
<SearchableList {...defaultProps} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
154
packages/cli/src/ui/components/shared/SearchableList.tsx
Normal file
154
packages/cli/src/ui/components/shared/SearchableList.tsx
Normal file
@@ -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<string[]>(() =>
|
||||
items.map((i) => i.key),
|
||||
);
|
||||
|
||||
// FZF instance for fuzzy searching
|
||||
const { fzfInstance, searchMap } = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
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<string>();
|
||||
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 (
|
||||
<BaseSettingsDialog
|
||||
{...baseProps}
|
||||
items={displayItems}
|
||||
searchBuffer={searchBuffer}
|
||||
maxLabelWidth={maxLabelWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user