From a2599a6f79d36921e9d025a1e352e5609223bdd3 Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Sat, 7 Nov 2026 16:09:40 -0800 Subject: [PATCH] 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 <5108773+dengliu@users.noreply.github.com> --- crates/ide-host/tests/plugins.rs | 14 + .../tests/fixtures/web-content/Cargo.lock | 336 +++++++++ .../tests/fixtures/web-content/Cargo.toml | 20 + .../fixtures/web-content/dist/plugin.wasm | Bin 0 -> 140424 bytes .../web-content/dist/plugin.wasm.sources | 1 + .../tests/fixtures/web-content/plugin.toml | 22 + .../tests/fixtures/web-content/src/lib.rs | 19 + .../tests/fixtures/web-content/web/index.html | 49 ++ plugins/README.md | 47 ++ sdk/rust/openidea-plugin/README.md | 6 + .../plugins/__tests__/contentEditor.test.tsx | 351 +++++++++ .../plugins/__tests__/webViews.test.tsx | 56 ++ src/features/plugins/uiPoints.tsx | 17 + src/features/plugins/views/WebView.tsx | 34 + src/features/plugins/views/contentActions.ts | 48 ++ src/features/plugins/views/contentEditor.tsx | 677 ++++++++++++++++++ src/features/plugins/views/contentTab.ts | 279 ++++++++ src/platform/locales/text/en.json | 11 +- src/platform/locales/text/zh.json | 10 +- 19 files changed, 1995 insertions(+), 2 deletions(-) create mode 100644 crates/ide-plugin/tests/fixtures/web-content/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/web-content/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/web-content/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/web-content/src/lib.rs create mode 100644 crates/ide-plugin/tests/fixtures/web-content/web/index.html create mode 100644 src/features/plugins/__tests__/contentEditor.test.tsx create mode 100644 src/features/plugins/views/contentActions.ts create mode 100644 src/features/plugins/views/contentEditor.tsx create mode 100644 src/features/plugins/views/contentTab.ts diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 00b22406e..567ffe120 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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; diff --git a/crates/ide-plugin/tests/fixtures/web-content/Cargo.lock b/crates/ide-plugin/tests/fixtures/web-content/Cargo.lock new file mode 100644 index 000000000..b776d235f --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-content/Cargo.lock @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/web-content/Cargo.toml b/crates/ide-plugin/tests/fixtures/web-content/Cargo.toml new file mode 100644 index 000000000..cfc961ef3 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-content/Cargo.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..d62e2f80db00c42a07788aa771d6cf75e2f1a707 GIT binary patch literal 140424 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�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 literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources new file mode 100644 index 000000000..0d32d0e04 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources @@ -0,0 +1 @@ +6c669b7893f975728d8f7677e9720743b655860383d8a196eb6db76620e28fe1 diff --git a/crates/ide-plugin/tests/fixtures/web-content/plugin.toml b/crates/ide-plugin/tests/fixtures/web-content/plugin.toml new file mode 100644 index 000000000..2c3824e2d --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-content/plugin.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/web-content/src/lib.rs b/crates/ide-plugin/tests/fixtures/web-content/src/lib.rs new file mode 100644 index 000000000..aa062c600 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-content/src/lib.rs @@ -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, 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); diff --git a/crates/ide-plugin/tests/fixtures/web-content/web/index.html b/crates/ide-plugin/tests/fixtures/web-content/web/index.html new file mode 100644 index 000000000..a0f460a5b --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-content/web/index.html @@ -0,0 +1,49 @@ + +Content Probe + +

Plugin content editor tab

+ +

