mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 00:51:25 -07:00
Make a stateful extensions list component, with update statuses (#8301)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import { ToolStatsDisplay } from './ToolStatsDisplay.js';
|
||||
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
|
||||
import { Help } from './Help.js';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
import { ExtensionsList } from './views/ExtensionsList.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
@@ -96,5 +97,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{item.type === 'compression' && (
|
||||
<CompressionMessage compression={item.compression} />
|
||||
)}
|
||||
{item.type === 'extensions_list' && <ExtensionsList />}
|
||||
</Box>
|
||||
);
|
||||
|
||||
110
packages/cli/src/ui/components/views/ExtensionsList.test.tsx
Normal file
110
packages/cli/src/ui/components/views/ExtensionsList.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { vi } from 'vitest';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
import { ExtensionsList } from './ExtensionsList.js';
|
||||
import { createMockCommandContext } from '../../../test-utils/mockCommandContext.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js');
|
||||
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
|
||||
const mockExtensions = [
|
||||
{ name: 'ext-one', version: '1.0.0', isActive: true },
|
||||
{ name: 'ext-two', version: '2.1.0', isActive: true },
|
||||
{ name: 'ext-disabled', version: '3.0.0', isActive: false },
|
||||
];
|
||||
|
||||
describe('<ExtensionsList />', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const mockUIState = (
|
||||
extensions: unknown[],
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>,
|
||||
disabledExtensions: string[] = [],
|
||||
) => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
commandContext: createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: () => extensions,
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
extensions: {
|
||||
disabled: disabledExtensions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
extensionsUpdateState,
|
||||
// Add other required properties from UIState if needed by the component
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('should render "No extensions installed." if there are no extensions', () => {
|
||||
mockUIState([], new Map());
|
||||
const { lastFrame } = render(<ExtensionsList />);
|
||||
expect(lastFrame()).toContain('No extensions installed.');
|
||||
});
|
||||
|
||||
it('should render a list of extensions with their version and status', () => {
|
||||
mockUIState(mockExtensions, new Map(), ['ext-disabled']);
|
||||
const { lastFrame } = render(<ExtensionsList />);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ext-one (v1.0.0) - active');
|
||||
expect(output).toContain('ext-two (v2.1.0) - active');
|
||||
expect(output).toContain('ext-disabled (v3.0.0) - disabled');
|
||||
});
|
||||
|
||||
it('should display "unknown state" if an extension has no update state', () => {
|
||||
mockUIState([mockExtensions[0]], new Map());
|
||||
const { lastFrame } = render(<ExtensionsList />);
|
||||
expect(lastFrame()).toContain('(unknown state)');
|
||||
});
|
||||
|
||||
const stateTestCases = [
|
||||
{
|
||||
state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
|
||||
expectedText: '(checking for updates)',
|
||||
},
|
||||
{
|
||||
state: ExtensionUpdateState.UPDATING,
|
||||
expectedText: '(updating)',
|
||||
},
|
||||
{
|
||||
state: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
expectedText: '(update available)',
|
||||
},
|
||||
{
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
expectedText: '(updated, needs restart)',
|
||||
},
|
||||
{
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
expectedText: '(error checking for updates)',
|
||||
},
|
||||
{
|
||||
state: ExtensionUpdateState.UP_TO_DATE,
|
||||
expectedText: '(up to date)',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { state, expectedText } of stateTestCases) {
|
||||
it(`should correctly display the state: ${state}`, () => {
|
||||
const updateState = new Map([[mockExtensions[0].name, state]]);
|
||||
mockUIState([mockExtensions[0]], updateState);
|
||||
const { lastFrame } = render(<ExtensionsList />);
|
||||
expect(lastFrame()).toContain(expectedText);
|
||||
});
|
||||
}
|
||||
});
|
||||
67
packages/cli/src/ui/components/views/ExtensionsList.tsx
Normal file
67
packages/cli/src/ui/components/views/ExtensionsList.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
|
||||
export const ExtensionsList = () => {
|
||||
const { commandContext, extensionsUpdateState } = useUIState();
|
||||
const allExtensions = commandContext.services.config!.getExtensions();
|
||||
const settings = commandContext.services.settings;
|
||||
const disabledExtensions = settings.merged.extensions?.disabled ?? [];
|
||||
|
||||
if (allExtensions.length === 0) {
|
||||
return <Text>No extensions installed.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
<Text>Installed extensions:</Text>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{allExtensions.map((ext) => {
|
||||
const state = extensionsUpdateState.get(ext.name);
|
||||
const isActive = !disabledExtensions.includes(ext.name);
|
||||
const activeString = isActive ? 'active' : 'disabled';
|
||||
|
||||
let stateColor = 'gray';
|
||||
const stateText = state || 'unknown state';
|
||||
|
||||
switch (state) {
|
||||
case ExtensionUpdateState.CHECKING_FOR_UPDATES:
|
||||
case ExtensionUpdateState.UPDATING:
|
||||
stateColor = 'cyan';
|
||||
break;
|
||||
case ExtensionUpdateState.UPDATE_AVAILABLE:
|
||||
case ExtensionUpdateState.UPDATED_NEEDS_RESTART:
|
||||
stateColor = 'yellow';
|
||||
break;
|
||||
case ExtensionUpdateState.ERROR:
|
||||
stateColor = 'red';
|
||||
break;
|
||||
case ExtensionUpdateState.UP_TO_DATE:
|
||||
case ExtensionUpdateState.NOT_UPDATABLE:
|
||||
stateColor = 'green';
|
||||
break;
|
||||
default:
|
||||
console.error(`Unhandled ExtensionUpdateState ${state}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={ext.name}>
|
||||
<Text>
|
||||
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
|
||||
{` - ${activeString}`}
|
||||
{<Text color={stateColor}>{` (${stateText})`}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user