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 (#18838)
This commit is contained in:
@@ -144,28 +144,30 @@ export function BaseSettingsDialog({
|
||||
useEffect(() => {
|
||||
const prevItems = prevItemsRef.current;
|
||||
if (prevItems !== items) {
|
||||
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 {
|
||||
if (items.length === 0) {
|
||||
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;
|
||||
}
|
||||
@@ -416,7 +418,10 @@ export function BaseSettingsDialog({
|
||||
|
||||
return;
|
||||
},
|
||||
{ isActive: true },
|
||||
{
|
||||
isActive: true,
|
||||
priority: focusSection === 'settings' && !editingKey,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
156
packages/cli/src/ui/components/shared/SearchableList.test.tsx
Normal file
156
packages/cli/src/ui/components/shared/SearchableList.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
189
packages/cli/src/ui/components/shared/SearchableList.tsx
Normal file
189
packages/cli/src/ui/components/shared/SearchableList.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user