diff --git a/news/1 Enhancements/17944.md b/news/1 Enhancements/17944.md new file mode 100644 index 000000000000..e875616b3ecd --- /dev/null +++ b/news/1 Enhancements/17944.md @@ -0,0 +1 @@ +Group interpreters in interpreter quick picker using separators. diff --git a/package.nls.json b/package.nls.json index ab5baa1a454e..df3ba64f848f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -53,7 +53,9 @@ "Interpreters.DiscoveringInterpreters": "Discovering Python Interpreters", "Interpreters.condaInheritEnvMessage": "We noticed you're using a conda environment. If you are experiencing issues with this environment in the integrated terminal, we recommend that you let the Python extension change \"terminal.integrated.inheritEnv\" to false in your user settings.", "Logging.CurrentWorkingDirectory": "cwd:", - "InterpreterQuickPickList.quickPickListPlaceholder": "Current: {0}", + "InterpreterQuickPickList.workspaceGroupName": "Workspace", + "InterpreterQuickPickList.globalGroupName": "Global", + "InterpreterQuickPickList.quickPickListPlaceholder": "Selected Interpreter: {0}", "InterpreterQuickPickList.enterPath.label": "Enter interpreter path...", "InterpreterQuickPickList.enterPath.placeholder": "Enter path to a Python interpreter.", "InterpreterQuickPickList.refreshInterpreterList": "Refresh Interpreter list", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 968db6618692..f229b7f37907 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -49,7 +49,6 @@ "Interpreters.pythonInterpreterPath": "Python 解释器路径: {0}", "Interpreters.condaInheritEnvMessage": "您正在使用 conda 环境,如果您在集成终端中遇到相关问题,建议您允许 Python 扩展将用户设置中的 \"terminal.integrated.inheritEnv\" 改为 false。", "Logging.CurrentWorkingDirectory": "cwd:", - "InterpreterQuickPickList.quickPickListPlaceholder": "当前: {0}", "InterpreterQuickPickList.enterPath.label": "输入解释器路径...", "InterpreterQuickPickList.enterPath.placeholder": "请输入 Python 解释器的路径。", "InterpreterQuickPickList.refreshInterpreterList": "刷新解释器列表", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 96c50d19393c..88adaf77a987 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -51,7 +51,6 @@ "Interpreters.entireWorkspace": "完整工作區", "Interpreters.pythonInterpreterPath": "Python 直譯器路徑: {0}", "Logging.CurrentWorkingDirectory": "cwd:", - "InterpreterQuickPickList.quickPickListPlaceholder": "目前: {0}", "InterpreterQuickPickList.enterPath.label": "輸入直譯器路徑...", "InterpreterQuickPickList.enterPath.placeholder": "輸入 Python 直譯器的路徑。", "InterpreterQuickPickList.refreshInterpreterList": "重新整理直譯器清單", diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 9030d256cc74..0f244ac1171a 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -292,9 +292,11 @@ export namespace Interpreters { } export namespace InterpreterQuickPickList { + export const globalGroupName = localize('InterpreterQuickPickList.globalGroupName', 'Global'); + export const workspaceGroupName = localize('InterpreterQuickPickList.workspaceGroupName', 'Workspace'); export const quickPickListPlaceholder = localize( 'InterpreterQuickPickList.quickPickListPlaceholder', - 'Current: {0}', + 'Selected Interpreter: {0}', ); export const enterPath = { label: localize('InterpreterQuickPickList.enterPath.label', 'Enter interpreter path...'), diff --git a/src/client/interpreter/autoSelection/index.ts b/src/client/interpreter/autoSelection/index.ts index 50d4d4343c34..3117ad1826fb 100644 --- a/src/client/interpreter/autoSelection/index.ts +++ b/src/client/interpreter/autoSelection/index.ts @@ -14,7 +14,6 @@ import { compareSemVerLikeVersions } from '../../pythonEnvironments/base/info/py import { PythonEnvironment } from '../../pythonEnvironments/info'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; -import { EnvTypeHeuristic, getEnvTypeHeuristic } from '../configuration/environmentTypeComparer'; import { IInterpreterComparer } from '../configuration/types'; import { IInterpreterHelper, IInterpreterService } from '../contracts'; import { IInterpreterAutoSelectionService, IInterpreterAutoSelectionProxyService } from './types'; @@ -204,19 +203,14 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio const interpreters = await this.interpreterService.getAllInterpreters(resource); const workspaceUri = this.interpreterHelper.getActiveWorkspaceUri(resource); - // When auto-selecting an intepreter for a workspace, we either want to return a local one - // or fallback on a globally-installed interpreter, and we don't want want to suggest a global environment - // because we would have to add a way to match environments to a workspace. - const filteredInterpreters = interpreters.filter( - (i) => getEnvTypeHeuristic(i, workspaceUri?.folderUri.fsPath || '') !== EnvTypeHeuristic.Global, - ); - - filteredInterpreters.sort(this.envTypeComparer.compare.bind(this.envTypeComparer)); - + const recommendedInterpreter = this.envTypeComparer.getRecommended(interpreters, workspaceUri?.folderUri); + if (!recommendedInterpreter) { + return; + } if (workspaceUri) { - this.setWorkspaceInterpreter(workspaceUri.folderUri, filteredInterpreters[0]); + this.setWorkspaceInterpreter(workspaceUri.folderUri, recommendedInterpreter); } else { - this.setGlobalInterpreter(filteredInterpreters[0]); + this.setGlobalInterpreter(recommendedInterpreter); } queriedState.updateValue(true); diff --git a/src/client/interpreter/configuration/environmentTypeComparer.ts b/src/client/interpreter/configuration/environmentTypeComparer.ts index ea3d8e0a10eb..03b536e8a42c 100644 --- a/src/client/interpreter/configuration/environmentTypeComparer.ts +++ b/src/client/interpreter/configuration/environmentTypeComparer.ts @@ -3,22 +3,22 @@ import { injectable, inject } from 'inversify'; import { getArchitectureDisplayName } from '../../common/platform/registry'; +import { Resource } from '../../common/types'; import { isParentPath } from '../../pythonEnvironments/common/externalDependencies'; -import { EnvironmentType, PythonEnvironment } from '../../pythonEnvironments/info'; +import { EnvironmentType, PythonEnvironment, virtualEnvTypes } from '../../pythonEnvironments/info'; import { PythonVersion } from '../../pythonEnvironments/info/pythonVersion'; import { IInterpreterHelper } from '../contracts'; import { IInterpreterComparer } from './types'; -/* - * Enum description: - * - Local environments (.venv); - * - Global environments (pipenv, conda); - * - Globally-installed interpreters (/usr/bin/python3, Windows Store). - */ -export enum EnvTypeHeuristic { +export enum EnvLocationHeuristic { + /** + * Environments inside the workspace. + */ Local = 1, + /** + * Environments outside the workspace. + */ Global = 2, - GlobalInterpreters = 3, } @injectable() @@ -41,8 +41,14 @@ export class EnvironmentTypeComparer implements IInterpreterComparer { * Always sort with newest version of Python first within each subgroup. */ public compare(a: PythonEnvironment, b: PythonEnvironment): number { + // Check environment location. + const envLocationComparison = compareEnvironmentLocation(a, b, this.workspaceFolderPath); + if (envLocationComparison !== 0) { + return envLocationComparison; + } + // Check environment type. - const envTypeComparison = compareEnvironmentType(a, b, this.workspaceFolderPath); + const envTypeComparison = compareEnvironmentType(a, b); if (envTypeComparison !== 0) { return envTypeComparison; } @@ -53,20 +59,15 @@ export class EnvironmentTypeComparer implements IInterpreterComparer { return versionComparison; } - // Prioritize non-Conda environments. - if (isCondaEnvironment(a) && !isCondaEnvironment(b)) { + // If we have the "base" Conda env, put it last in its Python version subgroup. + if (isBaseCondaEnvironment(a)) { return 1; } - if (!isCondaEnvironment(a) && isCondaEnvironment(b)) { + if (isBaseCondaEnvironment(b)) { return -1; } - // If we have the "base" Conda env, put it last in its Python version subgroup. - if (isBaseCondaEnvironment(a)) { - return 1; - } - // Check alphabetical order. const nameA = getSortName(a, this.interpreterHelper); const nameB = getSortName(b, this.interpreterHelper); @@ -76,6 +77,25 @@ export class EnvironmentTypeComparer implements IInterpreterComparer { return nameA > nameB ? 1 : -1; } + + public getRecommended(interpreters: PythonEnvironment[], resource: Resource): PythonEnvironment | undefined { + // When recommending an intepreter for a workspace, we either want to return a local one + // or fallback on a globally-installed interpreter, and we don't want want to suggest a global environment + // because we would have to add a way to match environments to a workspace. + const workspaceUri = this.interpreterHelper.getActiveWorkspaceUri(resource); + const filteredInterpreters = interpreters.filter((i) => { + if (getEnvLocationHeuristic(i, workspaceUri?.folderUri.fsPath || '') === EnvLocationHeuristic.Local) { + return true; + } + if (virtualEnvTypes.includes(i.envType)) { + // We're not sure if these envs were created for the workspace, so do not recommend them. + return false; + } + return true; + }); + filteredInterpreters.sort(this.compare.bind(this)); + return filteredInterpreters.length ? filteredInterpreters[0] : undefined; + } } function getSortName(info: PythonEnvironment, interpreterHelper: IInterpreterHelper): string { @@ -113,12 +133,11 @@ function getSortName(info: PythonEnvironment, interpreterHelper: IInterpreterHel return `${sortNameParts.join(' ')} ${envSuffix}`.trim(); } -function isCondaEnvironment(environment: PythonEnvironment): boolean { - return environment.envType === EnvironmentType.Conda; -} - function isBaseCondaEnvironment(environment: PythonEnvironment): boolean { - return isCondaEnvironment(environment) && (environment.envName === 'base' || environment.envName === 'miniconda'); + return ( + environment.envType === EnvironmentType.Conda && + (environment.envName === 'base' || environment.envName === 'miniconda') + ); } /** @@ -151,11 +170,11 @@ function comparePythonVersionDescending(a: PythonVersion | undefined, b: PythonV } /** - * Compare 2 environment types: return 0 if they are the same, -1 if a comes before b, 1 otherwise. + * Compare 2 environment locations: return 0 if they are the same, -1 if a comes before b, 1 otherwise. */ -function compareEnvironmentType(a: PythonEnvironment, b: PythonEnvironment, workspacePath: string): number { - const aHeuristic = getEnvTypeHeuristic(a, workspacePath); - const bHeuristic = getEnvTypeHeuristic(b, workspacePath); +function compareEnvironmentLocation(a: PythonEnvironment, b: PythonEnvironment, workspacePath: string): number { + const aHeuristic = getEnvLocationHeuristic(a, workspacePath); + const bHeuristic = getEnvLocationHeuristic(b, workspacePath); return Math.sign(aHeuristic - bHeuristic); } @@ -163,28 +182,38 @@ function compareEnvironmentType(a: PythonEnvironment, b: PythonEnvironment, work /** * Return a heuristic value depending on the environment type. */ -export function getEnvTypeHeuristic(environment: PythonEnvironment, workspacePath: string): EnvTypeHeuristic { - const { envType } = environment; - +export function getEnvLocationHeuristic(environment: PythonEnvironment, workspacePath: string): EnvLocationHeuristic { if ( workspacePath.length > 0 && ((environment.envPath && isParentPath(environment.envPath, workspacePath)) || (environment.path && isParentPath(environment.path, workspacePath))) ) { - return EnvTypeHeuristic.Local; + return EnvLocationHeuristic.Local; } + return EnvLocationHeuristic.Global; +} - switch (envType) { - case EnvironmentType.Venv: - case EnvironmentType.Conda: - case EnvironmentType.VirtualEnv: - case EnvironmentType.VirtualEnvWrapper: - case EnvironmentType.Pipenv: - case EnvironmentType.Poetry: - return EnvTypeHeuristic.Global; - // The default case covers global environments. - // For now this includes: pyenv, Windows Store, Global, System and Unknown environment types. - default: - return EnvTypeHeuristic.GlobalInterpreters; - } +/** + * Compare 2 environment types: return 0 if they are the same, -1 if a comes before b, 1 otherwise. + */ +function compareEnvironmentType(a: PythonEnvironment, b: PythonEnvironment): number { + const envTypeByPriority = getPrioritizedEnvironmentType(); + return Math.sign(envTypeByPriority.indexOf(a.envType) - envTypeByPriority.indexOf(b.envType)); +} + +function getPrioritizedEnvironmentType(): EnvironmentType[] { + return [ + // Prioritize non-Conda environments. + EnvironmentType.Poetry, + EnvironmentType.Pipenv, + EnvironmentType.VirtualEnvWrapper, + EnvironmentType.Venv, + EnvironmentType.VirtualEnv, + EnvironmentType.Conda, + EnvironmentType.Pyenv, + EnvironmentType.WindowsStore, + EnvironmentType.Global, + EnvironmentType.System, + EnvironmentType.Unknown, + ]; } diff --git a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts index 047e26c80fe4..c853c62cd62b 100644 --- a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts +++ b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts @@ -6,10 +6,10 @@ import { inject, injectable } from 'inversify'; import { cloneDeep } from 'lodash'; import * as path from 'path'; -import { QuickPick, QuickPickItem } from 'vscode'; +import { QuickPick, QuickPickItem, QuickPickItemKind } from 'vscode'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../../../common/application/types'; import { Commands, Octicons } from '../../../../common/constants'; -import { arePathsSame } from '../../../../common/platform/fs-paths'; +import { arePathsSame, isParentPath } from '../../../../common/platform/fs-paths'; import { IPlatformService } from '../../../../common/platform/types'; import { IConfigurationService, IPathUtils, Resource } from '../../../../common/types'; import { getIcon } from '../../../../common/utils/icons'; @@ -22,6 +22,7 @@ import { } from '../../../../common/utils/multiStepInput'; import { SystemVariables } from '../../../../common/variables/systemVariables'; import { REFRESH_BUTTON_ICON } from '../../../../debugger/extension/attachQuickPick/types'; +import { EnvironmentType } from '../../../../pythonEnvironments/info'; import { captureTelemetry, sendTelemetryEvent } from '../../../../telemetry'; import { EventName } from '../../../../telemetry/constants'; import { IInterpreterService, PythonEnvironmentsChangedEvent } from '../../../contracts'; @@ -36,7 +37,7 @@ import { BaseInterpreterSelectorCommand } from './base'; const untildify = require('untildify'); export type InterpreterStateArgs = { path?: string; workspace: Resource }; -type QuickPickType = IInterpreterQuickPickItem | ISpecialQuickPickItem; +type QuickPickType = IInterpreterQuickPickItem | ISpecialQuickPickItem | QuickPickItem; function isInterpreterQuickPickItem(item: QuickPickType): item is IInterpreterQuickPickItem { return 'interpreter' in item; @@ -46,6 +47,23 @@ function isSpecialQuickPickItem(item: QuickPickType): item is ISpecialQuickPickI return 'alwaysShow' in item; } +function isSeparatorItem(item: QuickPickType): item is QuickPickItem { + return 'kind' in item && item.kind === QuickPickItemKind.Separator; +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace EnvGroups { + export const Workspace = InterpreterQuickPickList.workspaceGroupName(); + export const Conda = 'Conda'; + export const Global = InterpreterQuickPickList.globalGroupName(); + export const VirtualEnv = 'VirtualEnv'; + export const PipEnv = 'PipEnv'; + export const Pyenv = 'Pyenv'; + export const Venv = 'Venv'; + export const Poetry = 'Poetry'; + export const VirtualEnvWrapper = 'VirtualEnvWrapper'; + export const Recommended = Common.recommended(); +} @injectable() export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { private readonly manualEntrySuggestion: ISpecialQuickPickItem = { @@ -149,15 +167,35 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { if (defaultInterpreterPathSuggestion) { suggestions.push(defaultInterpreterPathSuggestion); } - const interpreterSuggestions = this.interpreterSelector.getSuggestions(resource); + const interpreterSuggestions = this.getSuggestions(resource); this.setRecommendedItem(interpreterSuggestions, resource); suggestions.push(...interpreterSuggestions); return suggestions; } + private getSuggestions(resource: Resource): QuickPickType[] { + const workspaceFolder = this.workspaceService.getWorkspaceFolder(resource); + const items = this.interpreterSelector.getSuggestions(resource, !!this.interpreterService.refreshPromise); + if (this.interpreterService.refreshPromise) { + // We cannot put items in groups while the list is loading as group of an item can change. + return items; + } + const itemsWithFullName = this.interpreterSelector.getSuggestions(resource, true); + const recommended = this.interpreterSelector.getRecommendedSuggestion( + itemsWithFullName, + this.workspaceService.getWorkspaceFolder(resource)?.uri, + ); + if (recommended && arePathsSame(items[0].interpreter.path, recommended.interpreter.path)) { + items.shift(); + } + return getGroupedQuickPickItems(items, recommended, workspaceFolder?.uri.fsPath); + } + private getActiveItem(resource: Resource, suggestions: QuickPickType[]) { const currentPythonPath = this.configurationService.getSettings(resource).pythonPath; - const activeInterpreter = suggestions.filter((i) => i.path === currentPythonPath); + const activeInterpreter = suggestions.filter( + (i) => isInterpreterQuickPickItem(i) && i.path === currentPythonPath, + ); if (activeInterpreter.length > 0) { return activeInterpreter[0]; } @@ -175,7 +213,7 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { if (defaultInterpreterPathValue && defaultInterpreterPathValue !== 'python') { return { label: `${Octicons.Gear} ${InterpreterQuickPickList.defaultInterpreterPath.label()}`, - detail: this.pathUtils.getDisplayName( + description: this.pathUtils.getDisplayName( defaultInterpreterPathValue, resource ? resource.fsPath : undefined, ), @@ -222,6 +260,7 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { resource: Resource, ): QuickPickType[] { const updatedItems = [...items.values()]; + const areItemsGrouped = items.find((item) => isSeparatorItem(item)); const env = event.old ?? event.new; let envIndex = -1; if (env) { @@ -230,11 +269,19 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { ); } if (event.new) { - const newSuggestion: QuickPickType = this.interpreterSelector.suggestionToQuickPickItem( + const newSuggestion = this.interpreterSelector.suggestionToQuickPickItem( event.new, resource, + !areItemsGrouped, ); if (envIndex === -1) { + if (areItemsGrouped) { + addSeparatorIfApplicable( + updatedItems, + newSuggestion, + this.workspaceService.getWorkspaceFolder(resource)?.uri.fsPath, + ); + } updatedItems.push(newSuggestion); } else { updatedItems[envIndex] = newSuggestion; @@ -248,12 +295,22 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { } private setRecommendedItem(items: QuickPickType[], resource: Resource) { - const interpreterSuggestions = this.interpreterSelector.getSuggestions(resource); + const interpreterSuggestions = this.interpreterSelector.getSuggestions(resource, true); if (!this.interpreterService.refreshPromise && interpreterSuggestions.length > 0) { - // List is in the final state, so first suggestion is the recommended one. - const recommended = cloneDeep(interpreterSuggestions[0]); + const suggestion = this.interpreterSelector.getRecommendedSuggestion( + interpreterSuggestions, + this.workspaceService.getWorkspaceFolder(resource)?.uri, + ); + if (!suggestion) { + return; + } + const areItemsGrouped = items.find((item) => isSeparatorItem(item) && item.label === EnvGroups.Recommended); + const recommended = cloneDeep(suggestion); recommended.label = `${Octicons.Star} ${recommended.label}`; - recommended.description = Common.recommended(); + recommended.description = areItemsGrouped + ? // No need to add a tag as "Recommended" group already exists. + recommended.description + : `${recommended.description ?? ''} - ${Common.recommended()}`; const index = items.findIndex( (item) => isInterpreterQuickPickItem(item) && @@ -361,3 +418,55 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { return undefined; } } + +function getGroupedQuickPickItems( + items: IInterpreterQuickPickItem[], + recommended: IInterpreterQuickPickItem | undefined, + workspacePath?: string, +): QuickPickType[] { + const updatedItems: QuickPickType[] = []; + if (recommended) { + updatedItems.push({ label: EnvGroups.Recommended, kind: QuickPickItemKind.Separator }, recommended); + } + let previousGroup = EnvGroups.Recommended; + for (const item of items) { + previousGroup = addSeparatorIfApplicable(updatedItems, item, workspacePath, previousGroup); + updatedItems.push(item); + } + return updatedItems; +} + +function addSeparatorIfApplicable( + items: QuickPickType[], + newItem: IInterpreterQuickPickItem, + workspacePath?: string, + previousGroup?: string | undefined, +) { + if (!previousGroup) { + const lastItem = items.length ? items[items.length - 1] : undefined; + previousGroup = + lastItem && isInterpreterQuickPickItem(lastItem) ? getGroup(lastItem, workspacePath) : undefined; + } + const currentGroup = getGroup(newItem, workspacePath); + if (!previousGroup || currentGroup !== previousGroup) { + const separatorItem: QuickPickItem = { label: currentGroup, kind: QuickPickItemKind.Separator }; + items.push(separatorItem); + previousGroup = currentGroup; + } + return previousGroup; +} + +function getGroup(item: IInterpreterQuickPickItem, workspacePath?: string) { + if (workspacePath && isParentPath(item.path, workspacePath)) { + return EnvGroups.Workspace; + } + switch (item.interpreter.envType) { + case EnvironmentType.Global: + case EnvironmentType.System: + case EnvironmentType.Unknown: + case EnvironmentType.WindowsStore: + return EnvGroups.Global; + default: + return EnvGroups[item.interpreter.envType]; + } +} diff --git a/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts b/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts index 6f814678b3a3..3d25eb59da79 100644 --- a/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts +++ b/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts @@ -5,6 +5,7 @@ import { inject, injectable } from 'inversify'; import { Disposable, Uri } from 'vscode'; +import { arePathsSame } from '../../../common/platform/fs-paths'; import { IPathUtils, Resource } from '../../../common/types'; import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { IInterpreterService } from '../../contracts'; @@ -24,11 +25,11 @@ export class InterpreterSelector implements IInterpreterSelector { this.disposables.forEach((disposable) => disposable.dispose()); } - public getSuggestions(resource: Resource): IInterpreterQuickPickItem[] { + public getSuggestions(resource: Resource, useFullDisplayName = false): IInterpreterQuickPickItem[] { const interpreters = this.interpreterManager.getInterpreters(resource); interpreters.sort(this.envTypeComparer.compare.bind(this.envTypeComparer)); - return interpreters.map((item) => this.suggestionToQuickPickItem(item, resource)); + return interpreters.map((item) => this.suggestionToQuickPickItem(item, resource, useFullDisplayName)); } public async getAllSuggestions(resource: Resource): Promise { @@ -38,14 +39,30 @@ export class InterpreterSelector implements IInterpreterSelector { return Promise.all(interpreters.map((item) => this.suggestionToQuickPickItem(item, resource))); } - public suggestionToQuickPickItem(suggestion: PythonEnvironment, workspaceUri?: Uri): IInterpreterQuickPickItem { + public suggestionToQuickPickItem( + suggestion: PythonEnvironment, + workspaceUri?: Uri, + useDetailedName = false, + ): IInterpreterQuickPickItem { const detail = this.pathUtils.getDisplayName(suggestion.path, workspaceUri ? workspaceUri.fsPath : undefined); const cachedPrefix = suggestion.cachedEntry ? '(cached) ' : ''; return { - label: suggestion.displayName || 'Python', - detail: `${cachedPrefix}${detail}`, + label: (useDetailedName ? suggestion.detailedDisplayName : suggestion.displayName) || 'Python', + description: `${cachedPrefix}${detail}`, path: suggestion.path, interpreter: suggestion, }; } + + public getRecommendedSuggestion( + suggestions: IInterpreterQuickPickItem[], + resource: Resource, + ): IInterpreterQuickPickItem | undefined { + const envs = this.interpreterManager.getInterpreters(resource); + const recommendedEnv = this.envTypeComparer.getRecommended(envs, resource); + if (!recommendedEnv) { + return undefined; + } + return suggestions.find((item) => arePathsSame(item.interpreter.path, recommendedEnv.path)); + } } diff --git a/src/client/interpreter/configuration/types.ts b/src/client/interpreter/configuration/types.ts index d9f03ddc2bae..72c5856361fa 100644 --- a/src/client/interpreter/configuration/types.ts +++ b/src/client/interpreter/configuration/types.ts @@ -25,9 +25,17 @@ export interface IPythonPathUpdaterServiceManager { export const IInterpreterSelector = Symbol('IInterpreterSelector'); export interface IInterpreterSelector extends Disposable { + getRecommendedSuggestion( + suggestions: IInterpreterQuickPickItem[], + resource: Resource, + ): IInterpreterQuickPickItem | undefined; getAllSuggestions(resource: Resource): Promise; - getSuggestions(resource: Resource): IInterpreterQuickPickItem[]; - suggestionToQuickPickItem(suggestion: PythonEnvironment, workspaceUri?: Uri | undefined): IInterpreterQuickPickItem; + getSuggestions(resource: Resource, useFullDisplayName?: boolean): IInterpreterQuickPickItem[]; + suggestionToQuickPickItem( + suggestion: PythonEnvironment, + workspaceUri?: Uri | undefined, + useDetailedName?: boolean, + ): IInterpreterQuickPickItem; } export interface IInterpreterQuickPickItem extends QuickPickItem { @@ -52,4 +60,5 @@ export interface ISpecialQuickPickItem { export const IInterpreterComparer = Symbol('IInterpreterComparer'); export interface IInterpreterComparer { compare(a: PythonEnvironment, b: PythonEnvironment): number; + getRecommended(interpreters: PythonEnvironment[], resource: Resource): PythonEnvironment | undefined; } diff --git a/src/client/interpreter/display/index.ts b/src/client/interpreter/display/index.ts index 0382641e954b..ea09edefa89d 100644 --- a/src/client/interpreter/display/index.ts +++ b/src/client/interpreter/display/index.ts @@ -119,7 +119,7 @@ export class InterpreterDisplay implements IInterpreterDisplay, IExtensionSingle ); this.interpreterPath = interpreter.path; } - let text = interpreter.displayName!; + let text = interpreter.detailedDisplayName!; if (this.experiments.inExperimentSync(InterpreterStatusBarPosition.Pinned)) { text = text.startsWith('Python') ? text.substring('Python'.length).trim() : text; } @@ -142,7 +142,7 @@ export class InterpreterDisplay implements IInterpreterDisplay, IExtensionSingle ); this.interpreterPath = interpreter.path; } - let text = interpreter.displayName!; + let text = interpreter.detailedDisplayName!; text = text.startsWith('Python') ? text.substring('Python'.length).trim() : text; this.languageStatus.text = text; this.currentlySelectedInterpreterPath = interpreter.path; diff --git a/src/client/pythonEnvironments/base/info/env.ts b/src/client/pythonEnvironments/base/info/env.ts index 33f2bb55a8ff..86f5c16b02e3 100644 --- a/src/client/pythonEnvironments/base/info/env.ts +++ b/src/client/pythonEnvironments/base/info/env.ts @@ -11,7 +11,15 @@ import { arePathsSame } from '../../common/externalDependencies'; import { getKindDisplayName } from './envKind'; import { areIdenticalVersion, areSimilarVersions, getVersionDisplayString, isVersionEmpty } from './pythonVersion'; -import { PythonEnvInfo, PythonEnvKind, PythonEnvSource, PythonReleaseLevel, PythonVersion } from '.'; +import { + globallyInstalledEnvKinds, + PythonEnvInfo, + PythonEnvKind, + PythonEnvSource, + PythonReleaseLevel, + PythonVersion, + virtualEnvKinds, +} from '.'; /** * Create a new info object with all values empty. @@ -118,19 +126,24 @@ function updateEnv( * The format is `Python (: )` * E.g. `Python 3.5.1 32-bit (myenv2: virtualenv)` */ -export function getEnvDisplayString(env: PythonEnvInfo): string { - return buildEnvDisplayString(env); +export function setEnvDisplayString(env: PythonEnvInfo): void { + env.display = buildEnvDisplayString(env); + env.detailedDisplayName = buildEnvDisplayString(env, true); } -function buildEnvDisplayString(env: PythonEnvInfo): string { +function buildEnvDisplayString(env: PythonEnvInfo, getAllDetails = false): string { // main parts + const shouldDisplayKind = getAllDetails || env.searchLocation || globallyInstalledEnvKinds.includes(env.kind); + const shouldDisplayArch = !virtualEnvKinds.includes(env.kind); const displayNameParts: string[] = ['Python']; if (env.version && !isVersionEmpty(env.version)) { displayNameParts.push(getVersionDisplayString(env.version)); } - const archName = getArchitectureDisplayName(env.arch); - if (archName !== '') { - displayNameParts.push(archName); + if (shouldDisplayArch) { + const archName = getArchitectureDisplayName(env.arch); + if (archName !== '') { + displayNameParts.push(archName); + } } // Note that currently we do not use env.distro in the display name. @@ -140,9 +153,11 @@ function buildEnvDisplayString(env: PythonEnvInfo): string { if (env.name && env.name !== '') { envSuffixParts.push(`'${env.name}'`); } - const kindName = getKindDisplayName(env.kind); - if (kindName !== '') { - envSuffixParts.push(kindName); + if (shouldDisplayKind) { + const kindName = getKindDisplayName(env.kind); + if (kindName !== '') { + envSuffixParts.push(kindName); + } } const envSuffix = envSuffixParts.length === 0 ? '' : `(${envSuffixParts.join(': ')})`; diff --git a/src/client/pythonEnvironments/base/info/index.ts b/src/client/pythonEnvironments/base/info/index.ts index 0f3e02f67cf7..e18788cb1c22 100644 --- a/src/client/pythonEnvironments/base/info/index.ts +++ b/src/client/pythonEnvironments/base/info/index.ts @@ -36,6 +36,15 @@ export const virtualEnvKinds = [ PythonEnvKind.Conda, PythonEnvKind.VirtualEnv, ]; + +export const globallyInstalledEnvKinds = [ + PythonEnvKind.OtherGlobal, + PythonEnvKind.Unknown, + PythonEnvKind.WindowsStore, + PythonEnvKind.System, + PythonEnvKind.Custom, +]; + /** * Information about a file. */ @@ -168,11 +177,13 @@ type _PythonEnvInfo = PythonEnvBaseInfo & PythonBuildInfo; * * @prop distro - the installed Python distro that this env is using or belongs to * @prop display - the text to use when showing the env to users + * @prop detailedDisplayName - display name containing all details * @prop searchLocation - the root under which a locator found this env, if any */ export type PythonEnvInfo = _PythonEnvInfo & { distro: PythonDistroInfo; display?: string; + detailedDisplayName?: string; searchLocation?: Uri; }; diff --git a/src/client/pythonEnvironments/base/locators/composite/envsResolver.ts b/src/client/pythonEnvironments/base/locators/composite/envsResolver.ts index bf07dd6b3ec0..f92f542f7c13 100644 --- a/src/client/pythonEnvironments/base/locators/composite/envsResolver.ts +++ b/src/client/pythonEnvironments/base/locators/composite/envsResolver.ts @@ -6,7 +6,7 @@ import { Event, EventEmitter } from 'vscode'; import { identifyEnvironment } from '../../../common/environmentIdentifier'; import { IEnvironmentInfoService } from '../../info/environmentInfoService'; import { PythonEnvInfo } from '../../info'; -import { getEnvDisplayString } from '../../info/env'; +import { setEnvDisplayString } from '../../info/env'; import { InterpreterInformation } from '../../info/interpreter'; import { BasicEnvInfo, @@ -145,6 +145,6 @@ function getResolvedEnv(interpreterInfo: InterpreterInformation, environment: Py resolvedEnv.executable.sysPrefix = interpreterInfo.executable.sysPrefix; resolvedEnv.arch = interpreterInfo.arch; // Display name should be set after all the properties as we need other properties to build display name. - resolvedEnv.display = getEnvDisplayString(resolvedEnv); + setEnvDisplayString(resolvedEnv); return resolvedEnv; } diff --git a/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts b/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts index fe1f548fe09b..a3e00e35d232 100644 --- a/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts +++ b/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import { Uri } from 'vscode'; import { uniq } from 'lodash'; import { PythonEnvInfo, PythonEnvKind, PythonEnvSource, UNKNOWN_PYTHON_VERSION, virtualEnvKinds } from '../../info'; -import { buildEnvInfo, comparePythonVersionSpecificity, getEnvDisplayString, getEnvMatcher } from '../../info/env'; +import { buildEnvInfo, comparePythonVersionSpecificity, setEnvDisplayString, getEnvMatcher } from '../../info/env'; import { getEnvironmentDirFromPath, getInterpreterPathFromDir, @@ -52,7 +52,7 @@ export async function resolveBasicEnv({ kind, executablePath, source }: BasicEnv // We can update env further using information we can get from the Windows registry. await updateEnvUsingRegistry(resolvedEnv); } - resolvedEnv.display = getEnvDisplayString(resolvedEnv); + setEnvDisplayString(resolvedEnv); return resolvedEnv; } diff --git a/src/client/pythonEnvironments/info/index.ts b/src/client/pythonEnvironments/info/index.ts index 187c2755f7f9..0bed9b91179a 100644 --- a/src/client/pythonEnvironments/info/index.ts +++ b/src/client/pythonEnvironments/info/index.ts @@ -23,6 +23,15 @@ export enum EnvironmentType { System = 'System', } +export const virtualEnvTypes = [ + EnvironmentType.Poetry, + EnvironmentType.Pipenv, + EnvironmentType.Venv, + EnvironmentType.VirtualEnvWrapper, + EnvironmentType.Conda, + EnvironmentType.VirtualEnv, +]; + /** * The IModuleInstaller implementations. */ @@ -68,6 +77,7 @@ export type InterpreterInformation = { export type PythonEnvironment = InterpreterInformation & { companyDisplayName?: string; displayName?: string; + detailedDisplayName?: string; envType: EnvironmentType; envName?: string; envPath?: string; diff --git a/src/client/pythonEnvironments/legacyIOC.ts b/src/client/pythonEnvironments/legacyIOC.ts index 7c2e774b4740..97ac3f6bac90 100644 --- a/src/client/pythonEnvironments/legacyIOC.ts +++ b/src/client/pythonEnvironments/legacyIOC.ts @@ -75,6 +75,7 @@ function convertEnvInfo(info: PythonEnvInfo): PythonEnvironment { env.companyDisplayName = distro.org; } env.displayName = info.display; + env.detailedDisplayName = info.detailedDisplayName; // We do not worry about using distro.defaultDisplayName. return env; diff --git a/src/test/activation/partialModeStatus.unit.test.ts b/src/test/activation/partialModeStatus.unit.test.ts index 7f1f0ada49c7..28f134379c87 100644 --- a/src/test/activation/partialModeStatus.unit.test.ts +++ b/src/test/activation/partialModeStatus.unit.test.ts @@ -44,11 +44,14 @@ suite('Partial Mode Status', async () => { parse: (s: string) => s, }, } as unknown) as typeof vscodeTypes; - rewiremock.disable(); rewiremock.enable(); rewiremock('vscode').with(vscodeMock); }); + teardown(() => { + rewiremock.disable(); + }); + test("No item is created if workspace is trusted and isn't virtual", async () => { workspaceService.setup((w) => w.isTrusted).returns(() => true); workspaceService.setup((w) => w.isVirtualWorkspace).returns(() => false); diff --git a/src/test/configuration/environmentTypeComparer.unit.test.ts b/src/test/configuration/environmentTypeComparer.unit.test.ts index c52a2aea584d..e3b909378b63 100644 --- a/src/test/configuration/environmentTypeComparer.unit.test.ts +++ b/src/test/configuration/environmentTypeComparer.unit.test.ts @@ -6,8 +6,8 @@ import * as path from 'path'; import * as sinon from 'sinon'; import { EnvironmentTypeComparer, - EnvTypeHeuristic, - getEnvTypeHeuristic, + EnvLocationHeuristic, + getEnvLocationHeuristic, } from '../../client/interpreter/configuration/environmentTypeComparer'; import { IInterpreterHelper } from '../../client/interpreter/contracts'; import { EnvironmentType, PythonEnvironment } from '../../client/pythonEnvironments/info'; @@ -250,9 +250,9 @@ suite('getEnvTypeHeuristic tests', () => { version: { major: 3, minor: 10, patch: 2 }, } as PythonEnvironment; - const envTypeHeuristic = getEnvTypeHeuristic(environment, workspacePath); + const envTypeHeuristic = getEnvLocationHeuristic(environment, workspacePath); - assert.strictEqual(envTypeHeuristic, EnvTypeHeuristic.Local); + assert.strictEqual(envTypeHeuristic, EnvLocationHeuristic.Local); }); test('If the path to an environment does not start with the workspace path it should be marked as global', () => { @@ -262,9 +262,9 @@ suite('getEnvTypeHeuristic tests', () => { version: { major: 3, minor: 10, patch: 2 }, } as PythonEnvironment; - const envTypeHeuristic = getEnvTypeHeuristic(environment, workspacePath); + const envTypeHeuristic = getEnvLocationHeuristic(environment, workspacePath); - assert.strictEqual(envTypeHeuristic, EnvTypeHeuristic.Global); + assert.strictEqual(envTypeHeuristic, EnvLocationHeuristic.Global); }); test('If envPath is not set, fallback to path', () => { @@ -274,9 +274,9 @@ suite('getEnvTypeHeuristic tests', () => { version: { major: 3, minor: 10, patch: 2 }, } as PythonEnvironment; - const envTypeHeuristic = getEnvTypeHeuristic(environment, workspacePath); + const envTypeHeuristic = getEnvLocationHeuristic(environment, workspacePath); - assert.strictEqual(envTypeHeuristic, EnvTypeHeuristic.Local); + assert.strictEqual(envTypeHeuristic, EnvLocationHeuristic.Local); }); }); @@ -296,9 +296,9 @@ suite('getEnvTypeHeuristic tests', () => { version: { major: 3, minor: 10, patch: 2 }, } as PythonEnvironment; - const envTypeHeuristic = getEnvTypeHeuristic(environment, workspacePath); + const envTypeHeuristic = getEnvLocationHeuristic(environment, workspacePath); - assert.strictEqual(envTypeHeuristic, EnvTypeHeuristic.GlobalInterpreters); + assert.strictEqual(envTypeHeuristic, EnvLocationHeuristic.Global); }); }); }); diff --git a/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts b/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts index dd3f1454baa8..0b91a5f9445e 100644 --- a/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts +++ b/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts @@ -6,7 +6,15 @@ import { expect } from 'chai'; import * as path from 'path'; import * as sinon from 'sinon'; import * as TypeMoq from 'typemoq'; -import { ConfigurationTarget, OpenDialogOptions, QuickPick, QuickPickItem, Uri } from 'vscode'; +import { + ConfigurationTarget, + OpenDialogOptions, + QuickPick, + QuickPickItem, + QuickPickItemKind, + Uri, + WorkspaceFolder, +} from 'vscode'; import { cloneDeep } from 'lodash'; import { instance, mock, verify, when } from 'ts-mockito'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../../../client/common/application/types'; @@ -21,6 +29,7 @@ import { IQuickPickParameters, } from '../../../../client/common/utils/multiStepInput'; import { + EnvGroups, InterpreterStateArgs, SetInterpreterCommand, } from '../../../../client/interpreter/configuration/interpreterSelector/commands/setInterpreter'; @@ -29,7 +38,7 @@ import { IInterpreterSelector, IPythonPathUpdaterServiceManager, } from '../../../../client/interpreter/configuration/types'; -import { PythonEnvironment } from '../../../../client/pythonEnvironments/info'; +import { EnvironmentType, PythonEnvironment } from '../../../../client/pythonEnvironments/info'; import { EventName } from '../../../../client/telemetry/constants'; import * as Telemetry from '../../../../client/telemetry'; import { MockWorkspaceConfiguration } from '../../../mocks/mockWorkspaceConfig'; @@ -100,32 +109,33 @@ suite('Set Interpreter Command', () => { const interpreterPath = 'path/to/interpreter'; const item: IInterpreterQuickPickItem = { - description: '', + description: interpreterPath, detail: '', label: 'This is the selected Python path', path: interpreterPath, - interpreter: { path: interpreterPath } as PythonEnvironment, + interpreter: { path: interpreterPath, envType: EnvironmentType.Conda } as PythonEnvironment, }; const defaultInterpreterPath = 'defaultInterpreterPath'; const defaultInterpreterPathSuggestion = { label: `${Octicons.Gear} ${InterpreterQuickPickList.defaultInterpreterPath.label()}`, - detail: defaultInterpreterPath, + description: defaultInterpreterPath, path: defaultInterpreterPath, alwaysShow: true, }; const refreshedItem: IInterpreterQuickPickItem = { - description: '', + description: interpreterPath, detail: '', label: 'Refreshed path', path: interpreterPath, - interpreter: { path: interpreterPath } as PythonEnvironment, + interpreter: { path: interpreterPath, envType: EnvironmentType.Conda } as PythonEnvironment, }; const expectedEnterInterpreterPathSuggestion = { label: `${Octicons.Add} ${InterpreterQuickPickList.enterPath.label()}`, alwaysShow: true, }; const currentPythonPath = 'python'; + const workspacePath = 'path/to/workspace'; setup(() => { _enterOrBrowseInterpreterPath = sinon.stub( @@ -141,7 +151,13 @@ suite('Set Interpreter Command', () => { properties, }; }); - interpreterSelector.setup((i) => i.getSuggestions(TypeMoq.It.isAny())).returns(() => [item]); + interpreterSelector + .setup((i) => i.getSuggestions(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => [item]); + interpreterSelector + .setup((i) => i.getRecommendedSuggestion(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => item); + pythonSettings.setup((p) => p.pythonPath).returns(() => currentPythonPath); pythonSettings.setup((p) => p.defaultInterpreterPath).returns(() => defaultInterpreterPath); @@ -154,6 +170,10 @@ suite('Set Interpreter Command', () => { }), ); + workspace + .setup((w) => w.getWorkspaceFolder(TypeMoq.It.isAny())) + .returns(() => (({ uri: { fsPath: workspacePath } } as unknown) as WorkspaceFolder)); + setInterpreterCommand = new SetInterpreterCommand( appShell.object, new PathUtils(false), @@ -190,8 +210,13 @@ suite('Set Interpreter Command', () => { const multiStepInput = TypeMoq.Mock.ofType>(); const recommended = cloneDeep(item); recommended.label = `${Octicons.Star} ${item.label}`; - recommended.description = Common.recommended(); - const suggestions = [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, recommended]; + recommended.description = interpreterPath; + const suggestions = [ + expectedEnterInterpreterPathSuggestion, + defaultInterpreterPathSuggestion, + { kind: QuickPickItemKind.Separator, label: EnvGroups.Recommended }, + recommended, + ]; const expectedParameters: IQuickPickParameters = { placeholder: InterpreterQuickPickList.quickPickListPlaceholder().format(currentPythonPath), items: suggestions, @@ -220,6 +245,110 @@ suite('Set Interpreter Command', () => { assert.deepStrictEqual(actualParameters, expectedParameters, 'Params not equal'); }); + test('Items displayed should be grouped if no refresh is going on', async () => { + const state: InterpreterStateArgs = { path: 'some path', workspace: undefined }; + const multiStepInput = TypeMoq.Mock.ofType>(); + const interpreterItems: IInterpreterQuickPickItem[] = [ + { + description: `${workspacePath}/interpreterPath1`, + detail: '', + label: 'This is the selected Python path', + path: `${workspacePath}/interpreterPath1`, + interpreter: { + path: `${workspacePath}/interpreterPath1`, + envType: EnvironmentType.Venv, + } as PythonEnvironment, + }, + { + description: 'interpreterPath2', + detail: '', + label: 'This is the selected Python path', + path: 'interpreterPath2', + interpreter: { + path: 'interpreterPath2', + envType: EnvironmentType.VirtualEnvWrapper, + } as PythonEnvironment, + }, + { + description: 'interpreterPath3', + detail: '', + label: 'This is the selected Python path', + path: 'interpreterPath3', + interpreter: { + path: 'interpreterPath3', + envType: EnvironmentType.VirtualEnvWrapper, + } as PythonEnvironment, + }, + { + description: 'interpreterPath4', + detail: '', + label: 'This is the selected Python path', + path: 'interpreterPath4', + interpreter: { path: 'interpreterPath4', envType: EnvironmentType.Conda } as PythonEnvironment, + }, + item, + { + description: 'interpreterPath5', + detail: '', + label: 'This is the selected Python path', + path: 'interpreterPath5', + interpreter: { path: 'interpreterPath5', envType: EnvironmentType.Global } as PythonEnvironment, + }, + ]; + interpreterSelector.reset(); + interpreterSelector + .setup((i) => i.getSuggestions(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => interpreterItems); + interpreterSelector + .setup((i) => i.getRecommendedSuggestion(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => item); + const recommended = cloneDeep(item); + recommended.label = `${Octicons.Star} ${item.label}`; + recommended.description = interpreterPath; + const suggestions = [ + expectedEnterInterpreterPathSuggestion, + defaultInterpreterPathSuggestion, + { kind: QuickPickItemKind.Separator, label: EnvGroups.Recommended }, + recommended, + { label: EnvGroups.Workspace, kind: QuickPickItemKind.Separator }, + interpreterItems[0], + { label: EnvGroups.VirtualEnvWrapper, kind: QuickPickItemKind.Separator }, + interpreterItems[1], + interpreterItems[2], + { label: EnvGroups.Conda, kind: QuickPickItemKind.Separator }, + interpreterItems[3], + item, + { label: EnvGroups.Global, kind: QuickPickItemKind.Separator }, + interpreterItems[5], + ]; + const expectedParameters: IQuickPickParameters = { + placeholder: InterpreterQuickPickList.quickPickListPlaceholder().format(currentPythonPath), + items: suggestions, + activeItem: recommended, + matchOnDetail: true, + matchOnDescription: true, + title: InterpreterQuickPickList.browsePath.openButtonLabel(), + sortByLabel: true, + keepScrollPosition: true, + }; + let actualParameters: IQuickPickParameters | undefined; + multiStepInput + .setup((i) => i.showQuickPick(TypeMoq.It.isAny())) + .callback((options) => { + actualParameters = options; + }) + .returns(() => Promise.resolve((undefined as unknown) as QuickPickItem)); + + await setInterpreterCommand._pickInterpreter(multiStepInput.object, state); + + expect(actualParameters).to.not.equal(undefined, 'Parameters not set'); + const refreshButtonCallback = actualParameters!.customButtonSetup?.callback; + expect(refreshButtonCallback).to.not.equal(undefined, 'Callback not set'); + delete actualParameters!.customButtonSetup; + delete actualParameters!.onChangeItem; + assert.deepStrictEqual(actualParameters?.items, expectedParameters.items, 'Params not equal'); + }); + test('If system variables are used in the default interpreter path, make sure they are resolved when the path is displayed', async () => { // Create a SetInterpreterCommand instance from scratch, and use a different defaultInterpreterPath from the rest of the tests. const workspaceDefaultInterpreterPath = '${workspaceFolder}/defaultInterpreterPath'; @@ -264,16 +393,17 @@ suite('Set Interpreter Command', () => { const multiStepInput = TypeMoq.Mock.ofType>(); const recommended = cloneDeep(item); recommended.label = `${Octicons.Star} ${item.label}`; - recommended.description = Common.recommended(); + recommended.description = interpreterPath; + const separator = { label: EnvGroups.Recommended, kind: QuickPickItemKind.Separator }; const defaultPathSuggestion = { label: `${Octicons.Gear} ${InterpreterQuickPickList.defaultInterpreterPath.label()}`, - detail: expandedDetail, + description: expandedDetail, path: expandedPath, alwaysShow: true, }; - const suggestions = [expectedEnterInterpreterPathSuggestion, defaultPathSuggestion, recommended]; + const suggestions = [expectedEnterInterpreterPathSuggestion, defaultPathSuggestion, separator, recommended]; const expectedParameters: IQuickPickParameters = { placeholder: InterpreterQuickPickList.quickPickListPlaceholder().format(currentPythonPath), items: suggestions, @@ -326,7 +456,7 @@ suite('Set Interpreter Command', () => { verify(interpreterService.triggerRefresh()).once(); }); - test('If an event to update quickpick is received, the quickpick is updated accordingly', async () => { + test('Events to update quickpick updates the quickpick accordingly', async () => { const state: InterpreterStateArgs = { path: 'some path', workspace: undefined }; const multiStepInput = TypeMoq.Mock.ofType>(); let actualParameters: IQuickPickParameters | undefined; @@ -347,13 +477,14 @@ suite('Set Interpreter Command', () => { expect(onChangedCallback).to.not.equal(undefined, 'Callback not set'); multiStepInput.verifyAll(); + const separator = { label: EnvGroups.Conda, kind: QuickPickItemKind.Separator }; const quickPick = { - items: [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, item], + items: [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, separator, item], activeItems: [item], busy: false, }; interpreterSelector - .setup((i) => i.suggestionToQuickPickItem(TypeMoq.It.isAny(), undefined)) + .setup((i) => i.suggestionToQuickPickItem(TypeMoq.It.isAny(), undefined, false)) .returns(() => refreshedItem); const changeEvent: PythonEnvironmentsChangedEvent = { @@ -365,7 +496,12 @@ suite('Set Interpreter Command', () => { assert.deepStrictEqual( quickPick, { - items: [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, refreshedItem], + items: [ + expectedEnterInterpreterPathSuggestion, + defaultInterpreterPathSuggestion, + separator, + refreshedItem, + ], activeItems: [refreshedItem], busy: true, }, @@ -373,7 +509,18 @@ suite('Set Interpreter Command', () => { ); // Refresh is over; set the final states accordingly - interpreterSelector.setup((i) => i.getSuggestions(TypeMoq.It.isAny())).returns(() => [refreshedItem]); + interpreterSelector.reset(); + interpreterSelector + .setup((i) => i.getSuggestions(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => [refreshedItem]); + interpreterSelector + .setup((i) => i.getRecommendedSuggestion(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => refreshedItem); + interpreterSelector + .setup((i) => + i.suggestionToQuickPickItem(TypeMoq.It.isValue(refreshedItem.interpreter), undefined, false), + ) + .returns(() => refreshedItem); when(interpreterService.refreshPromise).thenReturn(undefined); refreshPromiseDeferred.resolve(); @@ -381,18 +528,71 @@ suite('Set Interpreter Command', () => { const recommended = cloneDeep(refreshedItem); recommended.label = `${Octicons.Star} ${refreshedItem.label}`; - recommended.description = Common.recommended(); + recommended.description = `${interpreterPath} - ${Common.recommended()}`; assert.deepStrictEqual( quickPick, { // Refresh has finished, so recommend an interpreter - items: [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, recommended], + items: [ + expectedEnterInterpreterPathSuggestion, + defaultInterpreterPathSuggestion, + separator, + recommended, + ], activeItems: [recommended], // Refresh has finished, so quickpick busy indicator should go away busy: false, }, 'Quickpick not updated correctly after refresh has finished', ); + + const newItem = { + description: `${workspacePath}/interpreterPath1`, + detail: '', + label: 'This is the selected Python path', + path: `${workspacePath}/interpreterPath1`, + interpreter: { + path: `${workspacePath}/interpreterPath1`, + envType: EnvironmentType.Venv, + } as PythonEnvironment, + }; + const changeEvent2: PythonEnvironmentsChangedEvent = { + old: undefined, + new: newItem.interpreter, + }; + interpreterSelector.reset(); + interpreterSelector + .setup((i) => i.getSuggestions(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => [refreshedItem, newItem]); + interpreterSelector + .setup((i) => i.getRecommendedSuggestion(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => refreshedItem); + interpreterSelector + .setup((i) => + i.suggestionToQuickPickItem(TypeMoq.It.isValue(refreshedItem.interpreter), undefined, false), + ) + .returns(() => refreshedItem); + interpreterSelector + .setup((i) => i.suggestionToQuickPickItem(TypeMoq.It.isValue(newItem.interpreter), undefined, false)) + .returns(() => newItem); + await onChangedCallback!(changeEvent2, (quickPick as unknown) as QuickPick); // Invoke callback, meaning that the items are supposed to change. + + assert.deepStrictEqual( + quickPick, + { + items: [ + expectedEnterInterpreterPathSuggestion, + defaultInterpreterPathSuggestion, + separator, + recommended, + { label: EnvGroups.Workspace, kind: QuickPickItemKind.Separator }, + newItem, + ], + activeItems: [recommended], + busy: false, + }, + 'Quickpick not updated correctly', + ); }); test('If an item is selected, update state and return', async () => { diff --git a/src/test/interpreters/autoSelection/index.unit.test.ts b/src/test/interpreters/autoSelection/index.unit.test.ts index 2f4c6eabd575..c9636ec176db 100644 --- a/src/test/interpreters/autoSelection/index.unit.test.ts +++ b/src/test/interpreters/autoSelection/index.unit.test.ts @@ -88,6 +88,11 @@ suite('Interpreters - Auto Selection', () => { envPath: path.join('some', 'pipenv', 'env'), version: { major: 3, minor: 10, patch: 0 }, } as PythonEnvironment, + { + envType: EnvironmentType.Pyenv, + envPath: path.join('some', 'pipenv', 'env'), + version: { major: 3, minor: 5, patch: 0 }, + } as PythonEnvironment, ]), ); diff --git a/src/test/interpreters/display.unit.test.ts b/src/test/interpreters/display.unit.test.ts index 9ee927a7262c..9561aad47836 100644 --- a/src/test/interpreters/display.unit.test.ts +++ b/src/test/interpreters/display.unit.test.ts @@ -36,7 +36,7 @@ import { EnvironmentType, PythonEnvironment } from '../../client/pythonEnvironme const info: PythonEnvironment = { architecture: Architecture.Unknown, companyDisplayName: '', - displayName: '', + detailedDisplayName: '', envName: '', path: '', envType: EnvironmentType.Unknown, @@ -171,7 +171,7 @@ suite('Interpreters Display', () => { const workspaceFolder = Uri.file('workspace'); const activeInterpreter: PythonEnvironment = { ...info, - displayName: 'Dummy_Display_Name', + detailedDisplayName: 'Dummy_Display_Name', envType: EnvironmentType.Unknown, path: path.join('user', 'development', 'env', 'bin', 'python'), }; @@ -187,7 +187,7 @@ suite('Interpreters Display', () => { if (inExperiment === InterpreterStatusBarPosition.Unpinned) { languageStatusItem.verify( - (s) => (s.text = TypeMoq.It.isValue(activeInterpreter.displayName)!), + (s) => (s.text = TypeMoq.It.isValue(activeInterpreter.detailedDisplayName)!), TypeMoq.Times.once(), ); languageStatusItem.verify( @@ -196,7 +196,7 @@ suite('Interpreters Display', () => { ); } else { statusBar.verify( - (s) => (s.text = TypeMoq.It.isValue(activeInterpreter.displayName)!), + (s) => (s.text = TypeMoq.It.isValue(activeInterpreter.detailedDisplayName)!), TypeMoq.Times.once(), ); statusBar.verify( @@ -210,7 +210,7 @@ suite('Interpreters Display', () => { const workspaceFolder = Uri.file('workspace'); const activeInterpreter: PythonEnvironment = { ...info, - displayName: 'Dummy_Display_Name', + detailedDisplayName: 'Dummy_Display_Name', envType: EnvironmentType.Unknown, path: path.join('user', 'development', 'env', 'bin', 'python'), }; @@ -237,7 +237,7 @@ suite('Interpreters Display', () => { setupWorkspaceFolder(resource, workspaceFolder); const pythonInterpreter: PythonEnvironment = ({ - displayName, + detailedDisplayName: displayName, path: pythonPath, } as any) as PythonEnvironment; interpreterService @@ -299,7 +299,7 @@ suite('Interpreters Display', () => { const pythonPath = path.join('user', 'development', 'env', 'bin', 'python'); const activeInterpreter: PythonEnvironment = { ...info, - displayName: 'Dummy_Display_Name', + detailedDisplayName: 'Dummy_Display_Name', envType: EnvironmentType.Unknown, companyDisplayName: 'Company Name', path: pythonPath, @@ -322,7 +322,7 @@ suite('Interpreters Display', () => { interpreterService.verifyAll(); if (inExperiment === InterpreterStatusBarPosition.Unpinned) { languageStatusItem.verify( - (s) => (s.text = TypeMoq.It.isValue(activeInterpreter.displayName)!), + (s) => (s.text = TypeMoq.It.isValue(activeInterpreter.detailedDisplayName)!), TypeMoq.Times.once(), ); languageStatusItem.verify( @@ -331,7 +331,7 @@ suite('Interpreters Display', () => { ); } else { statusBar.verify( - (s) => (s.text = TypeMoq.It.isValue(activeInterpreter.displayName)!), + (s) => (s.text = TypeMoq.It.isValue(activeInterpreter.detailedDisplayName)!), TypeMoq.Times.once(), ); statusBar.verify((s) => (s.tooltip = TypeMoq.It.isValue(pythonPath)!), TypeMoq.Times.atLeastOnce()); @@ -348,7 +348,7 @@ suite('Interpreters Display', () => { const workspaceFolder = Uri.file('workspace'); const activeInterpreter: PythonEnvironment = { ...info, - displayName: 'Dummy_Display_Name', + detailedDisplayName: 'Dummy_Display_Name', envType: EnvironmentType.Unknown, path: path.join('user', 'development', 'env', 'bin', 'python'), }; diff --git a/src/test/mocks/vsc/index.ts b/src/test/mocks/vsc/index.ts index bdffedc2ead3..fcef8af923d1 100644 --- a/src/test/mocks/vsc/index.ts +++ b/src/test/mocks/vsc/index.ts @@ -35,6 +35,11 @@ export enum LanguageStatusSeverity { Error = 2, } +export enum QuickPickItemKind { + Separator = -1, + Default = 1, +} + export class Disposable { constructor(private callOnDispose: () => void) {} diff --git a/src/test/pythonEnvironments/base/info/env.unit.test.ts b/src/test/pythonEnvironments/base/info/env.unit.test.ts index e29d054a4480..6b82a3292adf 100644 --- a/src/test/pythonEnvironments/base/info/env.unit.test.ts +++ b/src/test/pythonEnvironments/base/info/env.unit.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { Architecture } from '../../../../client/common/utils/platform'; import { parseVersionInfo } from '../../../../client/common/utils/version'; import { PythonEnvInfo, PythonDistroInfo, PythonEnvKind } from '../../../../client/pythonEnvironments/base/info'; -import { getEnvDisplayString } from '../../../../client/pythonEnvironments/base/info/env'; +import { setEnvDisplayString } from '../../../../client/pythonEnvironments/base/info/env'; import { createLocatedEnv } from '../common'; suite('pyenvs info - getEnvDisplayString()', () => { @@ -41,26 +41,27 @@ suite('pyenvs info - getEnvDisplayString()', () => { env.display = info.display; return env; } - const tests: [PythonEnvInfo, string][] = [ - [getEnv({}), 'Python'], - [getEnv({ version, arch, name, kind, distro }), "Python 3.8.1 64-bit ('my-env': venv)"], + const tests: [PythonEnvInfo, string, string][] = [ + [getEnv({}), 'Python', 'Python'], + [getEnv({ version, arch, name, kind, distro }), "Python 3.8.1 ('my-env')", "Python 3.8.1 ('my-env': venv)"], // without "suffix" info - [getEnv({ version }), 'Python 3.8.1'], - [getEnv({ arch }), 'Python 64-bit'], - [getEnv({ version, arch }), 'Python 3.8.1 64-bit'], + [getEnv({ version }), 'Python 3.8.1', 'Python 3.8.1'], + [getEnv({ arch }), 'Python 64-bit', 'Python 64-bit'], + [getEnv({ version, arch }), 'Python 3.8.1 64-bit', 'Python 3.8.1 64-bit'], // with "suffix" info - [getEnv({ name }), "Python ('my-env')"], - [getEnv({ kind }), 'Python (venv)'], - [getEnv({ name, kind }), "Python ('my-env': venv)"], + [getEnv({ name }), "Python ('my-env')", "Python ('my-env')"], + [getEnv({ kind }), 'Python', 'Python (venv)'], + [getEnv({ name, kind }), "Python ('my-env')", "Python ('my-env': venv)"], // env.location is ignored. - [getEnv({ location }), 'Python'], - [getEnv({ name, location }), "Python ('my-env')"], + [getEnv({ location }), 'Python', 'Python'], + [getEnv({ name, location }), "Python ('my-env')", "Python ('my-env')"], ]; - tests.forEach(([env, expected]) => { - test(`"${expected}"`, () => { - const result = getEnvDisplayString(env); + tests.forEach(([env, expectedDisplay, expectedDetailedDisplay]) => { + test(`"${expectedDisplay}"`, () => { + setEnvDisplayString(env); - assert.strictEqual(result, expected); + assert.equal(env.display, expectedDisplay); + assert.equal(env.detailedDisplayName, expectedDetailedDisplay); }); }); }); diff --git a/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts b/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts index ddc0510d03fa..3d6dcd528912 100644 --- a/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts +++ b/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts @@ -57,6 +57,7 @@ suite('Python envs locator - Environments Resolver', () => { updatedEnv.executable.sysPrefix = 'path'; updatedEnv.arch = Architecture.x64; updatedEnv.display = expectedDisplay; + updatedEnv.detailedDisplayName = expectedDisplay; return updatedEnv; } @@ -79,6 +80,7 @@ suite('Python envs locator - Environments Resolver', () => { mtime: -1, }, display, + detailedDisplayName: display, version, arch: Architecture.Unknown, distro: { org: '' }, @@ -151,7 +153,7 @@ suite('Python envs locator - Environments Resolver', () => { const envs = await getEnvsWithUpdates(iterator); assertEnvsEqual(envs, [ - createExpectedEnvInfo(resolvedEnvReturnedByBasicResolver, "Python 3.8.3 64-bit ('win1': venv)"), + createExpectedEnvInfo(resolvedEnvReturnedByBasicResolver, "Python 3.8.3 ('win1': venv)"), ]); }); @@ -215,10 +217,7 @@ suite('Python envs locator - Environments Resolver', () => { // Assert assertEnvsEqual(envs, [ - createExpectedEnvInfo( - resolvedUpdatedEnvReturnedByBasicResolver, - "Python 3.8.3 64-bit ('win1': poetry)", - ), + createExpectedEnvInfo(resolvedUpdatedEnvReturnedByBasicResolver, "Python 3.8.3 ('win1': poetry)"), ]); didUpdate.dispose(); }); @@ -276,7 +275,7 @@ suite('Python envs locator - Environments Resolver', () => { assertEnvEqual( expected, - createExpectedEnvInfo(resolvedEnvReturnedByBasicResolver, "Python 3.8.3 64-bit ('win1': venv)"), + createExpectedEnvInfo(resolvedEnvReturnedByBasicResolver, "Python 3.8.3 ('win1': venv)"), ); }); diff --git a/src/test/pythonEnvironments/base/locators/composite/resolverUtils.unit.test.ts b/src/test/pythonEnvironments/base/locators/composite/resolverUtils.unit.test.ts index 556072dd0804..bd12004e31ef 100644 --- a/src/test/pythonEnvironments/base/locators/composite/resolverUtils.unit.test.ts +++ b/src/test/pythonEnvironments/base/locators/composite/resolverUtils.unit.test.ts @@ -14,7 +14,7 @@ import { PythonVersion, UNKNOWN_PYTHON_VERSION, } from '../../../../../client/pythonEnvironments/base/info'; -import { buildEnvInfo, getEnvDisplayString } from '../../../../../client/pythonEnvironments/base/info/env'; +import { buildEnvInfo, setEnvDisplayString } from '../../../../../client/pythonEnvironments/base/info/env'; import { InterpreterInformation } from '../../../../../client/pythonEnvironments/base/info/interpreter'; import { parseVersion } from '../../../../../client/pythonEnvironments/base/info/pythonVersion'; import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants'; @@ -61,7 +61,7 @@ suite('Resolver Utils', () => { }); envInfo.location = path.join(testPyenvVersionsDir, '3.9.0'); envInfo.name = '3.9.0'; - envInfo.display = getEnvDisplayString(envInfo); + setEnvDisplayString(envInfo); return envInfo; } @@ -125,7 +125,7 @@ suite('Resolver Utils', () => { source: [PythonEnvSource.PathEnvVar], ...createExpectedInterpreterInfo(python38path), }; - expected.display = getEnvDisplayString(expected); + setEnvDisplayString(expected); const actual = await resolveBasicEnv({ executablePath: python38path, @@ -147,7 +147,7 @@ suite('Resolver Utils', () => { source: [PythonEnvSource.PathEnvVar], ...createExpectedInterpreterInfo(python38path), }; - expected.display = getEnvDisplayString(expected); + setEnvDisplayString(expected); const actual = await resolveBasicEnv({ executablePath: python38path, @@ -183,7 +183,7 @@ suite('Resolver Utils', () => { fileInfo: undefined, name: 'base', }); - info.display = getEnvDisplayString(info); + setEnvDisplayString(info); return info; } function createSimpleEnvInfo( @@ -210,7 +210,7 @@ suite('Resolver Utils', () => { searchLocation: undefined, source: [], }; - info.display = getEnvDisplayString(info); + setEnvDisplayString(info); return info; } @@ -307,7 +307,7 @@ suite('Resolver Utils', () => { searchLocation: Uri.file(path.dirname(location)), source: [], }; - info.display = getEnvDisplayString(info); + setEnvDisplayString(info); return info; } @@ -362,7 +362,7 @@ suite('Resolver Utils', () => { searchLocation: undefined, source: [], }; - info.display = getEnvDisplayString(info); + setEnvDisplayString(info); return info; } @@ -550,7 +550,7 @@ suite('Resolver Utils', () => { org: 'PythonCore', source: [PythonEnvSource.WindowsRegistry], }); - expected.display = getEnvDisplayString(expected); + setEnvDisplayString(expected); expected.distro.defaultDisplayName = 'Python 3.9 (64-bit)'; assertEnvEqual(actual, expected); }); @@ -570,7 +570,7 @@ suite('Resolver Utils', () => { org: 'PythonCodingPack', // Provided by registry source: [PythonEnvSource.WindowsRegistry, PythonEnvSource.PathEnvVar], }); - expected.display = getEnvDisplayString(expected); + setEnvDisplayString(expected); expected.distro.defaultDisplayName = 'Python 3.8 (32-bit)'; assertEnvEqual(actual, expected); }); @@ -597,7 +597,7 @@ suite('Resolver Utils', () => { name: 'conda3', source: [PythonEnvSource.WindowsRegistry], }); - expected.display = getEnvDisplayString(expected); + setEnvDisplayString(expected); expected.distro.defaultDisplayName = 'Anaconda py38_4.8.3'; assertEnvEqual(actual, expected); }); diff --git a/src/test/vscode-mock.ts b/src/test/vscode-mock.ts index 53a255a184d4..c103f82e5455 100644 --- a/src/test/vscode-mock.ts +++ b/src/test/vscode-mock.ts @@ -107,6 +107,7 @@ mockedVSCode.FileType = vscodeMocks.FileType; mockedVSCode.UIKind = vscodeMocks.UIKind; mockedVSCode.FileSystemError = vscodeMocks.vscMockExtHostedTypes.FileSystemError; mockedVSCode.LanguageStatusSeverity = vscodeMocks.LanguageStatusSeverity; +mockedVSCode.QuickPickItemKind = vscodeMocks.QuickPickItemKind; (mockedVSCode as any).NotebookCellKind = vscodeMocks.vscMockExtHostedTypes.NotebookCellKind; (mockedVSCode as any).CellOutputKind = vscodeMocks.vscMockExtHostedTypes.CellOutputKind; (mockedVSCode as any).NotebookCellRunState = vscodeMocks.vscMockExtHostedTypes.NotebookCellRunState;