mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-04 10:21:02 -07:00
feat(cli): add UI to update extensions (#23682)
This commit is contained in:
@@ -10,6 +10,7 @@ import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ExtensionDetails } from './ExtensionDetails.js';
|
||||
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
|
||||
const mockExtension: RegistryExtension = {
|
||||
id: 'ext1',
|
||||
@@ -48,7 +49,11 @@ describe('ExtensionDetails', () => {
|
||||
mockOnLink = vi.fn();
|
||||
});
|
||||
|
||||
const renderDetails = async (isInstalled = false) =>
|
||||
const renderDetails = async (
|
||||
isInstalled = false,
|
||||
updateState?: ExtensionUpdateState,
|
||||
onUpdate = vi.fn(),
|
||||
) =>
|
||||
renderWithProviders(
|
||||
<ExtensionDetails
|
||||
extension={mockExtension}
|
||||
@@ -56,6 +61,8 @@ describe('ExtensionDetails', () => {
|
||||
onInstall={mockOnInstall}
|
||||
onLink={mockOnLink}
|
||||
isInstalled={isInstalled}
|
||||
updateState={updateState}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -165,4 +172,40 @@ describe('ExtensionDetails', () => {
|
||||
expect(lastFrame()).toContain('[L] Link');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show update button when update is available', async () => {
|
||||
const { lastFrame } = await renderDetails(
|
||||
true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[I] Update');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onUpdate when "i" is pressed', async () => {
|
||||
const mockOnUpdate = vi.fn();
|
||||
const { stdin } = await renderDetails(
|
||||
true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
mockOnUpdate,
|
||||
);
|
||||
await React.act(async () => {
|
||||
stdin.write('i');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockOnUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show [Updating...] and hide "Already Installed" when update is in progress', async () => {
|
||||
const { lastFrame } = await renderDetails(
|
||||
true,
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[Updating...]');
|
||||
expect(lastFrame()).not.toContain('Already Installed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
|
||||
export interface ExtensionDetailsProps {
|
||||
extension: RegistryExtension;
|
||||
@@ -23,6 +24,8 @@ export interface ExtensionDetailsProps {
|
||||
requestConsentOverride: (consent: string) => Promise<boolean>,
|
||||
) => void | Promise<void>;
|
||||
isInstalled: boolean;
|
||||
updateState?: ExtensionUpdateState;
|
||||
onUpdate?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ExtensionDetails({
|
||||
@@ -31,6 +34,8 @@ export function ExtensionDetails({
|
||||
onInstall,
|
||||
onLink,
|
||||
isInstalled,
|
||||
updateState,
|
||||
onUpdate,
|
||||
}: ExtensionDetailsProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [consentRequest, setConsentRequest] = useState<{
|
||||
@@ -76,7 +81,12 @@ export function ExtensionDetails({
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) {
|
||||
if (
|
||||
keyMatchers[Command.LINK_EXTENSION](key) &&
|
||||
isLinkable &&
|
||||
!isInstalled &&
|
||||
!isInstalling
|
||||
) {
|
||||
setIsInstalling(true);
|
||||
void onLink(
|
||||
(prompt: string) =>
|
||||
@@ -86,6 +96,14 @@ export function ExtensionDetails({
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
keyMatchers[Command.UPDATE_EXTENSION](key) &&
|
||||
updateState === ExtensionUpdateState.UPDATE_AVAILABLE &&
|
||||
!isInstalling
|
||||
) {
|
||||
void onUpdate?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
@@ -150,6 +168,16 @@ export function ExtensionDetails({
|
||||
<Text color={theme.text.primary} bold>
|
||||
{extension.extensionName}
|
||||
</Text>
|
||||
{updateState === ExtensionUpdateState.UPDATE_AVAILABLE && (
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.status.warning}>[I] Update</Text>
|
||||
</Box>
|
||||
)}
|
||||
{updateState === ExtensionUpdateState.UPDATING && (
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>[Updating...]</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
@@ -258,7 +286,7 @@ export function ExtensionDetails({
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{isInstalled && (
|
||||
{isInstalled && updateState !== ExtensionUpdateState.UPDATING && (
|
||||
<Box flexDirection="row" marginTop={1} justifyContent="center">
|
||||
<Text color={theme.status.success}>Already Installed</Text>
|
||||
</Box>
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
type GenericListItem,
|
||||
} from '../shared/SearchableList.js';
|
||||
import { type TextBuffer } from '../shared/text-buffer.js';
|
||||
import { type UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../../hooks/useExtensionRegistry.js');
|
||||
@@ -97,6 +99,7 @@ describe('ExtensionRegistryView', () => {
|
||||
|
||||
vi.mocked(useExtensionUpdates).mockReturnValue({
|
||||
extensionsUpdateState: new Map(),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
||||
|
||||
// Mock useRegistrySearch implementation
|
||||
@@ -134,6 +137,9 @@ describe('ExtensionRegistryView', () => {
|
||||
uiState: {
|
||||
staticExtraHeight: 5,
|
||||
terminalHeight: 40,
|
||||
historyManager: {
|
||||
addItem: vi.fn(),
|
||||
} as unknown as UseHistoryManagerReturn,
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
@@ -226,4 +232,42 @@ describe('ExtensionRegistryView', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show [Update available] and hide [Installed] when update is available', async () => {
|
||||
mockExtensionManager.getExtensions = vi
|
||||
.fn()
|
||||
.mockReturnValue([{ name: 'Test Extension 1' }]);
|
||||
vi.mocked(useExtensionUpdates).mockReturnValue({
|
||||
extensionsUpdateState: new Map([
|
||||
['Test Extension 1', ExtensionUpdateState.UPDATE_AVAILABLE],
|
||||
]),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
||||
|
||||
const { lastFrame } = await renderView();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[Update available]');
|
||||
expect(lastFrame()).not.toContain('[Installed]');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show [Updating...] and hide [Installed] when update is in progress', async () => {
|
||||
mockExtensionManager.getExtensions = vi
|
||||
.fn()
|
||||
.mockReturnValue([{ name: 'Test Extension 1' }]);
|
||||
vi.mocked(useExtensionUpdates).mockReturnValue({
|
||||
extensionsUpdateState: new Map([
|
||||
['Test Extension 1', ExtensionUpdateState.UPDATING],
|
||||
]),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
||||
|
||||
const { lastFrame } = await renderView();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[Updating...]');
|
||||
expect(lastFrame()).not.toContain('[Installed]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,15 +52,16 @@ export function ExtensionRegistryView({
|
||||
'',
|
||||
config.getExtensionRegistryURI(),
|
||||
);
|
||||
const { terminalHeight, staticExtraHeight } = useUIState();
|
||||
const { terminalHeight, staticExtraHeight, historyManager } = useUIState();
|
||||
const [selectedExtension, setSelectedExtension] =
|
||||
useState<RegistryExtension | null>(null);
|
||||
|
||||
const { extensionsUpdateState } = useExtensionUpdates(
|
||||
extensionManager,
|
||||
() => 0,
|
||||
config.getEnableExtensionReloading(),
|
||||
);
|
||||
const { extensionsUpdateState, dispatchExtensionStateUpdate } =
|
||||
useExtensionUpdates(
|
||||
extensionManager,
|
||||
historyManager.addItem,
|
||||
config.getEnableExtensionReloading(),
|
||||
);
|
||||
|
||||
const [installedExtensions, setInstalledExtensions] = useState(() =>
|
||||
extensionManager.getExtensions(),
|
||||
@@ -117,6 +118,23 @@ export function ExtensionRegistryView({
|
||||
[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(
|
||||
@@ -125,7 +143,6 @@ export function ExtensionRegistryView({
|
||||
const updateState = extensionsUpdateState.get(
|
||||
item.extension.extensionName,
|
||||
);
|
||||
const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width="100%" justifyContent="space-between">
|
||||
@@ -148,15 +165,20 @@ export function ExtensionRegistryView({
|
||||
<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 && (
|
||||
{updateState === ExtensionUpdateState.UPDATE_AVAILABLE ? (
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Text color={theme.status.warning}>[Update available]</Text>
|
||||
</Box>
|
||||
) : updateState === ExtensionUpdateState.UPDATING ? (
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Text color={theme.text.secondary}>[Updating...]</Text>
|
||||
</Box>
|
||||
) : (
|
||||
isInstalled && (
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Text color={theme.status.success}>[Installed]</Text>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
<Box flexShrink={1} minWidth={0}>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
@@ -287,6 +309,12 @@ export function ExtensionRegistryView({
|
||||
isInstalled={installedExtensions.some(
|
||||
(e) => e.name === selectedExtension.extensionName,
|
||||
)}
|
||||
updateState={extensionsUpdateState.get(
|
||||
selectedExtension.extensionName,
|
||||
)}
|
||||
onUpdate={async () => {
|
||||
await handleUpdate(selectedExtension);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -105,6 +105,10 @@ export enum Command {
|
||||
UNFOCUS_BACKGROUND_SHELL = 'background.unfocus',
|
||||
UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList',
|
||||
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'background.unfocusWarning',
|
||||
|
||||
// Extension Controls
|
||||
UPDATE_EXTENSION = 'extension.update',
|
||||
LINK_EXTENSION = 'extension.link',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,6 +406,10 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]],
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||
|
||||
// Extension Controls
|
||||
[Command.UPDATE_EXTENSION, [new KeyBinding('i')]],
|
||||
[Command.LINK_EXTENSION, [new KeyBinding('l')]],
|
||||
]);
|
||||
|
||||
interface CommandCategory {
|
||||
@@ -529,6 +537,10 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Extension Controls',
|
||||
commands: [Command.UPDATE_EXTENSION, Command.LINK_EXTENSION],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -638,6 +650,10 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
'Move focus from background shell list to Gemini.',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to move focus away from background shell.',
|
||||
|
||||
// Extension Controls
|
||||
[Command.UPDATE_EXTENSION]: 'Update the current extension if available.',
|
||||
[Command.LINK_EXTENSION]: 'Link the current extension to a local path.',
|
||||
};
|
||||
|
||||
const keybindingsSchema = z.array(
|
||||
|
||||
Reference in New Issue
Block a user