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 000000000..d62e2f80d Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm differ 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": "插件未保存内容" }