Add generic searchable list to back settings and extensions (#18838)

This commit is contained in:
christine betts
2026-02-12 15:50:41 -05:00
committed by GitHub
parent 207ac6f2dc
commit 991b2c6002
5 changed files with 532 additions and 133 deletions

View File

@@ -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 (

View 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();
});
});
});

View 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>
);
}