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

+10
Переглянути файл
@@ -5427,6 +5427,12 @@ export type LanguageServerSettings_Serialize = {
codeActionKinds?: string[] | null,
};
export type LanguageServerTarget = {
target?: string,
/** Empty selects the first configured server for the language. */
server?: string,
};
export type LanguagesSettings = {
/** Explicit plugin formatter, by language id. Empty entries use the normal formatter policy. */
pluginFormatters?: { [key in string]: string },
@@ -7857,6 +7863,8 @@ export type Settings_Deserialize = {
* options on top of the language definition, or a server defined here.
*/
languageServers?: { [key in string]: LanguageServerSettings_Deserialize },
/** Project language id → server and plugin target. Empty target uses the host. */
languageServerTargets?: { [key in string]: LanguageServerTarget },
/** Editor › Inspections profiles. */
inspections?: InspectionSettings,
/** Editor › Natural Languages › Spelling. */
@@ -7919,6 +7927,8 @@ export type Settings_Serialize = {
* options on top of the language definition, or a server defined here.
*/
languageServers: { [key in string]: LanguageServerSettings_Serialize },
/** Project language id → server and plugin target. Empty target uses the host. */
languageServerTargets: { [key in string]: LanguageServerTarget },
/** Editor › Inspections profiles. */
inspections: InspectionSettings,
/** Editor › Natural Languages › Spelling. */
+31 -2
Переглянути файл
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { commands } from "@/platform/ipc";
import { provideEditorService } from "@/workbench/contributions";
import { describe, expect, it, vi } from "vitest";
import { Text } from "@codemirror/state";
import { fragmentOffset, linkReference, looksLikePath, resolvePath, textReference } from "./links";
import { fragmentOffset, followReference, linkReference, looksLikePath, resolvePath, textReference } from "./links";
const at = (src: string, marker = "|") => {
const offset = src.indexOf(marker);
@@ -90,3 +92,30 @@ describe("links and fragments", () => {
expect(fragmentOffset("x", "/i.html", "nope")).toBeNull();
});
});
it("opens a container document link in the editor and resolves its fragment through virtual content", async () => {
const uri = "openidea-target://target/7/usr/lib/library.php";
const openFile = vi.fn(async () => {});
provideEditorService({ openFile } as never);
const read = vi.spyOn(commands, "lspVirtualDocument").mockResolvedValue({
status: "ok",
data: {
uri,
text: "<?php\nfunction library() {}\n",
title: "library.php",
description: null,
languageId: "php",
decompiled: false,
},
});
const disk = vi.spyOn(commands, "fsReadText");
try {
await followReference({ kind: "file", path: uri, fragment: "L2", from: 0, to: 1 });
expect(read).toHaveBeenCalledWith(uri);
expect(disk).not.toHaveBeenCalled();
expect(openFile).toHaveBeenCalledWith(uri, { focus: true, selection: { line: 1, col: 0 } });
} finally {
read.mockRestore();
disk.mockRestore();
}
});
+7 -2
Переглянути файл
@@ -10,6 +10,7 @@ import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { whenSynced, type EditorDocContext } from "@/editor/api";
import { call, commands, isTauri, type DocumentLink } from "@/platform/ipc";
import { isVirtualUri } from "@/platform/virtualDocs";
import { openExternalUrl } from "@/platform/openUrl";
import { executeAction } from "@/platform/actions";
import { editorService } from "@/workbench/contributions";
@@ -179,7 +180,11 @@ export async function followReference(ref: Reference): Promise<void> {
if (ref.kind !== "file") return;
let selection: { line: number; col: number } | undefined;
if (ref.fragment) {
const text = await call(commands.fsReadText(ref.path)).catch(() => null);
const text = await (
isVirtualUri(ref.path)
? call(commands.lspVirtualDocument(ref.path)).then((doc) => doc.text)
: call(commands.fsReadText(ref.path))
).catch(() => null);
const offset = text === null ? null : fragmentOffset(text, ref.path, ref.fragment);
if (text !== null && offset !== null) {
const before = text.slice(0, offset);
@@ -202,7 +207,7 @@ export async function referenceAt(view: EditorView, doc: EditorDocContext, offse
if (!ref || doc.virtual) return ref?.kind === "url" ? ref : null;
return resolvePath(ref, doc.path);
}
if (await isFile(link.path)) return link;
if (isVirtualUri(link.path) || (await isFile(link.path))) return link;
// Servers resolve `/x` against the file system or workspace root; web roots (`public/`) are ours.
const own = ref && !doc.virtual ? await resolvePath(ref, doc.path) : null;
if (own) return own;
+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",
+20 -1
Переглянути файл
@@ -601,5 +601,24 @@
"Invalid data source id": "Invalid data source id",
"Database connection failed": "Database connection failed",
"Database service unavailable": "Database service unavailable",
"Trust this project to connect plugin data sources": "Trust this project to connect plugin data sources"
"Trust this project to connect plugin data sources": "Trust this project to connect plugin data sources",
"Language server runs on": "Language server runs on",
"Requires a container root for path mapping": "Requires a container root for path mapping",
"Target unavailable; language server runs on the host": "Target unavailable; language server runs on the host",
"Project only. Install the selected server inside the target; IDE downloads are host binaries.": "Project only. Install the selected server inside the target; IDE downloads are host binaries.",
"First configured server": "First configured server",
"Language server targets": "Language server targets",
"target wrapper has no executable pin": "target wrapper has no executable pin",
"language server targets require a container root": "language server targets require a container root",
"container language server is no longer available": "container language server is no longer available",
"invalid container document URI": "invalid container document URI",
"container document reader is unavailable": "container document reader is unavailable",
"container document exceeds the size or time limit": "container document exceeds the size or time limit",
"container document reader failed": "container document reader failed",
"container document exceeds 4 MiB": "container document exceeds 4 MiB",
"container document is not UTF-8 text": "container document is not UTF-8 text",
"plugin run target changed; navigate again": "plugin run target changed; navigate again",
"plugin host is unavailable": "plugin host is unavailable",
"language server targets belong to project settings": "language server targets belong to project settings",
"library documents are always read-only": "library documents are always read-only"
}
+20 -1
Переглянути файл
@@ -6944,5 +6944,24 @@
"Invalid data source id": "数据源标识无效",
"Database connection failed": "数据库连接失败",
"Database service unavailable": "数据库服务不可用",
"Trust this project to connect plugin data sources": "信任此项目后才能连接插件数据源"
"Trust this project to connect plugin data sources": "信任此项目后才能连接插件数据源",
"Language server runs on": "语言服务器运行于",
"Requires a container root for path mapping": "需要容器根目录以映射路径",
"Target unavailable; language server runs on the host": "目标不可用;语言服务器在主机上运行",
"Project only. Install the selected server inside the target; IDE downloads are host binaries.": "仅限当前项目。请在目标中安装所选服务器;IDE 下载的是主机二进制文件。",
"First configured server": "第一个配置的服务器",
"Language server targets": "语言服务器目标",
"target wrapper has no executable pin": "目标包装程序没有可执行文件固定校验",
"language server targets require a container root": "语言服务器目标需要容器根目录",
"container language server is no longer available": "容器语言服务器已不可用",
"invalid container document URI": "容器文档 URI 无效",
"container document reader is unavailable": "容器文档读取器不可用",
"container document exceeds the size or time limit": "容器文档超过大小或时间限制",
"container document reader failed": "容器文档读取失败",
"container document exceeds 4 MiB": "容器文档超过 4 MiB",
"container document is not UTF-8 text": "容器文档不是 UTF-8 文本",
"plugin run target changed; navigate again": "插件运行目标已更改;请重新导航",
"plugin host is unavailable": "插件宿主不可用",
"language server targets belong to project settings": "语言服务器目标属于项目设置",
"library documents are always read-only": "库文档始终为只读"
}