From b1be3c6a090638d3c7e5faa6546decb4dea985be Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:56:00 +0800 Subject: [PATCH] feat(tools): pick among interchangeable language servers for every language (#313) A language server and the fallback servers after it in languages/*.toml now share one picker in the Language Servers details pane, for every language rather than Python only: Language server for : Automatic, each server (with its status) or None, with the Automatic order in the hint. Servers outside such a group keep their on/off checkbox. ToolInfo.alternatives lists each server's group in preference order; a regular server after a fallback starts no group with it. The Python-only picker at the top of the page is removed, and names drop a language suffix the label already states. Storage is unchanged (each server's enabled flag, global or project scope), so existing settings carry over. --- crates/ide-tools/src/lib.rs | 23 +++++ crates/ide-tools/src/tests.rs | 14 +++ src/bindings.ts | 5 ++ .../tools/LanguageServersPage.module.css | 15 ---- src/features/tools/LanguageServersPage.tsx | 45 +--------- src/features/tools/ServerSettings.tsx | 85 +++++++++++++++++-- .../tools/__tests__/serverSettings.test.tsx | 30 ++++++- src/features/tools/backend.ts | 11 ++- src/platform/locales/text/zh.json | 6 +- 9 files changed, 159 insertions(+), 75 deletions(-) diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 45ca0951..ccbeb890 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -77,6 +77,9 @@ pub struct ToolInfo { pub default_args: Vec, /// A language server's `initializationOptions` from its language definition, as JSON text. pub default_initialization_options: Option, + /// The interchangeable servers this one belongs to, in preference order: a + /// server and the `fallback` ones after it. Empty when it has none. + pub alternatives: Vec, } /// Streamed install progress. @@ -224,6 +227,7 @@ struct Usage { install_hint: Option, args: Vec, initialization_options: Option, + alternatives: Vec, } fn usages(langs: &[LanguageConfig]) -> BTreeMap { @@ -266,6 +270,23 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap { s.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).map(|v| v.to_string()); } } + for l in langs { + let servers = &l.language_servers; + for (i, head) in servers.iter().enumerate().filter(|(_, s)| !s.fallback) { + let chain: Vec = std::iter::once(head) + .chain(servers[i + 1..].iter().take_while(|s| s.fallback)) + .map(|s| s.name.clone()) + .collect(); + if chain.len() < 2 { + continue; + } + for name in &chain { + if let Some(u) = map.get_mut(name).filter(|u| u.alternatives.is_empty()) { + u.alternatives = chain.clone(); + } + } + } + } map } @@ -480,6 +501,7 @@ impl Tools { custom_error: None, default_args: u.args.clone(), default_initialization_options: u.initialization_options.clone(), + alternatives: u.alternatives.clone(), }) .map(|info| self.apply_custom(info, None)); }; @@ -504,6 +526,7 @@ impl Tools { custom_error: None, default_args: usage.map(|u| u.args.clone()).unwrap_or_default(), default_initialization_options: usage.and_then(|u| u.initialization_options.clone()), + alternatives: usage.map(|u| u.alternatives.clone()).unwrap_or_default(), }) .map(|info| self.apply_custom(info, Some(spec))) } diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 2167734a..b9a2ec48 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -88,6 +88,20 @@ fn everything_missing_on_an_empty_system() { assert!(find(&list, "delve").kind == ToolKind::Debugger); } +#[test] +fn interchangeable_servers_list_their_alternatives() { + let env = Env::new(); + let list = env.tools().list(&langs()); + let python = ["basedpyright", "pyright", "ty"]; + for name in python { + assert_eq!(find(&list, name).alternatives, python); + } + assert_eq!(find(&list, "vtsls").alternatives, ["vtsls", "typescript-language-server"]); + // A regular server after a fallback starts no group with it. + assert!(find(&list, "angular-language-server").alternatives.is_empty()); + assert!(find(&list, "rust-analyzer").alternatives.is_empty()); +} + #[cfg(unix)] #[test] fn detects_tools_on_path_with_versions() { diff --git a/src/bindings.ts b/src/bindings.ts index 401a6f5d..a1819fd4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5890,6 +5890,11 @@ export type ToolInfo = { defaultArgs: string[], /** A language server's `initializationOptions` from its language definition, as JSON text. */ defaultInitializationOptions: string | null, + /** + * The interchangeable servers this one belongs to, in preference order: a + * server and the `fallback` ones after it. Empty when it has none. + */ + alternatives: string[], }; export type ToolKind = "languageServer" | "debugger" | diff --git a/src/features/tools/LanguageServersPage.module.css b/src/features/tools/LanguageServersPage.module.css index 926d001a..1c1eff93 100644 --- a/src/features/tools/LanguageServersPage.module.css +++ b/src/features/tools/LanguageServersPage.module.css @@ -11,21 +11,6 @@ max-width: 680px; color: var(--fg-secondary); } -.pythonAnalyzer { - display: flex; - flex-direction: column; - gap: 4px; -} -.pythonAnalyzerRow { - display: flex; - align-items: center; - gap: 10px; -} -.pythonAnalyzerHint { - margin: 0; - color: var(--fg-secondary); - font-size: var(--fs-small); -} .body { display: flex; flex: 1; diff --git a/src/features/tools/LanguageServersPage.tsx b/src/features/tools/LanguageServersPage.tsx index 6b0d722c..933b34cd 100644 --- a/src/features/tools/LanguageServersPage.tsx +++ b/src/features/tools/LanguageServersPage.tsx @@ -8,7 +8,7 @@ import { useEffect, useMemo, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import type { ToolInfo } from "@/bindings"; -import { Badge, Button, EmptyState, IconButton, ProgressBar, SearchField, Select, Spinner, T } from "@/ui"; +import { Badge, Button, EmptyState, IconButton, ProgressBar, SearchField, Spinner, T } from "@/ui"; import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { useSettingsForm } from "@/workbench/contributions"; @@ -28,25 +28,6 @@ import { tx } from "@/platform/i18n"; type Filter = "all" | "missing" | "installed"; -const PYTHON_ANALYZERS = ["basedpyright", "pyright", "ty"] as const; -type PythonAnalyzer = (typeof PYTHON_ANALYZERS)[number] | "auto" | "none" | "custom"; - -function selectedPythonAnalyzer(servers: Record): PythonAnalyzer { - const enabled = PYTHON_ANALYZERS.filter((name) => servers[name]?.enabled !== false); - if (enabled.length === PYTHON_ANALYZERS.length) return "auto"; - if (enabled.length === 0) return "none"; - return enabled.length === 1 ? enabled[0]! : "custom"; -} - -const PYTHON_ANALYZER_OPTIONS: ReadonlyArray<{ value: PythonAnalyzer; label: string; disabled?: boolean }> = [ - { value: "auto", label: "Automatic (first available)" }, - { value: "basedpyright", label: "basedpyright" }, - { value: "pyright", label: "Pyright" }, - { value: "ty", label: "ty" }, - { value: "none", label: "None" }, - { value: "custom", label: "Custom combination", disabled: true }, -]; - const STATUS_TONE: Record = { installed: "success", onPath: "accent", @@ -90,6 +71,7 @@ function pendingTool(name: string, entry: ServerEntry, languages: string[]): Too customError: null, defaultArgs: [], defaultInitializationOptions: null, + alternatives: [], }; } @@ -336,29 +318,6 @@ export function LanguageServersPage() { "Language servers and debug adapters used by the IDE. Missing tools can be installed into the IDE's own tools folder; nothing is downloaded until you click Install.", )}

-
-
- - Python language server - - { + for (const name of names) { + const on = choice === "auto" || choice === name; + // Same as the on/off box: on is the default, so only a project spells it out. + form.set(serverKey(name, "enabled"), on ? (form.scope === "project" ? true : null) : false); + } + }} + /> + + ); +} + /** A language server: on/off, executable, startup options, workspace configuration. */ export function ServerSettings({ tool, pending }: { tool: ToolInfo; pending: boolean }) { const form = useSettingsForm(); @@ -372,14 +433,20 @@ export function ServerSettings({ tool, pending }: { tool: ToolInfo; pending: boo return (
- set("enabled")(on ? (form.scope === "project" ? true : null) : false)} - /> + {tool.alternatives.length > 1 ? ( + + ) : ( + set("enabled")(on ? (form.scope === "project" ? true : null) : false)} + /> + )}
diff --git a/src/features/tools/__tests__/serverSettings.test.tsx b/src/features/tools/__tests__/serverSettings.test.tsx index 882a1d24..2f96403d 100644 --- a/src/features/tools/__tests__/serverSettings.test.tsx +++ b/src/features/tools/__tests__/serverSettings.test.tsx @@ -73,13 +73,35 @@ describe("Language Servers page settings", () => { afterEach(() => setToolsBackend(null)); + const pythonPicker = async (user: ReturnType) => { + await waitFor(() => expect(listItem("pyright")).not.toBeNull()); + await user.click(listItem("pyright")!); + // No on/off box: the group's picker replaces it. + expect(within(details("Pyright")).queryByRole("checkbox", { name: /^Use Pyright/ })).toBeNull(); + return within(details("Pyright")).getByRole("button", { name: "Language server for Python" }); + }; + + it("lists one picker per group of interchangeable servers, on each member", async () => { + const user = userEvent.setup(); + renderPage(); + await pythonPicker(user); + await user.click(listItem("ty")!); + const ty = details("ty (Python)"); + expect(within(ty).getByRole("button", { name: "Language server for Python" })).toHaveTextContent("Automatic"); + // The language is in the label already, so names drop a "(Python)" suffix. + expect(within(ty).getByText(/basedpyright → Pyright → ty\.$/)).toBeInTheDocument(); + await user.click(listItem("gopls")!); + expect(within(details("gopls")).getByRole("checkbox", { name: /Use gopls/ })).toBeInTheDocument(); + expect(within(details("gopls")).queryByRole("button", { name: /^Language server for/ })).toBeNull(); + }); + it("selects exactly one Python analyzer and restores automatic fallback without writing defaults", async () => { const user = userEvent.setup(); renderPage(); - const picker = screen.getByRole("button", { name: "Python language server" }); + const picker = await pythonPicker(user); expect(picker).toHaveTextContent("Automatic"); await user.click(picker); - await user.click(screen.getByRole("option", { name: "ty" })); + await user.click(screen.getByRole("option", { name: /^ty/ })); expect(stagedFor("languageServers.basedpyright.enabled")).toBe(false); expect(stagedFor("languageServers.pyright.enabled")).toBe(false); expect(stagedFor("languageServers.ty.enabled")).toBeNull(); @@ -99,9 +121,9 @@ describe("Language Servers page settings", () => { , ); - const picker = screen.getByRole("button", { name: "Python language server" }); + const picker = await pythonPicker(user); await user.click(picker); - await user.click(screen.getByRole("option", { name: "Pyright" })); + await user.click(screen.getByRole("option", { name: /^Pyright/ })); expect(stagedFor("languageServers.basedpyright.enabled")).toBe(false); expect(stagedFor("languageServers.pyright.enabled")).toBe(true); expect(stagedFor("languageServers.ty.enabled")).toBe(false); diff --git a/src/features/tools/backend.ts b/src/features/tools/backend.ts index 4aca205f..f0ae5756 100644 --- a/src/features/tools/backend.ts +++ b/src/features/tools/backend.ts @@ -70,6 +70,7 @@ function tool(partial: Partial & Pick) customError: null, defaultArgs: [], defaultInitializationOptions: null, + alternatives: [], ...partial, }; } @@ -93,7 +94,15 @@ export function sampleTools(): ToolInfo[] { version: "4.3.3", path: "/opt/homebrew/bin/typescript-language-server", }), - tool({ name: "basedpyright", languages: ["Python"], installMethod: "pip (virtualenv)" }), + ...["basedpyright", "pyright", "ty"].map((name) => + tool({ + name, + displayName: name === "pyright" ? "Pyright" : name === "ty" ? "ty (Python)" : name, + languages: ["Python"], + installMethod: "pip (virtualenv)", + alternatives: ["basedpyright", "pyright", "ty"], + }), + ), tool({ name: "gopls", languages: ["Go"], installMethod: "go install" }), tool({ name: "delve", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b2d2c872..8c396967 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4555,10 +4555,10 @@ "Unversioned": "未跟踪", "Merge conflict": "合并冲突", "by {author}": "由 {author} 创建", - "Automatic (first available)": "自动(第一个可用的)", + "Automatic": "自动", "Custom combination": "自定义组合", - "Python language server": "Python 语言服务器", - "Only one of these built-in Python analyzers runs at a time.": "这些内置 Python 分析器同一时间只运行一个。", + "Language server for {languages}": "{languages} 语言服务器", + "Only one of these runs at a time. Automatic uses the first one available: {order}.": "这些服务器同一时间只运行一个。自动模式使用第一个可用的:{order}。", "Configure the settings related to version control used in your project": "配置项目中使用的版本控制相关设置", "Use non-modal commit interface": "使用非模态提交界面", "When off, Local Changes and Stash are tabs of the Git tool window and Commit opens a dialog.": "关闭时,本地更改和贮藏作为 Git 工具窗口的标签页显示,提交时打开对话框。",