mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
1 parent
ec171c3188
commit
2bf412ff0d
92 files changed
+496
-50
No files matched your search
@@ -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 { .. }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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)
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b390c7f6acf5d32cbf451faf7a6393f2d4f130d5131ef0857a2b0904c434f598
|
||||
7dfbea1faeae8930b462fe80d431c83c49aeafa45ac7acd36f7d7f05fac3aaa4
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
1eea85130f5feacdcd6aade3548ac8e6ca4c1b316441346a89a7be6c13caaba7
|
||||
adeef6ba69276d3ac8e9ba349ca6e787befa6532ccac69bc35a381c0428af189
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
d2e1a074d81a4f29935524b6ba11a94d6a9bc301833ea721ed9e7cab461e198e
|
||||
51def3be1a570ee61cb23f0b738002427298dbdafffcb687896531685bbe3c77
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
150ed795daab29e3a152e50ebdf5e4ec3ca569d792962b77fce5fac428e7e076
|
||||
6c4439b36b22289c8dc54498d6e7e72a4c4cc15eac1a4957fc2b71d4fbd3c330
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
580e19fd349b6be1f2d4c32e572015337ef8f1e73b1b1f24199fc6d4ea73189f
|
||||
00d56f431ab8e59c04796cf56054a98ba78040a5e4142c3f727725c31781a008
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
64d1e9a8bf48355f380feba052ea0ffb5f4ae7957b04d0a62a04e1d49d6ed690
|
||||
9fca272eec5a601b8de6071aa358954e9b79db08f45714f077624418e8f616bc
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ef76535391c4186a6373ef61197d96cc3ad7c42f47505ec75328ba5385e4df6e
|
||||
5adfc0d749feabe5fe7e20857fd636d01341b91f3dd8440779af6dcd14435619
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
246a3ace4e3ccdd9ed4f2499cde275c9ea2402fbb902893cc2d8d3403175bd47
|
||||
1b388c40cb6be29c5e10e938c0c8f6848d10cb4bf1f7438db37f53b13314708e
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
75b3b93fe5ca2c5a58052c931f4d9cd824c379227d7f4cd8477fa89d045cda06
|
||||
af4478864196b5802f505d26beafe4492bc1335536311bc873b9d18773f9eebb
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
59f62db0a1c20a1d4fa6b380cd3272ec70ea4789835873d0abe3839ec08c94c3
|
||||
46ce9f2d5b4f87a4be26facea4870f3de59952dc95c536fcf33fb0ff0a7c27a7
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
548c6ed16fcd5d3a053bf6d8417d52ebd0fd74138d0665e9205eb8bf32ec497a
|
||||
f4e37cdac925f376bac945d5bb86c648b559787aa22737e6440835456f9f74cf
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.": "插件代码打开的日志标签页暂不支持远程项目。"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user