mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Add ExtensionDetails dialog and support install (#20845)
This commit is contained in:
@@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
async installOrUpdateExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
): Promise<GeminiCLIExtension> {
|
||||
if (
|
||||
this.settings.security?.allowedExtensions &&
|
||||
@@ -247,7 +248,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
(result.failureReason === 'no release data' &&
|
||||
installMetadata.type === 'git') ||
|
||||
// Otherwise ask the user if they would like to try a git clone.
|
||||
(await this.requestConsent(
|
||||
(await (requestConsentOverride ?? this.requestConsent)(
|
||||
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.
|
||||
|
||||
Would you like to attempt to install via "git clone" instead?`,
|
||||
@@ -321,7 +322,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
this.requestConsent,
|
||||
requestConsentOverride ?? this.requestConsent,
|
||||
newHasHooks,
|
||||
previousExtensionConfig,
|
||||
previousHasHooks,
|
||||
|
||||
@@ -475,14 +475,18 @@ describe('extensionsCommand', () => {
|
||||
mockInstallExtension.mockResolvedValue({ name: extension.url });
|
||||
|
||||
// Call onSelect
|
||||
component.props.onSelect?.(extension);
|
||||
await component.props.onSelect?.(extension);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: extension.url,
|
||||
type: 'git',
|
||||
});
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: extension.url,
|
||||
type: 'git',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -622,10 +626,14 @@ describe('extensionsCommand', () => {
|
||||
mockInstallExtension.mockResolvedValue({ name: packageName });
|
||||
await installAction!(mockContext, packageName);
|
||||
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: packageName,
|
||||
type: 'git',
|
||||
});
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: packageName,
|
||||
type: 'git',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.INFO,
|
||||
text: `Installing extension from "${packageName}"...`,
|
||||
@@ -647,10 +655,14 @@ describe('extensionsCommand', () => {
|
||||
|
||||
await installAction!(mockContext, packageName);
|
||||
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: packageName,
|
||||
type: 'git',
|
||||
});
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: packageName,
|
||||
type: 'git',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to install extension from "${packageName}": ${errorMessage}`,
|
||||
|
||||
@@ -279,9 +279,9 @@ async function exploreAction(
|
||||
return {
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ExtensionRegistryView, {
|
||||
onSelect: (extension) => {
|
||||
onSelect: async (extension, requestConsentOverride) => {
|
||||
debugLogger.log(`Selected extension: ${extension.extensionName}`);
|
||||
void installAction(context, extension.url);
|
||||
await installAction(context, extension.url, requestConsentOverride);
|
||||
context.ui.removeComponent();
|
||||
},
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
@@ -458,7 +458,11 @@ async function enableAction(context: CommandContext, args: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function installAction(context: CommandContext, args: string) {
|
||||
async function installAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
) {
|
||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
@@ -505,8 +509,11 @@ async function installAction(context: CommandContext, args: string) {
|
||||
|
||||
try {
|
||||
const installMetadata = await inferInstallMetadata(source);
|
||||
const extension =
|
||||
await extensionLoader.installOrUpdateExtension(installMetadata);
|
||||
const extension = await extensionLoader.installOrUpdateExtension(
|
||||
installMetadata,
|
||||
undefined,
|
||||
requestConsentOverride,
|
||||
);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Extension "${extension.name}" installed successfully.`,
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface SearchableListProps<T extends GenericListItem> {
|
||||
onSearch?: (query: string) => void;
|
||||
/** Whether to reset selection to the top when items change (e.g. after search) */
|
||||
resetSelectionOnItemsChange?: boolean;
|
||||
/** Whether the list is focused and accepts keyboard input. Defaults to true. */
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +87,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
useSearch,
|
||||
onSearch,
|
||||
resetSelectionOnItemsChange = false,
|
||||
isFocused = true,
|
||||
}: SearchableListProps<T>): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
|
||||
@@ -111,7 +114,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
const { activeIndex, setActiveIndex } = useSelectionList({
|
||||
items: selectionItems,
|
||||
onSelect: handleSelectValue,
|
||||
isFocused: true,
|
||||
isFocused,
|
||||
showNumbers: false,
|
||||
wrapAround: true,
|
||||
priority: true,
|
||||
@@ -157,7 +160,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
const visibleItems = filteredItems.slice(
|
||||
@@ -209,7 +212,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
<TextInput
|
||||
buffer={searchBuffer}
|
||||
placeholder={searchPlaceholder}
|
||||
focus={true}
|
||||
focus={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
123
packages/cli/src/ui/components/views/ExtensionDetails.test.tsx
Normal file
123
packages/cli/src/ui/components/views/ExtensionDetails.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @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 { ExtensionDetails } from './ExtensionDetails.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
|
||||
const mockExtension: RegistryExtension = {
|
||||
id: 'ext1',
|
||||
extensionName: 'Test Extension',
|
||||
extensionDescription: 'A test extension description',
|
||||
fullName: 'author/test-extension',
|
||||
extensionVersion: '1.2.3',
|
||||
rank: 1,
|
||||
stars: 123,
|
||||
url: 'https://github.com/author/test-extension',
|
||||
repoDescription: 'Repo description',
|
||||
avatarUrl: '',
|
||||
lastUpdated: '2023-10-27',
|
||||
hasMCP: true,
|
||||
hasContext: true,
|
||||
hasHooks: true,
|
||||
hasSkills: true,
|
||||
hasCustomCommands: true,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: 'Apache-2.0',
|
||||
};
|
||||
|
||||
describe('ExtensionDetails', () => {
|
||||
let mockOnBack: ReturnType<typeof vi.fn>;
|
||||
let mockOnInstall: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnBack = vi.fn();
|
||||
mockOnInstall = vi.fn();
|
||||
});
|
||||
|
||||
const renderDetails = (isInstalled = false) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<ExtensionDetails
|
||||
extension={mockExtension}
|
||||
onBack={mockOnBack}
|
||||
onInstall={mockOnInstall}
|
||||
isInstalled={isInstalled}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
it('should render extension details correctly', async () => {
|
||||
const { lastFrame } = renderDetails();
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Test Extension');
|
||||
expect(lastFrame()).toContain('v1.2.3');
|
||||
expect(lastFrame()).toContain('123');
|
||||
expect(lastFrame()).toContain('[G]');
|
||||
expect(lastFrame()).toContain('author/test-extension');
|
||||
expect(lastFrame()).toContain('A test extension description');
|
||||
expect(lastFrame()).toContain('MCP');
|
||||
expect(lastFrame()).toContain('Context file');
|
||||
expect(lastFrame()).toContain('Hooks');
|
||||
expect(lastFrame()).toContain('Skills');
|
||||
expect(lastFrame()).toContain('Commands');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show install prompt when not installed', async () => {
|
||||
const { lastFrame } = renderDetails(false);
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[Enter] Install');
|
||||
expect(lastFrame()).not.toContain('Already Installed');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show already installed message when installed', async () => {
|
||||
const { lastFrame } = renderDetails(true);
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Already Installed');
|
||||
expect(lastFrame()).not.toContain('[Enter] Install');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onBack when Escape is pressed', async () => {
|
||||
const { stdin } = renderDetails();
|
||||
await React.act(async () => {
|
||||
stdin.write('\x1b'); // Escape
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockOnBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onInstall when Enter is pressed and not installed', async () => {
|
||||
const { stdin } = renderDetails(false);
|
||||
await React.act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockOnInstall).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT call onInstall when Enter is pressed and already installed', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { stdin } = renderDetails(true);
|
||||
await React.act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
// Advance timers to trigger the keypress flush
|
||||
await React.act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
expect(mockOnInstall).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
245
packages/cli/src/ui/components/views/ExtensionDetails.tsx
Normal file
245
packages/cli/src/ui/components/views/ExtensionDetails.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export interface ExtensionDetailsProps {
|
||||
extension: RegistryExtension;
|
||||
onBack: () => void;
|
||||
onInstall: (
|
||||
requestConsentOverride: (consent: string) => Promise<boolean>,
|
||||
) => void | Promise<void>;
|
||||
isInstalled: boolean;
|
||||
}
|
||||
|
||||
export function ExtensionDetails({
|
||||
extension,
|
||||
onBack,
|
||||
onInstall,
|
||||
isInstalled,
|
||||
}: ExtensionDetailsProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [consentRequest, setConsentRequest] = useState<{
|
||||
prompt: string;
|
||||
resolve: (value: boolean) => void;
|
||||
} | null>(null);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (consentRequest) {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
consentRequest.resolve(false);
|
||||
setConsentRequest(null);
|
||||
setIsInstalling(false);
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
consentRequest.resolve(true);
|
||||
setConsentRequest(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onBack();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) {
|
||||
setIsInstalling(true);
|
||||
void onInstall(
|
||||
(prompt: string) =>
|
||||
new Promise((resolve) => {
|
||||
setConsentRequest({ prompt, resolve });
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
if (consentRequest) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>{consentRequest.prompt}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} />
|
||||
<Box flexDirection="row" justifyContent="space-between" marginTop={1}>
|
||||
<Text color={theme.text.secondary}>[Esc] Cancel</Text>
|
||||
<Text color={theme.text.primary}>[Enter] Accept</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInstalling) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
Installing {extension.extensionName}...
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
{/* Header Row */}
|
||||
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{'>'} Extensions {'>'}{' '}
|
||||
</Text>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{extension.extensionName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{extension.extensionVersion ? `v${extension.extensionVersion}` : ''}{' '}
|
||||
|{' '}
|
||||
</Text>
|
||||
<Text color={theme.status.warning}>⭐ </Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{String(extension.stars || 0)} |{' '}
|
||||
</Text>
|
||||
{extension.isGoogleOwned && (
|
||||
<Text color={theme.text.primary}>[G] </Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>{extension.fullName}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
{extension.extensionDescription || extension.repoDescription}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Features List */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{[
|
||||
extension.hasMCP && { label: 'MCP', color: theme.text.primary },
|
||||
extension.hasContext && {
|
||||
label: 'Context file',
|
||||
color: theme.status.error,
|
||||
},
|
||||
extension.hasHooks && { label: 'Hooks', color: theme.status.warning },
|
||||
extension.hasSkills && {
|
||||
label: 'Skills',
|
||||
color: theme.status.success,
|
||||
},
|
||||
extension.hasCustomCommands && {
|
||||
label: 'Commands',
|
||||
color: theme.text.primary,
|
||||
},
|
||||
]
|
||||
.filter((f): f is { label: string; color: string } => !!f)
|
||||
.map((feature, index, array) => (
|
||||
<Box key={feature.label} flexDirection="row">
|
||||
<Text color={feature.color}>{feature.label} </Text>
|
||||
{index < array.length - 1 && (
|
||||
<Box marginRight={1}>
|
||||
<Text color={theme.text.secondary}>|</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Details about MCP / Context */}
|
||||
{extension.hasMCP && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
This extension will run the following MCP servers:
|
||||
</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.primary}>
|
||||
* {extension.extensionName} (local)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{extension.hasContext && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
This extension will append info to your gemini.md context using
|
||||
gemini.md
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Spacer to push warning to bottom */}
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{/* Warning Box */}
|
||||
{!isInstalled && (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
The extension you are about to install may have been created by a
|
||||
third-party developer and sourced{'\n'}
|
||||
from a public repository. Google does not vet, endorse, or guarantee
|
||||
the functionality or security{'\n'}
|
||||
of extensions. Please carefully inspect any extension and its source
|
||||
code before installing to{'\n'}
|
||||
understand the permissions it requires and the actions it may
|
||||
perform.
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{isInstalled && (
|
||||
<Box flexDirection="row" marginTop={1} justifyContent="center">
|
||||
<Text color={theme.status.success}>Already Installed</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -206,4 +206,34 @@ describe('ExtensionRegistryView', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSelect when extension is selected and Enter is pressed in details', async () => {
|
||||
const { stdin, lastFrame } = renderView();
|
||||
|
||||
// Select the first extension in the list (Enter opens details)
|
||||
await React.act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
// Verify we are in details view
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('author/ext1');
|
||||
expect(lastFrame()).toContain('[Enter] Install');
|
||||
});
|
||||
|
||||
// Ensure onSelect hasn't been called yet
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
|
||||
// Press Enter again in the details view to trigger install
|
||||
await React.act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(
|
||||
mockExtensions[0],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
|
||||
@@ -23,9 +23,13 @@ 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) => void;
|
||||
onSelect?: (
|
||||
extension: RegistryExtension,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
) => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
extensionManager: ExtensionManager;
|
||||
}
|
||||
@@ -45,6 +49,8 @@ export function ExtensionRegistryView({
|
||||
config.getExtensionRegistryURI(),
|
||||
);
|
||||
const { terminalHeight, staticExtraHeight } = useUIState();
|
||||
const [selectedExtension, setSelectedExtension] =
|
||||
useState<RegistryExtension | null>(null);
|
||||
|
||||
const { extensionsUpdateState } = useExtensionUpdates(
|
||||
extensionManager,
|
||||
@@ -52,7 +58,9 @@ export function ExtensionRegistryView({
|
||||
config.getEnableExtensionReloading(),
|
||||
);
|
||||
|
||||
const installedExtensions = extensionManager.getExtensions();
|
||||
const [installedExtensions, setInstalledExtensions] = useState(() =>
|
||||
extensionManager.getExtensions(),
|
||||
);
|
||||
|
||||
const items: ExtensionItem[] = useMemo(
|
||||
() =>
|
||||
@@ -65,11 +73,28 @@ export function ExtensionRegistryView({
|
||||
[extensions],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: ExtensionItem) => {
|
||||
onSelect?.(item.extension);
|
||||
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],
|
||||
[onSelect, extensionManager],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
@@ -206,19 +231,41 @@ export function ExtensionRegistryView({
|
||||
}
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
<>
|
||||
<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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user