From e24357eee05053e0f2daec44abc44121889fbbff Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 12 Nov 2026 13:21:52 -0800 Subject: [PATCH] Add refresh button to environment picker Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/common/localize.ts | 4 + src/common/pickers/environments.ts | 266 +++++++++++++----- src/common/window.apis.ts | 25 +- src/test/common/apiWrappers.unit.test.ts | 124 +++++++- .../common/environmentPicker.unit.test.ts | 241 +++++++++++++++- 5 files changed, 582 insertions(+), 78 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index 19a3bcd84..c007166c5 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -132,6 +132,10 @@ export namespace Pickers { export namespace Environments { export const selectExecutable = l10n.t('Select Python Executable'); export const selectEnvironment = l10n.t('Select a Python Environment'); + export const refresh = l10n.t('Refresh environments'); + export const refreshFailed = l10n.t( + 'Failed to refresh Python environments. Check the Python Environments logs for more details.', + ); } export namespace Packages { diff --git a/src/common/pickers/environments.ts b/src/common/pickers/environments.ts index 86a9ba059..52b798616 100644 --- a/src/common/pickers/environments.ts +++ b/src/common/pickers/environments.ts @@ -1,6 +1,17 @@ -import { ProgressLocation, QuickInputButtons, QuickPickItem, QuickPickItemKind, ThemeIcon, Uri, l10n } from 'vscode'; +import { + ProgressLocation, + QuickInputButton, + QuickInputButtons, + QuickPick, + QuickPickItem, + QuickPickItemKind, + ThemeIcon, + Uri, + l10n, +} from 'vscode'; import { CreateEnvironmentOptions, IconPath, PythonEnvironment, PythonProject } from '../../api'; import type { InternalEnvironmentManager } from '../../managers/common/registeredManagers'; +import { showErrorMessageWithLogs } from '../errors/utils'; import { Common, Interpreter, Pickers } from '../localize'; import { traceError } from '../logging'; import { EventNames } from '../telemetry/constants'; @@ -27,6 +38,17 @@ type QuickPickIcon = | ThemeIcon | undefined; +interface EnvironmentPickItem extends QuickPickItem { + readonly result?: PythonEnvironment; +} + +const REFRESH_ENVIRONMENTS_BUTTON: QuickInputButton = { + iconPath: new ThemeIcon('refresh'), + tooltip: Pickers.Environments.refresh, +}; +const inFlightManagerRefreshes = new WeakMap>(); +let refreshErrorNotificationShown = false; + function getIconPath(i: IconPath | undefined): QuickPickIcon { if (i === undefined || i instanceof ThemeIcon || i instanceof Uri) { return i; @@ -47,6 +69,158 @@ interface EnvironmentPickOptions { showBackButton?: boolean; projects: PythonProject[]; } + +function isSameEnvironment(left: PythonEnvironment, right: PythonEnvironment): boolean { + return left.envId.id === right.envId.id && left.envId.managerId === right.envId.managerId; +} + +function getEnvironmentDescription(environment: PythonEnvironment): string { + return environment.description?.trim() + ? `${environment.description} (${environment.displayPath})` + : environment.displayPath; +} + +function createEnvironmentPickItem( + environment: PythonEnvironment, +): EnvironmentPickItem { + return { + label: environment.displayName ?? environment.name, + description: getEnvironmentDescription(environment), + result: environment, + iconPath: getIconPath(environment.iconPath), + }; +} + +async function buildEnvironmentPickItems( + managers: InternalEnvironmentManager[], + options: EnvironmentPickOptions, +): Promise { + const items: EnvironmentPickItem[] = [ + { + label: Interpreter.enterInterpreterPath, + iconPath: new ThemeIcon('edit'), + }, + { + label: Interpreter.browsePath, + iconPath: new ThemeIcon('folder'), + }, + ]; + + if (getWorkspaceFolders()?.length) { + items.push( + { + label: '', + kind: QuickPickItemKind.Separator, + }, + { + label: Interpreter.createVirtualEnvironment, + iconPath: new ThemeIcon('add'), + }, + ); + } + + if (options.recommended) { + items.push( + { + label: Common.recommended, + kind: QuickPickItemKind.Separator, + }, + createEnvironmentPickItem(options.recommended), + ); + } + + for (const manager of managers) { + items.push({ + label: manager.displayName, + kind: QuickPickItemKind.Separator, + }); + const environments = await manager.getEnvironments('all'); + items.push(...environments.map((environment) => createEnvironmentPickItem(environment))); + } + + return items; +} + +function findEquivalentPickItem( + items: readonly EnvironmentPickItem[], + previous: EnvironmentPickItem | undefined, +): EnvironmentPickItem | undefined { + if (previous?.result) { + const previousResult = previous.result; + return items.find((item) => item.result !== undefined && isSameEnvironment(item.result, previousResult)); + } + return previous ? items.find((item) => item.label === previous.label) : undefined; +} + +function refreshManager(manager: InternalEnvironmentManager): Promise { + const inFlight = inFlightManagerRefreshes.get(manager); + if (inFlight) { + return inFlight; + } + + const refresh = manager.refresh(undefined); + inFlightManagerRefreshes.set(manager, refresh); + const clearRefresh = () => { + if (inFlightManagerRefreshes.get(manager) === refresh) { + inFlightManagerRefreshes.delete(manager); + } + }; + void refresh.then(clearRefresh, clearRefresh); + return refresh; +} + +async function refreshEnvironmentPickItems( + managers: InternalEnvironmentManager[], + options: EnvironmentPickOptions, + quickPick: QuickPick, +): Promise { + let hidden = false; + const hideDisposable = quickPick.onDidHide(() => { + hidden = true; + }); + const activeItem = quickPick.activeItems[0]; + quickPick.busy = true; + quickPick.enabled = false; + + try { + await Promise.all(managers.map((manager) => refreshManager(manager))); + if (hidden) { + return; + } + + const items = await buildEnvironmentPickItems(managers, options); + if (hidden) { + return; + } + + quickPick.keepScrollPosition = true; + quickPick.items = items; + const recommended = options.recommended; + const preservedItem = + findEquivalentPickItem(items, activeItem) ?? + (recommended + ? items.find((item) => item.result !== undefined && isSameEnvironment(item.result, recommended)) + : undefined); + if (preservedItem) { + quickPick.activeItems = [preservedItem]; + } + } catch (error) { + traceError('Failed to refresh environments from the environment picker:', error); + if (!hidden && !refreshErrorNotificationShown) { + refreshErrorNotificationShown = true; + void showErrorMessageWithLogs(Pickers.Environments.refreshFailed).catch((notificationError) => + traceError('Failed to show the environment refresh error notification:', notificationError), + ); + } + } finally { + hideDisposable.dispose(); + if (!hidden) { + quickPick.busy = false; + quickPick.enabled = true; + } + } +} + async function browseForPython( managers: InternalEnvironmentManager[], projectEnvManagers: InternalEnvironmentManager[], @@ -144,15 +318,33 @@ async function createEnvironment( } async function pickEnvironmentImpl( - items: (QuickPickItem | (QuickPickItem & { result: PythonEnvironment }))[], managers: InternalEnvironmentManager[], projectEnvManagers: InternalEnvironmentManager[], options: EnvironmentPickOptions, ): Promise { + const items = await buildEnvironmentPickItems(managers, options); + let refreshInProgress: Promise | undefined; const selected = await showQuickPickWithButtons(items, { placeHolder: Pickers.Environments.selectEnvironment, ignoreFocusOut: true, showBackButton: options?.showBackButton, + buttons: [REFRESH_ENVIRONMENTS_BUTTON], + buttonHandler: (button, quickPick) => { + if (button !== REFRESH_ENVIRONMENTS_BUTTON) { + return; + } + if (!refreshInProgress) { + const refresh = refreshEnvironmentPickItems(managers, options, quickPick); + refreshInProgress = refresh; + const clearRefresh = () => { + if (refreshInProgress === refresh) { + refreshInProgress = undefined; + } + }; + void refresh.then(clearRefresh, clearRefresh); + } + return refreshInProgress; + }, }); if (selected && !Array.isArray(selected)) { @@ -177,75 +369,7 @@ export async function pickEnvironment( projectEnvManagers: InternalEnvironmentManager[], options: EnvironmentPickOptions, ): Promise { - const items: (QuickPickItem | (QuickPickItem & { result: PythonEnvironment }))[] = [ - { - label: Interpreter.enterInterpreterPath, - iconPath: new ThemeIcon('edit'), - }, - { - label: Interpreter.browsePath, - iconPath: new ThemeIcon('folder'), - }, - ]; - - if (getWorkspaceFolders()?.length) { - items.push( - { - label: '', - kind: QuickPickItemKind.Separator, - }, - { - label: Interpreter.createVirtualEnvironment, - iconPath: new ThemeIcon('add'), - }, - ); - } - - if (options?.recommended) { - const pathDescription = options.recommended.displayPath; - const description = - options.recommended.description && options.recommended.description.trim() - ? `${options.recommended.description} (${pathDescription})` - : pathDescription; - - items.push( - { - label: Common.recommended, - kind: QuickPickItemKind.Separator, - }, - { - label: options.recommended.displayName, - description: description, - result: options.recommended, - iconPath: getIconPath(options.recommended.iconPath), - }, - ); - } - - for (const manager of managers) { - items.push({ - label: manager.displayName, - kind: QuickPickItemKind.Separator, - }); - const envs = await manager.getEnvironments('all'); - items.push( - ...envs.map((e) => { - const pathDescription = e.displayPath; - const description = - e.description && e.description.trim() ? `${e.description} (${pathDescription})` : pathDescription; - - return { - label: e.displayName ?? e.name, - description: description, - result: e, - manager: manager, - iconPath: getIconPath(e.iconPath), - }; - }), - ); - } - - return pickEnvironmentImpl(items, managers, projectEnvManagers, options); + return pickEnvironmentImpl(managers, projectEnvManagers, options); } export async function pickEnvironmentFrom(environments: PythonEnvironment[]): Promise { diff --git a/src/common/window.apis.ts b/src/common/window.apis.ts index 13ee0393c..c094b080a 100644 --- a/src/common/window.apis.ts +++ b/src/common/window.apis.ts @@ -152,6 +152,13 @@ export interface QuickPickButtonEvent { readonly button: QuickInputButton; } +export interface QuickPickWithButtonsOptions extends QuickPickOptions { + showBackButton?: boolean; + buttons?: QuickInputButton[]; + selected?: T[]; + buttonHandler?: (button: QuickInputButton, quickPick: QuickPick) => void | Thenable; +} + export function showQuickPick( items: readonly T[] | Thenable, options?: QuickPickOptions, @@ -175,7 +182,7 @@ export function withProgress( export async function showQuickPickWithButtons( items: readonly T[], - options?: QuickPickOptions & { showBackButton?: boolean; buttons?: QuickInputButton[]; selected?: T[] }, + options?: QuickPickWithButtonsOptions, token?: CancellationToken, itemButtonHandler?: (e: QuickPickItemButtonEvent) => void, ): Promise { @@ -206,8 +213,20 @@ export async function showQuickPickWithButtons( deferred.reject(QuickInputButtons.Back); quickPick.hide(); } else if (options?.buttons?.includes(button)) { - deferred.reject({ item: quickPick.selectedItems, button }); - quickPick.hide(); + if (options.buttonHandler) { + const buttonHandler = options.buttonHandler; + void Promise.resolve() + .then(() => buttonHandler(button, quickPick)) + .catch((error) => { + if (!deferred.completed) { + deferred.reject(error); + quickPick.hide(); + } + }); + } else { + deferred.reject({ item: quickPick.selectedItems, button }); + quickPick.hide(); + } } }), quickPick.onDidAccept(() => { diff --git a/src/test/common/apiWrappers.unit.test.ts b/src/test/common/apiWrappers.unit.test.ts index ccc814ab3..acc01b31c 100644 --- a/src/test/common/apiWrappers.unit.test.ts +++ b/src/test/common/apiWrappers.unit.test.ts @@ -1,8 +1,19 @@ import assert from 'node:assert'; import { reset, verify, when } from 'ts-mockito'; -import { Disposable, MessageItem, MessageOptions, TextDocument, WindowState } from 'vscode'; +import { + Disposable, + MessageItem, + MessageOptions, + QuickInputButton, + QuickPick, + QuickPickItem, + TextDocument, + ThemeIcon, + WindowState, +} from 'vscode'; import { executeCommand, registerCommand } from '../../common/command.api'; -import { onDidChangeWindowState, showWarningMessage } from '../../common/window.apis'; +import { createDeferred } from '../../common/utils/deferred'; +import { onDidChangeWindowState, showQuickPickWithButtons, showWarningMessage } from '../../common/window.apis'; import { onDidOpenTextDocument } from '../../common/workspace.apis'; import { mockedVSCodeNamespaces } from '../unittests'; @@ -11,6 +22,47 @@ suite('Typed API wrappers', () => { const window = mockedVSCodeNamespaces.window!; const workspace = mockedVSCodeNamespaces.workspace!; + function createQuickPickHarness() { + const buttonListeners = new Set<(button: QuickInputButton) => void>(); + const acceptListeners = new Set<() => void>(); + const hideListeners = new Set<() => void>(); + let hideCount = 0; + let disposeCount = 0; + const register = (listeners: Set<(event: T) => void>, listener: (event: T) => void) => { + listeners.add(listener); + return new Disposable(() => listeners.delete(listener)); + }; + const quickPick = { + items: [], + buttons: [], + selectedItems: [], + activeItems: [], + onDidTriggerButton: (listener: (button: QuickInputButton) => void) => register(buttonListeners, listener), + onDidAccept: (listener: () => void) => register(acceptListeners, listener), + onDidHide: (listener: () => void) => register(hideListeners, listener), + onDidTriggerItemButton: () => new Disposable(() => undefined), + show: () => undefined, + hide: () => { + hideCount += 1; + hideListeners.forEach((listener) => listener()); + }, + dispose: () => { + disposeCount += 1; + }, + } as unknown as QuickPick; + return { + quickPick, + triggerButton: (button: QuickInputButton) => buttonListeners.forEach((listener) => listener(button)), + hide: () => quickPick.hide(), + get hideCount() { + return hideCount; + }, + get disposeCount() { + return disposeCount; + }, + }; + } + teardown(() => { reset(commands); reset(window); @@ -115,4 +167,72 @@ suite('Typed API wrappers', () => { assert.strictEqual(selected, action); assert.strictEqual(selected?.id, 1); }); + + test('keeps a quick pick open when a custom button handler succeeds', async () => { + const harness = createQuickPickHarness(); + when(window.createQuickPick()).thenReturn(harness.quickPick); + const button: QuickInputButton = { iconPath: new ThemeIcon('refresh') }; + const handlerComplete = createDeferred(); + let handled = false; + const result = showQuickPickWithButtons([{ label: 'Environment' }], { + buttons: [button], + buttonHandler: () => { + handled = true; + return handlerComplete.promise; + }, + }); + + harness.triggerButton(button); + await Promise.resolve(); + + assert.strictEqual(handled, true); + assert.strictEqual(harness.hideCount, 0); + handlerComplete.resolve(); + await handlerComplete.promise; + assert.strictEqual(harness.hideCount, 0); + harness.hide(); + assert.strictEqual(await result, undefined); + assert.strictEqual(harness.disposeCount, 1); + }); + + test('preserves legacy custom button rejection and hide behavior', async () => { + const harness = createQuickPickHarness(); + when(window.createQuickPick()).thenReturn(harness.quickPick); + const button: QuickInputButton = { iconPath: new ThemeIcon('edit') }; + const item = { label: 'Environment' }; + const result = showQuickPickWithButtons([item], { buttons: [button], selected: [item] }); + + harness.triggerButton(button); + + await assert.rejects( + result, + (error) => + typeof error === 'object' && + error !== null && + 'button' in error && + error.button === button && + 'item' in error && + Array.isArray(error.item) && + error.item[0] === item, + ); + assert.strictEqual(harness.hideCount, 1); + }); + + test('rejects and hides a quick pick when its custom button handler fails', async () => { + const harness = createQuickPickHarness(); + when(window.createQuickPick()).thenReturn(harness.quickPick); + const button: QuickInputButton = { iconPath: new ThemeIcon('refresh') }; + const failure = new Error('button handler failed'); + const result = showQuickPickWithButtons([{ label: 'Environment' }], { + buttons: [button], + buttonHandler: () => { + throw failure; + }, + }); + + harness.triggerButton(button); + + await assert.rejects(result, (error) => error === failure); + assert.strictEqual(harness.hideCount, 1); + }); }); diff --git a/src/test/common/environmentPicker.unit.test.ts b/src/test/common/environmentPicker.unit.test.ts index f93ba6714..3729b8e52 100644 --- a/src/test/common/environmentPicker.unit.test.ts +++ b/src/test/common/environmentPicker.unit.test.ts @@ -4,12 +4,22 @@ import assert from 'node:assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { CancellationTokenSource, Uri } from 'vscode'; +import { + CancellationTokenSource, + QuickInputButtons, + QuickPick, + QuickPickItem, + Uri, +} from 'vscode'; import { PythonEnvironment } from '../../api'; -import { Interpreter } from '../../common/localize'; +import * as errorUtils from '../../common/errors/utils'; +import { Interpreter, Pickers } from '../../common/localize'; +import * as logging from '../../common/logging'; import { pickEnvironment } from '../../common/pickers/environments'; import * as managerPicker from '../../common/pickers/managers'; +import { createDeferred } from '../../common/utils/deferred'; import * as pythonPath from '../../common/utils/pythonPath'; +import type { QuickPickWithButtonsOptions } from '../../common/window.apis'; import * as windowApis from '../../common/window.apis'; import * as workspaceApis from '../../common/workspace.apis'; import type { InternalEnvironmentManager } from '../../managers/common/registeredManagers'; @@ -75,6 +85,233 @@ suite('Environment Picker Creation Availability', () => { }); }); +suite('Environment Picker Refresh', () => { + interface TestEnvironmentPickItem extends QuickPickItem { + readonly result?: PythonEnvironment; + } + + interface TestQuickPick extends QuickPick { + fireHide(): void; + } + + type TestPickerOptions = QuickPickWithButtonsOptions; + + function createManager( + id: string, + getEnvironments: sinon.SinonStub, + refresh: sinon.SinonStub, + ): InternalEnvironmentManager { + return { + id, + displayName: id, + supportsCreate: false, + getEnvironments, + refresh, + } as unknown as InternalEnvironmentManager; + } + + function createQuickPick( + items: readonly TestEnvironmentPickItem[], + activeItem?: TestEnvironmentPickItem, + ): TestQuickPick { + let hideListener: (() => void) | undefined; + return { + items, + activeItems: activeItem ? [activeItem] : [], + busy: false, + enabled: true, + keepScrollPosition: false, + onDidHide: (listener: () => void) => { + hideListener = listener; + return { + dispose: () => { + hideListener = undefined; + }, + }; + }, + fireHide: () => hideListener?.(), + } as unknown as TestQuickPick; + } + + function getRefreshHandler(options: TestPickerOptions) { + const button = options.buttons?.[0]; + const handler = options.buttonHandler; + assert.ok(button, 'Expected a refresh button'); + assert.ok(handler, 'Expected a refresh button handler'); + return { button, handler }; + } + + setup(() => { + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns(undefined); + }); + + teardown(() => { + sinon.restore(); + }); + + test('refreshes every manager, rebuilds items, and preserves the recommended selection', async () => { + const beforeRefresh = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'current', 'python'), + id: 'current-environment', + managerId: 'first-manager', + displayName: 'Current Python', + }); + const afterRefresh = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), 'current', 'python'), + id: 'current-environment', + managerId: 'first-manager', + displayName: 'Current Python (refreshed)', + }); + const firstGetEnvironments = sinon + .stub() + .onFirstCall() + .resolves([beforeRefresh]) + .onSecondCall() + .resolves([afterRefresh]); + const secondGetEnvironments = sinon.stub().resolves([]); + const firstRefresh = sinon.stub().resolves(); + const secondRefresh = sinon.stub().resolves(); + const managers = [ + createManager('first-manager', firstGetEnvironments, firstRefresh), + createManager('second-manager', secondGetEnvironments, secondRefresh), + ]; + + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items, options) => { + const initialItems = items as readonly TestEnvironmentPickItem[]; + const recommendedItem = initialItems.find((item) => item.result === beforeRefresh); + assert.strictEqual(recommendedItem?.result, beforeRefresh); + const quickPick = createQuickPick(initialItems, recommendedItem); + const { button, handler } = getRefreshHandler(options as TestPickerOptions); + + assert.strictEqual(button.tooltip, Pickers.Environments.refresh); + await handler(button, quickPick); + + const refreshedItems = quickPick.items as readonly TestEnvironmentPickItem[]; + const refreshedRecommended = refreshedItems.find((item) => item.result === beforeRefresh); + assert.ok(refreshedItems.some((item) => item.result === afterRefresh)); + assert.strictEqual(quickPick.activeItems[0], refreshedRecommended); + assert.strictEqual(quickPick.keepScrollPosition, true); + assert.strictEqual(quickPick.busy, false); + assert.strictEqual(quickPick.enabled, true); + return undefined; + }); + + assert.strictEqual( + await pickEnvironment(managers, [], { projects: [], recommended: beforeRefresh }), + undefined, + ); + sinon.assert.calledOnceWithExactly(firstRefresh, undefined); + sinon.assert.calledOnceWithExactly(secondRefresh, undefined); + sinon.assert.calledTwice(firstGetEnvironments); + sinon.assert.calledTwice(secondGetEnvironments); + }); + + test('coalesces duplicate refresh clicks while a refresh is running', async () => { + const refreshComplete = createDeferred(); + const refresh = sinon.stub().returns(refreshComplete.promise); + const manager = createManager('test-manager', sinon.stub().resolves([]), refresh); + + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items, options) => { + const quickPick = createQuickPick(items as readonly TestEnvironmentPickItem[]); + const { button, handler } = getRefreshHandler(options as TestPickerOptions); + + const first = handler(button, quickPick); + const second = handler(button, quickPick); + sinon.assert.calledOnce(refresh); + + refreshComplete.resolve(); + await Promise.all([first, second]); + return undefined; + }); + + assert.strictEqual(await pickEnvironment([manager], [], { projects: [] }), undefined); + sinon.assert.calledOnce(refresh); + }); + + test('does not rebuild or notify after the picker is cancelled during refresh', async () => { + const refreshComplete = createDeferred(); + const getEnvironments = sinon.stub().resolves([]); + const manager = createManager('test-manager', getEnvironments, sinon.stub().returns(refreshComplete.promise)); + const showError = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items, options) => { + const initialItems = items as readonly TestEnvironmentPickItem[]; + const quickPick = createQuickPick(initialItems); + const { button, handler } = getRefreshHandler(options as TestPickerOptions); + + const refreshing = handler(button, quickPick); + quickPick.fireHide(); + refreshComplete.resolve(); + await refreshing; + + assert.strictEqual(quickPick.items, initialItems); + return undefined; + }); + + assert.strictEqual(await pickEnvironment([manager], [], { projects: [] }), undefined); + sinon.assert.calledOnce(getEnvironments); + sinon.assert.notCalled(showError); + }); + + test('reports refresh and rebuild failures without blocking the existing picker', async () => { + const getEnvironments = sinon + .stub() + .onFirstCall() + .resolves([]) + .onSecondCall() + .rejects(new Error('rebuild failed')); + const refresh = sinon + .stub() + .onFirstCall() + .resolves() + .onSecondCall() + .rejects(new Error('refresh failed')); + const manager = createManager('test-manager', getEnvironments, refresh); + const notificationComplete = createDeferred(); + const showError = sinon.stub(errorUtils, 'showErrorMessageWithLogs').returns(notificationComplete.promise); + const traceError = sinon.stub(logging, 'traceError'); + + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items, options) => { + const initialItems = items as readonly TestEnvironmentPickItem[]; + const quickPick = createQuickPick(initialItems); + const { button, handler } = getRefreshHandler(options as TestPickerOptions); + + await handler(button, quickPick); + await handler(button, quickPick); + + assert.strictEqual(quickPick.items, initialItems); + assert.strictEqual(quickPick.busy, false); + assert.strictEqual(quickPick.enabled, true); + return undefined; + }); + + assert.strictEqual(await pickEnvironment([manager], [], { projects: [] }), undefined); + sinon.assert.calledTwice(refresh); + sinon.assert.calledOnceWithExactly(showError, Pickers.Environments.refreshFailed); + sinon.assert.calledTwice(getEnvironments); + sinon.assert.calledTwice(traceError); + notificationComplete.resolve(); + }); + + test('preserves back and cancel semantics when the refresh button is present', async () => { + const picker = sinon.stub(windowApis, 'showQuickPickWithButtons'); + picker.callsFake(async (_items, options) => { + const pickerOptions = options as TestPickerOptions; + assert.strictEqual(pickerOptions.showBackButton, true); + assert.strictEqual(pickerOptions.buttons?.[0].tooltip, Pickers.Environments.refresh); + throw QuickInputButtons.Back; + }); + + await assert.rejects( + pickEnvironment([], [], { projects: [], showBackButton: true }), + (error) => error === QuickInputButtons.Back, + ); + + picker.resolves(undefined); + assert.strictEqual(await pickEnvironment([], [], { projects: [] }), undefined); + }); +}); + suite('Environment Picker Interpreter Path Entry', () => { const interpreter = Uri.file(path.join(process.cwd(), 'project with spaces', '.venv', 'python')); const environment = createMockPythonEnvironment({ envPath: interpreter.fsPath });