feat(lsp): run language servers in a plugin run target (#2250)

* feat(lsp): run language servers in a plugin run target

* test(settings): list the language server targets page
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 12:22:21 +08:00
1 джерело f8e15a7980
коміт 0e81c6aeb1
38 files changed
+1462 -40

No files matched your search

+108
Переглянути файл
@@ -0,0 +1,108 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { usePluginRunTargets } from "@/platform/pluginRunTargets";
import type { ProjectInfo } from "@/bindings";
import { PageContext } from "../components/context";
import { LanguageServerTargetRows } from "../languages/LanguageServerTarget";
import { DEFAULT_SETTINGS, validateSettings } from "../model/schema";
import { openSettings, useSettingsDialog } from "../model/staging";
vi.mock("@/platform/ipc", async (original) => {
const actual = await original<typeof import("@/platform/ipc")>();
return {
...actual,
commands: {
...actual.commands,
languages: async () => [{ id: "php", name: "PHP", languageServers: ["intelephense", "phpactor"] }],
},
};
});
beforeEach(() => {
useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: { root: "/project" } as ProjectInfo });
usePluginRunTargets.setState({
targets: [
{
id: "mapped",
pluginId: "example",
pluginName: "Example",
prompt: false,
target: {
id: "mapped",
name: "Container",
program: "wrapper",
args: [],
workdirArgs: [],
containerRoot: "/app",
commands: [],
},
},
{
id: "unmapped",
pluginId: "example",
pluginName: "Example",
prompt: false,
target: {
id: "unmapped",
name: "No root",
program: "wrapper",
args: [],
workdirArgs: [],
containerRoot: null,
commands: [],
},
},
],
});
openSettings("languages.php");
});
afterEach(() => {
cleanup();
usePluginRunTargets.setState({ targets: [] });
});
function page() {
render(
<PageContext.Provider value={{ pageId: "languages.php", scope: "global" }}>
<LanguageServerTargetRows language="php" />
</PageContext.Provider>,
);
}
describe("language server targets", () => {
it("defaults to the host, disables unmapped targets, and stages the server choice in the project", async () => {
page();
expect(screen.getByRole("button", { name: "Language server runs on" })).toHaveTextContent("Local machine");
fireEvent.click(screen.getByRole("button", { name: "Language server runs on" }));
fireEvent.click(screen.getByRole("option", { name: /No root/ }));
expect(screen.getByRole("button", { name: "Language server runs on" })).toHaveTextContent("Local machine");
expect(screen.queryByRole("button", { name: "Language server" })).not.toBeInTheDocument();
expect(screen.getByRole("option", { name: /No root/ })).toHaveTextContent(
"Requires a container root for path mapping",
);
fireEvent.click(screen.getByRole("option", { name: /^Container/ }));
await waitFor(() => expect(screen.getByRole("button", { name: "Language server" })).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "Language server" }));
fireEvent.click(await screen.findByRole("option", { name: "phpactor" }));
expect(Object.values(useSettingsDialog.getState().staged)).toEqual(
expect.arrayContaining([
expect.objectContaining({
scope: "project",
key: "languageServerTargets",
value: { php: { target: "mapped", server: "phpactor" } },
}),
]),
);
expect(
validateSettings({ languageServerTargets: { php: { target: "mapped", server: "phpactor" } } })
.languageServerTargets.php,
).toEqual({ target: "mapped", server: "phpactor" });
});
it("requires an open project", () => {
act(() => useProject.setState({ project: null }));
page();
expect(screen.getByRole("button", { name: "Language server runs on" })).toBeDisabled();
});
});
+1
Переглянути файл
@@ -307,6 +307,7 @@ describe("language pages", () => {
"Rust",
"TypeScript",
"Plugin Formatters",
"Language server targets",
]);
});
+3
Переглянути файл
@@ -3,6 +3,7 @@
* Build Tools › Maven: the toolchain each language's server and run configurations
* use (applied by `ide-sdk` when language servers start and when run configurations load).
*/
import { LanguageServerTargetRows, LanguageServerTargetsPage } from "./LanguageServerTarget";
import { useRunTargets } from "@/platform/pluginRunTargets";
import { tx } from "@/platform/i18n";
import { useEffect, useId, useMemo, useState } from "react";
@@ -123,6 +124,7 @@ export function NodePage() {
hint="Runs npm scripts and Node-based language servers (vtsls, eslint, pyright…)."
/>
<RunTargetRow language="node" />
<LanguageServerTargetsPage ids={["javascript", "javascriptreact", "typescript", "typescriptreact"]} />
<Row label="Package manager" hint="Used by the npm run configurations detected from package.json scripts.">
<Select value={pm} options={pmOptions} onChange={setPm} aria-label="Package manager" />
</Row>
@@ -233,6 +235,7 @@ export function PhpPage() {
</Section>
<Section title="CLI Interpreter">
<RunTargetRow language="php" />
<LanguageServerTargetRows language="php" />
<SdkRow
k="languages.php.interpreter"
label="PHP interpreter"
+111
Переглянути файл
@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import type { LanguageInfo, LanguageServerTarget } from "@/bindings";
import { commands } from "@/platform/ipc";
import { usePluginRunTargets } from "@/platform/pluginRunTargets";
import { useProject } from "@/platform/project";
import { useLanguageListVersion } from "@/platform/languageList";
import { type SelectOption } from "@/ui";
import { Row, Section, Select } from "../components/Form";
import { useSetting } from "../components/context";
import { useSettingsDialog } from "../model/staging";
function useServerLanguages() {
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
const version = useLanguageListVersion((s) => s.version);
useEffect(() => {
let alive = true;
void commands.languages().then(
(list) => alive && setLanguages(list),
() => {},
);
return () => {
alive = false;
};
}, [version]);
return languages.filter((l) => l.languageServers?.length);
}
function TargetField({ language, servers }: { language: string; servers: string[] }) {
const [selections, setSelections] = useSetting<Record<string, LanguageServerTarget>>(
"languageServerTargets",
"project",
);
const targets = usePluginRunTargets((s) => s.targets);
const project = useProject((s) => !!s.project);
const newProjects = useSettingsDialog((s) => s.newProjects);
const selection = selections?.[language] ?? { target: "", server: "" };
const set = (patch: Partial<LanguageServerTarget>) =>
setSelections({ ...selections, [language]: { ...selection, ...patch } });
const options: SelectOption[] = [
{ value: "", label: "Local machine" },
...targets.map((t) => ({
value: t.id,
label: t.target.name,
disabled: !t.target.containerRoot,
detail: t.target.containerRoot ? t.pluginName : "Requires a container root for path mapping",
})),
];
if (selection.target && !targets.some((t) => t.id === selection.target))
options.push({
value: selection.target,
label: selection.target,
detail: "Target unavailable; language server runs on the host",
});
return (
<>
<Row
label="Language server runs on"
hint="Project only. Install the selected server inside the target; IDE downloads are host binaries."
>
<Select
value={selection.target ?? ""}
options={options}
onChange={(target) => set({ target })}
disabled={!project || newProjects}
aria-label="Language server runs on"
/>
</Row>
{selection.target && (
<Row label="Language server">
<Select
value={selection.server ?? ""}
options={[
{ value: "", label: "First configured server" },
...servers.map((name) => ({ value: name, label: name })),
]}
onChange={(server) => set({ server })}
disabled={!project || newProjects}
aria-label="Language server"
/>
</Row>
)}
</>
);
}
export function LanguageServerTargetRows({ language }: { language: string }) {
const languages = useServerLanguages();
const selected = languages.find((l) => l.id === language);
return <TargetField language={language} servers={selected?.languageServers ?? []} />;
}
/** All languages, including plugin languages and languages without an interpreter page. */
export function LanguageServerTargetsPage({ ids }: { ids?: string[] }) {
const languages = useServerLanguages();
const [chosen, setChosen] = useState("php");
const available = ids ? languages.filter((l) => ids.includes(l.id)) : languages;
const selected = available.find((l) => l.id === chosen) ?? available[0];
return (
<Section title="Language server targets">
<Row label="Language">
<Select
value={selected?.id ?? ""}
options={available.map((l) => ({ value: l.id, label: l.name }))}
onChange={setChosen}
aria-label="Language"
/>
</Row>
{selected && <TargetField language={selected.id} servers={selected.languageServers ?? []} />}
</Section>
);
}
+15 -1
Переглянути файл
@@ -8,7 +8,7 @@ import { validateCMake } from "@/platform/cmakeSettings";
import { SUPPORTED_LOCALES, type Locale } from "@/platform/i18n";
import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames";
import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes";
import type { AgentSettings, LanguageServerSettings, Settings } from "@/platform/ipc";
import type { AgentSettings, LanguageServerSettings, LanguageServerTarget, Settings } from "@/platform/ipc";
import {
DEFAULT_LANGUAGES,
DEFAULT_PROJECT_STRUCTURE,
@@ -94,6 +94,7 @@ export interface ValidSettings extends WorkflowSettings {
tools: Record<string, { path: string }>;
/** Settings › Language Servers, by server name (as the backend sends them). */
languageServers: Record<string, LanguageServerEntry>;
languageServerTargets: Record<string, LanguageServerTarget>;
/** Tools › Agents › Chat: the meters in an agent task's header. */
agents: Required<AgentSettings>;
/** Tools › MCP Server (global). */
@@ -165,6 +166,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
spelling: { ideWords: [], projectWords: [], customDictionaries: [] },
tools: {},
languageServers: {},
languageServerTargets: {},
agents: { showContextUsage: true, showWorkTime: true, showUsageLimits: true, showCost: false },
mcpServer: { enabled: false },
...DEFAULT_WORKFLOW,
@@ -280,6 +282,18 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
languageServers: Object.fromEntries(
Object.entries(obj(root.languageServers)).map(([name, ls]) => [name, obj(ls) as LanguageServerEntry]),
),
languageServerTargets: Object.fromEntries(
Object.entries(obj(root.languageServerTargets)).map(([language, value]) => {
const entry = obj(value);
return [
language,
{
target: typeof entry.target === "string" ? entry.target : "",
server: typeof entry.server === "string" ? entry.server : "",
},
];
}),
),
agents: {
showContextUsage: bool(agents.showContextUsage, d.agents.showContextUsage),
showWorkTime: bool(agents.showWorkTime, d.agents.showWorkTime),
+11
Переглянути файл
@@ -1,3 +1,4 @@
import { LanguageServerTargetsPage } from "../languages/LanguageServerTarget";
import { PluginFormattersPage } from "../languages/PluginFormattersPage";
/**
* PyCharm's top-level "Python" group (Interpreter), "Project Structure", and
@@ -16,6 +17,16 @@ import type { PageSpec } from "./spec";
export const LANGUAGES_GROUP = "Languages & Frameworks";
export const LANGUAGE_PAGES: PageSpec[] = [
{
id: "languages.serverTargets",
title: "Language server targets",
group: LANGUAGES_GROUP,
projectScope: true,
defaultScope: "project",
order: 451,
keywords: ["lsp", "container", "target"],
component: LanguageServerTargetsPage,
},
{
id: "languages.pluginFormatters",
title: "Plugin Formatters",