mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Add initial implementation of /extensions explore command (#19029)
This commit is contained in:
@@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => {
|
||||
'Failed to fetch extensions: Not Found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return irrelevant results', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
...mockExtensions,
|
||||
{
|
||||
id: 'dataplex',
|
||||
extensionName: 'dataplex',
|
||||
extensionDescription: 'Connect to Dataplex Universal Catalog...',
|
||||
fullName: 'google-cloud/dataplex',
|
||||
rank: 6,
|
||||
stars: 6,
|
||||
url: '',
|
||||
repoDescription: '',
|
||||
lastUpdated: '',
|
||||
extensionVersion: '1.0.0',
|
||||
avatarUrl: '',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: '',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'conductor',
|
||||
extensionName: 'conductor',
|
||||
extensionDescription: 'A conductor extension that actually matches.',
|
||||
fullName: 'someone/conductor',
|
||||
rank: 100,
|
||||
stars: 100,
|
||||
url: '',
|
||||
repoDescription: '',
|
||||
lastUpdated: '',
|
||||
extensionVersion: '1.0.0',
|
||||
avatarUrl: '',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: '',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('conductor');
|
||||
const ids = results.map((r) => r.id);
|
||||
|
||||
expect(ids).not.toContain('dataplex');
|
||||
expect(ids).toContain('conductor');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ export class ExtensionRegistryClient {
|
||||
const fzf = new AsyncFzf(allExtensions, {
|
||||
selector: (ext: RegistryExtension) =>
|
||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
||||
fuzzy: 'v2',
|
||||
fuzzy: true,
|
||||
});
|
||||
const results = await fzf.find(query);
|
||||
return results.map((r: { item: RegistryExtension }) => r.item);
|
||||
@@ -108,7 +108,6 @@ export class ExtensionRegistryClient {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import open from 'open';
|
||||
@@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises';
|
||||
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
||||
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
||||
import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js';
|
||||
import React from 'react';
|
||||
|
||||
function showMessageIfNoExtensions(
|
||||
@@ -265,7 +267,28 @@ async function restartAction(
|
||||
}
|
||||
}
|
||||
|
||||
async function exploreAction(context: CommandContext) {
|
||||
async function exploreAction(
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const settings = context.services.settings.merged;
|
||||
const useRegistryUI = settings.experimental?.extensionRegistry;
|
||||
|
||||
if (useRegistryUI) {
|
||||
const extensionManager = context.services.config?.getExtensionLoader();
|
||||
if (extensionManager instanceof ExtensionManager) {
|
||||
return {
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ExtensionRegistryView, {
|
||||
onSelect: (extension) => {
|
||||
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
|
||||
},
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
extensionManager,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||
|
||||
// Only check for NODE_ENV for explicit test mode, not for unit test framework
|
||||
|
||||
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
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 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 { ExtensionRegistryView } from './ExtensionRegistryView.js';
|
||||
import { type ExtensionManager } from '../../../config/extension-manager.js';
|
||||
import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js';
|
||||
import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js';
|
||||
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import {
|
||||
type SearchListState,
|
||||
type GenericListItem,
|
||||
} from '../shared/SearchableList.js';
|
||||
import { type TextBuffer } from '../shared/text-buffer.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../../hooks/useExtensionRegistry.js');
|
||||
vi.mock('../../hooks/useExtensionUpdates.js');
|
||||
vi.mock('../../hooks/useRegistrySearch.js');
|
||||
vi.mock('../../../config/extension-manager.js');
|
||||
vi.mock('../../contexts/UIStateContext.js');
|
||||
vi.mock('../../contexts/ConfigContext.js');
|
||||
|
||||
const mockExtensions: RegistryExtension[] = [
|
||||
{
|
||||
id: 'ext1',
|
||||
extensionName: 'Test Extension 1',
|
||||
extensionDescription: 'Description 1',
|
||||
fullName: 'author/ext1',
|
||||
extensionVersion: '1.0.0',
|
||||
rank: 1,
|
||||
stars: 10,
|
||||
url: 'http://example.com',
|
||||
repoDescription: 'Repo Desc 1',
|
||||
avatarUrl: 'http://avatar.com',
|
||||
lastUpdated: '2023-01-01',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
hasHooks: false,
|
||||
hasSkills: false,
|
||||
hasCustomCommands: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: 'mit',
|
||||
},
|
||||
{
|
||||
id: 'ext2',
|
||||
extensionName: 'Test Extension 2',
|
||||
extensionDescription: 'Description 2',
|
||||
fullName: 'author/ext2',
|
||||
extensionVersion: '2.0.0',
|
||||
rank: 2,
|
||||
stars: 20,
|
||||
url: 'http://example.com/2',
|
||||
repoDescription: 'Repo Desc 2',
|
||||
avatarUrl: 'http://avatar.com/2',
|
||||
lastUpdated: '2023-01-02',
|
||||
hasMCP: true,
|
||||
hasContext: true,
|
||||
hasHooks: true,
|
||||
hasSkills: true,
|
||||
hasCustomCommands: true,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: 'apache-2.0',
|
||||
},
|
||||
];
|
||||
|
||||
describe('ExtensionRegistryView', () => {
|
||||
let mockExtensionManager: ExtensionManager;
|
||||
let mockOnSelect: ReturnType<typeof vi.fn>;
|
||||
let mockOnClose: ReturnType<typeof vi.fn>;
|
||||
let mockSearch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockExtensionManager = {
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
} as unknown as ExtensionManager;
|
||||
|
||||
mockOnSelect = vi.fn();
|
||||
mockOnClose = vi.fn();
|
||||
mockSearch = vi.fn();
|
||||
|
||||
vi.mocked(useExtensionRegistry).mockReturnValue({
|
||||
extensions: mockExtensions,
|
||||
loading: false,
|
||||
error: null,
|
||||
search: mockSearch,
|
||||
});
|
||||
|
||||
vi.mocked(useExtensionUpdates).mockReturnValue({
|
||||
extensionsUpdateState: new Map(),
|
||||
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
||||
|
||||
// Mock useRegistrySearch implementation
|
||||
vi.mocked(useRegistrySearch).mockImplementation(
|
||||
(props: { items: GenericListItem[]; onSearch?: (q: string) => void }) =>
|
||||
({
|
||||
filteredItems: props.items, // Pass through items
|
||||
searchBuffer: {
|
||||
text: '',
|
||||
cursorOffset: 0,
|
||||
viewport: { width: 10, height: 1 },
|
||||
visualCursor: [0, 0] as [number, number],
|
||||
viewportVisualLines: [{ text: '', visualRowIndex: 0 }],
|
||||
visualScrollRow: 0,
|
||||
lines: [''],
|
||||
cursor: [0, 0] as [number, number],
|
||||
selectionAnchor: undefined,
|
||||
} as unknown as TextBuffer,
|
||||
searchQuery: '',
|
||||
setSearchQuery: vi.fn(),
|
||||
maxLabelWidth: 10,
|
||||
}) as unknown as SearchListState<GenericListItem>,
|
||||
);
|
||||
|
||||
vi.mocked(useUIState).mockReturnValue({
|
||||
mainAreaWidth: 100,
|
||||
} as unknown as ReturnType<typeof useUIState>);
|
||||
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
|
||||
} as unknown as ReturnType<typeof useConfig>);
|
||||
});
|
||||
|
||||
const renderView = () =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<ExtensionRegistryView
|
||||
extensionManager={mockExtensionManager}
|
||||
onSelect={mockOnSelect}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
it('should render extensions', async () => {
|
||||
const { lastFrame } = renderView();
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Test Extension 1');
|
||||
expect(lastFrame()).toContain('Test Extension 2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use useRegistrySearch hook', () => {
|
||||
renderView();
|
||||
expect(useRegistrySearch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call search function when typing', async () => {
|
||||
// Mock useRegistrySearch to trigger onSearch
|
||||
vi.mocked(useRegistrySearch).mockImplementation(
|
||||
(props: {
|
||||
items: GenericListItem[];
|
||||
onSearch?: (q: string) => void;
|
||||
}): SearchListState<GenericListItem> => {
|
||||
const { onSearch } = props;
|
||||
// Simulate typing
|
||||
React.useEffect(() => {
|
||||
if (onSearch) {
|
||||
onSearch('test query');
|
||||
}
|
||||
}, [onSearch]);
|
||||
return {
|
||||
filteredItems: props.items,
|
||||
searchBuffer: {
|
||||
text: 'test query',
|
||||
cursorOffset: 10,
|
||||
viewport: { width: 10, height: 1 },
|
||||
visualCursor: [0, 10] as [number, number],
|
||||
viewportVisualLines: [{ text: 'test query', visualRowIndex: 0 }],
|
||||
visualScrollRow: 0,
|
||||
lines: ['test query'],
|
||||
cursor: [0, 10] as [number, number],
|
||||
selectionAnchor: undefined,
|
||||
} as unknown as TextBuffer,
|
||||
searchQuery: 'test query',
|
||||
setSearchQuery: vi.fn(),
|
||||
maxLabelWidth: 10,
|
||||
} as unknown as SearchListState<GenericListItem>;
|
||||
},
|
||||
);
|
||||
|
||||
renderView();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useRegistrySearch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onSearch: mockSearch,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
200
packages/cli/src/ui/components/views/ExtensionRegistryView.tsx
Normal file
200
packages/cli/src/ui/components/views/ExtensionRegistryView.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
|
||||
import {
|
||||
SearchableList,
|
||||
type GenericListItem,
|
||||
} from '../shared/SearchableList.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import type { ExtensionManager } from '../../../config/extension-manager.js';
|
||||
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||
|
||||
interface ExtensionRegistryViewProps {
|
||||
onSelect?: (extension: RegistryExtension) => void;
|
||||
onClose?: () => void;
|
||||
extensionManager: ExtensionManager;
|
||||
}
|
||||
|
||||
interface ExtensionItem extends GenericListItem {
|
||||
extension: RegistryExtension;
|
||||
}
|
||||
|
||||
export function ExtensionRegistryView({
|
||||
onSelect,
|
||||
onClose,
|
||||
extensionManager,
|
||||
}: ExtensionRegistryViewProps): React.JSX.Element {
|
||||
const { extensions, loading, error, search } = useExtensionRegistry();
|
||||
const config = useConfig();
|
||||
|
||||
const { extensionsUpdateState } = useExtensionUpdates(
|
||||
extensionManager,
|
||||
() => 0,
|
||||
config.getEnableExtensionReloading(),
|
||||
);
|
||||
|
||||
const installedExtensions = extensionManager.getExtensions();
|
||||
|
||||
const items: ExtensionItem[] = useMemo(
|
||||
() =>
|
||||
extensions.map((ext) => ({
|
||||
key: ext.id,
|
||||
label: ext.extensionName,
|
||||
description: ext.extensionDescription || ext.repoDescription,
|
||||
extension: ext,
|
||||
})),
|
||||
[extensions],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: ExtensionItem) => {
|
||||
onSelect?.(item.extension);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: ExtensionItem, isActive: boolean, _labelWidth: number) => {
|
||||
const isInstalled = installedExtensions.some(
|
||||
(e) => e.name === item.extension.extensionName,
|
||||
);
|
||||
const updateState = extensionsUpdateState.get(
|
||||
item.extension.extensionName,
|
||||
);
|
||||
const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width="100%" justifyContent="space-between">
|
||||
<Box flexDirection="row" flexShrink={1} minWidth={0}>
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.secondary}
|
||||
>
|
||||
{isActive ? '> ' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text
|
||||
bold={isActive}
|
||||
color={isActive ? theme.status.success : theme.text.primary}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginX={1}>
|
||||
<Text color={theme.text.secondary}>|</Text>
|
||||
</Box>
|
||||
{isInstalled && (
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Text color={theme.status.success}>[Installed]</Text>
|
||||
</Box>
|
||||
)}
|
||||
{hasUpdate && (
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Text color={theme.status.warning}>[Update available]</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexShrink={1} minWidth={0}>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{item.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginLeft={2} width={8} flexDirection="row">
|
||||
<Text color={theme.status.warning}>⭐</Text>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.secondary}
|
||||
>
|
||||
{' '}
|
||||
{item.extension.stars || 0}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[installedExtensions, extensionsUpdateState],
|
||||
);
|
||||
|
||||
const header = useMemo(
|
||||
() => (
|
||||
<Box flexDirection="row" justifyContent="space-between" width="100%">
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
Browse and search extensions from the registry.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{installedExtensions.length &&
|
||||
`${installedExtensions.length} installed`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
[installedExtensions.length],
|
||||
);
|
||||
|
||||
const footer = useCallback(
|
||||
({
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalVisible,
|
||||
}: {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
totalVisible: number;
|
||||
}) => (
|
||||
<Text color={theme.text.secondary}>
|
||||
({startIndex + 1}-{endIndex}) / {totalVisible}
|
||||
</Text>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text color={theme.text.secondary}>Loading extensions...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box padding={1} flexDirection="column">
|
||||
<Text color={theme.status.error}>Error loading extensions:</Text>
|
||||
<Text color={theme.text.secondary}>{error}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchableList<ExtensionItem>
|
||||
title="Extensions"
|
||||
items={items}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose || (() => {})}
|
||||
searchPlaceholder="Search extension gallery"
|
||||
renderItem={renderItem}
|
||||
header={header}
|
||||
footer={footer}
|
||||
maxItemsToShow={8}
|
||||
useSearch={useRegistrySearch}
|
||||
onSearch={search}
|
||||
resetSelectionOnItemsChange={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
101
packages/cli/src/ui/hooks/useExtensionRegistry.ts
Normal file
101
packages/cli/src/ui/hooks/useExtensionRegistry.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
ExtensionRegistryClient,
|
||||
type RegistryExtension,
|
||||
} from '../../config/extensionRegistryClient.js';
|
||||
|
||||
export interface UseExtensionRegistryResult {
|
||||
extensions: RegistryExtension[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
search: (query: string) => void;
|
||||
}
|
||||
|
||||
export function useExtensionRegistry(
|
||||
initialQuery = '',
|
||||
): UseExtensionRegistryResult {
|
||||
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const client = useMemo(() => new ExtensionRegistryClient(), []);
|
||||
|
||||
// Ref to track the latest query to avoid race conditions
|
||||
const latestQueryRef = useRef(initialQuery);
|
||||
|
||||
// Ref for debounce timeout
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const searchExtensions = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const results = await client.searchExtensions(query);
|
||||
|
||||
// Only update if this is still the latest query
|
||||
if (query === latestQueryRef.current) {
|
||||
// Check if results are different from current extensions
|
||||
setExtensions((prev) => {
|
||||
if (
|
||||
prev.length === results.length &&
|
||||
prev.every((ext, i) => ext.id === results[i].id)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return results;
|
||||
});
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (query === latestQueryRef.current) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setExtensions([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string) => {
|
||||
latestQueryRef.current = query;
|
||||
|
||||
// Clear existing timeout
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
void searchExtensions(query);
|
||||
}, 300);
|
||||
},
|
||||
[searchExtensions],
|
||||
);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void searchExtensions(initialQuery);
|
||||
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [initialQuery, searchExtensions]);
|
||||
|
||||
return {
|
||||
extensions,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
};
|
||||
}
|
||||
67
packages/cli/src/ui/hooks/useRegistrySearch.ts
Normal file
67
packages/cli/src/ui/hooks/useRegistrySearch.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
useTextBuffer,
|
||||
type TextBuffer,
|
||||
} from '../components/shared/text-buffer.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import type { GenericListItem } from '../components/shared/SearchableList.js';
|
||||
|
||||
const MIN_VIEWPORT_WIDTH = 20;
|
||||
const VIEWPORT_WIDTH_OFFSET = 8;
|
||||
|
||||
export interface UseRegistrySearchResult<T extends GenericListItem> {
|
||||
filteredItems: T[];
|
||||
searchBuffer: TextBuffer | undefined;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
maxLabelWidth: number;
|
||||
}
|
||||
|
||||
export function useRegistrySearch<T extends GenericListItem>(props: {
|
||||
items: T[];
|
||||
initialQuery?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
}): UseRegistrySearchResult<T> {
|
||||
const { items, initialQuery = '', onSearch } = props;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||||
|
||||
useEffect(() => {
|
||||
onSearch?.(searchQuery);
|
||||
}, [searchQuery, onSearch]);
|
||||
|
||||
const { mainAreaWidth } = useUIState();
|
||||
const viewportWidth = Math.max(
|
||||
MIN_VIEWPORT_WIDTH,
|
||||
mainAreaWidth - VIEWPORT_WIDTH_OFFSET,
|
||||
);
|
||||
|
||||
const searchBuffer = useTextBuffer({
|
||||
initialText: searchQuery,
|
||||
initialCursorOffset: searchQuery.length,
|
||||
viewport: {
|
||||
width: viewportWidth,
|
||||
height: 1,
|
||||
},
|
||||
singleLine: true,
|
||||
onChange: (text) => setSearchQuery(text),
|
||||
});
|
||||
|
||||
const maxLabelWidth = 0;
|
||||
|
||||
const filteredItems = items;
|
||||
|
||||
return {
|
||||
filteredItems,
|
||||
searchBuffer,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
maxLabelWidth,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user