mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
feat: Add interactive TUI for extension management
This commit is contained in:
parent
d7ebd815b3
commit
4d27950a95
27 changed files with 2132 additions and 425 deletions
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ActionSelectionStep } from './ActionSelectionStep.js';
|
||||
import type { Extension } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const createMockExtension = (name: string, isActive = true): Extension =>
|
||||
({
|
||||
id: name,
|
||||
name,
|
||||
version: '1.0.0',
|
||||
path: `/home/user/.qwen/extensions/${name}`,
|
||||
isActive,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: `github:user/${name}`,
|
||||
},
|
||||
mcpServers: {},
|
||||
commands: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
resolvedSettings: [],
|
||||
config: {},
|
||||
contextFiles: [],
|
||||
}) as unknown as Extension;
|
||||
|
||||
describe('ActionSelectionStep Snapshots', () => {
|
||||
const baseProps = {
|
||||
onNavigateToStep: vi.fn(),
|
||||
onNavigateBack: vi.fn(),
|
||||
onActionSelect: vi.fn(),
|
||||
};
|
||||
|
||||
it('should render for active extension without update', () => {
|
||||
const { lastFrame } = render(
|
||||
<ActionSelectionStep
|
||||
selectedExtension={createMockExtension('active-ext', true)}
|
||||
hasUpdateAvailable={false}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render for disabled extension', () => {
|
||||
const { lastFrame } = render(
|
||||
<ActionSelectionStep
|
||||
selectedExtension={createMockExtension('disabled-ext', false)}
|
||||
hasUpdateAvailable={false}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render for extension with update available', () => {
|
||||
const { lastFrame } = render(
|
||||
<ActionSelectionStep
|
||||
selectedExtension={createMockExtension('update-ext', true)}
|
||||
hasUpdateAvailable={true}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render for disabled extension with update', () => {
|
||||
const { lastFrame } = render(
|
||||
<ActionSelectionStep
|
||||
selectedExtension={createMockExtension('disabled-update-ext', false)}
|
||||
hasUpdateAvailable={true}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with no extension selected', () => {
|
||||
const { lastFrame } = render(
|
||||
<ActionSelectionStep
|
||||
selectedExtension={null}
|
||||
hasUpdateAvailable={false}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import { type ExtensionAction } from '../types.js';
|
||||
|
||||
interface ActionSelectionStepProps {
|
||||
selectedExtension: Extension | null;
|
||||
hasUpdateAvailable: boolean;
|
||||
onNavigateToStep: (step: string) => void;
|
||||
onNavigateBack: () => void;
|
||||
onActionSelect: (action: ExtensionAction) => void;
|
||||
}
|
||||
|
||||
export const ActionSelectionStep = ({
|
||||
selectedExtension,
|
||||
hasUpdateAvailable,
|
||||
onNavigateBack,
|
||||
onActionSelect,
|
||||
}: ActionSelectionStepProps) => {
|
||||
const [selectedAction, setSelectedAction] = useState<ExtensionAction | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isActive = selectedExtension?.isActive ?? false;
|
||||
|
||||
// Build action list based on extension state
|
||||
const actions = useMemo(() => {
|
||||
const allActions = [
|
||||
{
|
||||
key: 'view',
|
||||
get label() {
|
||||
return t('View Details');
|
||||
},
|
||||
value: 'view' as const,
|
||||
},
|
||||
...(hasUpdateAvailable
|
||||
? [
|
||||
{
|
||||
key: 'update',
|
||||
get label() {
|
||||
return t('Update Extension');
|
||||
},
|
||||
value: 'update' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isActive
|
||||
? [
|
||||
{
|
||||
key: 'disable',
|
||||
get label() {
|
||||
return t('Disable Extension');
|
||||
},
|
||||
value: 'disable' as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'enable',
|
||||
get label() {
|
||||
return t('Enable Extension');
|
||||
},
|
||||
value: 'enable' as const,
|
||||
},
|
||||
]),
|
||||
{
|
||||
key: 'uninstall',
|
||||
get label() {
|
||||
return t('Uninstall Extension');
|
||||
},
|
||||
value: 'uninstall' as const,
|
||||
},
|
||||
{
|
||||
key: 'back',
|
||||
get label() {
|
||||
return t('Back');
|
||||
},
|
||||
value: 'back' as const,
|
||||
},
|
||||
];
|
||||
return allActions;
|
||||
}, [hasUpdateAvailable, isActive]);
|
||||
|
||||
const handleActionSelect = (value: ExtensionAction) => {
|
||||
if (value === 'back') {
|
||||
onNavigateBack();
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAction(value);
|
||||
onActionSelect(value);
|
||||
};
|
||||
|
||||
const selectedIndex = selectedAction
|
||||
? actions.findIndex((action) => action.value === selectedAction)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={actions}
|
||||
initialIndex={selectedIndex}
|
||||
onSelect={handleActionSelect}
|
||||
showNumbers={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
|
||||
interface ExtensionDetailStepProps {
|
||||
selectedExtension: Extension | null;
|
||||
}
|
||||
|
||||
export const ExtensionDetailStep = ({
|
||||
selectedExtension,
|
||||
}: ExtensionDetailStepProps) => {
|
||||
if (!selectedExtension) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No extension selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const ext = selectedExtension;
|
||||
const isActive = ext.isActive;
|
||||
const activeColor = isActive ? theme.status.success : theme.text.secondary;
|
||||
const activeString = isActive ? t('active') : t('disabled');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('Name:')} `}</Text>
|
||||
<Text>{ext.name}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('Version:')} `}</Text>
|
||||
<Text>{ext.version}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('Status:')} `}</Text>
|
||||
<Text color={activeColor}>{activeString}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('Path:')} `}</Text>
|
||||
<Text>{ext.path}</Text>
|
||||
</Box>
|
||||
|
||||
{ext.installMetadata && (
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('Source:')} `}</Text>
|
||||
<Text>{ext.installMetadata.source}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.mcpServers && Object.keys(ext.mcpServers).length > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('MCP Servers:')} `}</Text>
|
||||
<Text>{Object.keys(ext.mcpServers).join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.commands && ext.commands.length > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('Commands:')} `}</Text>
|
||||
<Text>{ext.commands.join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.skills && ext.skills.length > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('Skills:')} `}</Text>
|
||||
<Text>{ext.skills.map((s) => s.name).join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.agents && ext.agents.length > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{`${t('Agents:')} `}</Text>
|
||||
<Text>{ext.agents.map((a) => a.name).join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>{`${t('Settings:')} `}</Text>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{ext.resolvedSettings.map((setting) => (
|
||||
<Text key={setting.name}>
|
||||
- {setting.name}: {setting.value}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ExtensionListStep } from './ExtensionListStep.js';
|
||||
import type { Extension } from '@qwen-code/qwen-code-core';
|
||||
import { ExtensionUpdateState } from '../../../state/extensions.js';
|
||||
|
||||
const createMockExtension = (
|
||||
name: string,
|
||||
isActive = true,
|
||||
version = '1.0.0',
|
||||
): Extension =>
|
||||
({
|
||||
id: name,
|
||||
name,
|
||||
version,
|
||||
path: `/home/user/.qwen/extensions/${name}`,
|
||||
isActive,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: `github:user/${name}`,
|
||||
},
|
||||
mcpServers: {},
|
||||
commands: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
resolvedSettings: [],
|
||||
config: {},
|
||||
contextFiles: [],
|
||||
}) as unknown as Extension;
|
||||
|
||||
describe('ExtensionListStep Snapshots', () => {
|
||||
const baseProps = {
|
||||
onExtensionSelect: vi.fn(),
|
||||
};
|
||||
|
||||
it('should render empty state', () => {
|
||||
const { lastFrame } = render(
|
||||
<ExtensionListStep
|
||||
extensions={[]}
|
||||
extensionsUpdateState={new Map()}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render list with single extension', () => {
|
||||
const extensions = [createMockExtension('test-extension', true)];
|
||||
const { lastFrame } = render(
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={new Map()}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render list with multiple extensions', () => {
|
||||
const extensions = [
|
||||
createMockExtension('active-extension', true),
|
||||
createMockExtension('disabled-extension', false),
|
||||
createMockExtension('update-available', true),
|
||||
];
|
||||
const updateState = new Map([
|
||||
['active-extension', ExtensionUpdateState.UP_TO_DATE],
|
||||
['disabled-extension', ExtensionUpdateState.NOT_UPDATABLE],
|
||||
['update-available', ExtensionUpdateState.UPDATE_AVAILABLE],
|
||||
]);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={updateState}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with checking status', () => {
|
||||
const extensions = [createMockExtension('checking-extension', true)];
|
||||
const updateState = new Map([
|
||||
['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES],
|
||||
]);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={updateState}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with error status', () => {
|
||||
const extensions = [createMockExtension('error-extension', true)];
|
||||
const updateState = new Map([
|
||||
['error-extension', ExtensionUpdateState.ERROR],
|
||||
]);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={updateState}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import { ExtensionUpdateState } from '../../../state/extensions.js';
|
||||
|
||||
interface ExtensionListStepProps {
|
||||
extensions: Extension[];
|
||||
extensionsUpdateState: Map<string, string>;
|
||||
onExtensionSelect: (extensionIndex: number) => void;
|
||||
}
|
||||
|
||||
export const ExtensionListStep = ({
|
||||
extensions,
|
||||
extensionsUpdateState,
|
||||
onExtensionSelect,
|
||||
}: ExtensionListStepProps) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Reset selection when extensions change
|
||||
useEffect(() => {
|
||||
if (extensions.length > 0 && selectedIndex >= extensions.length) {
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [extensions, selectedIndex]);
|
||||
|
||||
// Keyboard navigation
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'up' || key.name === 'k') {
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : extensions.length - 1,
|
||||
);
|
||||
} else if (key.name === 'down' || key.name === 'j') {
|
||||
setSelectedIndex((prev) =>
|
||||
prev < extensions.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
} else if (key.name === 'return' || key.name === 'space') {
|
||||
if (extensions.length > 0) {
|
||||
onExtensionSelect(selectedIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (extensions.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No extensions installed.')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t("Use '/extensions install' to install your first extension.")}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const getUpdateStateColor = (state: string | undefined): string => {
|
||||
if (!state) return theme.text.secondary;
|
||||
|
||||
switch (state) {
|
||||
case ExtensionUpdateState.CHECKING_FOR_UPDATES:
|
||||
case ExtensionUpdateState.UPDATING:
|
||||
return theme.text.secondary;
|
||||
case ExtensionUpdateState.UPDATE_AVAILABLE:
|
||||
case ExtensionUpdateState.UPDATED_NEEDS_RESTART:
|
||||
return theme.status.warning;
|
||||
case ExtensionUpdateState.ERROR:
|
||||
return theme.status.error;
|
||||
case ExtensionUpdateState.UP_TO_DATE:
|
||||
case ExtensionUpdateState.NOT_UPDATABLE:
|
||||
case ExtensionUpdateState.UPDATED:
|
||||
return theme.status.success;
|
||||
default:
|
||||
return theme.text.secondary;
|
||||
}
|
||||
};
|
||||
|
||||
const getLocalizedUpdateState = (state: string | undefined): string => {
|
||||
if (!state) return '';
|
||||
// Map internal state values to translation keys
|
||||
const stateMap: Record<string, string> = {
|
||||
'up to date': t('up to date'),
|
||||
'update available': t('update available'),
|
||||
'checking...': t('checking...'),
|
||||
'not updatable': t('not updatable'),
|
||||
error: t('error'),
|
||||
};
|
||||
return stateMap[state] || state;
|
||||
};
|
||||
|
||||
const renderExtensionItem = (
|
||||
extension: Extension,
|
||||
index: number,
|
||||
isSelected: boolean,
|
||||
) => {
|
||||
const isActive = extension.isActive;
|
||||
const activeColor = isActive ? theme.status.success : theme.text.secondary;
|
||||
const activeString = isActive ? t('active') : t('disabled');
|
||||
|
||||
const updateState = extensionsUpdateState.get(extension.name);
|
||||
const stateColor = getUpdateStateColor(updateState);
|
||||
const stateText = getLocalizedUpdateState(updateState);
|
||||
|
||||
return (
|
||||
<Box key={extension.name} alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
|
||||
{isSelected ? '●' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
wrap="truncate"
|
||||
>
|
||||
{extension.name}
|
||||
<Text color={theme.text.secondary}> v{extension.version}</Text>
|
||||
<Text color={activeColor}> ({activeString})</Text>
|
||||
{stateText && <Text color={stateColor}> [{stateText}]</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{extensions.map((extension, index) =>
|
||||
renderExtensionItem(extension, index, index === selectedIndex),
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('{{count}} extensions installed', {
|
||||
count: extensions.length.toString(),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
|
||||
interface ScopeSelectStepProps {
|
||||
selectedExtension: Extension | null;
|
||||
mode: 'disable' | 'enable';
|
||||
onScopeSelect: (scope: 'user' | 'workspace') => void;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export function ScopeSelectStep({
|
||||
selectedExtension,
|
||||
mode,
|
||||
onScopeSelect,
|
||||
onNavigateBack,
|
||||
}: ScopeSelectStepProps) {
|
||||
const scopeItems = [
|
||||
{
|
||||
key: 'user',
|
||||
get label() {
|
||||
return t('User (global)');
|
||||
},
|
||||
value: 'user' as const,
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
get label() {
|
||||
return t('Workspace (project-specific)');
|
||||
},
|
||||
value: 'workspace' as const,
|
||||
},
|
||||
{
|
||||
key: 'back',
|
||||
get label() {
|
||||
return t('Back');
|
||||
},
|
||||
value: 'back' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = (value: 'user' | 'workspace' | 'back') => {
|
||||
if (value === 'back') {
|
||||
onNavigateBack();
|
||||
return;
|
||||
}
|
||||
onScopeSelect(value);
|
||||
};
|
||||
|
||||
if (!selectedExtension) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No extension selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const title =
|
||||
mode === 'disable'
|
||||
? t('Disable "{{name}}" - Select Scope', { name: selectedExtension.name })
|
||||
: t('Enable "{{name}}" - Select Scope', { name: selectedExtension.name });
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.text.primary}>{title}</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
onSelect={handleSelect}
|
||||
showNumbers={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
|
||||
interface UninstallConfirmStepProps {
|
||||
selectedExtension: Extension | null;
|
||||
onConfirm: (extension: Extension) => Promise<void>;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('EXTENSION_UNINSTALL_STEP');
|
||||
|
||||
export function UninstallConfirmStep({
|
||||
selectedExtension,
|
||||
onConfirm,
|
||||
onNavigateBack,
|
||||
}: UninstallConfirmStepProps) {
|
||||
useKeypress(
|
||||
async (key) => {
|
||||
if (!selectedExtension) return;
|
||||
|
||||
if (key.name === 'y' || key.name === 'return') {
|
||||
try {
|
||||
await onConfirm(selectedExtension);
|
||||
// Navigation will be handled by the parent component after successful uninstall
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to uninstall extension:', error);
|
||||
}
|
||||
} else if (key.name === 'n' || key.name === 'escape') {
|
||||
onNavigateBack();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (!selectedExtension) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No extension selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.status.error}>
|
||||
{t('Are you sure you want to uninstall extension "{{name}}"?', {
|
||||
name: selectedExtension.name,
|
||||
})}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('This action cannot be undone.')}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Press Y/Enter to confirm, N/Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- useSelectionList (src/ui/hooks/useSelectionList.ts:287:3)
|
||||
- BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- useSelectionList (src/ui/hooks/useSelectionList.ts:287:3)
|
||||
- BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- useSelectionList (src/ui/hooks/useSelectionList.ts:287:3)
|
||||
- BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- useSelectionList (src/ui/hooks/useSelectionList.ts:287:3)
|
||||
- BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- useSelectionList (src/ui/hooks/useSelectionList.ts:287:3)
|
||||
- BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render empty state 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
-workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel
|
||||
opment.js:12644:41)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render list with multiple extensions 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
-workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel
|
||||
opment.js:12644:41)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
-workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel
|
||||
opment.js:12644:41)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render with checking status 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
-workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel
|
||||
opment.js:12644:41)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render with error status 1`] = `
|
||||
"
|
||||
ERROR useKeypressContext must be used within a KeypressProvider
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:77:11
|
||||
|
||||
74: export function useKeypressContext() {
|
||||
75: const context = useContext(KeypressContext);
|
||||
76: if (!context) {
|
||||
77: throw new Error(
|
||||
78: 'useKeypressContext must be used within a KeypressProvider',
|
||||
79: );
|
||||
80: }
|
||||
|
||||
- useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11)
|
||||
- useKeypress (src/ui/hooks/useKeypress.ts:24:38)
|
||||
- ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3)
|
||||
-Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco
|
||||
m-frame nciler.development.js:15859:20)
|
||||
-renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev
|
||||
s elopment.js:3221:22)
|
||||
-updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile
|
||||
nent r.development.js:6475:19)
|
||||
-beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm
|
||||
ent.js:8009:18)
|
||||
-runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
EV velopment.js:1738:13)
|
||||
-performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de
|
||||
rk velopment.js:12834:22)
|
||||
-workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel
|
||||
opment.js:12644:41)
|
||||
"
|
||||
`;
|
||||
11
packages/cli/src/ui/components/extensions/steps/index.ts
Normal file
11
packages/cli/src/ui/components/extensions/steps/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { ExtensionListStep } from './ExtensionListStep.js';
|
||||
export { ExtensionDetailStep } from './ExtensionDetailStep.js';
|
||||
export { ActionSelectionStep } from './ActionSelectionStep.js';
|
||||
export { UninstallConfirmStep } from './UninstallConfirmStep.js';
|
||||
export { ScopeSelectStep } from './ScopeSelectStep.js';
|
||||
Loading…
Add table
Add a link
Reference in a new issue