From df6fbb6d214fb7d34b3cf194b50e465a2d0330ea Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Tue, 3 Nov 2026 22:24:23 +0100 Subject: [PATCH] fix(tools): offer to install only the first missing server of interchangeable ones (#1082) Opening a Python file with none of basedpyright, Pyright, ty and Pyrefly installed raised four install notices at once: each fallback server reports missing after the one before it, and every missing server was prompted. Among interchangeable servers only the first missing one is offered now (events handled one at a time so a fallback sees whether the server before it was offered); a fallback is still offered when the servers before it can't be installed or are turned off. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- src/features/tools/__tests__/tools.test.tsx | 47 +++++++++++++++++++++ src/features/tools/missing.ts | 23 +++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 49c64365..260a2d49 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -158,6 +158,53 @@ describe("missing language server prompt", () => { off(); }); + it("offers only the first missing server of interchangeable ones", async () => { + const off = installMissingServerPrompts(); + // basedpyright is missing, so its fallbacks start and turn out missing too; gopls isn't one of them. + ["basedpyright", "pyright", "ty", "pyrefly", "gopls"].forEach((name, i) => + emitLocalIdeEvent({ type: "languageServer", server: 10 + i, name, status: "missing", message: null }), + ); + await waitFor(() => expect(notes).toHaveLength(2)); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(notes.map((n) => n.key)).toEqual([missingKey("basedpyright"), missingKey("gopls")]); + off(); + }); + + it("offers a fallback when the servers before it can't be installed", async () => { + setToolsBackend( + new MemoryToolsBackend( + sampleTools().map((t) => (t.name === "basedpyright" ? { ...t, canInstall: false } : t)), + 1, + ), + ); + const off = installMissingServerPrompts(); + for (const [i, name] of ["basedpyright", "pyright", "ty"].entries()) { + emitLocalIdeEvent({ type: "languageServer", server: 20 + i, name, status: "missing", message: null }); + } + await waitFor(() => expect(notes).toHaveLength(1)); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(notes.map((n) => n.key)).toEqual([missingKey("pyright")]); + off(); + }); + + it("offers the first server that reports missing when earlier ones are off from the start", async () => { + const off = installMissingServerPrompts(); + // basedpyright turned off in settings never starts, so never reports. + for (const [i, name] of ["pyright", "ty"].entries()) { + emitLocalIdeEvent({ type: "languageServer", server: 40 + i, name, status: "missing", message: null }); + } + await waitFor(() => expect(notes).toHaveLength(1)); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(notes.map((n) => n.key)).toEqual([missingKey("pyright")]); + off(); + }); + it("stays silent for tools without an installer", async () => { const off = installMissingServerPrompts(); emitLocalIdeEvent({ type: "languageServer", server: 2, name: "solargraph", status: "missing", message: null }); diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index 6bd53191..cbed9907 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -12,10 +12,21 @@ import { tx } from "@/platform/i18n"; export const missingKey = (name: string) => `lsp-missing-${name}`; -export async function promptInstall(name: string, sticky = true): Promise { +/** + * `offered`: servers already offered for install. A fallback of one of them (pyright + * after basedpyright) stays quiet: installing the first is enough, and the Language + * Servers… page offers the others. + */ +export async function promptInstall( + name: string, + sticky = true, + offered: ReadonlySet = new Set(), +): Promise { if (!useTools.getState().loaded) await refreshTools(); const tool = toolByName(name); if (!tool || !tool.canInstall || tool.status !== "missing") return false; + const position = tool.alternatives.indexOf(name); + if (position > 0 && tool.alternatives.slice(0, position).some((n) => offered.has(n))) return false; // An installed alternative (typescript-language-server for vtsls) already serves these files. const served = useTools .getState() @@ -49,11 +60,19 @@ export async function promptInstall(name: string, sticky = true): Promise void { const prompted = new Set(); + const offered = new Set(); + // One at a time: a missing server's fallback reports missing right after it, and + // must see whether the server's own notice went up. + let queue = Promise.resolve(); return onIdeEvent("languageServer", (e) => { if (e.status !== "missing" || prompted.has(e.name)) return; prompted.add(e.name); // Async on purpose: lands after the synchronous generic notice and replaces it. - void promptInstall(e.name).catch(() => {}); + queue = queue + .then(async () => { + if (await promptInstall(e.name, true, offered)) offered.add(e.name); + }) + .catch(() => {}); }); }