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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,6 @@ export const selectNewLanguageRuntime = async (
return items;
};

await fetchContributedItems();

const disposables = new DisposableStore();
const quickPick = disposables.add(quickInputService.createQuickPick<IQuickPickItem>({ useSeparators: true }));
quickPick.title = options?.title || localize('positron.languageRuntime.startSession', 'Start New Interpreter Session');
Expand Down Expand Up @@ -706,6 +704,20 @@ export const selectNewLanguageRuntime = async (
}));

quickPick.show();

// Fold in contributed items after show() rather than awaiting them first:
// getItems() is an extension-host RPC that can hang for seconds right after
// a window reload, which would leave the picker invisible until it resolves.
// When startup isn't Complete yet, the onDidChangeRuntimeStartupPhase
// handler above does the fetch instead.
if (languageRuntimeService.startupPhase === RuntimeStartupPhase.Complete) {
fetchContributedItems().then(() => {
// Skip if the user dismissed the picker while the fetch was pending.
if (!disposables.isDisposed) {
rebuildItems();
}
});
}
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
import { IQuickInputService, IQuickPickItem, QuickInputHideReason, QuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js';
import { ILanguageRuntimeMetadata, ILanguageRuntimeService, IRuntimePickerContribution, LanguageRuntimeSessionLocation, LanguageRuntimeSessionMode, LanguageRuntimeStartupBehavior, RuntimeStartupPhase } from '../../../../services/languageRuntime/common/languageRuntimeService.js';
import { ILanguageRuntimeMetadata, ILanguageRuntimeService, IRuntimePickerContribution, IRuntimePickerItem, LanguageRuntimeSessionLocation, LanguageRuntimeSessionMode, LanguageRuntimeStartupBehavior, RuntimeStartupPhase } from '../../../../services/languageRuntime/common/languageRuntimeService.js';
import { IRuntimeStartupService } from '../../../../services/runtimeStartup/common/runtimeStartupService.js';
import { stubInterface } from '../../../../../test/vitest/stubInterface.js';
import { TestQuickPick } from '../../../../../test/vitest/testQuickPick.js';
Expand Down Expand Up @@ -78,9 +78,11 @@ describe('selectNewLanguageRuntime', () => {
return ctx.instantiationService.invokeFunction(accessor => selectNewLanguageRuntime(accessor, options));
}

// The helper does `await fetchContributedItems()` before setting up the
// picker, so the items array isn't populated until pick.show() is called.
// Poll until that happens before reading items / firing events from tests.
// The helper builds the runtime rows synchronously and calls pick.show()
// immediately; contributed items (from picker contributions) are fetched
// afterwards and folded in via a rebuild, so they may not be present the
// instant show() is called. Poll for show() before reading runtime rows;
// poll again (vi.waitFor) when asserting on contributed items.
async function waitUntilOpened(): Promise<void> {
await vi.waitFor(() => expect(pick.show).toHaveBeenCalled());
}
Expand All @@ -99,6 +101,19 @@ describe('selectNewLanguageRuntime', () => {
);
}

function pickItemByLabel(label: string): IQuickPickItem | undefined {
return pick.items.find(
(item): item is IQuickPickItem => item.type !== 'separator' && item.label === label,
);
}

// Contributed items are fetched after show() and folded in via a rebuild,
// so tests must poll for them rather than reading synchronously.
async function waitForItemByLabel(label: string): Promise<IQuickPickItem> {
await vi.waitFor(() => expect(pickItemByLabel(label)).toBeDefined());
return pickItemByLabel(label)!;
}


describe('resolution', () => {
it('resolves undefined when the picker is hidden without acceptance', async () => {
Expand Down Expand Up @@ -402,12 +417,9 @@ describe('selectNewLanguageRuntime', () => {
const promise = runPicker();
await waitUntilOpened();

// A contributed item counts as a selectable row, so the empty-state
// placeholder must NOT appear even though there are no runtimes.
const contributedItem = pick.items.find(
(item): item is IQuickPickItem => item.type !== 'separator' && item.label === 'Install Python via uv',
);
expect(contributedItem).toBeDefined();
// A contributed item counts as a selectable row, so once it arrives the
// empty-state placeholder must NOT appear even though there are no runtimes.
await waitForItemByLabel('Install Python via uv');
expect(pick.busy).toBe(false);
expect(pick.placeholder).toBeUndefined();

Expand All @@ -417,6 +429,42 @@ describe('selectNewLanguageRuntime', () => {
});

describe('contributed items', () => {
it('opens the picker immediately without waiting for slow contributed items', async () => {
// Regression: getItems() is an extension-host RPC that enumerates
// interpreters and can hang for seconds right after a window reload
// while the extension host is still activating. The picker previously
// awaited it before show(), so a slow RPC left the picker invisible --
// clicking the session button appeared to do nothing. show() must now
// happen up front, with contributed items folded in once they resolve.
const runtimeService = ctx.get(ILanguageRuntimeService);
registerRuntime(makeRuntime({ runtimeId: 'py-1' }));

let resolveItems!: (items: IRuntimePickerItem[]) => void;
const contribution: IRuntimePickerContribution = {
handle: 9,
languageId: 'python',
getItems: vi.fn(() => new Promise<IRuntimePickerItem[]>(resolve => { resolveItems = resolve; })),
onSelect: vi.fn(),
};
ctx.disposables.add(runtimeService.registerPickerContribution(contribution));

const promise = runPicker();

// The picker shows even though getItems() has not resolved: runtimes
// are visible immediately, the pending contributed item is not.
await waitUntilOpened();
expect(contribution.getItems).toHaveBeenCalled();
expect(pickItemById('py-1')).toBeDefined();
expect(pickItemByLabel('Install Python via uv')).toBeUndefined();

// Once the slow RPC resolves, the contributed item folds in.
resolveItems([{ id: 'install-uv', label: 'Install Python via uv' }]);
await waitForItemByLabel('Install Python via uv');

pick.cancel(QuickInputHideReason.Gesture);
await promise;
});

it('resolves the registered runtime and triggers a quiet rediscovery on selection', async () => {
const installedRuntime = makeRuntime({ runtimeId: 'py-installed-by-uv' });
const runtimeService = ctx.get(ILanguageRuntimeService);
Expand All @@ -436,8 +484,7 @@ describe('selectNewLanguageRuntime', () => {
const promise = runPicker();
await waitUntilOpened();

const installItem = pick.items
.find((item): item is IQuickPickItem => item.type !== 'separator' && item.label === 'Install Python via uv')!;
const installItem = await waitForItemByLabel('Install Python via uv');
pick.accept(installItem);

await expect(promise).resolves.toEqual(installedRuntime);
Expand All @@ -458,8 +505,7 @@ describe('selectNewLanguageRuntime', () => {
const promise = runPicker();
await waitUntilOpened();

const item = pick.items
.find((it): it is IQuickPickItem => it.type !== 'separator' && it.label === 'No-op installer')!;
const item = await waitForItemByLabel('No-op installer');
pick.accept(item);
await expect(promise).resolves.toBeUndefined();
});
Expand All @@ -478,8 +524,7 @@ describe('selectNewLanguageRuntime', () => {
const promise = runPicker();
await waitUntilOpened();

const item = pick.items
.find((it): it is IQuickPickItem => it.type !== 'separator' && it.label === 'Failing installer')!;
const item = await waitForItemByLabel('Failing installer');
pick.accept(item);
await expect(promise).resolves.toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
Expand Down Expand Up @@ -507,8 +552,7 @@ describe('selectNewLanguageRuntime', () => {
const promise = runPicker();
await waitUntilOpened();

const labels = pick.items.map(i => i.label);
expect(labels).toContain('Working option');
await waitForItemByLabel('Working option');
expect(consoleErrorSpy).toHaveBeenCalled();

pick.cancel(QuickInputHideReason.Gesture);
Expand Down
Loading