mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Add prompt to migrate workspace extensions (#7065)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -15,7 +15,9 @@ import {
|
||||
disableExtension,
|
||||
enableExtension,
|
||||
installExtension,
|
||||
loadExtension,
|
||||
loadExtensions,
|
||||
performWorkspaceExtensionMigration,
|
||||
uninstallExtension,
|
||||
updateExtension,
|
||||
} from './extension.js';
|
||||
@@ -46,6 +48,7 @@ vi.mock('child_process', async (importOriginal) => {
|
||||
execSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions');
|
||||
|
||||
describe('loadExtensions', () => {
|
||||
@@ -430,6 +433,80 @@ describe('uninstallExtension', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('performWorkspaceExtensionMigration', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
let tempHomeDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should install the extensions in the user directory', async () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0');
|
||||
const ext2Path = createExtension(workspaceExtensionsDir, 'ext2', '1.0.0');
|
||||
const extensionsToMigrate = [
|
||||
loadExtension(ext1Path)!,
|
||||
loadExtension(ext2Path)!,
|
||||
];
|
||||
const failed =
|
||||
await performWorkspaceExtensionMigration(extensionsToMigrate);
|
||||
|
||||
expect(failed).toEqual([]);
|
||||
|
||||
const userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions');
|
||||
const userExt1Path = path.join(userExtensionsDir, 'ext1');
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
|
||||
expect(extensions).toHaveLength(2);
|
||||
const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
||||
expect(metadata).toEqual({
|
||||
source: ext1Path,
|
||||
type: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the names of failed installations', async () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
|
||||
const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0');
|
||||
|
||||
const extensions = [
|
||||
loadExtension(ext1Path)!,
|
||||
{
|
||||
path: '/ext/path/1',
|
||||
config: { name: 'ext2', version: '1.0.0' },
|
||||
contextFiles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const failed = await performWorkspaceExtensionMigration(extensions);
|
||||
expect(failed).toEqual(['ext2']);
|
||||
});
|
||||
});
|
||||
|
||||
function createExtension(
|
||||
extensionsDir: string,
|
||||
name: string,
|
||||
|
||||
@@ -77,15 +77,51 @@ export class ExtensionStorage {
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
|
||||
return loadExtensionsFromDir(workspaceDir);
|
||||
}
|
||||
|
||||
async function copyExtension(
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
await fs.promises.cp(source, destination, { recursive: true });
|
||||
}
|
||||
|
||||
export async function performWorkspaceExtensionMigration(
|
||||
extensions: Extension[],
|
||||
): Promise<string[]> {
|
||||
const failedInstallNames: string[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: extension.path,
|
||||
type: 'local',
|
||||
};
|
||||
await installExtension(installMetadata);
|
||||
} catch (_) {
|
||||
failedInstallNames.push(extension.config.name);
|
||||
}
|
||||
}
|
||||
return failedInstallNames;
|
||||
}
|
||||
|
||||
export function loadExtensions(workspaceDir: string): Extension[] {
|
||||
const allExtensions = [
|
||||
...loadExtensionsFromDir(workspaceDir),
|
||||
...loadExtensionsFromDir(os.homedir()),
|
||||
];
|
||||
const settings = loadSettings(workspaceDir).merged;
|
||||
const disabledExtensions = settings.extensions?.disabled ?? [];
|
||||
const allExtensions = [...loadUserExtensions()];
|
||||
|
||||
if (!settings.extensionManagement) {
|
||||
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
|
||||
}
|
||||
|
||||
const uniqueExtensions = new Map<string, Extension>();
|
||||
for (const extension of allExtensions) {
|
||||
if (!uniqueExtensions.has(extension.config.name)) {
|
||||
if (
|
||||
!uniqueExtensions.has(extension.config.name) &&
|
||||
!disabledExtensions.includes(extension.config.name)
|
||||
) {
|
||||
uniqueExtensions.set(extension.config.name, extension);
|
||||
}
|
||||
}
|
||||
@@ -283,18 +319,6 @@ async function cloneFromGit(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an extension from a source to a destination path.
|
||||
* @param source The source path of the extension.
|
||||
* @param destination The destination path to copy the extension to.
|
||||
*/
|
||||
async function copyExtension(
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
await fs.promises.cp(source, destination, { recursive: true });
|
||||
}
|
||||
|
||||
export async function installExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
cwd: string = process.cwd(),
|
||||
|
||||
@@ -122,6 +122,10 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
expect(settings.errors.length).toBe(0);
|
||||
});
|
||||
@@ -157,6 +161,10 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,6 +200,10 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,6 +237,10 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,6 +280,10 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,6 +335,10 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,6 +401,10 @@ describe('Settings Loading and Merging', () => {
|
||||
'/system/dir',
|
||||
],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -537,6 +565,7 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'NODE_ENV',
|
||||
@@ -944,6 +973,10 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Check that error objects are populated in settings.errors
|
||||
@@ -1316,6 +1349,10 @@ describe('Settings Loading and Merging', () => {
|
||||
mcpServers: {},
|
||||
includeDirectories: [],
|
||||
chatCompression: {},
|
||||
extensions: {
|
||||
disabled: [],
|
||||
workspacesWithMigrationNudge: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +133,28 @@ function mergeSettings(
|
||||
...(safeWorkspace.chatCompression || {}),
|
||||
...(system.chatCompression || {}),
|
||||
},
|
||||
extensions: {
|
||||
...(systemDefaults.extensions || {}),
|
||||
...(user.extensions || {}),
|
||||
...(safeWorkspace.extensions || {}),
|
||||
...(system.extensions || {}),
|
||||
disabled: [
|
||||
...new Set([
|
||||
...(systemDefaults.extensions?.disabled || []),
|
||||
...(user.extensions?.disabled || []),
|
||||
...(safeWorkspace.extensions?.disabled || []),
|
||||
...(system.extensions?.disabled || []),
|
||||
]),
|
||||
],
|
||||
workspacesWithMigrationNudge: [
|
||||
...new Set([
|
||||
...(systemDefaults.extensions?.workspacesWithMigrationNudge || []),
|
||||
...(user.extensions?.workspacesWithMigrationNudge || []),
|
||||
...(safeWorkspace.extensions?.workspacesWithMigrationNudge || []),
|
||||
...(system.extensions?.workspacesWithMigrationNudge || []),
|
||||
]),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -572,6 +572,16 @@ export const SETTINGS_SCHEMA = {
|
||||
description: 'List of disabled extensions.',
|
||||
showInDialog: false,
|
||||
},
|
||||
workspacesWithMigrationNudge: {
|
||||
type: 'array',
|
||||
label: 'Workspaces with Migration Nudge',
|
||||
category: 'Extensions',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'List of workspaces for which the migration nudge has been shown.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
skipNextSpeakerCheck: {
|
||||
|
||||
@@ -103,6 +103,8 @@ import { SettingsDialog } from './components/SettingsDialog.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
import { isNarrowWidth } from './utils/isNarrowWidth.js';
|
||||
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
|
||||
import { WorkspaceMigrationDialog } from './components/WorkspaceMigrationDialog.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
// Maximum number of queued messages to display in UI to prevent performance issues
|
||||
@@ -223,6 +225,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
>();
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const {
|
||||
showWorkspaceMigrationDialog,
|
||||
workspaceExtensions,
|
||||
onWorkspaceMigrationDialogOpen,
|
||||
onWorkspaceMigrationDialogClose,
|
||||
} = useWorkspaceMigration(settings);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
|
||||
@@ -1018,8 +1026,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{shouldShowIdePrompt && currentIDE ? (
|
||||
{showWorkspaceMigrationDialog ? (
|
||||
<WorkspaceMigrationDialog
|
||||
workspaceExtensions={workspaceExtensions}
|
||||
onOpen={onWorkspaceMigrationDialogOpen}
|
||||
onClose={onWorkspaceMigrationDialogClose}
|
||||
/>
|
||||
) : shouldShowIdePrompt && currentIDE ? (
|
||||
<IdeIntegrationNudge
|
||||
ide={currentIDE}
|
||||
onComplete={handleIdePromptComplete}
|
||||
@@ -1167,8 +1180,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
... (+
|
||||
{messageQueue.length -
|
||||
MAX_DISPLAYED_QUEUED_MESSAGES}{' '}
|
||||
{messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES}
|
||||
more)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
108
packages/cli/src/ui/components/WorkspaceMigrationDialog.tsx
Normal file
108
packages/cli/src/ui/components/WorkspaceMigrationDialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import {
|
||||
type Extension,
|
||||
performWorkspaceExtensionMigration,
|
||||
} from '../../config/extension.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function WorkspaceMigrationDialog(props: {
|
||||
workspaceExtensions: Extension[];
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { workspaceExtensions, onOpen, onClose } = props;
|
||||
const [migrationComplete, setMigrationComplete] = useState(false);
|
||||
const [failedExtensions, setFailedExtensions] = useState<string[]>([]);
|
||||
onOpen();
|
||||
const onMigrate = async () => {
|
||||
const failed =
|
||||
await performWorkspaceExtensionMigration(workspaceExtensions);
|
||||
setFailedExtensions(failed);
|
||||
setMigrationComplete(true);
|
||||
};
|
||||
|
||||
useInput((input) => {
|
||||
if (migrationComplete && input === 'q') {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
if (migrationComplete) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
padding={1}
|
||||
>
|
||||
{failedExtensions.length > 0 ? (
|
||||
<>
|
||||
<Text>
|
||||
The following extensions failed to migrate. Please try installing
|
||||
them manually. To see other changes, Gemini CLI must be restarted.
|
||||
Press {"'q'"} to quit.
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
||||
{failedExtensions.map((failed) => (
|
||||
<Text key={failed}>- {failed}</Text>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Text>
|
||||
Migration complete. To see changes, Gemini CLI must be restarted.
|
||||
Press {"'q'"} to quit.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>Workspace-level extensions are deprecated{'\n'}</Text>
|
||||
<Text>Would you like to install them at the user level?</Text>
|
||||
<Text>
|
||||
The extension definition will remain in your workspace directory.
|
||||
</Text>
|
||||
<Text>
|
||||
If you opt to skip, you can install them manually using the extensions
|
||||
install command.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
||||
{workspaceExtensions.map((extension) => (
|
||||
<Text key={extension.config.name}>- {extension.config.name}</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Install all', value: 'migrate' },
|
||||
{ label: 'Skip', value: 'skip' },
|
||||
]}
|
||||
onSelect={(value: string) => {
|
||||
if (value === 'migrate') {
|
||||
onMigrate();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
66
packages/cli/src/ui/hooks/useWorkspaceMigration.ts
Normal file
66
packages/cli/src/ui/hooks/useWorkspaceMigration.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
type Extension,
|
||||
getWorkspaceExtensions,
|
||||
} from '../../config/extension.js';
|
||||
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import process from 'node:process';
|
||||
|
||||
export function useWorkspaceMigration(settings: LoadedSettings) {
|
||||
const [showWorkspaceMigrationDialog, setShowWorkspaceMigrationDialog] =
|
||||
useState(false);
|
||||
const [workspaceExtensions, setWorkspaceExtensions] = useState<Extension[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.merged.extensionManagement) {
|
||||
return;
|
||||
}
|
||||
const cwd = process.cwd();
|
||||
const extensions = getWorkspaceExtensions(cwd);
|
||||
if (
|
||||
extensions.length > 0 &&
|
||||
!settings.merged.extensions?.workspacesWithMigrationNudge?.includes(cwd)
|
||||
) {
|
||||
setWorkspaceExtensions(extensions);
|
||||
setShowWorkspaceMigrationDialog(true);
|
||||
console.log(settings.merged.extensions);
|
||||
}
|
||||
}, [settings.merged.extensions, settings.merged.extensionManagement]);
|
||||
|
||||
const onWorkspaceMigrationDialogOpen = () => {
|
||||
const userSettings = settings.forScope(SettingScope.User);
|
||||
const extensionSettings = userSettings.settings.extensions || {
|
||||
disabled: [],
|
||||
};
|
||||
const workspacesWithMigrationNudge =
|
||||
extensionSettings.workspacesWithMigrationNudge || [];
|
||||
|
||||
const cwd = process.cwd();
|
||||
if (!workspacesWithMigrationNudge.includes(cwd)) {
|
||||
workspacesWithMigrationNudge.push(cwd);
|
||||
}
|
||||
|
||||
extensionSettings.workspacesWithMigrationNudge =
|
||||
workspacesWithMigrationNudge;
|
||||
settings.setValue(SettingScope.User, 'extensions', extensionSettings);
|
||||
};
|
||||
|
||||
const onWorkspaceMigrationDialogClose = () => {
|
||||
setShowWorkspaceMigrationDialog(false);
|
||||
};
|
||||
|
||||
return {
|
||||
showWorkspaceMigrationDialog,
|
||||
workspaceExtensions,
|
||||
onWorkspaceMigrationDialogOpen,
|
||||
onWorkspaceMigrationDialogClose,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user