feat(plugins): plugin-backed content editor tabs with custom actions (#2045)

A plugin web view can open an editor tab with its own title, language (or file name for detection), content and toolbar actions (API 1.3). Clicking an action sends `{ action, content }` back to the plugin, which replies with new content and/or a status message, or left/right content shown in the IDE's diff viewer. The tab follows the editor document lifecycle: modified marker, close-with-changes prompt, read-only, and saves whose reply arrives after newer edits (or that the plugin rejects) keep the document dirty.

Co-authored-by: dengliu <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-08 08:09:40 +08:00
1 джерело bd1a3ce1e3
коміт a2599a6f79
19 files changed
+1995 -2

No files matched your search

+14
Переглянути файл
@@ -4636,6 +4636,20 @@ fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() {
assert!(err.message.contains("limited to"), "{}", err.message);
}
#[test]
fn web_content_fixture_installs_and_bridges_view_messages() {
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&sample("web-content"), &Consent::default()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert_eq!(f.run("web-content", "ping", None).unwrap().as_deref(), Some("Web content ready"));
f.take_events();
assert_eq!(f.run("web-content", "open", None).unwrap(), None);
assert!(
f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::ViewMessage { plugin_id, view, message }
if plugin_id == "web-content" && view == "inspector" && message == "open"))
);
}
#[test]
fn web_log_tabs_use_the_web_pty_permission_boundary() {
use ide_host::plugins::web::WebPtyCommand;
+336
Переглянути файл
@@ -0,0 +1,336 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen",
]
[[package]]
name = "openidea-test-web-content"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
Переглянути файл
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-web-content"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
6c669b7893f975728d8f7677e9720743b655860383d8a196eb6db76620e28fe1
+22
Переглянути файл
@@ -0,0 +1,22 @@
id = "web-content"
name = "Web Content Probe"
version = "0.1.0"
description = "Test fixture for a plugin-backed content editor tab with custom toolbar actions."
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:ping", "on-command:open"]
[[contributes.views]]
id = "inspector"
title = "Content Probe"
icon = "file-code"
location = "right"
web = "web/index.html"
[[contributes.commands]]
id = "ping"
title = "Content Probe: Ping"
[[contributes.commands]]
id = "open"
title = "Content Probe: Open Editor Tab"
+19
Переглянути файл
@@ -0,0 +1,19 @@
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
#[derive(Default)]
struct WebContent;
impl Plugin for WebContent {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"open" => {
host::post_view_message("inspector", "open")?;
Ok(None)
}
"ping" => Ok(Some("Web content ready".into())),
_ => Err(Error::NotFound(id.into())),
}
}
}
register_plugin!(WebContent);
+49
Переглянути файл
@@ -0,0 +1,49 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Content Probe</title>
<style>
body { background: var(--openidea-bg); color: var(--openidea-fg); font-family: var(--openidea-font-ui, sans-serif); padding: 12px; }
button { font: inherit; padding: 6px; } pre { white-space: pre-wrap; }
</style></head><body>
<h2>Plugin content editor tab</h2>
<button id="open">Open content tab</button>
<pre id="status" aria-live="polite"></pre>
<script>
let stored = "name = \"demo\"\nreplicas = 2\n";
const openTab = () => openidea.postMessage(JSON.stringify({
op: "open-content-tab",
key: "service-config",
title: "service.toml",
fileName: "service.toml",
language: "toml",
content: stored,
actions: [
{ id: "reload", label: "Reload", icon: "rerun" },
{ id: "diff", label: "Diff", icon: "diff" },
{ id: "save", label: "Apply", icon: "green-checkmark", primary: true, save: true }
]
}));
document.querySelector("#open").onclick = openTab;
openidea.onMessage((text) => {
if (text === "open") { openTab(); return; }
let m; try { m = JSON.parse(text); } catch { return; }
if (m.op === "content-tab-error") {
document.querySelector("#status").textContent = m.error;
} else if (m.op === "content-tab-action") {
if (m.action === "reload") {
openidea.postMessage(JSON.stringify({
op: "content-tab-reply", requestId: m.requestId, content: stored, status: "Reloaded"
}));
} else if (m.action === "diff") {
openidea.postMessage(JSON.stringify({
op: "content-tab-reply", requestId: m.requestId,
diff: { left: stored, right: m.content, leftLabel: "Live", rightLabel: "Edited", title: "Diff: service.toml" }
}));
} else if (m.action === "save") {
stored = m.content;
openidea.postMessage(JSON.stringify({
op: "content-tab-reply", requestId: m.requestId, saved: true, status: "Applied"
}));
}
}
});
</script></body></html>
+47
Переглянути файл
@@ -1157,6 +1157,53 @@ Its `Log Probe: Open Stream` and `Log Probe: Try Undeclared Program` commands
also ask the sandboxed page to open a tab, exercising the Wasm-to-page bridge.
This extends the web bridge without changing released WIT signatures.
### Plugin-backed content editor tabs (API 1.3)
A web view can open an editor tab on content the plugin supplies, with
plugin-defined toolbar actions that call back into the plugin. Declare
`engines.openidea = "^1.3"` when using this protocol:
```js
openidea.postMessage(JSON.stringify({
op: "open-content-tab",
key: "service-config",
title: "service.toml",
fileName: "service.toml",
language: "toml",
content: "name = \"demo\"\nreplicas = 2\n",
readOnly: false,
actions: [
{ id: "reload", label: "Reload", icon: "rerun" },
{ id: "diff", label: "Diff", icon: "diff" },
{ id: "save", label: "Apply", icon: "green-checkmark", primary: true, save: true },
],
}));
```
`title` (1–256 characters) and `content` (at most 1 Mi UTF-16 code units, no NUL)
are required. `key` (1–128 characters) defaults to `title` and is scoped to the
calling plugin and view; `fileName` (1–256 characters, no path separators) and
`language` (1–64 characters) select syntax highlighting and the tab's file icon.
`readOnly` defaults to `false`. `actions` lists up to 16 toolbar buttons with
unique `id`s (1–64 characters), `label`s (1–128 characters), an optional built-in
`icon` name, `primary`, and `save` (at most one action may set `save: true`).
Invalid requests send `content-tab-error { error }` through `openidea.onMessage`.
Clicking an action (or saving via ⌘S / the close-with-changes prompt) sends
`content-tab-action { requestId, key, action, content }` to the web view and, when
the plugin has code, as a `ViewEvent::Message`. The plugin replies with
`content-tab-reply { requestId, content?, saved?, readOnly?, status?, error?, diff? }`:
- `content` replaces unchanged editor text and updates the clean baseline (unless `saved: false`); later edits stay in the editor;
- `saved: true` marks the submitted content clean; edits made while the action is pending remain dirty;
- `saved: false` rejects a pending Save and keeps the tab open and dirty;
- `status` / `error` update the toolbar status (and `error` rejects a pending Save so the tab stays open and dirty);
- `diff: { left, right, leftLabel?, rightLabel?, title? }` opens the IDE's diff viewer.
The tab participates in the standard `EditorDocumentLifecycle` (dirty indicator,
Save / Don't Save / Cancel prompt on close, Undo / Redo, and read-only toggle).
`crates/ide-plugin/tests/fixtures/web-content` is a built fixture exercising this flow.
### Translations
A package translates its own text with `locales/<tag>.json`, one file per language
+6
Переглянути файл
@@ -58,3 +58,9 @@ tab streams output from a granted process, with plugin-defined argument toggles.
It uses the same backend authorization and bounded PTY transport. See the
[protocol and lifecycle](../../../plugins/README.md#streaming-log-editor-tabs-api-13)
and the [built fixture](../../../crates/ide-plugin/tests/fixtures/web-log).
API 1.3 also adds `open-content-tab` to the web bridge: a plugin-backed editor
tab with custom toolbar actions (`content-tab-action` / `content-tab-reply`),
native diff viewer integration, and full `EditorDocumentLifecycle` support. See
the [protocol and lifecycle](../../../plugins/README.md#plugin-backed-content-editor-tabs-api-13)
and the [built fixture](../../../crates/ide-plugin/tests/fixtures/web-content).
+351
Переглянути файл
@@ -0,0 +1,351 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { PluginInfo } from "@/bindings";
import { registerActions } from "@/platform/actions";
import { useContributions } from "@/workbench/contributions";
import { usePlugins } from "../store";
import { startViewUi } from "../uiPoints";
import { registerContentActions } from "../views/contentActions";
import {
PluginContentEditor,
closeContentSession,
pluginContentEditorDocument,
pluginContentEditorIcon,
pluginContentEditorTitle,
} from "../views/contentEditor";
import {
PLUGIN_CONTENT_EDITOR_KIND,
contentTabData,
contentTabKey,
handleContentTabReplyText,
registerContentTabSender,
} from "../views/contentTab";
const plugin = { id: "probe", enabled: true, hasCode: false, capabilities: [] } as unknown as PluginInfo;
const spec = {
key: "service-config",
title: "service.toml",
fileName: "service.toml",
language: "toml",
content: 'name = "demo"\nreplicas = 2\n',
readOnly: false,
actions: [
{ id: "reload", label: "Reload", icon: "rerun" as const },
{ id: "diff", label: "Diff", icon: "diff" as const },
{ id: "save", label: "Apply", icon: "green-checkmark" as const, primary: true, save: true },
],
};
let stopActions: () => void;
beforeEach(() => {
stopActions = registerContentActions();
});
afterEach(() => {
cleanup();
closeContentSession(contentTabKey("probe", "inspector", "service-config"));
stopActions();
usePlugins.setState({ plugins: [] });
vi.restoreAllMocks();
});
function setup(overrides: Partial<typeof spec> = {}) {
usePlugins.setState({ plugins: [plugin] });
const sent: Array<Record<string, unknown>> = [];
const offSender = registerContentTabSender("probe", "inspector", (msg) => {
const text = (msg as { text?: string }).text;
if (text) sent.push(JSON.parse(text) as Record<string, unknown>);
});
const data = contentTabData("probe", "inspector", { ...spec, ...overrides }, 100);
const key = contentTabKey("probe", "inspector", data.key);
const input = { kind: PLUGIN_CONTENT_EDITOR_KIND, key, data };
const rendered = render(<PluginContentEditor input={input} />);
const content = () => rendered.container.querySelector(".cm-content")!;
const view = () => EditorView.findFromDOM(content() as HTMLElement)!;
return {
sent,
offSender,
rendered,
input,
view,
reply: (payload: Record<string, unknown>) =>
act(() => {
handleContentTabReplyText("probe", JSON.stringify({ op: "content-tab-reply", ...payload }));
}),
};
}
describe("plugin-backed content editor tabs", () => {
it("validates open-content-tab requests and rejects invalid icons, duplicate actions, path separators and disabled plugins", () => {
usePlugins.setState({ plugins: [plugin] });
for (const bad of [
{ title: "" },
{ title: "x".repeat(257) },
{ key: "x".repeat(129) },
{ fileName: "sub/service.toml" },
{ fileName: "sub\\service.toml" },
{ language: "x".repeat(65) },
{ content: 123 },
{ content: "a\0b" },
{ readOnly: "nope" },
{ actions: [{ id: "a", label: "A", icon: "not-a-builtin-icon" }] },
{ actions: [spec.actions[0], spec.actions[0]] },
{
actions: [
{ id: "s1", label: "S1", save: true },
{ id: "s2", label: "S2", save: true },
],
},
]) {
expect(() => contentTabData("probe", "inspector", { ...spec, ...bad }, 100)).toThrow();
}
expect(() => contentTabData("probe", "inspector", spec, (2 << 20) + 1)).toThrow();
usePlugins.setState({ plugins: [{ ...plugin, enabled: false }] });
expect(() => contentTabData("probe", "inspector", spec, 100)).toThrow();
});
it("derives tab title and file icon from fileName/title without hardcoding YAML", () => {
usePlugins.setState({ plugins: [plugin] });
const tomlData = contentTabData("probe", "inspector", spec, 100);
const tomlInput = { kind: PLUGIN_CONTENT_EDITOR_KIND, key: "k1", data: tomlData };
expect(pluginContentEditorTitle(tomlInput)).toBe("service.toml");
expect(pluginContentEditorIcon(tomlInput)).toBe("file-toml");
const jsonData = contentTabData("probe", "inspector", { ...spec, title: "Config", fileName: "settings.json" }, 100);
const jsonInput = { kind: PLUGIN_CONTENT_EDITOR_KIND, key: "k2", data: jsonData };
expect(pluginContentEditorIcon(jsonInput)).toBe("file-json");
});
it("tracks dirty state, Undo/Redo, discard, and close-with-changes Save lifecycle", async () => {
const f = setup();
try {
expect(f.view().state.doc.toString()).toBe(spec.content);
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(false);
// Edit the document: marks the tab dirty.
act(() => {
f.view().dispatch({
changes: { from: f.view().state.doc.length, insert: 'env = "prod"\n' },
});
});
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(true);
expect(screen.getByText(/Modified/)).toBeInTheDocument();
// Undo and Redo through the document lifecycle.
await act(() => pluginContentEditorDocument.undo!(f.input));
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(false);
await act(() => pluginContentEditorDocument.redo!(f.input));
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(true);
// Prepare close freezes editing until cancelClose.
await act(() => pluginContentEditorDocument.prepareClose(f.input));
expect(f.view().state.facet(EditorState.readOnly)).toBe(true);
act(() => pluginContentEditorDocument.cancelClose(f.input));
expect(f.view().state.facet(EditorState.readOnly)).toBe(false);
// A failed save rejects and leaves the tab dirty.
const failedSave = pluginContentEditorDocument.save(f.input);
expect(f.sent.at(-1)).toMatchObject({
op: "content-tab-action",
key: "service-config",
action: "save",
content: `${spec.content}env = "prod"\n`,
});
f.reply({ requestId: f.sent.at(-1)!.requestId, error: "validation failed" });
await expect(failedSave).rejects.toThrow("validation failed");
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(true);
await waitFor(() => expect(screen.getByText("validation failed")).toBeInTheDocument());
// A succeeding save clears the dirty state.
const okSave = pluginContentEditorDocument.save(f.input);
f.reply({ requestId: f.sent.at(-1)!.requestId, saved: true, status: "Saved to cluster" });
await expect(okSave).resolves.toBeUndefined();
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(false);
await waitFor(() => expect(screen.getByText("Saved to cluster")).toBeInTheDocument());
// Discard reverts unsaved edits to the last saved baseline.
act(() => {
f.view().dispatch({
changes: { from: f.view().state.doc.length, insert: "temp = true\n" },
});
});
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(true);
await act(() => pluginContentEditorDocument.discard(f.input));
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(false);
expect(f.view().state.doc.toString()).toBe(`${spec.content}env = "prod"\n`);
} finally {
f.offSender();
}
});
it("dispatches toolbar actions, updates content on reload, and opens the IDE diff viewer on diff replies", async () => {
const diffs: unknown[] = [];
const unregisterDiff = registerActions([
{
id: "vcs.openDiff",
title: "Open Diff",
run: ({ arg }) => void diffs.push(arg),
},
]);
const f = setup();
try {
// Click Reload action -> plugin replies with new content.
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
await waitFor(() => expect(f.sent.at(-1)).toMatchObject({ action: "reload" }));
f.reply({
requestId: f.sent.at(-1)!.requestId,
content: 'name = "demo"\nreplicas = 5\n',
status: "Reloaded",
});
await waitFor(() => expect(f.view().state.doc.toString()).toBe('name = "demo"\nreplicas = 5\n'));
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(false);
// Click Diff action -> plugin replies with left/right diff, opening vcs.openDiff.
fireEvent.click(screen.getByRole("button", { name: "Diff" }));
await waitFor(() => expect(f.sent.at(-1)).toMatchObject({ action: "diff" }));
f.reply({
requestId: f.sent.at(-1)!.requestId,
diff: {
left: 'name = "demo"\nreplicas = 2\n',
right: 'name = "demo"\nreplicas = 5\n',
leftLabel: "Live",
rightLabel: "Edited",
title: "Diff: service.toml",
},
});
await waitFor(() => expect(diffs).toHaveLength(1));
expect(diffs[0]).toEqual({
path: "service.toml",
title: "Diff: service.toml",
left: { type: "text", text: 'name = "demo"\nreplicas = 2\n', label: "Live" },
right: { type: "text", text: 'name = "demo"\nreplicas = 5\n', label: "Edited" },
});
} finally {
f.offSender();
unregisterDiff();
}
});
it.each([undefined, "normalized = true\n"])("keeps later edits dirty when a save returns %j", async (replacement) => {
const f = setup();
try {
act(() => f.view().dispatch({ changes: { from: 0, insert: "first = true\n" } }));
const savedText = f.view().state.doc.toString();
const saving = pluginContentEditorDocument.save(f.input);
act(() => f.view().dispatch({ changes: { from: 0, insert: "later = true\n" } }));
f.reply({ requestId: f.sent.at(-1)!.requestId, saved: true, content: replacement });
await saving;
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(true);
expect(f.view().state.doc.toString()).toBe(`later = true\n${savedText}`);
await act(() => pluginContentEditorDocument.discard(f.input));
expect(f.view().state.doc.toString()).toBe(replacement ?? savedText);
} finally {
f.offSender();
}
});
it.each([{ saved: false }, { content: "rejected replacement", error: "validation failed" }])(
"keeps failed saves dirty for reply %j",
async (reply) => {
const f = setup();
try {
act(() => f.view().dispatch({ changes: { from: 0, insert: "edited = true\n" } }));
const editedText = f.view().state.doc.toString();
const saving = pluginContentEditorDocument.save(f.input);
f.reply({ requestId: f.sent.at(-1)!.requestId, ...reply });
await expect(saving).rejects.toThrow();
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(true);
expect(f.view().state.doc.toString()).toBe(editedText);
} finally {
f.offSender();
}
},
);
it("preserves unsaved changes when a reply makes the document read-only", async () => {
const f = setup();
try {
act(() => f.view().dispatch({ changes: { from: 0, insert: "edited = true\n" } }));
fireEvent.click(screen.getByRole("button", { name: "Diff" }));
await waitFor(() => expect(f.sent.at(-1)).toMatchObject({ action: "diff" }));
f.reply({ requestId: f.sent.at(-1)!.requestId, readOnly: true });
await waitFor(() => expect(f.view().state.facet(EditorState.readOnly)).toBe(true));
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(true);
const saving = pluginContentEditorDocument.save(f.input);
f.reply({ requestId: f.sent.at(-1)!.requestId, saved: true });
await saving;
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(false);
} finally {
f.offSender();
}
});
it("rejects an unsuccessful save while the editor is unmounted", async () => {
const f = setup();
try {
act(() => f.view().dispatch({ changes: { from: 0, insert: "edited = true\n" } }));
f.rendered.unmount();
const saving = pluginContentEditorDocument.save(f.input);
let prepared = false;
const preparing = pluginContentEditorDocument.prepareClose(f.input).then(() => {
prepared = true;
});
await Promise.resolve();
expect(prepared).toBe(false);
f.reply({ requestId: f.sent.at(-1)!.requestId, saved: false });
await expect(saving).rejects.toThrow();
await preparing;
expect(prepared).toBe(true);
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(true);
} finally {
f.offSender();
}
});
it("drains pending toolbar actions before deciding whether a tab can close", async () => {
const f = setup();
try {
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
await waitFor(() => expect(f.sent.at(-1)).toMatchObject({ action: "reload" }));
let prepared = false;
const preparing = pluginContentEditorDocument.prepareClose(f.input).then(() => {
prepared = true;
});
await act(async () => {
await Promise.resolve();
});
expect(prepared).toBe(false);
expect(f.view().state.facet(EditorState.readOnly)).toBe(true);
f.reply({ requestId: f.sent.at(-1)!.requestId, content: "loaded = true\n" });
await preparing;
expect(prepared).toBe(true);
expect(f.view().state.doc.toString()).toBe("loaded = true\n");
act(() => pluginContentEditorDocument.cancelClose(f.input));
expect(f.view().state.facet(EditorState.readOnly)).toBe(false);
} finally {
f.offSender();
}
});
it("supports readOnly mode and registers the editor kind with EditorDocumentLifecycle", () => {
const f = setup({ readOnly: true });
try {
expect(f.view().state.facet(EditorState.readOnly)).toBe(true);
expect(f.view().contentDOM.getAttribute("contenteditable")).toBe("false");
expect(pluginContentEditorDocument.isDirty(f.input)).toBe(false);
} finally {
f.offSender();
}
const dispose = startViewUi(() => {});
try {
const kind = useContributions.getState().editorKinds.get(PLUGIN_CONTENT_EDITOR_KIND)!;
expect(kind.keepAlive).toBe(true);
expect(kind.document).toBe(pluginContentEditorDocument);
} finally {
dispose();
}
});
});
+56
Переглянути файл
@@ -380,6 +380,62 @@ describe("WebView", () => {
view.unmount();
});
it("opens a generic plugin content editor tab and reports invalid requests via content-tab-error", async () => {
const opened: EditorInput[] = [];
provideEditorService({
openFile: async () => {},
openVirtual: () => {},
openInput: async (input: EditorInput) => void opened.push(input),
openDiff: () => {},
openMerge: () => {},
closeFile: () => {},
activeFile: () => null,
} as unknown as EditorService);
const { view, events } = setup(true);
usePlugins.setState({
plugins: [{ id: "acme.draw", enabled: true, hasCode: true, capabilities: [] } as unknown as PluginInfo],
});
const { posted, send } = await frameOf(view.container);
// Invalid payload sends content-tab-error back to the frame.
send({ text: JSON.stringify({ op: "open-content-tab", title: "", content: "x" }) });
await waitFor(() =>
expect(posted.some((m) => typeof m.text === "string" && m.text.includes('"op":"content-tab-error"'))).toBe(true),
);
expect(opened).toHaveLength(0);
// Valid payload opens a pluginContent editor tab.
send({
text: JSON.stringify({
op: "open-content-tab",
key: "cfg",
title: "service.toml",
fileName: "service.toml",
language: "toml",
content: 'name = "demo"\n',
actions: [{ id: "save", label: "Apply", icon: "green-checkmark", primary: true, save: true }],
}),
});
await waitFor(() => expect(opened).toHaveLength(1));
expect(opened[0]).toMatchObject({
kind: "pluginContent",
key: `pluginContent:${JSON.stringify(["acme.draw", "draw", "cfg"])}`,
data: {
pluginId: "acme.draw",
view: "draw",
key: "cfg",
title: "service.toml",
fileName: "service.toml",
language: "toml",
content: 'name = "demo"\n',
readOnly: false,
},
});
expect(events).toHaveLength(0);
view.unmount();
});
it("keeps the standard tool-window header unless header = 'none', preserves it while loading or on error, and renders keyboard-operable corner actions with inset vars when ready", async () => {
resetToolWindowLayout();
const prevProject = useProject.getState().project;
+17
Переглянути файл
@@ -63,6 +63,14 @@ import {
type CustomEditorDecl,
} from "./views/customEditor";
import { layoutActions, layoutWrapper } from "./views/editorLayout";
import { registerContentActions } from "./views/contentActions";
import {
PluginContentEditor,
pluginContentEditorDocument,
pluginContentEditorIcon,
pluginContentEditorTitle,
} from "./views/contentEditor";
import { PLUGIN_CONTENT_EDITOR_KIND } from "./views/contentTab";
import { PLUGIN_LOG_EDITOR_KIND, PluginLogEditor, pluginLogEditorTitle } from "./views/logEditor";
import { PluginView } from "./views/PluginView";
import { pluginServiceProvider } from "./views/serviceProvider";
@@ -616,6 +624,7 @@ const openWith = new Map<string, { pluginId: string; decl: CustomEditorDecl; mat
export function startViewUi(openInput: (input: ReturnType<typeof customEditorInput>) => void): () => void {
const disposers = [
registerLogActions(),
registerContentActions(),
registerEditorKind({
kind: CUSTOM_EDITOR_KIND,
component: CustomEditor,
@@ -631,6 +640,14 @@ export function startViewUi(openInput: (input: ReturnType<typeof customEditorInp
title: pluginLogEditorTitle,
icon: () => "file-log",
}),
registerEditorKind({
kind: PLUGIN_CONTENT_EDITOR_KIND,
component: PluginContentEditor,
keepAlive: true,
title: pluginContentEditorTitle,
icon: pluginContentEditorIcon,
document: pluginContentEditorDocument,
}),
registerMenuGroup({
id: "plugins",
menus: ["editor", "projectView", "editorTab"],
+34
Переглянути файл
@@ -25,6 +25,14 @@ import { ToolWindowCornerActions, useOwnToolWindowHeader } from "@/workbench/too
import { pluginsBackend, type FileFilter } from "../backend";
import { errorText, usePlugins } from "../store";
import { FRAME_TAG, HEADERLESS_CORNER_INSET, frameTheme, frameThemeMessage } from "./frame";
import { syncContentTabSession } from "./contentEditor";
import {
PLUGIN_CONTENT_EDITOR_KIND,
contentTabData,
contentTabKey,
handleContentTabReplyText,
registerContentTabSender,
} from "./contentTab";
import { PLUGIN_LOG_EDITOR_KIND } from "./logEditor";
import { logTabData } from "./logTab";
import { webPtyBackend } from "./ptyBackend";
@@ -192,6 +200,30 @@ export function WebView({ pluginId, view, label, header = "default", document =
}
return true;
};
const handleContentTabMessage = (rawText: string): boolean => {
if (handleContentTabReplyText(pluginId, rawText)) return true;
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch {
return false;
}
if (!parsed || typeof parsed !== "object" || !("op" in parsed) || parsed.op !== "open-content-tab") return false;
try {
const data = contentTabData(pluginId, view, parsed, rawText.length);
const key = contentTabKey(pluginId, view, data.key);
syncContentTabSession(key, data);
void editorService().openInput({
kind: PLUGIN_CONTENT_EDITOR_KIND,
key,
data,
});
} catch (err) {
post({ text: JSON.stringify({ op: "content-tab-error", error: errorText(err) }) });
}
return true;
};
const offContentSender = registerContentTabSender(pluginId, view, (message) => post(message));
const createPtys = () =>
ptyBridge(pluginId, webPtyBackend(pluginId, view), (message) => post({ text: JSON.stringify(message) }));
let ptys = createPtys();
@@ -288,6 +320,7 @@ export function WebView({ pluginId, view, label, header = "default", document =
);
} else if (typeof m.text === "string") {
if (handleLogTabMessage(m.text)) return;
if (handleContentTabMessage(m.text)) return;
if (ptys.handle(m.text)) return;
if (hasCode) {
pluginsBackend()
@@ -301,6 +334,7 @@ export function WebView({ pluginId, view, label, header = "default", document =
return () => {
window.removeEventListener("message", onMessage);
off();
offContentSender();
ptys.close();
offPlugins();
offProject();
+48
Переглянути файл
@@ -0,0 +1,48 @@
import { registerActions } from "@/platform/actions";
import { editorService } from "@/workbench/contributions";
import { PLUGIN_CONTENT_EDITOR_KIND } from "./contentTab";
export interface ContentControls {
key: string;
find(replace?: boolean): void;
runAction(id: string): void;
}
const controls = new Map<string, ContentControls>();
export function bindContentControls(id: string, value: ContentControls): () => void {
controls.set(id, value);
return () => {
controls.delete(id);
};
}
/** Toolbar actions address their own tab; user keybindings address the active input. */
export function registerContentActions(): () => void {
const target = (arg: unknown) => {
if (typeof arg === "string") return controls.get(arg);
const input = editorService().activeInput?.();
return input?.kind === PLUGIN_CONTENT_EDITOR_KIND
? [...controls.values()].find((c) => c.key === input.key)
: undefined;
};
return registerActions([
{
id: "plugins.content.find",
title: "Find in Editor",
category: "Plugins",
hidden: true,
run: ({ arg }) => target(arg)?.find(false),
},
{
id: "plugins.content.action",
title: "Run Content Editor Action",
category: "Plugins",
hidden: true,
run: ({ arg }) => {
const m = arg as { target?: string; id?: string } | undefined;
if (m && typeof m.id === "string") target(m.target)?.runAction(m.id);
},
},
]);
}
+677
Переглянути файл
@@ -0,0 +1,677 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { Compartment, EditorState, Prec, type StateEffect } from "@codemirror/state";
import { EditorView, highlightSpecialChars, keymap } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab, redo, undo } from "@codemirror/commands";
import { syntaxHighlighting } from "@codemirror/language";
import { caretRowHighlight } from "@/editor/core/cm/caretRow";
import { findExtension, findResultLines, findScope, type FindOptions } from "@/editor/core/cm/find";
import { foldingExtension } from "@/editor/core/cm/folding";
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
import { overviewLane } from "@/editor/core/cm/lanes";
import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages";
import { caretExtension, fontZoom, setFontZoom, stepFontZoom } from "@/editor/core/cm/prefs";
import { fontExtension, heavyExtension, lineNumbersExtension } from "@/editor/core/cm/setup";
import { openideaTheme } from "@/editor/core/cm/theme";
import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar";
import { executeAction } from "@/platform/actions";
import { editorPrefs } from "@/platform/editorSettings";
import { tx } from "@/platform/i18n";
import { bindingsFor, normalizeBinding, registerKeyClaim, strokeOf } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { Button, EmptyState, Icon, IconButton, fileIconName } from "@/ui";
import logStyles from "@/ui/LogPane.module.css";
import { notifyEditorDocumentChanged, type EditorDocumentLifecycle, type EditorInput } from "@/workbench/contributions";
import { errorText, usePlugins } from "../store";
import { bindContentControls } from "./contentActions";
import { cancelPluginContentRequests, dispatchContentTabAction, type PluginContentEditorData } from "./contentTab";
export function pluginContentEditorTitle(input: EditorInput): string {
const s = sessions.get(input.key);
if (s) return s.data.title;
const data = input.data as Partial<PluginContentEditorData> | undefined;
return data?.title || input.key;
}
export function pluginContentEditorIcon(input: EditorInput): string {
const s = sessions.get(input.key);
const data = s?.data ?? (input.data as Partial<PluginContentEditorData> | undefined);
return fileIconName(data?.fileName || data?.title || "untitled.txt");
}
interface ContentSession {
key: string;
data: PluginContentEditorData;
baselineText: string;
currentText: string;
readOnly: boolean;
closing: boolean;
view: EditorView | null;
pending?: Promise<void>;
invokeAction?: (id: string, fromSave: boolean) => Promise<void>;
setClosing?: (closing: boolean) => void;
discard?: () => void;
syncData?: (next: PluginContentEditorData) => void;
}
const sessions = new Map<string, ContentSession>();
function ensureSession(key: string, data: PluginContentEditorData): ContentSession {
let s = sessions.get(key);
if (!s) {
s = {
key,
data,
baselineText: data.content,
currentText: data.content,
readOnly: data.readOnly,
closing: false,
view: null,
};
sessions.set(key, s);
}
return s;
}
export function syncContentTabSession(key: string, data: PluginContentEditorData) {
const s = sessions.get(key);
if (!s) {
ensureSession(key, data);
return;
}
s.syncData?.(data);
}
export function closeContentSession(key: string) {
const s = sessions.get(key);
sessions.delete(key);
if (s) notifyEditorDocumentChanged();
}
export const pluginContentEditorDocument: EditorDocumentLifecycle = {
isDirty: (input) => {
const s = sessions.get(input.key);
return !!s && s.currentText !== s.baselineText;
},
flush: async () => {},
save: async (input) => {
const s = sessions.get(input.key);
if (s?.pending) await s.pending;
if (!s || s.currentText === s.baselineText) return;
const saveAction =
s.data.actions.find((a) => a.save) ??
s.data.actions.find((a) => a.id === "save") ??
s.data.actions.find((a) => a.primary);
if (!saveAction) throw new Error(tx("No save action is configured for this tab"));
if (s.invokeAction) {
await s.invokeAction(saveAction.id, true);
} else {
const snapshot = s.currentText;
s.pending = dispatchContentTabAction(s.data, saveAction.id, snapshot)
.then((reply) => {
if (reply.error) throw new Error(reply.error);
if (reply.saved === false) throw new Error(tx("The plugin did not save the content"));
if (reply.content !== undefined && s.currentText === snapshot) s.currentText = reply.content;
s.baselineText = reply.content ?? snapshot;
notifyEditorDocumentChanged();
})
.finally(() => {
s.pending = undefined;
});
await s.pending;
}
},
prepareClose: async (input) => {
const s = sessions.get(input.key);
if (s) {
s.closing = true;
s.setClosing?.(true);
await s.pending?.catch(() => {});
}
},
cancelClose: (input) => {
const s = sessions.get(input.key);
if (s) {
s.closing = false;
s.setClosing?.(false);
}
},
discard: async (input) => {
const s = sessions.get(input.key);
if (!s) return;
if (s.discard) s.discard();
else {
s.currentText = s.baselineText;
notifyEditorDocumentChanged();
}
},
close: async (input) => {
closeContentSession(input.key);
},
undo: async (input) => {
const v = sessions.get(input.key)?.view;
if (v) undo(v);
},
redo: async (input) => {
const v = sessions.get(input.key)?.view;
if (v) redo(v);
},
};
function isActionStroke(stroke: string | null, actionId: string): boolean {
return !!stroke && bindingsFor(actionId).some((b) => normalizeBinding(b) === stroke);
}
const wheelZoom = EditorView.domEventHandlers({
wheel(e, view) {
if (!e.metaKey && !e.ctrlKey) return false;
e.preventDefault();
stepFontZoom(view, e.deltaY < 0 ? 1 : -1);
return true;
},
});
export function PluginContentEditor({ input }: { input: EditorInput }) {
const data = input.data as PluginContentEditorData | undefined;
if (!data || !data.title || typeof data.content !== "string") {
return <EmptyState title={tx("Invalid content editor configuration")} />;
}
return <PluginContentEditorBody input={input} initialData={data} />;
}
function PluginContentEditorBody({ input, initialData }: { input: EditorInput; initialData: PluginContentEditorData }) {
const session = useMemo(() => ensureSession(input.key, initialData), [input.key, initialData]);
const [data, setData] = useState<PluginContentEditorData>(() => session.data);
const [readOnly, setReadOnly] = useState<boolean>(() => session.readOnly);
const [modified, setModified] = useState<boolean>(() => session.currentText !== session.baselineText);
const [busy, setBusy] = useState<boolean>(false);
const [status, setStatus] = useState<{ text: string; isError: boolean } | null>(null);
const [find, setFind] = useState<{ replace: boolean; seq: number } | null>(null);
const [viewGen, setViewGen] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const readOnlySlotRef = useRef(new Compartment());
const suppressDirtyRef = useRef(false);
const findOpenRef = useRef(false);
const findWatchersRef = useRef(new Set<(u: { docChanged: boolean; selectionSet: boolean }) => void>());
const findEscapeRef = useRef<() => boolean>(() => false);
const saveShortcutRef = useRef<() => boolean>(() => false);
const findId = useId();
const findTarget = useMemo<FindTarget>(
() => ({
id: `pluginContent:${findId}:${input.key}`,
view: () => viewRef.current,
watch(fn) {
findWatchersRef.current.add(fn);
return () => void findWatchersRef.current.delete(fn);
},
isOpen: () => findOpenRef.current,
results(options: FindOptions) {
const view = viewRef.current;
if (!view) return null;
const state = view.state;
const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length };
return {
title: tx`Occurrences of '${options.search}'`,
query: {
pattern: options.search,
isRegex: options.regexp,
caseSensitive: options.caseSensitive,
wholeWord: options.wholeWord,
context: options.context,
include: [],
exclude: [],
scope: { kind: "files", paths: [data.title] },
maxMatches: 0,
},
read: () => ({
path: data.title,
relPath: data.title,
matches: findResultLines(
viewRef.current?.state ?? state,
options,
state.field(findScope, false)
? ((viewRef.current?.state ?? state).field(findScope, false) ?? scope)
: { from: 0, to: (viewRef.current?.state ?? state).doc.length },
),
}),
};
},
}),
[findId, input.key, data.title],
);
const openFind = (replace = false) => {
if (!viewRef.current) return;
const allowReplace = replace && !readOnly && !session.closing;
findOpenRef.current = true;
seedFind(findTarget, allowReplace);
setFind((f) => ({ replace: allowReplace, seq: (f?.seq ?? 0) + 1 }));
};
const closeFind = () => {
findOpenRef.current = false;
setFind(null);
viewRef.current?.focus();
};
findEscapeRef.current = () => {
if (!findOpenRef.current) return false;
closeFind();
return true;
};
const applyDocText = useCallback(
(nextText: string, updateBaseline: boolean) => {
session.currentText = nextText;
if (updateBaseline) session.baselineText = nextText;
const view = viewRef.current;
if (view && view.state.doc.toString() !== nextText) {
suppressDirtyRef.current = true;
try {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextText } });
} finally {
suppressDirtyRef.current = false;
}
}
const nextDirty = session.currentText !== session.baselineText;
setModified(nextDirty);
notifyEditorDocumentChanged();
},
[session],
);
const invokeAction = useCallback(
(actionId: string, fromSave = false): Promise<void> => {
if (session.pending) return session.pending;
if (session.closing && !fromSave) return Promise.resolve();
const currentText = viewRef.current?.state.doc.toString() ?? session.currentText;
session.currentText = currentText;
setBusy(true);
setStatus(null);
const pending = (async () => {
try {
const reply = await dispatchContentTabAction(session.data, actionId, currentText);
if (reply.error) throw new Error(reply.error);
if (fromSave && reply.saved === false) throw new Error(tx("The plugin did not save the content"));
if (typeof reply.readOnly === "boolean") {
session.readOnly = reply.readOnly;
setReadOnly(reply.readOnly);
}
if (reply.content !== undefined && session.currentText === currentText) {
applyDocText(reply.content, reply.saved !== false);
} else if (reply.saved === true || fromSave || (reply.content !== undefined && reply.saved !== false)) {
session.baselineText = reply.content ?? currentText;
setModified(session.currentText !== session.baselineText);
notifyEditorDocumentChanged();
} else {
setModified(session.currentText !== session.baselineText);
notifyEditorDocumentChanged();
}
if (reply.diff) {
void executeAction("vcs.openDiff", {
path: session.data.fileName ?? session.data.title,
title: reply.diff.title ?? session.data.title,
left: {
type: "text",
text: reply.diff.left,
label: reply.diff.leftLabel ?? tx("Before"),
},
right: {
type: "text",
text: reply.diff.right,
label: reply.diff.rightLabel ?? tx("After"),
},
});
}
if (reply.status !== undefined) {
setStatus(reply.status ? { text: reply.status, isError: false } : null);
}
} catch (err) {
const msg = errorText(err);
setStatus({ text: msg, isError: true });
if (fromSave) throw err;
} finally {
session.pending = undefined;
setBusy(false);
}
})();
session.pending = pending;
return pending;
},
[applyDocText, session],
);
saveShortcutRef.current = () => {
const saveAction =
data.actions.find((a) => a.save) ??
data.actions.find((a) => a.id === "save") ??
data.actions.find((a) => a.primary);
if (!saveAction || busy || readOnly || session.closing) return false;
void invokeAction(saveAction.id, true).catch(() => {});
return true;
};
useEffect(() => {
session.invokeAction = invokeAction;
session.setClosing = (closing) => {
session.closing = closing;
const view = viewRef.current;
if (view) {
const effectiveReadOnly = session.readOnly || closing;
view.dispatch({
effects: readOnlySlotRef.current.reconfigure([
EditorState.readOnly.of(effectiveReadOnly),
EditorView.editable.of(!effectiveReadOnly),
]),
});
}
};
session.discard = () => {
applyDocText(session.baselineText, true);
};
session.syncData = (next) => {
const wasDirty = session.currentText !== session.baselineText;
session.data = next;
setData(next);
if (session.readOnly !== next.readOnly) {
session.readOnly = next.readOnly;
setReadOnly(next.readOnly);
}
if (!wasDirty) {
applyDocText(next.content, true);
}
};
return () => {
session.invokeAction = undefined;
session.setClosing = undefined;
session.discard = undefined;
session.syncData = undefined;
};
}, [applyDocText, invokeAction, session]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const fontSlot = new Compartment();
const caretSlot = new Compartment();
const lineNumbersSlot = new Compartment();
const foldingSlot = new Compartment();
const heavySlot = new Compartment();
const langSlot = new Compartment();
const readOnlySlot = readOnlySlotRef.current;
const editorSettings = () => useProject.getState().settings?.editor;
const effectiveReadOnly = session.readOnly || session.closing;
const view = new EditorView({
parent: host,
state: EditorState.create({
doc: session.currentText,
extensions: [
fontSlot.of(fontExtension(editorSettings())),
caretSlot.of(caretExtension(editorPrefs())),
lineNumbersSlot.of(lineNumbersExtension(editorSettings())),
foldingSlot.of(foldingExtension(editorPrefs().folding.showOutline)),
heavySlot.of(heavyExtension(session.currentText.length, editorSettings())),
langSlot.of([]),
readOnlySlot.of([EditorState.readOnly.of(effectiveReadOnly), EditorView.editable.of(!effectiveReadOnly)]),
fontZoom,
wheelZoom,
caretRowHighlight,
openideaTheme,
highlightSpecialChars(),
syntaxHighlighting(openideaHighlightStyle),
history(),
findExtension(),
overviewLane,
Prec.high(
keymap.of([
{ key: "Escape", run: () => findEscapeRef.current() },
{ key: "Mod-s", run: () => saveShortcutRef.current() },
]),
),
keymap.of([...historyKeymap, ...defaultKeymap, indentWithTab]),
EditorView.contentAttributes.of({ "aria-label": session.data.title }),
EditorView.updateListener.of((u) => {
if (u.docChanged && !suppressDirtyRef.current) {
session.currentText = u.state.doc.toString();
const nextDirty = session.currentText !== session.baselineText;
setModified(nextDirty);
notifyEditorDocumentChanged();
}
if (u.docChanged || u.selectionSet) {
findWatchersRef.current.forEach((fn) => fn(u));
}
}),
],
}),
});
viewRef.current = view;
session.view = view;
setViewGen((g) => g + 1);
let alive = true;
const desc = findLanguageDescription(session.data.language ?? null, session.data.fileName ?? session.data.title);
if (desc) {
void loadSupport(desc).then((support) => {
if (alive && support && viewRef.current === view) {
view.dispatch({ effects: langSlot.reconfigure(support) });
}
});
}
let prevAppearance = editorPrefs().appearance;
let prevEditor = editorSettings();
const offSettings = useProject.subscribe(() => {
const effects: StateEffect<unknown>[] = [];
const nextPrefs = editorPrefs();
const nextEditor = editorSettings();
if (JSON.stringify(nextPrefs.appearance) !== JSON.stringify(prevAppearance)) {
prevAppearance = nextPrefs.appearance;
effects.push(caretSlot.reconfigure(caretExtension(nextPrefs)));
}
if (nextEditor !== prevEditor) {
prevEditor = nextEditor;
effects.push(
fontSlot.reconfigure(fontExtension(nextEditor)),
lineNumbersSlot.reconfigure(lineNumbersExtension(nextEditor)),
foldingSlot.reconfigure(foldingExtension(nextPrefs.folding.showOutline)),
heavySlot.reconfigure(heavyExtension(view.state.doc.length, nextEditor)),
);
}
if (effects.length) view.dispatch({ effects });
});
const offPlugins = usePlugins.subscribe((state, previous) => {
const p = state.plugins.find((pl) => pl.id === session.data.pluginId);
const prev = previous.plugins.find((pl) => pl.id === session.data.pluginId);
if (p?.enabled !== prev?.enabled && !p?.enabled) {
cancelPluginContentRequests(session.data.pluginId);
}
});
return () => {
alive = false;
offSettings();
offPlugins();
session.view = null;
view.destroy();
viewRef.current = null;
};
}, [session, data.language, data.fileName, data.title]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const effectiveReadOnly = readOnly || session.closing;
view.dispatch({
effects: readOnlySlotRef.current.reconfigure([
EditorState.readOnly.of(effectiveReadOnly),
EditorView.editable.of(!effectiveReadOnly),
]),
});
}, [readOnly, session]);
useEffect(() => {
return registerKeyClaim((e, stroke) => {
if (!rootRef.current?.contains(e.target as Node)) return false;
const view = viewRef.current;
if (!view) return false;
if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") {
e.preventDefault();
e.stopPropagation();
openFind(false);
return true;
}
if (isActionStroke(stroke, "find.replaceInFile") || stroke === "Meta+R" || stroke === "Ctrl+R") {
e.preventDefault();
e.stopPropagation();
openFind(true);
return true;
}
if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
e.preventDefault();
e.stopPropagation();
if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind(false);
return true;
}
if (isActionStroke(stroke, "file.saveAll") || stroke === "Meta+S" || stroke === "Ctrl+S") {
if (saveShortcutRef.current()) {
e.preventDefault();
e.stopPropagation();
return true;
}
}
if (
isActionStroke(stroke, "editor.increaseFontSize") ||
stroke === "Meta+=" ||
stroke === "Meta+Shift+=" ||
stroke === "Ctrl+=" ||
stroke === "Ctrl+Shift+=" ||
stroke === "Meta+NumpadAdd" ||
stroke === "Ctrl+NumpadAdd"
) {
e.preventDefault();
e.stopPropagation();
stepFontZoom(view, 1);
return true;
}
if (
isActionStroke(stroke, "editor.decreaseFontSize") ||
stroke === "Meta+-" ||
stroke === "Meta+Shift+-" ||
stroke === "Ctrl+-" ||
stroke === "Ctrl+Shift+-" ||
stroke === "Meta+NumpadSubtract" ||
stroke === "Ctrl+NumpadSubtract"
) {
e.preventDefault();
e.stopPropagation();
stepFontZoom(view, -1);
return true;
}
if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") {
e.preventDefault();
e.stopPropagation();
view.dispatch({ effects: setFontZoom.of(0) });
return true;
}
if (stroke === "Escape" && findOpenRef.current) {
e.preventDefault();
e.stopPropagation();
closeFind();
return true;
}
return false;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [findTarget, readOnly]);
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const stroke = strokeOf(e.nativeEvent);
const view = viewRef.current;
if (!view || !stroke) return;
if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") {
openFind(false);
} else if (isActionStroke(stroke, "find.replaceInFile") || stroke === "Meta+R" || stroke === "Ctrl+R") {
openFind(true);
} else if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind(false);
} else if (stroke === "Meta+S" || stroke === "Ctrl+S") {
if (!saveShortcutRef.current()) return;
} else if (e.key === "Escape" && findOpenRef.current) {
closeFind();
} else {
return;
}
e.preventDefault();
e.stopPropagation();
};
useEffect(() =>
bindContentControls(findId, {
key: input.key,
find: (replace = false) => openFind(replace),
runAction: (id: string) => void invokeAction(id, false).catch(() => {}),
}),
);
return (
<div ref={rootRef} className={logStyles.pane} onKeyDown={onKeyDown}>
<div className={logStyles.toolbar}>
<span style={{ fontSize: 12, fontWeight: 600 }}>{data.title}</span>
{modified && (
<span style={{ fontSize: 11.5, color: "var(--warning)", fontWeight: 500 }}>• {tx("Modified")}</span>
)}
{status && (
<span
style={{
fontSize: 11.5,
color: status.isError ? "var(--danger)" : "var(--fg-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={status.text}
>
{status.text}
</span>
)}
<span className={logStyles.flex} />
<IconButton
icon="search"
size="small"
title="Find in Editor"
action="plugins.content.find"
actionArg={findId}
/>
{data.actions.map((action) => (
<Button
key={action.id}
variant={action.primary ? "primary" : "secondary"}
size="small"
disabled={busy || session.closing}
onClick={() => void executeAction("plugins.content.action", { target: findId, id: action.id })}
>
{action.icon && <Icon name={action.icon} size={14} />}
{action.label}
</Button>
))}
</div>
{find && (
<FindPanel
target={findTarget}
version={viewGen}
replace={find.replace && !readOnly}
seq={find.seq}
onReplaceShown={(replace) => setFind((f) => (f ? { ...f, replace: replace && !readOnly } : f))}
onClose={closeFind}
/>
)}
<div ref={hostRef} className={logStyles.output} />
</div>
);
}
+279
Переглянути файл
@@ -0,0 +1,279 @@
import { tx } from "@/platform/i18n";
import { ICON_NAMES, type IconName } from "@/ui";
import { pluginsBackend } from "../backend";
import { usePlugins } from "../store";
import { onViewMessage } from "./viewStore";
export const PLUGIN_CONTENT_EDITOR_KIND = "pluginContent";
const BUILTIN_ICONS = new Set<string>(ICON_NAMES);
export interface PluginContentActionSpec {
id: string;
label: string;
icon?: IconName;
primary?: boolean;
save?: boolean;
}
export interface PluginContentEditorData {
pluginId: string;
view: string;
key: string;
title: string;
language?: string;
fileName?: string;
content: string;
readOnly: boolean;
actions: PluginContentActionSpec[];
}
export interface ContentTabDiff {
left: string;
right: string;
leftLabel?: string;
rightLabel?: string;
title?: string;
}
export interface ContentTabReply {
op: "content-tab-reply";
requestId: string;
content?: string;
saved?: boolean;
readOnly?: boolean;
status?: string;
error?: string;
diff?: ContentTabDiff;
}
const text = (v: unknown, max: number): v is string =>
typeof v === "string" && v.length > 0 && v.length <= max && !/[\0\r\n]/.test(v);
const bodyText = (v: unknown, max: number): v is string =>
typeof v === "string" && v.length <= max && !v.includes("\0");
export function contentTabKey(pluginId: string, view: string, key: string): string {
return `${PLUGIN_CONTENT_EDITOR_KIND}:${JSON.stringify([pluginId, view, key])}`;
}
/** Validates an `open-content-tab` message from a plugin web view. */
export function contentTabData(pluginId: string, view: string, value: object, length: number): PluginContentEditorData {
const m = value as Record<string, unknown>;
const { title, key = title, language, fileName, content, readOnly = false, actions = [] } = m;
if (
length > 2 << 20 ||
!text(title, 256) ||
!text(key, 128) ||
(language !== undefined && !text(language, 64)) ||
(fileName !== undefined && (!text(fileName, 256) || /[/\\]/.test(fileName))) ||
!bodyText(content, 1 << 20) ||
typeof readOnly !== "boolean" ||
!Array.isArray(actions) ||
actions.length > 16
) {
throw new Error(tx("Invalid content editor configuration"));
}
const parsedActions: PluginContentActionSpec[] = [];
const ids = new Set<string>();
let saveCount = 0;
for (const raw of actions) {
if (!raw || typeof raw !== "object") throw new Error(tx("Invalid content editor configuration"));
const a = raw as Record<string, unknown>;
if (
!text(a.id, 64) ||
ids.has(a.id) ||
!text(a.label, 128) ||
(a.icon !== undefined && (typeof a.icon !== "string" || !BUILTIN_ICONS.has(a.icon))) ||
(a.primary !== undefined && typeof a.primary !== "boolean") ||
(a.save !== undefined && typeof a.save !== "boolean")
) {
throw new Error(tx("Invalid content editor configuration"));
}
ids.add(a.id);
if (a.save === true) saveCount++;
if (saveCount > 1) throw new Error(tx("Invalid content editor configuration"));
parsedActions.push({
id: a.id,
label: a.label,
icon: a.icon as IconName | undefined,
primary: a.primary === true ? true : undefined,
save: a.save === true ? true : undefined,
});
}
const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId);
if (!plugin?.enabled) {
throw new Error(tx("Invalid content editor configuration"));
}
return {
pluginId,
view,
key,
title,
language: language as string | undefined,
fileName: fileName as string | undefined,
content,
readOnly,
actions: parsedActions,
};
}
// --- Frame & backend action dispatch ----------------------------------------------
const frameSenders = new Map<string, Set<(message: object) => void>>();
const pendingRequests = new Map<
string,
{
pluginId: string;
resolve: (reply: ContentTabReply) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
>();
let nextReqSeq = 1;
export function registerContentTabSender(pluginId: string, view: string, send: (message: object) => void): () => void {
const k = `${pluginId}/${view}`;
let set = frameSenders.get(k);
if (!set) {
set = new Set();
frameSenders.set(k, set);
}
set.add(send);
const offBackend = onViewMessage(pluginId, view, (raw) => {
handleContentTabReplyText(pluginId, raw);
});
return () => {
offBackend();
set?.delete(send);
if (set?.size === 0) frameSenders.delete(k);
};
}
function validDiff(v: unknown): ContentTabDiff | undefined {
if (v === undefined) return undefined;
if (!v || typeof v !== "object") throw new Error(tx("Invalid content editor configuration"));
const d = v as Record<string, unknown>;
if (
!bodyText(d.left, 1 << 20) ||
!bodyText(d.right, 1 << 20) ||
(d.leftLabel !== undefined && !text(d.leftLabel, 128)) ||
(d.rightLabel !== undefined && !text(d.rightLabel, 128)) ||
(d.title !== undefined && !text(d.title, 256))
) {
throw new Error(tx("Invalid content editor configuration"));
}
return {
left: d.left,
right: d.right,
leftLabel: d.leftLabel as string | undefined,
rightLabel: d.rightLabel as string | undefined,
title: d.title as string | undefined,
};
}
export function handleContentTabReplyText(pluginId: string, rawText: string): boolean {
if (!rawText.startsWith("{") || !rawText.includes('"content-tab-reply"')) return false;
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch {
return false;
}
if (!parsed || typeof parsed !== "object" || (parsed as { op?: unknown }).op !== "content-tab-reply") {
return false;
}
const m = parsed as Record<string, unknown>;
if (typeof m.requestId !== "string") return true;
const entry = pendingRequests.get(m.requestId);
if (!entry || entry.pluginId !== pluginId) return true;
pendingRequests.delete(m.requestId);
clearTimeout(entry.timer);
try {
if (
(m.content !== undefined && !bodyText(m.content, 1 << 20)) ||
(m.saved !== undefined && typeof m.saved !== "boolean") ||
(m.readOnly !== undefined && typeof m.readOnly !== "boolean") ||
(m.status !== undefined && (typeof m.status !== "string" || m.status.length > 512)) ||
(m.error !== undefined && (typeof m.error !== "string" || m.error.length > 1024))
) {
throw new Error(tx("Invalid content editor configuration"));
}
const diff = validDiff(m.diff);
entry.resolve({
op: "content-tab-reply",
requestId: m.requestId,
content: m.content as string | undefined,
saved: m.saved as boolean | undefined,
readOnly: m.readOnly as boolean | undefined,
status: m.status as string | undefined,
error: m.error as string | undefined,
diff,
});
} catch (err) {
entry.reject(err instanceof Error ? err : new Error(String(err)));
}
return true;
}
export function dispatchContentTabAction(
data: PluginContentEditorData,
action: string,
content: string,
timeoutMs = 15_000,
): Promise<ContentTabReply> {
const plugin = usePlugins.getState().plugins.find((p) => p.id === data.pluginId);
if (!plugin?.enabled) {
return Promise.reject(new Error(tx("Invalid content editor configuration")));
}
const requestId = `ct-${nextReqSeq++}`;
const payload = JSON.stringify({
op: "content-tab-action",
requestId,
key: data.key,
action,
content,
});
return new Promise<ContentTabReply>((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error(tx("Content editor action timed out")));
}, timeoutMs);
pendingRequests.set(requestId, { pluginId: data.pluginId, resolve, reject, timer });
const senders = frameSenders.get(`${data.pluginId}/${data.view}`);
if (senders) {
for (const send of senders) send({ text: payload });
}
if (plugin.hasCode) {
let offOnce = () => {};
offOnce = onViewMessage(data.pluginId, data.view, (raw) => {
if (handleContentTabReplyText(data.pluginId, raw)) offOnce();
});
pluginsBackend()
.viewEvent(data.pluginId, data.view, { kind: "message", text: payload }, null)
.catch((err: unknown) => {
offOnce();
if ((!senders || senders.size === 0) && pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
} else if (!senders || senders.size === 0) {
pendingRequests.delete(requestId);
clearTimeout(timer);
reject(new Error(tx("Content editor action timed out")));
}
});
}
export function cancelPluginContentRequests(pluginId?: string) {
for (const [id, entry] of pendingRequests) {
if (!pluginId || entry.pluginId === pluginId) {
pendingRequests.delete(id);
clearTimeout(entry.timer);
entry.reject(new Error(tx("Content editor action timed out")));
}
}
}
+10 -1
Переглянути файл
@@ -383,5 +383,14 @@
"Retract Message": "Retract Message",
"Retract": "Retract",
"Couldn't retract the message": "Couldn't retract the message",
"The agent is still stopping. Try again once it has stopped.": "The agent is still stopping. Try again once it has stopped."
"The agent is still stopping. Try again once it has stopped.": "The agent is still stopping. Try again once it has stopped.",
"Invalid content editor configuration": "Invalid content editor configuration",
"No save action is configured for this tab": "No save action is configured for this tab",
"Content editor action timed out": "Content editor action timed out",
"Find in Editor": "Find in Editor",
"Run Content Editor Action": "Run Content Editor Action",
"Modified": "Modified",
"Before": "Before",
"After": "After",
"The plugin did not save the content": "The plugin did not save the content"
}
+9 -1
Переглянути файл
@@ -6737,5 +6737,13 @@
"Retract Message": "撤回消息",
"Retract": "撤回",
"Couldn't retract the message": "无法撤回消息",
"The agent is still stopping. Try again once it has stopped.": "智能体仍在停止中。请在停止后重试。"
"The agent is still stopping. Try again once it has stopped.": "智能体仍在停止中。请在停止后重试。",
"Invalid content editor configuration": "无效的内容编辑器配置",
"No save action is configured for this tab": "此标签页未配置保存操作",
"Content editor action timed out": "内容编辑器操作超时",
"Find in Editor": "在编辑器中查找",
"Run Content Editor Action": "运行内容编辑器操作",
"Before": "更改前",
"After": "更改后",
"The plugin did not save the content": "插件未保存内容"
}