mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-30 15:04:16 -07:00
Add generic searchable list to back settings and extensions (#18838)
This commit is contained in:
@@ -7,7 +7,6 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { AsyncFzf } from 'fzf';
|
|
||||||
import type { Key } from '../hooks/useKeypress.js';
|
import type { Key } from '../hooks/useKeypress.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -32,27 +31,17 @@ import {
|
|||||||
getEffectiveValue,
|
getEffectiveValue,
|
||||||
} from '../../utils/settingsUtils.js';
|
} from '../../utils/settingsUtils.js';
|
||||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
|
||||||
import {
|
import {
|
||||||
type SettingsValue,
|
type SettingsValue,
|
||||||
TOGGLE_TYPES,
|
TOGGLE_TYPES,
|
||||||
} from '../../config/settingsSchema.js';
|
} from '../../config/settingsSchema.js';
|
||||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||||
import type { Config } 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 {
|
import {
|
||||||
BaseSettingsDialog,
|
|
||||||
type SettingsDialogItem,
|
type SettingsDialogItem,
|
||||||
|
BaseSettingsDialog,
|
||||||
} from './shared/BaseSettingsDialog.js';
|
} from './shared/BaseSettingsDialog.js';
|
||||||
|
import { useFuzzyList } from '../hooks/useFuzzyList.js';
|
||||||
interface FzfResult {
|
|
||||||
item: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
score: number;
|
|
||||||
positions?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
settings: LoadedSettings;
|
settings: LoadedSettings;
|
||||||
@@ -81,60 +70,6 @@ export function SettingsDialog({
|
|||||||
|
|
||||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
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
|
// Local pending settings state for the selected scope
|
||||||
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||||
// Deep clone to avoid mutation
|
// Deep clone to avoid mutation
|
||||||
@@ -182,49 +117,8 @@ export function SettingsDialog({
|
|||||||
setShowRestartPrompt(newRestartRequired.size > 0);
|
setShowRestartPrompt(newRestartRequired.size > 0);
|
||||||
}, [selectedScope, settings, globalPendingChanges]);
|
}, [selectedScope, settings, globalPendingChanges]);
|
||||||
|
|
||||||
// Calculate max width for the left column (Label/Description) to keep values aligned or close
|
// Generate items for SearchableList
|
||||||
const maxLabelOrDescriptionWidth = useMemo(() => {
|
const settingKeys = useMemo(() => getDialogSettingKeys(), []);
|
||||||
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 items: SettingsDialogItem[] = useMemo(() => {
|
||||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||||
const mergedSettings = settings.merged;
|
const mergedSettings = settings.merged;
|
||||||
@@ -270,6 +164,10 @@ export function SettingsDialog({
|
|||||||
});
|
});
|
||||||
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
|
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
|
||||||
|
|
||||||
|
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
|
||||||
// Scope selection handler
|
// Scope selection handler
|
||||||
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
||||||
setSelectedScope(scope);
|
setSelectedScope(scope);
|
||||||
@@ -696,12 +594,12 @@ export function SettingsDialog({
|
|||||||
borderColor={showRestartPrompt ? theme.status.warning : undefined}
|
borderColor={showRestartPrompt ? theme.status.warning : undefined}
|
||||||
searchEnabled={showSearch}
|
searchEnabled={showSearch}
|
||||||
searchBuffer={searchBuffer}
|
searchBuffer={searchBuffer}
|
||||||
items={items}
|
items={filteredItems}
|
||||||
showScopeSelector={showScopeSelection}
|
showScopeSelector={showScopeSelection}
|
||||||
selectedScope={selectedScope}
|
selectedScope={selectedScope}
|
||||||
onScopeChange={handleScopeChange}
|
onScopeChange={handleScopeChange}
|
||||||
maxItemsToShow={effectiveMaxItemsToShow}
|
maxItemsToShow={effectiveMaxItemsToShow}
|
||||||
maxLabelWidth={maxLabelOrDescriptionWidth}
|
maxLabelWidth={maxLabelWidth}
|
||||||
onItemToggle={handleItemToggle}
|
onItemToggle={handleItemToggle}
|
||||||
onEditCommit={handleEditCommit}
|
onEditCommit={handleEditCommit}
|
||||||
onItemClear={handleItemClear}
|
onItemClear={handleItemClear}
|
||||||
|
|||||||
@@ -144,28 +144,30 @@ export function BaseSettingsDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevItems = prevItemsRef.current;
|
const prevItems = prevItemsRef.current;
|
||||||
if (prevItems !== items) {
|
if (prevItems !== items) {
|
||||||
const prevActiveItem = prevItems[activeIndex];
|
if (items.length === 0) {
|
||||||
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);
|
setActiveIndex(0);
|
||||||
setScrollOffset(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;
|
prevItemsRef.current = items;
|
||||||
}
|
}
|
||||||
@@ -416,7 +418,10 @@ export function BaseSettingsDialog({
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
{ isActive: true },
|
{
|
||||||
|
isActive: true,
|
||||||
|
priority: focusSection === 'settings' && !editingKey,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* @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<typeof vi.fn>;
|
||||||
|
let mockOnClose: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnSelect = vi.fn();
|
||||||
|
mockOnClose = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderList = (
|
||||||
|
props: Partial<SearchableListProps<GenericListItem>> = {},
|
||||||
|
) => {
|
||||||
|
const defaultProps: SearchableListProps<GenericListItem> = {
|
||||||
|
title: 'Test List',
|
||||||
|
items: mockItems,
|
||||||
|
onSelect: mockOnSelect,
|
||||||
|
onClose: mockOnClose,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<KeypressProvider>
|
||||||
|
<SearchableList {...defaultProps} />
|
||||||
|
</KeypressProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should render all items initially', () => {
|
||||||
|
const { lastFrame } = renderList();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* @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<T extends GenericListItem> {
|
||||||
|
/** 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<T extends GenericListItem>({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
initialSearchQuery = '',
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
maxItemsToShow = 10,
|
||||||
|
}: SearchableListProps<T>): 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 (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{title && (
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text bold>{title}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
{searchBuffer && (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.focused}
|
||||||
|
paddingX={1}
|
||||||
|
marginBottom={1}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
buffer={searchBuffer}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
focus={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{visibleItems.length === 0 ? (
|
||||||
|
<Text color={theme.text.secondary}>No items found.</Text>
|
||||||
|
) : (
|
||||||
|
visibleItems.map((item, idx) => {
|
||||||
|
const index = scrollOffset + idx;
|
||||||
|
const isActive = index === activeIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.key} flexDirection="row">
|
||||||
|
<Text
|
||||||
|
color={isActive ? theme.status.success : theme.text.secondary}
|
||||||
|
>
|
||||||
|
{isActive ? '> ' : ' '}
|
||||||
|
</Text>
|
||||||
|
<Box width={maxLabelWidth + 2}>
|
||||||
|
<Text
|
||||||
|
color={isActive ? theme.status.success : theme.text.primary}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{item.description && (
|
||||||
|
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer/Scroll Indicators */}
|
||||||
|
{(showScrollUp || showScrollDown) && (
|
||||||
|
<Box marginTop={1} justifyContent="center">
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{showScrollUp ? '▲ ' : ' '}
|
||||||
|
{filteredItems.length} items
|
||||||
|
{showScrollDown ? ' ▼' : ' '}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* @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<T extends GenericListItem> {
|
||||||
|
items: T[];
|
||||||
|
initialQuery?: string;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFuzzyListResult<T extends GenericListItem> {
|
||||||
|
filteredItems: T[];
|
||||||
|
searchBuffer: TextBuffer | undefined;
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
maxLabelWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFuzzyList<T extends GenericListItem>({
|
||||||
|
items,
|
||||||
|
initialQuery = '',
|
||||||
|
onSearch,
|
||||||
|
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
|
||||||
|
// Search state
|
||||||
|
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user