Add initial implementation of /extensions explore command (#19029)

This commit is contained in:
christine betts
2026-02-20 12:30:49 -05:00
committed by GitHub
parent 0f855fc0c4
commit 2bb7aaecd0
10 changed files with 1135 additions and 3 deletions

View File

@@ -0,0 +1,233 @@
/**
* @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,
type SearchListState,
type GenericListItem,
} from './SearchableList.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { useTextBuffer } from './text-buffer.js';
const useMockSearch = (props: {
items: GenericListItem[];
initialQuery?: string;
onSearch?: (query: string) => void;
}): SearchListState<GenericListItem> => {
const { onSearch, items, initialQuery = '' } = props;
const [text, setText] = React.useState(initialQuery);
const filteredItems = React.useMemo(
() =>
items.filter((item: GenericListItem) =>
item.label.toLowerCase().includes(text.toLowerCase()),
),
[items, text],
);
React.useEffect(() => {
onSearch?.(text);
}, [text, onSearch]);
const searchBuffer = useTextBuffer({
initialText: text,
onChange: setText,
viewport: { width: 100, height: 1 },
singleLine: true,
});
return {
filteredItems,
searchBuffer,
searchQuery: text,
setSearchQuery: setText,
maxLabelWidth: 10,
};
};
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,
useSearch: useMockSearch,
...props,
};
return render(
<KeypressProvider>
<SearchableList {...defaultProps} />
</KeypressProvider>,
);
};
it('should render all items initially', async () => {
const { lastFrame, waitUntilReady } = renderList();
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Test List');
expect(frame).toContain('Item One');
expect(frame).toContain('Item Two');
expect(frame).toContain('Item Three');
expect(frame).toContain('Description for item one');
});
it('should reset selection to top when items change if resetSelectionOnItemsChange is true', async () => {
const { lastFrame, stdin, waitUntilReady } = renderList({
resetSelectionOnItemsChange: true,
});
await waitUntilReady();
await React.act(async () => {
stdin.write('\u001B[B'); // Down arrow
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('> Item Two');
});
await React.act(async () => {
stdin.write('One');
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Item One');
expect(frame).not.toContain('Item Two');
});
await React.act(async () => {
// Backspace "One" (3 chars)
stdin.write('\u007F\u007F\u007F');
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Item Two');
expect(frame).toContain('> Item One');
expect(frame).not.toContain('> Item Two');
});
});
it('should filter items based on search query', async () => {
const { lastFrame, stdin } = renderList();
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();
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();
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();
await React.act(async () => {
stdin.write('\u001B[B'); // Down arrow
});
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();
});
});
it('should match snapshot', async () => {
const { lastFrame, waitUntilReady } = renderList();
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,231 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useMemo, useCallback } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
/**
* Generic interface for items in a searchable list.
*/
export interface GenericListItem {
key: string;
label: string;
description?: string;
[key: string]: unknown;
}
/**
* State returned by the search hook.
*/
export interface SearchListState<T extends GenericListItem> {
filteredItems: T[];
searchBuffer: TextBuffer | undefined;
searchQuery: string;
setSearchQuery: (query: string) => void;
maxLabelWidth: number;
}
/**
* Props for the SearchableList component.
*/
export interface SearchableListProps<T extends GenericListItem> {
title?: string;
items: T[];
onSelect: (item: T) => void;
onClose: () => void;
searchPlaceholder?: string;
/** Custom item renderer */
renderItem?: (
item: T,
isActive: boolean,
labelWidth: number,
) => React.ReactNode;
/** Optional header content */
header?: React.ReactNode;
/** Optional footer content */
footer?: (info: {
startIndex: number;
endIndex: number;
totalVisible: number;
}) => React.ReactNode;
maxItemsToShow?: number;
/** Hook to handle search logic */
useSearch: (props: {
items: T[];
onSearch?: (query: string) => void;
}) => SearchListState<T>;
onSearch?: (query: string) => void;
/** Whether to reset selection to the top when items change (e.g. after search) */
resetSelectionOnItemsChange?: boolean;
}
/**
* A generic searchable list component with keyboard navigation.
*/
export function SearchableList<T extends GenericListItem>({
title,
items,
onSelect,
onClose,
searchPlaceholder = 'Search...',
renderItem,
header,
footer,
maxItemsToShow = 10,
useSearch,
onSearch,
resetSelectionOnItemsChange = false,
}: SearchableListProps<T>): React.JSX.Element {
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
items,
onSearch,
});
const selectionItems = useMemo(
() =>
filteredItems.map((item) => ({
key: item.key,
value: item,
})),
[filteredItems],
);
const handleSelectValue = useCallback(
(item: T) => {
onSelect(item);
},
[onSelect],
);
const { activeIndex, setActiveIndex } = useSelectionList({
items: selectionItems,
onSelect: handleSelectValue,
isFocused: true,
showNumbers: false,
wrapAround: true,
});
// Reset selection to top when items change if requested
const prevItemsRef = React.useRef(filteredItems);
React.useEffect(() => {
if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) {
setActiveIndex(0);
}
prevItemsRef.current = filteredItems;
}, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]);
// Handle global Escape key to close the list
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onClose();
return true;
}
return false;
},
{ isActive: true },
);
const scrollOffset = Math.max(
0,
Math.min(
activeIndex - Math.floor(maxItemsToShow / 2),
Math.max(0, filteredItems.length - maxItemsToShow),
),
);
const visibleItems = filteredItems.slice(
scrollOffset,
scrollOffset + maxItemsToShow,
);
const defaultRenderItem = (
item: T,
isActive: boolean,
labelWidth: number,
) => (
<Box flexDirection="column">
<Text
color={isActive ? theme.status.success : theme.text.primary}
bold={isActive}
>
{isActive ? '> ' : ' '}
{item.label.padEnd(labelWidth)}
</Text>
{item.description && (
<Box marginLeft={2}>
<Text color={theme.text.secondary} wrap="truncate-end">
{item.description}
</Text>
</Box>
)}
</Box>
);
return (
<Box flexDirection="column" width="100%" height="100%" paddingX={1}>
{title && (
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{title}
</Text>
</Box>
)}
{searchBuffer && (
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
marginBottom={1}
>
<TextInput
buffer={searchBuffer}
placeholder={searchPlaceholder}
focus={true}
/>
</Box>
)}
{header && <Box marginBottom={1}>{header}</Box>}
<Box flexDirection="column" flexGrow={1}>
{filteredItems.length === 0 ? (
<Box marginX={2}>
<Text color={theme.text.secondary}>No items found.</Text>
</Box>
) : (
visibleItems.map((item, index) => {
const isSelected = activeIndex === scrollOffset + index;
return (
<Box key={item.key} marginBottom={1}>
{renderItem
? renderItem(item, isSelected, maxLabelWidth)
: defaultRenderItem(item, isSelected, maxLabelWidth)}
</Box>
);
})
)}
</Box>
{footer && (
<Box marginTop={1}>
{footer({
startIndex: scrollOffset,
endIndex: scrollOffset + visibleItems.length,
totalVisible: filteredItems.length,
})}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SearchableList > should match snapshot 1`] = `
" Test List
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
> Item One
Description for item one
Item Two
Description for item two
Item Three
Description for item three
"
`;