diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index 06becc9b..131484e6 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -18,6 +18,7 @@ import { EnvManagerTreeItem, EnvTreeItem, EnvTreeItemKind, + getEnvironmentParentDirName, NoPythonEnvTreeItem, PackageTreeItem, PythonEnvTreeItem, @@ -28,6 +29,53 @@ const COPIED_STATE = 'copied'; const SELECTED_STATE = 'selected'; const ENV_STATE_KEYS = [COPIED_STATE, SELECTED_STATE]; +/** + * Extracts the base name from a display name by removing version info. + * @example getBaseName('.venv (3.12)') returns '.venv' + * @example getBaseName('myenv (3.14.1)') returns 'myenv' + */ +function getBaseName(displayName: string): string { + return displayName.replace(/\s*\([0-9.]+\)\s*$/, '').trim(); +} + +/** + * 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. + * + * @param envs List of environments to analyze + * @returns Map from environment ID to disambiguation suffix (parent folder name) + */ +function computeDisambiguationSuffixes(envs: PythonEnvironment[]): Map { + const suffixes = new Map(); + + // Group environments by their base name (ignoring version) + const baseNameToEnvs = new Map(); + for (const env of envs) { + const displayName = env.displayName ?? env.name; + const baseName = getBaseName(displayName); + const existing = baseNameToEnvs.get(baseName) ?? []; + existing.push(env); + baseNameToEnvs.set(baseName, existing); + } + + // For base names with multiple environments, compute suffixes + for (const [, similarEnvs] of baseNameToEnvs) { + if (similarEnvs.length > 1) { + for (const env of similarEnvs) { + suffixes.set(env.envId.id, getEnvironmentParentDirName(env)); + } + } + } + + return suffixes; +} + export class EnvManagerView implements TreeDataProvider, Disposable { private treeView: TreeView; private treeDataChanged: EventEmitter = new EventEmitter< @@ -126,8 +174,16 @@ export class EnvManagerView implements TreeDataProvider, Disposable const manager = (element as EnvManagerTreeItem).manager; const views: EnvTreeItem[] = []; const envs = await manager.getEnvironments('all'); - envs.filter((e) => !e.group).forEach((env) => { - const view = new PythonEnvTreeItem(env, element as EnvManagerTreeItem, this.selected.get(env.envId.id)); + const nonGroupedEnvs = envs.filter((e) => !e.group); + const disambiguationSuffixes = computeDisambiguationSuffixes(nonGroupedEnvs); + nonGroupedEnvs.forEach((env) => { + const suffix = disambiguationSuffixes.get(env.envId.id); + const view = new PythonEnvTreeItem( + env, + element as EnvManagerTreeItem, + this.selected.get(env.envId.id), + suffix, + ); views.push(view); this.revealMap.set(env.envId.id, view); }); @@ -172,8 +228,10 @@ export class EnvManagerView implements TreeDataProvider, Disposable return false; }); + const groupDisambiguationSuffixes = computeDisambiguationSuffixes(grouped); grouped.forEach((env) => { - const view = new PythonEnvTreeItem(env, groupItem, this.selected.get(env.envId.id)); + const suffix = groupDisambiguationSuffixes.get(env.envId.id); + const view = new PythonEnvTreeItem(env, groupItem, this.selected.get(env.envId.id), suffix); views.push(view); this.revealMap.set(env.envId.id, view); }); diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index ac7e5b1b..ea7c20f5 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -5,6 +5,43 @@ import { InternalEnvironmentManager, InternalPackageManager } from '../../intern import { isActivatableEnvironment } from '../common/activation'; import { removable } from './utils'; +/** + * Extracts the parent folder name from an environment path for disambiguation. + * + * This function handles various path formats including: + * - Unix paths with bin folder: /home/user/my-project/.venv/bin/python → my-project + * - Windows paths with Scripts folder: C:\Users\bob\project\.venv\Scripts\python.exe → project + * - Direct venv folder paths: /home/user/project/.venv → project + * + * @param environment The Python environment to extract the parent folder from + * @returns The name of the parent folder containing the virtual environment + */ +export function getEnvironmentParentDirName(environment: PythonEnvironment): string { + const envPath = environment.environmentPath.fsPath.replace(/\\/g, '/'); + const parts = envPath.split('/').filter((p) => p.length > 0); + + let venvFolderIndex = -1; + + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i].toLowerCase(); + if (part === 'bin' || part === 'scripts') { + venvFolderIndex = i - 1; + break; + } + if (part.startsWith('python')) { + continue; + } + venvFolderIndex = i; + break; + } + + if (venvFolderIndex > 0) { + return parts[venvFolderIndex - 1]; + } + + return parts.length >= 2 ? parts[parts.length - 2] : parts[0] || ''; +} + export enum EnvTreeItemKind { manager = 'python-env-manager', environment = 'python-env', @@ -65,12 +102,21 @@ export class PythonGroupEnvTreeItem implements EnvTreeItem { export class PythonEnvTreeItem implements EnvTreeItem { public readonly kind = EnvTreeItemKind.environment; public readonly treeItem: TreeItem; + /** + * Creates a tree item for a Python environment. + * @param environment The Python environment to display + * @param parent The parent tree item (manager or group) + * @param selected If set, indicates this environment is selected ('global' or workspace path) + * @param disambiguationSuffix If set, shown in description to distinguish similarly-named environments + */ constructor( public readonly environment: PythonEnvironment, public readonly parent: EnvManagerTreeItem | PythonGroupEnvTreeItem, public readonly selected?: string, + public readonly disambiguationSuffix?: string, ) { - let name = environment.displayName ?? environment.name; + const name = environment.displayName ?? environment.name; + let tooltip = environment.tooltip ?? environment.description; const isBroken = !!environment.error; @@ -82,8 +128,20 @@ export class PythonEnvTreeItem implements EnvTreeItem { const item = new TreeItem(name, TreeItemCollapsibleState.Collapsed); item.contextValue = this.getContextValue(); - // Show error message for broken environments - item.description = isBroken ? environment.error : environment.description; + + // Build description with optional [uv] indicator and disambiguation suffix + const uvIndicator = environment.description?.toLowerCase().includes('uv') ? '[uv]' : ''; + const descriptionParts: string[] = []; + if (uvIndicator) { + descriptionParts.push(uvIndicator); + } + if (disambiguationSuffix) { + descriptionParts.push(disambiguationSuffix); + } + const computedDescription = descriptionParts.length > 0 ? descriptionParts.join(' ') : undefined; + + // Use error message for broken environments, otherwise use computed description + item.description = isBroken ? environment.error : computedDescription; item.tooltip = isBroken ? environment.error : tooltip; // Show warning icon for broken environments item.iconPath = isBroken ? new ThemeIcon('warning') : environment.iconPath; diff --git a/src/test/features/views/treeViewItems.unit.test.ts b/src/test/features/views/treeViewItems.unit.test.ts index 929f76f2..738f8398 100644 --- a/src/test/features/views/treeViewItems.unit.test.ts +++ b/src/test/features/views/treeViewItems.unit.test.ts @@ -1,241 +1,324 @@ import * as assert from 'assert'; -import { EnvManagerTreeItem, PythonEnvTreeItem } from '../../../features/views/treeViewItems'; -import { InternalEnvironmentManager, PythonEnvironmentImpl } from '../../../internal.api'; import { Uri } from 'vscode'; +import { + EnvManagerTreeItem, + getEnvironmentParentDirName, + PythonEnvTreeItem, +} from '../../../features/views/treeViewItems'; +import { InternalEnvironmentManager, PythonEnvironmentImpl } from '../../../internal.api'; + +/** + * Helper to create a mock PythonEnvironmentImpl with minimal required fields. + * Reduces boilerplate in tests. + */ +function createMockEnvironment(options: { + id?: string; + managerId?: string; + name?: string; + displayName?: string; + description?: string; + environmentPath: string; + hasActivation?: boolean; +}): PythonEnvironmentImpl { + const envPath = options.environmentPath; + return new PythonEnvironmentImpl( + { + id: options.id ?? 'test-env', + managerId: options.managerId ?? 'ms-python.python:test-manager', + }, + { + name: options.name ?? '.venv (3.12)', + displayName: options.displayName ?? options.name ?? '.venv (3.12)', + description: options.description, + displayPath: envPath, + version: '3.12.1', + environmentPath: Uri.file(envPath), + execInfo: { + run: { executable: envPath }, + ...(options.hasActivation && { + activation: [{ executable: envPath.replace('python', 'activate') }], + }), + }, + sysPrefix: envPath.includes('bin') ? envPath.replace('/bin/python', '') : envPath, + }, + ); +} + +/** + * Helper to create a mock InternalEnvironmentManager. + */ +function createMockManager( + options: { + id?: string; + name?: string; + displayName?: string; + supportsCreate?: boolean; + supportsRemove?: boolean; + } = {}, +): InternalEnvironmentManager { + return new InternalEnvironmentManager(options.id ?? 'ms-python.python:test-manager', { + name: options.name ?? 'test', + displayName: options.displayName, + description: 'test', + preferredPackageManagerId: 'pip', + refresh: () => Promise.resolve(), + getEnvironments: () => Promise.resolve([]), + resolve: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + get: () => Promise.resolve(undefined), + ...(options.supportsCreate && { create: () => Promise.resolve(undefined) }), + ...(options.supportsRemove && { remove: () => Promise.resolve() }), + }); +} suite('Test TreeView Items', () => { suite('EnvManagerTreeItem', () => { - test('Context Value: no-create', () => { - const manager = new InternalEnvironmentManager('ms-python.python:test-manager', { - name: 'test', - description: 'test', - preferredPackageManagerId: 'pip', - refresh: () => Promise.resolve(), - getEnvironments: () => Promise.resolve([]), - resolve: () => Promise.resolve(undefined), - set: () => Promise.resolve(), - get: () => Promise.resolve(undefined), - }); + test('Context value excludes create when manager does not support it', () => { + // Arrange + const manager = createMockManager({ supportsCreate: false }); + + // Act const item = new EnvManagerTreeItem(manager); - assert.equal(item.treeItem.contextValue, 'pythonEnvManager;ms-python.python:test-manager;'); - }); - - test('Context Value: with create', () => { - const manager = new InternalEnvironmentManager('ms-python.python:test-manager', { - name: 'test', - description: 'test', - preferredPackageManagerId: 'pip', - refresh: () => Promise.resolve(), - getEnvironments: () => Promise.resolve([]), - resolve: () => Promise.resolve(undefined), - set: () => Promise.resolve(), - get: () => Promise.resolve(undefined), - create: () => Promise.resolve(undefined), - }); + + // Assert + assert.strictEqual(item.treeItem.contextValue, 'pythonEnvManager;ms-python.python:test-manager;'); + }); + + test('Context value includes create when manager supports it', () => { + // Arrange + const manager = createMockManager({ supportsCreate: true }); + + // Act const item = new EnvManagerTreeItem(manager); - assert.equal(item.treeItem.contextValue, 'pythonEnvManager;create;ms-python.python:test-manager;'); - }); - - test('Name is used', () => { - const manager = new InternalEnvironmentManager('ms-python.python:test-manager', { - name: 'test', - description: 'test', - preferredPackageManagerId: 'pip', - refresh: () => Promise.resolve(), - getEnvironments: () => Promise.resolve([]), - resolve: () => Promise.resolve(undefined), - set: () => Promise.resolve(), - get: () => Promise.resolve(undefined), - }); + + // Assert + assert.strictEqual(item.treeItem.contextValue, 'pythonEnvManager;create;ms-python.python:test-manager;'); + }); + + test('Uses name as label when displayName is not provided', () => { + // Arrange + const manager = createMockManager({ name: 'test-name' }); + + // Act const item = new EnvManagerTreeItem(manager); - assert.equal(item.treeItem.label, manager.name); - }); - - test('DisplayName is used', () => { - const manager = new InternalEnvironmentManager('ms-python.python:test-manager', { - name: 'test', - displayName: 'Test', - description: 'test', - preferredPackageManagerId: 'pip', - refresh: () => Promise.resolve(), - getEnvironments: () => Promise.resolve([]), - resolve: () => Promise.resolve(undefined), - set: () => Promise.resolve(), - get: () => Promise.resolve(undefined), - }); + + // Assert + assert.strictEqual(item.treeItem.label, 'test-name'); + }); + + test('Uses displayName as label when provided', () => { + // Arrange + const manager = createMockManager({ name: 'test', displayName: 'Test Display Name' }); + + // Act const item = new EnvManagerTreeItem(manager); - assert.equal(item.treeItem.label, manager.displayName); + + // Assert + assert.strictEqual(item.treeItem.label, 'Test Display Name'); }); }); suite('PythonEnvTreeItem', () => { - const manager1 = new InternalEnvironmentManager('ms-python.python:test-manager', { - name: 'test', - displayName: 'Test', - description: 'test', - preferredPackageManagerId: 'pip', - refresh: () => Promise.resolve(), - getEnvironments: () => Promise.resolve([]), - resolve: () => Promise.resolve(undefined), - set: () => Promise.resolve(), - get: () => Promise.resolve(undefined), - }); - const managerItem1 = new EnvManagerTreeItem(manager1); - - const manager2 = new InternalEnvironmentManager('ms-python.python:test-manager', { - name: 'test', - displayName: 'Test', - description: 'test', - preferredPackageManagerId: 'pip', - refresh: () => Promise.resolve(), - getEnvironments: () => Promise.resolve([]), - resolve: () => Promise.resolve(undefined), - set: () => Promise.resolve(), - get: () => Promise.resolve(undefined), - create: () => Promise.resolve(undefined), - remove: () => Promise.resolve(), - }); - const managerItem2 = new EnvManagerTreeItem(manager2); - - test('Context Value: no-remove, no-activate', () => { - const env = new PythonEnvironmentImpl( - { - id: 'test-env', - managerId: manager1.id, - }, - { - name: 'test-env', - displayName: 'Test Env', - description: 'This is test environment', - displayPath: '/home/user/envs/.venv/bin/python', - version: '3.12.1', - environmentPath: Uri.file('/home/user/envs/.venv/bin/python'), - execInfo: { - run: { - executable: '/home/user/envs/.venv/bin/python', - }, - }, - sysPrefix: '/home/user/envs/.venv', - }, - ); - - const item = new PythonEnvTreeItem(env, managerItem1); - assert.equal(item.treeItem.contextValue, 'pythonEnvironment;'); - }); - - test('Context Value: no-remove, with activate', () => { - const env = new PythonEnvironmentImpl( - { - id: 'test-env', - managerId: manager1.id, - }, - { - name: 'test-env', - displayName: 'Test Env', - description: 'This is test environment', - displayPath: '/home/user/envs/.venv/bin/python', - version: '3.12.1', - environmentPath: Uri.file('/home/user/envs/.venv/bin/python'), - execInfo: { - run: { - executable: '/home/user/envs/.venv/bin/python', - }, - activation: [ - { - executable: '/home/user/envs/.venv/bin/activate', - }, - ], - }, - sysPrefix: '/home/user/envs/.venv', - }, - ); - - const item = new PythonEnvTreeItem(env, managerItem1); - assert.equal(item.treeItem.contextValue, 'pythonEnvironment;activatable;'); - }); - - test('Context Value: with remove, with activate', () => { - const env = new PythonEnvironmentImpl( - { - id: 'test-env', - managerId: manager2.id, - }, - { - name: 'test-env', - displayName: 'Test Env', - description: 'This is test environment', - displayPath: '/home/user/envs/.venv/bin/python', - version: '3.12.1', - environmentPath: Uri.file('/home/user/envs/.venv/bin/python'), - execInfo: { - run: { - executable: '/home/user/envs/.venv/bin/python', - }, - activation: [ - { - executable: '/home/user/envs/.venv/bin/activate', - }, - ], - }, - sysPrefix: '/home/user/envs/.venv', - }, - ); - - const item = new PythonEnvTreeItem(env, managerItem2); - assert.equal(item.treeItem.contextValue, 'pythonEnvironment;remove;activatable;'); - }); - - test('Context Value: with remove, no-activate', () => { - const env = new PythonEnvironmentImpl( - { - id: 'test-env', - managerId: manager2.id, - }, - { - name: 'test-env', - displayName: 'Test Env', - description: 'This is test environment', - displayPath: '/home/user/envs/.venv/bin/python', - version: '3.12.1', - environmentPath: Uri.file('/home/user/envs/.venv/bin/python'), - execInfo: { - run: { - executable: '/home/user/envs/.venv/bin/python', - }, - }, - sysPrefix: '/home/user/envs/.venv', - }, - ); - - const item = new PythonEnvTreeItem(env, managerItem2); - assert.equal(item.treeItem.contextValue, 'pythonEnvironment;remove;'); - }); - - test('Display Name is used', () => { - const env = new PythonEnvironmentImpl( - { - id: 'test-env', - managerId: manager1.id, - }, - { - name: 'test-env', - displayName: 'Test Env', - description: 'This is test environment', - displayPath: '/home/user/envs/.venv/bin/python', - version: '3.12.1', - environmentPath: Uri.file('/home/user/envs/.venv/bin/python'), - execInfo: { - run: { - executable: '/home/user/envs/.venv/bin/python', - }, - }, - sysPrefix: '/home/user/envs/.venv', - }, - ); - - const item = new PythonEnvTreeItem(env, managerItem1); - - assert.equal(item.treeItem.label, env.displayName); + let managerWithoutRemove: EnvManagerTreeItem; + let managerWithRemove: EnvManagerTreeItem; + + setup(() => { + managerWithoutRemove = new EnvManagerTreeItem(createMockManager({ supportsRemove: false })); + managerWithRemove = new EnvManagerTreeItem(createMockManager({ supportsRemove: true })); + }); + + test('Context value excludes remove and activatable when not supported', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/envs/.venv/bin/python', + hasActivation: false, + }); + + // Act + const item = new PythonEnvTreeItem(env, managerWithoutRemove); + + // Assert + assert.strictEqual(item.treeItem.contextValue, 'pythonEnvironment;'); + }); + + test('Context value includes activatable when environment has activation', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/envs/.venv/bin/python', + hasActivation: true, + }); + + // Act + const item = new PythonEnvTreeItem(env, managerWithoutRemove); + + // Assert + assert.strictEqual(item.treeItem.contextValue, 'pythonEnvironment;activatable;'); + }); + + test('Context value includes remove when manager supports it', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/envs/.venv/bin/python', + hasActivation: true, + }); + + // Act + const item = new PythonEnvTreeItem(env, managerWithRemove); + + // Assert + assert.strictEqual(item.treeItem.contextValue, 'pythonEnvironment;remove;activatable;'); + }); + + test('Uses environment displayName as tree item label', () => { + // Arrange + const env = createMockEnvironment({ + displayName: 'My Custom Env', + environmentPath: '/home/user/envs/.venv/bin/python', + }); + + // Act + const item = new PythonEnvTreeItem(env, managerWithoutRemove); + + // Assert + assert.strictEqual(item.treeItem.label, 'My Custom Env'); + }); + + test('Shows disambiguation suffix in description field, not in label', () => { + // Arrange + const env = createMockEnvironment({ + name: '.venv (3.12)', + displayName: '.venv (3.12)', + environmentPath: '/home/user/my-project/.venv/bin/python', + }); + + // Act + const item = new PythonEnvTreeItem(env, managerWithoutRemove, undefined, 'my-project'); + + // Assert + assert.strictEqual(item.treeItem.label, '.venv (3.12)'); + assert.strictEqual(item.treeItem.description, 'my-project'); + }); + + test('Description is undefined when no disambiguation suffix and no uv indicator', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/my-project/.venv/bin/python', + description: undefined, + }); + + // Act + const item = new PythonEnvTreeItem(env, managerWithoutRemove, undefined, undefined); + + // Assert + assert.strictEqual(item.treeItem.description, undefined); + }); + + test('Shows [uv] indicator in description when environment is uv-managed', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/my-project/.venv/bin/python', + description: 'uv', + }); + + // Act + const item = new PythonEnvTreeItem(env, managerWithoutRemove, undefined, undefined); + + // Assert + assert.strictEqual(item.treeItem.description, '[uv]'); + }); + + test('Shows [uv] indicator combined with disambiguation suffix', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/my-project/.venv/bin/python', + description: 'uv workspace', + }); + + // Act + const item = new PythonEnvTreeItem(env, managerWithoutRemove, undefined, 'my-project'); + + // Assert + assert.strictEqual(item.treeItem.description, '[uv] my-project'); + }); + }); + + suite('getEnvironmentParentDirName', () => { + test('Extracts parent folder from Unix path with bin directory', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/my-project/.venv/bin/python', + }); + + // Act + const result = getEnvironmentParentDirName(env); + + // Assert + assert.strictEqual(result, 'my-project'); + }); + + test('Extracts parent folder from Windows path with Scripts directory', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: 'C:\\Users\\bob\\backend\\.venv\\Scripts\\python.exe', + }); + + // Act + const result = getEnvironmentParentDirName(env); + + // Assert + assert.strictEqual(result, 'backend'); + }); + + test('Extracts parent folder when environmentPath points to venv folder directly', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/api-service/.venv', + }); + + // Act + const result = getEnvironmentParentDirName(env); + + // Assert + assert.strictEqual(result, 'api-service'); + }); + + test('Works correctly with deeply nested project paths', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/code/projects/monorepo/packages/backend/.venv/bin/python', + }); + + // Act + const result = getEnvironmentParentDirName(env); + + // Assert + assert.strictEqual(result, 'backend'); + }); + + test('Handles paths with spaces in folder names', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/My Project/.venv/bin/python', + }); + + // Act + const result = getEnvironmentParentDirName(env); + + // Assert + assert.strictEqual(result, 'My Project'); + }); + + test('Handles venv folders with custom names', () => { + // Arrange + const env = createMockEnvironment({ + environmentPath: '/home/user/myapp/virtualenv/bin/python', + }); + + // Act + const result = getEnvironmentParentDirName(env); + + // Assert + assert.strictEqual(result, 'myapp'); }); }); });