From b1c7084985263e0dd20d0bcfad4435445dcc1f62 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 23 Oct 2026 20:40:34 +0530 Subject: [PATCH 1/2] Fix identical, meaningless suffixes for same-name global interpreters (#1893) - Disambiguate same-name non-virtual interpreters using distinguishing directory or executable name - When multiple interpreters share the same folder, use the executable name (e.g. python3.13 vs python3.13t) - Disambiguate generic bin/scripts directories with their distinguishing parent folder (e.g. usr/bin vs local/bin vs homebrew/bin) - Preserve project parent folder naming for virtual environments - Add unit tests covering uv launcher duplicates, free-threaded builds, and pythoncore installations --- src/features/views/envManagersView.ts | 129 ++++++++++++- .../views/envManagersView.unit.test.ts | 176 +++++++++++++++++- 2 files changed, 294 insertions(+), 11 deletions(-) diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index c756e3c38..e433970b9 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -1,5 +1,6 @@ import { Disposable, Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeView } from 'vscode'; import { DidChangeEnvironmentEventArgs, EnvironmentGroupInfo, PythonEnvironment } from '../../api'; +import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; import { ProjectViews } from '../../common/localize'; import { createSimpleDebounce } from '../../common/utils/debounce'; import { createTreeView } from '../../common/window.apis'; @@ -36,20 +37,56 @@ function getBaseName(displayName: string): string { return displayName.replace(/\s*\([0-9.]+\)\s*$/, '').trim(); } +/** + * Checks if an environment is a virtual environment (project-scoped venv/poetry/pipenv). + */ +function isVirtualEnv(env: PythonEnvironment): boolean { + if (env.envId.managerId === SYSTEM_MANAGER_ID) { + return false; + } + if ( + env.envId.managerId === VENV_MANAGER_ID || + env.envId.managerId.includes('venv') || + env.envId.managerId.includes('pipenv') || + env.envId.managerId.includes('poetry') + ) { + return true; + } + const envPath = (env.environmentPath.fsPath || '').replace(/\\/g, '/'); + const parts = envPath.split('/').filter((p) => p.length > 0); + if (parts.some((p) => /^(appdata|local|\.local|usr|opt)$/i.test(p))) { + return false; + } + let binIndex = -1; + for (let i = parts.length - 1; i >= 0; i--) { + const lower = parts[i].toLowerCase(); + if (lower === 'bin' || lower === 'scripts') { + binIndex = i; + break; + } + } + if (binIndex > 0) { + const folder = parts[binIndex - 1].toLowerCase(); + if (folder === '.venv' || folder === 'venv' || folder === 'env' || folder === '.env' || folder === 'virtualenv') { + return true; + } + } + return false; +} + /** * Computes disambiguation suffixes for environments with similar base names. * * When multiple environments share the same base name (ignoring version numbers), - * this function returns a map from environment ID to the parent folder name, - * which can be displayed to help users distinguish between them. - * - * @example Two environments '.venv (3.12)' in folders 'alice' and 'bob' would - * return suffixes 'alice' and 'bob' respectively. + * this function returns a map from environment ID to a distinguishing suffix: + * - For virtual environments, returns the parent project directory name. + * - For non-virtual environments (such as global/system interpreters), uses the distinguishing + * executable folder, or the executable name when environments share the same directory. * * @param envs List of environments to analyze - * @returns Map from environment ID to disambiguation suffix (parent folder name) + * @returns Map from environment ID to disambiguation suffix */ -function computeDisambiguationSuffixes(envs: PythonEnvironment[]): Map { +export function computeDisambiguationSuffixes(envs: PythonEnvironment[]): Map { const suffixes = new Map(); // Group environments by their base name (ignoring version) @@ -64,9 +101,81 @@ function computeDisambiguationSuffixes(envs: PythonEnvironment[]): Map 1) { - for (const env of similarEnvs) { - suffixes.set(env.envId.id, getEnvironmentParentDirName(env)); + if (similarEnvs.length <= 1) { + continue; + } + + const venvs: PythonEnvironment[] = []; + const nonVenvs: PythonEnvironment[] = []; + + for (const env of similarEnvs) { + if (isVirtualEnv(env)) { + venvs.push(env); + } else { + nonVenvs.push(env); + } + } + + for (const env of venvs) { + suffixes.set(env.envId.id, getEnvironmentParentDirName(env)); + } + + if (nonVenvs.length > 0) { + const envWithPaths = nonVenvs.map((env) => { + const execPath = (env.execInfo?.run?.executable || env.environmentPath.fsPath).replace(/\\/g, '/'); + const parts = execPath.split('/').filter((p) => p.length > 0); + const execName = parts[parts.length - 1] || ''; + const dirParts = parts.slice(0, -1); + const dirPath = dirParts.join('/'); + return { env, execPath, execName, dirParts, dirPath }; + }); + + // Group non-venvs by their directory path + const dirMap = new Map(); + for (const item of envWithPaths) { + const list = dirMap.get(item.dirPath) ?? []; + list.push(item); + dirMap.set(item.dirPath, list); + } + + const candidateSuffixes = new Map(); + for (const item of envWithPaths) { + const sameDirItems = dirMap.get(item.dirPath); + if (sameDirItems && sameDirItems.length > 1) { + // Interpreters in the same directory: disambiguate using executable name + candidateSuffixes.set(item.env.envId.id, item.execName); + } else { + // Different directories: use distinguishing folder + const lastDir = item.dirParts[item.dirParts.length - 1] || ''; + if (lastDir.toLowerCase() === 'bin' || lastDir.toLowerCase() === 'scripts') { + const parentOfLast = item.dirParts.length >= 2 ? item.dirParts[item.dirParts.length - 2] : ''; + candidateSuffixes.set(item.env.envId.id, parentOfLast ? `${parentOfLast}/${lastDir}` : lastDir); + } else { + candidateSuffixes.set(item.env.envId.id, lastDir); + } + } + } + + // Verify candidates do not collide across different directories + const counts = new Map(); + for (const suffix of candidateSuffixes.values()) { + counts.set(suffix, (counts.get(suffix) ?? 0) + 1); + } + + for (const item of envWithPaths) { + const cand = candidateSuffixes.get(item.env.envId.id)!; + if ((counts.get(cand) ?? 0) > 1) { + // Suffix collision across directories: include distinguishing folder and executable + const lastDir = item.dirParts[item.dirParts.length - 1] || ''; + const parentOfLast = item.dirParts.length >= 2 ? item.dirParts[item.dirParts.length - 2] : ''; + const folder = + lastDir.toLowerCase() === 'bin' || lastDir.toLowerCase() === 'scripts' + ? (parentOfLast ? `${parentOfLast}/${lastDir}` : lastDir) + : lastDir; + suffixes.set(item.env.envId.id, `${folder}/${item.execName}`); + } else { + suffixes.set(item.env.envId.id, cand); + } } } } diff --git a/src/test/features/views/envManagersView.unit.test.ts b/src/test/features/views/envManagersView.unit.test.ts index ad7015308..98a2e9917 100644 --- a/src/test/features/views/envManagersView.unit.test.ts +++ b/src/test/features/views/envManagersView.unit.test.ts @@ -4,7 +4,8 @@ import * as typeMoq from 'typemoq'; import { EventEmitter, TreeView, Uri } from 'vscode'; import { PythonEnvironment } from '../../../api'; import * as windowApis from '../../../common/window.apis'; -import { EnvManagerView } from '../../../features/views/envManagersView'; +import { EnvManagerView, computeDisambiguationSuffixes } from '../../../features/views/envManagersView'; +import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../../../common/constants'; import { ITemporaryStateManager } from '../../../features/views/temporaryStateManager'; import { EnvTreeItem } from '../../../features/views/treeViewItems'; import type { @@ -243,3 +244,176 @@ suite('EnvManagerView.reveal Tests', () => { view.dispose(); }); }); + + +suite('computeDisambiguationSuffixes Tests', () => { + function createTestEnv(options: { + id: string; + managerId: string; + name: string; + displayName?: string; + execPath: string; + }): PythonEnvironment { + return { + envId: { + id: options.id, + managerId: options.managerId, + }, + name: options.name, + displayName: options.displayName ?? options.name, + displayPath: options.execPath, + version: '3.13.0', + environmentPath: Uri.file(options.execPath), + execInfo: { + run: { executable: options.execPath }, + }, + sysPrefix: options.execPath, + }; + } + + test('Differentiates multiple uv global launchers in same directory by executable name (Fixes #1893)', () => { + const env1 = createTestEnv({ + id: 'uv-python', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.15', + execPath: 'C:\\Users\\kanadig\\.local\\bin\\python.exe', + }); + const env2 = createTestEnv({ + id: 'uv-python3', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.15', + execPath: 'C:\\Users\\kanadig\\.local\\bin\\python3.exe', + }); + const env3 = createTestEnv({ + id: 'uv-python3.13', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.15', + execPath: 'C:\\Users\\kanadig\\.local\\bin\\python3.13.exe', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2, env3]); + + assert.strictEqual(suffixes.get('uv-python'), 'python.exe'); + assert.strictEqual(suffixes.get('uv-python3'), 'python3.exe'); + assert.strictEqual(suffixes.get('uv-python3.13'), 'python3.13.exe'); + for (const suffix of suffixes.values()) { + assert.notStrictEqual(suffix, 'kanadig'); + assert.notStrictEqual(suffix, 'bin'); + } + }); + + test('Differentiates regular and free-threaded system interpreters in /usr/bin by executable name', () => { + const envRegular = createTestEnv({ + id: 'fedora-regular', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.13', + execPath: '/usr/bin/python3.13', + }); + const envFreeThreaded = createTestEnv({ + id: 'fedora-freethreaded', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.13', + execPath: '/usr/bin/python3.13t', + }); + + const suffixes = computeDisambiguationSuffixes([envRegular, envFreeThreaded]); + + assert.strictEqual(suffixes.get('fedora-regular'), 'python3.13'); + assert.strictEqual(suffixes.get('fedora-freethreaded'), 'python3.13t'); + assert.notStrictEqual(suffixes.get('fedora-regular'), 'bin'); + assert.notStrictEqual(suffixes.get('fedora-freethreaded'), 'bin'); + }); + + test('Differentiates pythoncore installations by distinguishing directory without AppData', () => { + const env1 = createTestEnv({ + id: 'core-64', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.14.0', + execPath: 'C:\\Users\\test\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe', + }); + const env2 = createTestEnv({ + id: 'core-64t', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.14.0', + execPath: 'C:\\Users\\test\\AppData\\Local\\Python\\pythoncore-3.14t-64\\python3.14t.exe', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.get('core-64'), 'pythoncore-3.14-64'); + assert.strictEqual(suffixes.get('core-64t'), 'pythoncore-3.14t-64'); + assert.notStrictEqual(suffixes.get('core-64'), 'AppData'); + assert.notStrictEqual(suffixes.get('core-64t'), 'AppData'); + }); + + test('Differentiates global interpreters across bin locations using distinguishing path', () => { + const env1 = createTestEnv({ + id: 'usr-bin', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: '/usr/bin/python3.13', + }); + const env2 = createTestEnv({ + id: 'usr-local-bin', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: '/usr/local/bin/python3.13', + }); + const env3 = createTestEnv({ + id: 'opt-homebrew-bin', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: '/opt/homebrew/bin/python3.13', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2, env3]); + + assert.strictEqual(suffixes.get('usr-bin'), 'usr/bin'); + assert.strictEqual(suffixes.get('usr-local-bin'), 'local/bin'); + assert.strictEqual(suffixes.get('opt-homebrew-bin'), 'homebrew/bin'); + for (const suffix of suffixes.values()) { + assert.notStrictEqual(suffix, 'usr'); + assert.notStrictEqual(suffix, 'bin'); + assert.notStrictEqual(suffix, 'opt'); + } + }); + + test('Preserves project parent folder names for virtual environments', () => { + const env1 = createTestEnv({ + id: 'project-a-venv', + managerId: VENV_MANAGER_ID, + name: '.venv (3.12)', + execPath: '/home/user/alice/.venv/bin/python', + }); + const env2 = createTestEnv({ + id: 'project-b-venv', + managerId: VENV_MANAGER_ID, + name: '.venv (3.12)', + execPath: '/home/user/bob/.venv/bin/python', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.get('project-a-venv'), 'alice'); + assert.strictEqual(suffixes.get('project-b-venv'), 'bob'); + }); + + test('Does not add suffix for environments with unique base names', () => { + const env1 = createTestEnv({ + id: 'python-312', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.12.0', + execPath: '/usr/bin/python3.12', + }); + const env2 = createTestEnv({ + id: 'python-313', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: '/usr/bin/python3.13', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.size, 0); + }); +}); From 881972450668aa5676f56461c949512ac9f4102f Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Sat, 24 Oct 2026 13:17:06 +0530 Subject: [PATCH 2/2] fix: resolve environment disambiguation collisions --- src/features/views/envManagersView.ts | 248 +++++++++++++----- .../views/envManagersView.unit.test.ts | 172 +++++++++++- 2 files changed, 352 insertions(+), 68 deletions(-) diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index e433970b9..603370f98 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -1,6 +1,6 @@ import { Disposable, Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeView } from 'vscode'; import { DidChangeEnvironmentEventArgs, EnvironmentGroupInfo, PythonEnvironment } from '../../api'; -import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; +import { VENV_MANAGER_ID } from '../../common/constants'; import { ProjectViews } from '../../common/localize'; import { createSimpleDebounce } from '../../common/utils/debounce'; import { createTreeView } from '../../common/window.apis'; @@ -34,16 +34,13 @@ const ENV_STATE_KEYS = [COPIED_STATE, SELECTED_STATE]; * @example getBaseName('myenv (3.14.1)') returns 'myenv' */ function getBaseName(displayName: string): string { - return displayName.replace(/\s*\([0-9.]+\)\s*$/, '').trim(); + return displayName.replace(/\s*\((?:[0-9.]+|no-python)\)\s*$/, '').trim(); } /** * Checks if an environment is a virtual environment (project-scoped venv/poetry/pipenv). */ function isVirtualEnv(env: PythonEnvironment): boolean { - if (env.envId.managerId === SYSTEM_MANAGER_ID) { - return false; - } if ( env.envId.managerId === VENV_MANAGER_ID || env.envId.managerId.includes('venv') || @@ -52,11 +49,10 @@ function isVirtualEnv(env: PythonEnvironment): boolean { ) { return true; } + const envPath = (env.environmentPath.fsPath || '').replace(/\\/g, '/'); const parts = envPath.split('/').filter((p) => p.length > 0); - if (parts.some((p) => /^(appdata|local|\.local|usr|opt)$/i.test(p))) { - return false; - } + let binIndex = -1; for (let i = parts.length - 1; i >= 0; i--) { const lower = parts[i].toLowerCase(); @@ -65,12 +61,56 @@ function isVirtualEnv(env: PythonEnvironment): boolean { break; } } + if (binIndex > 0) { const folder = parts[binIndex - 1].toLowerCase(); - if (folder === '.venv' || folder === 'venv' || folder === 'env' || folder === '.env' || folder === 'virtualenv') { + if ( + folder === '.venv' || + folder === 'venv' || + folder === 'env' || + folder === '.env' || + folder === 'virtualenv' || + folder.startsWith('.venv') || + folder.startsWith('venv-') || + folder.includes('virtualenv') + ) { + return true; + } + } + + if (parts.length > 0) { + const lastPart = parts[parts.length - 1].toLowerCase(); + if ( + lastPart === '.venv' || + lastPart === 'venv' || + lastPart === 'env' || + lastPart === '.env' || + lastPart === 'virtualenv' || + lastPart.startsWith('.venv') || + lastPart.startsWith('venv-') + ) { return true; } } + + return false; +} + +/** + * Checks if an environment is a Conda environment without Python. + */ +function isPythonLessEnv(env: PythonEnvironment): boolean { + if (env.version === 'no-python') { + return true; + } + const nameLower = (env.displayName || env.name || '').toLowerCase(); + if (nameLower.includes('no-python')) { + return true; + } + const exec = (env.execInfo?.run?.executable || '').replace(/\\/g, '/').toLowerCase(); + if (exec.endsWith('/conda') || exec.endsWith('/conda.exe') || exec.endsWith('/conda.bat')) { + return true; + } return false; } @@ -82,6 +122,9 @@ function isVirtualEnv(env: PythonEnvironment): boolean { * - For virtual environments, returns the parent project directory name. * - For non-virtual environments (such as global/system interpreters), uses the distinguishing * executable folder, or the executable name when environments share the same directory. + * - For Python-less Conda environments, uses the environment location/prefix directory. + * - When candidate suffixes collide across distinct roots (e.g. C:\Python313 vs D:\Python313), + * additional ancestor segments are included until suffixes are unique and unambiguous. * * @param envs List of environments to analyze * @returns Map from environment ID to disambiguation suffix @@ -105,79 +148,154 @@ export function computeDisambiguationSuffixes(envs: PythonEnvironment[]): Map(); for (const env of similarEnvs) { - if (isVirtualEnv(env)) { - venvs.push(env); - } else { - nonVenvs.push(env); + if (!isVirtualEnv(env) && !isPythonLessEnv(env)) { + const execPath = (env.execInfo?.run?.executable || env.environmentPath.fsPath).replace(/\\/g, '/'); + const parts = execPath.split('/').filter((p) => p.length > 0); + const dirPath = parts.slice(0, -1).join('/'); + const list = dirMap.get(dirPath) ?? []; + list.push(env); + dirMap.set(dirPath, list); } } - for (const env of venvs) { - suffixes.set(env.envId.id, getEnvironmentParentDirName(env)); + interface EnvDisambiguationItem { + env: PythonEnvironment; + parts: string[]; + isBinDir: boolean; + depth: number; + currentSuffix: string; } - if (nonVenvs.length > 0) { - const envWithPaths = nonVenvs.map((env) => { - const execPath = (env.execInfo?.run?.executable || env.environmentPath.fsPath).replace(/\\/g, '/'); - const parts = execPath.split('/').filter((p) => p.length > 0); - const execName = parts[parts.length - 1] || ''; - const dirParts = parts.slice(0, -1); - const dirPath = dirParts.join('/'); - return { env, execPath, execName, dirParts, dirPath }; - }); + const items: EnvDisambiguationItem[] = similarEnvs.map((env) => { + const isVenv = isVirtualEnv(env); + const isNoPy = !isVenv && isPythonLessEnv(env); + + if (isVenv) { + const envPath = (env.environmentPath.fsPath || '').replace(/\\/g, '/'); + const parts = envPath.split('/').filter((p) => p.length > 0); + let venvIndex = -1; + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i].toLowerCase(); + if (part === 'bin' || part === 'scripts') { + venvIndex = i - 1; + break; + } + if (part.startsWith('python')) { + continue; + } + venvIndex = i; + break; + } + const projectParts = venvIndex > 0 ? parts.slice(0, venvIndex) : parts; + const finalParts = projectParts.length > 0 ? projectParts : [getEnvironmentParentDirName(env)]; + const initialSuffix = getEnvironmentParentDirName(env); + return { + env, + parts: finalParts, + isBinDir: false, + depth: 1, + currentSuffix: initialSuffix, + }; + } - // Group non-venvs by their directory path - const dirMap = new Map(); - for (const item of envWithPaths) { - const list = dirMap.get(item.dirPath) ?? []; - list.push(item); - dirMap.set(item.dirPath, list); + if (isNoPy) { + const envPath = (env.environmentPath.fsPath || env.sysPrefix || '').replace(/\\/g, '/'); + const parts = envPath.split('/').filter((p) => p.length > 0); + return { + env, + parts, + isBinDir: false, + depth: 1, + currentSuffix: parts[parts.length - 1] || '', + }; } - const candidateSuffixes = new Map(); - for (const item of envWithPaths) { - const sameDirItems = dirMap.get(item.dirPath); - if (sameDirItems && sameDirItems.length > 1) { - // Interpreters in the same directory: disambiguate using executable name - candidateSuffixes.set(item.env.envId.id, item.execName); - } else { - // Different directories: use distinguishing folder - const lastDir = item.dirParts[item.dirParts.length - 1] || ''; - if (lastDir.toLowerCase() === 'bin' || lastDir.toLowerCase() === 'scripts') { - const parentOfLast = item.dirParts.length >= 2 ? item.dirParts[item.dirParts.length - 2] : ''; - candidateSuffixes.set(item.env.envId.id, parentOfLast ? `${parentOfLast}/${lastDir}` : lastDir); - } else { - candidateSuffixes.set(item.env.envId.id, lastDir); - } - } + const execPath = (env.execInfo?.run?.executable || env.environmentPath.fsPath).replace(/\\/g, '/'); + const parts = execPath.split('/').filter((p) => p.length > 0); + const execName = parts[parts.length - 1] || ''; + const dirParts = parts.slice(0, -1); + const dirPath = dirParts.join('/'); + const sameDirEnvs = dirMap.get(dirPath); + const isSameDir = sameDirEnvs !== undefined && sameDirEnvs.length > 1; + + if (isSameDir) { + return { + env, + parts: [...dirParts, execName], + isBinDir: false, + depth: 1, + currentSuffix: execName, + }; + } + + const lastDir = dirParts[dirParts.length - 1] || ''; + const isBin = lastDir.toLowerCase() === 'bin' || lastDir.toLowerCase() === 'scripts'; + if (isBin) { + const parentOfLast = dirParts.length >= 2 ? dirParts[dirParts.length - 2] : ''; + return { + env, + parts: dirParts, + isBinDir: true, + depth: 1, + currentSuffix: parentOfLast ? `${parentOfLast}/${lastDir}` : lastDir, + }; } - // Verify candidates do not collide across different directories + return { + env, + parts: dirParts, + isBinDir: false, + depth: 1, + currentSuffix: lastDir, + }; + }); + + // Iteratively resolve collisions by expanding depth for colliding items + let hasCollisions = true; + let iteration = 0; + const maxIterations = 20; + + while (hasCollisions && iteration < maxIterations) { + iteration++; const counts = new Map(); - for (const suffix of candidateSuffixes.values()) { - counts.set(suffix, (counts.get(suffix) ?? 0) + 1); + for (const item of items) { + counts.set(item.currentSuffix, (counts.get(item.currentSuffix) ?? 0) + 1); } - for (const item of envWithPaths) { - const cand = candidateSuffixes.get(item.env.envId.id)!; - if ((counts.get(cand) ?? 0) > 1) { - // Suffix collision across directories: include distinguishing folder and executable - const lastDir = item.dirParts[item.dirParts.length - 1] || ''; - const parentOfLast = item.dirParts.length >= 2 ? item.dirParts[item.dirParts.length - 2] : ''; - const folder = - lastDir.toLowerCase() === 'bin' || lastDir.toLowerCase() === 'scripts' - ? (parentOfLast ? `${parentOfLast}/${lastDir}` : lastDir) - : lastDir; - suffixes.set(item.env.envId.id, `${folder}/${item.execName}`); - } else { - suffixes.set(item.env.envId.id, cand); + hasCollisions = false; + for (const item of items) { + if ((counts.get(item.currentSuffix) ?? 0) > 1) { + hasCollisions = true; + const maxParts = item.parts.length; + const neededCount = item.isBinDir ? item.depth + 2 : item.depth + 1; + if (neededCount <= maxParts) { + item.depth++; + item.currentSuffix = item.parts.slice(-neededCount).join('/'); + } } } } + + // If identical collisions still remain after exhausting all ancestors (e.g. duplicate paths), + // ensure unique suffixes deterministically + const finalCounts = new Map(); + for (const item of items) { + finalCounts.set(item.currentSuffix, (finalCounts.get(item.currentSuffix) ?? 0) + 1); + } + + const seenCounts = new Map(); + for (const item of items) { + if ((finalCounts.get(item.currentSuffix) ?? 0) > 1) { + const seen = (seenCounts.get(item.currentSuffix) ?? 0) + 1; + seenCounts.set(item.currentSuffix, seen); + suffixes.set(item.env.envId.id, `${item.currentSuffix} (${seen})`); + } else { + suffixes.set(item.env.envId.id, item.currentSuffix); + } + } } return suffixes; diff --git a/src/test/features/views/envManagersView.unit.test.ts b/src/test/features/views/envManagersView.unit.test.ts index 98a2e9917..1f775e46c 100644 --- a/src/test/features/views/envManagersView.unit.test.ts +++ b/src/test/features/views/envManagersView.unit.test.ts @@ -253,7 +253,10 @@ suite('computeDisambiguationSuffixes Tests', () => { name: string; displayName?: string; execPath: string; + environmentPath?: string; + version?: string; }): PythonEnvironment { + const envPath = options.environmentPath ?? options.execPath; return { envId: { id: options.id, @@ -262,12 +265,12 @@ suite('computeDisambiguationSuffixes Tests', () => { name: options.name, displayName: options.displayName ?? options.name, displayPath: options.execPath, - version: '3.13.0', - environmentPath: Uri.file(options.execPath), + version: options.version ?? '3.13.0', + environmentPath: Uri.file(envPath), execInfo: { run: { executable: options.execPath }, }, - sysPrefix: options.execPath, + sysPrefix: envPath, }; } @@ -416,4 +419,167 @@ suite('computeDisambiguationSuffixes Tests', () => { assert.strictEqual(suffixes.size, 0); }); + test('Preserves project parent folder names for virtual environments resolved through system manager (Finding A)', () => { + const env1 = createTestEnv({ + id: 'sys-venv-alice', + managerId: SYSTEM_MANAGER_ID, + name: '.venv (3.12)', + execPath: '/home/user/alice/.venv/bin/python', + }); + const env2 = createTestEnv({ + id: 'sys-venv-bob', + managerId: SYSTEM_MANAGER_ID, + name: '.venv (3.12)', + execPath: '/home/user/bob/.venv/bin/python', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.get('sys-venv-alice'), 'alice'); + assert.strictEqual(suffixes.get('sys-venv-bob'), 'bob'); + assert.notStrictEqual(suffixes.get('sys-venv-alice'), '.venv/bin/python'); + assert.notStrictEqual(suffixes.get('sys-venv-bob'), '.venv/bin/python'); + }); + + test('Differentiates distinct Python-less Conda environments sharing Conda launcher executable (Finding B)', () => { + const env1 = createTestEnv({ + id: 'conda-project1', + managerId: 'conda', + name: '', + displayName: ' (no-python)', + version: 'no-python', + execPath: '/home/user/miniconda3/bin/conda', + environmentPath: '/home/user/miniconda3/envs/project1', + }); + const env2 = createTestEnv({ + id: 'conda-project2', + managerId: 'conda', + name: '', + displayName: ' (no-python)', + version: 'no-python', + execPath: '/home/user/miniconda3/bin/conda', + environmentPath: '/home/user/miniconda3/envs/project2', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.get('conda-project1'), 'project1'); + assert.strictEqual(suffixes.get('conda-project2'), 'project2'); + assert.notStrictEqual(suffixes.get('conda-project1'), 'conda'); + assert.notStrictEqual(suffixes.get('conda-project2'), 'conda'); + }); + + test('Differentiates same-name Python-less Conda environments across different locations', () => { + const env1 = createTestEnv({ + id: 'conda-myenv-1', + managerId: 'conda', + name: 'myenv', + displayName: 'myenv (no-python)', + version: 'no-python', + execPath: '/home/user/miniconda3/bin/conda', + environmentPath: '/home/user/miniconda3/envs/myenv', + }); + const env2 = createTestEnv({ + id: 'conda-myenv-2', + managerId: 'conda', + name: 'myenv', + displayName: 'myenv (no-python)', + version: 'no-python', + execPath: '/home/user/miniconda3/bin/conda', + environmentPath: '/opt/conda/envs/myenv', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.get('conda-myenv-1'), 'miniconda3/envs/myenv'); + assert.strictEqual(suffixes.get('conda-myenv-2'), 'conda/envs/myenv'); + assert.notStrictEqual(suffixes.get('conda-myenv-1'), suffixes.get('conda-myenv-2')); + assert.notStrictEqual(suffixes.get('conda-myenv-1'), 'conda'); + }); + + test('Differentiates identical folder names on different Windows drive roots (Finding C)', () => { + const env1 = createTestEnv({ + id: 'c-py313', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: 'C:\\Python313\\python.exe', + }); + const env2 = createTestEnv({ + id: 'd-py313', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: 'D:\\Python313\\python.exe', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.get('c-py313'), 'C:/Python313'); + assert.strictEqual(suffixes.get('d-py313'), 'D:/Python313'); + assert.notStrictEqual(suffixes.get('c-py313'), suffixes.get('d-py313')); + assert.notStrictEqual(suffixes.get('c-py313'), 'Python313/python.exe'); + assert.notStrictEqual(suffixes.get('d-py313'), 'Python313/python.exe'); + }); + + test('Resolves multi-level collisions across different drives by including ancestors', () => { + const env1 = createTestEnv({ + id: 'c-tools-py313', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: 'C:\\Tools\\Python313\\python.exe', + }); + const env2 = createTestEnv({ + id: 'd-tools-py313', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: 'D:\\Tools\\Python313\\python.exe', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.get('c-tools-py313'), 'C:/Tools/Python313'); + assert.strictEqual(suffixes.get('d-tools-py313'), 'D:/Tools/Python313'); + assert.notStrictEqual(suffixes.get('c-tools-py313'), suffixes.get('d-tools-py313')); + }); + + test('Produces deterministic suffixes regardless of input environment order', () => { + const env1 = createTestEnv({ + id: 'c-py313', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: 'C:\\Python313\\python.exe', + }); + const env2 = createTestEnv({ + id: 'd-py313', + managerId: SYSTEM_MANAGER_ID, + name: 'Python 3.13.0', + execPath: 'D:\\Python313\\python.exe', + }); + + const suffixesForward = computeDisambiguationSuffixes([env1, env2]); + const suffixesReversed = computeDisambiguationSuffixes([env2, env1]); + + assert.strictEqual(suffixesForward.get('c-py313'), suffixesReversed.get('c-py313')); + assert.strictEqual(suffixesForward.get('d-py313'), suffixesReversed.get('d-py313')); + }); + + test('Differentiates virtual environments with identical project names in different workspaces', () => { + const env1 = createTestEnv({ + id: 'user1-project', + managerId: VENV_MANAGER_ID, + name: '.venv (3.12)', + execPath: '/home/user1/project/.venv/bin/python', + }); + const env2 = createTestEnv({ + id: 'user2-project', + managerId: VENV_MANAGER_ID, + name: '.venv (3.12)', + execPath: '/home/user2/project/.venv/bin/python', + }); + + const suffixes = computeDisambiguationSuffixes([env1, env2]); + + assert.strictEqual(suffixes.get('user1-project'), 'user1/project'); + assert.strictEqual(suffixes.get('user2-project'), 'user2/project'); + assert.notStrictEqual(suffixes.get('user1-project'), suffixes.get('user2-project')); + }); });