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]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-08 04:06:04 +08:00
1 джерело 292ddc4d2a
коміт fbf57bed23
24 files changed
+1509 -27

No files matched your search

+44
Переглянути файл
@@ -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"
+20
Переглянути файл
@@ -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"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
74acb8ab9079c558a120c145319f01b54294cbf6fa4cbd33907a0d58e1ef2906
+29
Переглянути файл
@@ -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"
+19
Переглянути файл
@@ -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);
+33
Переглянути файл
@@ -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>
+48
Переглянути файл
@@ -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
+6
Переглянути файл
@@ -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).
+8
Переглянути файл
@@ -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";
}
+172
Переглянути файл
@@ -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();
}
});
});
+44
Переглянути файл
@@ -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();
});
+58
Переглянути файл
@@ -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>[] = [];
+10
Переглянути файл
@@ -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"],
+26 -24
Переглянути файл
@@ -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()
+53
Переглянути файл
@@ -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);
},
},
]);
}
+494
Переглянути файл
@@ -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>
);
}
+58
Переглянути файл
@@ -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;
}
+27
Переглянути файл
@@ -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)),
};
}
+1 -1
Переглянути файл
@@ -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>();
+11 -1
Переглянути файл
@@ -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"
}
+8 -1
Переглянути файл
@@ -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": "切换日志选项"
}
+6
Переглянути файл
@@ -17,6 +17,12 @@
.flex {
flex: 1;
}
.output {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
}
.log {
flex: 1;
min-height: 0;