/** * @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, ) => void | Promise; onLink?: ( extension: RegistryExtension, requestConsentOverride?: (consent: string) => Promise, ) => void | Promise; onClose?: () => void; extensionManager: ExtensionManager; } interface ExtensionItem extends GenericListItem { extension: RegistryExtension; } export function ExtensionRegistryView({ onSelect, onLink, onClose, extensionManager, }: ExtensionRegistryViewProps): React.JSX.Element { const config = useConfig(); const { extensions, loading, error, search } = useExtensionRegistry( '', config.getExtensionRegistryURI(), ); const { terminalHeight, staticExtraHeight, historyManager } = useUIState(); const [selectedExtension, setSelectedExtension] = useState(null); const { extensionsUpdateState, dispatchExtensionStateUpdate } = useExtensionUpdates( extensionManager, historyManager.addItem, 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, ) => { await onSelect?.(extension, requestConsentOverride); // Refresh installed extensions list setInstalledExtensions(extensionManager.getExtensions()); // Go back to the search page (list view) setSelectedExtension(null); }, [onSelect, extensionManager], ); const handleLink = useCallback( async ( extension: RegistryExtension, requestConsentOverride?: (consent: string) => Promise, ) => { await onLink?.(extension, requestConsentOverride); // Refresh installed extensions list setInstalledExtensions(extensionManager.getExtensions()); // Go back to the search page (list view) setSelectedExtension(null); }, [onLink, extensionManager], ); const handleUpdate = useCallback( async (extension: RegistryExtension) => { dispatchExtensionStateUpdate({ type: 'SCHEDULE_UPDATE', payload: { all: false, names: [extension.extensionName], onComplete: () => { // Refresh installed extensions list if needed setInstalledExtensions(extensionManager.getExtensions()); }, }, }); }, [dispatchExtensionStateUpdate, 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, ); return ( {isActive ? '● ' : ' '} {item.label} | {updateState === ExtensionUpdateState.UPDATE_AVAILABLE ? ( [Update available] ) : updateState === ExtensionUpdateState.UPDATING ? ( [Updating...] ) : ( isInstalled && ( [Installed] ) )} {item.description} ⭐ {' '} {item.extension.stars || 0} ); }, [installedExtensions, extensionsUpdateState], ); const header = useMemo( () => ( Browse and search extensions from the registry. {installedExtensions.length && `${installedExtensions.length} installed`} ), [installedExtensions.length], ); const footer = useCallback( ({ startIndex, endIndex, totalVisible, }: { startIndex: number; endIndex: number; totalVisible: number; }) => ( ({startIndex + 1}-{endIndex}) / {totalVisible} ), [], ); 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 ( Loading extensions... ); } if (error) { return ( Error loading extensions: {error} ); } return ( <> 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} /> {selectedExtension && ( { await handleInstall(selectedExtension, requestConsentOverride); }} onLink={async (requestConsentOverride) => { await handleLink(selectedExtension, requestConsentOverride); }} isInstalled={installedExtensions.some( (e) => e.name === selectedExtension.extensionName, )} updateState={extensionsUpdateState.get( selectedExtension.extensionName, )} onUpdate={async () => { await handleUpdate(selectedExtension); }} /> )} ); }