mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-16 22:36:48 -07:00
271 lines
8.2 KiB
TypeScript
271 lines
8.2 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type React from 'react';
|
|
import { useMemo, useCallback, useState } 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';
|
|
|
|
import { useUIState } from '../../contexts/UIStateContext.js';
|
|
import { ExtensionDetails } from './ExtensionDetails.js';
|
|
|
|
export interface ExtensionRegistryViewProps {
|
|
onSelect?: (
|
|
extension: RegistryExtension,
|
|
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
|
) => void | Promise<void>;
|
|
onClose?: () => void;
|
|
extensionManager: ExtensionManager;
|
|
}
|
|
|
|
interface ExtensionItem extends GenericListItem {
|
|
extension: RegistryExtension;
|
|
}
|
|
|
|
export function ExtensionRegistryView({
|
|
onSelect,
|
|
onClose,
|
|
extensionManager,
|
|
}: ExtensionRegistryViewProps): React.JSX.Element {
|
|
const config = useConfig();
|
|
const { extensions, loading, error, search } = useExtensionRegistry(
|
|
'',
|
|
config.getExtensionRegistryURI(),
|
|
);
|
|
const { terminalHeight, staticExtraHeight } = useUIState();
|
|
const [selectedExtension, setSelectedExtension] =
|
|
useState<RegistryExtension | null>(null);
|
|
|
|
const { extensionsUpdateState } = useExtensionUpdates(
|
|
extensionManager,
|
|
() => 0,
|
|
config.getEnableExtensionReloading(),
|
|
);
|
|
|
|
const [installedExtensions, setInstalledExtensions] = useState(() =>
|
|
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) => {
|
|
setSelectedExtension(item.extension);
|
|
}, []);
|
|
|
|
const handleBack = useCallback(() => {
|
|
setSelectedExtension(null);
|
|
}, []);
|
|
|
|
const handleInstall = useCallback(
|
|
async (
|
|
extension: RegistryExtension,
|
|
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
|
) => {
|
|
await onSelect?.(extension, requestConsentOverride);
|
|
|
|
// Refresh installed extensions list
|
|
setInstalledExtensions(extensionManager.getExtensions());
|
|
|
|
// Go back to the search page (list view)
|
|
setSelectedExtension(null);
|
|
},
|
|
[onSelect, extensionManager],
|
|
);
|
|
|
|
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>
|
|
),
|
|
[],
|
|
);
|
|
|
|
const maxItemsToShow = useMemo(() => {
|
|
// SearchableList layout overhead:
|
|
// Container paddingY: 0
|
|
// Title (marginBottom 1): 2
|
|
// Search buffer (border 2, marginBottom 1): 4
|
|
// Header (marginBottom 1): 2
|
|
// Footer (marginTop 1): 2
|
|
// List item (marginBottom 1): 2 per item
|
|
// Total static height = 2 + 4 + 2 + 2 = 10
|
|
const staticHeight = 10;
|
|
const availableTerminalHeight = terminalHeight - staticExtraHeight;
|
|
const remainingHeight = Math.max(0, availableTerminalHeight - staticHeight);
|
|
const itemHeight = 2; // Each item takes 2 lines (content + marginBottom 1)
|
|
|
|
// Ensure we show at least a few items and not more than we have
|
|
return Math.max(4, Math.floor(remainingHeight / itemHeight));
|
|
}, [terminalHeight, staticExtraHeight]);
|
|
|
|
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 (
|
|
<>
|
|
<Box
|
|
display={selectedExtension ? 'none' : 'flex'}
|
|
flexDirection="column"
|
|
width="100%"
|
|
height="100%"
|
|
>
|
|
<SearchableList<ExtensionItem>
|
|
title="Extensions"
|
|
items={items}
|
|
onSelect={handleSelect}
|
|
onClose={onClose || (() => {})}
|
|
searchPlaceholder="Search extension gallery"
|
|
renderItem={renderItem}
|
|
header={header}
|
|
footer={footer}
|
|
maxItemsToShow={maxItemsToShow}
|
|
useSearch={useRegistrySearch}
|
|
onSearch={search}
|
|
resetSelectionOnItemsChange={true}
|
|
isFocused={!selectedExtension}
|
|
/>
|
|
</Box>
|
|
{selectedExtension && (
|
|
<ExtensionDetails
|
|
extension={selectedExtension}
|
|
onBack={handleBack}
|
|
onInstall={async (requestConsentOverride) => {
|
|
await handleInstall(selectedExtension, requestConsentOverride);
|
|
}}
|
|
isInstalled={installedExtensions.some(
|
|
(e) => e.name === selectedExtension.extensionName,
|
|
)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|