дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +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
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "库文档始终为只读"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача