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 6805e818f7
commit 140c2b9914
6 changed files with 182 additions and 16 deletions

View File

@@ -127,6 +127,13 @@ available combinations.
| `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` |
#### 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 -->
## Customizing Keybindings

View File

@@ -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');
});
});
});

View File

@@ -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>

View File

@@ -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]');
});
});
});

View File

@@ -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);
}}
/>
)}
</>

View File

@@ -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(