From fbf57bed23bed848d16c3f1f54b8ce627d181f84 Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Sat, 7 Nov 2026 12:06:04 -0800 Subject: [PATCH] feat(plugins): generic streaming log editor tab for plugin web views (#2043) A plugin web view can open a read-only editor tab that streams a process's output, with plugin-defined toggles, Find, wrap, zoom and Clear. The process is authorised in the backend against the exact granted `process:` and its pinned executable, output is bounded, and the stream survives tab switches but ends when the tab closes, the plugin is disabled or the window closes. Adds a web-log test fixture and docs. Verified on macOS; Windows/Linux not run. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- crates/ide-host/tests/plugins.rs | 44 ++ .../tests/fixtures/web-log/Cargo.lock | 333 ++++++++++++ .../tests/fixtures/web-log/Cargo.toml | 20 + .../tests/fixtures/web-log/dist/plugin.wasm | Bin 0 -> 69007 bytes .../fixtures/web-log/dist/plugin.wasm.sources | 1 + .../tests/fixtures/web-log/plugin.toml | 29 + .../tests/fixtures/web-log/src/lib.rs | 19 + .../tests/fixtures/web-log/web/index.html | 33 ++ plugins/README.md | 48 ++ sdk/rust/openidea-plugin/README.md | 6 + src/editor/core/cm/log.ts | 8 + .../plugins/__tests__/logEditor.test.tsx | 172 ++++++ .../plugins/__tests__/ptyBackend.test.ts | 44 ++ .../plugins/__tests__/webViews.test.tsx | 58 ++ src/features/plugins/uiPoints.tsx | 10 + src/features/plugins/views/WebView.tsx | 50 +- src/features/plugins/views/logActions.ts | 53 ++ src/features/plugins/views/logEditor.tsx | 494 ++++++++++++++++++ src/features/plugins/views/logTab.ts | 58 ++ src/features/plugins/views/ptyBackend.ts | 27 + src/features/plugins/views/ptyBridge.ts | 2 +- src/platform/locales/text/en.json | 12 +- src/platform/locales/text/zh.json | 9 +- src/ui/LogPane.module.css | 6 + 24 files changed, 1509 insertions(+), 27 deletions(-) create mode 100644 crates/ide-plugin/tests/fixtures/web-log/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/web-log/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/web-log/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/web-log/src/lib.rs create mode 100644 crates/ide-plugin/tests/fixtures/web-log/web/index.html create mode 100644 src/features/plugins/__tests__/logEditor.test.tsx create mode 100644 src/features/plugins/__tests__/ptyBackend.test.ts create mode 100644 src/features/plugins/views/logActions.ts create mode 100644 src/features/plugins/views/logEditor.tsx create mode 100644 src/features/plugins/views/logTab.ts create mode 100644 src/features/plugins/views/ptyBackend.ts diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 1c6dc6e44..4cbe1f2f9 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -4443,6 +4443,50 @@ fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() { assert!(err.message.contains("limited to"), "{}", err.message); } +#[test] +fn web_log_tabs_use_the_web_pty_permission_boundary() { + use ide_host::plugins::web::WebPtyCommand; + let f = fixture(Limits::default()); + let info = f.host.install_from_dir(&sample("web-log"), &Consent::default()).unwrap(); + assert!(info.capabilities.iter().any(|c| c.id == "process:bash" && !c.granted)); + assert_eq!(f.run("web-log", "ping", None).unwrap().as_deref(), Some("Web log ready")); + for command in ["open", "denied"] { + f.take_events(); + assert_eq!(f.run("web-log", command, None).unwrap(), None); + assert!( + f.take_events() + .iter() + .any(|(_, event)| matches!(event, PluginEvent::ViewMessage { plugin_id, view, message } + if plugin_id == "web-log" && view == "logs" && message == command)) + ); + } + let command = |program: &str| WebPtyCommand { + program: program.into(), + args: vec!["--noprofile".into(), "--norc".into()], + env: Default::default(), + cols: 240, + rows: 40, + }; + for program in ["bash", "sh", "/bin/bash"] { + assert_eq!( + f.host.web_pty_options("web-log", "logs", "main", command(program)).unwrap_err().kind, + ErrorKind::PermissionDenied + ); + } + #[cfg(unix)] + { + f.host.set_capability("web-log", "process:bash", true).unwrap(); + let options = f.host.web_pty_options("web-log", "logs", "main", command("bash")).unwrap(); + assert_eq!(options.shell.as_deref(), f.host.program_for("web-log", "bash").unwrap().to_str()); + assert_eq!(options.cwd.0, f.project); + assert!(options.env.is_empty()); + assert!(!options.shell_integration); + assert!(f.host.web_pty_options("web-log", "logs", "main", command("sh")).is_err()); + f.host.set_capability("web-log", "process:bash", false).unwrap(); + assert!(f.host.web_pty_options("web-log", "logs", "main", command("bash")).is_err()); + } +} + #[test] fn web_ptys_require_a_current_exact_program_grant_and_project() { use ide_host::plugins::web::WebPtyCommand; diff --git a/crates/ide-plugin/tests/fixtures/web-log/Cargo.lock b/crates/ide-plugin/tests/fixtures/web-log/Cargo.lock new file mode 100644 index 000000000..ecf4a074d --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-log/Cargo.lock @@ -0,0 +1,333 @@ +# 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 = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-test-web-log" +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", +] + +[[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-log/Cargo.toml b/crates/ide-plugin/tests/fixtures/web-log/Cargo.toml new file mode 100644 index 000000000..852116e7a --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-log/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-web-log" +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-log/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..dba2d159cf1d688e047a889dd6138c84928dc639 GIT binary patch literal 69007 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 -> Result, Error> { + match id { + "open" | "denied" => { + host::post_view_message("logs", id)?; + Ok(None) + } + "ping" => Ok(Some("Web log ready".into())), + _ => Err(Error::NotFound(id.into())), + } + } +} + +register_plugin!(WebLog); diff --git a/crates/ide-plugin/tests/fixtures/web-log/web/index.html b/crates/ide-plugin/tests/fixtures/web-log/web/index.html new file mode 100644 index 000000000..77d265918 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-log/web/index.html @@ -0,0 +1,33 @@ + +Log Probe + +

Streaming log tab

+

Grant process:bash in Settings → Plugins, then open the stream. Toggle Verbose to restart it. Switch tabs, find, clear, wrap, and close the tab.

+ + +

+
diff --git a/plugins/README.md b/plugins/README.md
index 0b9f307e1..c9280906b 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -1001,6 +1001,54 @@ web page: start bash without the optional grant to see refusal, grant
 `process:bash` in Settings → Plugins, then send commands and resize or stop it.
 This API is a web bridge extension; existing WIT signatures are unchanged.
 
+### Streaming log editor tabs (API 1.3)
+
+A web view can open a read-only editor tab backed by an authorized process:
+
+```js
+openidea.postMessage(JSON.stringify({
+  op: "open-log-tab",
+  key: "server-output",
+  title: "Server logs",
+  program: "my-server",
+  baseArgs: ["logs"],
+  toggles: [{ id: "verbose", label: "Verbose", args: ["--verbose"] }],
+}));
+```
+
+Declare `engines.openidea = "^1.3"` and `process:my-server` in the manifest.
+The install permission prompt and exact pinned executable, trusted project cwd,
+session caps and lifecycle rules are the same as for web frame PTYs. The tab
+uses the backend PTY API, never a shell fallback. It supplies no environment
+overrides; the environment is inherited. A shell requires its own explicit grant.
+
+`program` is required. `baseArgs` and `toggles` default to empty arrays; `title`
+defaults to the program. Each toggle has a unique `id`, a plugin-localized
+`label`, and `args` appended after `baseArgs` in declaration order. Changing a
+toggle terminates the previous process and replaces the stream. `key` defaults
+to the title and is scoped to the calling plugin and view; reopening the same
+key focuses its existing tab. Titles are at most 256 characters, keys 128,
+and at most 32 toggles have ids of 64 and labels of 128 characters. The combined
+arguments must fit the PTY limits above. `cwd` and `env` are refused. Invalid or
+ungranted requests send `log-error { error }` through `openidea.onMessage`;
+backend start errors appear in the tab.
+
+The viewer supports selection/copy, soft wrap, Find, font zoom and Clear. It
+retains at most 5,000 lines and 1 Mi UTF-16 code units, including partial lines.
+Output is acknowledged only after rendering; the PTY bridge's 256 Ki pending
+output cap also applies. Tabs are read-only, never dirty and not restored on
+restart. Recently used tabs keep streaming while hidden under the editor's
+five-tab retention limit; evicting a hidden tab stops its process. Closing the
+tab, disabling/updating the plugin, changing grants, switching projects or
+closing the window stops it, including a pending spawn.
+
+The built fixture `crates/ide-plugin/tests/fixtures/web-log` exercises permission
+refusal, continuous Unicode output and a plugin-defined Verbose toggle. On Unix,
+grant its optional `process:bash` permission and use its Log Probe tool window.
+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.
+
 ### 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 3ca2a409f..9cde23a80 100644
--- a/sdk/rust/openidea-plugin/README.md
+++ b/sdk/rust/openidea-plugin/README.md
@@ -13,3 +13,9 @@ See [the protocol, permission and resource limits](../../../plugins/README.md#we
 and the [minimal fixture](../../../crates/ide-plugin/tests/fixtures/web-pty).
 The frame cannot access IPC directly. Its sessions belong to that frame and
 end with its view or plugin. There is no new WIT import for this API.
+
+API 1.3 also adds `open-log-tab` to the web bridge: a generic read-only editor
+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).
diff --git a/src/editor/core/cm/log.ts b/src/editor/core/cm/log.ts
index aed05826c..beb911b97 100644
--- a/src/editor/core/cm/log.ts
+++ b/src/editor/core/cm/log.ts
@@ -17,6 +17,10 @@ const TIMESTAMP = [
 
 const EXCEPTION = /^[\w$]+(?:\.[\w$]+)*(?:Exception|Error)\b/;
 const FRAME = /^\s+(?:at\s|\.\.\. \d+ (?:more|common frames omitted))/;
+const SOURCE_LOC = /^[\w./-]+\.(?:go|rs|py|java|kt|ts|tsx|js|c|cc|cpp|h|hpp|rb|cs|php):\d+\b/;
+const KV_KEY = /^[a-zA-Z_][\w.-]*=/;
+const QUOTED_STR = /^"(?:[^"\\]|\\.)*"/;
+const BOOL_LITERAL = /^(?:true|false|null)\b/;
 
 type State = { keyLevel: boolean };
 
