diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 11624cf1d..e2f6977e9 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -417,6 +417,129 @@ impl PluginApi { } } +impl PluginApi { + /// Runs an approved program until it exits or `cancel` fires, passing + /// each output line to `on_line`; returns its exit code. + pub(super) fn stream_process( + &self, + program: &str, + args: Vec, + cwd: Option<&Path>, + cancel: &CancellationToken, + on_line: &dyn Fn(super::PluginOutputLine), + ) -> Result, HostError> { + let binary = self.approved_program(program)?; + let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id())); + let _ = std::fs::create_dir_all(&cwd); + let mut cmd = std::process::Command::new(&binary); + ide_core::process::prepare(ide_core::process::own_group(&mut cmd)); + let mut child = cmd + .args(&args) + .current_dir(&cwd) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?; + // Bounded: a program printing faster than the lines are sent waits on its pipe. + let (tx, rx) = std::sync::mpsc::sync_channel(1024); + let read = |pipe: Box, stderr: bool| { + let tx = tx.clone(); + std::thread::spawn(move || { + read_lines(pipe, MAX_OUTPUT_LINE, &mut |text| { + tx.send(super::PluginOutputLine { stderr, text }).is_ok() + }); + }); + }; + if let Some(out) = child.stdout.take() { + read(Box::new(out), false); + } + if let Some(err) = child.stderr.take() { + read(Box::new(err), true); + } + drop(tx); + // Revoking the grant removes the pin: stop then. + let cap = format!("process:{program}"); + let pin = (self.id().to_owned(), cap.clone()); + let stop = |child: &mut std::process::Child| -> Option { + if cancel.is_cancelled() { + kill_group(child); + return Some(HostError::Cancelled); + } + if !self.shared.pins.read().contains_key(&pin) { + kill_group(child); + return Some(HostError::Denied(cap.clone())); + } + None + }; + loop { + if let Some(e) = stop(&mut child) { + return Err(e); + } + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(line) => on_line(line), + // Both pipes closed: the program is done (or detached them). + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + } + loop { + if let Some(status) = child.try_wait().map_err(failed)? { + return Ok(status.code()); + } + if let Some(e) = stop(&mut child) { + return Err(e); + } + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +/// Longest output line a followed program sends; the rest of the line is dropped. +const MAX_OUTPUT_LINE: usize = 16 << 10; + +/// Passes `pipe`'s lines (without `\r\n`, at most `max` bytes each) to `line` +/// until it returns false or the pipe closes, holding at most one line. +fn read_lines(pipe: Box, max: usize, line: &mut dyn FnMut(String) -> bool) { + use std::io::{BufRead, Read}; + let mut reader = std::io::BufReader::new(pipe); + let mut buf = Vec::new(); + loop { + buf.clear(); + match (&mut reader).take(max as u64).read_until(b'\n', &mut buf) { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + if buf.last() != Some(&b'\n') && buf.len() == max { + // Too long: skip to the end of the line. + let mut rest = Vec::new(); + loop { + rest.clear(); + match (&mut reader).take(max as u64).read_until(b'\n', &mut rest) { + Ok(0) | Err(_) => break, + Ok(_) if rest.last() == Some(&b'\n') => break, + Ok(_) => {} + } + } + } + let end = buf.strip_suffix(b"\n").unwrap_or(&buf); + let end = end.strip_suffix(b"\r").unwrap_or(end); + if !line(String::from_utf8_lossy(end).into_owned()) { + return; + } + } +} + +/// Kills a program spawned in its own group and what it started. A kill that +/// lands while the program forks can miss the new process, so the group is +/// signalled again before the program is reaped (until then its group id +/// can't be reused). +fn kill_group(child: &mut std::process::Child) { + ide_core::process::kill_tree_of(child.id()); + std::thread::sleep(Duration::from_millis(50)); + ide_core::process::kill_tree(child); +} + fn failed(e: impl std::fmt::Display) -> HostError { HostError::Failed(e.to_string()) } diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 80dd5ee74..7d2d13c38 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -61,7 +61,8 @@ use ide_extension::{ use ide_language::plugin_point::PluginLanguages; use ide_language::{LanguageConfig, ProgramPin}; use ide_plugin::{ - Actor, ActorError, CANCELLED, CallError, HostApi, Invocation, Limits, PluginEngine, Runtime, RuntimeSetup, + Actor, ActorError, CANCELLED, CallError, HostApi, HostError, Invocation, Limits, PluginEngine, Runtime, + RuntimeSetup, }; use parking_lot::{Condvar, Mutex, RwLock}; use serde::{Deserialize, Serialize}; @@ -1076,6 +1077,66 @@ impl PluginHost { }) } + /// The Services pane of node `key` of the plugin's `location = "services"` + /// view `view`. + pub fn service_detail( + &self, + id: &str, + view: &str, + key: &str, + invocation: Invocation, + ) -> IdeResult> { + self.services_view(id, view)?; + let (view, key) = (view.to_owned(), key.to_owned()); + self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| { + rt.service_detail(&view, &key, inv) + }) + } + + /// Runs the program of the log tab `tab` of node `key`'s pane, with the + /// arguments of the toggles in `on`, streaming its output until it exits + /// or `cancel` fires; returns its exit code. The pane is asked of the + /// plugin again, so the UI never names what runs. Needs the plugin's + /// `process:` grant. + #[allow(clippy::too_many_arguments)] + pub fn stream_service_log( + &self, + id: &str, + view: &str, + key: &str, + tab: &str, + on: &[String], + invocation: Invocation, + cancel: &ide_core::CancellationToken, + on_line: &dyn Fn(PluginOutputLine), + ) -> IdeResult> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let cwd = invocation.workspace_root.clone(); + let tabs = self.service_detail(id, view, key, invocation)?; + let Some(ide_plugin::ServicePane::Log(log)) = tabs.into_iter().find(|t| t.id == tab).map(|t| t.body) else { + return Err(IdeError::not_found(format!("the pane of {key:?} has no log tab {tab:?}"))); + }; + let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args); + let args: Vec = log.args.into_iter().chain(toggled).collect(); + let plugin = + self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("plugin {id:?}")))?; + let cap = Capability::Process(log.program.clone()); + if !plugin.meta.lock().granted.iter().any(|g| g.covers(&cap)) { + let denied = HostError::Denied(cap.to_string()).to_string(); + return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, denied)); + } + let api = api::PluginApi { pkg: plugin.pkg.clone(), shared: self.shared.clone() }; + api.stream_process(&log.program, args, cwd.as_deref(), cancel, on_line).map_err(|e| match e { + HostError::Cancelled => IdeError::cancelled(), + e @ HostError::Denied(_) => IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e.to_string()), + HostError::NotFound(m) => IdeError::not_found(m), + HostError::Invalid(m) => IdeError::invalid(m), + e => IdeError::unavailable(e.to_string()), + }) + } + fn declared_view(&self, id: &str, view: &str) -> IdeResult<()> { let declared = [ui_points::VIEWS, ui_points::CUSTOM_EDITORS] .iter() @@ -1083,6 +1144,19 @@ impl PluginHost { if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) } } + fn services_view(&self, id: &str, view: &str) -> IdeResult<()> { + let services = self + .ui + .items(ui_points::VIEWS, Some(id)) + .iter() + .any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION)); + if services { + Ok(()) + } else { + Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}"))) + } + } + /// One call into a plugin's code for the UI: checks it may run (asking /// for missing consent), activates it, retries once when the instance was /// reclaimed meanwhile, and reports crashes. diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 60379b13d..6815ca382 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -14,7 +14,8 @@ //! The commands a window needs at run time (`plugin_run_command`, //! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`, //! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`, -//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`) +//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`, +//! `plugin_service_log`) //! carry the desktop commands' names and arguments, so a remote window's UI //! routes them here unchanged. Plugin results come back as `plugin` //! notifications ([`PluginEventMessage`]); secrets stay in the desktop's @@ -465,6 +466,40 @@ impl RemotePlugins { } }); let h = host.clone(); + d.add("plugin_service_detail", move |c| { + let h = h.clone(); + async move { + super::views::service_detail( + &c.host, + alive(&h)?, + &c.window, + c.arg("pluginId")?, + c.arg("view")?, + c.arg("key")?, + ) + .await + } + }); + let h = host.clone(); + d.add("plugin_service_log", move |c| { + let h = h.clone(); + async move { + super::views::service_log( + &c.host, + alive(&h)?, + &c.window, + c.arg("pluginId")?, + c.arg("view")?, + c.arg("key")?, + c.arg("tab")?, + c.arg("on")?, + c.arg("request")?, + c.sender("onLine")?, + ) + .await + } + }); + let h = host.clone(); d.add("plugin_navigation_files", move |c| { let h = h.clone(); async move { super::index_ops::plugin_navigation_files(&c.host, alive(&h)?, &c.window).await } diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index ffd3bf262..5090998a8 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -9,6 +9,14 @@ pub use ide_plugin::{ use serde::{Deserialize, Serialize}; use specta::Type; +/// A line a view's `process` body printed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginOutputLine { + pub stderr: bool, + pub text: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum PluginSource { diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 0fa1dd92f..b5bd0047e 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -26,7 +26,7 @@ //! id = "jobs" //! title = "Jobs" //! icon = "server" -//! location = "right" # left, right, bottom +//! location = "right" # left, right, bottom, services (nodes of the Services tool window) //! //! [[contributes.customEditors]] //! id = "table" @@ -55,6 +55,9 @@ use serde_json::Value; use std::collections::BTreeSet; use std::path::PathBuf; +/// A view whose tree shows in the Services tool window; the plugin's +/// `service-provider` renders the selected node's pane. +pub const SERVICES_LOCATION: &str = "services"; pub const MENUS: &str = "menus"; pub const KEYMAPS: &str = "keymaps"; pub const STATUS_BAR: &str = "statusBar"; @@ -395,8 +398,14 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { validate_item_id(&d.id, "view id")?; title(VIEWS, &d.title)?; let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?; - if !["left", "right", "bottom"].contains(&d.location.as_str()) { - return Err(format!("view {:?}: `location` is left, right or bottom", d.id)); + if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) { + return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id)); + } + if d.location == SERVICES_LOCATION { + pkg.manifest.needs_api(&format!("view {:?}: `location = \"services\"`", d.id), 3)?; + if d.web.is_some() { + return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id)); + } } if d.icon.as_ref().is_some_and(|i| i.len() > 64) { return Err(format!("view {:?}: icon names are up to 64 bytes", d.id)); diff --git a/crates/ide-host/src/plugins/views.rs b/crates/ide-host/src/plugins/views.rs index f12e31572..bef19f922 100644 --- a/crates/ide-host/src/plugins/views.rs +++ b/crates/ide-host/src/plugins/views.rs @@ -1,10 +1,11 @@ //! Declarative views and custom editors as the -//! window commands `plugin_view_render` / `plugin_view_event` serve them — +//! window commands `plugin_view_render` / `plugin_view_event` (and, for services views, +//! `plugin_service_detail` / `plugin_service_log`) serve them — //! the desktop for local projects, `openidea-server` for remote ones. -use super::{PluginEditor, PluginHost}; +use super::{PluginEditor, PluginHost, PluginOutputLine}; use ide_core::{DocId, IdeError, IdeResult}; -use ide_plugin::{EditorContext, Invocation, View, ViewEvent, Viewport}; +use ide_plugin::{EditorContext, Invocation, ServiceTab, View, ViewEvent, Viewport}; use std::sync::Arc; /// The window's project, plus the editor the call is about: the document a @@ -67,6 +68,47 @@ pub async fn event( blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await } +/// The Services pane of node `key` of a plugin's services view. +pub async fn service_detail( + host: &crate::Host, + plugins: Arc, + window: &str, + plugin_id: String, + view: String, + key: String, +) -> IdeResult> { + let invocation = view_invocation(host, window, None, None)?; + blocking(move || plugins.service_detail(&plugin_id, &view, &key, invocation)).await +} + +/// Runs the program of the log tab `tab` of node `key`'s pane in the window's +/// project root, streaming its lines until it exits or request `request` is +/// cancelled; returns its exit code. +#[allow(clippy::too_many_arguments)] +pub async fn service_log( + host: &crate::Host, + plugins: Arc, + window: &str, + plugin_id: String, + view: String, + key: String, + tab: String, + on: Vec, + request: ide_core::RequestId, + on_line: crate::ops::Channel, +) -> IdeResult> { + let invocation = view_invocation(host, window, None, None)?; + let cancel = host.begin_project_read_request(window, request); + let result = blocking(move || { + plugins.stream_service_log(&plugin_id, &view, &key, &tab, &on, invocation, &cancel, &|line| { + let _ = on_line.send(line); + }) + }) + .await; + host.end_request(request); + result +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 7ce801f62..a6cf5b29c 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -2811,6 +2811,188 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() { assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint"))); } +/// `hello` as a services view, which needs API 1.3. +fn services_variant(text: &str) -> String { + text.replace("location = \"right\"", "location = \"services\"") + .replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"") +} + +#[test] +fn services_views_need_api_1_3_and_code() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let info = f.install(&hello_variant(tmp.path(), services_variant)); + assert!(info.error.is_none(), "{:?}", info.error); + assert_eq!(f.host.ui_items("views").remove(0).decl["location"].as_str(), Some("services")); + + for (edit, message) in [ + ( + Box::new(|t: &str| t.replace("location = \"right\"", "location = \"services\"")) + as Box String>, + "new in plugin API 1.3", + ), + ( + Box::new(|t: &str| t.replace("location = \"right\"", "location = \"middle\"")), + "left, right, bottom or services", + ), + ( + Box::new(|t: &str| { + services_variant(t) + .replace("location = \"services\"", "location = \"services\"\nweb = \"web/index.html\"") + }), + "a services view is rendered by the plugin's code (no `web`)", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), edit); + std::fs::create_dir_all(pkg.join("web")).unwrap(); + std::fs::write(pkg.join("web/index.html"), "

x

").unwrap(); + let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err(); + assert!(err.message.contains(message), "{message}: {err:?}"); + } +} + +#[test] +fn services_views_have_a_detail_pane() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + f.install(&hello_variant(tmp.path(), services_variant)); + let tabs = f.host.service_detail("hello", "greetings", "pod:default/nginx", f.inv()).unwrap(); + assert_eq!(tabs.iter().map(|t| t.id.as_str()).collect::>(), ["log", "info"]); + assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: pod:default/nginx".into())); + let ide_plugin::ServicePane::Log(log) = &tabs[0].body else { panic!("a log: {:?}", tabs[0].body) }; + assert_eq!( + (log.program.as_str(), log.args.as_slice()), + ("sh", ["-c".to_owned(), "pod:default/nginx".to_owned()].as_slice()) + ); + + let f = fixture(Limits::default()); + f.install(&sample("hello")); + let err = f.host.service_detail("hello", "greetings", "x", f.inv()).unwrap_err(); + assert!(err.message.contains("declares no services view"), "a right view has no pane: {err:?}"); +} + +/// `hello` as a services view that may run `sh`, granted or not. +#[cfg(unix)] +fn services_with_sh(f: &Fixture, dir: &Path, grant: bool) { + let sh = PathBuf::from("/bin"); + f.host.set_program_path(Arc::new(move || Some(sh.clone().into_os_string()))); + let pkg = hello_variant(dir, |t| { + services_variant(t).replace( + "required = [\"editor.read\", \"editor.write\"]", + "required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]", + ) + }); + f.install(&pkg); + f.host.set_capability("hello", "process:sh", grant).unwrap(); +} + +/// Follows the Log tab of the node whose key is `script`. +#[cfg(unix)] +fn stream( + f: &Fixture, + script: &str, + on: &[&str], + cancel: &ide_core::CancellationToken, + lines: impl Fn(PluginOutputLine), +) -> ide_core::IdeResult> { + let on: Vec = on.iter().map(|s| (*s).to_owned()).collect(); + f.host.stream_service_log("hello", "greetings", script, "log", &on, f.inv(), cancel, &lines) +} + +#[cfg(unix)] +#[test] +fn service_logs_stream_lines_with_the_toggles_args() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + services_with_sh(&f, tmp.path(), true); + let lines = Mutex::new(Vec::new()); + let script = "printf 'a\\nb\\n'; printf 'e\\n' >&2; exit 3"; + let status = stream(&f, script, &[], &Default::default(), |l| lines.lock().push(l)); + assert_eq!(status.unwrap(), Some(3)); + let mut lines = lines.into_inner(); + lines.sort_by_key(|l| l.stderr); + let line = |stderr: bool, text: &str| PluginOutputLine { stderr, text: text.into() }; + assert_eq!(lines, [line(false, "a"), line(false, "b"), line(true, "e")]); + + // `sh -c script arg` names the script `arg` ($0): the toggle's args are appended. + let named = Mutex::new(Vec::new()); + stream(&f, "echo $0", &["named"], &Default::default(), |l| named.lock().push(l.text)).unwrap(); + assert_eq!(named.into_inner(), ["named"]); + + let long = Mutex::new(Vec::new()); + stream(&f, "head -c 40000 /dev/zero | tr '\\0' x; echo; echo next", &[], &Default::default(), |l| { + long.lock().push(l.text.len()) + }) + .unwrap(); + assert_eq!(long.into_inner(), [16 << 10, 4], "a long line is cut, the next one is whole"); +} + +#[cfg(unix)] +#[test] +fn service_logs_need_the_grant_and_a_log_tab() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + services_with_sh(&f, tmp.path(), false); + let marker = tmp.path().join("ran"); + let script = format!("touch {}", marker.display()); + let err = stream(&f, &script, &[], &Default::default(), |_| {}).unwrap_err(); + assert_eq!(err.kind, ErrorKind::PermissionDenied); + assert!(err.message.contains("permission denied: process:sh is not granted"), "{err:?}"); + assert!(!marker.exists(), "nothing was started"); + + f.host.set_capability("hello", "process:sh", true).unwrap(); + let info = + f.host.stream_service_log("hello", "greetings", &script, "info", &[], f.inv(), &Default::default(), &|_| {}); + assert_eq!(info.unwrap_err().kind, ErrorKind::NotFound, "a text tab runs nothing"); + let cancelled = ide_core::CancellationToken::default(); + cancelled.cancel(); + assert_eq!(stream(&f, &script, &[], &cancelled, |_| {}).unwrap_err().kind, ErrorKind::Cancelled); + assert!(!marker.exists(), "a cancelled request starts nothing"); +} + +#[cfg(unix)] +#[test] +fn service_log_cancel_kills_the_child() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + services_with_sh(&f, tmp.path(), true); + let cancel = ide_core::CancellationToken::default(); + let started = Instant::now(); + let result = stream(&f, "echo up; sleep 30.4321", &[], &cancel, |l| { + if l.text == "up" { + cancel.cancel(); + } + }); + assert!(started.elapsed() < Duration::from_secs(2), "{:?}", started.elapsed()); + assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled); + let orphan = || std::process::Command::new("pgrep").args(["-f", "sleep 30.4321"]).status().unwrap().success(); + assert!(eventually(|| !orphan()), "what the program started is killed too"); +} + +#[cfg(unix)] +#[test] +fn service_log_stops_when_the_grant_is_revoked() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + services_with_sh(&f, tmp.path(), true); + let started = Instant::now(); + let watchdog = ide_core::CancellationToken::default(); + let timer = watchdog.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(3)); + timer.cancel(); + }); + let result = stream(&f, "while :; do echo tick; sleep 0.05; done", &[], &watchdog, |l| { + if l.text == "tick" && started.elapsed() < Duration::from_secs(1) { + f.host.set_capability("hello", "process:sh", false).unwrap(); + } + }); + assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed()); + let err = result.unwrap_err(); + assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}"); +} + #[test] fn plugins_set_their_own_context_keys() { let f = fixture(Limits::default()); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index e418109cb..c66014520 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -11,10 +11,12 @@ use crate::types::{ RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent, }; use crate::types::{ - FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, + FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent, + ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, }; use crate::wit_host::{ - bindings, indexing_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings, + bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings, syntax_bindings, + views_bindings, }; use ide_core::CancellationToken; use ide_core::ipc_path::to_ipc; @@ -34,6 +36,8 @@ use bindings::openidea::plugin::types as wit_types; use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index; use providers_bindings::exports::openidea::plugin::providers as wit_prov; use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem; +use services_bindings::exports::openidea::plugin::service_provider as wit_svc; +use services_bindings::openidea::plugin::service_types as wit_st; use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax; use views_bindings::exports::openidea::plugin::view_provider as wit_view; use views_bindings::openidea::plugin::view_types as wit_vt; @@ -519,6 +523,7 @@ struct Exports { indexer: Option, views: Option, semantic: Option, + services: Option, } /// Most results one syntax call may return (more are dropped). @@ -599,6 +604,7 @@ impl Runtime { let provider_indices = wit_prov::GuestIndices::new(&pre).ok(); let view_indices = wit_view::GuestIndices::new(&pre).ok(); let semantic_indices = wit_sem::GuestIndices::new(&pre).ok(); + let service_indices = wit_svc::GuestIndices::new(&pre).ok(); let instance = pre.instantiate(&mut store).map_err(api_hint)?; let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?; let syntax = match syntax_indices { @@ -621,9 +627,13 @@ impl Runtime { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, }; + let services = match service_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; Ok(Self { store, - exports: Exports { plugin, syntax, providers, views, indexer, semantic }, + exports: Exports { plugin, syntax, providers, views, indexer, semantic, services }, limits: setup.limits, version: setup.version, }) @@ -946,6 +956,29 @@ impl Runtime { } } + /// The Services pane of node `key` of the plugin's services view `view`. + pub fn service_detail( + &mut self, + view: &str, + key: &str, + invocation: Invocation, + ) -> Result, CallError> { + if self.exports.services.is_none() { + return Err(CallError::missing_export("service-provider")); + } + let result = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let services = e.services.as_ref().expect("checked"); + let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep())); + store.data_mut().drop_invocation(ctx); + r + })?; + match result { + Ok(tabs) => Ok(tabs.into_iter().take(MAX_SERVICE_TABS).map(service_tab_from_wit).collect()), + Err(e) => Err(CallError::Guest(e.into())), + } + } + /// A user action in a view or custom editor. pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> { if self.exports.views.is_none() { @@ -1143,6 +1176,40 @@ fn opt_clip(s: Option, max: usize) -> Option { s.map(|s| clip(s, max)) } +const MAX_SERVICE_TABS: usize = 8; +const MAX_LOG_TOGGLES: usize = 8; +const MAX_LOG_ARGS: usize = 256; +const MAX_LOG_ARG: usize = 4096; + +fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab { + let body = match t.body { + wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)), + wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)), + wit_st::Pane::Log(log) => { + let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG); + if args_fit(&log.args) + && log.toggles.len() <= MAX_LOG_TOGGLES + && log.toggles.iter().all(|t| args_fit(&t.args)) + { + ServicePane::Log(ServiceLog { + program: clip(log.program, 256), + args: log.args, + toggles: log + .toggles + .into_iter() + .map(|t| ServiceLogToggle { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), args: t.args }) + .collect(), + }) + } else { + ServicePane::Message(format!( + "This log asks for more than {MAX_LOG_ARGS} arguments of {MAX_LOG_ARG} bytes or {MAX_LOG_TOGGLES} toggles." + )) + } + } + }; + ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body } +} + fn view_from_wit(v: wit_vt::View) -> View { let body = match v.body { wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)), diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index e41eb314d..cd101ab59 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -505,6 +505,42 @@ pub struct View { pub status: Option, } +/// A tab of the Services pane of a plugin's node (`service-provider.detail`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ServiceTab { + pub id: String, + pub label: String, + pub body: ServicePane, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", content = "value", rename_all = "camelCase")] +pub enum ServicePane { + Message(String), + /// Read-only text that can be selected and copied. + Text(String), + /// A program the IDE runs (with the plugin's `process:` grant), followed in a log. + Log(ServiceLog), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ServiceLog { + pub program: String, + pub args: Vec, + pub toggles: Vec, +} + +/// A switch above the log; when on, `args` are appended and the program restarts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ServiceLogToggle { + pub id: String, + pub label: String, + pub args: Vec, +} + /// The table rows the UI shows. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 4414a996a..f2326035a 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -815,6 +815,19 @@ pub(crate) mod indexing_bindings { }); } +/// The optional `service-provider` export. +pub(crate) mod services_bindings { + wasmtime::component::bindgen!({ + world: "services", path: "../../wit", + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, + "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + }, + }); +} + pub(crate) mod semantic_bindings { wasmtime::component::bindgen!({ world: "semantic-tokens", path: "../../wit", diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index b10eabd0e..8baad7a88 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 0dd381428..aaa350d53 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -31c58d5da122b9ec92bc18c2ea0aa5a153075125f9df995eb458ca854d4693ba +670aec4d79e92537efb7048698bbb8f236cd6006524c74459891b03c9b613450 diff --git a/crates/ide-plugin/tests/fixtures/hello/src/lib.rs b/crates/ide-plugin/tests/fixtures/hello/src/lib.rs index 414c223e0..4f1253592 100644 --- a/crates/ide-plugin/tests/fixtures/hello/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/hello/src/lib.rs @@ -1,6 +1,7 @@ //! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK. //! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md). +use openidea_plugin::services::{Pane, ProcessLog, Services, Tab}; use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin}; use std::collections::BTreeSet; @@ -96,7 +97,15 @@ impl Views for Hello { } } -register_plugin!(Hello, views); +/// As a services view (tests): a node's key is the `sh -c` script its Log tab runs. +impl Services for Hello { + fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result, Error> { + let log = ProcessLog::new("sh", &["-c", key]).toggle("named", "Named", &["named"]); + Ok(vec![Tab::new("log", "Log", log), Tab::new("info", "Info", Pane::Text(format!("{view}: {key}")))]) + } +} + +register_plugin!(Hello, views, services); /// Today's UTC date as `YYYY-MM-DD` (WASI wall clock). fn today() -> String { diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 377c739e3..473c8cfe1 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 8e761b2f9..14301c47f 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -1a8d4b16e31b4904b6a82c5d37ba71731bda3ff8a5ddb31846960fdb117f3e3b +c913c2c94fecb43d00f73f3171cc2fda54caf7a28f170424b0addb1d40ed4cae diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 62bbd1544..7c3273700 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 8b2281a54..cb880be2a 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -c3e25003bb5cae1ba412becc31b9c9e1ee542de42898c425fa24899b0572822a +911388ae62843d7b1e4c40d91307fbe507535c60680a9626cc07d724b75a9dfb diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index 1c84e19be..f7e985dc9 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index d4ee0499e..f968ef132 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -9c513786838e20dc5b7ae53dc57536ae24364b19e5bbad2126ba769e15f05aab +a3818b6ec40df8f141290537a7c024aadb02d7be1eb063ca9170799ca3271898 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index bdfd6985a..cfedbe513 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index ba02d39d2..cadc9c206 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -c1ea0651bd0127a01dd54884aad76a9c910ad0f56fd442dd7d50883da85242dd +9e4c12fdf905227128d97e92639cf336124929c37384e5f5796e27ebfc5a7b37 diff --git a/plugins/README.md b/plugins/README.md index e9bc57cc2..e0637f12b 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -137,23 +137,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK. ### Contribution points -| Point | Declaration | Code needed | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `commands` | `id`, `title`, `category?`, `when?` | to run them | -| `themes` | `path` (theme JSON), `editor-scheme?` | no | -| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | -| `languages` | `path` (`languages/.toml` format, see §6) | no | -| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | -| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | -| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | -| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no | -| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | -| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | -| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no | -| `views` | `id`, `title`, `icon?` (an icon name, or an SVG of the package), `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` | -| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` | -| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no | -| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no | +| Point | Declaration | Code needed | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `commands` | `id`, `title`, `category?`, `when?` | to run them | +| `themes` | `path` (theme JSON), `editor-scheme?` | no | +| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | +| `languages` | `path` (`languages/.toml` format, see §6) | no | +| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | +| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | +| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | +| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no | +| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | +| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | +| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no | +| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` | +| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` | +| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no | +| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no | A `viewItem.context` menu needs `view`, the id of one of the plugin's own views. It adds the commands to the right-click menu of the items in that @@ -543,6 +543,50 @@ Scripts get the same as `openidea.theme` (`{ dark, vars }`) and `openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only ever added. +#### Services views + +`location = "services"` (API 1.3: `engines.openidea = "^1.3"`) puts a view's +tree in the Services tool window, next to Docker and the run configurations, +instead of a tool window of its own. Its code renders it (no `web`): + +```toml +[[contributes.views]] +id = "kube" +title = "Kubernetes" +icon = "icons/kubernetes.svg" +location = "services" + +[capabilities] +optional = ["process:kubectl"] +``` + +The items of the view's `tree` are Services nodes: `render`, `handle` and the +toolbar work as in a tool window, the toolbar's buttons acting on the one +selected node, and `viewItem.context` menus give the nodes a right-click menu. +The pane beside the tree comes from the `service-provider` export: register +`register_plugin!(MyPlugin, views, services)` and implement +`openidea_plugin::services::Services`, which returns the selected node's tabs +(at most 8; asked again after `host::refresh_view("kube")`): + +```rust +impl Services for MyPlugin { + fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result, Error> { + let pod = key.trim_start_matches("pod:"); + let log = ProcessLog::new("kubectl", &["logs", "-f", "--tail=1000", pod]) + .toggle("ts", "Timestamps", &["--timestamps"]); + Ok(vec![Tab::new("log", "Log", log), Tab::new("inspect", "Inspect", Pane::Text(self.describe(pod)?))]) + } +} +``` + +The IDE switches tabs without calling the plugin. A tab is a `Pane::Message`, +read-only `Pane::Text` (at most 1 MiB) or a `ProcessLog`: the IDE asks for the +pane again and runs that program (it needs the plugin's `process:` +grant) in the project root, following its output in the log Docker containers +use (ANSI stripped, the last 5000 lines and 16 KiB per line, Soft-wrap, Clear, +the exit code at the end). A toggle appends its `args` and starts the program +again. It stops when the node's pane closes or the Services window is hidden. + #### Web views A view or custom editor can instead be a web page of the package, for UIs the diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index e4ecfe714..c91fb19f8 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index a30e64ab3..10714c313 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -cbc68107db26fa7d6fa77c8983f469536defe541d9bcd037d490030669ff999d +eb76da4bc9db84dcbad34143bac18c9ecf974276f793009791a15aab2eee291d diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index c3e78d8f9..49d583963 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index e77d5653e..346ce1ca0 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -0715a5670087df9e7f653f0efcd4087988a995c1ffa3eb165d119cf2737438c7 +0a19948927a23de81b2a453b9992e021744093cbe41ea92daa9feb2b1bc9a2a9 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 7b5cb6576..c32998eac 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 8f1729b56..129770a9f 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -b015bde71d2e116255e27190829c08bd6768e1077d0ee61321a8660d7008b229 +564bfd00694f27f35f09eb0b81d341207a755528b5fe7c46c96717b3868d0f65 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index f9e482ebb..d12bd47c9 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index d36db1702..630ba0c84 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -6c93f780430507806394a152041e20062b69bc2ae2642ec24eaffc93e189bac5 +f3f447366fa38f9bf4911f35b6e899405b0e072ceee3ad5ea2fe90639d70fa53 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 7446309f6..0806128d3 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index a32266777..5f9e986af 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -0b0b37abc7412a1517c0c1bd7d678279acc1e1afbdb6cedcded1582779a068a1 +85cad466adb94f2b58e62263617e885d1c47cf70db5e8b38942bd415f0d779af diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 0794d4c27..bba7cd927 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 22c418009..613dd1d17 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -629c5aed682a5be77a5e1a4e9a3f3483533e28679c07bfc88b21021a0dee28d8 +659446eda0b50aa0fb08f6af68f7d355fcaf3342847e2e579999dcf52a7a7399 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 6013dd64e..3906ca057 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index e84354248..aaf5f027a 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -bcfd9e933a2c5cc575fc9d2f22b8b5a151fd1f366a31235b44dcc3ef8bd3422d +51ef9d43315ca9d7239999c222fbf7a2c01bc85c2b40f53f34a1a8e5d31baa6d diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 180d7e592..5201faad7 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 33f28627f..bab71bf24 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -a69659e480b2f88b818d33b3b28ef8fbe29f7564800ac6d57e6c2e013c85a9d9 +bdf9455c6e7c8c340c54d783f351220b38cffed36907e1b5a1f2e11801e6f5bb diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 85f95e7cf..bbc75cf59 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index f6da51999..894025033 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -55f4e790142e1de70853080ced47dc80415384f7a0ec5645b5376b89f31d8354 +7e4d00e1ea63b2eca48b9297667e04c05a9f9832f1dee2262272801d516b9a16 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 048eac2c1..c04b1123a 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index b3d9a9999..953175777 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -a89ac3590b8f7474a1d503ced271d2c60e3c99bbd5c28ae9e4c04256b1a794a2 +00cbd2dd7f0937554633ba5e533f19be85f70579c7c6fedb6fe0228f8f63bf4c diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 304ffc59e..c4cc0b12e 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index cacef0ef7..094c8bb6e 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -adae51c270a7ad0dd05082f25deb0b0ff5012225b2dc7e7efda923dad3904869 +eecf2cf561dd5e3e3edbf9f67ccb479573dd538a75d4426a1d086edf0a310043 diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 996c20778..0c0a44d31 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -45,6 +45,7 @@ pub mod indexing; pub mod mock; pub mod providers; pub mod semantic_tokens; +pub mod services; pub mod syntax; pub mod text; mod types; @@ -127,6 +128,22 @@ pub mod indexing_bindings { }); } +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod services_bindings { + wit_bindgen::generate!({ + world: "services", path: "wit", pub_export_macro: true, + export_macro_name: "__export_services_world", + default_bindings_module: "openidea_plugin::services_bindings", + with: { + "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, + }, + }); +} + #[cfg(target_arch = "wasm32")] #[doc(hidden)] pub mod semantic_bindings { @@ -195,6 +212,9 @@ macro_rules! __optional_exports { (semantic_tokens, $plugin:ty, $component:ident) => { $crate::__semantic_tokens_exports!($plugin, $component); }; + (services, $plugin:ty, $component:ident) => { + $crate::__services_exports!($plugin, $component); + }; (syntax, $plugin:ty, $component:ident) => { impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for $component { fn highlights( @@ -326,6 +346,27 @@ macro_rules! __indexing_exports { }; } +/// `register_plugin!(T, services)`: `T` is also a [`services::Services`]. +#[doc(hidden)] +#[macro_export] +macro_rules! __services_exports { + ($plugin:ty, $component:ident) => { + impl $crate::services_bindings::exports::openidea::plugin::service_provider::Guest for $component { + fn detail( + view: ::std::string::String, + key: ::std::string::String, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + ) -> ::core::result::Result< + ::std::vec::Vec<$crate::services_bindings::openidea::plugin::service_types::Tab>, + $crate::bindings::openidea::plugin::types::Error, + > { + $crate::__rt::service_detail::<$plugin>(view, key, ctx) + } + } + $crate::services_bindings::__export_services_world!($component with_types_in $crate::services_bindings); + }; +} + #[doc(hidden)] #[macro_export] macro_rules! __semantic_tokens_exports { @@ -687,6 +728,34 @@ pub mod __rt { with::(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out) } + pub fn service_detail( + view: String, + key: String, + ctx: &b::invocation::Invocation, + ) -> Result, b::types::Error> { + use crate::services as s; + use crate::services_bindings::openidea::plugin::service_types as w; + let ctx = Invocation::__new(ctx); + let tab = |t: s::Tab| w::Tab { + id: t.id, + label: t.label, + body: match t.body { + s::Pane::Message(m) => w::Pane::Message(m), + s::Pane::Text(text) => w::Pane::Text(text), + s::Pane::Log(log) => w::Pane::Log(w::ProcessLog { + program: log.program, + args: log.args, + toggles: log + .toggles + .into_iter() + .map(|t| w::LogToggle { id: t.id, label: t.label, args: t.args }) + .collect(), + }), + }, + }; + with::(|p| p.detail(&view, &key, &ctx)).map(|tabs| tabs.into_iter().map(tab).collect()).map_err(error_out) + } + pub fn view_event( view: String, event: vt::ViewEvent, diff --git a/sdk/rust/openidea-plugin/src/services.rs b/sdk/rust/openidea-plugin/src/services.rs new file mode 100644 index 000000000..cc1738361 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/services.rs @@ -0,0 +1,76 @@ +//! The pane beside the Services tree for the nodes of the plugin's +//! `location = "services"` views (world `plugin-services`). The nodes, the +//! toolbar and their events come from [`crate::views::Views`] as in any view; +//! register both: `register_plugin!(MyPlugin, views, services)`. + +use crate::{Error, Invocation}; + +/// A switch above a log: when on, `args` are appended and the program starts again. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogToggle { + pub id: String, + pub label: String, + pub args: Vec, +} + +/// A program the IDE runs (with the plugin's `process:` grant, in the +/// project root), its output followed in a log until it exits or the pane closes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProcessLog { + pub program: String, + pub args: Vec, + pub toggles: Vec, +} + +impl ProcessLog { + pub fn new(program: &str, args: &[&str]) -> Self { + Self { program: program.to_owned(), args: strings(args), toggles: Vec::new() } + } + + /// Adds a switch above the log that appends `args` when on. + pub fn toggle(mut self, id: &str, label: &str, args: &[&str]) -> Self { + self.toggles.push(LogToggle { id: id.to_owned(), label: label.to_owned(), args: strings(args) }); + self + } +} + +/// The body of a [`Tab`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Pane { + /// A centred message: an empty state or why there is nothing to show. + Message(String), + /// Read-only text the user can select and copy (at most 1 MiB). + Text(String), + Log(ProcessLog), +} + +impl From for Pane { + fn from(log: ProcessLog) -> Self { + Self::Log(log) + } +} + +/// A tab of a node's pane; the IDE switches tabs without calling the plugin. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tab { + pub id: String, + pub label: String, + pub body: Pane, +} + +impl Tab { + pub fn new(id: &str, label: &str, body: impl Into) -> Self { + Self { id: id.to_owned(), label: label.to_owned(), body: body.into() } + } +} + +/// The detail pane of the nodes of the plugin's services views. +pub trait Services { + /// The tabs of node `key` of view `view` (at most 8), asked when the node is + /// selected and again after [`crate::host::refresh_view`] of the view. + fn detail(&mut self, view: &str, key: &str, ctx: &Invocation) -> Result, Error>; +} + +fn strings(args: &[&str]) -> Vec { + args.iter().map(|a| (*a).to_owned()).collect() +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 14e74f548..559abf97e 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -61,6 +61,13 @@ world plugin-semantic-tokens { export semantic-tokens-provider; } +/// A plugin whose views show in the Services tool window +/// (`location = "services"`): their nodes' detail pane. +world plugin-services { + include plugin; + export service-provider; +} + /// Only the `syntax-provider` export: guests generate it next to `plugin` /// (the component tools merge both into one component). world syntax { @@ -85,3 +92,8 @@ world indexing { world semantic-tokens { export semantic-tokens-provider; } + +/// Only the `service-provider` export, generated next to `plugin` like `syntax`. +world services { + export service-provider; +} diff --git a/sdk/rust/openidea-plugin/wit/services.wit b/sdk/rust/openidea-plugin/wit/services.wit new file mode 100644 index 000000000..17d7eafa5 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/services.wit @@ -0,0 +1,50 @@ +package openidea:plugin@1.3.0; + +/// What the pane beside the Services tree shows for a plugin's node. +interface service-types { + /// A switch above a log; when on, `args` are appended and the program + /// starts again. + record log-toggle { + id: string, + label: string, + args: list, + } + + /// A program the IDE runs, its output followed in a log (ANSI stripped, + /// the last 5000 lines) until it exits or the pane closes. Needs the + /// plugin's `process:` grant; runs in the project root. + record process-log { + program: string, + args: list, + toggles: list, + } + + /// The body of a tab. + variant pane { + /// A centred message: an empty state or why there is nothing to show. + message(string), + /// Read-only text the user can select and copy, at most 1 MiB. + text(string), + log(process-log), + } + + /// A tab of a node's pane; the IDE switches tabs without calling the plugin. + record tab { + id: string, + label: string, + body: pane, + } +} + +/// The detail pane of the nodes of a plugin's `location = "services"` views. +/// The nodes themselves, the toolbar and their events come from +/// `view-provider` (`render` / `handle` of the view). +interface service-provider { + use types.{error}; + use invocation.{invocation}; + use service-types.{tab}; + + /// The tabs of node `key` of view `view` (at most 8), rendered when the + /// node is selected and again after `views.refresh(view)`. + detail: func(view: string, key: string, ctx: borrow) -> result, error>; +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fa9fa7c81..447cd5052 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -489,6 +489,8 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_answer_prompt, plugin::plugin_view_render, plugin::plugin_view_event, + plugin::plugin_service_detail, + plugin::plugin_service_log, plugin::plugin_web_page, plugin::plugin_web_save_file, plugin::plugin_web_read_file, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index d82e1a0b9..33598f630 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -437,6 +437,54 @@ pub async fn plugin_view_event( ide_host::plugins::views::event(&state.host, plugins, window.label(), plugin_id, view, event, doc, editor).await } +/// The Services pane of node `key` of a plugin's `location = "services"` view. +#[tauri::command] +#[specta::specta] +pub async fn plugin_service_detail( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + view: String, + key: String, +) -> IdeResult> { + let plugins = state.plugins.clone(); + ide_host::plugins::views::service_detail(&state.host, plugins, window.label(), plugin_id, view, key).await +} + +/// Runs the program of the log tab `tab` of node `key`'s Services pane (with +/// the toggles in `on`), streaming its output until it exits or `request` is +/// cancelled; returns the exit code. +#[tauri::command] +#[specta::specta] +#[allow(clippy::too_many_arguments)] +pub async fn plugin_service_log( + window: Window, + state: State<'_, AppState>, + request: ide_core::RequestId, + plugin_id: String, + view: String, + key: String, + tab: String, + on: Vec, + on_line: Channel, +) -> IdeResult> { + let plugins = state.plugins.clone(); + let on_line = super::sender(on_line); + ide_host::plugins::views::service_log( + &state.host, + plugins, + window.label(), + plugin_id, + view, + key, + tab, + on, + request, + on_line, + ) + .await +} + /// The page of a plugin's web view or custom editor (`web = …`). #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 1b0245596..6e0904ec4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1090,6 +1090,14 @@ export const commands = { language: string, selections: Range[], } | null) => typedError(__TAURI_INVOKE("plugin_view_event", { pluginId, view, event, doc, editor })), + /** The Services pane of node `key` of a plugin's `location = "services"` view. */ + pluginServiceDetail: (pluginId: string, view: string, key: string) => typedError(__TAURI_INVOKE("plugin_service_detail", { pluginId, view, key })), + /** + * Runs the program of the log tab `tab` of node `key`'s Services pane (with + * the toggles in `on`), streaming its output until it exits or `request` is + * cancelled; returns the exit code. + */ + pluginServiceLog: (request: RequestId, pluginId: string, view: string, key: string, tab: string, on: string[], onLine: Channel) => typedError(__TAURI_INVOKE("plugin_service_log", { request, pluginId, view, key, tab, on, onLine })), /** The page of a plugin's web view or custom editor (`web = …`). */ pluginWebPage: (pluginId: string, view: string) => typedError(__TAURI_INVOKE("plugin_web_page", { pluginId, view })), /** @@ -5870,6 +5878,12 @@ export type PluginInfo = { previousVersion: string | null, }; +/** A line a view's `process` body printed. */ +export type PluginOutputLine = { + stderr: boolean, + text: string, +}; + /** A secret a plugin declares, and whether the user stored it. */ export type PluginSecretState = { name: string, @@ -6956,6 +6970,32 @@ export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped" */ "misconfigured"; +export type ServiceLog = { + program: string, + args: string[], + toggles: ServiceLogToggle[], +}; + +/** A switch above the log; when on, `args` are appended and the program restarts. */ +export type ServiceLogToggle = { + id: string, + label: string, + args: string[], +}; + +export type ServicePane = { kind: "message"; value: string } | +/** Read-only text that can be selected and copied. */ +{ kind: "text"; value: string } | +/** A program the IDE runs (with the plugin's `process:` grant), followed in a log. */ +{ kind: "log"; value: ServiceLog }; + +/** A tab of the Services pane of a plugin's node (`service-provider.detail`). */ +export type ServiceTab = { + id: string, + label: string, + body: ServicePane, +}; + /** Exception filters of one session (see [`DebugManager::exception_filters`]). */ export type SessionExceptionFilters = { session: DebugSessionId, diff --git a/src/features/plugins/__tests__/servicePane.test.tsx b/src/features/plugins/__tests__/servicePane.test.tsx new file mode 100644 index 000000000..e6c719de0 --- /dev/null +++ b/src/features/plugins/__tests__/servicePane.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ServiceTab } from "@/bindings"; +import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend"; +import { ServicePane } from "../views/ServicePane"; +import { bumpView } from "../views/viewStore"; + +afterEach(() => setPluginsBackend(null)); + +const log = { + program: "kubectl", + args: ["logs", "x"], + toggles: [{ id: "ts", label: "Timestamps", args: ["--timestamps"] }], +}; +const tabs: ServiceTab[] = [ + { id: "log", label: "Log", body: { kind: "log", value: log } }, + { id: "inspect", label: "Inspect", body: { kind: "text", value: "a: 1" } }, +]; + +function backend(detail: () => ServiceTab[] = () => tabs) { + const stops: ReturnType[] = []; + const b = { + ...createMemoryPluginsBackend([]), + serviceDetail: vi.fn(async () => detail()), + serviceLog: vi.fn(() => { + const stop = vi.fn(); + stops.push(stop); + return stop; + }), + }; + setPluginsBackend(b); + return { b, stops }; +} + +describe("ServicePane", () => { + it("switches tabs without calling the plugin", async () => { + const { b } = backend(); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole("tab", { name: "Inspect" })); + expect(screen.getByRole("document").textContent).toBe("a: 1"); + expect(b.serviceDetail).toHaveBeenCalledTimes(1); + }); + + it("follows a log tab by node and tab, and restarts it on a toggle", async () => { + const { b, stops } = backend(); + const user = userEvent.setup(); + const { unmount } = render(); + await waitFor(() => expect(b.serviceLog).toHaveBeenCalledTimes(1)); + expect(b.serviceLog.mock.calls[0]?.slice(0, 5)).toEqual(["kube", "pods", "pod:a", "log", new Set()]); + await user.click(screen.getByRole("switch", { name: "Timestamps" })); + expect(b.serviceLog).toHaveBeenCalledTimes(2); + expect(b.serviceLog.mock.calls[1]?.[4]).toEqual(new Set(["ts"])); + expect(stops.at(0)).toHaveBeenCalled(); + unmount(); + expect(stops.at(1)).toHaveBeenCalled(); + }); + + it("asks again on a refresh and restarts a log whose program changed", async () => { + let args = ["logs", "x"]; + const { b, stops } = backend(() => [{ id: "log", label: "Log", body: { kind: "log", value: { ...log, args } } }]); + render(); + await waitFor(() => expect(b.serviceLog).toHaveBeenCalledTimes(1)); + args = ["logs", "y"]; + bumpView("kube", "pods"); + await waitFor(() => expect(b.serviceLog).toHaveBeenCalledTimes(2)); + expect(b.serviceDetail).toHaveBeenCalledTimes(2); + expect(stops.at(0)).toHaveBeenCalled(); + }); + + it("shows the plugin's error", async () => { + const b = { + ...createMemoryPluginsBackend([]), + serviceDetail: vi.fn(async () => Promise.reject(new Error("no cluster"))), + }; + setPluginsBackend(b); + render(); + expect(await screen.findByText("no cluster")).toBeTruthy(); + }); +}); diff --git a/src/features/plugins/__tests__/serviceProvider.test.tsx b/src/features/plugins/__tests__/serviceProvider.test.tsx new file mode 100644 index 000000000..2780c4b83 --- /dev/null +++ b/src/features/plugins/__tests__/serviceProvider.test.tsx @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, renderHook, waitFor } from "@testing-library/react"; +import type { View, ViewEvent, ViewTreeItem } from "@/bindings"; +import { serviceProviders, type ServiceTreeItem } from "@/workbench/contributions"; +import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend"; +import { defineBuiltinUiPoints } from "../points"; +import { clearUiContributions, syncUiContributions } from "../projection"; +import { pluginServiceProvider } from "../views/serviceProvider"; + +let undefinePoints: () => void = () => {}; + +beforeEach(() => { + undefinePoints = defineBuiltinUiPoints(); +}); + +afterEach(() => { + clearUiContributions(); + undefinePoints(); + setPluginsBackend(null); +}); + +const item = (key: string, label: string, parent: string | null = null): ViewTreeItem => ({ + key, + parent, + label, + icon: "folder", + detail: null, + expandable: false, + expanded: false, +}); + +const pods: View = { + toolbar: [{ id: "delete", label: "Delete", icon: "trash", enabled: true }], + status: null, + body: { + kind: "tree", + value: [item("ctx", "minikube"), item("ns", "default", "ctx"), item("pod:default/nginx", "nginx", "ns")], + }, +}; + +function backend(view: View = pods) { + const events: [string, string, ViewEvent][] = []; + const b: PluginsBackend = { + ...createMemoryPluginsBackend([]), + renderView: vi.fn(async () => view), + viewEvent: vi.fn(async (pluginId: string, v: string, e: ViewEvent) => void events.push([pluginId, v, e])), + }; + setPluginsBackend(b); + return { b, events }; +} + +const decl = { id: "kube", title: "Kubernetes", location: "services" as const }; + +const noMenu = () => []; + +async function roots(pluginId: string) { + const provider = pluginServiceProvider(pluginId, decl, "plugin", noMenu); + const { result } = renderHook(() => provider.useRoots()); + await waitFor(() => expect(result.current).not.toHaveLength(0)); + return { provider, roots: result.current }; +} + +const keys = (items: ServiceTreeItem[]): unknown => + items.map((i) => (i.children ? { [i.key]: keys(i.children) } : i.key)); + +describe("plugin service providers", () => { + it("maps the plugin tree to service nodes", async () => { + backend(); + const { roots: nodes } = await roots("p"); + expect(keys(nodes)).toEqual([ + { "plugin:p/kube/ctx": [{ "plugin:p/kube/ns": ["plugin:p/kube/pod:default/nginx"] }] }, + ]); + expect(nodes[0]?.label).toBe("minikube"); + }); + + it("routes events with the raw key", async () => { + const { events } = backend(); + const { provider, roots: nodes } = await roots("p"); + const nginx = nodes[0]!.children![0]!.children![0]!; + provider.select?.(nginx); + await waitFor(() => expect(events).toContainEqual(["p", "kube", { kind: "select", key: "pod:default/nginx" }])); + provider.activate?.(nginx); + await waitFor(() => expect(events).toContainEqual(["p", "kube", { kind: "activate", key: "pod:default/nginx" }])); + const actions = provider.actions?.(nginx) ?? []; + expect(actions[0]?.id).not.toBe(provider.actions?.(nodes[0]!)[0]?.id); + actions[0]?.run(); + await waitFor(() => expect(events).toContainEqual(["p", "kube", { kind: "button", id: "delete" }])); + const select = events.findIndex(([, , e]) => e.kind === "select" && e.key === "pod:default/nginx"); + const button = events.findIndex(([, , e]) => e.kind === "button"); + expect(select).toBeGreaterThanOrEqual(0); + expect(select).toBeLessThan(button); + }); + + it("keeps two plugins' nodes apart when they use the same keys", async () => { + backend(); + const a = await roots("a"); + const b = await roots("b"); + expect(a.roots[0]?.key).not.toBe(b.roots[0]?.key); + }); + + it("asks the plugin's service provider for the selected node's pane", async () => { + const { b } = backend(); + const serviceDetail = vi.fn(async () => [ + { id: "info", label: "Info", body: { kind: "text" as const, value: "Name: nginx" } }, + ]); + setPluginsBackend({ ...b, serviceDetail }); + const provider = pluginServiceProvider("p", decl, "plugin", noMenu); + const Detail = provider.detail!; + const node: ServiceTreeItem = { key: "plugin:p/kube/pod:default/nginx", label: "nginx", icon: "folder" }; + render(); + await waitFor(() => expect(serviceDetail).toHaveBeenCalledWith("p", "kube", "pod:default/nginx")); + }); + + it("offers the view's item menu on its nodes", async () => { + backend(); + const itemMenu = vi.fn((key: string) => [{ label: key, run: () => {} }]); + const provider = pluginServiceProvider("p", decl, "plugin", itemMenu); + const node: ServiceTreeItem = { key: "plugin:p/kube/pod:default/nginx", label: "nginx", icon: "folder" }; + expect(provider.menu?.(node)).toEqual([{ label: "pod:default/nginx", run: expect.any(Function) }]); + }); + + it("registers services views as providers and drops them with the plugin", () => { + backend(); + const plugin = { id: "p", name: "P", status: "active", enabled: true } as unknown as Parameters< + typeof syncUiContributions + >[1][number]; + syncUiContributions([{ pluginId: "p", point: "views", key: "kube", decl }], [plugin]); + expect(serviceProviders().map((p) => p.id)).toContain("plugin.p.kube"); + syncUiContributions([], [plugin]); + expect(serviceProviders().map((p) => p.id)).not.toContain("plugin.p.kube"); + }); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 62ceef6e6..a0cbbc43a 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -18,9 +18,11 @@ import type { PluginEditor, PluginEvent, PluginInfo, + PluginOutputLine, PluginsSnapshot, PluginSecretState, PluginStatusItem, + ServiceTab, UiContribution, View, ViewEvent, @@ -28,7 +30,7 @@ import type { WorkbenchEvent, } from "@/bindings"; import { tx } from "@/platform/i18n"; -import { call, commands, IpcError, isTauri } from "@/platform/ipc"; +import { call, commands, IpcError, isCancelled, isTauri, nextRequestId } from "@/platform/ipc"; import { editorContext } from "./editorContext"; export interface PluginsBackend { @@ -89,6 +91,22 @@ export interface PluginsBackend { /** Content of a plugin view (`doc`: the document of a custom editor). */ renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise; viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise; + /** The Services pane of node `key` of a plugin's services view. */ + serviceDetail(pluginId: string, view: string, key: string): Promise; + /** + * Runs the program of node `key`'s log tab `tab` (with the toggles in `on`) and streams its + * lines until it exits (`onExit`) or the returned stop function is called. + */ + serviceLog( + pluginId: string, + view: string, + key: string, + tab: string, + on: ReadonlySet, + onLine: (line: PluginOutputLine) => void, + onError: (message: string) => void, + onExit: (code: number | null) => void, + ): () => void; /** The URL of a web view's page (`web = …`), under the `openidea-plugin` scheme. */ webPageUrl(pluginId: string, view: string): Promise; /** Save dialog for a web view's `openidea.saveFile`; the path written, or `null` when cancelled. */ @@ -204,6 +222,16 @@ export const tauriPluginsBackend: PluginsBackend = { viewEvent: async (pluginId, view, event, doc) => { await call(commands.pluginViewEvent(pluginId, view, event, doc, doc == null ? editorContext() : null)); }, + serviceDetail: (pluginId, view, key) => call(commands.pluginServiceDetail(pluginId, view, key)), + serviceLog: (pluginId, view, key, tab, on, onLine, onError, onExit) => { + const request = nextRequestId(); + const channel = new Channel(); + channel.onmessage = onLine; + call(commands.pluginServiceLog(request, pluginId, view, key, tab, [...on], channel)).then(onExit, (e: unknown) => { + if (!isCancelled(e)) onError(e instanceof Error ? e.message : String(e)); + }); + return () => void commands.cancelRequest(request); + }, webPageUrl: async (pluginId, view) => { const page = await call(commands.pluginWebPage(pluginId, view)); return webSchemeUrl(page.path); @@ -540,6 +568,11 @@ export function createMemoryPluginsBackend( status: null, }), viewEvent: async () => {}, + serviceDetail: async () => [], + serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => { + onError(`${pluginId} · ${view} · ${key}: logs need the desktop app`); + return () => {}; + }, webPageUrl: async (pluginId, view) => { throw new IpcError({ kind: "unavailable", message: `${pluginId} · ${view}: web views need the desktop app` }); }, diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index 4a96c2ee0..a3eaa494e 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -34,6 +34,7 @@ import { registerFileEditorProvider, registerMenuGroup, registerMenuSlotItem, + registerServiceProvider, registerSettingsPage, registerTextEditorWrapper, registerToolWindow, @@ -58,6 +59,7 @@ import { } from "./views/customEditor"; import { layoutActions, layoutWrapper } from "./views/editorLayout"; import { PluginView } from "./views/PluginView"; +import { pluginServiceProvider } from "./views/serviceProvider"; import { WebView } from "./views/WebView"; import styles from "./PluginsPage.module.css"; @@ -242,7 +244,7 @@ interface ViewDecl { id: string; title: string; icon?: string | null; - location: ToolWindowAnchor; + location: ToolWindowAnchor | "services"; web?: string; } @@ -309,6 +311,10 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void { } function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void { + if (decl.location === "services") { + const itemMenu = (key: string) => pluginViewItemMenuItems(pluginId, decl.id, key); + return registerServiceProvider(pluginServiceProvider(pluginId, decl, icon, itemMenu)); + } return registerToolWindow({ id: pluginToolWindowId(pluginId, decl.id), title: decl.title, diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index 3cebc9aee..bd006dc73 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -199,17 +199,17 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps // --- tree ------------------------------------------------------------------------- -interface Node { +export interface TreeNode { item: ViewTreeItem; - children: Node[]; + children: TreeNode[]; } /** The flat item list as nodes (items naming an unknown parent are roots). */ -export function treeNodes(items: readonly ViewTreeItem[]): Node[] { - const byKey = new Map(); - const roots: Node[] = []; +export function treeNodes(items: readonly ViewTreeItem[]): TreeNode[] { + const byKey = new Map(); + const roots: TreeNode[] = []; for (const item of items) { - const node: Node = { item, children: [] }; + const node: TreeNode = { item, children: [] }; if (!byKey.has(item.key)) byKey.set(item.key, node); const parent = item.parent != null ? byKey.get(item.parent) : undefined; (parent ? parent.children : roots).push(node); @@ -238,7 +238,7 @@ function TreeBody({ items, send, itemMenu }: TreeBodyProps) { const roots = useMemo(() => treeNodes(items), [items]); const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]); return ( - + roots={roots} getKey={(n) => n.item.key} getLabel={(n) => n.item.label} diff --git a/src/features/plugins/views/ServicePane.module.css b/src/features/plugins/views/ServicePane.module.css new file mode 100644 index 000000000..a3532d619 --- /dev/null +++ b/src/features/plugins/views/ServicePane.module.css @@ -0,0 +1,29 @@ +.pane { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} + +.body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.text { + flex: 1; + box-sizing: border-box; + margin: 0; + padding: 6px 10px; + overflow: auto; + font-family: var(--font-mono); + font-size: var(--fs-code); + line-height: var(--lh-code); + color: var(--fg-editor); + background: var(--bg-editor); + white-space: pre; + -webkit-user-select: text; + user-select: text; +} diff --git a/src/features/plugins/views/ServicePane.tsx b/src/features/plugins/views/ServicePane.tsx new file mode 100644 index 000000000..83225ec74 --- /dev/null +++ b/src/features/plugins/views/ServicePane.tsx @@ -0,0 +1,87 @@ +/** + * The Services pane of a node of a plugin's `location = "services"` view: the + * tabs its `service-provider` returns, switched here without calling the plugin. + */ +import { useEffect, useState } from "react"; +import type { ServiceTab } from "@/bindings"; +import { EmptyState, LogPane, Spinner, Tabs } from "@/ui"; +import { copySelectionOnKeyDown } from "@/ui/selectableText"; +import { pluginsBackend } from "../backend"; +import { useViewRevision } from "./viewStore"; +import styles from "./ServicePane.module.css"; + +interface ServicePaneProps { + pluginId: string; + view: string; + itemKey: string; +} + +export function ServicePane({ pluginId, view, itemKey }: ServicePaneProps) { + // `views.refresh()` asks for the pane again. + const revision = useViewRevision(pluginId, view); + const [tabs, setTabs] = useState(null); + const [error, setError] = useState(null); + const [picked, setPicked] = useState(null); + + useEffect(() => { + let alive = true; + pluginsBackend() + .serviceDetail(pluginId, view, itemKey) + .then( + (t) => { + if (!alive) return; + setTabs(t); + setError(null); + }, + (e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)), + ); + return () => { + alive = false; + }; + }, [pluginId, view, itemKey, revision]); + + if (error) return ; + if (!tabs) return ; + const active = tabs.find((t) => t.id === picked) ?? tabs[0]; + if (!active) return ; + return ( +
+ ({ id: t.id, label: t.label }))} + /> +
+ +
+
+ ); +} + +function TabBody({ tab, pluginId, view, itemKey }: ServicePaneProps & { tab: ServiceTab }) { + const body = tab.body; + switch (body.kind) { + case "message": + return ; + case "text": + return ( +
+          {body.value}
+        
+ ); + case "log": + return ( + + pluginsBackend().serviceLog(pluginId, view, itemKey, tab.id, on, onLine, onError, onExit) + } + /> + ); + } +} diff --git a/src/features/plugins/views/serviceProvider.tsx b/src/features/plugins/views/serviceProvider.tsx new file mode 100644 index 000000000..9c301a00d --- /dev/null +++ b/src/features/plugins/views/serviceProvider.tsx @@ -0,0 +1,94 @@ +/** + * A plugin view declared with `location = "services"`: its tree's items are + * nodes of the Services tool window, its toolbar the Services toolbar for one + * selected node, `viewItem.context` its nodes' right-click menu, and the + * plugin's `service-provider` the selected node's pane. + */ +import { useEffect, useMemo, useState } from "react"; +import type { View, ViewEvent } from "@/bindings"; +import type { MenuItem } from "@/ui"; +import type { ServiceProviderContribution, ServiceTreeItem } from "@/workbench/contributions"; +import { pluginsBackend } from "../backend"; +import { treeNodes, type TreeNode } from "./PluginView"; +import { ServicePane } from "./ServicePane"; +import { useViewRevision } from "./viewStore"; + +interface ServicesViewDecl { + id: string; + title: string; +} + +export function pluginServiceProvider( + pluginId: string, + decl: ServicesViewDecl, + icon: string, + itemMenu: (key: string) => MenuItem[], +): ServiceProviderContribution { + const prefix = `plugin:${pluginId}/${decl.id}/`; + const rawKey = (item: ServiceTreeItem) => item.key.slice(prefix.length); + let toolbar: View["toolbar"] = []; + // Events of one node stay in order (a toolbar button after the select it acts on). + let queue = Promise.resolve(); + const send = (event: ViewEvent) => { + queue = queue + .then(() => pluginsBackend().viewEvent(pluginId, decl.id, event, null)) + .catch((e: unknown) => console.warn(`plugin ${pluginId}: view ${decl.id}`, e)); + }; + const toItem = (n: TreeNode): ServiceTreeItem => ({ + key: prefix + n.item.key, + label: n.item.label, + icon: n.item.icon ?? icon, + detail: n.item.detail ?? undefined, + expanded: n.item.expanded, + expandable: n.item.expandable, + children: n.children.length || n.item.expandable ? n.children.map(toItem) : undefined, + }); + + return { + id: `plugin.${pluginId}.${decl.id}`, + order: 900, + useRoots() { + const revision = useViewRevision(pluginId, decl.id); + const [view, setView] = useState(null); + useEffect(() => { + let alive = true; + pluginsBackend() + .renderView(pluginId, decl.id, null, null) + .then( + (v) => { + if (!alive) return; + toolbar = v.toolbar; + setView(v); + }, + (e: unknown) => console.warn(`plugin ${pluginId}: view ${decl.id}`, e), + ); + return () => { + alive = false; + }; + }, [revision]); + return useMemo(() => (view?.body.kind === "tree" ? treeNodes(view.body.value).map(toItem) : []), [view]); + }, + // Ids per node: the Services toolbar offers an action of several selected + // nodes only when they share its id, and a plugin's buttons act on one. + actions: (item) => + toolbar.map((b) => ({ + id: `${item.key}\0${b.id}`, + title: b.label, + icon: b.icon ?? "plugin", + disabled: !b.enabled, + run: () => { + send({ kind: "select", key: rawKey(item) }); + send({ kind: "button", id: b.id }); + }, + })), + menu: (item) => itemMenu(rawKey(item)), + select: (item) => send({ kind: "select", key: rawKey(item) }), + activate: (item) => send({ kind: "activate", key: rawKey(item) }), + expand: (item, expanded) => send({ kind: expanded ? "expand" : "collapse", key: rawKey(item) }), + detail: Detail, + }; + + function Detail({ item }: { item: ServiceTreeItem }) { + return ; + } +} diff --git a/src/features/services/Services.module.css b/src/features/services/Services.module.css index 98d0e9fae..f347c54d7 100644 --- a/src/features/services/Services.module.css +++ b/src/features/services/Services.module.css @@ -4,8 +4,7 @@ flex-direction: column; min-height: 0; } -.toolbar, -.subToolbar { +.toolbar { height: 32px; flex: none; display: flex; @@ -13,20 +12,12 @@ gap: 2px; padding: 0 6px; } -.subToolbar { - gap: 12px; - padding: 0 10px; - border-bottom: 1px solid var(--border); -} .sep { width: 1px; height: 16px; margin: 0 4px; background: var(--border); } -.flex { - flex: 1; -} .banner { flex: none; display: flex; @@ -133,10 +124,6 @@ .log > div { cursor: text; } -.wrap { - white-space: pre-wrap; - overflow-wrap: anywhere; -} .json { white-space: pre; } diff --git a/src/features/services/ServicesTree.test.tsx b/src/features/services/ServicesTree.test.tsx index efdef943b..96d74acd2 100644 --- a/src/features/services/ServicesTree.test.tsx +++ b/src/features/services/ServicesTree.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, within } from "@testing-library/react"; -import { useServiceProviders, type ServiceTreeItem } from "@/workbench/contributions"; +import { ToolWindowShownProvider, useServiceProviders, type ServiceTreeItem } from "@/workbench/contributions"; import { ServicesTreeView } from "./ServicesTree"; import { useServicesTree } from "./tree"; import { selectedServices, selectionActions, selectionMenu } from "./selection"; @@ -12,6 +12,7 @@ const roots: ServiceTreeItem[] = ["one", "two", "three"].map((key) => ({ key, la const run = vi.fn(); const stop = vi.fn(); const remove = vi.fn(); +const select = vi.fn(); beforeEach(() => { vi.clearAllMocks(); @@ -32,6 +33,7 @@ beforeEach(() => { { label: "Remove from Services", run: () => remove(item.key) }, ], detail: ({ item }) =>
{item.label}
, + select: (item) => select(item.key), }, ], ]), @@ -67,6 +69,45 @@ describe("Services selection → actions", () => { expect(screen.queryByTestId("detail")).not.toBeInTheDocument(); }); + it("tells the provider which node became the lead selection", () => { + const { row } = setup(); + fireEvent.mouseDown(row("one")); + fireEvent.mouseDown(row("two")); + expect(select.mock.calls).toEqual([["one"], ["two"]]); + }); + + it("drops the detail pane (and what it streams) while the Services window is hidden", () => { + const view = (shown: boolean) => ( + + + + ); + const { rerender } = render(view(true)); + fireEvent.mouseDown(within(screen.getByRole("tree", { name: "Services" })).getByText("one")); + expect(screen.getByTestId("detail")).toHaveTextContent("one"); + rerender(view(false)); + expect(screen.queryByTestId("detail")).not.toBeInTheDocument(); + rerender(view(true)); + expect(screen.getByTestId("detail")).toHaveTextContent("one"); + }); + + it("Expand All and Collapse All reach lazy nodes and tell their provider", () => { + const expand = vi.fn(); + const lazy: ServiceTreeItem[] = [{ key: "lazy", label: "lazy", icon: "run", expandable: true, children: [] }]; + useServiceProviders.setState({ + providers: new Map([["lazy", { id: "lazy", useRoots: () => lazy, expand: (i, open) => expand(i.key, open) }]]), + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Expand All" })); + expect(useServicesTree.getState().expanded.has("lazy")).toBe(true); + expect(expand.mock.calls).toEqual([["lazy", true]]); + fireEvent.click(screen.getByRole("button", { name: "Collapse All" })); + expect(expand.mock.calls).toEqual([ + ["lazy", true], + ["lazy", false], + ]); + }); + it("Shift click selects a range; Stop skips disabled services", () => { const { row, selected, toolbar } = setup(); fireEvent.mouseDown(row("one")); diff --git a/src/features/services/ServicesTree.tsx b/src/features/services/ServicesTree.tsx index 91bd4d4ad..b259ebf81 100644 --- a/src/features/services/ServicesTree.tsx +++ b/src/features/services/ServicesTree.tsx @@ -25,6 +25,7 @@ import { } from "@/ui"; import { useServiceProviders, + useToolWindowShown, serviceProviders, type ServiceProviderContribution, type ServiceTreeItem, @@ -134,7 +135,19 @@ export function ServicesTreeView() { ...(selected && selectedKeys.size === 1 ? moveActions(selected.item) : []), ]; + /** Expands / collapses many nodes at once, telling their providers what changed. */ + const setExpanded = (next: Set) => { + useServicesTree.setState({ expanded: next }); + for (const key of new Set([...expanded, ...next])) { + const at = index.get(key); + if (!at || expanded.has(key) === next.has(key)) continue; + providers.find((p) => p.id === at.provider)?.expand?.(at.item, next.has(key)); + } + }; + const Detail = owner?.detail; + // A hidden tool window stays mounted: drop the pane so a log it follows stops. + const shown = useToolWindowShown(); const add = shortcutLabel("services.addService"); return ( @@ -159,7 +172,7 @@ export function ServicesTreeView() { iconSize={16} title="Expand All" disabled={!roots.length} - onClick={() => useServicesTree.setState({ expanded: new Set(expandableKeys(roots)) })} + onClick={() => setExpanded(new Set(expandableKeys(roots)))} /> useServicesTree.setState({ expanded: new Set() })} + onClick={() => setExpanded(new Set())} /> {actions.length > 0 && } {actions.map((a) => ( @@ -209,13 +222,19 @@ export function ServicesTreeView() { getKey={(n) => n.key} getLabel={(n) => n.label} getChildren={(n) => n.children} - isExpandable={(n) => !!n.children?.length} + isExpandable={(n) => !!n.children?.length || !!n.expandable} expanded={expanded} - onExpandedChange={(next) => useServicesTree.setState({ expanded: next })} + onExpandedChange={(next, node, open) => { + useServicesTree.setState({ expanded: next }); + providers.find((p) => p.id === index.get(node.key)?.provider)?.expand?.(node, open); + }} selected={selectedKeys} - onSelectionChange={(keys, lead) => - useServicesTree.setState({ selectedKeys: keys, selectedKey: keys.size ? (lead?.key ?? null) : null }) - } + onSelectionChange={(keys, lead) => { + const selectedKey = keys.size ? (lead?.key ?? null) : null; + const changed = selectedKey !== useServicesTree.getState().selectedKey; + useServicesTree.setState({ selectedKeys: keys, selectedKey }); + if (lead && changed) providers.find((p) => p.id === index.get(lead.key)?.provider)?.select?.(lead); + }} renderNode={(n, state) => } onActivate={(n) => { const p = index.get(n.key); @@ -246,7 +265,7 @@ export function ServicesTreeView() { - {selected && Detail ? ( + {selected && Detail && shown ? ( ) : ( diff --git a/src/features/services/docker.tsx b/src/features/services/docker.tsx index 3fc5255cc..8c1fc397d 100644 --- a/src/features/services/docker.tsx +++ b/src/features/services/docker.tsx @@ -4,7 +4,7 @@ * log / inspect of the selection in the detail pane. */ import { copySelectionOnKeyDown } from "@/ui/selectableText"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import clsx from "clsx"; import { Channel } from "@tauri-apps/api/core"; import type { @@ -17,7 +17,7 @@ import type { } from "@/platform/ipc"; import { tx, useTx, useTxTemplate } from "@/platform/i18n"; import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; -import { Button, EmptyState, IconButton, Spinner, Tabs, Toggle, T } from "@/ui"; +import { Button, EmptyState, IconButton, LogPane, Spinner, Tabs, T } from "@/ui"; import type { MenuItem } from "@/ui"; import { notifications, @@ -274,107 +274,28 @@ export function DockerTaskBanner() { ); } -const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex -const MAX_LINES = 5000; - /** Follows a container's log while shown. */ function LogView({ conn, id, running }: { conn: string; id: string; running: boolean }) { - const [timestamps, setTimestamps] = useState(false); - const [wrap, setWrap] = useState(true); - // A new stream (and buffer) per option set. - return ( - - ); -} - -function LogStream(props: { - endpoint: DockerEndpoint; - id: string; - running: boolean; - timestamps: boolean; - setTimestamps: (v: boolean) => void; - wrap: boolean; - setWrap: (v: boolean) => void; -}) { const tx = useTx(); - const { endpoint, id, running, timestamps, setTimestamps, wrap, setWrap } = props; - const [lines, setLines] = useState([]); - const [error, setError] = useState(null); - const scroller = useRef(null); - const stick = useRef(true); - const ep = useRef(endpoint); - - useEffect(() => { - const request = nextRequestId(); - const channel = new Channel(); - let buffer: DockerLogLine[] = []; - let frame = 0; - const flush = () => { - frame = 0; - const add = buffer; - buffer = []; - setLines((prev) => { - const next = prev.concat(add); - return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next; - }); - }; - channel.onmessage = (line) => { - buffer.push({ stderr: line.stderr, text: line.text.replace(ANSI, "") }); - frame ||= requestAnimationFrame(flush); - }; - call(commands.dockerLogs(request, ep.current, id, 1000, running, timestamps, channel)).catch((e) => { - if (!isCancelled(e)) setError(errorMessage(e)); - }); - return () => { - cancelAnimationFrame(frame); - void commands.cancelRequest(request); - }; - }, [id, running, timestamps]); - - useEffect(() => { - const el = scroller.current; - if (el && stick.current) el.scrollTop = el.scrollHeight; - }, [lines]); - return (
-
- - - - setLines([])} /> -
-
{ - const el = e.currentTarget; - stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; + { + const request = nextRequestId(); + const channel = new Channel(); + channel.onmessage = onLine; + call(commands.dockerLogs(request, endpointOf(conn), id, 1000, running, on.has("timestamps"), channel)).catch( + (e) => { + if (!isCancelled(e)) onError(errorMessage(e)); + }, + ); + return () => void commands.cancelRequest(request); }} - > - {lines.map((l, i) => ( -
- {l.text || " "} -
- ))} - {error &&
{error}
} - {!lines.length && !error && ( -
{tx(running ? "Waiting for output…" : "No output")}
- )} -
+ />
); } diff --git a/src/features/services/tree.ts b/src/features/services/tree.ts index 451a04af5..cb131a896 100644 --- a/src/features/services/tree.ts +++ b/src/features/services/tree.ts @@ -82,9 +82,9 @@ export function orderedRoots( export function expandableKeys(items: readonly ServiceTreeItem[]): string[] { const out: string[] = []; const visit = (i: ServiceTreeItem) => { - if (i.children?.length) { + if (i.children?.length || i.expandable) { out.push(i.key); - i.children.forEach(visit); + i.children?.forEach(visit); } }; items.forEach(visit); diff --git a/src/ui/LogPane.module.css b/src/ui/LogPane.module.css new file mode 100644 index 000000000..aa1d8804f --- /dev/null +++ b/src/ui/LogPane.module.css @@ -0,0 +1,48 @@ +.pane { + flex: 1; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} +.toolbar { + height: 32px; + flex: none; + display: flex; + align-items: center; + gap: 12px; + padding: 0 10px; + border-bottom: 1px solid var(--border); +} +.flex { + flex: 1; +} +.log { + flex: 1; + min-height: 0; + margin: 0; + padding: 6px 10px; + overflow: auto; + font-family: var(--font-mono); + font-size: var(--fs-code); + line-height: var(--lh-code); + color: var(--fg-editor); + background: var(--bg-editor); + white-space: pre; + -webkit-user-select: text; + user-select: text; + cursor: default; +} +.log > div { + cursor: text; +} +.wrap { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.stderr { + color: var(--danger); +} +.dim { + color: var(--fg-subtle); +} diff --git a/src/ui/LogPane.test.tsx b/src/ui/LogPane.test.tsx new file mode 100644 index 000000000..fd9ced136 --- /dev/null +++ b/src/ui/LogPane.test.tsx @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { LogPane, type LogLine } from "./LogPane"; + +type Push = (l: LogLine) => void; + +function source() { + const pushes: Push[] = []; + const exits: ((code: number | null) => void)[] = []; + const stops: ReturnType[] = []; + const start = vi.fn( + ( + onLine: Push, + _onError: (message: string) => void, + _on: ReadonlySet, + onExit: (code: number | null) => void, + ) => { + pushes.push(onLine); + exits.push(onExit); + const stop = vi.fn(); + stops.push(stop); + return stop; + }, + ); + const push = (text: string, stderr = false) => pushes.at(-1)!({ text, stderr }); + const exit = (code: number | null) => act(() => exits.at(-1)!(code)); + return { start, stops, push, exit }; +} + +let frames: FrameRequestCallback[] = []; +const runFrames = () => + act(() => { + const run = frames; + frames = []; + run.forEach((f) => f(0)); + }); + +beforeEach(() => { + frames = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((fn) => frames.push(fn)); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("LogPane", () => { + it("drops output that arrives after the source was stopped", () => { + const s = source(); + const { unmount } = render(); + const late = s.start.mock.calls.at(-1)![0]; + unmount(); + expect(() => late({ text: "late", stderr: false })).not.toThrow(); + expect(frames).toHaveLength(0); + }); + + it("says when the program finished, after its last lines", () => { + const s = source(); + render(); + s.push("done"); + s.exit(3); + expect(screen.getByRole("log").textContent).toBe("doneProcess finished with exit code 3"); + }); + + it("appends lines and strips ANSI", () => { + const s = source(); + render(); + s.push("\x1b[31mred\x1b[0m"); + runFrames(); + expect(screen.getByRole("log").textContent).toBe("red"); + }); + + it("keeps the last 5000 lines", () => { + const s = source(); + render(); + for (let i = 0; i <= 5000; i++) s.push(`line ${i}`); + runFrames(); + const lines = screen.getByRole("log").children; + expect(lines).toHaveLength(5000); + expect(lines[0]?.textContent).toBe("line 1"); + }); + + it("restarts the source when a toggle flips", () => { + const s = source(); + render( + , + ); + expect(s.start).toHaveBeenCalledTimes(1); + expect(s.start.mock.calls[0]?.[2]).toEqual(new Set()); + fireEvent.click(screen.getByRole("switch", { name: "Timestamps" })); + expect(s.start).toHaveBeenCalledTimes(2); + expect(s.start.mock.calls[1]?.[2]).toEqual(new Set(["timestamps"])); + expect(s.stops.at(0)).toHaveBeenCalled(); + }); + + it("stops the source on unmount", () => { + const s = source(); + const { unmount } = render(); + unmount(); + expect(s.stops.at(0)).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/LogPane.tsx b/src/ui/LogPane.tsx new file mode 100644 index 000000000..f12083a15 --- /dev/null +++ b/src/ui/LogPane.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import { useTx } from "@/platform/i18n"; +import { IconButton } from "./Button"; +import { Toggle } from "./Checkbox"; +import { copySelectionOnKeyDown } from "./selectableText"; +import styles from "./LogPane.module.css"; + +export interface LogLine { + stderr: boolean; + text: string; +} + +export interface LogToggle { + id: string; + label: string; +} + +export interface LogPaneProps { + /** + * Starts the source and returns its stop function; called again whenever the toggles or `sourceKey` change. + * A program's source reports its exit code with `onExit`. + */ + start( + onLine: (l: LogLine) => void, + onError: (message: string) => void, + on: ReadonlySet, + onExit: (code: number | null) => void, + ): () => void; + sourceKey: string; + toggles?: LogToggle[]; + /** "Waiting for output…" rather than "No output" while empty. */ + live: boolean; + ariaLabel: string; +} + +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex +const MAX_LINES = 5000; + +/** A followed log (container, program output) with toggles, Soft-wrap and Clear. */ +export function LogPane({ sourceKey, ...props }: LogPaneProps) { + const [on, setOn] = useState>(() => new Set()); + const [wrap, setWrap] = useState(true); + const onKey = [...on].sort().join("\0"); + // A new source (and buffer) per option set. + return ; +} + +function LogStream( + props: Omit & { + on: ReadonlySet; + setOn: (on: ReadonlySet) => void; + wrap: boolean; + setWrap: (wrap: boolean) => void; + }, +) { + const tx = useTx(); + const { toggles = [], live, ariaLabel, on, setOn, wrap, setWrap } = props; + const [lines, setLines] = useState([]); + const [error, setError] = useState(null); + const [exit, setExit] = useState(undefined); + const scroller = useRef(null); + const stick = useRef(true); + const source = useRef({ start: props.start, on }); + + useEffect(() => { + let buffer: LogLine[] = []; + let frame = 0; + // Output that arrives after the stop (a source that already ran) is dropped. + let stopped = false; + const flush = () => { + frame = 0; + const add = buffer; + buffer = []; + setLines((prev) => { + const next = prev.concat(add); + return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next; + }); + }; + const stop = source.current.start( + (line) => { + if (stopped) return; + buffer.push({ stderr: line.stderr, text: line.text.replace(ANSI, "") }); + frame ||= requestAnimationFrame(flush); + }, + (message) => !stopped && setError(message), + source.current.on, + (code) => { + if (stopped) return; + flush(); + setExit(code); + }, + ); + return () => { + stopped = true; + cancelAnimationFrame(frame); + stop(); + }; + }, []); + + useEffect(() => { + const el = scroller.current; + if (el && stick.current) el.scrollTop = el.scrollHeight; + }, [lines]); + + const toggle = (id: string, checked: boolean) => { + const next = new Set(on); + if (checked) next.add(id); + else next.delete(id); + setOn(next); + }; + + return ( +
+
+ {toggles.map((t) => ( + toggle(t.id, checked)} label={t.label} /> + ))} + + + setLines([])} /> +
+
{ + const el = e.currentTarget; + stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; + }} + > + {lines.map((l, i) => ( +
+ {l.text || " "} +
+ ))} + {error &&
{error}
} + {exit !== undefined && ( +
+ {exit === null ? "Process finished" : `Process finished with exit code ${exit}`} +
+ )} + {!lines.length && !error && exit === undefined && ( +
{tx(live ? "Waiting for output…" : "No output")}
+ )} +
+
+ ); +} diff --git a/src/ui/index.ts b/src/ui/index.ts index 8cab09729..a93a7b13e 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -38,3 +38,4 @@ export { selectableText, copyMenuFor, copyText } from "./selectableText"; export { useSelectionBoundary } from "./useSelectionBoundary"; export { useSanitizedField } from "./useSanitizedField"; export * from "./rowSelection"; +export * from "./LogPane"; diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 10ddbadd0..e1f03e0e8 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -208,6 +208,8 @@ export interface ServiceTreeItem { dim?: boolean; /** Expanded the first time the node appears. */ expanded?: boolean; + /** Shows an expander before its children are known (the provider's `expand` loads them). */ + expandable?: boolean; /** Undefined for leaves. */ children?: ServiceTreeItem[]; } @@ -239,6 +241,10 @@ export interface ServiceProviderContribution { menu?(item: ServiceTreeItem): MenuItem[]; /** Double click / Enter. */ activate?(item: ServiceTreeItem): void; + /** A node of this provider became the lead selection. */ + select?(item: ServiceTreeItem): void; + /** A node of this provider was expanded or collapsed. */ + expand?(item: ServiceTreeItem, expanded: boolean): void; /** Right pane for a selected node. */ detail?: ComponentType<{ item: ServiceTreeItem }>; /** Status line above the tree (a long operation in progress…); renders null when idle. */ @@ -340,6 +346,11 @@ export const EditorShownProvider = EditorShown.Provider; /** Whether this editor tab is the one its pane shows (a `keepAlive` tab stays mounted when it isn't). */ export const useEditorShown = () => useContext(EditorShown); +const ToolWindowShown = createContext(true); +export const ToolWindowShownProvider = ToolWindowShown.Provider; +/** Whether this tool window is showing (one shown once stays mounted while hidden). */ +export const useToolWindowShown = () => useContext(ToolWindowShown); + export const registerEditorKind = (c: EditorKindContribution) => register("editorKinds", c.kind, c); export const registerTitleItem = (c: TitleItemContribution) => register("titleItems", c.id, c); export const registerServiceView = (c: ServiceViewContribution) => register("serviceViews", c.id, c); diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index 0a09cbaab..1294504b7 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -20,7 +20,12 @@ import { useProject } from "@/platform/project"; import { dragRequiresAlt, Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem, T } from "@/ui"; import { FOCUSABLE } from "@/ui/popupState"; import { TabStripRegistry } from "@/ui/Tabs"; -import { useContributions, type ToolWindowAnchor, type ToolWindowContribution } from "../contributions"; +import { + ToolWindowShownProvider, + useContributions, + type ToolWindowAnchor, + type ToolWindowContribution, +} from "../contributions"; import { useDistractionFree } from "../distractionFree"; import { hideToolWindow, @@ -800,7 +805,9 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo )}
- + + +
diff --git a/wit/plugin.wit b/wit/plugin.wit index 14e74f548..559abf97e 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -61,6 +61,13 @@ world plugin-semantic-tokens { export semantic-tokens-provider; } +/// A plugin whose views show in the Services tool window +/// (`location = "services"`): their nodes' detail pane. +world plugin-services { + include plugin; + export service-provider; +} + /// Only the `syntax-provider` export: guests generate it next to `plugin` /// (the component tools merge both into one component). world syntax { @@ -85,3 +92,8 @@ world indexing { world semantic-tokens { export semantic-tokens-provider; } + +/// Only the `service-provider` export, generated next to `plugin` like `syntax`. +world services { + export service-provider; +} diff --git a/wit/services.wit b/wit/services.wit new file mode 100644 index 000000000..17d7eafa5 --- /dev/null +++ b/wit/services.wit @@ -0,0 +1,50 @@ +package openidea:plugin@1.3.0; + +/// What the pane beside the Services tree shows for a plugin's node. +interface service-types { + /// A switch above a log; when on, `args` are appended and the program + /// starts again. + record log-toggle { + id: string, + label: string, + args: list, + } + + /// A program the IDE runs, its output followed in a log (ANSI stripped, + /// the last 5000 lines) until it exits or the pane closes. Needs the + /// plugin's `process:` grant; runs in the project root. + record process-log { + program: string, + args: list, + toggles: list, + } + + /// The body of a tab. + variant pane { + /// A centred message: an empty state or why there is nothing to show. + message(string), + /// Read-only text the user can select and copy, at most 1 MiB. + text(string), + log(process-log), + } + + /// A tab of a node's pane; the IDE switches tabs without calling the plugin. + record tab { + id: string, + label: string, + body: pane, + } +} + +/// The detail pane of the nodes of a plugin's `location = "services"` views. +/// The nodes themselves, the toolbar and their events come from +/// `view-provider` (`render` / `handle` of the view). +interface service-provider { + use types.{error}; + use invocation.{invocation}; + use service-types.{tab}; + + /// The tabs of node `key` of view `view` (at most 8), rendered when the + /// node is selected and again after `views.refresh(view)`. + detail: func(view: string, key: string, ctx: borrow) -> result, error>; +}