diff --git a/news/1 Enhancements/17043.md b/news/1 Enhancements/17043.md new file mode 100644 index 000000000000..35c4cc0e3c19 --- /dev/null +++ b/news/1 Enhancements/17043.md @@ -0,0 +1 @@ +Add support for dynamic updates in interpreter list. diff --git a/package.nls.json b/package.nls.json index edb7e74d2dce..d3c178e624ac 100644 --- a/package.nls.json +++ b/package.nls.json @@ -52,7 +52,7 @@ "Interpreters.RefreshingInterpreters": "Refreshing Python Interpreters", "Interpreters.entireWorkspace": "Entire workspace", "Interpreters.pythonInterpreterPath": "Python interpreter path: {0}", - "Interpreters.LoadingInterpreters": "Loading Python Interpreters", + "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}", diff --git a/package.nls.nl.json b/package.nls.nl.json index 6463e22b4644..8a28c7d1a816 100644 --- a/package.nls.nl.json +++ b/package.nls.nl.json @@ -26,7 +26,6 @@ "LanguageService.lsFailedToDownload": "We zijn een probleem tegengekomen bij het downloaden van de language server. Aan het terugschakelen naar het alternatief, Jedi. Bekijk het weergavepaneel voor details.", "LanguageService.lsFailedToExtract": "We zijn een probleem tegengekomen bij het uitpakken van de language server. Aan het terugschakelen naar het alternatief, Jedi. Bekijk het weergavepaneel voor details.", "Interpreters.RefreshingInterpreters": "Python-Interpreters verversen", - "Interpreters.LoadingInterpreters": "Python-Interpreters laden", "Linter.InstalledButNotEnabled": "Linter {0} is geinstalleerd maar niet ingeschakeld.", "Linter.replaceWithSelectedLinter": "Meerdere linters zijn ingeschakeld in de instellingen. Vervangen met '{0}'?", "diagnostics.warnSourceMaps": "Bronkaartondersteuning is ingeschakeld in de Python-extensie, dit zal een ongunstige impact hebben op de uitvoering van de extensie.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 1d2864a2469e..e5a143452f92 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -51,7 +51,6 @@ "Interpreters.RefreshingInterpreters": "正在刷新 Python 解释器", "Interpreters.entireWorkspace": "完整工作区", "Interpreters.pythonInterpreterPath": "Python 解释器路径: {0}", - "Interpreters.LoadingInterpreters": "正在加载 Python 解释器", "Interpreters.condaInheritEnvMessage": "您正在使用 conda 环境,如果您在集成终端中遇到相关问题,建议您允许 Python 扩展将用户设置中的 \"terminal.integrated.inheritEnv\" 改为 false。", "Logging.CurrentWorkingDirectory": "cwd:", "InterpreterQuickPickList.quickPickListPlaceholder": "当前: {0}", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 03529ff4a997..936edb6fdff2 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -34,7 +34,6 @@ "LanguageService.lsFailedToExtract": "擷取語言伺服器時遇到問題。改回使用替代方案 \"Jedi\"。請檢查 Python 輸出面板以取得更多資訊。", "Experiments.inGroup": "使用者屬於 \"{0}\" 實驗性群組", "Interpreters.RefreshingInterpreters": "正在重新整理 Python 解譯器", - "Interpreters.LoadingInterpreters": "正在載入 Python 解譯器", "Interpreters.condaInheritEnvMessage": "我們發覺到您在使用 conda 環境。如果你在整合式終端器中使用這個環境時遇到問題,建議您讓 Python 延伸模組變更使用者設定中的 \"terminal.integrated.inheritEnv\" 為 false。", "Logging.CurrentWorkingDirectory": "cwd:", "Common.doNotShowAgain": "不再顯示", diff --git a/src/client/common/utils/async.ts b/src/client/common/utils/async.ts index 788baf87a5e8..76f15ec511c4 100644 --- a/src/client/common/utils/async.ts +++ b/src/client/common/utils/async.ts @@ -77,7 +77,7 @@ class DeferredImpl implements Deferred { } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -export function createDeferred(scope: any = null): Deferred { +export function createDeferred(scope: any = null): Deferred { return new DeferredImpl(scope); } diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index e600eaee0c22..37d2e0408b6a 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -261,7 +261,7 @@ export namespace Experiments { export const optedOutOf = localize('Experiments.optedOutOf', "User opted out of experiment group '{0}'"); } export namespace Interpreters { - export const loading = localize('Interpreters.LoadingInterpreters', 'Loading Python Interpreters'); + export const discovering = localize('Interpreters.DiscoveringInterpreters', 'Discovering Python Interpreters'); export const refreshing = localize('Interpreters.RefreshingInterpreters', 'Refreshing Python Interpreters'); export const condaInheritEnvMessage = localize( 'Interpreters.condaInheritEnvMessage', diff --git a/src/client/common/utils/multiStepInput.ts b/src/client/common/utils/multiStepInput.ts index f327b8eb97ff..2a2b25fbb0e6 100644 --- a/src/client/common/utils/multiStepInput.ts +++ b/src/client/common/utils/multiStepInput.ts @@ -6,7 +6,7 @@ 'use strict'; import { inject, injectable } from 'inversify'; -import { Disposable, QuickInput, QuickInputButton, QuickInputButtons, QuickPick, QuickPickItem } from 'vscode'; +import { Disposable, QuickInput, QuickInputButton, QuickInputButtons, QuickPick, QuickPickItem, Event } from 'vscode'; import { IApplicationShell } from '../application/types'; // Borrowed from https://github.com/Microsoft/vscode-extension-samples/blob/master/quickinput-sample/src/multiStepInput.ts @@ -39,7 +39,8 @@ type QuickInputButtonSetup = { */ callback: buttonCallbackType; }; -export interface IQuickPickParameters { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface IQuickPickParameters { title?: string; step?: number; totalSteps?: number; @@ -50,7 +51,13 @@ export interface IQuickPickParameters { customButtonSetup?: QuickInputButtonSetup; matchOnDescription?: boolean; matchOnDetail?: boolean; + keepScrollPosition?: boolean; + sortByLabel?: boolean; acceptFilterBoxTextAsSelection?: boolean; + onChangeItem?: { + callback: (event: E, quickPick: QuickPick) => Promise; + event: Event; + }; } interface InputBoxParameters { @@ -110,6 +117,9 @@ export class MultiStepInput implements IMultiStepInput { matchOnDescription, matchOnDetail, acceptFilterBoxTextAsSelection, + onChangeItem, + keepScrollPosition, + sortByLabel, }: P): Promise> { const disposables: Disposable[] = []; try { @@ -117,6 +127,8 @@ export class MultiStepInput implements IMultiStepInput { const input = this.shell.createQuickPick(); input.title = title; input.step = step; + input.keepScrollPosition = keepScrollPosition; + input.sortByLabel = sortByLabel || false; input.totalSteps = totalSteps; input.placeholder = placeholder; input.ignoreFocusOut = true; @@ -160,6 +172,9 @@ export class MultiStepInput implements IMultiStepInput { this.current.dispose(); } this.current = input; + if (onChangeItem) { + disposables.push(onChangeItem.event((e) => onChangeItem.callback(e, input))); + } this.current.show(); }); } finally { diff --git a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts index d692f92137ed..f11708b5019f 100644 --- a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts +++ b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts @@ -6,9 +6,10 @@ import { inject, injectable } from 'inversify'; import { cloneDeep } from 'lodash'; import * as path from 'path'; -import { QuickPickItem } from 'vscode'; +import { QuickPick, QuickPickItem } from 'vscode'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../../../common/application/types'; import { Commands, Octicons } from '../../../../common/constants'; +import { arePathsSame } 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 +23,7 @@ import { import { REFRESH_BUTTON_ICON } from '../../../../debugger/extension/attachQuickPick/types'; import { captureTelemetry, sendTelemetryEvent } from '../../../../telemetry'; import { EventName } from '../../../../telemetry/constants'; +import { IInterpreterService, PythonEnvironmentsChangedEvent } from '../../../contracts'; import { IInterpreterQuickPickItem, IInterpreterSelector, @@ -35,8 +37,21 @@ const untildify = require('untildify'); export type InterpreterStateArgs = { path?: string; workspace: Resource }; type QuickPickType = IInterpreterQuickPickItem | ISpecialQuickPickItem; +function isInterpreterQuickPickItem(item: QuickPickType): item is IInterpreterQuickPickItem { + return 'interpreter' in item; +} + +function isSpecialQuickPickItem(item: QuickPickType): item is ISpecialQuickPickItem { + return 'alwaysShow' in item; +} + @injectable() export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { + private readonly manualEntrySuggestion: ISpecialQuickPickItem = { + label: `${Octicons.Add} ${InterpreterQuickPickList.enterPath.label()}`, + alwaysShow: true, + }; + constructor( @inject(IApplicationShell) applicationShell: IApplicationShell, @inject(IPathUtils) private readonly pathUtils: IPathUtils, @@ -48,6 +63,7 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { @inject(IPlatformService) private readonly platformService: IPlatformService, @inject(IInterpreterSelector) private readonly interpreterSelector: IInterpreterSelector, @inject(IWorkspaceService) workspaceService: IWorkspaceService, + @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, ) { super(pythonPathUpdaterService, commandManager, applicationShell, workspaceService); } @@ -62,98 +78,52 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { input: IMultiStepInput, state: InterpreterStateArgs, ): Promise> { - const suggestions: QuickPickType[] = []; - - const manualEntrySuggestion: ISpecialQuickPickItem = { - label: `${Octicons.Add} ${InterpreterQuickPickList.enterPath.label()}`, - alwaysShow: true, - }; - suggestions.push(manualEntrySuggestion); - - const config = this.workspaceService.getConfiguration('python', state.workspace); - const defaultInterpreterPathValue = config.get('defaultInterpreterPath'); - let defaultInterpreterPathSuggestion: ISpecialQuickPickItem | undefined; - if (defaultInterpreterPathValue && defaultInterpreterPathValue !== 'python') { - defaultInterpreterPathSuggestion = { - label: `${Octicons.Gear} ${InterpreterQuickPickList.defaultInterpreterPath.label()}`, - detail: this.pathUtils.getDisplayName( - defaultInterpreterPathValue, - state.workspace ? state.workspace.fsPath : undefined, - ), - path: defaultInterpreterPathValue, - alwaysShow: true, - }; - suggestions.push(defaultInterpreterPathSuggestion); - } - - let interpreterSuggestions = await this.interpreterSelector.getSuggestions(state.workspace); - - if (interpreterSuggestions.length > 0) { - const suggested = interpreterSuggestions.shift(); - if (suggested) { - const starred = cloneDeep(suggested); - starred.label = `${Octicons.Star} ${starred.label}`; - starred.description = Common.recommended(); - interpreterSuggestions.unshift(starred); - } - } - suggestions.push(...interpreterSuggestions); - - const currentPythonPath = this.pathUtils.getDisplayName( + // If the list is refreshing, it's crucial to maintain sorting order at all + // times so that the visible items do not change. + const preserveOrderWhenFiltering = !!this.interpreterService.refreshPromise; + const suggestions = await this.getItems(state.workspace); + state.path = undefined; + const currentInterpreterPathDisplay = this.pathUtils.getDisplayName( this.configurationService.getSettings(state.workspace).pythonPath, state.workspace ? state.workspace.fsPath : undefined, ); - - let activeInterpreter = interpreterSuggestions.filter((i) => i.detail === currentPythonPath); - - state.path = undefined; - const refreshButton = { - iconPath: getIcon(REFRESH_BUTTON_ICON), - tooltip: InterpreterQuickPickList.refreshInterpreterList(), - }; const selection = await input.showQuickPick>({ - placeholder: InterpreterQuickPickList.quickPickListPlaceholder().format(currentPythonPath), + placeholder: InterpreterQuickPickList.quickPickListPlaceholder().format(currentInterpreterPathDisplay), items: suggestions, - activeItem: activeInterpreter.length > 0 ? activeInterpreter[0] : interpreterSuggestions[0], + sortByLabel: !preserveOrderWhenFiltering, + keepScrollPosition: true, + activeItem: this.getActiveItem(state.workspace, suggestions), matchOnDetail: true, matchOnDescription: true, + title: InterpreterQuickPickList.browsePath.openButtonLabel(), customButtonSetup: { - button: refreshButton, - callback: async (quickPick) => { - quickPick.busy = true; - - interpreterSuggestions = await this.interpreterSelector.getSuggestions(state.workspace, true); - if (interpreterSuggestions.length > 0) { - const suggested = interpreterSuggestions.shift(); - if (suggested) { - const starred = cloneDeep(suggested); - starred.label = `${Octicons.Star} ${starred.label}`; - starred.description = Common.recommended(); - interpreterSuggestions.unshift(starred); - } + button: { + iconPath: getIcon(REFRESH_BUTTON_ICON), + tooltip: InterpreterQuickPickList.refreshInterpreterList(), + }, + callback: () => this.interpreterService.triggerRefresh(), + }, + onChangeItem: { + event: this.interpreterService.onDidChangeInterpreters, + callback: async (event: PythonEnvironmentsChangedEvent, quickPick) => { + if (this.interpreterService.refreshPromise) { + quickPick.busy = true; + this.interpreterService.refreshPromise.then(async () => { + quickPick.busy = false; + // Ensure we set a recommended item after refresh has finished. + await this.updateQuickPickItems(quickPick, {}, state.workspace); + }); } - - const newSuggestions = defaultInterpreterPathSuggestion - ? [manualEntrySuggestion, defaultInterpreterPathSuggestion, ...interpreterSuggestions] - : [manualEntrySuggestion, ...interpreterSuggestions]; - - activeInterpreter = interpreterSuggestions.filter((i) => i.detail === currentPythonPath); - - quickPick.items = newSuggestions; - quickPick.activeItems = - activeInterpreter.length > 0 ? [activeInterpreter[0]] : [interpreterSuggestions[0]]; - - quickPick.busy = false; + await this.updateQuickPickItems(quickPick, event, state.workspace); }, }, - title: InterpreterQuickPickList.browsePath.openButtonLabel(), }); if (selection === undefined) { sendTelemetryEvent(EventName.SELECT_INTERPRETER_SELECTED, undefined, { action: 'escape' }); - } else if (selection.label === manualEntrySuggestion.label) { + } else if (selection.label === this.manualEntrySuggestion.label) { sendTelemetryEvent(EventName.SELECT_INTERPRETER_ENTER_OR_FIND); - return this._enterOrBrowseInterpreterPath(input, state, interpreterSuggestions); + return this._enterOrBrowseInterpreterPath(input, state, suggestions); } else { sendTelemetryEvent(EventName.SELECT_INTERPRETER_SELECTED, undefined, { action: 'selected' }); state.path = (selection as IInterpreterQuickPickItem).path; @@ -162,11 +132,132 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { return undefined; } + private async getItems(resource: Resource) { + const suggestions: QuickPickType[] = [this.manualEntrySuggestion]; + const defaultInterpreterPathSuggestion = this.getDefaultInterpreterPathSuggestion(resource); + if (defaultInterpreterPathSuggestion) { + suggestions.push(defaultInterpreterPathSuggestion); + } + const interpreterSuggestions = await this.interpreterSelector.getSuggestions(resource); + await this.setRecommendedItem(interpreterSuggestions, resource); + suggestions.push(...interpreterSuggestions); + return suggestions; + } + + private getActiveItem(resource: Resource, suggestions: QuickPickType[]) { + const currentPythonPath = this.configurationService.getSettings(resource).pythonPath; + const activeInterpreter = suggestions.filter((i) => i.path === currentPythonPath); + if (activeInterpreter.length > 0) { + return activeInterpreter[0]; + } + const firstInterpreterSuggestion = suggestions.find((s) => isInterpreterQuickPickItem(s)); + if (firstInterpreterSuggestion) { + return firstInterpreterSuggestion; + } + return suggestions[0]; + } + + private getDefaultInterpreterPathSuggestion(resource: Resource): ISpecialQuickPickItem | undefined { + const config = this.workspaceService.getConfiguration('python', resource); + const defaultInterpreterPathValue = config.get('defaultInterpreterPath'); + if (defaultInterpreterPathValue && defaultInterpreterPathValue !== 'python') { + return { + label: `${Octicons.Gear} ${InterpreterQuickPickList.defaultInterpreterPath.label()}`, + detail: this.pathUtils.getDisplayName( + defaultInterpreterPathValue, + resource ? resource.fsPath : undefined, + ), + path: defaultInterpreterPathValue, + alwaysShow: true, + }; + } + return undefined; + } + + /** + * Updates quickpick using the change event received. + */ + private async updateQuickPickItems( + quickPick: QuickPick, + event: PythonEnvironmentsChangedEvent, + resource: Resource, + ) { + // Active items are reset once we replace the current list with updated items, so save it. + const activeItemBeforeUpdate = quickPick.activeItems.length > 0 ? quickPick.activeItems[0] : undefined; + quickPick.items = await this.getUpdatedItems(quickPick.items, event, resource); + // Ensure we maintain the same active item as before. + const activeItem = activeItemBeforeUpdate + ? quickPick.items.find((item) => { + if (isInterpreterQuickPickItem(item) && isInterpreterQuickPickItem(activeItemBeforeUpdate)) { + return arePathsSame(item.interpreter.path, activeItemBeforeUpdate.interpreter.path); + } + if (isSpecialQuickPickItem(item) && isSpecialQuickPickItem(activeItemBeforeUpdate)) { + // 'label' is a constant here instead of 'path'. + return item.label === activeItemBeforeUpdate.label; + } + return false; + }) + : undefined; + quickPick.activeItems = activeItem ? [activeItem] : []; + } + + /** + * Prepare updated items to replace the quickpick list with. + */ + private async getUpdatedItems( + items: readonly QuickPickType[], + event: PythonEnvironmentsChangedEvent, + resource: Resource, + ): Promise { + const updatedItems = [...items.values()]; + const env = event.old ?? event.update; + let envIndex = -1; + if (env) { + envIndex = updatedItems.findIndex( + (item) => isInterpreterQuickPickItem(item) && arePathsSame(item.interpreter.path, env.path), + ); + } + if (event.update) { + const newSuggestion: QuickPickType = this.interpreterSelector.suggestionToQuickPickItem( + event.update, + resource, + ); + if (envIndex === -1) { + updatedItems.push(newSuggestion); + } else { + updatedItems[envIndex] = newSuggestion; + } + } + if (envIndex !== -1 && event.update === undefined) { + updatedItems.splice(envIndex, 1); + } + await this.setRecommendedItem(updatedItems, resource); + return updatedItems; + } + + private async setRecommendedItem(items: QuickPickType[], resource: Resource) { + const interpreterSuggestions = await this.interpreterSelector.getSuggestions(resource); + 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]); + recommended.label = `${Octicons.Star} ${recommended.label}`; + recommended.description = Common.recommended(); + const index = items.findIndex( + (item) => + isInterpreterQuickPickItem(item) && + arePathsSame(item.interpreter.path, recommended.interpreter.path), + ); + if (index !== -1) { + items[index] = recommended; + } + } + } + @captureTelemetry(EventName.SELECT_INTERPRETER_ENTER_BUTTON) public async _enterOrBrowseInterpreterPath( input: IMultiStepInput, state: InterpreterStateArgs, - suggestions: IInterpreterQuickPickItem[], + suggestions: QuickPickType[], ): Promise> { const items: QuickPickItem[] = [ { @@ -236,7 +327,7 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { private async sendInterpreterEntryTelemetry( selection: string, workspace: Resource, - suggestions: IInterpreterQuickPickItem[], + suggestions: QuickPickType[], ): Promise { let interpreterPath = path.normalize(untildify(selection)); @@ -245,7 +336,7 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand { } const expandedPaths = suggestions.map((s) => { - const suggestionPath = s.interpreter.path; + const suggestionPath = isInterpreterQuickPickItem(s) ? s.interpreter.path : ''; let expandedPath = path.normalize(untildify(suggestionPath)); if (!path.isAbsolute(suggestionPath)) { diff --git a/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts b/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts index 3b60e25169fc..08a3c3ee3def 100644 --- a/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts +++ b/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts @@ -24,24 +24,29 @@ export class InterpreterSelector implements IInterpreterSelector { this.disposables.forEach((disposable) => disposable.dispose()); } - public async getSuggestions(resource: Resource, ignoreCache?: boolean): Promise { + public async getSuggestions(resource: Resource): Promise { + const interpreters = await this.interpreterManager.getInterpreters(resource, { + onSuggestion: true, + }); + interpreters.sort(this.envTypeComparer.compare.bind(this.envTypeComparer)); + + return Promise.all(interpreters.map((item) => this.suggestionToQuickPickItem(item, resource))); + } + + public async getAllSuggestions(resource: Resource): Promise { const interpreters = await this.interpreterManager.getAllInterpreters(resource, { onSuggestion: true, - ignoreCache, }); interpreters.sort(this.envTypeComparer.compare.bind(this.envTypeComparer)); return Promise.all(interpreters.map((item) => this.suggestionToQuickPickItem(item, resource))); } - protected async suggestionToQuickPickItem( - suggestion: PythonEnvironment, - workspaceUri?: Uri, - ): Promise { + public suggestionToQuickPickItem(suggestion: PythonEnvironment, workspaceUri?: Uri): IInterpreterQuickPickItem { const detail = this.pathUtils.getDisplayName(suggestion.path, workspaceUri ? workspaceUri.fsPath : undefined); const cachedPrefix = suggestion.cachedEntry ? '(cached) ' : ''; return { - label: suggestion.displayName!, + label: suggestion.displayName || 'Python', detail: `${cachedPrefix}${detail}`, path: suggestion.path, interpreter: suggestion, diff --git a/src/client/interpreter/configuration/types.ts b/src/client/interpreter/configuration/types.ts index 90384d248912..001fd3d545f8 100644 --- a/src/client/interpreter/configuration/types.ts +++ b/src/client/interpreter/configuration/types.ts @@ -25,7 +25,9 @@ export interface IPythonPathUpdaterServiceManager { export const IInterpreterSelector = Symbol('IInterpreterSelector'); export interface IInterpreterSelector extends Disposable { - getSuggestions(resource: Resource, ignoreCache?: boolean): Promise; + getAllSuggestions(resource: Resource): Promise; + getSuggestions(resource: Resource): Promise; + suggestionToQuickPickItem(suggestion: PythonEnvironment, workspaceUri?: Uri | undefined): IInterpreterQuickPickItem; } export interface IInterpreterQuickPickItem extends QuickPickItem { diff --git a/src/client/interpreter/contracts.ts b/src/client/interpreter/contracts.ts index 3853fe59632d..363e7c7e7416 100644 --- a/src/client/interpreter/contracts.ts +++ b/src/client/interpreter/contracts.ts @@ -43,7 +43,7 @@ export const IComponentAdapter = Symbol('IComponentAdapter'); export interface IComponentAdapter { readonly onRefreshStart: Event; triggerRefresh(query?: PythonLocatorQuery): Promise; - readonly refreshPromise: Promise; + readonly refreshPromise: Promise | undefined; readonly onChanged: Event; // VirtualEnvPrompt onDidCreate(resource: Resource, callback: () => void): Disposable; @@ -111,7 +111,7 @@ export interface ICondaLocatorService { export const IInterpreterService = Symbol('IInterpreterService'); export interface IInterpreterService { triggerRefresh(query?: PythonLocatorQuery): Promise; - readonly refreshPromise: Promise; + readonly refreshPromise: Promise | undefined; readonly onDidChangeInterpreters: Event; onDidChangeInterpreterConfiguration: Event; onDidChangeInterpreter: Event; diff --git a/src/client/interpreter/display/progressDisplay.ts b/src/client/interpreter/display/progressDisplay.ts index ef1c374491e3..862434f13404 100644 --- a/src/client/interpreter/display/progressDisplay.ts +++ b/src/client/interpreter/display/progressDisplay.ts @@ -11,7 +11,7 @@ import { inDiscoveryExperiment } from '../../common/experiments/helpers'; import { traceDecorators } from '../../common/logger'; import { IDisposableRegistry, IExperimentService } from '../../common/types'; import { createDeferred, Deferred } from '../../common/utils/async'; -import { Common, Interpreters } from '../../common/utils/localize'; +import { Interpreters } from '../../common/utils/localize'; import { IServiceContainer } from '../../ioc/types'; import { IComponentAdapter, IInterpreterLocatorProgressService } from '../contracts'; @@ -70,7 +70,7 @@ export class InterpreterLocatorProgressStatubarHandler implements IExtensionSing private createProgress() { const progressOptions: ProgressOptions = { location: ProgressLocation.Window, - title: this.isFirstTimeLoadingInterpreters ? Common.loadingExtension() : Interpreters.refreshing(), + title: this.isFirstTimeLoadingInterpreters ? Interpreters.discovering() : Interpreters.refreshing(), }; this.isFirstTimeLoadingInterpreters = false; this.shell.withProgress(progressOptions, () => { diff --git a/src/client/interpreter/interpreterService.ts b/src/client/interpreter/interpreterService.ts index f48eacff3511..32293a3c61ca 100644 --- a/src/client/interpreter/interpreterService.ts +++ b/src/client/interpreter/interpreterService.ts @@ -63,7 +63,7 @@ export class InterpreterService implements Disposable, IInterpreterService { : Promise.resolve(); } - public get refreshPromise(): Promise { + public get refreshPromise(): Promise | undefined { return inDiscoveryExperimentSync(this.experimentService) ? this.pyenvs.refreshPromise : Promise.resolve(); } diff --git a/src/client/jupyter/jupyterIntegration.ts b/src/client/jupyter/jupyterIntegration.ts index 1974270bdc88..9b512f122664 100644 --- a/src/client/jupyter/jupyterIntegration.ts +++ b/src/client/jupyter/jupyterIntegration.ts @@ -183,7 +183,7 @@ export class JupyterExtensionIntegration { return isWindowsStoreInterpreter(pythonPath); }, getSuggestions: async (resource: Resource): Promise => - this.interpreterSelector.getSuggestions(resource), + this.interpreterSelector.getAllSuggestions(resource), install: async ( product: JupyterProductToInstall, resource?: InterpreterUri, diff --git a/src/client/pythonEnvironments/base/info/env.ts b/src/client/pythonEnvironments/base/info/env.ts index 2810c92b276f..a082742a1713 100644 --- a/src/client/pythonEnvironments/base/info/env.ts +++ b/src/client/pythonEnvironments/base/info/env.ts @@ -113,10 +113,7 @@ function updateEnv( * E.g. `Python 3.5.1 32-bit (myenv2: virtualenv)` */ export function getEnvDisplayString(env: PythonEnvInfo): string { - if (env.display === undefined || env.display === '') { - env.display = buildEnvDisplayString(env); - } - return env.display; + return buildEnvDisplayString(env); } function buildEnvDisplayString(env: PythonEnvInfo): string { diff --git a/src/client/pythonEnvironments/base/locator.ts b/src/client/pythonEnvironments/base/locator.ts index b24ac553c5b6..59f61b5adc78 100644 --- a/src/client/pythonEnvironments/base/locator.ts +++ b/src/client/pythonEnvironments/base/locator.ts @@ -176,9 +176,9 @@ export interface IDiscoveryAPI { readonly onChanged: Event; /** * Resolves once environment list has finished refreshing, i.e all environments are - * discovered. + * discovered. Carries `undefined` if there is no refresh currently going on. */ - readonly refreshPromise: Promise; + readonly refreshPromise: Promise | undefined; /** * Triggers a new refresh for query if there isn't any already running. */ diff --git a/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts b/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts index c6d116792ea1..0b3bc2e2237f 100644 --- a/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts +++ b/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts @@ -3,7 +3,6 @@ import { Event } from 'vscode'; import { traceInfo } from '../../../../common/logger'; -import { asyncFilter } from '../../../../common/utils/arrayUtils'; import { pathExists } from '../../../common/externalDependencies'; import { PythonEnvInfo } from '../../info'; import { areSameEnv } from '../../info/env'; @@ -76,7 +75,13 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher pathExists(e.executable.filename)); + const areEnvsValid = await Promise.all(this.envs.map((e) => pathExists(e.executable.filename))); + const invalidIndexes = areEnvsValid.map((isValid, index) => (isValid ? -1 : index)).filter((i) => i !== -1); + invalidIndexes.forEach((index) => { + const env = this.envs.splice(index, 1)[0]; + // Ensure we fire events for any envs removed from collection. + this.fire({ old: env, update: undefined }); + }); } public getAllEnvs(): PythonEnvInfo[] { diff --git a/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts b/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts index 71f76b6e9ee3..f0785301691d 100644 --- a/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts +++ b/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts @@ -30,8 +30,10 @@ export class EnvsCollectionService extends PythonEnvsWatcher { - return Promise.all(Array.from(this.refreshPromises.values())).then(); + public get refreshPromise(): Promise | undefined { + return this.refreshPromises.size > 0 + ? Promise.all(Array.from(this.refreshPromises.values())).then() + : undefined; } constructor(private readonly cache: IEnvsCollectionCache, private readonly locator: IResolvingLocator) { @@ -87,15 +89,16 @@ export class EnvsCollectionService extends PythonEnvsWatcher { const stopWatch = new StopWatch(); const deferred = createDeferred(); - // Ensure we set this before we trigger the promise to correctly track when a refresh has started. + // Ensure we set this before we trigger the promise to accurately track when a refresh has started. this.refreshPromises.set(query, deferred.promise); this.refreshStarted.fire(); const iterator = this.locator.iterEnvs(query); const promise = this.addEnvsToCacheFromIterator(iterator); return promise .then(async () => { - deferred.resolve(); + // Ensure we delete this before we resolve the promise to accurately track when a refresh finishes. this.refreshPromises.delete(query); + deferred.resolve(); sendTelemetryEvent(EventName.PYTHON_INTERPRETER_DISCOVERY, stopWatch.elapsedTime, { interpreters: this.cache.getAllEnvs().length, }); diff --git a/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts b/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts index 9251404f4e54..0ecd54b26591 100644 --- a/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts +++ b/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts @@ -6,7 +6,7 @@ import { Uri } from 'vscode'; import { uniq } from 'lodash'; import { traceError, traceWarning } from '../../../../common/logger'; import { PythonEnvInfo, PythonEnvKind, PythonEnvSource, UNKNOWN_PYTHON_VERSION, virtualEnvKinds } from '../../info'; -import { buildEnvInfo, comparePythonVersionSpecificity, getEnvMatcher } from '../../info/env'; +import { buildEnvInfo, comparePythonVersionSpecificity, getEnvDisplayString, 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); } - // Display name is not set here as we need version, arch etc. to build it. + resolvedEnv.display = getEnvDisplayString(resolvedEnv); return resolvedEnv; } diff --git a/src/client/pythonEnvironments/common/environmentManagers/conda.ts b/src/client/pythonEnvironments/common/environmentManagers/conda.ts index 540325eae5c9..025cb7fc75b5 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/conda.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/conda.ts @@ -344,8 +344,17 @@ export class Conda { * Corresponds to "conda info --json". */ public async getInfo(): Promise { + return this.getInfoCached(this.command); + } + + /** + * Cache result for this particular command. + */ + @cache(30_000, true, 10_000) + // eslint-disable-next-line class-methods-use-this + private async getInfoCached(command: string): Promise { const disposables = new Set(); - const result = await exec(this.command, ['info', '--json'], {}, disposables); + const result = await exec(command, ['info', '--json'], {}, disposables); traceVerbose(`conda info --json: ${result.stdout}`); // Ensure the process we started is cleaned up. diff --git a/src/client/pythonEnvironments/common/environmentManagers/poetry.ts b/src/client/pythonEnvironments/common/environmentManagers/poetry.ts index eb52c5b653d5..cafa58835072 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/poetry.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/poetry.ts @@ -99,11 +99,11 @@ export class Poetry { /** * Creates a Poetry service corresponding to the corresponding "poetry" command. * - * @param _command - Command used to run poetry. This has the same meaning as the + * @param command - Command used to run poetry. This has the same meaning as the * first argument of spawn() - i.e. it can be a full path, or just a binary name. * @param cwd - The working directory to use as cwd when running poetry. */ - constructor(public readonly _command: string, private cwd: string) { + constructor(public readonly command: string, private cwd: string) { this.fixCwd(); } @@ -184,7 +184,7 @@ export class Poetry { */ @cache(30_000, true, 10_000) private async getEnvListCached(_cwd: string): Promise { - const result = await this.safeShellExecute(`${this._command} env list --full-path`); + const result = await this.safeShellExecute(`${this.command} env list --full-path`); if (!result) { return undefined; } @@ -220,7 +220,7 @@ export class Poetry { */ @cache(20_000, true, 10_000) private async getActiveEnvPathCached(_cwd: string): Promise { - const result = await this.safeShellExecute(`${this._command} env info -p`, true); + const result = await this.safeShellExecute(`${this.command} env info -p`, true); if (!result) { return undefined; } @@ -232,7 +232,7 @@ export class Poetry { * environments are created for the directory. Corresponds to "poetry config virtualenvs.path". Swallows errors if any. */ public async getVirtualenvsPathSetting(): Promise { - const result = await this.safeShellExecute(`${this._command} config virtualenvs.path`); + const result = await this.safeShellExecute(`${this.command} config virtualenvs.path`); if (!result) { return undefined; } diff --git a/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts b/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts index 6d8ff947542f..5c895696b73f 100644 --- a/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts +++ b/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts @@ -8,6 +8,7 @@ import * as sinon from 'sinon'; import * as TypeMoq from 'typemoq'; import { ConfigurationTarget, OpenDialogOptions, QuickPickItem, Uri } from 'vscode'; import { cloneDeep } from 'lodash'; +import { instance, mock, verify, when } from 'ts-mockito'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../../../client/common/application/types'; import { PathUtils } from '../../../../client/common/platform/pathUtils'; import { IPlatformService } from '../../../../client/common/platform/types'; @@ -33,10 +34,12 @@ import { EventName } from '../../../../client/telemetry/constants'; import * as Telemetry from '../../../../client/telemetry'; import { MockWorkspaceConfiguration } from '../../../mocks/mockWorkspaceConfig'; import { Octicons } from '../../../../client/common/constants'; +import { IInterpreterService, PythonEnvironmentsChangedEvent } from '../../../../client/interpreter/contracts'; +import { createDeferred, sleep } from '../../../../client/common/utils/async'; const untildify = require('untildify'); -type TelemetryEventType = { eventName: EventName; properties: Record }; +type TelemetryEventType = { eventName: EventName; properties: unknown }; suite('Set Interpreter Command', () => { let workspace: TypeMoq.IMock; @@ -48,6 +51,7 @@ suite('Set Interpreter Command', () => { let pythonSettings: TypeMoq.IMock; let platformService: TypeMoq.IMock; let multiStepInputFactory: TypeMoq.IMock; + let interpreterService: IInterpreterService; const folder1 = { name: 'one', uri: Uri.parse('one'), index: 1 }; const folder2 = { name: 'two', uri: Uri.parse('two'), index: 2 }; @@ -64,6 +68,8 @@ suite('Set Interpreter Command', () => { pythonSettings = TypeMoq.Mock.ofType(); workspace = TypeMoq.Mock.ofType(); + interpreterService = mock(); + when(interpreterService.refreshPromise).thenReturn(undefined); workspace.setup((w) => w.rootPath).returns(() => 'rootPath'); configurationService.setup((x) => x.getSettings(TypeMoq.It.isAny())).returns(() => pythonSettings.object); @@ -78,6 +84,7 @@ suite('Set Interpreter Command', () => { platformService.object, interpreterSelector.object, workspace.object, + instance(interpreterService), ); }); @@ -90,12 +97,13 @@ suite('Set Interpreter Command', () => { let sendTelemetryStub: sinon.SinonStub; let telemetryEvent: TelemetryEventType | undefined; + const interpreterPath = 'path/to/interpreter'; const item: IInterpreterQuickPickItem = { description: '', detail: '', - label: '', - path: 'This is the selected Python path', - interpreter: {} as PythonEnvironment, + label: 'This is the selected Python path', + path: interpreterPath, + interpreter: { path: interpreterPath } as PythonEnvironment, }; const defaultInterpreterPath = 'defaultInterpreterPath'; const defaultInterpreterPathSuggestion = { @@ -108,9 +116,9 @@ suite('Set Interpreter Command', () => { const refreshedItem: IInterpreterQuickPickItem = { description: '', detail: '', - label: '', - path: 'Refreshed path', - interpreter: {} as PythonEnvironment, + label: 'Refreshed path', + path: interpreterPath, + interpreter: { path: interpreterPath } as PythonEnvironment, }; const expectedEnterInterpreterPathSuggestion = { label: `${Octicons.Add} ${InterpreterQuickPickList.enterPath.label()}`, @@ -126,7 +134,7 @@ suite('Set Interpreter Command', () => { _enterOrBrowseInterpreterPath.resolves(); sendTelemetryStub = sinon .stub(Telemetry, 'sendTelemetryEvent') - .callsFake((eventName: EventName, _, properties: Record) => { + .callsFake((eventName: EventName, _, properties: unknown) => { telemetryEvent = { eventName, properties, @@ -135,9 +143,6 @@ suite('Set Interpreter Command', () => { interpreterSelector .setup((i) => i.getSuggestions(TypeMoq.It.isAny())) .returns(() => Promise.resolve([item])); - interpreterSelector - .setup((i) => i.getSuggestions(TypeMoq.It.isAny(), true)) - .returns(() => Promise.resolve([refreshedItem])); pythonSettings.setup((p) => p.pythonPath).returns(() => currentPythonPath); pythonSettings.setup((p) => p.defaultInterpreterPath).returns(() => defaultInterpreterPath); @@ -160,6 +165,7 @@ suite('Set Interpreter Command', () => { platformService.object, interpreterSelector.object, workspace.object, + instance(interpreterService), ); }); teardown(() => { @@ -187,13 +193,15 @@ suite('Set Interpreter Command', () => { recommended.label = `${Octicons.Star} ${item.label}`; recommended.description = Common.recommended(); const suggestions = [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, recommended]; - const expectedParameters = { + 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 @@ -201,8 +209,7 @@ suite('Set Interpreter Command', () => { .callback((options) => { actualParameters = options; }) - .returns(() => Promise.resolve((undefined as unknown) as QuickPickItem)) - .verifiable(TypeMoq.Times.once()); + .returns(() => Promise.resolve((undefined as unknown) as QuickPickItem)); await setInterpreterCommand._pickInterpreter(multiStepInput.object, state); @@ -210,21 +217,103 @@ suite('Set Interpreter Command', () => { const refreshButtonCallback = actualParameters!.customButtonSetup?.callback; expect(refreshButtonCallback).to.not.equal(undefined, 'Callback not set'); delete actualParameters!.customButtonSetup; + delete actualParameters!.onChangeItem; assert.deepStrictEqual(actualParameters, expectedParameters, 'Params not equal'); + }); + + test('Ensure a refresh is triggered if refresh button is clicked', async () => { + const state: InterpreterStateArgs = { path: 'some path', workspace: undefined }; + const multiStepInput = TypeMoq.Mock.ofType>(); + 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'); + + when(interpreterService.triggerRefresh()).thenResolve(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await refreshButtonCallback!({} as any); // Invoke callback, meaning that the refresh button is clicked. + verify(interpreterService.triggerRefresh()).once(); + }); + + test('If an event to update quickpick is received, the quickpick is updated accordingly', async () => { + const state: InterpreterStateArgs = { path: 'some path', workspace: undefined }; + const multiStepInput = TypeMoq.Mock.ofType>(); + let actualParameters: IQuickPickParameters | undefined; + multiStepInput + .setup((i) => i.showQuickPick(TypeMoq.It.isAny())) + .callback((options) => { + actualParameters = options; + }) + .returns(() => Promise.resolve((undefined as unknown) as QuickPickItem)); + const refreshPromiseDeferred = createDeferred(); + // Assume a refresh is currently going on... + when(interpreterService.refreshPromise).thenReturn(refreshPromiseDeferred.promise); + + await setInterpreterCommand._pickInterpreter(multiStepInput.object, state); + + expect(actualParameters).to.not.equal(undefined, 'Parameters not set'); + const onChangedCallback = actualParameters!.onChangeItem?.callback; + expect(onChangedCallback).to.not.equal(undefined, 'Callback not set'); multiStepInput.verifyAll(); - const quickPick = { items: [] }; + const quickPick = { + items: [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, item], + activeItems: [item], + busy: false, + }; + interpreterSelector + .setup((i) => i.suggestionToQuickPickItem(TypeMoq.It.isAny(), undefined)) + .returns(() => refreshedItem); + + const changeEvent: PythonEnvironmentsChangedEvent = { + old: item.interpreter, + update: refreshedItem.interpreter, + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - await refreshButtonCallback!(quickPick as any); // Invoke callback, meaning that the refresh button is clicked. + await onChangedCallback!(changeEvent, quickPick as any); // Invoke callback, meaning that the items are supposed to change. - const recommendedRefreshedItem = cloneDeep(refreshedItem); - recommendedRefreshedItem.label = `${Octicons.Star} ${refreshedItem.label}`; - recommendedRefreshedItem.description = Common.recommended(); assert.deepStrictEqual( - quickPick.items, - [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, recommendedRefreshedItem], + quickPick, + { + items: [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, refreshedItem], + activeItems: [refreshedItem], + busy: true, + }, 'Quickpick not updated correctly', ); + + // Refresh is over; set the final states accordingly + interpreterSelector + .setup((i) => i.getSuggestions(TypeMoq.It.isAny())) + .returns(() => Promise.resolve([refreshedItem])); + when(interpreterService.refreshPromise).thenReturn(undefined); + + refreshPromiseDeferred.resolve(); + await sleep(1); + + const recommended = cloneDeep(refreshedItem); + recommended.label = `${Octicons.Star} ${refreshedItem.label}`; + recommended.description = Common.recommended(); + assert.deepStrictEqual( + quickPick, + { + // Refresh has finished, so recommend an interpreter + items: [expectedEnterInterpreterPathSuggestion, defaultInterpreterPathSuggestion, recommended], + activeItems: [recommended], + // Refresh has finished, so quickpick busy indicator should go away + busy: false, + }, + 'Quickpick not updated correctly after refresh has finished', + ); }); test('If an item is selected, update state and return', async () => { @@ -383,7 +472,7 @@ suite('Set Interpreter Command', () => { setup(() => { sendTelemetryStub = sinon .stub(Telemetry, 'sendTelemetryEvent') - .callsFake((eventName: EventName, _, properties: Record) => { + .callsFake((eventName: EventName, _, properties: unknown) => { telemetryEvents.push({ eventName, properties, @@ -773,6 +862,7 @@ suite('Set Interpreter Command', () => { platformService.object, interpreterSelector.object, workspace.object, + instance(interpreterService), ); type InputStepType = () => Promise | void>; let inputStep!: InputStepType; diff --git a/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts b/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts index 4d01bb5bd9e0..81137acb52cf 100644 --- a/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts +++ b/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts @@ -50,12 +50,8 @@ suite('Interpreters - selector', () => { let interpreterService: TypeMoq.IMock; let fileSystem: TypeMoq.IMock; let newComparer: TypeMoq.IMock; - const ignoreCache = false; class TestInterpreterSelector extends InterpreterSelector { - public async suggestionToQuickPickItem( - suggestion: PythonEnvironment, - workspaceUri?: Uri, - ): Promise { + public suggestionToQuickPickItem(suggestion: PythonEnvironment, workspaceUri?: Uri): IInterpreterQuickPickItem { return super.suggestionToQuickPickItem(suggestion, workspaceUri); } } @@ -91,10 +87,10 @@ suite('Interpreters - selector', () => { { displayName: '4', path: 'c:/path4/path4', envType: EnvironmentType.Conda }, ].map((item) => ({ ...info, ...item })); interpreterService - .setup((x) => x.getAllInterpreters(TypeMoq.It.isAny(), { onSuggestion: true, ignoreCache })) + .setup((x) => x.getAllInterpreters(TypeMoq.It.isAny(), { onSuggestion: true })) .returns(() => new Promise((resolve) => resolve(initial))); - const actual = await selector.getSuggestions(undefined, ignoreCache); + const actual = await selector.getAllSuggestions(undefined); const expected: InterpreterQuickPickItem[] = [ new InterpreterQuickPickItem('1', 'c:/path1/path1'), @@ -153,7 +149,7 @@ suite('Interpreters - selector', () => { ].map((item) => ({ ...info, ...item })); interpreterService - .setup((x) => x.getAllInterpreters(TypeMoq.It.isAny(), { onSuggestion: true, ignoreCache })) + .setup((x) => x.getAllInterpreters(TypeMoq.It.isAny(), { onSuggestion: true })) .returns(() => new Promise((resolve) => resolve(environments))); const interpreterHelper = TypeMoq.Mock.ofType(); @@ -169,7 +165,7 @@ suite('Interpreters - selector', () => { new PathUtils(getOSType() === OSType.Windows), ); - const result = await selector.getSuggestions(undefined, ignoreCache); + const result = await selector.getAllSuggestions(undefined); const expected: InterpreterQuickPickItem[] = [ new InterpreterQuickPickItem('two', path.join(workspacePath, '.venv', 'bin', 'python')), diff --git a/src/test/interpreters/display/progressDisplay.unit.test.ts b/src/test/interpreters/display/progressDisplay.unit.test.ts index 9fc2c5451738..db5b5cb0a52b 100644 --- a/src/test/interpreters/display/progressDisplay.unit.test.ts +++ b/src/test/interpreters/display/progressDisplay.unit.test.ts @@ -8,7 +8,7 @@ import { anything, capture, instance, mock, when } from 'ts-mockito'; import { CancellationToken, Disposable, Progress, ProgressOptions } from 'vscode'; import { ApplicationShell } from '../../../client/common/application/applicationShell'; import { ExperimentService } from '../../../client/common/experiments/service'; -import { Common, Interpreters } from '../../../client/common/utils/localize'; +import { Interpreters } from '../../../client/common/utils/localize'; import { noop } from '../../../client/common/utils/misc'; import { IComponentAdapter, IInterpreterLocatorProgressService } from '../../../client/interpreter/contracts'; import { InterpreterLocatorProgressStatubarHandler } from '../../../client/interpreter/display/progressDisplay'; @@ -60,7 +60,7 @@ suite('Interpreters - Display Progress', () => { refreshingCallback(undefined); const options = capture(shell.withProgress as never).last()[0] as ProgressOptions; - expect(options.title).to.be.equal(Common.loadingExtension()); + expect(options.title).to.be.equal(Interpreters.discovering()); }); test('Display refreshing message when refreshing interpreters for the second time', async () => { @@ -78,7 +78,7 @@ suite('Interpreters - Display Progress', () => { refreshingCallback(undefined); let options = capture(shell.withProgress as never).last()[0] as ProgressOptions; - expect(options.title).to.be.equal(Common.loadingExtension()); + expect(options.title).to.be.equal(Interpreters.discovering()); refreshingCallback(undefined); @@ -104,7 +104,7 @@ suite('Interpreters - Display Progress', () => { const callback = capture(shell.withProgress as never).last()[1] as ProgressTask; const promise = callback(undefined as never, undefined as never); - expect(options.title).to.be.equal(Common.loadingExtension()); + expect(options.title).to.be.equal(Interpreters.discovering()); refreshedCallback(undefined); // Promise must resolve when refreshed callback is invoked. diff --git a/src/test/pythonEnvironments/base/info/env.unit.test.ts b/src/test/pythonEnvironments/base/info/env.unit.test.ts index 54593afd584b..6ca0e167b94d 100644 --- a/src/test/pythonEnvironments/base/info/env.unit.test.ts +++ b/src/test/pythonEnvironments/base/info/env.unit.test.ts @@ -9,6 +9,17 @@ import { getEnvDisplayString } from '../../../../client/pythonEnvironments/base/ import { createLocatedEnv } from '../common'; suite('pyenvs info - getEnvDisplayString()', () => { + const name = 'my-env'; + const location = 'x/y/z/spam/'; + const arch = Architecture.x64; + const version = '3.8.1'; + const kind = PythonEnvKind.Venv; + const distro: PythonDistroInfo = { + org: 'Distro X', + defaultDisplayName: 'distroX 1.2', + version: parseVersionInfo('1.2.3')?.version, + binDir: 'distroX/bin', + }; function getEnv(info: { version?: string; arch?: Architecture; @@ -30,61 +41,26 @@ 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)"], + // without "suffix" info + [getEnv({ version }), 'Python 3.8.1'], + [getEnv({ arch }), 'Python 64-bit'], + [getEnv({ version, arch }), '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)"], + // env.location is ignored. + [getEnv({ location }), 'Python'], + [getEnv({ name, location }), "Python ('my-env')"], + ]; + tests.forEach(([env, expected]) => { + test(`"${expected}"`, () => { + const result = getEnvDisplayString(env); - suite('already set', () => { - [ - 'Python', // built: absolute minimal - 'Python 3.7.x x64 (my-env: venv)', // built: full - 'spam', - 'some env', - // corner cases - '---', - ' ', - ].forEach((display: string) => { - test(`"${display}"`, () => { - const expected = display; - const env = getEnv({ display }); - - const result = getEnvDisplayString(env); - - assert.equal(result, expected); - }); - }); - }); - - suite('built', () => { - const name = 'my-env'; - const location = 'x/y/z/spam/'; - const arch = Architecture.x64; - const version = '3.8.1'; - const kind = PythonEnvKind.Venv; - const distro: PythonDistroInfo = { - org: 'Distro X', - defaultDisplayName: 'distroX 1.2', - version: parseVersionInfo('1.2.3')?.version, - binDir: 'distroX/bin', - }; - const tests: [PythonEnvInfo, string][] = [ - [getEnv({}), 'Python'], - [getEnv({ version, arch, name, kind, distro }), "Python 3.8.1 64-bit ('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'], - // with "suffix" info - [getEnv({ name }), "Python ('my-env')"], - [getEnv({ kind }), 'Python (venv)'], - [getEnv({ name, kind }), "Python ('my-env': venv)"], - // env.location is ignored. - [getEnv({ location }), 'Python'], - [getEnv({ name, location }), "Python ('my-env')"], - ]; - tests.forEach(([env, expected]) => { - test(`"${expected}"`, () => { - const result = getEnvDisplayString(env); - - assert.equal(result, expected); - }); + assert.equal(result, expected); }); }); }); 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 6694d77957ee..0038070076a6 100644 --- a/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts +++ b/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts @@ -66,6 +66,7 @@ suite('Python envs locator - Environments Resolver', () => { version: PythonVersion = UNKNOWN_PYTHON_VERSION, name = '', location = '', + display: string | undefined = undefined, ): PythonEnvInfo { return { name, @@ -77,7 +78,7 @@ suite('Python envs locator - Environments Resolver', () => { ctime: -1, mtime: -1, }, - display: undefined, + display, version, arch: Architecture.Unknown, distro: { org: '' }, @@ -117,6 +118,7 @@ suite('Python envs locator - Environments Resolver', () => { undefined, 'win1', path.join(testVirtualHomeDir, '.venvs', 'win1'), + "Python ('win1': venv)", ); const envsReturnedByParentLocator = [env1]; const parentLocator = new SimpleLocator(envsReturnedByParentLocator); 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 c5de00c3072a..556072dd0804 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 } from '../../../../../client/pythonEnvironments/base/info/env'; +import { buildEnvInfo, getEnvDisplayString } 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,6 +61,7 @@ suite('Resolver Utils', () => { }); envInfo.location = path.join(testPyenvVersionsDir, '3.9.0'); envInfo.name = '3.9.0'; + envInfo.display = getEnvDisplayString(envInfo); return envInfo; } @@ -114,7 +115,7 @@ suite('Resolver Utils', () => { test('resolveEnv', async () => { const python38path = path.join(testStoreAppRoot, 'python3.8.exe'); - const expected = { + const expected: PythonEnvInfo = { display: undefined, searchLocation: undefined, name: '', @@ -124,6 +125,7 @@ suite('Resolver Utils', () => { source: [PythonEnvSource.PathEnvVar], ...createExpectedInterpreterInfo(python38path), }; + expected.display = getEnvDisplayString(expected); const actual = await resolveBasicEnv({ executablePath: python38path, @@ -135,7 +137,7 @@ suite('Resolver Utils', () => { test('resolveEnv(string): forbidden path', async () => { const python38path = path.join(testLocalAppData, 'Program Files', 'WindowsApps', 'python3.8.exe'); - const expected = { + const expected: PythonEnvInfo = { display: undefined, searchLocation: undefined, name: '', @@ -145,6 +147,7 @@ suite('Resolver Utils', () => { source: [PythonEnvSource.PathEnvVar], ...createExpectedInterpreterInfo(python38path), }; + expected.display = getEnvDisplayString(expected); const actual = await resolveBasicEnv({ executablePath: python38path, @@ -180,6 +183,7 @@ suite('Resolver Utils', () => { fileInfo: undefined, name: 'base', }); + info.display = getEnvDisplayString(info); return info; } function createSimpleEnvInfo( @@ -189,7 +193,7 @@ suite('Resolver Utils', () => { name = '', location = '', ): PythonEnvInfo { - return { + const info: PythonEnvInfo = { name, location, kind, @@ -206,6 +210,8 @@ suite('Resolver Utils', () => { searchLocation: undefined, source: [], }; + info.display = getEnvDisplayString(info); + return info; } teardown(() => { @@ -284,7 +290,7 @@ suite('Resolver Utils', () => { name = '', location = '', ): PythonEnvInfo { - return { + const info: PythonEnvInfo = { name, location, kind, @@ -301,6 +307,8 @@ suite('Resolver Utils', () => { searchLocation: Uri.file(path.dirname(location)), source: [], }; + info.display = getEnvDisplayString(info); + return info; } test('resolveEnv', async () => { @@ -337,7 +345,7 @@ suite('Resolver Utils', () => { name = '', location = '', ): PythonEnvInfo { - return { + const info: PythonEnvInfo = { name, location, kind, @@ -354,6 +362,8 @@ suite('Resolver Utils', () => { searchLocation: undefined, source: [], }; + info.display = getEnvDisplayString(info); + return info; } test('resolveEnv', async () => { @@ -540,6 +550,7 @@ suite('Resolver Utils', () => { org: 'PythonCore', source: [PythonEnvSource.WindowsRegistry], }); + expected.display = getEnvDisplayString(expected); expected.distro.defaultDisplayName = 'Python 3.9 (64-bit)'; assertEnvEqual(actual, expected); }); @@ -559,6 +570,7 @@ suite('Resolver Utils', () => { org: 'PythonCodingPack', // Provided by registry source: [PythonEnvSource.WindowsRegistry, PythonEnvSource.PathEnvVar], }); + expected.display = getEnvDisplayString(expected); expected.distro.defaultDisplayName = 'Python 3.8 (32-bit)'; assertEnvEqual(actual, expected); }); @@ -585,6 +597,7 @@ suite('Resolver Utils', () => { name: 'conda3', source: [PythonEnvSource.WindowsRegistry], }); + expected.display = getEnvDisplayString(expected); expected.distro.defaultDisplayName = 'Anaconda py38_4.8.3'; assertEnvEqual(actual, expected); }); diff --git a/src/test/pythonEnvironments/common/environmentManagers/poetry.unit.test.ts b/src/test/pythonEnvironments/common/environmentManagers/poetry.unit.test.ts index 355a1251d118..166b388a11c0 100644 --- a/src/test/pythonEnvironments/common/environmentManagers/poetry.unit.test.ts +++ b/src/test/pythonEnvironments/common/environmentManagers/poetry.unit.test.ts @@ -106,7 +106,7 @@ suite('Poetry binary is located correctly', async () => { const poetry = await Poetry.getPoetry(testPoetryDir); - expect(poetry?._command).to.equal(undefined); + expect(poetry?.command).to.equal(undefined); }); test('Return undefined if cwd contains pyproject.toml which does not contain a poetry section', async () => { @@ -117,7 +117,7 @@ suite('Poetry binary is located correctly', async () => { const poetry = await Poetry.getPoetry(project3); - expect(poetry?._command).to.equal(undefined); + expect(poetry?.command).to.equal(undefined); }); test('When user has specified a valid poetry path, use it', async () => { @@ -135,7 +135,7 @@ suite('Poetry binary is located correctly', async () => { const poetry = await Poetry.getPoetry(project1); - expect(poetry?._command).to.equal('poetryPath'); + expect(poetry?.command).to.equal('poetryPath'); }); test("When user hasn't specified a path, use poetry on PATH if available", async () => { @@ -153,7 +153,7 @@ suite('Poetry binary is located correctly', async () => { const poetry = await Poetry.getPoetry(project1); - expect(poetry?._command).to.equal('poetry'); + expect(poetry?.command).to.equal('poetry'); }); test('When poetry is not available on PATH, try using the default poetry location if valid', async () => { @@ -180,7 +180,7 @@ suite('Poetry binary is located correctly', async () => { const poetry = await Poetry.getPoetry(project1); - expect(poetry?._command).to.equal(defaultPoetry); + expect(poetry?.command).to.equal(defaultPoetry); }); test('Return undefined otherwise', async () => { @@ -191,6 +191,6 @@ suite('Poetry binary is located correctly', async () => { const poetry = await Poetry.getPoetry(project1); - expect(poetry?._command).to.equal(undefined); + expect(poetry?.command).to.equal(undefined); }); }); diff --git a/types/vscode.proposed.d.ts b/types/vscode.proposed.d.ts index 89a2d2f12185..3550b5c667e9 100644 --- a/types/vscode.proposed.d.ts +++ b/types/vscode.proposed.d.ts @@ -750,6 +750,18 @@ declare module 'vscode' { replaceOutputItems(items: NotebookCellOutputItem | NotebookCellOutputItem[], outputId: string): Thenable; } + export interface QuickPick extends QuickInput { + /** + * An optional flag to sort the final results by index of first query match in label. Defaults to true. + */ + sortByLabel: boolean; + + /* + * An optional flag that can be set to true to maintain the scroll position of the quick pick when the quick pick items are updated. Defaults to false. + */ + keepScrollPosition?: boolean; + } + export enum NotebookCellExecutionState { Idle = 1, Pending = 2,