feat(cli): add UI to update extensions (#23682)

This commit is contained in:
ruomeng
2026-03-31 13:05:08 -04:00
committed by GitHub
parent 861bab35c0
commit 56296ea5f4
6 changed files with 182 additions and 16 deletions
+7
View File
@@ -127,6 +127,13 @@ available combinations.
| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` | | `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` |
| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` | | `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` |
#### Extension Controls
| Command | Action | Keys |
| ------------------ | ------------------------------------------- | ---- |
| `extension.update` | Update the current extension if available. | `I` |
| `extension.link` | Link the current extension to a local path. | `L` |
<!-- KEYBINDINGS-AUTOGEN:END --> <!-- KEYBINDINGS-AUTOGEN:END -->
## Customizing Keybindings ## Customizing Keybindings
@@ -10,6 +10,7 @@ import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtensionDetails } from './ExtensionDetails.js'; import { ExtensionDetails } from './ExtensionDetails.js';
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
const mockExtension: RegistryExtension = { const mockExtension: RegistryExtension = {
id: 'ext1', id: 'ext1',
@@ -48,7 +49,11 @@ describe('ExtensionDetails', () => {
mockOnLink = vi.fn(); mockOnLink = vi.fn();
}); });
const renderDetails = async (isInstalled = false) => const renderDetails = async (
isInstalled = false,
updateState?: ExtensionUpdateState,
onUpdate = vi.fn(),
) =>
renderWithProviders( renderWithProviders(
<ExtensionDetails <ExtensionDetails
extension={mockExtension} extension={mockExtension}
@@ -56,6 +61,8 @@ describe('ExtensionDetails', () => {
onInstall={mockOnInstall} onInstall={mockOnInstall}
onLink={mockOnLink} onLink={mockOnLink}
isInstalled={isInstalled} isInstalled={isInstalled}
updateState={updateState}
onUpdate={onUpdate}
/>, />,
); );
@@ -165,4 +172,40 @@ describe('ExtensionDetails', () => {
expect(lastFrame()).toContain('[L] Link'); 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 { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
export interface ExtensionDetailsProps { export interface ExtensionDetailsProps {
extension: RegistryExtension; extension: RegistryExtension;
@@ -23,6 +24,8 @@ export interface ExtensionDetailsProps {
requestConsentOverride: (consent: string) => Promise<boolean>, requestConsentOverride: (consent: string) => Promise<boolean>,
) => void | Promise<void>; ) => void | Promise<void>;
isInstalled: boolean; isInstalled: boolean;
updateState?: ExtensionUpdateState;
onUpdate?: () => void | Promise<void>;
} }
export function ExtensionDetails({ export function ExtensionDetails({
@@ -31,6 +34,8 @@ export function ExtensionDetails({
onInstall, onInstall,
onLink, onLink,
isInstalled, isInstalled,
updateState,
onUpdate,
}: ExtensionDetailsProps): React.JSX.Element { }: ExtensionDetailsProps): React.JSX.Element {
const keyMatchers = useKeyMatchers(); const keyMatchers = useKeyMatchers();
const [consentRequest, setConsentRequest] = useState<{ const [consentRequest, setConsentRequest] = useState<{
@@ -76,7 +81,12 @@ export function ExtensionDetails({
); );
return true; return true;
} }
if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) { if (
keyMatchers[Command.LINK_EXTENSION](key) &&
isLinkable &&
!isInstalled &&
!isInstalling
) {
setIsInstalling(true); setIsInstalling(true);
void onLink( void onLink(
(prompt: string) => (prompt: string) =>
@@ -86,6 +96,14 @@ export function ExtensionDetails({
); );
return true; return true;
} }
if (
keyMatchers[Command.UPDATE_EXTENSION](key) &&
updateState === ExtensionUpdateState.UPDATE_AVAILABLE &&
!isInstalling
) {
void onUpdate?.();
return true;
}
return false; return false;
}, },
{ isActive: true, priority: true }, { isActive: true, priority: true },
@@ -150,6 +168,16 @@ export function ExtensionDetails({
<Text color={theme.text.primary} bold> <Text color={theme.text.primary} bold>
{extension.extensionName} {extension.extensionName}
</Text> </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>
<Box flexDirection="row"> <Box flexDirection="row">
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
@@ -258,7 +286,7 @@ export function ExtensionDetails({
</Box> </Box>
</Box> </Box>
)} )}
{isInstalled && ( {isInstalled && updateState !== ExtensionUpdateState.UPDATING && (
<Box flexDirection="row" marginTop={1} justifyContent="center"> <Box flexDirection="row" marginTop={1} justifyContent="center">
<Text color={theme.status.success}>Already Installed</Text> <Text color={theme.status.success}>Already Installed</Text>
</Box> </Box>
@@ -21,6 +21,8 @@ import {
type GenericListItem, type GenericListItem,
} from '../shared/SearchableList.js'; } from '../shared/SearchableList.js';
import { type TextBuffer } from '../shared/text-buffer.js'; import { type TextBuffer } from '../shared/text-buffer.js';
import { type UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
// Mocks // Mocks
vi.mock('../../hooks/useExtensionRegistry.js'); vi.mock('../../hooks/useExtensionRegistry.js');
@@ -97,6 +99,7 @@ describe('ExtensionRegistryView', () => {
vi.mocked(useExtensionUpdates).mockReturnValue({ vi.mocked(useExtensionUpdates).mockReturnValue({
extensionsUpdateState: new Map(), extensionsUpdateState: new Map(),
dispatchExtensionStateUpdate: vi.fn(),
} as unknown as ReturnType<typeof useExtensionUpdates>); } as unknown as ReturnType<typeof useExtensionUpdates>);
// Mock useRegistrySearch implementation // Mock useRegistrySearch implementation
@@ -134,6 +137,9 @@ describe('ExtensionRegistryView', () => {
uiState: { uiState: {
staticExtraHeight: 5, staticExtraHeight: 5,
terminalHeight: 40, terminalHeight: 40,
historyManager: {
addItem: vi.fn(),
} as unknown as UseHistoryManagerReturn,
} as Partial<UIState>, } 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(), config.getExtensionRegistryURI(),
); );
const { terminalHeight, staticExtraHeight } = useUIState(); const { terminalHeight, staticExtraHeight, historyManager } = useUIState();
const [selectedExtension, setSelectedExtension] = const [selectedExtension, setSelectedExtension] =
useState<RegistryExtension | null>(null); useState<RegistryExtension | null>(null);
const { extensionsUpdateState } = useExtensionUpdates( const { extensionsUpdateState, dispatchExtensionStateUpdate } =
extensionManager, useExtensionUpdates(
() => 0, extensionManager,
config.getEnableExtensionReloading(), historyManager.addItem,
); config.getEnableExtensionReloading(),
);
const [installedExtensions, setInstalledExtensions] = useState(() => const [installedExtensions, setInstalledExtensions] = useState(() =>
extensionManager.getExtensions(), extensionManager.getExtensions(),
@@ -117,6 +118,23 @@ export function ExtensionRegistryView({
[onLink, extensionManager], [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( const renderItem = useCallback(
(item: ExtensionItem, isActive: boolean, _labelWidth: number) => { (item: ExtensionItem, isActive: boolean, _labelWidth: number) => {
const isInstalled = installedExtensions.some( const isInstalled = installedExtensions.some(
@@ -125,7 +143,6 @@ export function ExtensionRegistryView({
const updateState = extensionsUpdateState.get( const updateState = extensionsUpdateState.get(
item.extension.extensionName, item.extension.extensionName,
); );
const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE;
return ( return (
<Box flexDirection="row" width="100%" justifyContent="space-between"> <Box flexDirection="row" width="100%" justifyContent="space-between">
@@ -148,15 +165,20 @@ export function ExtensionRegistryView({
<Box flexShrink={0} marginX={1}> <Box flexShrink={0} marginX={1}>
<Text color={theme.text.secondary}>|</Text> <Text color={theme.text.secondary}>|</Text>
</Box> </Box>
{isInstalled && ( {updateState === ExtensionUpdateState.UPDATE_AVAILABLE ? (
<Box marginRight={1} flexShrink={0}>
<Text color={theme.status.success}>[Installed]</Text>
</Box>
)}
{hasUpdate && (
<Box marginRight={1} flexShrink={0}> <Box marginRight={1} flexShrink={0}>
<Text color={theme.status.warning}>[Update available]</Text> <Text color={theme.status.warning}>[Update available]</Text>
</Box> </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}> <Box flexShrink={1} minWidth={0}>
<Text color={theme.text.secondary} wrap="truncate-end"> <Text color={theme.text.secondary} wrap="truncate-end">
@@ -287,6 +309,12 @@ export function ExtensionRegistryView({
isInstalled={installedExtensions.some( isInstalled={installedExtensions.some(
(e) => e.name === selectedExtension.extensionName, (e) => e.name === selectedExtension.extensionName,
)} )}
updateState={extensionsUpdateState.get(
selectedExtension.extensionName,
)}
onUpdate={async () => {
await handleUpdate(selectedExtension);
}}
/> />
)} )}
</> </>
+16
View File
@@ -105,6 +105,10 @@ export enum Command {
UNFOCUS_BACKGROUND_SHELL = 'background.unfocus', UNFOCUS_BACKGROUND_SHELL = 'background.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList', UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'background.unfocusWarning', 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, [new KeyBinding('shift+tab')]],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]], [Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]],
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, [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 { interface CommandCategory {
@@ -529,6 +537,10 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, 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.', 'Move focus from background shell list to Gemini.',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
'Show warning when trying to move focus away from background shell.', '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( const keybindingsSchema = z.array(