feat(plugins): let plugin code open streaming log tabs (#2082)

views.open-log-tab(key, title, process-log) (SDK host::open_log_tab) opens the read-only log tab a web view opens with open-log-tab, from a command or event of a window, reusing the Services pane's process-log.

The host checks the process grant and limits when called and remembers what each key runs in that window; the tab starts it by key (plugin_log_tab_spawn) through the web-view PTY checks: pinned program, trusted project, session cap, lifecycle and grant revocation. Remote windows report that these tabs aren't available there yet.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-08 10:01:35 +08:00
1 parent ec171c3188
commit 2bf412ff0d
92 files changed
+496 -50

No files matched your search

+48
View File
@@ -437,8 +437,18 @@ pub(super) struct Shared {
pub ui: Arc<ide_extension::UiStore>,
/// The UI language the windows report (BCP 47).
pub locale: RwLock<String>,
/// (plugin, window) → the log tabs its code opened there, oldest first:
/// what a tab may run when it starts.
log_tabs: Mutex<HashMap<(String, String), LogTabs>>,
}
/// A window's log tabs of one plugin: key → what it runs, oldest first.
type LogTabs = Vec<(String, ide_plugin::ServiceLog)>;
/// Log tabs a plugin's code may have open in one window; opening more forgets
/// the oldest (that tab can no longer start its program).
const MAX_LOG_TABS: usize = 32;
impl Shared {
pub fn new(
sink: Arc<dyn PluginEventSink>,
@@ -467,6 +477,7 @@ impl Shared {
closed_clients: Mutex::default(),
subscriptions: Mutex::default(),
diagnostics: Mutex::default(),
log_tabs: Mutex::default(),
})
}
@@ -565,6 +576,20 @@ impl Shared {
self.closed_clients.lock().remove(client);
}
/// What the log tab `key` that `plugin`'s code opened in `window` runs.
pub fn log_tab(&self, plugin: &str, window: &str, key: &str) -> Option<ide_plugin::ServiceLog> {
let tabs = self.log_tabs.lock();
let tabs = tabs.get(&(plugin.to_owned(), window.to_owned()))?;
tabs.iter().find(|(k, _)| k == key).map(|(_, log)| log.clone())
}
/// Forgets the log tabs of a closed window, or of a stopped plugin.
pub fn forget_log_tabs(&self, plugin: Option<&str>, window: Option<&str>) {
self.log_tabs
.lock()
.retain(|(p, w), _| plugin.is_some_and(|plugin| plugin != p) || window.is_some_and(|window| window != w));
}
pub fn client_closed(&self, client: &str) -> bool {
self.closed_clients.lock().contains(client)
}
@@ -1777,6 +1802,29 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_log_tab(
&self,
client: &str,
key: String,
title: String,
log: ide_plugin::ServiceLog,
) -> Result<(), HostError> {
// Fails now, rather than in the tab, when the approved file is gone or changed.
self.approved_program(&log.program)?;
{
let mut all = self.shared.log_tabs.lock();
let tabs = all.entry((self.id().to_owned(), client.to_owned())).or_default();
tabs.retain(|(k, _)| *k != key);
tabs.push((key.clone(), log.clone()));
if tabs.len() > MAX_LOG_TABS {
tabs.remove(0);
}
}
let event = PluginEvent::OpenLogTab { plugin_id: self.id().to_owned(), key, title, log };
self.shared.sink.emit(Some(client), event);
Ok(())
}
fn locale(&self) -> String {
self.shared.locale.read().clone()
}
@@ -118,6 +118,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
| PluginEvent::OpenLogTab { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
+4
View File
@@ -224,6 +224,10 @@ pub enum PluginEvent {
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Open (or focus) the log tab `key` following `log` (`views.open-log-tab`);
/// it starts the program with `plugin_log_tab_spawn`.
#[serde(rename_all = "camelCase")]
OpenLogTab { plugin_id: String, key: String, title: String, log: ide_plugin::ServiceLog },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
+67 -2
View File
@@ -134,13 +134,47 @@ impl PluginHost {
window: &str,
command: WebPtyCommand,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.web_pty_options(plugin_id, view, window, command)
})
}
/// Starts the program of the log tab `key` that the plugin's code opened
/// in `window` (`views.open-log-tab`), with the toggles in `on`: the UI
/// names the tab, never what runs.
#[allow(clippy::too_many_arguments)]
pub fn spawn_log_tab_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
key: &str,
window: &str,
on: &[String],
(cols, rows): (u16, u16),
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.log_tab_pty_options(plugin_id, key, window, on, (cols, rows))
})
}
/// The PTY of a plugin, counted against its sessions and ended with its
/// window, project or permission; `options` authorizes it.
fn spawn_plugin_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
window: &str,
output: crate::ops::Emit<String>,
options: impl FnOnce() -> IdeResult<ide_terminal::TerminalOptions>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let _guard = self.lifecycle.lock();
let mut sessions = self.web_ptys.lock();
if self.shared.client_closed(window) {
return Err(IdeError::cancelled());
}
let options = self.web_pty_options(plugin_id, view, window, command)?;
let options = options()?;
let entries = sessions.entry(plugin_id.to_owned()).or_default();
entries.retain(|s| s.host.upgrade().is_some_and(|h| h.terminals.contains(s.id)));
if entries.len() >= 8 {
@@ -166,6 +200,7 @@ impl PluginHost {
}
pub(super) fn forget_web_ptys(&self, window: &str) {
self.shared.forget_log_tabs(None, Some(window));
for sessions in self.web_ptys.lock().values_mut() {
sessions.retain(|session| {
if session.window != window {
@@ -180,6 +215,7 @@ impl PluginHost {
}
pub(super) fn stop_web_ptys(&self, plugin: &str) {
self.shared.forget_log_tabs(Some(plugin), None);
if let Some(sessions) = self.web_ptys.lock().remove(plugin) {
for session in sessions {
if let Some(host) = session.host.upgrade() {
@@ -198,8 +234,37 @@ impl PluginHost {
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
self.web_page(plugin_id, view)?;
self.get(plugin_id)?.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
self.pty_options(plugin_id, window, command)
}
/// The options of the log tab `key`'s program, with the toggles in `on`.
pub fn log_tab_pty_options(
&self,
plugin_id: &str,
key: &str,
window: &str,
on: &[String],
(cols, rows): (u16, u16),
) -> IdeResult<ide_terminal::TerminalOptions> {
let log = self.shared.log_tab(plugin_id, window, key).ok_or_else(|| {
IdeError::not_found(format!("plugin {plugin_id:?} opened no log tab {key:?} in this window"))
})?;
let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args);
let args = log.args.into_iter().chain(toggled).collect();
let command = WebPtyCommand { program: log.program, args, env: Default::default(), cols, rows };
self.pty_options(plugin_id, window, command)
}
/// Checks `command` against the plugin's current grant, the limits and
/// the window's trusted project.
fn pty_options(
&self,
plugin_id: &str,
window: &str,
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
let plugin = self.get(plugin_id)?;
plugin.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
let cap = ide_extension::Capability::Process(command.program.clone());
if !plugin.meta.lock().granted.contains(&cap) {
return Err(IdeError::new(
+53
View File
@@ -5016,6 +5016,59 @@ fn web_log_tabs_use_the_web_pty_permission_boundary() {
}
}
/// `views.open-log-tab` from the plugin's code: the host remembers what the tab
/// may run, and the tab starts it by key, through the same PTY checks.
#[test]
fn log_tabs_opened_by_code_run_only_what_the_host_approved() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("web-log"), &Consent::default()).unwrap();
let err = f.run("web-log", "code", None).unwrap_err();
assert!(err.contains("process:bash"), "{err}");
let options = |key: &str, window: &str, on: &[&str]| {
let on: Vec<String> = on.iter().map(|s| s.to_string()).collect();
f.host.log_tab_pty_options("web-log", key, window, &on, (240, 40))
};
assert_eq!(options("code", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
#[cfg(unix)]
{
f.host.set_capability("web-log", "process:bash", true).unwrap();
f.take_events();
assert_eq!(f.run("web-log", "code", None).unwrap(), None);
let events = f.take_events();
let (client, log) = events
.iter()
.find_map(|(client, event)| match event {
PluginEvent::OpenLogTab { plugin_id, key, title, log }
if plugin_id == "web-log" && key == "code" && title == "Probe logs (code)" =>
{
Some((client.clone(), log.clone()))
}
_ => None,
})
.expect("an OpenLogTab event");
assert_eq!(client.as_deref(), Some("main"));
assert_eq!((log.program.as_str(), log.toggles[0].id.as_str()), ("bash", "verbose"));
let got = options("code", "main", &[]).unwrap();
assert_eq!(got.shell.as_deref(), f.host.program_for("web-log", "bash").unwrap().to_str());
assert_eq!(got.args, log.args);
assert_eq!(got.cwd.0, f.project);
assert!(got.env.is_empty() && !got.shell_integration);
// Toggles append their arguments; unknown ones add nothing.
let verbose = options("code", "main", &["verbose", "bogus"]).unwrap();
assert_eq!(verbose.args.last().map(String::as_str), Some("--verbose"));
assert_eq!(verbose.args.len(), log.args.len() + 1);
// Only the window it was opened in, and only that key.
assert_eq!(options("code", "other", &[]).unwrap_err().kind, ErrorKind::NotFound);
assert_eq!(options("probe", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
// A revoked grant stops it starting, and forgets the tab.
f.host.set_capability("web-log", "process:bash", false).unwrap();
assert!(options("code", "main", &[]).is_err());
f.host.set_capability("web-log", "process:bash", true).unwrap();
assert_eq!(options("code", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
}
}
#[test]
fn web_ptys_require_a_current_exact_program_grant_and_project() {
use ide_host::plugins::web::WebPtyCommand;
+3
View File
@@ -245,6 +245,9 @@ pub trait HostApi: Send + Sync {
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// Opens a log tab `key` in `client`'s window following `log` (already
/// checked against the plugin's grants and limits).
fn open_log_tab(&self, client: &str, key: String, title: String, log: ServiceLog) -> Result<(), HostError>;
/// The IDE's UI language as a BCP 47 tag.
fn locale(&self) -> String;
/// A setting of the plugin as JSON (project over global over the schema default).
+48 -2
View File
@@ -117,8 +117,8 @@ pub(crate) mod views_bindings {
}
use bindings::openidea::plugin::{
commit, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage, tasks,
types as wit, ui, vcs, views, workspace,
commit, detail_types, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage,
tasks, types as wit, ui, vcs, views, workspace,
};
/// A project handle: its root.
@@ -661,6 +661,8 @@ impl workspace::HostProject for StoreData {
impl editor::Host for StoreData {}
impl detail_types::Host for StoreData {}
impl editor::HostTextEditor for StoreData {
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
@@ -1151,6 +1153,29 @@ impl views::Host for StoreData {
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
}
fn open_log_tab(&mut self, key: String, title: String, log: views::ProcessLog) -> Fallible<()> {
if let Err(e) = self.require_interactive().and_then(|()| self.require(Capability::Process(log.program.clone())))
{
return Ok(Err(e));
}
if let Err(message) = check_log_tab(&key, &title, &log) {
return Ok(Err(wit::Error::Invalid(message)));
}
let Some(client) = self.invocation.client.clone() else {
return Ok(Err(wit::Error::Invalid("a log tab opens from a command or event of a window".into())));
};
let log = crate::ServiceLog {
program: log.program,
args: log.args,
toggles: log
.toggles
.into_iter()
.map(|t| crate::ServiceLogToggle { id: t.id, label: t.label, args: t.args })
.collect(),
};
Ok(self.host.open_log_tab(&client, key, title, log).map_err(to_wit))
}
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
if let Ok(dir) = self.host.plugin_dir()
&& let Ok(real) = sandbox::resolve_granted(&[Capability::FsRead(to_ipc(&dir))], &path)
@@ -1301,6 +1326,27 @@ impl fs::Host for StoreData {
}
}
/// The limits of a log tab, the same a web view's `open-log-tab` has.
fn check_log_tab(key: &str, title: &str, log: &views::ProcessLog) -> Result<(), String> {
let line = |s: &str, max: usize| !s.is_empty() && s.chars().count() <= max && !s.contains(['\0', '\r', '\n']);
let args = |a: &[String]| a.iter().all(|a| a.len() <= 4096 && !a.contains('\0'));
let total = log.args.len() + log.toggles.iter().map(|t| t.args.len()).sum::<usize>();
let mut ids = std::collections::BTreeSet::new();
if !line(key, 128) || !line(title, 256) {
Err("a log tab's key (at most 128 characters) and title (256) are one nonempty line".into())
} else if !line(&log.program, 256) || log.program.trim() != log.program {
Err(format!("{:?} is not a program name", log.program))
} else if log.toggles.len() > 32
|| log.toggles.iter().any(|t| !line(&t.id, 64) || !line(&t.label, 128) || !ids.insert(t.id.as_str()))
{
Err("at most 32 toggles, with unique ids (at most 64 characters) and labels (128)".into())
} else if total > 256 || !args(&log.args) || log.toggles.iter().any(|t| !args(&t.args)) {
Err("at most 256 arguments in all, of at most 4096 bytes each".into())
} else {
Ok(())
}
}
fn too_much_to_write(path: &str) -> wit::Error {
wit::Error::Invalid(format!("{path}: more than {} MiB to write", sandbox::MAX_WRITE_BYTES >> 20))
}
Binary file not shown.
@@ -1 +1 @@
6efbb6db8442a4dec21e65bb8c4c65e08b0da32082ee26e870c0419ebaf4e7fb
6d7d707c7492d487fbd9debae791ad7e3755ac005f1f518efa54e6536ec59f08
Binary file not shown.
@@ -1 +1 @@
21520d2522afaa7212e5c9b765110a334b2a1fe69302f602f05a61dcee08cd0b
652977e0bf63bc7664807016998a4d4795d2a7eb841ee1912ef562057e5437cb
Binary file not shown.
@@ -1 +1 @@
9d79e3f45abf0aaba03d30aa482decd3142d879b5964d12298b0aa6cbac581fd
42d3856b634bdaa69e21f4a42a9f375b73c2d19753a8986b82f2228bf92d37bc
Binary file not shown.
@@ -1 +1 @@
05442db7ac00e3bcc4670d87d846c46a05c02d101e1651c587245fd5aeb1152f
cacd381d84e1070fffe8f0ac64fad91b768df63b759c0b3e5e2d22a6a2fdeb73
Binary file not shown.
@@ -1 +1 @@
47c2a8deea407ed7bf6a56af8c118295f4ceed394faaff9b535e97cb4ea31115
0d418585dca868fbceb6432d06c5c838c3d42a9e99e7a9c7641199f25fb8fbe8
Binary file not shown.
@@ -1 +1 @@
884a42bd59ac64d08e9a0d3a72d4f1d1f25e850ef0b828be01142b61eda8db64
6caa94db0c866c0c7f86011dbf837b3f9edb1c891276a4d435a8d02e8f64e0cb
Binary file not shown.
@@ -1 +1 @@
48d2ab9cc824ed06ae34126d6651a9b7127599626cc00f7aa76d9b3161a2963f
8d9c6bcd572b6fb54dd42c79d66d4971e55ec3dc072fc769f5634212c2927e22
Binary file not shown.
@@ -1 +1 @@
5233d7518b685e641fdd48ac2a53e6ca7381c182d36d7920607714703ba37a47
2af65b7db7edce8450f0ca829adb3b79fcb64b1ec3d39b785fb9df9423815e7b
Binary file not shown.
@@ -1 +1 @@
3a77168e971d60ca4248b9bb2c27516614f74409324125821e74e3a236a67fb9
40f80dcba2f370d02e2d5ad9609b21500fc42fc7c7202b439485a9cbf7baf727
Binary file not shown.
@@ -1 +1 @@
4261434b2dd234426253f07f7e7e3daf8676fcf5680a578ac1b99f503fea2859
a5ca61ba85297c52936cf9892ccf1c678383641c84a5a885389b24582fa1abfb
Binary file not shown.
@@ -1 +1 @@
cc07e85ef5324b4d1d706665b0025b6682a581d1b91196f45a64284e238c7297
5723b90f8a742f912b315209fc597e3ea7f428a2a234cf4ea5b4620454206d71
Binary file not shown.
@@ -1 +1 @@
d0c974dee4010d0e3186bd426e3c53d09c2c56861b04e40f2187515bbb81f8d9
dd5d41490f53a3b9b03d45ee8aba9f3fb08dd9211e835db5d3f9f7c42d3b6f9a
Binary file not shown.
@@ -1 +1 @@
91ed6b9ee1cf77a0c3bee941aafceb252e7acd241f1d85ecb0366de98ee541d5
185557c699fc00796eb487f50c688c3e91d8895bd05d4185b492352c187f9d98
Binary file not shown.
@@ -1 +1 @@
3eb9c513691e5ad323d4ba6520ccec6305049a39f13e8d74dccb728ab2f0ffca
d047b5618051144ec754b481b6bc75fae428d96c83711e9f0edf78749df59016
Binary file not shown.
@@ -1 +1 @@
6d59a4f37827a64d40cfe34dbcd9fea2b1de40ab39596c364b2cb7f5502de063
18957534fc011510894544b61e154a9d0972c27ab1872d9d2accd01864984fa0
Binary file not shown.
@@ -1 +1 @@
5da249c8c78cfcdf8f580099974d2ca654e98debb319f253c42ba18e5082fd0e
3562bc253b0b4f0fa1cb15b9c8a7470a09719b28008aa8f5f459bbaba8eda598
Binary file not shown.
@@ -1 +1 @@
961f07c9e1044e3e8c861a85ce4b2020cbbe9ce02b3764d1ec44a83d6c95eba1
f28b40f3708ec4f828d04f7cec29208b66d0ab6158b26c60c0bd989cdc74f9b4
Binary file not shown.
@@ -1 +1 @@
31c61ed0204bd3b2f9152d20f9f275effc305ba7dd095d708c00f34fce023492
60f6c34375c918ee941dc8f5a1c7851732fd0f5dd2c14ec609db1955c557026a
Binary file not shown.
@@ -1 +1 @@
162cde1d53b72a4c88a98cc51a31a2536d0506aeef60e15295b44c682be50fb7
edac658f8a54e396f294c74d031a31787508583256659a8effa8cc3d330e9a08
Binary file not shown.
@@ -1 +1 @@
aa7327f38c6277cfd7751b921bd43ac63ab8cb615ea428ccdfe9c4750606c6ad
28e1d58c8ab50574e184cb401050a6fd2755c9398cd3a3720dc376f67461a44b
Binary file not shown.
@@ -1 +1 @@
afece2c28e5077507159da59a93a73ede869678d168b3eaa7345962183417744
e80605523d9aaa1c88c83deac439a39a739541bdb327e279352441547de3c232
Binary file not shown.
@@ -1 +1 @@
29dd12ef6b9399c3d58b80ca145f6e903b70a9b220be7ca69a2672ee2c3e5582
efb03a86021bf5690feab0446c77d977c6252a4da1ee41030d923350e6cc5d11
+5 -1
View File
@@ -4,7 +4,7 @@ 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"]
activation = ["on-command:ping", "on-command:open", "on-command:denied", "on-command:code"]
[capabilities]
optional = ["process:bash"]
@@ -27,3 +27,7 @@ title = "Log Probe: Open Stream"
[[contributes.commands]]
id = "denied"
title = "Log Probe: Try Undeclared Program"
[[contributes.commands]]
id = "code"
title = "Log Probe: Open From Code"
+19
View File
@@ -1,5 +1,14 @@
use openidea_plugin::details::ProcessLog;
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
const SCRIPT: &str = r#"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
read -t 1 ignored || true
done"#;
#[derive(Default)]
struct WebLog;
@@ -10,6 +19,16 @@ impl Plugin for WebLog {
host::post_view_message("logs", id)?;
Ok(None)
}
// The same stream as the page's, opened by the plugin's code.
"code" => {
let log = ProcessLog::new("bash", &["--noprofile", "--norc", "-c", SCRIPT, "log-probe"]).toggle(
"verbose",
"Verbose",
&["--verbose"],
);
host::open_log_tab("code", "Probe logs (code)", &log)?;
Ok(None)
}
"ping" => Ok(Some("Web log ready".into())),
_ => Err(Error::NotFound(id.into())),
}
Binary file not shown.
@@ -1 +1 @@
ebc3a1213b97addb5abcbbfcf4baaff3f7644d1b60b62e2f215d3a35f36f221b
009f7e069920bcf0e7699b57cd41954dc1dcb620977e081c24ec58ef0a9809d2
+19 -1
View File
@@ -1150,11 +1150,29 @@ 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.
A plugin's code opens the same tab without a page, from a command or event of a
window, with `host::open_log_tab(key, title, &log)` (WIT `views.open-log-tab`)
and the `ProcessLog` the Services pane uses:
```rust
use openidea_plugin::details::ProcessLog;
let log = ProcessLog::new("kubectl", &["logs", "-f", "deploy/api"]).toggle("previous", "Previous", &["--previous"]);
host::open_log_tab("api-logs", "api logs", &log)?;
```
The call fails at once without the `process:<program>` grant or over the limits
above. The host remembers what each key runs in that window, and the tab starts
it by key, so nothing the tab sends names a program; a plugin keeps at most 32
such tabs per window (opening more forgets the oldest). Tabs opened by code
don't run in remote projects yet.
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.
also ask the sandboxed page to open a tab, exercising the Wasm-to-page bridge;
`Log Probe: Open From Code` opens one from the plugin's code.
This extends the web bridge without changing released WIT signatures.
### Plugin-backed content editor tabs (API 1.3)
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b390c7f6acf5d32cbf451faf7a6393f2d4f130d5131ef0857a2b0904c434f598
7dfbea1faeae8930b462fe80d431c83c49aeafa45ac7acd36f7d7f05fac3aaa4
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1eea85130f5feacdcd6aade3548ac8e6ca4c1b316441346a89a7be6c13caaba7
adeef6ba69276d3ac8e9ba349ca6e787befa6532ccac69bc35a381c0428af189
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d2e1a074d81a4f29935524b6ba11a94d6a9bc301833ea721ed9e7cab461e198e
51def3be1a570ee61cb23f0b738002427298dbdafffcb687896531685bbe3c77
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
150ed795daab29e3a152e50ebdf5e4ec3ca569d792962b77fce5fac428e7e076
6c4439b36b22289c8dc54498d6e7e72a4c4cc15eac1a4957fc2b71d4fbd3c330
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
580e19fd349b6be1f2d4c32e572015337ef8f1e73b1b1f24199fc6d4ea73189f
00d56f431ab8e59c04796cf56054a98ba78040a5e4142c3f727725c31781a008
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
64d1e9a8bf48355f380feba052ea0ffb5f4ae7957b04d0a62a04e1d49d6ed690
9fca272eec5a601b8de6071aa358954e9b79db08f45714f077624418e8f616bc
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ef76535391c4186a6373ef61197d96cc3ad7c42f47505ec75328ba5385e4df6e
5adfc0d749feabe5fe7e20857fd636d01341b91f3dd8440779af6dcd14435619
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
246a3ace4e3ccdd9ed4f2499cde275c9ea2402fbb902893cc2d8d3403175bd47
1b388c40cb6be29c5e10e938c0c8f6848d10cb4bf1f7438db37f53b13314708e
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
75b3b93fe5ca2c5a58052c931f4d9cd824c379227d7f4cd8477fa89d045cda06
af4478864196b5802f505d26beafe4492bc1335536311bc873b9d18773f9eebb
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
59f62db0a1c20a1d4fa6b380cd3272ec70ea4789835873d0abe3839ec08c94c3
46ce9f2d5b4f87a4be26facea4870f3de59952dc95c536fcf33fb0ff0a7c27a7
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
548c6ed16fcd5d3a053bf6d8417d52ebd0fd74138d0665e9205eb8bf32ec497a
f4e37cdac925f376bac945d5bb86c648b559787aa22737e6440835456f9f74cf
+16
View File
@@ -451,6 +451,14 @@ mod imp {
wit::views::open_terminal(title, program, args).map_err(error)
}
pub fn open_log_tab(key: &str, title: &str, log: &crate::details::ProcessLog) -> Result<(), Error> {
use wit::detail_types::{LogToggle, ProcessLog};
let toggles =
log.toggles.iter().map(|t| LogToggle { id: t.id.clone(), label: t.label.clone(), args: t.args.clone() });
let log = ProcessLog { program: log.program.clone(), args: log.args.clone(), toggles: toggles.collect() };
wit::views::open_log_tab(key, title, &log).map_err(error)
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
wit::views::open_location(path, line, column).map_err(error)
}
@@ -705,6 +713,14 @@ pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(),
imp::open_terminal(title, program, args)
}
/// Opens (or focuses) the read-only log tab `key` in the invoking window,
/// following `log`'s program (the approved `process:<program>`) in the project
/// root, with its toggles above the output. Only from a command or event of a
/// window; `title` at most 256 characters, `key` 128, at most 32 toggles.
pub fn open_log_tab(key: &str, title: &str, log: &crate::details::ProcessLog) -> Result<(), Error> {
imp::open_log_tab(key, title, log)
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
imp::open_location(path, line, column)
}
+47
View File
@@ -122,6 +122,7 @@ struct State {
refreshed: Vec<String>,
view_messages: Vec<(String, String)>,
terminals: Vec<MockTerminal>,
log_tabs: Vec<MockLogTab>,
locations: Vec<(String, u32, u32)>,
settings: BTreeMap<String, String>,
locale: Option<String>,
@@ -135,6 +136,14 @@ pub struct MockTerminal {
pub args: Vec<String>,
}
/// A log tab the plugin opened.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MockLogTab {
pub key: String,
pub title: String,
pub log: crate::details::ProcessLog,
}
/// At most 8 programs a plugin may keep running, as the host allows.
const MAX_CHILDREN: usize = 8;
@@ -407,6 +416,11 @@ impl MockHost {
self.state.borrow().terminals.clone()
}
/// Log tabs the plugin opened, the latest of each key last.
pub fn log_tabs(&self) -> Vec<MockLogTab> {
self.state.borrow().log_tabs.clone()
}
/// The value of one of the plugin's settings, as JSON (`true`, `"text"`).
pub fn set_setting(&self, key: &str, json: &str) {
self.state.borrow_mut().settings.insert(key.into(), json.into());
@@ -1272,6 +1286,20 @@ pub mod current {
})
}
pub fn open_log_tab(key: &str, title: &str, log: &crate::details::ProcessLog) -> Result<(), Error> {
with(|s| {
require(s, &format!("process:{}", log.program))?;
let line =
|t: &str, max: usize| !t.is_empty() && t.chars().count() <= max && !t.contains(['\0', '\r', '\n']);
if !line(key, 128) || !line(title, 256) || log.toggles.len() > 32 {
return Err(Error::Invalid("a log tab's key, title or toggles are over the limits".into()));
}
s.log_tabs.retain(|t| t.key != key);
s.log_tabs.push(MockLogTab { key: key.into(), title: title.into(), log: log.clone() });
Ok(())
})
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
with(|s| {
if !is_private_path(path) {
@@ -1824,6 +1852,25 @@ mod tests {
assert_eq!(mock.opened_locations().len(), 2);
}
#[test]
fn log_tabs_need_the_program_grant() {
let log = crate::details::ProcessLog::new("kubectl", &["logs", "-f", "api"]).toggle(
"previous",
"Previous",
&["--previous"],
);
let _mock = MockHost::install();
assert_eq!(host::open_log_tab("api", "api logs", &log), Err(Error::Denied("process:kubectl".into())));
let mock = MockHost::install().with_capabilities(&["process:kubectl"]);
host::open_log_tab("api", "api logs", &log).unwrap();
// Reopening a key replaces that tab.
host::open_log_tab("api", "api logs again", &log).unwrap();
assert_eq!(mock.log_tabs().len(), 1);
assert_eq!((mock.log_tabs()[0].title.as_str(), &mock.log_tabs()[0].log), ("api logs again", &log));
assert!(matches!(host::open_log_tab("", "api logs", &log), Err(Error::Invalid(_))));
}
#[test]
fn fs_write_needs_a_grant_of_its_own() {
// `fs.read` does not let a plugin write.
+8
View File
@@ -123,6 +123,7 @@ interface view-types {
/// What a plugin's code can ask of its views.
interface views {
use types.{error};
use detail-types.{process-log};
/// The view (or every custom editor of that id) shows outdated content:
/// the IDE renders it again.
@@ -136,6 +137,13 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens a read-only log tab in the editor area of the window the call
/// came from, following `log`'s program (pinned by a `process:<program>`
/// grant) in the project root, its toggles above the output: the tab a
/// web view opens with `open-log-tab`. Reopening `key` focuses that tab.
/// Only from a command or event of a window; `title` at most 256
/// characters, `key` 128, at most 32 toggles.
open-log-tab: func(key: string, title: string, log: process-log) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
+1
View File
@@ -502,6 +502,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_service_log,
plugin::plugin_web_page,
plugin::plugin_web_pty_spawn,
plugin::plugin_log_tab_spawn,
plugin::plugin_web_save_file,
plugin::plugin_web_read_file,
plugin::plugin_line_markers,
+35
View File
@@ -565,6 +565,41 @@ pub async fn plugin_web_pty_spawn(
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Starts the program of a log tab the plugin's code opened in this window,
/// with the toggles in `on`.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn plugin_log_tab_spawn(
window: Window,
state: State<'_, AppState>,
plugin_id: String,
key: String,
on: Vec<String>,
cols: u16,
rows: u16,
on_output: Channel<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let plugins = state.plugins.clone();
let host = state.host.clone();
let window = window.label().to_owned();
tauri::async_runtime::spawn_blocking(move || {
plugins.spawn_log_tab_pty(
&host,
&plugin_id,
&key,
&window,
&on,
(cols, rows),
Arc::new(move |chunk| {
let _ = on_output.send(chunk);
}),
)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Largest file a web view saves or opens (`openidea.saveFile` / `openFile`).
const MAX_WEB_VIEW_FILE: u64 = 64 << 20;
+10
View File
@@ -1150,6 +1150,11 @@ export const commands = {
/** The page of a plugin's web view or custom editor (`web = …`). */
pluginWebPage: (pluginId: string, view: string) => typedError<WebPage, IdeError>(__TAURI_INVOKE("plugin_web_page", { pluginId, view })),
pluginWebPtySpawn: (pluginId: string, view: string, command: WebPtyCommand, onOutput: Channel<string>) => typedError<TerminalInfo, IdeError>(__TAURI_INVOKE("plugin_web_pty_spawn", { pluginId, view, command, onOutput })),
/**
* Starts the program of a log tab the plugin's code opened in this window,
* with the toggles in `on`.
*/
pluginLogTabSpawn: (pluginId: string, key: string, on: string[], cols: number, rows: number, onOutput: Channel<string>) => typedError<TerminalInfo, IdeError>(__TAURI_INVOKE("plugin_log_tab_spawn", { pluginId, key, on, cols, rows, onOutput })),
/**
* Writes what a web view saves (`openidea.saveFile`) to the path the user
* chose in the save dialog, on this machine.
@@ -6052,6 +6057,11 @@ actions: NotificationAction[] } |
{ type: "viewMessage"; pluginId: string; view: string; message: string } |
/** Open a terminal running `program` (already approved and verified). */
{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } | { type: "openLocation"; pluginId: string; path: IpcPath; line: number; column: number } |
/**
* Open (or focus) the log tab `key` following `log` (`views.open-log-tab`);
* it starts the program with `plugin_log_tab_spawn`.
*/
{ type: "openLogTab"; pluginId: string; key: string; title: string; log: ServiceLog } |
/**
* Plugin languages, or the servers they may start, changed: re-fetch
* `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
@@ -9,11 +9,11 @@ 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 { boundedLog, logTabData, type PluginLogEditorData } from "../views/logTab";
import { logTabPtyBackend, webPtyBackend } from "../views/ptyBackend";
import type { PtyBackend } from "../views/ptyBridge";
vi.mock("../views/ptyBackend", () => ({ webPtyBackend: vi.fn() }));
vi.mock("../views/ptyBackend", () => ({ webPtyBackend: vi.fn(), logTabPtyBackend: vi.fn() }));
const plugin = { id: "probe", enabled: true, capabilities: [{ id: "process:bash", granted: true }] } as PluginInfo;
const spec = {
@@ -36,7 +36,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
function setup() {
function setup(extra: Partial<PluginLogEditorData> = {}) {
usePlugins.setState({ plugins: [plugin] });
let output: (text: string) => void = () => {};
let exit: Parameters<PtyBackend["onExit"]>[0] = () => {};
@@ -54,7 +54,8 @@ function setup() {
},
};
vi.mocked(webPtyBackend).mockReturnValue(backend);
const data = logTabData("probe", "logs", spec, 100);
vi.mocked(logTabPtyBackend).mockReturnValue(backend);
const data = { ...logTabData("probe", "logs", spec, 100), ...extra };
const input = { kind: "pluginLog", key: "test-log", data };
const rendered = render(<PluginLogEditor input={input} />);
const content = () => rendered.container.querySelector(".cm-content")!;
@@ -122,6 +123,16 @@ describe("plugin streaming log tabs", () => {
expect(screen.getByRole("textbox", { name: "Find" })).toBeTruthy();
});
it("starts a tab opened by plugin code by its key, with the toggles that are on", async () => {
const f = setup({ view: "", key: "code", origin: "code" });
expect(webPtyBackend).not.toHaveBeenCalled();
expect(logTabPtyBackend).toHaveBeenLastCalledWith("probe", "code", []);
await act(async () => {});
fireEvent.click(screen.getByRole("switch", { name: "Verbose" }));
await waitFor(() => expect(f.backend.kill).toHaveBeenCalledWith(42));
expect(logTabPtyBackend).toHaveBeenLastCalledWith("probe", "code", ["verbose"]);
});
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();
+18
View File
@@ -31,6 +31,8 @@ import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { pluginsBackend } from "./backend";
import { usePluginT } from "./pluginText";
import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor";
import type { PluginLogEditorData } from "./views/logTab";
import { bumpView, deliverViewMessage } from "./views/viewStore";
import { revealWindowView } from "./views/viewWindows";
import { runPluginCommand, usePlugins } from "./store";
@@ -420,6 +422,22 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
// The program was approved (`process:`) and verified by the host.
void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd });
return true;
case "openLogTab":
void maybeEditorService()?.openInput({
kind: PLUGIN_LOG_EDITOR_KIND,
key: `pluginLog:${JSON.stringify([e.pluginId, "", e.key])}`,
data: {
pluginId: e.pluginId,
view: "",
key: e.key,
title: e.title,
program: e.log.program,
args: e.log.args,
toggles: e.log.toggles,
origin: "code",
} satisfies PluginLogEditorData,
});
return true;
case "openLocation":
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
return true;
+6 -2
View File
@@ -23,7 +23,7 @@ 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 { logTabPtyBackend, webPtyBackend } from "./ptyBackend";
import { ptyBridge } from "./ptyBridge";
export const PLUGIN_LOG_EDITOR_KIND = "pluginLog";
@@ -371,7 +371,11 @@ function PluginLogEditorBody({ input, data }: { input: EditorInput; data: Plugin
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 backend =
data.origin === "code"
? logTabPtyBackend(data.pluginId, data.key ?? "", [...on])
: webPtyBackend(data.pluginId, data.view);
const bridge = ptyBridge(data.pluginId, backend, (message) => {
const m = message as Record<string, unknown>;
if (stopped) return;
if (m.op === "pty-data") {
+2
View File
@@ -15,6 +15,8 @@ export interface PluginLogEditorData {
program: string;
args: string[];
toggles: PluginLogToggleSpec[];
/** Opened by the plugin's code (`views.open-log-tab`): the host starts what it approved for `key`. */
origin?: "code";
}
const text = (v: unknown, max: number): v is string =>
+24 -1
View File
@@ -1,11 +1,34 @@
import { Channel } from "@tauri-apps/api/core";
import { tx } from "@/platform/i18n";
import type { TerminalInfo, WebPtyCommand } from "@/bindings";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { isRemoteWindow } from "@/platform/remote";
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 pluginPtyBackend(pluginId, (command, channel) =>
call(commands.pluginWebPtySpawn(pluginId, view, command, channel)),
);
}
/**
* A log tab the plugin's code opened: the host runs what it approved for `key`,
* with the toggles in `on`; the command the tab builds only lends its size.
*/
export function logTabPtyBackend(pluginId: string, key: string, on: string[]): PtyBackend {
return pluginPtyBackend(pluginId, (command, channel) => {
if (isRemoteWindow())
throw new Error(tx("Log tabs opened by plugin code are not available in remote projects yet."));
return call(commands.pluginLogTabSpawn(pluginId, key, on, command.cols, command.rows, channel));
});
}
function pluginPtyBackend(
pluginId: string,
start: (command: WebPtyCommand, channel: Channel<string>) => Promise<TerminalInfo>,
): PtyBackend {
return {
spawn: async (command, output) => {
const current = usePlugins.getState().plugins.find((p) => p.id === pluginId);
@@ -17,7 +40,7 @@ export function webPtyBackend(pluginId: string, view: string): PtyBackend {
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));
return start(command, channel);
},
write: (id, data) => call(commands.termWrite(id, data)),
resize: (id, cols, rows) => call(commands.termResize(id, cols, rows)),
+2 -1
View File
@@ -402,5 +402,6 @@
"The plugin did not save the content": "The plugin did not save the content",
"Running an action applies pending settings first.": "Running an action applies pending settings first.",
"Could not apply settings.": "Could not apply settings.",
"The value exceeds this setting’s limits.": "The value exceeds this setting’s limits."
"The value exceeds this setting’s limits.": "The value exceeds this setting’s limits.",
"Log tabs opened by plugin code are not available in remote projects yet.": "Log tabs opened by plugin code are not available in remote projects yet."
}
+2 -1
View File
@@ -6755,5 +6755,6 @@
"The plugin did not save the content": "插件未保存内容",
"Running an action applies pending settings first.": "运行动作前会先应用待保存的设置。",
"Could not apply settings.": "无法应用设置。",
"The value exceeds this setting’s limits.": "该值超出了此设置的限制。"
"The value exceeds this setting’s limits.": "该值超出了此设置的限制。",
"Log tabs opened by plugin code are not available in remote projects yet.": "插件代码打开的日志标签页暂不支持远程项目。"
}
+8
View File
@@ -123,6 +123,7 @@ interface view-types {
/// What a plugin's code can ask of its views.
interface views {
use types.{error};
use detail-types.{process-log};
/// The view (or every custom editor of that id) shows outdated content:
/// the IDE renders it again.
@@ -136,6 +137,13 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens a read-only log tab in the editor area of the window the call
/// came from, following `log`'s program (pinned by a `process:<program>`
/// grant) in the project root, its toggles above the output: the tab a
/// web view opens with `open-log-tab`. Reopening `key` focuses that tab.
/// Only from a command or event of a window; `title` at most 256
/// characters, `key` 128, at most 32 toggles.
open-log-tab: func(key: string, title: string, log: process-log) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.