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 000000000..dba2d159c Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources new file mode 100644 index 000000000..7276e0e76 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources @@ -0,0 +1 @@ +74acb8ab9079c558a120c145319f01b54294cbf6fa4cbd33907a0d58e1ef2906 diff --git a/crates/ide-plugin/tests/fixtures/web-log/plugin.toml b/crates/ide-plugin/tests/fixtures/web-log/plugin.toml new file mode 100644 index 000000000..0bfee2f59 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-log/plugin.toml @@ -0,0 +1,29 @@ +id = "web-log" +name = "Web Log Probe" +version = "0.1.0" +description = "Test fixture for a capability-gated streaming editor tab." +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" +activation = ["on-command:ping", "on-command:open", "on-command:denied"] + +[capabilities] +optional = ["process:bash"] + +[[contributes.views]] +id = "logs" +title = "Log Probe" +icon = "file-log" +location = "right" +web = "web/index.html" + +[[contributes.commands]] +id = "ping" +title = "Log Probe: Ping" + +[[contributes.commands]] +id = "open" +title = "Log Probe: Open Stream" + +[[contributes.commands]] +id = "denied" +title = "Log Probe: Try Undeclared Program" diff --git a/crates/ide-plugin/tests/fixtures/web-log/src/lib.rs b/crates/ide-plugin/tests/fixtures/web-log/src/lib.rs new file mode 100644 index 000000000..43d9d3ac8 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-log/src/lib.rs @@ -0,0 +1,19 @@ +use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin}; + +#[derive(Default)] +struct WebLog; + +impl Plugin for WebLog { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> 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;