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
-
-
-
- Only one of these built-in Python analyzers runs at a time.
-
-
{error && (
{error}
diff --git a/src/features/tools/ServerSettings.tsx b/src/features/tools/ServerSettings.tsx
index d16ff89e..2e9710e4 100644
--- a/src/features/tools/ServerSettings.tsx
+++ b/src/features/tools/ServerSettings.tsx
@@ -7,7 +7,7 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import type { ToolInfo } from "@/bindings";
-import { Checkbox, Dialog, Icon, IconButton, Input, Popup, SearchField, T } from "@/ui";
+import { Checkbox, Dialog, Icon, IconButton, Input, Popup, SearchField, Select, T } from "@/ui";
import { tx } from "@/platform/i18n";
import { useSettingsForm } from "@/workbench/contributions";
import { toolsBackend, type LanguageChoice } from "./backend";
@@ -27,6 +27,7 @@ import {
type ServerEntry,
type ServerField,
} from "./serverConfig";
+import { STATUS_LABEL, useTools } from "./store";
import styles from "./ServerSettings.module.css";
let languageList: Promise
| null = null;
@@ -352,6 +353,66 @@ export function ToolSettings({ tool }: { tool: ToolInfo }) {
);
}
+/**
+ * Interchangeable servers (a server and its `fallback`s): one picker for the
+ * group instead of an on/off box per server. Automatic keeps them all on, so
+ * the first one available runs.
+ */
+function AlternativesRow({ tool }: { tool: ToolInfo }) {
+ const form = useSettingsForm();
+ const tools = useTools((s) => s.tools);
+ const servers = (form.useValue("languageServers") ?? {}) as Record;
+ const names = tool.alternatives;
+ const members = names.map((name) => tools.find((t) => t.name === name));
+ const common = members.reduce(
+ (acc, t) => (t ? acc.filter((l) => t.languages.includes(l)) : acc),
+ tool.languages,
+ );
+ const languages = common.join(", ");
+ // "ty (Python)" is just "ty" next to "Language server for Python".
+ const short = names.map((name, i) => {
+ const display = members[i]?.displayName ?? name;
+ const suffix = /^(.*) \((.+)\)$/.exec(display);
+ return suffix && common.includes(suffix[2]!) ? suffix[1]! : display;
+ });
+ const enabled = names.filter((name) => servers[name]?.enabled !== false);
+ const value =
+ enabled.length === names.length
+ ? "auto"
+ : enabled.length === 0
+ ? "none"
+ : enabled.length === 1
+ ? enabled[0]!
+ : null;
+ const options = [
+ { value: "auto", label: tx("Automatic") },
+ ...names.map((name, i) => {
+ const t = members[i];
+ return { value: name, label: short[i]!, detail: t ? tx(STATUS_LABEL[t.status]) : undefined };
+ }),
+ { value: "none", label: tx("None") },
+ ];
+ const order = short.join(" → ");
+ const label = languages ? tx`Language server for ${languages}` : tx("Language server");
+ return (
+
+
+ );
+}
+
/** 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 工具窗口的标签页显示,提交时打开对话框。",