From 7dcf983958d8aac40e68bf7d6b484098ca40153e Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 12 Nov 2026 13:21:54 -0800 Subject: [PATCH] Select existing environment before creating template project Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/common/pickers/environments.ts | 8 +- src/features/creators/newPackageProject.ts | 350 ++++++++++---- src/features/settings/settingHelpers.ts | 13 +- .../common/environmentPicker.unit.test.ts | 40 ++ .../creators/newPackageProject.unit.test.ts | 427 ++++++++++++++++++ .../settings/settingHelpers.unit.test.ts | 67 +++ 6 files changed, 809 insertions(+), 96 deletions(-) create mode 100644 src/test/features/creators/newPackageProject.unit.test.ts diff --git a/src/common/pickers/environments.ts b/src/common/pickers/environments.ts index 86a9ba059..029932b03 100644 --- a/src/common/pickers/environments.ts +++ b/src/common/pickers/environments.ts @@ -45,6 +45,7 @@ function getIconPath(i: IconPath | undefined): QuickPickIcon { interface EnvironmentPickOptions { recommended?: PythonEnvironment; showBackButton?: boolean; + showCreateEnvironment?: boolean; projects: PythonProject[]; } async function browseForPython( @@ -156,7 +157,9 @@ async function pickEnvironmentImpl( }); if (selected && !Array.isArray(selected)) { - if (selected.label === Interpreter.browsePath) { + if ('result' in selected) { + return selected.result; + } else if (selected.label === Interpreter.browsePath) { return browseForPython(managers, projectEnvManagers); } else if (selected.label === Interpreter.enterInterpreterPath) { return enterPythonPath(managers, projectEnvManagers); @@ -167,7 +170,6 @@ async function pickEnvironmentImpl( }); return createEnvironment(managers, projectEnvManagers, options); } - return (selected as { result: PythonEnvironment })?.result; } return undefined; } @@ -188,7 +190,7 @@ export async function pickEnvironment( }, ]; - if (getWorkspaceFolders()?.length) { + if (options.showCreateEnvironment !== false && getWorkspaceFolders()?.length) { items.push( { label: '', diff --git a/src/features/creators/newPackageProject.ts b/src/features/creators/newPackageProject.ts index 1599505eb..509a8a519 100644 --- a/src/features/creators/newPackageProject.ts +++ b/src/features/creators/newPackageProject.ts @@ -1,12 +1,18 @@ import * as fs from 'fs-extra'; import * as path from 'path'; -import { commands, l10n, MarkdownString, QuickInputButtons, Uri, window, workspace } from 'vscode'; +import { l10n, MarkdownString, QuickInputButtons, Uri } from 'vscode'; import { PythonEnvironment, PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from '../../api'; +import { executeCommand } from '../../common/command.api'; import { NEW_PROJECT_TEMPLATES_FOLDER, VENV_MANAGER_ID } from '../../common/constants'; import { traceError } from '../../common/logging'; -import { showInputBoxWithButtons } from '../../common/window.apis'; +import { pickEnvironment } from '../../common/pickers/environments'; +import { normalizePath } from '../../common/utils/pathUtils'; +import { showErrorMessage, showInputBoxWithButtons } from '../../common/window.apis'; +import { getWorkspaceFolders } from '../../common/workspace.apis'; +import type { InternalEnvironmentManager } from '../../managers/common/registeredManagers'; import type { EnvironmentManagers } from '../envManagers'; import type { PythonProjectManager } from '../projectManager'; +import { removePythonProjectSetting } from '../settings/settingHelpers'; import { isCopilotInstalled, manageCopilotInstructionsFile, @@ -15,6 +21,22 @@ import { replaceInFilesAndNames, } from './creationHelpers'; +function showProjectAlreadyExistsError(): void { + showErrorMessage( + l10n.t( + 'A project folder by that name already exists, aborting creation. Please retry with a unique package name given your workspace.', + ), + ); +} + +function showProjectAlreadyConfiguredError(): void { + showErrorMessage( + l10n.t( + 'A Python project by that name is already configured, aborting creation. Remove it or retry with a unique package name.', + ), + ); +} + export class NewPackageProject implements PythonProjectCreator { public readonly name = l10n.t('newPackage'); public readonly displayName = l10n.t('Package'); @@ -26,10 +48,69 @@ export class NewPackageProject implements PythonProjectCreator { private readonly projectManager: PythonProjectManager, ) {} + private getExactProject(uri: Uri): PythonProject | undefined { + const project = this.projectManager.get(uri); + return project && normalizePath(project.uri.fsPath) === normalizePath(uri.fsPath) ? project : undefined; + } + + private async rollbackCreatedPackage( + createdPackage: PythonProject, + projectDestinationFolder: string, + options: { + projectFolderOwned: boolean; + projectRegistrationOwned: boolean; + environmentManager?: InternalEnvironmentManager; + }, + ): Promise { + if (options.environmentManager) { + const scopeManager = this.envManagers.getEnvironmentManager(createdPackage.uri); + let selectedManagerCleared = false; + try { + await this.envManagers.setEnvironment(createdPackage.uri, undefined, false); + selectedManagerCleared = scopeManager?.id === options.environmentManager.id; + } catch (rollbackError) { + traceError('Failed to clear the new package environment after creation failed:', rollbackError); + } + if (!selectedManagerCleared) { + try { + await options.environmentManager.set(createdPackage.uri, undefined); + } catch (rollbackError) { + traceError( + 'Failed to clear the selected environment manager after package creation failed:', + rollbackError, + ); + } + } + } + if (options.projectRegistrationOwned) { + try { + await removePythonProjectSetting([{ project: createdPackage }]); + } catch (rollbackError) { + traceError('Failed to remove the new package setting after creation failed:', rollbackError); + } + try { + this.projectManager.remove(createdPackage); + } catch (rollbackError) { + traceError('Failed to remove the new package project after creation failed:', rollbackError); + } + } + if (options.projectFolderOwned) { + try { + await fs.remove(projectDestinationFolder); + } catch (rollbackError) { + traceError('Failed to delete the new package after creation failed:', rollbackError); + } + } + } + async create(options?: PythonProjectCreatorOptions): Promise { let packageName = options?.name; let createVenv: boolean | undefined; let createCopilotInstructions: boolean | undefined; + let selectedEnvironment: PythonEnvironment | undefined; + const restartCreation = () => { + void this.create(options); + }; if (options?.quickCreate === true) { // If quickCreate is true, we should not prompt for any input if (!packageName) { @@ -60,17 +141,14 @@ export class NewPackageProject implements PythonProjectCreator { }); } catch (ex) { if (ex === QuickInputButtons.Back) { - await commands.executeCommand('python-envs.createNewProjectFromTemplate'); + await executeCommand('python-envs.createNewProjectFromTemplate'); } } if (!packageName) { return undefined; } // Use helper to prompt for virtual environment creation - const callback = () => { - return this.create(options); - }; - createVenv = await promptForVenv(callback); + createVenv = await promptForVenv(restartCreation); if (createVenv === undefined) { return undefined; } @@ -78,121 +156,209 @@ export class NewPackageProject implements PythonProjectCreator { createCopilotInstructions = true; } } + } + + if (!packageName) { + return undefined; + } - // 1. Copy template folder - const newPackageTemplateFolder = path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'newPackageTemplate'); - if (!(await fs.pathExists(newPackageTemplateFolder))) { - window.showErrorMessage(l10n.t('Template folder does not exist, aborting creation.')); + // 1. Copy template folder + const newPackageTemplateFolder = path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'newPackageTemplate'); + if (!(await fs.pathExists(newPackageTemplateFolder))) { + showErrorMessage(l10n.t('Template folder does not exist, aborting creation.')); + return undefined; + } + + // Check if the destination folder is provided, otherwise use the first workspace folder + let destRoot = options?.rootUri.fsPath; + if (!destRoot) { + const workspaceFolders = getWorkspaceFolders(); + if (!workspaceFolders || workspaceFolders.length === 0) { + showErrorMessage(l10n.t('No workspace folder is open or provided, aborting creation.')); + traceError(`Template file not found at: ${newPackageTemplateFolder}`); return undefined; } + destRoot = workspaceFolders[0].uri.fsPath; + } - // Check if the destination folder is provided, otherwise use the first workspace folder - let destRoot = options?.rootUri.fsPath; - if (!destRoot) { - const workspaceFolders = workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - window.showErrorMessage(l10n.t('No workspace folder is open or provided, aborting creation.')); - traceError(`Template file not found at: ${newPackageTemplateFolder}`); + // Check if the destination folder already exists + const projectDestinationFolder = path.join(destRoot, `${packageName}_project`); + if (await fs.pathExists(projectDestinationFolder)) { + showProjectAlreadyExistsError(); + return undefined; + } + const createdPackage: PythonProject = { + name: packageName, + uri: Uri.file(projectDestinationFolder), + }; + if (this.getExactProject(createdPackage.uri)) { + showProjectAlreadyConfiguredError(); + return undefined; + } + + if (createVenv === false) { + const projectEnvManagers = this.envManagers.getProjectEnvManagers([createdPackage.uri]); + while (createVenv === false && !selectedEnvironment) { + try { + selectedEnvironment = await pickEnvironment(this.envManagers.managers, projectEnvManagers, { + projects: [createdPackage], + showBackButton: true, + showCreateEnvironment: false, + }); + } catch (ex) { + if (ex !== QuickInputButtons.Back) { + throw ex; + } + createVenv = await promptForVenv(restartCreation); + if (createVenv === undefined) { + return undefined; + } + continue; + } + if (!selectedEnvironment) { return undefined; } - destRoot = workspaceFolders[0].uri.fsPath; } + } - // Check if the destination folder already exists - const projectDestinationFolder = path.join(destRoot, `${packageName}_project`); - if (await fs.pathExists(projectDestinationFolder)) { - window.showErrorMessage( + let selectedEnvironmentManager: InternalEnvironmentManager | undefined; + if (selectedEnvironment) { + const manager = this.envManagers.getEnvironmentManager(selectedEnvironment); + if (!manager) { + throw new Error( l10n.t( - 'A project folder by that name already exists, aborting creation. Please retry with a unique package name given your workspace.', + 'Environment manager {0} is no longer available.', + selectedEnvironment.envId.managerId, ), ); + } + selectedEnvironmentManager = manager; + } + + let projectFolderOwned = false; + try { + await fs.mkdir(projectDestinationFolder); + projectFolderOwned = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + showProjectAlreadyExistsError(); return undefined; } + throw error; + } + + if (this.getExactProject(createdPackage.uri)) { + await this.rollbackCreatedPackage(createdPackage, projectDestinationFolder, { + projectFolderOwned, + projectRegistrationOwned: false, + }); + showProjectAlreadyConfiguredError(); + return undefined; + } + + let projectRegistrationOwned = false; + try { await fs.copy(newPackageTemplateFolder, projectDestinationFolder); // 2. Replace 'package_name' in all files and file/folder names using a helper await replaceInFilesAndNames(projectDestinationFolder, 'package_name', packageName); - const createdPackage: PythonProject | undefined = { - name: packageName, - uri: Uri.file(projectDestinationFolder), - }; // add package to list of packages await this.projectManager.add(createdPackage); + projectRegistrationOwned = this.getExactProject(createdPackage.uri) === createdPackage; + if (!projectRegistrationOwned) { + throw new Error(l10n.t('The new Python project could not be registered.')); + } + } catch (creationError) { + projectRegistrationOwned = this.getExactProject(createdPackage.uri) === createdPackage; + await this.rollbackCreatedPackage(createdPackage, projectDestinationFolder, { + projectFolderOwned, + projectRegistrationOwned, + }); + throw creationError; + } - // 4. Create virtual environment if requested - let createdEnv: PythonEnvironment | undefined; - if (createVenv) { - // gets default environment manager - const en = this.envManagers.getEnvironmentManager(undefined); - if (en?.supportsQuickCreate) { - // opt to use quickCreate if available - createdEnv = await en.create(Uri.file(projectDestinationFolder), { quickCreate: true }); - } else if (!options?.quickCreate && en?.supportsCreate) { - // if quickCreate unavailable, use create method only if project is not quickCreate - createdEnv = await en.create(Uri.file(projectDestinationFolder), {}); + // 4. Create virtual environment if requested + let createdEnv = selectedEnvironment; + if (createVenv) { + // gets default environment manager + const en = this.envManagers.getEnvironmentManager(undefined); + if (en?.supportsQuickCreate) { + // opt to use quickCreate if available + createdEnv = await en.create(Uri.file(projectDestinationFolder), { quickCreate: true }); + } else if (!options?.quickCreate && en?.supportsCreate) { + // if quickCreate unavailable, use create method only if project is not quickCreate + createdEnv = await en.create(Uri.file(projectDestinationFolder), {}); + } else { + // get venv manager or any manager that supports quick creating environments + const venvManager = this.envManagers.managers.find( + (m) => m.id === VENV_MANAGER_ID || m.supportsQuickCreate, + ); + if (venvManager) { + createdEnv = await venvManager.create(Uri.file(projectDestinationFolder), { + quickCreate: true, + }); } else { - // get venv manager or any manager that supports quick creating environments - const venvManager = this.envManagers.managers.find( - (m) => m.id === VENV_MANAGER_ID || m.supportsQuickCreate, + const createCustomEnvironment = l10n.t('Create custom environment'); + const action = await showErrorMessage( + l10n.t( + 'A virtual environment could not be created for the new package "{0}" because your default environment manager does not support this operation and no alternative was available.', + packageName, + ), + createCustomEnvironment, ); - if (venvManager) { - createdEnv = await venvManager.create(Uri.file(projectDestinationFolder), { - quickCreate: true, + if (action === createCustomEnvironment) { + await executeCommand('python-envs.createAny', { + uri: createdPackage.uri, + selectEnvironment: true, }); - } else { - const action = await window.showErrorMessage( - l10n.t( - 'A virtual environment could not be created for the new package "{0}" because your default environment manager does not support this operation and no alternative was available.', - packageName, - ), - l10n.t('Create custom environment'), - ); - if (action === l10n.t('Create custom environment')) { - await commands.executeCommand('python-envs.createAny', { - uri: createdPackage.uri, - selectEnvironment: true, - }); - createdEnv = await this.envManagers.getEnvironment(createdPackage.uri); - } + createdEnv = await this.envManagers.getEnvironment(createdPackage.uri); } } } - // 5. Get the Python environment for the destination folder if not already created - createdEnv = createdEnv || (await this.envManagers.getEnvironment(Uri.parse(projectDestinationFolder))); - if (!createdEnv) { - window.showErrorMessage( - l10n.t('Project created but unable to be correlated to correct Python environment.'), - ); - return undefined; - } - - // 6. Set the Python environment for the package - await this.envManagers.setEnvironment(createdPackage?.uri, createdEnv); + } + // 5. Get the Python environment for the destination folder if not already created + createdEnv = createdEnv || (await this.envManagers.getEnvironment(createdPackage.uri)); + if (!createdEnv) { + showErrorMessage(l10n.t('Project created but unable to be correlated to correct Python environment.')); + return undefined; + } - // 7. add custom github copilot instructions - if (createCopilotInstructions) { - const packageInstructionsPath = path.join( - NEW_PROJECT_TEMPLATES_FOLDER, - 'copilot-instructions-text', - 'package-copilot-instructions.md', - ); - await manageCopilotInstructionsFile(destRoot, packageInstructionsPath, [ - { searchValue: '', replaceValue: packageName }, - ]); + // 6. Set the Python environment for the package + try { + await this.envManagers.setEnvironment(createdPackage.uri, createdEnv); + } catch (assignmentError) { + if (selectedEnvironment) { + await this.rollbackCreatedPackage(createdPackage, projectDestinationFolder, { + projectFolderOwned, + projectRegistrationOwned, + environmentManager: selectedEnvironmentManager, + }); } + throw assignmentError; + } - // update launch.json file with config for the package - const launchJsonConfig = { - name: `Python Package: ${packageName}`, - type: 'debugpy', - request: 'launch', - module: packageName, - }; - await manageLaunchJsonFile(destRoot, JSON.stringify(launchJsonConfig)); - - return createdPackage; + // 7. add custom github copilot instructions + if (createCopilotInstructions) { + const packageInstructionsPath = path.join( + NEW_PROJECT_TEMPLATES_FOLDER, + 'copilot-instructions-text', + 'package-copilot-instructions.md', + ); + await manageCopilotInstructionsFile(destRoot, packageInstructionsPath, [ + { searchValue: '', replaceValue: packageName }, + ]); } - return undefined; + + // update launch.json file with config for the package + const launchJsonConfig = { + name: `Python Package: ${packageName}`, + type: 'debugpy', + request: 'launch', + module: packageName, + }; + await manageLaunchJsonFile(destRoot, JSON.stringify(launchJsonConfig)); + + return createdPackage; } } diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index 7c22b38df..9c87795c2 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -889,7 +889,18 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]): const overrides = config.get('pythonProjects', []); es.forEach((e) => { const pwPath = normalizePath(e.project.uri.fsPath); - const index = overrides.findIndex((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); + const workspaceIndex = overrides.findIndex( + (s) => + s.workspace === w.name && + normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath, + ); + const index = + workspaceIndex >= 0 + ? workspaceIndex + : overrides.findIndex( + (s) => + !s.workspace && normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath, + ); if (index >= 0) { overrides.splice(index, 1); } diff --git a/src/test/common/environmentPicker.unit.test.ts b/src/test/common/environmentPicker.unit.test.ts index f93ba6714..8cd6c13bf 100644 --- a/src/test/common/environmentPicker.unit.test.ts +++ b/src/test/common/environmentPicker.unit.test.ts @@ -63,6 +63,46 @@ suite('Environment Picker Creation Availability', () => { assert.strictEqual(selected, environment); }); + test('can hide environment creation when an open folder exists', async () => { + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([folder]); + const picker = sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items) => { + assert.ok(items.some((item) => item.label === Interpreter.browsePath)); + assert.ok(items.some((item) => item.label === Interpreter.enterInterpreterPath)); + assert.ok(!items.some((item) => item.label === Interpreter.createVirtualEnvironment)); + return undefined; + }); + + assert.strictEqual( + await pickEnvironment([], [], { projects: [], showCreateEnvironment: false }), + undefined, + ); + sinon.assert.calledOnce(picker); + }); + + test('selects an environment whose name matches an action label', async () => { + const environment = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'matching-label', 'python'), + displayName: Interpreter.createVirtualEnvironment, + }); + const manager = { + displayName: 'Test manager', + getEnvironments: sinon.stub().resolves([environment]), + } as unknown as InternalEnvironmentManager; + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([folder]); + const pickManager = sinon.stub(managerPicker, 'pickEnvironmentManager'); + sinon + .stub(windowApis, 'showQuickPickWithButtons') + .callsFake(async (items) => items.find((item) => 'result' in item)); + + const selected = await pickEnvironment([manager], [manager], { + projects: [], + showCreateEnvironment: false, + }); + + assert.strictEqual(selected, environment); + sinon.assert.notCalled(pickManager); + }); + test('still allows browsing for an interpreter without an open folder', async () => { sinon.stub(workspaceApis, 'getWorkspaceFolders').returns(undefined); sinon diff --git a/src/test/features/creators/newPackageProject.unit.test.ts b/src/test/features/creators/newPackageProject.unit.test.ts new file mode 100644 index 000000000..c9100434f --- /dev/null +++ b/src/test/features/creators/newPackageProject.unit.test.ts @@ -0,0 +1,427 @@ +import assert from 'node:assert'; +import fsExtra from 'fs-extra'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { QuickInputButtons, QuickPickItem, Uri, WorkspaceFolder } from 'vscode'; +import { PythonEnvironment, PythonProject } from '../../../api'; +import { NEW_PROJECT_TEMPLATES_FOLDER } from '../../../common/constants'; +import { Interpreter } from '../../../common/localize'; +import * as environmentPickers from '../../../common/pickers/environments'; +import * as windowApis from '../../../common/window.apis'; +import * as workspaceApis from '../../../common/workspace.apis'; +import * as creationHelpers from '../../../features/creators/creationHelpers'; +import { NewPackageProject } from '../../../features/creators/newPackageProject'; +import type { EnvironmentManagers } from '../../../features/envManagers'; +import type { PythonProjectManager } from '../../../features/projectManager'; +import * as settingHelpers from '../../../features/settings/settingHelpers'; +import type { InternalEnvironmentManager } from '../../../managers/common/registeredManagers'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('NewPackageProject', () => { + const packageName = 'sample_package'; + const workspaceRoot = path.join(process.cwd(), 'package-project-workspace'); + const rootUri = Uri.file(workspaceRoot); + const projectDestination = path.join(rootUri.fsPath, `${packageName}_project`); + const templateFolder = path.resolve(path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'newPackageTemplate')); + const workspaceFolder: WorkspaceFolder = { + index: 0, + name: 'workspace', + uri: rootUri, + }; + + let existingEnvironment: PythonEnvironment; + let createdEnvironment: PythonEnvironment; + let manager: InternalEnvironmentManager; + let envManagers: EnvironmentManagers; + let projectManager: PythonProjectManager; + let managerCreate: sinon.SinonStub; + let managerGetEnvironments: sinon.SinonStub; + let managerSet: sinon.SinonStub; + let getEnvironmentManager: sinon.SinonStub; + let getProjectEnvManagers: sinon.SinonStub; + let getEnvironment: sinon.SinonStub; + let setEnvironment: sinon.SinonStub; + let addProject: sinon.SinonStub; + let getProject: sinon.SinonStub; + let removeProject: sinon.SinonStub; + let claimProjectFolder: sinon.SinonStub; + let copyTemplate: sinon.SinonStub; + let removeFolder: sinon.SinonStub; + let removeProjectSetting: sinon.SinonStub; + let manageLaunchJsonFile: sinon.SinonStub; + let showErrorMessage: sinon.SinonStub; + let showInputBox: sinon.SinonStub; + let registeredProject: PythonProject | undefined; + + setup(() => { + existingEnvironment = createMockPythonEnvironment({ + name: 'existing', + envPath: path.join(process.cwd(), 'existing-venv', 'python'), + managerId: 'test:venv', + }); + createdEnvironment = createMockPythonEnvironment({ + name: 'created', + envPath: path.join(projectDestination, '.venv', 'python'), + managerId: 'test:venv', + }); + managerCreate = sinon.stub().resolves(createdEnvironment); + managerGetEnvironments = sinon.stub().resolves([]); + managerSet = sinon.stub().resolves(); + manager = { + id: 'test:venv', + displayName: 'Test environments', + supportsCreate: true, + supportsQuickCreate: true, + create: managerCreate, + getEnvironments: managerGetEnvironments, + set: managerSet, + } as unknown as InternalEnvironmentManager; + getEnvironmentManager = sinon.stub().returns(manager); + getProjectEnvManagers = sinon.stub().returns([manager]); + getEnvironment = sinon.stub().resolves(undefined); + setEnvironment = sinon.stub().resolves(); + envManagers = { + managers: [manager], + getEnvironmentManager, + getProjectEnvManagers, + getEnvironment, + setEnvironment, + } as unknown as EnvironmentManagers; + registeredProject = undefined; + addProject = sinon.stub().callsFake(async (project: PythonProject) => { + registeredProject = project; + }); + getProject = sinon.stub().callsFake(() => registeredProject); + removeProject = sinon.stub().callsFake((project: PythonProject) => { + if (registeredProject === project) { + registeredProject = undefined; + } + }); + projectManager = { + add: addProject, + get: getProject, + remove: removeProject, + } as unknown as PythonProjectManager; + + showInputBox = sinon.stub(windowApis, 'showInputBoxWithButtons').resolves(packageName); + showErrorMessage = sinon.stub(windowApis, 'showErrorMessage'); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon.stub(creationHelpers, 'isCopilotInstalled').returns(false); + sinon.stub(creationHelpers, 'replaceInFilesAndNames').resolves(); + sinon.stub(creationHelpers, 'manageCopilotInstructionsFile').resolves(); + manageLaunchJsonFile = sinon.stub(creationHelpers, 'manageLaunchJsonFile').resolves(); + sinon.stub(fsExtra, 'pathExists').callsFake(async (checkedPath) => { + return path.resolve(String(checkedPath)) === templateFolder; + }); + claimProjectFolder = sinon.stub(fsExtra, 'mkdir').resolves(); + copyTemplate = sinon.stub(fsExtra, 'copy').resolves(); + removeFolder = sinon.stub(fsExtra, 'remove').resolves(); + removeProjectSetting = sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + function assertProject(project: PythonProject | Uri | undefined): asserts project is PythonProject { + assert.ok(project); + assert.ok(!(project instanceof Uri)); + assert.strictEqual(project.name, packageName); + assert.strictEqual(project.uri.fsPath, Uri.file(projectDestination).fsPath); + } + + test('creates and assigns a new environment when requested', async () => { + const promptForVenv = sinon.stub(creationHelpers, 'promptForVenv').resolves(true); + const pickEnvironment = sinon.stub(environmentPickers, 'pickEnvironment'); + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create(); + + assertProject(result); + sinon.assert.calledOnce(promptForVenv); + sinon.assert.notCalled(pickEnvironment); + sinon.assert.calledOnce(managerCreate); + const [creationScope, creationOptions] = managerCreate.firstCall.args; + assert.strictEqual((creationScope as Uri).fsPath, result.uri.fsPath); + assert.deepStrictEqual(creationOptions, { quickCreate: true }); + sinon.assert.calledOnceWithExactly(setEnvironment, result.uri, createdEnvironment); + sinon.assert.calledOnceWithExactly(claimProjectFolder, projectDestination); + sinon.assert.callOrder(claimProjectFolder, copyTemplate, addProject, managerCreate, setEnvironment); + }); + + test('keeps quick-create mode noninteractive', async () => { + const promptForVenv = sinon.stub(creationHelpers, 'promptForVenv'); + const pickEnvironment = sinon.stub(environmentPickers, 'pickEnvironment'); + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create({ + name: packageName, + rootUri, + quickCreate: true, + }); + + assertProject(result); + sinon.assert.notCalled(showInputBox); + sinon.assert.notCalled(promptForVenv); + sinon.assert.notCalled(pickEnvironment); + sinon.assert.calledOnceWithExactly(claimProjectFolder, projectDestination); + sinon.assert.calledOnce(copyTemplate); + sinon.assert.calledOnceWithExactly(addProject, result); + sinon.assert.calledOnce(managerCreate); + sinon.assert.calledOnceWithExactly(setEnvironment, result.uri, createdEnvironment); + sinon.assert.calledOnce(manageLaunchJsonFile); + }); + + test('uses a file URI when resolving an environment created without a returned descriptor', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(true); + managerCreate.resolves(undefined); + getEnvironment.resolves(createdEnvironment); + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create(); + + assertProject(result); + sinon.assert.calledOnce(getEnvironment); + const scope = getEnvironment.firstCall.args[0]; + assert.ok(scope instanceof Uri); + assert.strictEqual(scope.fsPath, result.uri.fsPath); + sinon.assert.calledOnceWithExactly(setEnvironment, result.uri, createdEnvironment); + }); + + test('does not replace a stale exact project registration', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + const pickEnvironment = sinon.stub(environmentPickers, 'pickEnvironment'); + registeredProject = { + name: 'stale', + uri: Uri.file(projectDestination), + }; + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create(); + + assert.strictEqual(result, undefined); + sinon.assert.calledOnce(showErrorMessage); + sinon.assert.notCalled(pickEnvironment); + sinon.assert.notCalled(claimProjectFolder); + sinon.assert.notCalled(copyTemplate); + sinon.assert.notCalled(addProject); + sinon.assert.notCalled(removeProjectSetting); + sinon.assert.notCalled(removeProject); + assert.strictEqual(registeredProject.name, 'stale'); + }); + + test('selects and assigns an existing environment before completing project creation', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + const pickEnvironment = sinon.stub(environmentPickers, 'pickEnvironment').resolves(existingEnvironment); + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create(); + + assertProject(result); + sinon.assert.calledOnce(pickEnvironment); + const [availableManagers, projectManagers, options] = pickEnvironment.firstCall.args; + assert.strictEqual(availableManagers, envManagers.managers); + assert.deepStrictEqual(projectManagers, [manager]); + assert.strictEqual(options.showBackButton, true); + assert.strictEqual(options.showCreateEnvironment, false); + assert.strictEqual(options.projects.length, 1); + assert.strictEqual(options.projects[0].uri.fsPath, result.uri.fsPath); + sinon.assert.calledOnceWithExactly(setEnvironment, result.uri, existingEnvironment); + sinon.assert.notCalled(managerCreate); + sinon.assert.calledOnceWithExactly(claimProjectFolder, projectDestination); + sinon.assert.callOrder( + pickEnvironment, + claimProjectFolder, + copyTemplate, + addProject, + setEnvironment, + manageLaunchJsonFile, + ); + }); + + test('cancels without copying or registering a project when no environment is selected', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + sinon.stub(environmentPickers, 'pickEnvironment').resolves(undefined); + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create(); + + assert.strictEqual(result, undefined); + sinon.assert.notCalled(copyTemplate); + sinon.assert.notCalled(addProject); + sinon.assert.notCalled(setEnvironment); + sinon.assert.notCalled(claimProjectFolder); + sinon.assert.notCalled(removeProjectSetting); + sinon.assert.notCalled(removeProject); + sinon.assert.notCalled(removeFolder); + }); + + test('returns to the virtual environment choice when the environment picker goes back', async () => { + const promptForVenv = sinon.stub(creationHelpers, 'promptForVenv'); + promptForVenv.onFirstCall().resolves(false); + promptForVenv.onSecondCall().resolves(true); + const pickEnvironment = sinon + .stub(environmentPickers, 'pickEnvironment') + .rejects(QuickInputButtons.Back); + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create(); + + assertProject(result); + sinon.assert.calledTwice(promptForVenv); + sinon.assert.calledOnce(pickEnvironment); + sinon.assert.calledOnce(managerCreate); + sinon.assert.calledOnceWithExactly(setEnvironment, result.uri, createdEnvironment); + }); + + test('leaves the workspace unchanged when no environments are discovered and the picker is cancelled', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + managerGetEnvironments.resolves([]); + const showPicker = sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items) => { + const pickerItems = items as readonly (QuickPickItem & { result?: PythonEnvironment })[]; + assert.ok(!pickerItems.some((item) => item.result), 'No discovered environment should be offered'); + assert.ok( + !pickerItems.some((item) => item.label === Interpreter.createVirtualEnvironment), + 'Creation should stay hidden after the user declined it', + ); + return undefined; + }); + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create(); + + assert.strictEqual(result, undefined); + sinon.assert.calledOnce(showPicker); + sinon.assert.calledOnce(managerGetEnvironments); + sinon.assert.notCalled(copyTemplate); + sinon.assert.notCalled(addProject); + sinon.assert.notCalled(setEnvironment); + sinon.assert.notCalled(claimProjectFolder); + }); + + test('propagates environment discovery failures before changing the workspace', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + const discoveryError = new Error('environment discovery failed'); + sinon.stub(environmentPickers, 'pickEnvironment').rejects(discoveryError); + const creator = new NewPackageProject(envManagers, projectManager); + + await assert.rejects(creator.create(), discoveryError); + + sinon.assert.notCalled(copyTemplate); + sinon.assert.notCalled(addProject); + sinon.assert.notCalled(setEnvironment); + sinon.assert.notCalled(claimProjectFolder); + sinon.assert.notCalled(removeFolder); + }); + + test('does not overwrite a destination created while the environment picker is open', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + sinon.stub(environmentPickers, 'pickEnvironment').resolves(existingEnvironment); + claimProjectFolder.rejects(Object.assign(new Error('already exists'), { code: 'EEXIST' })); + const creator = new NewPackageProject(envManagers, projectManager); + + const result = await creator.create(); + + assert.strictEqual(result, undefined); + sinon.assert.calledOnceWithExactly(claimProjectFolder, projectDestination); + sinon.assert.calledOnce(showErrorMessage); + sinon.assert.notCalled(copyTemplate); + sinon.assert.notCalled(addProject); + sinon.assert.notCalled(setEnvironment); + sinon.assert.notCalled(removeFolder); + }); + + test('removes only the claimed folder when project registration fails before mutation', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + sinon.stub(environmentPickers, 'pickEnvironment').resolves(existingEnvironment); + addProject.rejects(new Error('registration failed')); + const creator = new NewPackageProject(envManagers, projectManager); + + await assert.rejects(creator.create(), /registration failed/); + + sinon.assert.calledOnceWithExactly(claimProjectFolder, projectDestination); + sinon.assert.notCalled(removeProjectSetting); + sinon.assert.notCalled(removeProject); + sinon.assert.calledOnceWithExactly(removeFolder, projectDestination); + }); + + test('stops when project registration does not retain the created project', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + sinon.stub(environmentPickers, 'pickEnvironment').resolves(existingEnvironment); + const concurrentProject: PythonProject = { + name: 'concurrent', + uri: Uri.file(projectDestination), + }; + addProject.callsFake(async () => { + registeredProject = concurrentProject; + }); + const creator = new NewPackageProject(envManagers, projectManager); + + await assert.rejects(creator.create(), /could not be registered/); + + sinon.assert.notCalled(removeProjectSetting); + sinon.assert.notCalled(removeProject); + sinon.assert.calledOnceWithExactly(removeFolder, projectDestination); + sinon.assert.notCalled(setEnvironment); + assert.strictEqual(registeredProject, concurrentProject); + }); + + test('rolls back the project and files when assigning the selected environment fails', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + sinon.stub(environmentPickers, 'pickEnvironment').resolves(existingEnvironment); + const assignmentError = new Error('environment assignment failed'); + const defaultManager = { + id: 'test:default', + set: sinon.stub().resolves(), + } as unknown as InternalEnvironmentManager; + getEnvironmentManager.callsFake((scope) => (scope instanceof Uri ? defaultManager : manager)); + setEnvironment.onFirstCall().rejects(assignmentError); + setEnvironment.onSecondCall().resolves(); + const creator = new NewPackageProject(envManagers, projectManager); + + await assert.rejects(creator.create(), assignmentError); + + sinon.assert.calledTwice(setEnvironment); + const [assignmentUri, assignedEnvironment] = setEnvironment.firstCall.args; + assert.strictEqual((assignmentUri as Uri).fsPath, Uri.file(projectDestination).fsPath); + assert.strictEqual(assignedEnvironment, existingEnvironment); + const [clearUri, clearedEnvironment, shouldPersist] = setEnvironment.secondCall.args; + assert.strictEqual((clearUri as Uri).fsPath, Uri.file(projectDestination).fsPath); + assert.strictEqual(clearedEnvironment, undefined); + assert.strictEqual(shouldPersist, false); + sinon.assert.calledOnceWithExactly(managerSet, sinon.match.instanceOf(Uri), undefined); + const selectedManagerScope = managerSet.firstCall.args[0] as Uri; + assert.strictEqual(selectedManagerScope.fsPath, Uri.file(projectDestination).fsPath); + sinon.assert.notCalled(managerGetEnvironments); + sinon.assert.notCalled(defaultManager.set as sinon.SinonStub); + sinon.assert.calledOnce(removeProjectSetting); + const removedSettingProject = removeProjectSetting.firstCall.args[0][0].project as PythonProject; + assert.strictEqual(removedSettingProject.uri.fsPath, Uri.file(projectDestination).fsPath); + sinon.assert.calledOnce(removeProject); + const removedProject = removeProject.firstCall.args[0] as PythonProject; + assert.strictEqual(removedProject.uri.fsPath, Uri.file(projectDestination).fsPath); + sinon.assert.calledOnceWithExactly(removeFolder, projectDestination); + sinon.assert.notCalled(manageLaunchJsonFile); + }); + + test('does not clear the selected manager twice when central rollback owns its scope', async () => { + sinon.stub(creationHelpers, 'promptForVenv').resolves(false); + sinon.stub(environmentPickers, 'pickEnvironment').resolves(existingEnvironment); + setEnvironment.onFirstCall().rejects(new Error('environment assignment failed')); + setEnvironment.onSecondCall().resolves(); + const creator = new NewPackageProject(envManagers, projectManager); + + await assert.rejects(creator.create(), /environment assignment failed/); + + sinon.assert.calledTwice(setEnvironment); + const [clearUri, clearedEnvironment, shouldPersist] = setEnvironment.secondCall.args; + assert.ok(clearUri instanceof Uri); + assert.strictEqual(clearUri.fsPath, Uri.file(projectDestination).fsPath); + assert.strictEqual(clearedEnvironment, undefined); + assert.strictEqual(shouldPersist, false); + sinon.assert.notCalled(managerSet); + sinon.assert.calledOnce(removeProjectSetting); + sinon.assert.calledOnce(removeProject); + sinon.assert.calledOnceWithExactly(removeFolder, projectDestination); + }); +}); diff --git a/src/test/features/settings/settingHelpers.unit.test.ts b/src/test/features/settings/settingHelpers.unit.test.ts index 12bb155f7..d76806539 100644 --- a/src/test/features/settings/settingHelpers.unit.test.ts +++ b/src/test/features/settings/settingHelpers.unit.test.ts @@ -936,6 +936,73 @@ suite('Setting Helpers - Project Removal', () => { ]); assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); }); + + test('removes only the matching workspace entry when multi-root paths are identical', async () => { + const project = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const firstSetting = { + path: 'script.py', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }; + const config = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceValue: [ + firstSetting, + { + path: 'script.py', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(secondWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + await removePythonProjectSetting([{ project }]); + + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0].value, [firstSetting]); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + }); + + test('prefers the matching workspace entry over a legacy unqualified entry', async () => { + const project = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const legacySetting = { + path: 'script.py', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + }; + const config = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceValue: [ + legacySetting, + { + path: 'script.py', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(secondWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + await removePythonProjectSetting([{ project }]); + + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0].value, [legacySetting]); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + }); }); suite('inline-script project registration', () => {