дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
f8e15a7980
коміт
0e81c6aeb1
38 files changed
+1462
-40
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -307,6 +307,7 @@ describe("language pages", () => {
|
||||
"Rust",
|
||||
"TypeScript",
|
||||
"Plugin Formatters",
|
||||
"Language server targets",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Заблокувати користувача