@@ -58,6 +62,7 @@ const parser: StreamParser = {
       const m = stream.match(LEVEL_VALUE) as RegExpMatchArray | null;
       if (m) return levelToken(m[0]);
     }
+    if (stream.match(QUOTED_STR)) return "attributeValue";
     if (stream.match(LEVEL_KEY)) {
       state.keyLevel = true;
       return "propertyName";
@@ -65,12 +70,15 @@ const parser: StreamParser = {
     const before = stream.start === 0 ? "" : stream.string[stream.start - 1]!;
     const wordStart = !/[\w$.-]/.test(before);
     if (wordStart) {
+      if (stream.match(KV_KEY)) return "propertyName";
       for (const re of TIMESTAMP) if (stream.match(re)) return "number";
       const level = (stream.match(LEVEL_UPPER) || (/[[<]/.test(before) && stream.match(LEVEL_MARKED))) as
         | RegExpMatchArray
         | boolean
         | null;
       if (level) return levelToken(stream.current());
+      if (stream.match(SOURCE_LOC)) return "labelName";
+      if (stream.match(BOOL_LITERAL)) return "bool";
       if (stream.match(EXCEPTION)) return "typeName";
       if (stream.match(/^https?:\/\/[^\s"'<>)\]]+/)) return "link";
     }
diff --git a/src/features/plugins/__tests__/logEditor.test.tsx b/src/features/plugins/__tests__/logEditor.test.tsx
new file mode 100644
index 000000000..c41115560
--- /dev/null
+++ b/src/features/plugins/__tests__/logEditor.test.tsx
@@ -0,0 +1,172 @@
+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, TerminalInfo } from "@/bindings";
+import { useProject } from "@/platform/project";
+import { useContributions } from "@/workbench/contributions";
+import { usePlugins } from "../store";
+import { startViewUi } from "../uiPoints";
+import { registerLogActions } from "../views/logActions";
+import { PluginLogEditor } from "../views/logEditor";
+import { boundedLog, logTabData } from "../views/logTab";
+import { webPtyBackend } from "../views/ptyBackend";
+import type { PtyBackend } from "../views/ptyBridge";
+
+vi.mock("../views/ptyBackend", () => ({ webPtyBackend: vi.fn() }));
+
+const plugin = { id: "probe", enabled: true, capabilities: [{ id: "process:bash", granted: true }] } as PluginInfo;
+const spec = {
+  program: "bash",
+  title: "Probe logs",
+  baseArgs: ["--noprofile", "--norc"],
+  toggles: [{ id: "verbose", label: "Verbose", args: ["-v"] }],
+};
+let stopActions: () => void;
+beforeEach(() => {
+  stopActions = registerLogActions();
+});
+
+const projectState = useProject.getState();
+afterEach(() => {
+  cleanup();
+  stopActions();
+  usePlugins.setState({ plugins: [] });
+  useProject.setState(projectState, true);
+  vi.restoreAllMocks();
+});
+
+function setup() {
+  usePlugins.setState({ plugins: [plugin] });
+  let output: (text: string) => void = () => {};
+  let exit: Parameters[0] = () => {};
+  const backend: PtyBackend = {
+    spawn: vi.fn(async (_command, listener) => {
+      output = listener;
+      return { id: 42 } as TerminalInfo;
+    }),
+    write: vi.fn(async () => {}),
+    resize: vi.fn(async () => {}),
+    kill: vi.fn(async () => {}),
+    onExit: (listener) => {
+      exit = listener;
+      return vi.fn();
+    },
+  };
+  vi.mocked(webPtyBackend).mockReturnValue(backend);
+  const data = logTabData("probe", "logs", spec, 100);
+  const input = { kind: "pluginLog", key: "test-log", data };
+  const rendered = render();
+  const content = () => rendered.container.querySelector(".cm-content")!;
+  const view = () => EditorView.findFromDOM(content() as HTMLElement)!;
+  return {
+    backend,
+    rendered,
+    input,
+    view,
+    output: (text: string) => act(() => output(text)),
+    exit: () => act(() => exit(42, 0)),
+  };
+}
+
+describe("plugin streaming log tabs", () => {
+  it("rejects malformed requests and exact-grant failures instead of rewriting commands", () => {
+    usePlugins.setState({ plugins: [plugin] });
+    for (const bad of [
+      { program: "sh" },
+      { program: "/bin/bash" },
+      { program: " bash" },
+      { baseArgs: [123] },
+      { baseArgs: ["\0"] },
+      { baseArgs: ["x".repeat(4097)] },
+      { cwd: "/" },
+      { env: { PATH: "/tmp" } },
+      { title: "x".repeat(257) },
+      { toggles: [spec.toggles[0], spec.toggles[0]] },
+      { toggles: [{ id: "v", label: "V", args: [false] }] },
+      { key: "x".repeat(129) },
+    ])
+      expect(() => logTabData("probe", "logs", { ...spec, ...bad }, 100)).toThrow();
+    expect(() => logTabData("probe", "logs", spec, (2 << 20) + 1)).toThrow();
+    usePlugins.setState({ plugins: [{ ...plugin, enabled: false }] });
+    expect(() => logTabData("probe", "logs", spec, 100)).toThrow();
+  });
+
+  it("bounds newline-free output and retained lines", () => {
+    expect(boundedLog("x".repeat(2 << 20))).toHaveLength(1 << 20);
+    expect(boundedLog("😀" + "x".repeat((1 << 20) - 1))).toBe("x".repeat((1 << 20) - 1));
+    expect(boundedLog(Array.from({ length: 6000 }, (_, i) => `${i}`).join("\n")).split("\n")).toHaveLength(5000);
+  });
+
+  it("renders partial output read-only, handles early exit, clears, wraps and finds", async () => {
+    const f = setup();
+    expect(webPtyBackend).toHaveBeenCalledWith("probe", "logs");
+    expect(f.backend.spawn).toHaveBeenCalledWith(
+      expect.objectContaining({ program: "bash", env: {}, args: spec.baseArgs }),
+      expect.any(Function),
+    );
+    expect(f.view().state.facet(EditorState.readOnly)).toBe(true);
+    expect(f.view().contentDOM.getAttribute("contenteditable")).toBe("false");
+    f.output("INFO partial");
+    f.exit();
+    await waitFor(() => expect(f.view().state.doc.toString()).toContain("[Process finished with exit code 0]"));
+    expect(f.view().state.doc.toString()).toContain("INFO partial");
+    fireEvent.click(screen.getByRole("button", { name: "Clear" }));
+    expect(f.view().state.doc.length).toBe(0);
+    const wasWrapped = f.view().contentDOM.classList.contains("cm-lineWrapping");
+    fireEvent.click(screen.getByRole("switch", { name: "Soft-wrap" }));
+    expect(screen.getByRole("switch", { name: "Soft-wrap" }).getAttribute("aria-checked")).toBe(String(!wasWrapped));
+    expect(f.view().contentDOM.classList.contains("cm-lineWrapping")).toBe(!wasWrapped);
+    fireEvent.click(screen.getByRole("button", { name: "Find in Logs" }));
+    expect(screen.getByRole("textbox", { name: "Find" })).toBeTruthy();
+  });
+
+  it("kills the previous process on toggle and every lifecycle boundary", async () => {
+    for (const boundary of ["unmount", "disable", "project", "window"] as const) {
+      const f = setup();
+      await act(async () => {});
+      fireEvent.click(screen.getByRole("switch", { name: "Verbose" }));
+      await waitFor(() => expect(f.backend.kill).toHaveBeenCalledWith(42));
+      expect(f.backend.spawn).toHaveBeenLastCalledWith(
+        expect.objectContaining({ args: [...spec.baseArgs, "-v"] }),
+        expect.any(Function),
+      );
+      vi.mocked(f.backend.kill).mockClear();
+      act(() => {
+        if (boundary === "unmount") f.rendered.unmount();
+        if (boundary === "disable") usePlugins.setState({ plugins: [{ ...plugin, enabled: false }] });
+        if (boundary === "project") useProject.setState({ project: { root: "/other" } as never });
+        if (boundary === "window") window.dispatchEvent(new Event("pagehide"));
+      });
+      expect(f.backend.kill).toHaveBeenCalledWith(42);
+      f.rendered.unmount();
+    }
+  });
+
+  it("kills a spawn that resolves after its tab closes", async () => {
+    const f = setup();
+    let resolve!: (info: TerminalInfo) => void;
+    vi.mocked(f.backend.spawn).mockImplementation(
+      () =>
+        new Promise((r) => {
+          resolve = r;
+        }),
+    );
+    fireEvent.click(screen.getByRole("switch", { name: "Verbose" }));
+    f.rendered.unmount();
+    await act(async () => resolve({ id: 99 } as TerminalInfo));
+    expect(f.backend.kill).toHaveBeenCalledWith(99);
+  });
+
+  it("registers a transient tab that stays mounted when switching editors and has no dirty document", () => {
+    const dispose = startViewUi(() => {});
+    try {
+      const kind = useContributions.getState().editorKinds.get("pluginLog")!;
+      expect(kind.keepAlive).toBe(true);
+      expect(kind.restorable).not.toBe(true);
+      expect(kind.document).toBeUndefined();
+    } finally {
+      dispose();
+    }
+  });
+});
diff --git a/src/features/plugins/__tests__/ptyBackend.test.ts b/src/features/plugins/__tests__/ptyBackend.test.ts
new file mode 100644
index 000000000..61f4691ad
--- /dev/null
+++ b/src/features/plugins/__tests__/ptyBackend.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, expect, it, vi } from "vitest";
+import type { PluginInfo } from "@/bindings";
+import { commands } from "@/platform/ipc";
+import { usePlugins } from "../store";
+import { webPtyBackend } from "../views/ptyBackend";
+
+vi.mock("@tauri-apps/api/core", () => ({
+  Channel: class {
+    onmessage?: (data: string) => void;
+  },
+}));
+vi.mock("@/platform/ipc", async (original) => ({
+  ...(await original()),
+  isTauri: () => true,
+  commands: { pluginWebPtySpawn: vi.fn(async () => ({ status: "ok", data: { id: 42 } })) },
+}));
+
+afterEach(() => {
+  usePlugins.setState({ plugins: [] });
+  vi.clearAllMocks();
+});
+
+it("uses the plugin-scoped backend for both log and frame streams, rechecking the current grant", async () => {
+  const plugin = { id: "probe", enabled: true, capabilities: [{ id: "process:bash", granted: true }] } as PluginInfo;
+  const command = { program: "bash", args: [], env: {}, cols: 240, rows: 40 };
+  const backend = webPtyBackend("probe", "logs");
+  usePlugins.setState({ plugins: [plugin] });
+  const output = vi.fn();
+  await expect(backend.spawn(command, output)).resolves.toEqual({ id: 42 });
+  expect(commands.pluginWebPtySpawn).toHaveBeenCalledWith(
+    "probe",
+    "logs",
+    command,
+    expect.objectContaining({ onmessage: output }),
+  );
+  for (const p of [
+    { ...plugin, capabilities: [] },
+    { ...plugin, enabled: false },
+  ]) {
+    usePlugins.setState({ plugins: [p] });
+    await expect(backend.spawn(command, output)).rejects.toThrow("capability not granted");
+  }
+  expect(commands.pluginWebPtySpawn).toHaveBeenCalledOnce();
+});
diff --git a/src/features/plugins/__tests__/webViews.test.tsx b/src/features/plugins/__tests__/webViews.test.tsx
index 51c47fb20..e60b0792d 100644
--- a/src/features/plugins/__tests__/webViews.test.tsx
+++ b/src/features/plugins/__tests__/webViews.test.tsx
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
 import { act, render, waitFor } from "@testing-library/react";
 import type { PluginInfo, ViewEvent } from "@/bindings";
 import { addFileIconProvider, fileIconName, registerExternalIcons } from "@/ui";
+import { provideEditorService, type EditorInput, type EditorService } from "@/workbench/contributions";
 import {
   addContributedFileTemplate,
   fileTemplates,
@@ -253,6 +254,63 @@ describe("WebView", () => {
     }
   });
 
+  it("opens a streaming log editor tab only when process: capability is granted", 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);
+    const { send } = await frameOf(view.container);
+    const payload = {
+      op: "open-log-tab",
+      title: "App Logs",
+      key: "shared",
+      program: "tail",
+      baseArgs: ["-f", "app.log"],
+      toggles: [{ id: "verbose", label: "Verbose", args: ["-v"] }],
+    };
+
+    // Denied when process:tail is not granted.
+    send({ text: JSON.stringify(payload) });
+    await new Promise((r) => setTimeout(r, 0));
+    expect(opened).toHaveLength(0);
+    expect(events).toHaveLength(0);
+
+    // Granted when process:tail is granted.
+    usePlugins.setState({
+      plugins: [
+        {
+          id: "acme.draw",
+          enabled: true,
+          hasCode: true,
+          capabilities: [{ id: "process:tail", granted: true }],
+        } as unknown as PluginInfo,
+      ],
+    });
+    send({ text: JSON.stringify(payload) });
+    await waitFor(() => expect(opened).toHaveLength(1));
+    expect(opened[0]).toMatchObject({
+      kind: "pluginLog",
+      key: `pluginLog:${JSON.stringify(["acme.draw", "draw", "shared"])}`,
+      data: {
+        view: "draw",
+        pluginId: "acme.draw",
+        title: "App Logs",
+        program: "tail",
+        baseArgs: ["-f", "app.log"],
+        toggles: [{ id: "verbose", label: "Verbose", args: ["-v"] }],
+      },
+    });
+    view.unmount();
+  });
+
   it("closes frame PTYs on plugin changes, navigation, window teardown and unmount", async () => {
     const original = ptyModule.ptyBridge;
     const closes: ReturnType[] = [];
diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx
index 25ca7b6fe..856377fba 100644
--- a/src/features/plugins/uiPoints.tsx
+++ b/src/features/plugins/uiPoints.tsx
@@ -63,9 +63,11 @@ import {
   type CustomEditorDecl,
 } from "./views/customEditor";
 import { layoutActions, layoutWrapper } from "./views/editorLayout";
+import { PLUGIN_LOG_EDITOR_KIND, PluginLogEditor, pluginLogEditorTitle } from "./views/logEditor";
 import { PluginView } from "./views/PluginView";
 import { pluginServiceProvider } from "./views/serviceProvider";
 import { WebView } from "./views/WebView";
+import { registerLogActions } from "./views/logActions";
 import styles from "./PluginsPage.module.css";
 
 // --- menus ------------------------------------------------------------------------
@@ -607,6 +609,7 @@ const openWith = new Map) => void): () => void {
   const disposers = [
+    registerLogActions(),
     registerEditorKind({
       kind: CUSTOM_EDITOR_KIND,
       component: CustomEditor,
@@ -615,6 +618,13 @@ export function startViewUi(openInput: (input: ReturnType "file-log",
+    }),
     registerMenuGroup({
       id: "plugins",
       menus: ["editor", "projectView", "editorTab"],
diff --git a/src/features/plugins/views/WebView.tsx b/src/features/plugins/views/WebView.tsx
index d1a57a25e..74a6660a2 100644
--- a/src/features/plugins/views/WebView.tsx
+++ b/src/features/plugins/views/WebView.tsx
@@ -16,15 +16,17 @@
  * - passes on IDE shortcuts it doesn't handle itself (Save, Close…).
  */
 import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
-import { Channel } from "@tauri-apps/api/core";
 import { tx } from "@/platform/i18n";
-import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
 import { useProject } from "@/platform/project";
 import { subscribeTheme, themeKey } from "@/platform/themeWatch";
 import { EmptyState, Spinner } from "@/ui";
+import { editorService } from "@/workbench/contributions";
 import { pluginsBackend, type FileFilter } from "../backend";
 import { errorText, usePlugins } from "../store";
 import { FRAME_TAG, frameTheme, frameThemeMessage } from "./frame";
+import { PLUGIN_LOG_EDITOR_KIND } from "./logEditor";
+import { logTabData } from "./logTab";
+import { webPtyBackend } from "./ptyBackend";
 import { onViewMessage } from "./viewStore";
 import { ptyBridge } from "./ptyBridge";
 import styles from "./PluginView.module.css";
@@ -156,29 +158,28 @@ export function WebView({ pluginId, view, label, document = null, doc = null }:
 
   useEffect(() => {
     let dialog = false;
+    const handleLogTabMessage = (rawText: string): boolean => {
+      let parsed: unknown;
+      try {
+        parsed = JSON.parse(rawText);
+      } catch {
+        return false;
+      }
+      if (!parsed || typeof parsed !== "object" || !("op" in parsed) || parsed.op !== "open-log-tab") return false;
+      try {
+        const data = logTabData(pluginId, view, parsed, rawText.length);
+        void editorService().openInput({
+          kind: PLUGIN_LOG_EDITOR_KIND,
+          key: `pluginLog:${JSON.stringify([pluginId, view, data.key ?? data.title])}`,
+          data,
+        });
+      } catch (err) {
+        post({ text: JSON.stringify({ op: "log-error", error: errorText(err) }) });
+      }
+      return true;
+    };
     const createPtys = () =>
-      ptyBridge(
-        pluginId,
-        {
-          spawn: async (command, output) => {
-            const current = usePlugins.getState().plugins.find((p) => p.id === pluginId);
-            if (
-              !isTauri() ||
-              !current?.enabled ||
-              !current.capabilities.some((c) => c.id === `process:${command.program}` && c.granted)
-            )
-              throw new Error(tx`Cannot start PTY: process:${command.program} capability not granted.`);
-            const channel = new Channel();
-            channel.onmessage = output;
-            return call(commands.pluginWebPtySpawn(pluginId, view, command, channel));
-          },
-          write: (id, data) => call(commands.termWrite(id, data)),
-          resize: (id, cols, rows) => call(commands.termResize(id, cols, rows)),
-          kill: (id) => call(commands.termKill(id)),
-          onExit: (listener) => onIdeEvent("terminalExited", (ev) => listener(ev.terminal, ev.code)),
-        },
-        (message) => post({ text: JSON.stringify(message) }),
-      );
+      ptyBridge(pluginId, webPtyBackend(pluginId, view), (message) => post({ text: JSON.stringify(message) }));
     let ptys = createPtys();
     const resetPtys = () => {
       ptys.close();
@@ -272,6 +273,7 @@ export function WebView({ pluginId, view, label, document = null, doc = null }:
           (err: unknown) => post({ reply: id, error: errorText(err) }),
         );
       } else if (typeof m.text === "string") {
+        if (handleLogTabMessage(m.text)) return;
         if (ptys.handle(m.text)) return;
         if (hasCode) {
           pluginsBackend()
diff --git a/src/features/plugins/views/logActions.ts b/src/features/plugins/views/logActions.ts
new file mode 100644
index 000000000..92e0c91b7
--- /dev/null
+++ b/src/features/plugins/views/logActions.ts
@@ -0,0 +1,53 @@
+import { registerActions } from "@/platform/actions";
+import { editorService } from "@/workbench/contributions";
+
+export interface LogControls {
+  key: string;
+  find(): void;
+  clear(): void;
+  wrap(): void;
+  toggle(id: string, checked: boolean): void;
+}
+
+const controls = new Map();
+
+export function bindLogControls(id: string, value: LogControls): () => void {
+  controls.set(id, value);
+  return () => {
+    controls.delete(id);
+  };
+}
+
+/** Toolbar actions address their own tab; user keybindings address the active input. */
+export function registerLogActions(): () => void {
+  const target = (arg: unknown) => {
+    if (typeof arg === "string") return controls.get(arg);
+    const input = editorService().activeInput?.();
+    return input?.kind === "pluginLog" ? [...controls.values()].find((c) => c.key === input.key) : undefined;
+  };
+  return registerActions([
+    ...(
+      [
+        ["find", "Find in Logs"],
+        ["clear", "Clear"],
+        ["wrap", "Soft-wrap"],
+      ] as const
+    ).map(([operation, title]) => ({
+      id: `plugins.log.${operation}`,
+      title,
+      category: "Plugins",
+      hidden: true,
+      run: ({ arg }: { arg?: unknown }) => target(arg)?.[operation](),
+    })),
+    {
+      id: "plugins.log.toggleArgument",
+      title: "Toggle Log Option",
+      category: "Plugins",
+      hidden: true,
+      run: ({ arg }) => {
+        const m = arg as { target?: string; id?: string; checked?: boolean } | undefined;
+        if (m && typeof m.id === "string" && typeof m.checked === "boolean") target(m.target)?.toggle(m.id, m.checked);
+      },
+    },
+  ]);
+}
diff --git a/src/features/plugins/views/logEditor.tsx b/src/features/plugins/views/logEditor.tsx
new file mode 100644
index 000000000..dd6256094
--- /dev/null
+++ b/src/features/plugins/views/logEditor.tsx
@@ -0,0 +1,494 @@
+import { 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 } from "@codemirror/commands";
+import { LanguageSupport, syntaxHighlighting } from "@codemirror/language";
+import { caretRowHighlight } from "@/editor/core/cm/caretRow";
+import { findExtension, findResultLines, findScope, type FindOptions } from "@/editor/core/cm/find";
+import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
+import { overviewLane } from "@/editor/core/cm/lanes";
+import { logLanguage } from "@/editor/core/cm/log";
+import { caretExtension, fontZoom, setFontZoom, softWrapActive, stepFontZoom } from "@/editor/core/cm/prefs";
+import { fontExtension, heavyExtension, lineNumbersExtension, prefsOf } 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 { EmptyState, IconButton, Toggle, selectableText } from "@/ui";
+import logStyles from "@/ui/LogPane.module.css";
+import type { EditorInput } from "@/workbench/contributions";
+import { usePlugins } from "../store";
+import { bindLogControls } from "./logActions";
+import { boundedLog, type PluginLogEditorData } from "./logTab";
+import { webPtyBackend } from "./ptyBackend";
+import { ptyBridge } from "./ptyBridge";
+
+export const PLUGIN_LOG_EDITOR_KIND = "pluginLog";
+
+export function pluginLogEditorTitle(input: EditorInput): string {
+  const data = input.data as Partial | undefined;
+  return data?.title || input.key;
+}
+
+const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
+
+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 PluginLogEditor({ input }: { input: EditorInput }) {
+  const data = input.data as PluginLogEditorData | undefined;
+  if (!data || !data.program) {
+    return ;
+  }
+  return ;
+}
+
+function PluginLogEditorBody({ input, data }: { input: EditorInput; data: PluginLogEditorData }) {
+  const initialWrap = useMemo(() => {
+    const s = useProject.getState().settings?.editor;
+    return softWrapActive(s?.softWrap ?? true, prefsOf(s), "stream.log");
+  }, []);
+  const [on, setOn] = useState>(() => new Set());
+  const [wrap, setWrap] = useState(initialWrap);
+  const [find, setFind] = useState<{ seq: number } | null>(null);
+  const [viewGen, setViewGen] = useState(0);
+
+  const rootRef = useRef(null);
+  const hostRef = useRef(null);
+  const viewRef = useRef(null);
+  const wrapSlotRef = useRef(new Compartment());
+  const stickRef = useRef(true);
+  const receivedRef = useRef(false);
+  const findOpenRef = useRef(false);
+  const findWatchersRef = useRef(new Set<(u: { docChanged: boolean; selectionSet: boolean }) => void>());
+  const findEscapeRef = useRef<() => boolean>(() => false);
+  const findId = useId();
+
+  const findTarget = useMemo(
+    () => ({
+      id: `pluginLog:${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 = () => {
+    if (!viewRef.current) return;
+    findOpenRef.current = true;
+    seedFind(findTarget, false);
+    setFind((f) => ({ 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;
+  };
+
+  // Mount CodeMirror EditorView with regular editor's theme, font, color scheme, line numbers, find & zoom.
+  useEffect(() => {
+    const host = hostRef.current;
+    if (!host) return;
+    const fontSlot = new Compartment();
+    const caretSlot = new Compartment();
+    const lineNumbersSlot = new Compartment();
+    const heavySlot = new Compartment();
+    const wrapSlot = wrapSlotRef.current;
+    const editorSettings = () => useProject.getState().settings?.editor;
+
+    const view = new EditorView({
+      parent: host,
+      state: EditorState.create({
+        doc: tx("Waiting for output…"),
+        extensions: [
+          fontSlot.of(fontExtension(editorSettings())),
+          caretSlot.of(caretExtension(editorPrefs())),
+          lineNumbersSlot.of(lineNumbersExtension(editorSettings())),
+          wrapSlot.of(wrap ? EditorView.lineWrapping : []),
+          heavySlot.of(heavyExtension(0, editorSettings())),
+          fontZoom,
+          wheelZoom,
+          caretRowHighlight,
+          openideaTheme,
+          highlightSpecialChars(),
+          syntaxHighlighting(openideaHighlightStyle),
+          new LanguageSupport(logLanguage),
+          findExtension(),
+          overviewLane,
+          Prec.high(keymap.of([{ key: "Escape", run: () => findEscapeRef.current() }])),
+          keymap.of(defaultKeymap),
+          EditorState.readOnly.of(true),
+          EditorView.editable.of(false),
+          EditorView.contentAttributes.of({
+            "aria-label": data.title,
+            class: selectableText,
+            tabindex: "0",
+          }),
+          EditorView.updateListener.of((u) => {
+            if (u.docChanged || u.selectionSet) {
+              findWatchersRef.current.forEach((fn) => fn(u));
+            }
+          }),
+        ],
+      }),
+    });
+    viewRef.current = view;
+    setViewGen((g) => g + 1);
+
+    const onScroll = () => {
+      const el = view.scrollDOM;
+      stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 32;
+    };
+    view.scrollDOM.addEventListener("scroll", onScroll, { passive: true });
+
+    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)),
+          heavySlot.reconfigure(heavyExtension(view.state.doc.length, nextEditor)),
+        );
+      }
+      if (effects.length) view.dispatch({ effects });
+    });
+
+    return () => {
+      offSettings();
+      view.scrollDOM.removeEventListener("scroll", onScroll);
+      view.destroy();
+      viewRef.current = null;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [data.title]);
+
+  // Reconfigure soft wrap when toggled.
+  useEffect(() => {
+    const view = viewRef.current;
+    if (!view) return;
+    view.dispatch({
+      effects: wrapSlotRef.current.reconfigure(wrap ? EditorView.lineWrapping : []),
+    });
+  }, [wrap]);
+
+  // Claim Find (⌘F / ⌘G / ⇧⌘G) and Font Zoom (⌘= / ⌘+ / ⌘- / ⌘0 / keymap bindings) while focused.
+  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();
+        return true;
+      }
+      if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
+        e.preventDefault();
+        e.stopPropagation();
+        if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind();
+        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]);
+
+  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();
+    } else if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
+      if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind();
+    } else if (
+      isActionStroke(stroke, "editor.increaseFontSize") ||
+      stroke === "Meta+=" ||
+      stroke === "Meta+Shift+=" ||
+      stroke === "Ctrl+="
+    ) {
+      stepFontZoom(view, 1);
+    } else if (
+      isActionStroke(stroke, "editor.decreaseFontSize") ||
+      stroke === "Meta+-" ||
+      stroke === "Meta+Shift+-" ||
+      stroke === "Ctrl+-"
+    ) {
+      stepFontZoom(view, -1);
+    } else if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") {
+      view.dispatch({ effects: setFontZoom.of(0) });
+    } else if (e.key === "Escape" && findOpenRef.current) {
+      closeFind();
+    } else {
+      return;
+    }
+    e.preventDefault();
+    e.stopPropagation();
+  };
+
+  // One bounded PTY bridge per mounted tab; toggling replaces the owned process.
+  useEffect(() => {
+    const view = viewRef.current;
+    if (!view) return;
+    receivedRef.current = false;
+    view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: tx("Waiting for output…") } });
+    stickRef.current = true;
+    let stopped = false;
+    let pending = "";
+    let sequence: unknown;
+    let frame = 0;
+    const append = (text: string) => {
+      if (stopped) return;
+      const previous = receivedRef.current ? view.state.doc.toString() : "";
+      receivedRef.current = true;
+      const clean = text.replace(/\r\n/g, "\n").replace(ANSI, "");
+      const result = boundedLog(previous + clean);
+      const removed = previous.length + clean.length - result.length;
+      const stick = stickRef.current;
+      if (view.state.doc.toString() !== previous || removed > previous.length) {
+        view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: result } });
+      } else {
+        view.dispatch({
+          changes: [
+            { from: 0, to: removed, insert: "" },
+            { from: previous.length, insert: clean },
+          ],
+        });
+      }
+      if (stick && !findOpenRef.current) view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight;
+    };
+    const flush = () => {
+      frame = 0;
+      append(pending);
+      pending = "";
+      const consumed = sequence;
+      sequence = undefined;
+      bridge.handle(JSON.stringify({ op: "pty-ack", sessionId: "log", sequence: consumed }));
+    };
+    const bridge = ptyBridge(data.pluginId, webPtyBackend(data.pluginId, data.view), (message) => {
+      const m = message as Record;
+      if (stopped) return;
+      if (m.op === "pty-data") {
+        pending = String(m.data);
+        sequence = m.sequence;
+        frame ||= requestAnimationFrame(flush);
+      } else if (m.op === "pty-exit") {
+        const exitCode = m.exitCode;
+        append(m.exitCode === null ? tx("\n[Process finished]") : tx`\n[Process finished with exit code ${exitCode}]`);
+      } else if (m.op === "pty-error") {
+        if (frame) cancelAnimationFrame(frame);
+        frame = 0;
+        append(pending);
+        pending = "";
+        const error = m.error;
+        append(tx`\nError: ${error}`);
+      }
+    });
+    bridge.handle(
+      JSON.stringify({
+        op: "pty-spawn",
+        sessionId: "log",
+        program: data.program,
+        args: [...data.baseArgs, ...data.toggles.filter((t) => on.has(t.id)).flatMap((t) => t.args)],
+        cols: 240,
+        rows: 40,
+      }),
+    );
+    const stop = (report = true) => {
+      if (stopped) return;
+      bridge.close();
+      if (frame) cancelAnimationFrame(frame);
+      if (report) append(pending + tx("\n[Log stream stopped]"));
+      stopped = true;
+      pending = "";
+    };
+    const offPlugin = usePlugins.subscribe((state, previous) => {
+      const key = (plugins: typeof state.plugins) => {
+        const p = plugins.find((p) => p.id === data.pluginId);
+        return (
+          p &&
+          JSON.stringify([
+            p.path,
+            p.version,
+            p.enabled,
+            ["failed", "disabled", "needsConsent"].includes(p.status),
+            p.capabilities,
+          ])
+        );
+      };
+      if (key(state.plugins) !== key(previous.plugins)) stop();
+    });
+    const offProject = useProject.subscribe((state, previous) => {
+      if (state.project !== previous.project) stop();
+    });
+    const onUnload = () => stop();
+    window.addEventListener("pagehide", onUnload);
+    return () => {
+      stop(false);
+      offPlugin();
+      offProject();
+      window.removeEventListener("pagehide", onUnload);
+    };
+  }, [data, on]);
+
+  const toggle = (id: string, checked: boolean) => {
+    const next = new Set(on);
+    if (checked) next.add(id);
+    else next.delete(id);
+    setOn(next);
+  };
+
+  const clearLogs = () => {
+    const view = viewRef.current;
+    if (!view) return;
+    receivedRef.current = true;
+    view.dispatch({
+      changes: { from: 0, to: view.state.doc.length, insert: "" },
+    });
+  };
+
+  useEffect(() =>
+    bindLogControls(findId, {
+      key: input.key,
+      find: openFind,
+      clear: clearLogs,
+      wrap: () => setWrap((value) => !value),
+      toggle,
+    }),
+  );
+
+  return (
+    
+
+ {(data.toggles ?? []).map((t) => ( + + void executeAction("plugins.log.toggleArgument", { target: findId, id: t.id, checked }) + } + label={t.label} + aria-label={t.label} + /> + ))} + void executeAction("plugins.log.wrap", findId)} + label={tx("Soft-wrap")} + aria-label={tx("Soft-wrap")} + /> + + + +
+ {find && } +
+
+ ); +} diff --git a/src/features/plugins/views/logTab.ts b/src/features/plugins/views/logTab.ts new file mode 100644 index 000000000..5b27ad00a --- /dev/null +++ b/src/features/plugins/views/logTab.ts @@ -0,0 +1,58 @@ +import { tx } from "@/platform/i18n"; +import { usePlugins } from "../store"; + +export interface PluginLogToggleSpec { + id: string; + label: string; + args: string[]; +} + +export interface PluginLogEditorData { + pluginId: string; + view: string; + key?: string; + title: string; + program: string; + baseArgs: string[]; + toggles: PluginLogToggleSpec[]; +} + +const text = (v: unknown, max: number): v is string => + typeof v === "string" && v.length > 0 && v.length <= max && !/[\0\r\n]/.test(v); +const args = (v: unknown): v is string[] => + Array.isArray(v) && v.length <= 256 && v.every((a) => typeof a === "string" && a.length <= 4096 && !a.includes("\0")); + +/** Reject malformed requests instead of silently running a different command. */ +export function logTabData(pluginId: string, view: string, value: object, length: number): PluginLogEditorData { + const m = value as Record; + const { program, baseArgs = [], toggles = [], key, title = program } = m; + if ( + length > 2 << 20 || + !text(program, 256) || + program.trim() !== program || + !text(title, 256) || + (key !== undefined && !text(key, 128)) || + !args(baseArgs) || + !Array.isArray(toggles) || + toggles.length > 32 || + toggles.some((t) => !t || !text(t.id, 64) || !text(t.label, 128) || !args(t.args)) || + new Set(toggles.map((t) => t.id)).size !== toggles.length || + baseArgs.length + toggles.reduce((n, t) => n + t.args.length, 0) > 256 || + "cwd" in m || + "env" in m + ) + throw new Error(tx("Invalid log stream configuration")); + const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId); + if (!plugin?.enabled || !plugin.capabilities.some((c) => c.id === `process:${program}` && c.granted)) + throw new Error(tx`Cannot start PTY: process:${program} capability not granted.`); + return { pluginId, view, key: key as string | undefined, title, program, baseArgs, toggles }; +} + +/** Bound both line count and text size, including output with no newlines. */ +export function boundedLog(text: string): string { + let result = text.slice(-(1 << 20)); + if (/^[\uDC00-\uDFFF]/.test(result)) result = result.slice(1); + const lines = result.split("\n"); + if (lines.length > 5000) result = lines.slice(-5000).join("\n"); + return result; +} diff --git a/src/features/plugins/views/ptyBackend.ts b/src/features/plugins/views/ptyBackend.ts new file mode 100644 index 000000000..bb2991b00 --- /dev/null +++ b/src/features/plugins/views/ptyBackend.ts @@ -0,0 +1,27 @@ +import { Channel } from "@tauri-apps/api/core"; +import { tx } from "@/platform/i18n"; +import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc"; +import { usePlugins } from "../store"; +import type { PtyBackend } from "./ptyBridge"; + +/** Both frame terminals and log tabs use the backend's exact, pinned process grant. */ +export function webPtyBackend(pluginId: string, view: string): PtyBackend { + return { + spawn: async (command, output) => { + const current = usePlugins.getState().plugins.find((p) => p.id === pluginId); + if ( + !isTauri() || + !current?.enabled || + !current.capabilities.some((c) => c.id === `process:${command.program}` && c.granted) + ) + throw new Error(tx`Cannot start PTY: process:${command.program} capability not granted.`); + const channel = new Channel(); + channel.onmessage = output; + return call(commands.pluginWebPtySpawn(pluginId, view, command, channel)); + }, + write: (id, data) => call(commands.termWrite(id, data)), + resize: (id, cols, rows) => call(commands.termResize(id, cols, rows)), + kill: (id) => call(commands.termKill(id)), + onExit: (listener) => onIdeEvent("terminalExited", (ev) => listener(ev.terminal, ev.code)), + }; +} diff --git a/src/features/plugins/views/ptyBridge.ts b/src/features/plugins/views/ptyBridge.ts index f2dc53734..b4b3ccf72 100644 --- a/src/features/plugins/views/ptyBridge.ts +++ b/src/features/plugins/views/ptyBridge.ts @@ -43,7 +43,7 @@ function dimensions(cols: unknown, rows: unknown): cols is number { ); } -/** One opaque frame owns this bridge. Terminal ids never cross the frame boundary. */ +/** One frame or log tab owns this bridge. Terminal ids never cross the frame boundary. */ export function ptyBridge(pluginId: string, backend: PtyBackend, send: (message: object) => void) { const sessions = new Map(); const earlyExits = new Map(); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 5005493ac..05c1adb3f 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -322,5 +322,15 @@ "devcontainer up exited with {status}": "devcontainer up exited with {status}", "Select an item to view details": "Select an item to view details", "Nothing to show": "Nothing to show", - "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory" + "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory", + "Invalid log stream configuration": "Invalid log stream configuration", + "Find in Logs": "Find in Logs", + "Waiting for output…": "Waiting for output…", + "Soft-wrap": "Soft-wrap", + "Clear": "Clear", + "\n[Process finished]": "\n[Process finished]", + "\n[Process finished with exit code {exitCode}]": "\n[Process finished with exit code {exitCode}]", + "\nError: {error}": "\nError: {error}", + "\n[Log stream stopped]": "\n[Log stream stopped]", + "Toggle Log Option": "Toggle Log Option" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 130ba8560..6304d6b8f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6687,6 +6687,13 @@ "Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.": "使用 Dev Containers CLI 构建或启动项目的开发容器(devcontainer.json),并在其中打开项目。", "Read-only: the table schema could not be identified in the result database.": "只读:无法确定结果所在数据库中的表架构。", "devcontainer up exited with {status}": "devcontainer up 已退出,状态为 {status}", + "Invalid log stream configuration": "无效的日志流配置", "Select an item to view details": "选择条目以查看详情", - "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE 托管的 Tomcat 运行目录不得与 Tomcat 主目录或基目录重叠" + "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE 托管的 Tomcat 运行目录不得与 Tomcat 主目录或基目录重叠", + "Find in Logs": "在日志中查找", + "\n[Process finished]": "\n[进程已结束]", + "\n[Process finished with exit code {exitCode}]": "\n[进程已结束,退出代码为 {exitCode}]", + "\nError: {error}": "\n错误:{error}", + "\n[Log stream stopped]": "\n[日志流已停止]", + "Toggle Log Option": "切换日志选项" } diff --git a/src/ui/LogPane.module.css b/src/ui/LogPane.module.css index aa1d8804f..714c460e0 100644 --- a/src/ui/LogPane.module.css +++ b/src/ui/LogPane.module.css @@ -17,6 +17,12 @@ .flex { flex: 1; } +.output { + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; +} .log { flex: 1; min-height: 0;