дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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:<program>` 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 <[email protected]>
Цей коміт міститься в:
1 джерело
292ddc4d2a
коміт
fbf57bed23
24 files changed
+1509
-27
No files matched your search
@@ -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;
|
||||
|
||||
+333
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
74acb8ab9079c558a120c145319f01b54294cbf6fa4cbd33907a0d58e1ef2906
|
||||
@@ -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"
|
||||
@@ -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<Option<String>, 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);
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Log Probe</title>
|
||||
<style>
|
||||
body { background: var(--openidea-bg); color: var(--openidea-fg); font-family: var(--openidea-font, sans-serif); padding: 12px; }
|
||||
button { font: inherit; padding: 6px; } pre { white-space: pre-wrap; }
|
||||
</style></head><body>
|
||||
<h2>Streaming log tab</h2>
|
||||
<p>Grant process:bash in Settings → Plugins, then open the stream. Toggle Verbose to restart it. Switch tabs, find, clear, wrap, and close the tab.</p>
|
||||
<button id="open">Open log stream</button>
|
||||
<button id="denied">Try undeclared program</button>
|
||||
<pre id="status" aria-live="polite"></pre>
|
||||
<script>
|
||||
const script = `i=0
|
||||
while true; do
|
||||
i=$((i+1))
|
||||
printf 'INFO probe line %s — Unicode 日志\n' "$i"
|
||||
if [ "$1" = "--verbose" ]; then printf 'DEBUG verbose output enabled\n'; fi
|
||||
if [ "$i" = 1 ]; then printf 'WARN a long line for wrapping: %0200d\n' 0; fi
|
||||
read -t 1 ignored || true
|
||||
done`;
|
||||
const send = (program) => openidea.postMessage(JSON.stringify({
|
||||
op: 'open-log-tab', key: 'probe', title: 'Probe logs', program,
|
||||
baseArgs: ['--noprofile', '--norc', '-c', script, 'log-probe'],
|
||||
toggles: [{ id: 'verbose', label: 'Verbose', args: ['--verbose'] }]
|
||||
}));
|
||||
document.querySelector('#open').onclick = () => send('bash');
|
||||
document.querySelector('#denied').onclick = () => send('sh');
|
||||
openidea.onMessage(text => {
|
||||
if (text === 'open' || text === 'denied') { send(text === 'open' ? 'bash' : 'sh'); return; }
|
||||
let m; try { m = JSON.parse(text); } catch { return; }
|
||||
if (m.op === 'log-error') document.querySelector('#status').textContent = m.error;
|
||||
});
|
||||
</script></body></html>
|
||||
@@ -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/<tag>.json`, one file per language
|
||||
|
||||
@@ -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).
|
||||
@@ -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<State> = {
|
||||
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<State> = {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -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<PtyBackend["onExit"]>[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(<PluginLogEditor input={input} />);
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<object>()),
|
||||
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();
|
||||
});
|
||||
@@ -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:<program> 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<typeof vi.fn>[] = [];
|
||||
|
||||
@@ -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<string, { pluginId: string; decl: CustomEditorDecl; mat
|
||||
/** One-time registrations behind the points: the editor kind and the context menu group. */
|
||||
export function startViewUi(openInput: (input: ReturnType<typeof customEditorInput>) => void): () => void {
|
||||
const disposers = [
|
||||
registerLogActions(),
|
||||
registerEditorKind({
|
||||
kind: CUSTOM_EDITOR_KIND,
|
||||
component: CustomEditor,
|
||||
@@ -615,6 +618,13 @@ export function startViewUi(openInput: (input: ReturnType<typeof customEditorInp
|
||||
restorable: true,
|
||||
document: customEditorDocument,
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: PLUGIN_LOG_EDITOR_KIND,
|
||||
component: PluginLogEditor,
|
||||
keepAlive: true,
|
||||
title: pluginLogEditorTitle,
|
||||
icon: () => "file-log",
|
||||
}),
|
||||
registerMenuGroup({
|
||||
id: "plugins",
|
||||
menus: ["editor", "projectView", "editorTab"],
|
||||
|
||||
@@ -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<string>();
|
||||
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()
|
||||
|
||||
@@ -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<string, LogControls>();
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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<PluginLogEditorData> | 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 <EmptyState title={tx("Invalid log stream configuration")} />;
|
||||
}
|
||||
return <PluginLogEditorBody input={input} data={data} />;
|
||||
}
|
||||
|
||||
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<ReadonlySet<string>>(() => new Set());
|
||||
const [wrap, setWrap] = useState<boolean>(initialWrap);
|
||||
const [find, setFind] = useState<{ seq: number } | null>(null);
|
||||
const [viewGen, setViewGen] = useState(0);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const 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<FindTarget>(
|
||||
() => ({
|
||||
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<unknown>[] = [];
|
||||
const nextPrefs = editorPrefs();
|
||||
const nextEditor = editorSettings();
|
||||
if (JSON.stringify(nextPrefs.appearance) !== JSON.stringify(prevAppearance)) {
|
||||
prevAppearance = nextPrefs.appearance;
|
||||
effects.push(caretSlot.reconfigure(caretExtension(nextPrefs)));
|
||||
}
|
||||
if (nextEditor !== prevEditor) {
|
||||
prevEditor = nextEditor;
|
||||
effects.push(
|
||||
fontSlot.reconfigure(fontExtension(nextEditor)),
|
||||
lineNumbersSlot.reconfigure(lineNumbersExtension(nextEditor)),
|
||||
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<string, unknown>;
|
||||
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 (
|
||||
<div ref={rootRef} className={logStyles.pane} onKeyDown={onKeyDown}>
|
||||
<div className={logStyles.toolbar}>
|
||||
{(data.toggles ?? []).map((t) => (
|
||||
<Toggle
|
||||
key={t.id}
|
||||
checked={on.has(t.id)}
|
||||
onChange={(checked) =>
|
||||
void executeAction("plugins.log.toggleArgument", { target: findId, id: t.id, checked })
|
||||
}
|
||||
label={t.label}
|
||||
aria-label={t.label}
|
||||
/>
|
||||
))}
|
||||
<Toggle
|
||||
checked={wrap}
|
||||
onChange={() => void executeAction("plugins.log.wrap", findId)}
|
||||
label={tx("Soft-wrap")}
|
||||
aria-label={tx("Soft-wrap")}
|
||||
/>
|
||||
<span className={logStyles.flex} />
|
||||
<IconButton icon="search" size="small" title="Find in Logs" action="plugins.log.find" actionArg={findId} />
|
||||
<IconButton icon="trash" size="small" title="Clear" action="plugins.log.clear" actionArg={findId} />
|
||||
</div>
|
||||
{find && <FindPanel target={findTarget} version={viewGen} replace={false} seq={find.seq} onClose={closeFind} />}
|
||||
<div ref={hostRef} className={logStyles.output} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
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)),
|
||||
};
|
||||
}
|
||||
@@ -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<string, Session>();
|
||||
const earlyExits = new Map<TerminalId, number | null>();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "切换日志选项"
|
||||
}
|
||||
@@ -17,6 +17,12 @@
|
||||
.flex {
|
||||
flex: 1;
|
||||
}
|
||||
.output {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
Reference in new issue
Заблокувати користувача