Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions src/features/views/envManagersView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
EnvManagerTreeItem,
EnvTreeItem,
EnvTreeItemKind,
getEnvironmentParentDirName,
NoPythonEnvTreeItem,
PackageTreeItem,
PythonEnvTreeItem,
Expand All @@ -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();
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern [0-9.]+ could match invalid version strings like '3..12' or '...'. Consider using a more precise pattern like [0-9]+(?:\\.[0-9]+)* to match valid semantic version formats.

Suggested change
return displayName.replace(/\s*\([0-9.]+\)\s*$/, '').trim();
return displayName.replace(/\s*\([0-9]+(?:\.[0-9]+)*\)\s*$/, '').trim();

Copilot uses AI. Check for mistakes.
}

/**
* 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<string, string> {
const suffixes = new Map<string, string>();

// Group environments by their base name (ignoring version)
const baseNameToEnvs = new Map<string, PythonEnvironment[]>();
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<EnvTreeItem>, Disposable {
private treeView: TreeView<EnvTreeItem>;
private treeDataChanged: EventEmitter<EnvTreeItem | EnvTreeItem[] | null | undefined> = new EventEmitter<
Expand Down Expand Up @@ -126,8 +174,16 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, 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);
});
Expand Down Expand Up @@ -172,8 +228,10 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, 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);
});
Expand Down
64 changes: 61 additions & 3 deletions src/features/views/treeViewItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +31 to +32
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for identifying venv folders relies on checking if a part starts with 'python', but this could incorrectly skip legitimate folder names that start with 'python' (e.g., 'python-utils', 'python_project'). Consider making this check more specific by checking for exact matches with common Python executable names like 'python', 'python3', 'python.exe', etc.

Copilot uses AI. Check for mistakes.
}
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',
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down
Loading