mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 17:11:04 -07:00
Add initial implementation of /extensions explore command (#19029)
This commit is contained in:
233
packages/cli/src/ui/components/shared/SearchableList.test.tsx
Normal file
233
packages/cli/src/ui/components/shared/SearchableList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
231
packages/cli/src/ui/components/shared/SearchableList.tsx
Normal file
231
packages/cli/src/ui/components/shared/SearchableList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
"
|
||||
`;
|
||||
Reference in New Issue
Block a user