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

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