mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 工具窗口的标签页显示,提交时打开对话框。",
|
||||
|
||||
Reference in New Issue
Block a user