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 <languages>: 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.
This commit is contained in:
l0ng-ai
2026-10-30 10:56:00 +08:00
committed by GitHub
parent 5792e7d9b6
commit b1be3c6a09
9 changed files with 159 additions and 75 deletions
+23
View File
@@ -77,6 +77,9 @@ pub struct ToolInfo {
pub default_args: Vec<String>,
/// A language server's `initializationOptions` from its language definition, as JSON text.
pub default_initialization_options: Option<String>,
/// 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<String>,
}
/// Streamed install progress.
@@ -224,6 +227,7 @@ struct Usage {
install_hint: Option<String>,
args: Vec<String>,
initialization_options: Option<String>,
alternatives: Vec<String>,
}
fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
@@ -266,6 +270,23 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
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<String> = 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)))
}
+14
View File
@@ -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() {
+5
View File
@@ -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" |
@@ -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;
+2 -43
View File
@@ -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<string, ServerEntry>): 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<ToolInfo["status"], "success" | "accent" | "warning"> = {
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.",
)}
</p>
<div className={styles.pythonAnalyzer}>
<div className={styles.pythonAnalyzerRow}>
<span>
<T>Python language server</T>
</span>
<Select
aria-label="Python language server"
value={selectedPythonAnalyzer(servers)}
options={PYTHON_ANALYZER_OPTIONS}
onChange={(selected) => {
if (selected === "custom") return;
for (const name of PYTHON_ANALYZERS) {
const on = selected === "auto" || selected === name;
// Same as each server's own checkbox: on is the default, so only a project spells it out.
form.set(serverKey(name, "enabled"), on ? (form.scope === "project" ? true : null) : false);
}
}}
/>
</div>
<p className={styles.pythonAnalyzerHint}>
<T>Only one of these built-in Python analyzers runs at a time.</T>
</p>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
+76 -9
View File
@@ -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<LanguageChoice[]> | 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<string, ServerEntry>;
const names = tool.alternatives;
const members = names.map((name) => tools.find((t) => t.name === name));
const common = members.reduce<string[]>(
(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 (
<Row label={label} hint={tx`Only one of these runs at a time. Automatic uses the first one available: ${order}.`}>
<Select
aria-label={label}
value={value}
placeholder={tx("Custom combination")}
options={options}
onChange={(choice) => {
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);
}
}}
/>
</Row>
);
}
/** 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 (
<div className={styles.form}>
<Checkbox
checked={entry.enabled !== false}
label={
languages.length ? tx`Use ${tool.displayName} for ${languages.join(", ")} files` : tx`Use ${tool.displayName}`
}
// Enabled is the default; a project says so explicitly to win over a global "off".
onChange={(on) => set("enabled")(on ? (form.scope === "project" ? true : null) : false)}
/>
{tool.alternatives.length > 1 ? (
<AlternativesRow tool={tool} />
) : (
<Checkbox
checked={entry.enabled !== false}
label={
languages.length
? tx`Use ${tool.displayName} for ${languages.join(", ")} files`
: tx`Use ${tool.displayName}`
}
// Enabled is the default; a project says so explicitly to win over a global "off".
onChange={(on) => set("enabled")(on ? (form.scope === "project" ? true : null) : false)}
/>
)}
<Section title="Executable">
<ExecutableRow tool={tool} pending={pending} />
@@ -73,13 +73,35 @@ describe("Language Servers page settings", () => {
afterEach(() => setToolsBackend(null));
const pythonPicker = async (user: ReturnType<typeof userEvent.setup>) => {
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", () => {
<LanguageServersPage />
</TestSettingsForm>,
);
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);
+10 -1
View File
@@ -70,6 +70,7 @@ function tool(partial: Partial<ToolInfo> & Pick<ToolInfo, "name" | "languages">)
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",
+3 -3
View File
@@ -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 工具窗口的标签页显示,提交时打开对话框。",