mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 05:24:34 -07:00
[feat]: Add /extensions restart command (#12739)
This commit is contained in:
@@ -4,7 +4,10 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
import type {
|
||||||
|
ExtensionLoader,
|
||||||
|
GeminiCLIExtension,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import {
|
import {
|
||||||
@@ -13,12 +16,21 @@ import {
|
|||||||
extensionsCommand,
|
extensionsCommand,
|
||||||
} from './extensionsCommand.js';
|
} from './extensionsCommand.js';
|
||||||
import { type CommandContext, type SlashCommand } from './types.js';
|
import { type CommandContext, type SlashCommand } from './types.js';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
type MockedFunction,
|
||||||
|
} from 'vitest';
|
||||||
import { type ExtensionUpdateAction } from '../state/extensions.js';
|
import { type ExtensionUpdateAction } from '../state/extensions.js';
|
||||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
|
|
||||||
vi.mock('open', () => ({
|
vi.mock('open', () => ({
|
||||||
default: vi.fn(),
|
default: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -572,4 +584,178 @@ describe('extensionsCommand', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('restart', () => {
|
||||||
|
let restartAction: SlashCommand['action'];
|
||||||
|
let mockRestartExtension: MockedFunction<
|
||||||
|
typeof ExtensionLoader.prototype.restartExtension
|
||||||
|
>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
restartAction = extensionsCommand().subCommands?.find(
|
||||||
|
(c) => c.name === 'restart',
|
||||||
|
)?.action;
|
||||||
|
expect(restartAction).not.toBeNull();
|
||||||
|
|
||||||
|
mockRestartExtension = vi.fn();
|
||||||
|
mockContext.services.config!.getExtensionLoader = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => ({
|
||||||
|
getExtensions: mockGetExtensions,
|
||||||
|
restartExtension: mockRestartExtension,
|
||||||
|
}));
|
||||||
|
mockContext.invocation!.name = 'restart';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts all active extensions when --all is provided', async () => {
|
||||||
|
const mockExtensions = [
|
||||||
|
{ name: 'ext1', isActive: true },
|
||||||
|
{ name: 'ext2', isActive: true },
|
||||||
|
{ name: 'ext3', isActive: false },
|
||||||
|
] as GeminiCLIExtension[];
|
||||||
|
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||||
|
|
||||||
|
await restartAction!(mockContext, '--all');
|
||||||
|
|
||||||
|
expect(mockRestartExtension).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||||
|
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[1]);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Restarting 2 extensions...',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: '2 extensions restarted successfully.',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
|
||||||
|
type: 'RESTARTED',
|
||||||
|
payload: { name: 'ext1' },
|
||||||
|
});
|
||||||
|
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
|
||||||
|
type: 'RESTARTED',
|
||||||
|
payload: { name: 'ext2' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts only specified active extensions', async () => {
|
||||||
|
const mockExtensions = [
|
||||||
|
{ name: 'ext1', isActive: false },
|
||||||
|
{ name: 'ext2', isActive: true },
|
||||||
|
{ name: 'ext3', isActive: true },
|
||||||
|
] as GeminiCLIExtension[];
|
||||||
|
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||||
|
|
||||||
|
await restartAction!(mockContext, 'ext1 ext3');
|
||||||
|
|
||||||
|
expect(mockRestartExtension).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[2]);
|
||||||
|
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
|
||||||
|
type: 'RESTARTED',
|
||||||
|
payload: { name: 'ext3' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error if no extension loader is available', async () => {
|
||||||
|
mockContext.services.config!.getExtensionLoader = vi.fn();
|
||||||
|
|
||||||
|
await restartAction!(mockContext, '--all');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: "Extensions are not yet loaded, can't restart yet",
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockRestartExtension).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows usage error for no arguments', async () => {
|
||||||
|
await restartAction!(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Usage: /extensions restart <extension-names>|--all',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockRestartExtension).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles errors during extension restart', async () => {
|
||||||
|
const mockExtensions = [
|
||||||
|
{ name: 'ext1', isActive: true },
|
||||||
|
] as GeminiCLIExtension[];
|
||||||
|
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||||
|
mockRestartExtension.mockRejectedValue(new Error('Failed to restart'));
|
||||||
|
|
||||||
|
await restartAction!(mockContext, '--all');
|
||||||
|
|
||||||
|
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Failed to restart some extensions:\n ext1: Failed to restart',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a warning if an extension is not found', async () => {
|
||||||
|
const mockExtensions = [
|
||||||
|
{ name: 'ext1', isActive: true },
|
||||||
|
] as GeminiCLIExtension[];
|
||||||
|
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||||
|
|
||||||
|
await restartAction!(mockContext, 'ext1 ext2');
|
||||||
|
|
||||||
|
expect(mockRestartExtension).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.WARNING,
|
||||||
|
text: 'Extension(s) not found or not active: ext2',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restart any extensions if none are found', async () => {
|
||||||
|
const mockExtensions = [
|
||||||
|
{ name: 'ext1', isActive: true },
|
||||||
|
] as GeminiCLIExtension[];
|
||||||
|
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||||
|
|
||||||
|
await restartAction!(mockContext, 'ext2 ext3');
|
||||||
|
|
||||||
|
expect(mockRestartExtension).not.toHaveBeenCalled();
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.WARNING,
|
||||||
|
text: 'Extension(s) not found or not active: ext2, ext3',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest only enabled extension names for the restart command', async () => {
|
||||||
|
mockContext.invocation!.name = 'restart';
|
||||||
|
const mockExtensions = [
|
||||||
|
{ name: 'ext1', isActive: true },
|
||||||
|
{ name: 'ext2', isActive: false },
|
||||||
|
] as GeminiCLIExtension[];
|
||||||
|
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||||
|
|
||||||
|
const suggestions = completeExtensions(mockContext, 'ext');
|
||||||
|
expect(suggestions).toEqual(['ext1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
import { debugLogger, listExtensions } from '@google/gemini-cli-core';
|
import { debugLogger, listExtensions } from '@google/gemini-cli-core';
|
||||||
import type { ExtensionUpdateInfo } from '../../config/extension.js';
|
import type { ExtensionUpdateInfo } from '../../config/extension.js';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
import { MessageType, type HistoryItemExtensionsList } from '../types.js';
|
import {
|
||||||
|
MessageType,
|
||||||
|
type HistoryItemExtensionsList,
|
||||||
|
type HistoryItemInfo,
|
||||||
|
} from '../types.js';
|
||||||
import {
|
import {
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
@@ -17,6 +21,7 @@ import open from 'open';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
|
||||||
async function listAction(context: CommandContext) {
|
async function listAction(context: CommandContext) {
|
||||||
const historyItem: HistoryItemExtensionsList = {
|
const historyItem: HistoryItemExtensionsList = {
|
||||||
@@ -116,6 +121,118 @@ function updateAction(context: CommandContext, args: string): Promise<void> {
|
|||||||
return updateComplete.then((_) => {});
|
return updateComplete.then((_) => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restartAction(
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||||
|
if (!extensionLoader) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: "Extensions are not yet loaded, can't restart yet",
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restartArgs = args.split(' ').filter((value) => value.length > 0);
|
||||||
|
const all = restartArgs.length === 1 && restartArgs[0] === '--all';
|
||||||
|
const names = all ? null : restartArgs;
|
||||||
|
if (!all && names?.length === 0) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Usage: /extensions restart <extension-names>|--all',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
let extensionsToRestart = extensionLoader
|
||||||
|
.getExtensions()
|
||||||
|
.filter((extension) => extension.isActive);
|
||||||
|
if (names) {
|
||||||
|
extensionsToRestart = extensionsToRestart.filter((extension) =>
|
||||||
|
names.includes(extension.name),
|
||||||
|
);
|
||||||
|
if (names.length !== extensionsToRestart.length) {
|
||||||
|
const notFound = names.filter(
|
||||||
|
(name) =>
|
||||||
|
!extensionsToRestart.some((extension) => extension.name === name),
|
||||||
|
);
|
||||||
|
if (notFound.length > 0) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.WARNING,
|
||||||
|
text: `Extension(s) not found or not active: ${notFound.join(
|
||||||
|
', ',
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extensionsToRestart.length === 0) {
|
||||||
|
// We will have logged a different message above already.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = extensionsToRestart.length > 1 ? 's' : '';
|
||||||
|
|
||||||
|
const restartingMessage = {
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Restarting ${extensionsToRestart.length} extension${s}...`,
|
||||||
|
color: theme.text.primary,
|
||||||
|
};
|
||||||
|
context.ui.addItem(restartingMessage, Date.now());
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
extensionsToRestart.map(async (extension) => {
|
||||||
|
if (extension.isActive) {
|
||||||
|
await extensionLoader.restartExtension(extension);
|
||||||
|
context.ui.dispatchExtensionStateUpdate({
|
||||||
|
type: 'RESTARTED',
|
||||||
|
payload: {
|
||||||
|
name: extension.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
const errorMessages = failures
|
||||||
|
.map((failure, index) => {
|
||||||
|
const extensionName = extensionsToRestart[index].name;
|
||||||
|
return `${extensionName}: ${getErrorMessage(failure.reason)}`;
|
||||||
|
})
|
||||||
|
.join('\n ');
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to restart some extensions:\n ${errorMessages}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const infoItem: HistoryItemInfo = {
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `${extensionsToRestart.length} extension${s} restarted successfully.`,
|
||||||
|
icon: ' ',
|
||||||
|
color: theme.text.primary,
|
||||||
|
};
|
||||||
|
context.ui.addItem(infoItem, Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exploreAction(context: CommandContext) {
|
async function exploreAction(context: CommandContext) {
|
||||||
const extensionsUrl = 'https://geminicli.com/extensions/';
|
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||||
|
|
||||||
@@ -284,10 +401,14 @@ export function completeExtensions(
|
|||||||
partialArg: string,
|
partialArg: string,
|
||||||
) {
|
) {
|
||||||
let extensions = context.services.config?.getExtensions() ?? [];
|
let extensions = context.services.config?.getExtensions() ?? [];
|
||||||
|
|
||||||
if (context.invocation?.name === 'enable') {
|
if (context.invocation?.name === 'enable') {
|
||||||
extensions = extensions.filter((ext) => !ext.isActive);
|
extensions = extensions.filter((ext) => !ext.isActive);
|
||||||
}
|
}
|
||||||
if (context.invocation?.name === 'disable') {
|
if (
|
||||||
|
context.invocation?.name === 'disable' ||
|
||||||
|
context.invocation?.name === 'restart'
|
||||||
|
) {
|
||||||
extensions = extensions.filter((ext) => ext.isActive);
|
extensions = extensions.filter((ext) => ext.isActive);
|
||||||
}
|
}
|
||||||
const extensionNames = extensions.map((ext) => ext.name);
|
const extensionNames = extensions.map((ext) => ext.name);
|
||||||
@@ -351,6 +472,14 @@ const exploreExtensionsCommand: SlashCommand = {
|
|||||||
action: exploreAction,
|
action: exploreAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const restartCommand: SlashCommand = {
|
||||||
|
name: 'restart',
|
||||||
|
description: 'Restart all extensions',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: restartAction,
|
||||||
|
completion: completeExtensions,
|
||||||
|
};
|
||||||
|
|
||||||
export function extensionsCommand(
|
export function extensionsCommand(
|
||||||
enableExtensionReloading?: boolean,
|
enableExtensionReloading?: boolean,
|
||||||
): SlashCommand {
|
): SlashCommand {
|
||||||
@@ -365,6 +494,7 @@ export function extensionsCommand(
|
|||||||
listExtensionsCommand,
|
listExtensionsCommand,
|
||||||
updateExtensionsCommand,
|
updateExtensionsCommand,
|
||||||
exploreExtensionsCommand,
|
exploreExtensionsCommand,
|
||||||
|
restartCommand,
|
||||||
...conditionalCommands,
|
...conditionalCommands,
|
||||||
],
|
],
|
||||||
action: (context, args) =>
|
action: (context, args) =>
|
||||||
|
|||||||
@@ -86,7 +86,11 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{itemForDisplay.type === 'info' && (
|
{itemForDisplay.type === 'info' && (
|
||||||
<InfoMessage text={itemForDisplay.text} />
|
<InfoMessage
|
||||||
|
text={itemForDisplay.text}
|
||||||
|
icon={itemForDisplay.icon}
|
||||||
|
color={itemForDisplay.color}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{itemForDisplay.type === 'warning' && (
|
{itemForDisplay.type === 'warning' && (
|
||||||
<WarningMessage text={itemForDisplay.text} />
|
<WarningMessage text={itemForDisplay.text} />
|
||||||
|
|||||||
@@ -11,21 +11,28 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
|||||||
|
|
||||||
interface InfoMessageProps {
|
interface InfoMessageProps {
|
||||||
text: string;
|
text: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
|
export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||||
const prefix = 'ℹ ';
|
text,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
}) => {
|
||||||
|
color ??= theme.status.warning;
|
||||||
|
const prefix = icon ?? 'ℹ ';
|
||||||
const prefixWidth = prefix.length;
|
const prefixWidth = prefix.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" marginTop={1}>
|
<Box flexDirection="row" marginTop={1}>
|
||||||
<Box width={prefixWidth}>
|
<Box width={prefixWidth}>
|
||||||
<Text color={theme.status.warning}>{prefix}</Text>
|
<Text color={color}>{prefix}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1} flexDirection="column">
|
<Box flexGrow={1} flexDirection="column">
|
||||||
{text.split('\n').map((line, index) => (
|
{text.split('\n').map((line, index) => (
|
||||||
<Text wrap="wrap" key={index}>
|
<Text wrap="wrap" key={index}>
|
||||||
<RenderInline text={line} defaultColor={theme.status.warning} />
|
<RenderInline text={line} defaultColor={color} />
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
extensionUpdatesReducer,
|
||||||
|
type ExtensionUpdatesState,
|
||||||
|
ExtensionUpdateState,
|
||||||
|
} from './extensions.js';
|
||||||
|
|
||||||
|
describe('extensionUpdatesReducer', () => {
|
||||||
|
it('should handle RESTARTED action', () => {
|
||||||
|
const initialState: ExtensionUpdatesState = {
|
||||||
|
extensionStatuses: new Map([
|
||||||
|
[
|
||||||
|
'ext1',
|
||||||
|
{
|
||||||
|
status: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||||
|
lastUpdateTime: 0,
|
||||||
|
lastUpdateCheck: 0,
|
||||||
|
notified: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
batchChecksInProgress: 0,
|
||||||
|
scheduledUpdate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
type: 'RESTARTED' as const,
|
||||||
|
payload: { name: 'ext1' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const newState = extensionUpdatesReducer(initialState, action);
|
||||||
|
|
||||||
|
const expectedStatus = {
|
||||||
|
status: ExtensionUpdateState.UPDATED,
|
||||||
|
lastUpdateTime: 0,
|
||||||
|
lastUpdateCheck: 0,
|
||||||
|
notified: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(newState.extensionStatuses.get('ext1')).toEqual(expectedStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not change state for RESTARTED action if status is not UPDATED_NEEDS_RESTART', () => {
|
||||||
|
const initialState: ExtensionUpdatesState = {
|
||||||
|
extensionStatuses: new Map([
|
||||||
|
[
|
||||||
|
'ext1',
|
||||||
|
{
|
||||||
|
status: ExtensionUpdateState.UPDATED,
|
||||||
|
lastUpdateTime: 0,
|
||||||
|
lastUpdateCheck: 0,
|
||||||
|
notified: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
batchChecksInProgress: 0,
|
||||||
|
scheduledUpdate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
type: 'RESTARTED' as const,
|
||||||
|
payload: { name: 'ext1' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const newState = extensionUpdatesReducer(initialState, action);
|
||||||
|
|
||||||
|
expect(newState).toEqual(initialState);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,8 @@ export type ExtensionUpdateAction =
|
|||||||
| { type: 'BATCH_CHECK_START' }
|
| { type: 'BATCH_CHECK_START' }
|
||||||
| { type: 'BATCH_CHECK_END' }
|
| { type: 'BATCH_CHECK_END' }
|
||||||
| { type: 'SCHEDULE_UPDATE'; payload: ScheduleUpdateArgs }
|
| { type: 'SCHEDULE_UPDATE'; payload: ScheduleUpdateArgs }
|
||||||
| { type: 'CLEAR_SCHEDULED_UPDATE' };
|
| { type: 'CLEAR_SCHEDULED_UPDATE' }
|
||||||
|
| { type: 'RESTARTED'; payload: { name: string } };
|
||||||
|
|
||||||
export function extensionUpdatesReducer(
|
export function extensionUpdatesReducer(
|
||||||
state: ExtensionUpdatesState,
|
state: ExtensionUpdatesState,
|
||||||
@@ -125,6 +126,20 @@ export function extensionUpdatesReducer(
|
|||||||
...state,
|
...state,
|
||||||
scheduledUpdate: null,
|
scheduledUpdate: null,
|
||||||
};
|
};
|
||||||
|
case 'RESTARTED': {
|
||||||
|
const existing = state.extensionStatuses.get(action.payload.name);
|
||||||
|
if (existing?.status !== ExtensionUpdateState.UPDATED_NEEDS_RESTART) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatuses = new Map(state.extensionStatuses);
|
||||||
|
newStatuses.set(action.payload.name, {
|
||||||
|
...existing,
|
||||||
|
status: ExtensionUpdateState.UPDATED,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...state, extensionStatuses: newStatuses };
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
checkExhaustive(action);
|
checkExhaustive(action);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
|
|||||||
export type HistoryItemInfo = HistoryItemBase & {
|
export type HistoryItemInfo = HistoryItemBase & {
|
||||||
type: 'info';
|
type: 'info';
|
||||||
text: string;
|
text: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HistoryItemError = HistoryItemBase & {
|
export type HistoryItemError = HistoryItemBase & {
|
||||||
|
|||||||
@@ -169,4 +169,27 @@ describe('SimpleExtensionLoader', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('restartExtension', () => {
|
||||||
|
it('should stop and then start the extension', async () => {
|
||||||
|
const loader = new TestingSimpleExtensionLoader([activeExtension]);
|
||||||
|
vi.spyOn(loader, 'stopExtension');
|
||||||
|
vi.spyOn(loader, 'startExtension');
|
||||||
|
await loader.start(mockConfig);
|
||||||
|
await loader.restartExtension(activeExtension);
|
||||||
|
expect(loader.stopExtension).toHaveBeenCalledWith(activeExtension);
|
||||||
|
expect(loader.startExtension).toHaveBeenCalledWith(activeExtension);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Adding these overrides allows us to access the protected members.
|
||||||
|
class TestingSimpleExtensionLoader extends SimpleExtensionLoader {
|
||||||
|
override async startExtension(extension: GeminiCLIExtension): Promise<void> {
|
||||||
|
await super.startExtension(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async stopExtension(extension: GeminiCLIExtension): Promise<void> {
|
||||||
|
await super.stopExtension(extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ export abstract class ExtensionLoader {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restartExtension(extension: GeminiCLIExtension): Promise<void> {
|
||||||
|
await this.stopExtension(extension);
|
||||||
|
await this.startExtension(extension);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionEvents {
|
export interface ExtensionEvents {
|
||||||
|
|||||||
Reference in New Issue
Block a user