+
diff --git a/plugins/README.md b/plugins/README.md
index ad00046db..b0a6dd099 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -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/.json`, one file per language
diff --git a/sdk/rust/openidea-plugin/README.md b/sdk/rust/openidea-plugin/README.md
index d6dc1750b..1571d1071 100644
--- a/sdk/rust/openidea-plugin/README.md
+++ b/sdk/rust/openidea-plugin/README.md
@@ -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).
diff --git a/src/features/plugins/__tests__/contentEditor.test.tsx b/src/features/plugins/__tests__/contentEditor.test.tsx
new file mode 100644
index 000000000..0f5a84bb8
--- /dev/null
+++ b/src/features/plugins/__tests__/contentEditor.test.tsx
@@ -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 = {}) {
+  usePlugins.setState({ plugins: [plugin] });
+  const sent: Array> = [];
+  const offSender = registerContentTabSender("probe", "inspector", (msg) => {
+    const text = (msg as { text?: string }).text;
+    if (text) sent.push(JSON.parse(text) as Record);
+  });
+  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();
+  const content = () => rendered.container.querySelector(".cm-content")!;
+  const view = () => EditorView.findFromDOM(content() as HTMLElement)!;
+  return {
+    sent,
+    offSender,
+    rendered,
+    input,
+    view,
+    reply: (payload: Record) =>
+      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();
+    }
+  });
+});
diff --git a/src/features/plugins/__tests__/webViews.test.tsx b/src/features/plugins/__tests__/webViews.test.tsx
index 024964fe3..ef9ad03af 100644
--- a/src/features/plugins/__tests__/webViews.test.tsx
+++ b/src/features/plugins/__tests__/webViews.test.tsx
@@ -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;
diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx
index ba21f8621..0e24b5ee2 100644
--- a/src/features/plugins/uiPoints.tsx
+++ b/src/features/plugins/uiPoints.tsx
@@ -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) => void): () => void {
   const disposers = [
     registerLogActions(),
+    registerContentActions(),
     registerEditorKind({
       kind: CUSTOM_EDITOR_KIND,
       component: CustomEditor,
@@ -631,6 +640,14 @@ export function startViewUi(openInput: (input: ReturnType "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"],
diff --git a/src/features/plugins/views/WebView.tsx b/src/features/plugins/views/WebView.tsx
index 4c5ebd8ea..afa6ecf82 100644
--- a/src/features/plugins/views/WebView.tsx
+++ b/src/features/plugins/views/WebView.tsx
@@ -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();
diff --git a/src/features/plugins/views/contentActions.ts b/src/features/plugins/views/contentActions.ts
new file mode 100644
index 000000000..ff81165f7
--- /dev/null
+++ b/src/features/plugins/views/contentActions.ts
@@ -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();
+
+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);
+      },
+    },
+  ]);
+}
diff --git a/src/features/plugins/views/contentEditor.tsx b/src/features/plugins/views/contentEditor.tsx
new file mode 100644
index 000000000..976bf380c
--- /dev/null
+++ b/src/features/plugins/views/contentEditor.tsx
@@ -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 | 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 | 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;
+  invokeAction?: (id: string, fromSave: boolean) => Promise;
+  setClosing?: (closing: boolean) => void;
+  discard?: () => void;
+  syncData?: (next: PluginContentEditorData) => void;
+}
+
+const sessions = new Map();
+
+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 ;
+  }
+  return ;
+}
+
+function PluginContentEditorBody({ input, initialData }: { input: EditorInput; initialData: PluginContentEditorData }) {
+  const session = useMemo(() => ensureSession(input.key, initialData), [input.key, initialData]);
+  const [data, setData] = useState(() => session.data);
+  const [readOnly, setReadOnly] = useState(() => session.readOnly);
+  const [modified, setModified] = useState(() => session.currentText !== session.baselineText);
+  const [busy, setBusy] = useState(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(null);
+  const hostRef = useRef(null);
+  const viewRef = useRef(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(
+    () => ({
+      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 => {
+      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[] = [];
+      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 (
+    
+
+ {data.title} + {modified && ( + • {tx("Modified")} + )} + {status && ( + + {status.text} + + )} + + + {data.actions.map((action) => ( + + ))} +
+ {find && ( + setFind((f) => (f ? { ...f, replace: replace && !readOnly } : f))} + onClose={closeFind} + /> + )} +
+
+ ); +} diff --git a/src/features/plugins/views/contentTab.ts b/src/features/plugins/views/contentTab.ts new file mode 100644 index 000000000..3bef3a463 --- /dev/null +++ b/src/features/plugins/views/contentTab.ts @@ -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(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; + 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(); + 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; + 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 void>>(); +const pendingRequests = new Map< + string, + { + pluginId: string; + resolve: (reply: ContentTabReply) => void; + reject: (err: Error) => void; + timer: ReturnType; + } +>(); +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; + 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; + 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 { + 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((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"))); + } + } +} diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 776b566f9..cb71b8df9 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5dd839a3f..790b4fc4a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "插件未保存内容" }