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