mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-30 06:54:15 -07:00
Add ExtensionDetails dialog
This commit is contained in:
@@ -279,8 +279,8 @@ async function exploreAction(
|
|||||||
return {
|
return {
|
||||||
type: 'custom_dialog' as const,
|
type: 'custom_dialog' as const,
|
||||||
component: React.createElement(ExtensionRegistryView, {
|
component: React.createElement(ExtensionRegistryView, {
|
||||||
onSelect: (extension) => {
|
onSelect: async (extension) => {
|
||||||
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
|
await installAction(context, extension.url);
|
||||||
},
|
},
|
||||||
onClose: () => context.ui.removeComponent(),
|
onClose: () => context.ui.removeComponent(),
|
||||||
extensionManager,
|
extensionManager,
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface SearchableListProps<T extends GenericListItem> {
|
|||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
/** Whether to reset selection to the top when items change (e.g. after search) */
|
/** Whether to reset selection to the top when items change (e.g. after search) */
|
||||||
resetSelectionOnItemsChange?: boolean;
|
resetSelectionOnItemsChange?: boolean;
|
||||||
|
/** Whether the list is focused and accepts keyboard input. Defaults to true. */
|
||||||
|
isFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +86,7 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
useSearch,
|
useSearch,
|
||||||
onSearch,
|
onSearch,
|
||||||
resetSelectionOnItemsChange = false,
|
resetSelectionOnItemsChange = false,
|
||||||
|
isFocused = true,
|
||||||
}: SearchableListProps<T>): React.JSX.Element {
|
}: SearchableListProps<T>): React.JSX.Element {
|
||||||
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
|
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
|
||||||
items,
|
items,
|
||||||
@@ -109,7 +112,7 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
const { activeIndex, setActiveIndex } = useSelectionList({
|
const { activeIndex, setActiveIndex } = useSelectionList({
|
||||||
items: selectionItems,
|
items: selectionItems,
|
||||||
onSelect: handleSelectValue,
|
onSelect: handleSelectValue,
|
||||||
isFocused: true,
|
isFocused,
|
||||||
showNumbers: false,
|
showNumbers: false,
|
||||||
wrapAround: true,
|
wrapAround: true,
|
||||||
priority: true,
|
priority: true,
|
||||||
@@ -155,7 +158,7 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
{ isActive: true },
|
{ isActive: isFocused },
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleItems = filteredItems.slice(
|
const visibleItems = filteredItems.slice(
|
||||||
@@ -207,7 +210,7 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
<TextInput
|
<TextInput
|
||||||
buffer={searchBuffer}
|
buffer={searchBuffer}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
focus={true}
|
focus={isFocused}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* @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 () => {
|
||||||
|
const { stdin } = renderDetails(true);
|
||||||
|
await React.act(async () => {
|
||||||
|
stdin.write('\r'); // Enter
|
||||||
|
});
|
||||||
|
// Wait a bit to ensure it's not called
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
expect(mockOnInstall).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||||
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
|
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
|
export interface ExtensionDetailsProps {
|
||||||
|
extension: RegistryExtension;
|
||||||
|
onBack: () => void;
|
||||||
|
onInstall: () => void;
|
||||||
|
isInstalled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionDetails({
|
||||||
|
extension,
|
||||||
|
onBack,
|
||||||
|
onInstall,
|
||||||
|
isInstalled,
|
||||||
|
}: ExtensionDetailsProps): React.JSX.Element {
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
|
onBack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (keyMatchers[Command.RETURN](key) && !isInstalled) {
|
||||||
|
onInstall();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ isActive: true, priority: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<Box marginRight={1}>
|
||||||
|
<Text color={theme.text.primary}>MCP </Text>
|
||||||
|
<Text color={theme.text.secondary}>|</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{extension.hasContext && (
|
||||||
|
<Box marginRight={1}>
|
||||||
|
<Text color={theme.status.error}>Context file </Text>
|
||||||
|
<Text color={theme.text.secondary}>|</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{extension.hasHooks && (
|
||||||
|
<Box marginRight={1}>
|
||||||
|
<Text color={theme.status.warning}>Hooks </Text>
|
||||||
|
<Text color={theme.text.secondary}>|</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{extension.hasSkills && (
|
||||||
|
<Box marginRight={1}>
|
||||||
|
<Text color={theme.status.success}>Skills </Text>
|
||||||
|
<Text color={theme.text.secondary}>|</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{extension.hasCustomCommands && (
|
||||||
|
<Box marginRight={1}>
|
||||||
|
<Text color={theme.text.primary}>Commands</Text>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -203,4 +203,31 @@ 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback, useState } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||||
|
|
||||||
@@ -23,9 +23,10 @@ import type { ExtensionManager } from '../../../config/extension-manager.js';
|
|||||||
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||||
|
|
||||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||||
|
import { ExtensionDetails } from './ExtensionDetails.js';
|
||||||
|
|
||||||
interface ExtensionRegistryViewProps {
|
interface ExtensionRegistryViewProps {
|
||||||
onSelect?: (extension: RegistryExtension) => void;
|
onSelect?: (extension: RegistryExtension) => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
extensionManager: ExtensionManager;
|
extensionManager: ExtensionManager;
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,8 @@ export function ExtensionRegistryView({
|
|||||||
const { extensions, loading, error, search } = useExtensionRegistry();
|
const { extensions, loading, error, search } = useExtensionRegistry();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const { terminalHeight, staticExtraHeight } = useUIState();
|
const { terminalHeight, staticExtraHeight } = useUIState();
|
||||||
|
const [selectedExtension, setSelectedExtension] =
|
||||||
|
useState<RegistryExtension | null>(null);
|
||||||
|
|
||||||
const { extensionsUpdateState } = useExtensionUpdates(
|
const { extensionsUpdateState } = useExtensionUpdates(
|
||||||
extensionManager,
|
extensionManager,
|
||||||
@@ -49,7 +52,9 @@ export function ExtensionRegistryView({
|
|||||||
config.getEnableExtensionReloading(),
|
config.getEnableExtensionReloading(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const installedExtensions = extensionManager.getExtensions();
|
const [installedExtensions, setInstalledExtensions] = useState(() =>
|
||||||
|
extensionManager.getExtensions(),
|
||||||
|
);
|
||||||
|
|
||||||
const items: ExtensionItem[] = useMemo(
|
const items: ExtensionItem[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -62,11 +67,22 @@ export function ExtensionRegistryView({
|
|||||||
[extensions],
|
[extensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback((item: ExtensionItem) => {
|
||||||
(item: ExtensionItem) => {
|
setSelectedExtension(item.extension);
|
||||||
onSelect?.(item.extension);
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setSelectedExtension(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = useCallback(
|
||||||
|
async (extension: RegistryExtension) => {
|
||||||
|
await onSelect?.(extension);
|
||||||
|
// Refresh installed extensions list
|
||||||
|
setInstalledExtensions(extensionManager.getExtensions());
|
||||||
|
setSelectedExtension(null);
|
||||||
},
|
},
|
||||||
[onSelect],
|
[onSelect, extensionManager],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
@@ -203,19 +219,39 @@ export function ExtensionRegistryView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchableList<ExtensionItem>
|
<>
|
||||||
title="Extensions"
|
<Box
|
||||||
items={items}
|
display={selectedExtension ? 'none' : 'flex'}
|
||||||
onSelect={handleSelect}
|
flexDirection="column"
|
||||||
onClose={onClose || (() => {})}
|
width="100%"
|
||||||
searchPlaceholder="Search extension gallery"
|
height="100%"
|
||||||
renderItem={renderItem}
|
>
|
||||||
header={header}
|
<SearchableList<ExtensionItem>
|
||||||
footer={footer}
|
title="Extensions"
|
||||||
maxItemsToShow={maxItemsToShow}
|
items={items}
|
||||||
useSearch={useRegistrySearch}
|
onSelect={handleSelect}
|
||||||
onSearch={search}
|
onClose={onClose || (() => {})}
|
||||||
resetSelectionOnItemsChange={true}
|
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={() => handleInstall(selectedExtension)}
|
||||||
|
isInstalled={installedExtensions.some(
|
||||||
|
(e) => e.name === selectedExtension.extensionName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user