Files
openidea/src/features/tools/LanguageServersPage.tsx
T
l0ng-ai b1be3c6a09 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.
2026-10-30 10:56:00 +08:00

406 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Settings › Languages & Frameworks › Language Servers, laid out like the
* Plugins page: language servers and debug adapters in a list on the left
* (+ adds a server of one's own, − removes it), the selected tool's install
* actions and settings on the right. Settings are staged in the dialog and
* written on Apply / OK; installs run right away.
*/
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import type { ToolInfo } from "@/bindings";
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";
import { languageNames, NewServerDialog, ServerSettings, ToolSettings, useLanguageChoices } from "./ServerSettings";
import { serverKey, type ServerEntry } from "./serverConfig";
import {
cancelInstall,
installTool,
refreshTools,
STATUS_LABEL,
uninstallTool,
useTools,
type InstallJob,
} from "./store";
import styles from "./LanguageServersPage.module.css";
import { tx } from "@/platform/i18n";
type Filter = "all" | "missing" | "installed";
const STATUS_TONE: Record<ToolInfo["status"], "success" | "accent" | "warning"> = {
installed: "success",
onPath: "accent",
custom: "accent",
missing: "warning",
};
const KIND_LABEL: Record<ToolInfo["kind"], string> = {
languageServer: "Language server",
debugger: "Debugger",
plugin: "Plugin",
};
export function filterTools(tools: readonly ToolInfo[], query: string, filter: Filter): ToolInfo[] {
const q = query.trim().toLowerCase();
return tools.filter((t) => {
if (filter === "missing" && t.status !== "missing") return false;
if (filter === "installed" && t.status === "missing") return false;
if (!q) return true;
return [t.name, t.displayName, t.command, ...t.languages].some((s) => s.toLowerCase().includes(q));
});
}
/** A server added in this dialog, listed before Apply checks its executable. */
function pendingTool(name: string, entry: ServerEntry, languages: string[]): ToolInfo {
return {
name,
displayName: name,
kind: "languageServer",
command: entry.command ?? "",
languages,
status: "missing",
path: null,
version: null,
installMethod: null,
canInstall: false,
unavailableReason: null,
canUninstall: false,
installHint: null,
homepage: null,
customError: null,
defaultArgs: [],
defaultInitializationOptions: null,
alternatives: [],
};
}
function JobRow({ name, job }: { name: string; job: InstallJob }) {
return (
<div className={styles.job}>
<ProgressBar value={job.fraction} aria-label={tx`Installing ${name}`} className={styles.jobBar} />
<div className={styles.jobLine}>
<span className={styles.jobMessage} title={job.message ?? undefined}>
{job.message ?? "Starting…"}
</span>
<Button size="small" variant="ghost" onClick={() => cancelInstall(name)}>
<T>Cancel</T>
</Button>
</div>
</div>
);
}
function ToolRow({
tool,
selected,
off,
pending,
onSelect,
}: {
tool: ToolInfo;
selected: boolean;
off: boolean;
pending: boolean;
onSelect: () => void;
}) {
return (
<li className={clsx(styles.row, selected && styles.rowSelected)}>
<button
type="button"
className={clsx(styles.rowMain, off && styles.rowOff)}
aria-current={selected || undefined}
data-tool={tool.name}
title={`${tool.displayName} — ${tool.languages.join(", ")}`}
onClick={onSelect}
>
<Badge dot tone={pending ? "accent" : STATUS_TONE[tool.status]}>
{pending ? tx("New") : STATUS_LABEL[tool.status]}
</Badge>
<span className={styles.rowName}>{tool.displayName}</span>
{pending && (
<span className={styles.kindTag}>
<T>New</T>
</span>
)}
{tool.kind !== "languageServer" && (
<span className={styles.kindTag}>
<T>{KIND_LABEL[tool.kind]}</T>
</span>
)}
{off && (
<span className={styles.kindTag}>
<T>Disabled</T>
</span>
)}
<span className={styles.rowLanguages}>{tool.languages.join(", ")}</span>
</button>
</li>
);
}
function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
const job = useTools((s) => s.jobs[tool.name]);
const last = useTools((s) => s.logs[tool.name]);
const [logOpen, setLogOpen] = useState(false);
const log = job?.lines ?? last?.lines ?? [];
const lastError = !job && last?.error !== "Cancelled" ? last?.error : null;
const busy = !!job;
// Servers and adapters show the install hint next to their executable.
const manualHint =
!pending &&
!tool.canInstall &&
tool.status === "missing" &&
(tool.unavailableReason ?? (tool.kind === "plugin" ? tool.installHint : null));
return (
<div className={styles.details} aria-label={`${tool.displayName} details`}>
<div className={styles.header}>
<div className={styles.headerText}>
<div className={styles.title}>
<span>{tool.displayName}</span>
{pending ? (
<Badge tone="accent">
<T>New</T>
</Badge>
) : (
<Badge tone={STATUS_TONE[tool.status]}>{STATUS_LABEL[tool.status]}</Badge>
)}
</div>
<div className={styles.subtitle}>
<T>{KIND_LABEL[tool.kind]}</T>
{tool.version && <> · {tool.version}</>}
{tool.homepage && (
<>
{" · "}
<a href={tool.homepage} target="_blank" rel="noreferrer">
<T>Homepage</T>
</a>
</>
)}
</div>
</div>
<div className={styles.actions}>
{tool.canInstall && (
<Button
size="small"
variant={tool.status === "missing" ? "primary" : "secondary"}
disabled={busy}
title={
tool.installMethod ? tx`Installs into the IDE's own tools folder via ${tool.installMethod}` : undefined
}
onClick={() => void installTool(tool.name)}
aria-label={
tool.status === "installed" ? tx`Reinstall ${tool.displayName}` : tx`Install ${tool.displayName}`
}
>
{tool.status === "installed" ? "Reinstall" : "Install"}
</Button>
)}
{tool.canUninstall && (
<Button size="small" variant="ghost" disabled={busy} onClick={() => void uninstallTool(tool.name)}>
<T>Uninstall</T>
</Button>
)}
</div>
</div>
{job && <JobRow name={tool.displayName} job={job} />}
{lastError && (
<div className={styles.error} role="alert">
{lastError}
</div>
)}
{manualHint && (
<div className={styles.notice} role="note">
{manualHint}
</div>
)}
{log.length > 0 && (
<div>
<button
type="button"
className={styles.linkButton}
aria-expanded={logOpen}
onClick={() => setLogOpen(!logOpen)}
>
{logOpen ? tx("Hide install log") : tx("Show install log")}
</button>
{logOpen && (
<pre className={styles.log} aria-label={`${tool.displayName} install log`}>
{log.join("\n")}
</pre>
)}
</div>
)}
{tool.kind === "languageServer" ? (
<ServerSettings tool={tool} pending={pending} />
) : (
tool.kind === "debugger" && <ToolSettings tool={tool} />
)}
</div>
);
}
export function LanguageServersPage() {
const { tools, loaded, loading, error } = useTools();
const form = useSettingsForm();
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [selectedName, setSelectedName] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const known = useLanguageChoices();
const staged = form.useValue("languageServers") as Record<string, ServerEntry> | undefined;
const servers = useMemo(() => staged ?? {}, [staged]);
const applied = useProject((s) => s.settings?.languageServers);
useEffect(() => {
void refreshTools();
// Applied paths and servers change what is found and listed.
return onIdeEvent("settingsChanged", (e) => {
if (!e.key || /^(tools|languageServers)(\.|$)/.test(e.key)) void refreshTools();
});
}, []);
// The backend's list, minus servers removed here and plus servers added here (until Apply).
const { all, pending } = useMemo(() => {
const isCustom = (e: ServerEntry | undefined) => (e?.languages?.length ?? 0) > 0;
const list = tools.filter((t) => !isCustom(applied?.[t.name]) || isCustom(servers[t.name]));
const added = new Set<string>();
for (const [name, entry] of Object.entries(servers)) {
if (!isCustom(entry) || list.some((t) => t.name === name)) continue;
list.push(pendingTool(name, entry, languageNames(entry.languages ?? [], known)));
added.add(name);
}
return { all: list, pending: added };
}, [tools, servers, applied, known]);
const shown = useMemo(() => filterTools(all, query, filter), [all, query, filter]);
const ready = shown.filter((t) => t.status !== "missing");
const missing = shown.filter((t) => t.status === "missing");
const ordered = [...ready, ...missing];
const selected = ordered.find((t) => t.name === selectedName) ?? ordered[0] ?? null;
const missingCount = all.filter((t) => t.status === "missing").length;
const removable = !!selected && (servers[selected.name]?.languages?.length ?? 0) > 0;
const onListKey = (e: KeyboardEvent<HTMLUListElement>) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const i = selected ? ordered.indexOf(selected) : -1;
const next = ordered[Math.min(ordered.length - 1, Math.max(0, i + (e.key === "ArrowDown" ? 1 : -1)))];
if (!next) return;
setSelectedName(next.name);
e.currentTarget.querySelector<HTMLElement>(`[data-tool="${CSS.escape(next.name)}"]`)?.focus();
};
const group = (title: string, list: ToolInfo[]) =>
list.length > 0 && (
<>
<li className={styles.groupHeader} role="presentation">
<T>{title}</T> <span className={styles.groupCount}>{list.length}</span>
</li>
{list.map((t) => (
<ToolRow
key={t.name}
tool={t}
selected={t === selected}
off={servers[t.name]?.enabled === false}
pending={pending.has(t.name)}
onSelect={() => setSelectedName(t.name)}
/>
))}
</>
);
return (
<div className={styles.page}>
<p className={styles.intro}>
{tx(
"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>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.body}>
<div className={styles.side}>
<div className={styles.toolbar}>
<IconButton icon="plus" size="small" title="Add Language Server…" onClick={() => setAdding(true)} />
<IconButton
icon="minus"
size="small"
title="Remove Language Server"
disabled={!removable}
onClick={() => selected && form.set(serverKey(selected.name), null)}
/>
<span className={styles.toolbarSep} aria-hidden />
{loading ? (
<Spinner size={14} label="Checking tools" />
) : (
<IconButton
icon="refresh"
size="small"
title="Re-check installed tools"
onClick={() => void refreshTools()}
/>
)}
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder="Filter by tool or language"
aria-label="Filter language servers"
/>
</div>
<div className={styles.segmented} role="radiogroup" aria-label={tx("Show")}>
{(["all", "missing", "installed"] as const).map((f) => (
<button
key={f}
type="button"
role="radio"
aria-checked={filter === f}
className={clsx(styles.segment, filter === f && styles.segmentOn)}
onClick={() => setFilter(f)}
>
{f === "all" ? tx("All") : f === "missing" ? tx`Missing (${missingCount})` : tx("Available")}
</button>
))}
</div>
<ul className={styles.list} aria-label={tx("Language servers and debug adapters")} onKeyDown={onListKey}>
{group("Available", ready)}
{group("Missing", missing)}
{loaded && shown.length === 0 && (
<li className={styles.listEmpty}>
<T>No tools match.</T>
</li>
)}
{!loaded && !error && (
<li className={styles.listEmpty}>
<T>Checking installed tools…</T>
</li>
)}
</ul>
</div>
{selected ? (
<Details key={selected.name} tool={selected} pending={pending.has(selected.name)} />
) : (
<EmptyState className={styles.detailsEmpty} title={loaded ? "No tool selected" : "Loading…"} />
)}
</div>
{adding && (
<NewServerDialog
existing={all.map((t) => t.name)}
onClose={() => setAdding(false)}
onAdded={(name) => {
setAdding(false);
setFilter("all");
setQuery("");
setSelectedName(name);
}}
/>
)}
</div>
);
}