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(() => {}); }); }