From 941fed2511e36437f50c2bb5bf8fc0cd03ef4d8d Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 12 Nov 2026 13:21:56 -0800 Subject: [PATCH] Preselect projects using selected environment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/common/pickers/projects.ts | 31 +- src/common/utils/environmentUtils.ts | 34 ++ src/features/envCommands.ts | 71 +++- src/test/common/environmentUtils.unit.test.ts | 22 ++ src/test/common/projectPicker.unit.test.ts | 63 ++++ .../envCommands.projectSelection.unit.test.ts | 313 ++++++++++++++++++ 6 files changed, 525 insertions(+), 9 deletions(-) create mode 100644 src/common/utils/environmentUtils.ts create mode 100644 src/test/common/environmentUtils.unit.test.ts create mode 100644 src/test/common/projectPicker.unit.test.ts create mode 100644 src/test/features/envCommands.projectSelection.unit.test.ts diff --git a/src/common/pickers/projects.ts b/src/common/pickers/projects.ts index 19e93f64c..116f8ed82 100644 --- a/src/common/pickers/projects.ts +++ b/src/common/pickers/projects.ts @@ -3,11 +3,21 @@ import { QuickPickItem } from 'vscode'; import { PythonProject } from '../../api'; import { showQuickPick, showQuickPickWithButtons } from '../window.apis'; import { Pickers } from '../localize'; +import { normalizePath } from '../utils/pathUtils'; interface ProjectQuickPickItem extends QuickPickItem { project: PythonProject; } +export interface PickProjectManyOptions { + /** Whether to show the Quick Pick back button. */ + showBackButton?: boolean; + /** Whether to show the picker instead of auto-selecting when there is one project. */ + showPickerForSingleProject?: boolean; + /** Projects that should be checked when the picker opens. */ + selectedProjects?: readonly PythonProject[]; +} + export async function pickProject(projects: ReadonlyArray): Promise { if (projects.length > 1) { const items: ProjectQuickPickItem[] = projects.map((pw) => ({ @@ -28,11 +38,23 @@ export async function pickProject(projects: ReadonlyArray): Promi return undefined; } +/** + * Prompts for zero or more projects, optionally preselecting existing project choices. + * + * The boolean overload is retained for existing callers that only configure back navigation. + */ export async function pickProjectMany( projects: readonly PythonProject[], - showBackButton?: boolean, + showBackButtonOrOptions?: boolean | PickProjectManyOptions, ): Promise { - if (projects.length > 1) { + const options = + typeof showBackButtonOrOptions === 'boolean' + ? { showBackButton: showBackButtonOrOptions } + : showBackButtonOrOptions; + if (projects.length > 1 || (projects.length === 1 && options?.showPickerForSingleProject)) { + const selectedProjectPaths = new Set( + options?.selectedProjects?.map((project) => normalizePath(path.resolve(project.uri.fsPath))) ?? [], + ); const items: ProjectQuickPickItem[] = projects.map((pw) => ({ label: path.basename(pw.uri.fsPath), description: pw.uri.fsPath, @@ -42,7 +64,10 @@ export async function pickProjectMany( placeHolder: Pickers.Project.selectProjects, ignoreFocusOut: true, canPickMany: true, - showBackButton: showBackButton, + showBackButton: options?.showBackButton, + selected: items.filter((item) => + selectedProjectPaths.has(normalizePath(path.resolve(item.project.uri.fsPath))), + ), }); if (Array.isArray(item)) { return item.map((p) => p.project); diff --git a/src/common/utils/environmentUtils.ts b/src/common/utils/environmentUtils.ts new file mode 100644 index 000000000..03d718b96 --- /dev/null +++ b/src/common/utils/environmentUtils.ts @@ -0,0 +1,34 @@ +import path from 'path'; +import { PythonEnvironment } from '../../api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../constants'; +import { normalizePath } from './pathUtils'; + +/** + * Determines whether two descriptors should represent the same checked project + * in an environment-selection picker. + * + * Stable IDs take precedence. A normalized absolute interpreter path is used as + * a fallback because manager discovery can recreate an equivalent descriptor. + */ +export function isSameEnvironmentForProjectSelection( + first: PythonEnvironment | undefined, + second: PythonEnvironment | undefined, +): boolean { + if (first === second) { + return true; + } + if (!first || !second || first.envId.managerId !== second.envId.managerId) { + return false; + } + if (first.envId.managerId === INLINE_SCRIPT_MANAGER_ID) { + return haveSameEnvironmentPath(first, second) && first.version === second.version; + } + return first.envId.id === second.envId.id || haveSameEnvironmentPath(first, second); +} + +function haveSameEnvironmentPath(first: PythonEnvironment, second: PythonEnvironment): boolean { + return ( + normalizePath(path.resolve(first.environmentPath.fsPath)) === + normalizePath(path.resolve(second.environmentPath.fsPath)) + ); +} diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 0117a2ff2..5d9b3f4ac 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -49,6 +49,8 @@ import { pickWorkspaceFolder, } from '../common/pickers/managers'; import { pickProject, pickProjectMany } from '../common/pickers/projects'; +import { isSameEnvironmentForProjectSelection as isSameEnvironment } from '../common/utils/environmentUtils'; +import { normalizePath } from '../common/utils/pathUtils'; import { isWindows } from '../common/utils/platformUtils'; import { handlePythonPath } from '../common/utils/pythonPath'; import { @@ -484,10 +486,24 @@ async function setEnvironmentCommandInternal( const view = context as PythonEnvTreeItem; const projects = wm.getProjects(); if (projects.length > 0) { - const selected = await pickProjectMany(projects); - if (selected && selected.length > 0) { - // Check if the selected environment is already the current one for each project - await setEnvironmentForProjects(selected, context.environment, em); + const currentEnvironments = await Promise.all( + projects.map((project) => em.getEnvironment(project.uri)), + ); + const projectsUsingEnvironment = projects.filter((_, index) => + isSameEnvironment(currentEnvironments[index], view.environment), + ); + const selected = await pickProjectMany(projects, { + showPickerForSingleProject: true, + selectedProjects: projectsUsingEnvironment, + }); + if (selected !== undefined) { + await applyProjectEnvironmentSelection( + projects, + projectsUsingEnvironment, + selected, + view.environment, + em, + ); } } else { await em.setEnvironments('global', view.environment); @@ -564,10 +580,10 @@ async function setEnvironmentForProjects( environment: PythonEnvironment, em: EnvironmentManagers, ) { - let alreadySet: PythonProject[] = []; + const alreadySet: PythonProject[] = []; for (const p of selectedProjects) { const currentEnv = await em.getEnvironment(p.uri); - if (currentEnv?.envId.id === environment.envId.id) { + if (isSameEnvironment(currentEnv, environment)) { alreadySet.push(p); } } @@ -587,6 +603,49 @@ async function setEnvironmentForProjects( await em.setEnvironments(uris, environment); } +async function applyProjectEnvironmentSelection( + projects: readonly PythonProject[], + previouslySelected: readonly PythonProject[], + selected: readonly PythonProject[], + environment: PythonEnvironment, + em: EnvironmentManagers, +): Promise { + const selectedPaths = new Set(selected.map(getProjectPathKey)); + const currentEnvironments = await Promise.all(projects.map((project) => em.getEnvironment(project.uri))); + const currentEnvironmentByProject = new Map( + projects.map((project, index) => [getProjectPathKey(project), currentEnvironments[index]]), + ); + const resetCandidates = previouslySelected.filter( + (project) => !selectedPaths.has(getProjectPathKey(project)), + ); + const projectsToSet = selected.filter( + (project) => !isSameEnvironment(currentEnvironmentByProject.get(getProjectPathKey(project)), environment), + ); + + if (projectsToSet.length > 0) { + await em.setEnvironments( + projectsToSet.map((project) => project.uri), + environment, + ); + } + const resetEnvironments = await Promise.all( + resetCandidates.map((project) => em.getEnvironment(project.uri)), + ); + const projectsToReset = resetCandidates.filter((_, index) => + isSameEnvironment(resetEnvironments[index], environment), + ); + if (projectsToReset.length > 0) { + await em.setEnvironments( + projectsToReset.map((project) => project.uri), + undefined, + ); + } +} + +function getProjectPathKey(project: PythonProject): string { + return normalizePath(path.resolve(project.uri.fsPath)); +} + export async function setEnvManagerCommand(em: EnvironmentManagers, wm: PythonProjectManager): Promise { const projects = await pickProjectMany(wm.getProjects()); if (projects && projects.length > 0) { diff --git a/src/test/common/environmentUtils.unit.test.ts b/src/test/common/environmentUtils.unit.test.ts new file mode 100644 index 000000000..7aaf03fa3 --- /dev/null +++ b/src/test/common/environmentUtils.unit.test.ts @@ -0,0 +1,22 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { isSameEnvironmentForProjectSelection } from '../../common/utils/environmentUtils'; +import { createMockPythonEnvironment } from '../mocks/pythonEnvironment'; + +suite('Environment identity utilities', () => { + test('reconciles same-manager picker descriptors by normalized absolute path', () => { + const environmentPath = path.join(process.cwd(), 'same-environment', 'python'); + const first = createMockPythonEnvironment({ + envPath: environmentPath, + id: 'first-id', + managerId: 'ms-python.python:venv', + }); + const second = createMockPythonEnvironment({ + envPath: environmentPath, + id: 'second-id', + managerId: 'ms-python.python:venv', + }); + + assert.strictEqual(isSameEnvironmentForProjectSelection(first, second), true); + }); +}); diff --git a/src/test/common/projectPicker.unit.test.ts b/src/test/common/projectPicker.unit.test.ts new file mode 100644 index 000000000..f607cbe57 --- /dev/null +++ b/src/test/common/projectPicker.unit.test.ts @@ -0,0 +1,63 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { PythonProject } from '../../api'; +import { pickProjectMany } from '../../common/pickers/projects'; +import * as windowApis from '../../common/window.apis'; + +suite('Project multi-picker', () => { + teardown(() => sinon.restore()); + + test('preselects the requested projects', async () => { + const projects: PythonProject[] = ['one', 'two', 'three'].map((name) => ({ + name, + uri: Uri.file(path.join(process.cwd(), name)), + })); + const showQuickPick = sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items, options) => { + assert.deepStrictEqual(options?.selected, [items[0], items[2]]); + return options?.selected ?? []; + }); + + const selected = await pickProjectMany(projects, { + showBackButton: true, + selectedProjects: [projects[0], { ...projects[2] }], + }); + + assert.deepStrictEqual(selected, [projects[0], projects[2]]); + assert.strictEqual(showQuickPick.firstCall.args[1]?.showBackButton, true); + }); + + test('retains the boolean back-button option for existing callers', async () => { + const projects: PythonProject[] = ['one', 'two'].map((name) => ({ + name, + uri: Uri.file(path.join(process.cwd(), name)), + })); + const showQuickPick = sinon.stub(windowApis, 'showQuickPickWithButtons').resolves(undefined); + + await pickProjectMany(projects, true); + + assert.strictEqual(showQuickPick.firstCall.args[1]?.showBackButton, true); + assert.deepStrictEqual(showQuickPick.firstCall.args[1]?.selected, []); + }); + + test('can show and preselect a single project without changing the legacy shortcut', async () => { + const project: PythonProject = { + name: 'one', + uri: Uri.file(path.join(process.cwd(), 'one')), + }; + const showQuickPick = sinon.stub(windowApis, 'showQuickPickWithButtons').resolves([]); + + const selected = await pickProjectMany([project], { + showPickerForSingleProject: true, + selectedProjects: [project], + }); + + assert.deepStrictEqual(selected, []); + assert.deepStrictEqual(showQuickPick.firstCall.args[1]?.selected, [showQuickPick.firstCall.args[0][0]]); + + showQuickPick.resetHistory(); + assert.deepStrictEqual(await pickProjectMany([project]), [project]); + sinon.assert.notCalled(showQuickPick); + }); +}); diff --git a/src/test/features/envCommands.projectSelection.unit.test.ts b/src/test/features/envCommands.projectSelection.unit.test.ts new file mode 100644 index 000000000..6424978aa --- /dev/null +++ b/src/test/features/envCommands.projectSelection.unit.test.ts @@ -0,0 +1,313 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironment, PythonProject } from '../../api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; +import * as projectPickers from '../../common/pickers/projects'; +import { setEnvironmentCommand } from '../../features/envCommands'; +import type { EnvironmentManagers } from '../../features/envManagers'; +import type { PythonProjectManager } from '../../features/projectManager'; +import { EnvManagerTreeItem, PythonEnvTreeItem } from '../../features/views/treeViewItems'; +import { InternalEnvironmentManager } from '../../managers/common/registeredManagers'; +import { createMockPythonEnvironment } from '../mocks/pythonEnvironment'; + +suite('Set environment for projects command', () => { + const managerId = 'ms-python.python:venv'; + const projects: PythonProject[] = ['one', 'two', 'three', 'four'].map((name) => ({ + name, + uri: Uri.file(path.join(process.cwd(), name)), + })); + let target: PythonEnvironment; + let currentEnvironments: Map; + let getEnvironment: sinon.SinonStub; + let setEnvironments: sinon.SinonStub; + let managers: EnvironmentManagers; + let projectManager: PythonProjectManager; + + setup(() => { + target = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'shared-env'), + id: 'shared-env', + managerId, + }); + currentEnvironments = new Map(); + getEnvironment = sinon + .stub() + .callsFake(async (uri: Uri) => currentEnvironments.get(uri.fsPath)); + setEnvironments = sinon.stub().callsFake(async (uris: Uri[], environment?: PythonEnvironment) => { + uris.forEach((uri) => currentEnvironments.set(uri.fsPath, environment)); + }); + managers = { + getEnvironment, + setEnvironments, + } as unknown as EnvironmentManagers; + projectManager = { + getProjects: () => projects, + } as unknown as PythonProjectManager; + }); + + teardown(() => sinon.restore()); + + function setCurrentEnvironment(project: PythonProject, environment: PythonEnvironment | undefined): void { + currentEnvironments.set(project.uri.fsPath, environment); + } + + function createEnvironmentItem(environment = target): PythonEnvTreeItem { + const provider: EnvironmentManager = { + name: 'test', + preferredPackageManagerId: 'ms-python.python:pip', + get: async () => environment, + set: async () => undefined, + getEnvironments: async () => [environment], + refresh: async () => undefined, + resolve: async () => environment, + }; + const parent = new EnvManagerTreeItem(new InternalEnvironmentManager(environment.envId.managerId, provider)); + return new PythonEnvTreeItem(environment, parent); + } + + test('preselects only exact current environment matches', async () => { + setCurrentEnvironment( + projects[0], + createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'equivalent-env'), + id: target.envId.id, + managerId, + }), + ); + setCurrentEnvironment( + projects[1], + createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'same-id-other-manager'), + id: target.envId.id, + managerId: 'ms-python.python:conda', + }), + ); + setCurrentEnvironment( + projects[2], + createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'other-env'), + id: 'other-env', + managerId, + }), + ); + setCurrentEnvironment( + projects[3], + createMockPythonEnvironment({ + envPath: target.environmentPath.fsPath, + id: 'same-path-different-id', + managerId, + }), + ); + const pickProjects = sinon.stub(projectPickers, 'pickProjectMany').resolves(undefined); + + await setEnvironmentCommand(createEnvironmentItem(), managers, projectManager); + + const options = pickProjects.firstCall.args[1]; + assert.ok(options && typeof options !== 'boolean'); + assert.strictEqual(options.showPickerForSingleProject, true); + assert.deepStrictEqual(options.selectedProjects, [projects[0], projects[3]]); + sinon.assert.notCalled(setEnvironments); + }); + + test('uses the inline environment path and version for exact matches', async () => { + target = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'inline-env'), + id: 'inline-target', + managerId: INLINE_SCRIPT_MANAGER_ID, + version: '3.12.0', + }); + setCurrentEnvironment( + projects[0], + createMockPythonEnvironment({ + envPath: target.environmentPath.fsPath, + id: 'different-id', + managerId: INLINE_SCRIPT_MANAGER_ID, + version: target.version, + }), + ); + setCurrentEnvironment( + projects[1], + createMockPythonEnvironment({ + envPath: target.environmentPath.fsPath, + id: target.envId.id, + managerId: INLINE_SCRIPT_MANAGER_ID, + version: '3.13.0', + }), + ); + setCurrentEnvironment( + projects[2], + createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'other-inline-env'), + id: target.envId.id, + managerId: INLINE_SCRIPT_MANAGER_ID, + version: target.version, + }), + ); + const pickProjects = sinon.stub(projectPickers, 'pickProjectMany').resolves(undefined); + + await setEnvironmentCommand(createEnvironmentItem(), managers, projectManager); + + const options = pickProjects.firstCall.args[1]; + assert.ok(options && typeof options !== 'boolean'); + assert.deepStrictEqual(options.selectedProjects, [projects[0]]); + sinon.assert.notCalled(setEnvironments); + }); + + test('sets newly checked projects and resets only unchecked matches', async () => { + setCurrentEnvironment(projects[0], target); + setCurrentEnvironment(projects[1], target); + setCurrentEnvironment( + projects[2], + createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'other-env'), + id: 'other-env', + managerId, + }), + ); + sinon.stub(projectPickers, 'pickProjectMany').resolves([projects[1], projects[2]]); + + await setEnvironmentCommand(createEnvironmentItem(), managers, projectManager); + + assert.strictEqual(setEnvironments.callCount, 2); + assert.deepStrictEqual(setEnvironments.firstCall.args, [[projects[2].uri], target]); + assert.deepStrictEqual(setEnvironments.secondCall.args, [[projects[0].uri], undefined]); + }); + + test('resets all matching projects when an empty selection is accepted', async () => { + setCurrentEnvironment(projects[0], target); + setCurrentEnvironment(projects[2], target); + sinon.stub(projectPickers, 'pickProjectMany').resolves([]); + + await setEnvironmentCommand(createEnvironmentItem(), managers, projectManager); + + assert.deepStrictEqual(setEnvironments.firstCall.args, [ + [projects[0].uri, projects[2].uri], + undefined, + ]); + sinon.assert.calledOnce(setEnvironments); + }); + + test('does not change projects when the picker is cancelled', async () => { + setCurrentEnvironment(projects[0], target); + sinon.stub(projectPickers, 'pickProjectMany').resolves(undefined); + + await setEnvironmentCommand(createEnvironmentItem(), managers, projectManager); + + sinon.assert.notCalled(setEnvironments); + }); + + test('does not change projects when the preselected state is accepted unchanged', async () => { + setCurrentEnvironment(projects[0], target); + setCurrentEnvironment(projects[2], target); + sinon.stub(projectPickers, 'pickProjectMany').resolves([projects[0], projects[2]]); + + await setEnvironmentCommand(createEnvironmentItem(), managers, projectManager); + + sinon.assert.notCalled(setEnvironments); + }); + + test('shows a preselected single project and resets it when unchecked', async () => { + const singleProjectManager = { + getProjects: () => [projects[0]], + } as unknown as PythonProjectManager; + setCurrentEnvironment(projects[0], target); + const pickProjects = sinon.stub(projectPickers, 'pickProjectMany').resolves([]); + + await setEnvironmentCommand(createEnvironmentItem(), managers, singleProjectManager); + + const options = pickProjects.firstCall.args[1]; + assert.ok(options && typeof options !== 'boolean'); + assert.strictEqual(options.showPickerForSingleProject, true); + assert.deepStrictEqual(options.selectedProjects, [projects[0]]); + assert.deepStrictEqual(setEnvironments.firstCall.args, [[projects[0].uri], undefined]); + }); + + test('rechecks current environments before applying the accepted selection', async () => { + const otherEnvironment = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'other-env'), + id: 'other-env', + managerId, + }); + setCurrentEnvironment(projects[0], target); + setCurrentEnvironment(projects[1], otherEnvironment); + sinon.stub(projectPickers, 'pickProjectMany').callsFake(async () => { + setCurrentEnvironment(projects[0], otherEnvironment); + setCurrentEnvironment(projects[1], target); + return [projects[0]]; + }); + + await setEnvironmentCommand(createEnvironmentItem(), managers, projectManager); + + sinon.assert.calledOnceWithExactly(setEnvironments, [projects[0].uri], target); + }); + + test('does not reset existing projects when a new assignment fails', async () => { + const failure = new Error('set failed'); + const otherEnvironment = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'other-env'), + id: 'other-env', + managerId, + }); + setCurrentEnvironment(projects[0], target); + setCurrentEnvironment(projects[1], otherEnvironment); + sinon.stub(projectPickers, 'pickProjectMany').resolves([projects[1]]); + setEnvironments.onFirstCall().rejects(failure); + + await assert.rejects( + setEnvironmentCommand(createEnvironmentItem(), managers, projectManager), + (error: unknown) => error === failure, + ); + + sinon.assert.calledOnce(setEnvironments); + assert.deepStrictEqual(setEnvironments.firstCall.args, [[projects[1].uri], target]); + assert.strictEqual(currentEnvironments.get(projects[0].uri.fsPath), target); + assert.strictEqual(currentEnvironments.get(projects[1].uri.fsPath), otherEnvironment); + }); + + test('keeps confirmed new assignments when a later reset fails', async () => { + const failure = new Error('reset failed'); + const otherEnvironment = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'other-env'), + id: 'other-env', + managerId, + }); + setCurrentEnvironment(projects[0], target); + setCurrentEnvironment(projects[1], otherEnvironment); + sinon.stub(projectPickers, 'pickProjectMany').resolves([projects[1]]); + setEnvironments.onSecondCall().rejects(failure); + + await assert.rejects( + setEnvironmentCommand(createEnvironmentItem(), managers, projectManager), + (error: unknown) => error === failure, + ); + + assert.strictEqual(setEnvironments.callCount, 2); + assert.deepStrictEqual(setEnvironments.firstCall.args, [[projects[1].uri], target]); + assert.deepStrictEqual(setEnvironments.secondCall.args, [[projects[0].uri], undefined]); + assert.strictEqual(currentEnvironments.get(projects[0].uri.fsPath), target); + assert.strictEqual(currentEnvironments.get(projects[1].uri.fsPath), target); + }); + + test('rechecks reset candidates after applying new assignments', async () => { + const otherEnvironment = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'other-env'), + id: 'other-env', + managerId, + }); + setCurrentEnvironment(projects[0], target); + setCurrentEnvironment(projects[1], otherEnvironment); + sinon.stub(projectPickers, 'pickProjectMany').resolves([projects[1]]); + setEnvironments.callsFake(async (uris: Uri[], environment?: PythonEnvironment) => { + uris.forEach((uri) => currentEnvironments.set(uri.fsPath, environment)); + setCurrentEnvironment(projects[0], otherEnvironment); + }); + + await setEnvironmentCommand(createEnvironmentItem(), managers, projectManager); + + sinon.assert.calledOnceWithExactly(setEnvironments, [projects[1].uri], target); + assert.strictEqual(currentEnvironments.get(projects[0].uri.fsPath), otherEnvironment); + }); + +});