mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +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
|
|
@ -35,6 +35,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
|||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
|
|
@ -297,6 +298,15 @@ export const DialogManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (uiState.isExtensionsManagerDialogOpen) {
|
||||
return (
|
||||
<ExtensionsManagerDialog
|
||||
onClose={uiActions.closeExtensionsManagerDialog}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isResumeDialogOpen) {
|
||||
return (
|
||||
<SessionPicker
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
|
||||
import { UIStateContext } from '../../contexts/UIStateContext.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import type { UIState } from '../../contexts/UIStateContext.js';
|
||||
import type { Config, 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;
|
||||
|
||||
const createMockConfig = (extensions: Extension[] = []): Config =>
|
||||
({
|
||||
getExtensions: () => extensions,
|
||||
getExtensionManager: () => ({
|
||||
getLoadedExtensions: () => extensions,
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined),
|
||||
disableExtension: vi.fn().mockResolvedValue(undefined),
|
||||
enableExtension: vi.fn().mockResolvedValue(undefined),
|
||||
uninstallExtension: vi.fn().mockResolvedValue(undefined),
|
||||
updateExtension: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
getLoadedExtensions: () => extensions,
|
||||
}) as unknown as Config;
|
||||
|
||||
const createMockUIState = (
|
||||
extensionsUpdateState = new Map<string, ExtensionUpdateState>(),
|
||||
): UIState =>
|
||||
({
|
||||
extensionsUpdateState,
|
||||
}) as unknown as UIState;
|
||||
|
||||
describe('ExtensionsManagerDialog Snapshots', () => {
|
||||
const baseProps = {
|
||||
onClose: vi.fn(),
|
||||
config: createMockConfig(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render empty state when no extensions installed', () => {
|
||||
const uiState = createMockUIState();
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionsManagerDialog {...baseProps} />
|
||||
</KeypressProvider>
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render extension list with extensions', () => {
|
||||
const extensions = [
|
||||
createMockExtension('test-extension', true),
|
||||
createMockExtension('another-extension', false),
|
||||
];
|
||||
const uiState = createMockUIState(
|
||||
new Map([
|
||||
['test-extension', ExtensionUpdateState.UP_TO_DATE],
|
||||
['another-extension', ExtensionUpdateState.UPDATE_AVAILABLE],
|
||||
]),
|
||||
);
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionsManagerDialog
|
||||
{...baseProps}
|
||||
config={createMockConfig(extensions)}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with update available status', () => {
|
||||
const extensions = [createMockExtension('outdated-extension', true)];
|
||||
const uiState = createMockUIState(
|
||||
new Map([['outdated-extension', ExtensionUpdateState.UPDATE_AVAILABLE]]),
|
||||
);
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionsManagerDialog
|
||||
{...baseProps}
|
||||
config={createMockConfig(extensions)}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with checking status', () => {
|
||||
const extensions = [createMockExtension('checking-extension', true)];
|
||||
const uiState = createMockUIState(
|
||||
new Map([
|
||||
['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES],
|
||||
]),
|
||||
);
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionsManagerDialog
|
||||
{...baseProps}
|
||||
config={createMockConfig(extensions)}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
ExtensionListStep,
|
||||
ExtensionDetailStep,
|
||||
ActionSelectionStep,
|
||||
UninstallConfirmStep,
|
||||
ScopeSelectStep,
|
||||
} from './steps/index.js';
|
||||
import { MANAGEMENT_STEPS, type ExtensionAction } from './types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import type { Extension, Config } from '@qwen-code/qwen-code-core';
|
||||
import { SettingScope, createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
|
||||
interface ExtensionsManagerDialogProps {
|
||||
onClose: () => void;
|
||||
config: Config | null;
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('EXTENSIONS_MANAGER_DIALOG');
|
||||
|
||||
export function ExtensionsManagerDialog({
|
||||
onClose,
|
||||
config,
|
||||
}: ExtensionsManagerDialogProps) {
|
||||
const { extensionsUpdateState } = useUIState();
|
||||
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [selectedExtensionIndex, setSelectedExtensionIndex] =
|
||||
useState<number>(-1);
|
||||
const [navigationStack, setNavigationStack] = useState<string[]>([
|
||||
MANAGEMENT_STEPS.EXTENSION_LIST,
|
||||
]);
|
||||
const [updateInProgress, setUpdateInProgress] = useState(false);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Load extensions
|
||||
const loadExtensions = useCallback(async () => {
|
||||
if (!config) return;
|
||||
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
debugLogger.error('ExtensionManager not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await extensionManager.refreshCache();
|
||||
const loadedExtensions = extensionManager.getLoadedExtensions();
|
||||
setExtensions(loadedExtensions);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to load extensions:', error);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadExtensions();
|
||||
}, [loadExtensions]);
|
||||
|
||||
// Memoized selected extension
|
||||
const selectedExtension = useMemo(
|
||||
() =>
|
||||
selectedExtensionIndex >= 0 ? extensions[selectedExtensionIndex] : null,
|
||||
[extensions, selectedExtensionIndex],
|
||||
);
|
||||
|
||||
// Check if update is available for selected extension
|
||||
const hasUpdateAvailable = useMemo(() => {
|
||||
if (!selectedExtension) return false;
|
||||
const state = extensionsUpdateState.get(selectedExtension.name);
|
||||
return state === ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
}, [selectedExtension, extensionsUpdateState]);
|
||||
|
||||
// Helper to get current step
|
||||
const getCurrentStep = useCallback(
|
||||
() =>
|
||||
navigationStack[navigationStack.length - 1] ||
|
||||
MANAGEMENT_STEPS.EXTENSION_LIST,
|
||||
[navigationStack],
|
||||
);
|
||||
|
||||
const handleSelectExtension = useCallback((extensionIndex: number) => {
|
||||
setSelectedExtensionIndex(extensionIndex);
|
||||
setSuccessMessage(null); // Clear success message when navigating
|
||||
setNavigationStack((prev) => [...prev, MANAGEMENT_STEPS.ACTION_SELECTION]);
|
||||
}, []);
|
||||
|
||||
const handleNavigateToStep = useCallback((step: string) => {
|
||||
setNavigationStack((prev) => [...prev, step]);
|
||||
}, []);
|
||||
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
setNavigationStack((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
return prev;
|
||||
}
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleUpdateExtension = useCallback(async () => {
|
||||
if (!config || !selectedExtension) return;
|
||||
|
||||
setUpdateInProgress(true);
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
throw new Error('ExtensionManager not available');
|
||||
}
|
||||
|
||||
const state = extensionsUpdateState.get(selectedExtension.name);
|
||||
if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) {
|
||||
throw new Error('No update available');
|
||||
}
|
||||
|
||||
// Use the extension manager to update
|
||||
await extensionManager.updateExtension(
|
||||
selectedExtension,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
(name, newState) => {
|
||||
debugLogger.debug(`Update state for ${name}:`, newState);
|
||||
},
|
||||
);
|
||||
|
||||
// Reload extensions after update to get new version info
|
||||
await loadExtensions();
|
||||
|
||||
// Trigger a re-check of update status for all extensions
|
||||
await extensionManager.checkForAllExtensionUpdates((name, newState) => {
|
||||
debugLogger.debug(`Recheck update state for ${name}:`, newState);
|
||||
});
|
||||
|
||||
// Show success message
|
||||
setSuccessMessage(
|
||||
t('Extension "{{name}}" updated successfully.', {
|
||||
name: selectedExtension.name,
|
||||
}),
|
||||
);
|
||||
|
||||
// Go back to action selection
|
||||
handleNavigateBack();
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to update extension:', error);
|
||||
setUpdateError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
);
|
||||
} finally {
|
||||
setUpdateInProgress(false);
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
selectedExtension,
|
||||
extensionsUpdateState,
|
||||
loadExtensions,
|
||||
handleNavigateBack,
|
||||
]);
|
||||
|
||||
const handleActionSelect = useCallback(
|
||||
(action: ExtensionAction) => {
|
||||
switch (action) {
|
||||
case 'view':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.EXTENSION_DETAIL);
|
||||
break;
|
||||
case 'update':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.UPDATE_PROGRESS);
|
||||
handleUpdateExtension();
|
||||
break;
|
||||
case 'disable':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT);
|
||||
break;
|
||||
case 'enable':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT);
|
||||
break;
|
||||
case 'uninstall':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleNavigateToStep, handleUpdateExtension],
|
||||
);
|
||||
|
||||
const handleDisableExtension = useCallback(
|
||||
async (scope: 'user' | 'workspace') => {
|
||||
if (!config || !selectedExtension) return;
|
||||
|
||||
try {
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
throw new Error('ExtensionManager not available');
|
||||
}
|
||||
|
||||
const settingScope =
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
|
||||
|
||||
await extensionManager.disableExtension(
|
||||
selectedExtension.name,
|
||||
settingScope,
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setExtensions((prev) =>
|
||||
prev.map((ext) =>
|
||||
ext.name === selectedExtension.name
|
||||
? { ...ext, isActive: false }
|
||||
: ext,
|
||||
),
|
||||
);
|
||||
|
||||
// Show success message
|
||||
setSuccessMessage(
|
||||
t('Extension "{{name}}" disabled successfully.', {
|
||||
name: selectedExtension.name,
|
||||
}),
|
||||
);
|
||||
|
||||
// Go back to extension list to show success message
|
||||
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to disable extension:', error);
|
||||
}
|
||||
},
|
||||
[config, selectedExtension],
|
||||
);
|
||||
|
||||
const handleEnableExtension = useCallback(
|
||||
async (scope: 'user' | 'workspace') => {
|
||||
if (!config || !selectedExtension) return;
|
||||
|
||||
try {
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
throw new Error('ExtensionManager not available');
|
||||
}
|
||||
|
||||
const settingScope =
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
|
||||
|
||||
await extensionManager.enableExtension(
|
||||
selectedExtension.name,
|
||||
settingScope,
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setExtensions((prev) =>
|
||||
prev.map((ext) =>
|
||||
ext.name === selectedExtension.name
|
||||
? { ...ext, isActive: true }
|
||||
: ext,
|
||||
),
|
||||
);
|
||||
|
||||
// Show success message
|
||||
setSuccessMessage(
|
||||
t('Extension "{{name}}" enabled successfully.', {
|
||||
name: selectedExtension.name,
|
||||
}),
|
||||
);
|
||||
|
||||
// Go back to extension list to show success message
|
||||
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to enable extension:', error);
|
||||
}
|
||||
},
|
||||
[config, selectedExtension],
|
||||
);
|
||||
|
||||
const handleUninstallExtension = useCallback(
|
||||
async (extension: Extension) => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
throw new Error('ExtensionManager not available');
|
||||
}
|
||||
|
||||
await extensionManager.uninstallExtension(extension.name, false);
|
||||
|
||||
// Reload extensions
|
||||
await loadExtensions();
|
||||
|
||||
// Navigate back to extension list
|
||||
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
|
||||
setSelectedExtensionIndex(-1);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to uninstall extension:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[config, loadExtensions],
|
||||
);
|
||||
|
||||
// Centralized ESC key handling
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name !== 'escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = getCurrentStep();
|
||||
// If there's a success message, clear it first instead of closing
|
||||
if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
setSuccessMessage(null);
|
||||
return;
|
||||
}
|
||||
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
onClose();
|
||||
} else {
|
||||
handleNavigateBack();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const renderStepHeader = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
const getStepHeaderText = () => {
|
||||
switch (currentStep) {
|
||||
case MANAGEMENT_STEPS.EXTENSION_LIST:
|
||||
return t('Manage Extensions');
|
||||
case MANAGEMENT_STEPS.ACTION_SELECTION:
|
||||
return selectedExtension?.name || t('Choose Action');
|
||||
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
|
||||
return t('Extension Details');
|
||||
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
return t('Disable Extension');
|
||||
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
|
||||
return t('Enable Extension');
|
||||
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
|
||||
return t('Uninstall Extension');
|
||||
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
|
||||
return t('Update Extension');
|
||||
default:
|
||||
return t('Unknown Step');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text bold>{getStepHeaderText()}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, selectedExtension]);
|
||||
|
||||
const renderStepFooter = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
const getNavigationInstructions = () => {
|
||||
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
if (extensions.length === 0) {
|
||||
return t('Esc to close');
|
||||
}
|
||||
return t('Enter to select, ↑↓ to navigate, Esc to close');
|
||||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) {
|
||||
return t('Esc to go back');
|
||||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) {
|
||||
return t('Y/Enter to confirm, N/Esc to cancel');
|
||||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
|
||||
return updateInProgress ? t('Updating...') : '';
|
||||
}
|
||||
|
||||
return t('Enter to select, ↑↓ to navigate, Esc to go back');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, extensions.length, updateInProgress]);
|
||||
|
||||
const renderStepContent = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
|
||||
// Show success message if present (only on extension list step)
|
||||
if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.status.success}>{successMessage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (updateError && currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.status.error}>{t('Update failed:')}</Text>
|
||||
<Text>{updateError}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case MANAGEMENT_STEPS.EXTENSION_LIST:
|
||||
return (
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={extensionsUpdateState}
|
||||
onExtensionSelect={handleSelectExtension}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.ACTION_SELECTION:
|
||||
return (
|
||||
<ActionSelectionStep
|
||||
selectedExtension={selectedExtension}
|
||||
hasUpdateAvailable={hasUpdateAvailable}
|
||||
onNavigateToStep={handleNavigateToStep}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onActionSelect={handleActionSelect}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
|
||||
return <ExtensionDetailStep selectedExtension={selectedExtension} />;
|
||||
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
return (
|
||||
<ScopeSelectStep
|
||||
selectedExtension={selectedExtension}
|
||||
mode="disable"
|
||||
onScopeSelect={handleDisableExtension}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
|
||||
return (
|
||||
<ScopeSelectStep
|
||||
selectedExtension={selectedExtension}
|
||||
mode="enable"
|
||||
onScopeSelect={handleEnableExtension}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
|
||||
return (
|
||||
<UninstallConfirmStep
|
||||
selectedExtension={selectedExtension}
|
||||
onConfirm={handleUninstallExtension}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
{updateInProgress
|
||||
? t('Updating {{name}}...', {
|
||||
name: selectedExtension?.name || '',
|
||||
})
|
||||
: t('Update complete!')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>
|
||||
{t('Invalid step: {{step}}', { step: currentStep })}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
getCurrentStep,
|
||||
extensions,
|
||||
extensionsUpdateState,
|
||||
selectedExtension,
|
||||
hasUpdateAvailable,
|
||||
updateInProgress,
|
||||
updateError,
|
||||
successMessage,
|
||||
handleSelectExtension,
|
||||
handleNavigateToStep,
|
||||
handleNavigateBack,
|
||||
handleActionSelect,
|
||||
handleDisableExtension,
|
||||
handleEnableExtension,
|
||||
handleUninstallExtension,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
gap={1}
|
||||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
{renderStepFooter()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExtensionsManagerDialog Snapshots > should render empty state when no extensions installed 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`ExtensionsManagerDialog Snapshots > should render extension list with extensions 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`ExtensionsManagerDialog Snapshots > should render with checking status 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`ExtensionsManagerDialog Snapshots > should render with update available status 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
9
packages/cli/src/ui/components/extensions/index.ts
Normal file
9
packages/cli/src/ui/components/extensions/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
|
||||
export type { ExtensionsManagerDialogProps } from './types.js';
|
||||
export { MANAGEMENT_STEPS } from './types.js';
|
||||
|
|
@ -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';
|
||||
89
packages/cli/src/ui/components/extensions/types.ts
Normal file
89
packages/cli/src/ui/components/extensions/types.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Extension, Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Management steps for the extensions manager dialog.
|
||||
*/
|
||||
export const MANAGEMENT_STEPS = {
|
||||
EXTENSION_LIST: 'extension-list',
|
||||
ACTION_SELECTION: 'action-selection',
|
||||
EXTENSION_DETAIL: 'extension-detail',
|
||||
UNINSTALL_CONFIRMATION: 'uninstall-confirmation',
|
||||
DISABLE_SCOPE_SELECT: 'disable-scope-select',
|
||||
ENABLE_SCOPE_SELECT: 'enable-scope-select',
|
||||
UPDATE_PROGRESS: 'update-progress',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Props for step navigation.
|
||||
*/
|
||||
export interface StepNavigationProps {
|
||||
onNavigateToStep: (step: string) => void;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the extension list step.
|
||||
*/
|
||||
export interface ExtensionListStepProps extends StepNavigationProps {
|
||||
extensions: Extension[];
|
||||
extensionsUpdateState: Map<string, string>;
|
||||
onExtensionSelect: (extensionIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the extension detail step.
|
||||
*/
|
||||
export interface ExtensionDetailStepProps extends StepNavigationProps {
|
||||
selectedExtension: Extension | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the action selection step.
|
||||
*/
|
||||
export interface ActionSelectionStepProps extends StepNavigationProps {
|
||||
selectedExtension: Extension | null;
|
||||
hasUpdateAvailable: boolean;
|
||||
onActionSelect: (action: ExtensionAction) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the uninstall confirmation step.
|
||||
*/
|
||||
export interface UninstallConfirmStepProps extends StepNavigationProps {
|
||||
selectedExtension: Extension | null;
|
||||
onConfirm: (extension: Extension) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the scope selection step.
|
||||
*/
|
||||
export interface ScopeSelectStepProps extends StepNavigationProps {
|
||||
selectedExtension: Extension | null;
|
||||
mode: 'disable' | 'enable';
|
||||
onScopeSelect: (scope: 'user' | 'workspace') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available actions for an extension.
|
||||
*/
|
||||
export type ExtensionAction =
|
||||
| 'view'
|
||||
| 'update'
|
||||
| 'disable'
|
||||
| 'enable'
|
||||
| 'uninstall'
|
||||
| 'back';
|
||||
|
||||
/**
|
||||
* Props for the ExtensionsManagerDialog component.
|
||||
*/
|
||||
export interface ExtensionsManagerDialogProps {
|
||||
onClose: () => void;
|
||||
config: Config | null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue