feat(plugins): services views in the Services tool window (#1760)

A plugin view declared with `location = "services"` (plugin API 1.3, `engines.openidea ^1.3`) shows its tree in the Services tool window, next to Docker and the run configurations. The nodes, the toolbar (acting on one selected node) and their events come from `view-provider`; `viewItem.context` menus give the nodes a right-click menu. The pane beside the tree comes from a new optional export, `service-provider.detail(view, key)` (worlds `plugin-services` / `services`), which returns typed tabs: a message, read-only text or a process log. The IDE switches tabs without calling the plugin.

For a log, the UI names only the node and tab (`plugin_service_log`); the host asks the plugin for the pane again and runs its program with the plugin's `process:` grant in the project root, with the arguments of the toggles that are on, streaming its output into the log pane Docker uses until it exits (the exit code is shown), the pane closes or the Services window is hidden. A cancelled request starts nothing, lines are cut at 16 KiB, and revoking the grant or cancelling kills the program and what it started. The log pane moves to `src/ui/LogPane` and drops output that arrives after its source was stopped. The Rust SDK gains `openidea_plugin::services` (`Services`, `Tab`, `Pane`, `ProcessLog`). All components are rebuilt for the WIT change.

Closes #1736

Co-authored-by: arnaud-ritti <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-07 09:39:18 +08:00
1 parent 809bc1cc2f
commit 39f6162c12
72 files changed
+1936 -178

No files matched your search

+123
View File
@@ -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<String>,
cwd: Option<&Path>,
cancel: &CancellationToken,
on_line: &dyn Fn(super::PluginOutputLine),
) -> Result<Option<i32>, 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<dyn std::io::Read + Send>, 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<HostError> {
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<dyn std::io::Read + Send>, 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())
}
+75 -1
View File
@@ -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<Vec<ide_plugin::ServiceTab>> {
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:<program>` 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<Option<i32>> {
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<String> = 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.
+36 -1
View File
@@ -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 }
+8
View File
@@ -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 {
+12 -3
View File
@@ -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<UiDecl, String> {
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));
+45 -3
View File
@@ -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<PluginHost>,
window: &str,
plugin_id: String,
view: String,
key: String,
) -> IdeResult<Vec<ServiceTab>> {
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<PluginHost>,
window: &str,
plugin_id: String,
view: String,
key: String,
tab: String,
on: Vec<String>,
request: ide_core::RequestId,
on_line: crate::ops::Channel<PluginOutputLine>,
) -> IdeResult<Option<i32>> {
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::*;
+182
View File
@@ -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<dyn Fn(&str) -> 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"), "<p>x</p>").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::<Vec<_>>(), ["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<Option<i32>> {
let on: Vec<String> = 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());
+70 -3
View File
@@ -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<wit_index::Guest>,
views: Option<wit_view::Guest>,
semantic: Option<wit_sem::Guest>,
services: Option<wit_svc::Guest>,
}
/// 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<Vec<ServiceTab>, 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<String>, max: usize) -> Option<String> {
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)),
+36
View File
@@ -505,6 +505,42 @@ pub struct View {
pub status: Option<String>,
}
/// 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<String>,
pub toggles: Vec<ServiceLogToggle>,
}
/// 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<String>,
}
/// The table rows the UI shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+13
View File
@@ -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",
Binary file not shown.
@@ -1 +1 @@
31c58d5da122b9ec92bc18c2ea0aa5a153075125f9df995eb458ca854d4693ba
670aec4d79e92537efb7048698bbb8f236cd6006524c74459891b03c9b613450
+10 -1
View File
@@ -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<Vec<Tab>, 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 {
Binary file not shown.
@@ -1 +1 @@
1a8d4b16e31b4904b6a82c5d37ba71731bda3ff8a5ddb31846960fdb117f3e3b
c913c2c94fecb43d00f73f3171cc2fda54caf7a28f170424b0addb1d40ed4cae
Binary file not shown.
@@ -1 +1 @@
c3e25003bb5cae1ba412becc31b9c9e1ee542de42898c425fa24899b0572822a
911388ae62843d7b1e4c40d91307fbe507535c60680a9626cc07d724b75a9dfb
Binary file not shown.
@@ -1 +1 @@
9c513786838e20dc5b7ae53dc57536ae24364b19e5bbad2126ba769e15f05aab
a3818b6ec40df8f141290537a7c024aadb02d7be1eb063ca9170799ca3271898
Binary file not shown.
@@ -1 +1 @@
c1ea0651bd0127a01dd54884aad76a9c910ad0f56fd442dd7d50883da85242dd
9e4c12fdf905227128d97e92639cf336124929c37384e5f5796e27ebfc5a7b37
+61 -17
View File
@@ -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/<id>.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/<id>.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<Vec<Tab>, 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:<program>`
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
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
cbc68107db26fa7d6fa77c8983f469536defe541d9bcd037d490030669ff999d
eb76da4bc9db84dcbad34143bac18c9ecf974276f793009791a15aab2eee291d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0715a5670087df9e7f653f0efcd4087988a995c1ffa3eb165d119cf2737438c7
0a19948927a23de81b2a453b9992e021744093cbe41ea92daa9feb2b1bc9a2a9
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b015bde71d2e116255e27190829c08bd6768e1077d0ee61321a8660d7008b229
564bfd00694f27f35f09eb0b81d341207a755528b5fe7c46c96717b3868d0f65
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6c93f780430507806394a152041e20062b69bc2ae2642ec24eaffc93e189bac5
f3f447366fa38f9bf4911f35b6e899405b0e072ceee3ad5ea2fe90639d70fa53
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0b0b37abc7412a1517c0c1bd7d678279acc1e1afbdb6cedcded1582779a068a1
85cad466adb94f2b58e62263617e885d1c47cf70db5e8b38942bd415f0d779af
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
629c5aed682a5be77a5e1a4e9a3f3483533e28679c07bfc88b21021a0dee28d8
659446eda0b50aa0fb08f6af68f7d355fcaf3342847e2e579999dcf52a7a7399
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
bcfd9e933a2c5cc575fc9d2f22b8b5a151fd1f366a31235b44dcc3ef8bd3422d
51ef9d43315ca9d7239999c222fbf7a2c01bc85c2b40f53f34a1a8e5d31baa6d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a69659e480b2f88b818d33b3b28ef8fbe29f7564800ac6d57e6c2e013c85a9d9
bdf9455c6e7c8c340c54d783f351220b38cffed36907e1b5a1f2e11801e6f5bb
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
55f4e790142e1de70853080ced47dc80415384f7a0ec5645b5376b89f31d8354
7e4d00e1ea63b2eca48b9297667e04c05a9f9832f1dee2262272801d516b9a16
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a89ac3590b8f7474a1d503ced271d2c60e3c99bbd5c28ae9e4c04256b1a794a2
00cbd2dd7f0937554633ba5e533f19be85f70579c7c6fedb6fe0228f8f63bf4c
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
adae51c270a7ad0dd05082f25deb0b0ff5012225b2dc7e7efda923dad3904869
eecf2cf561dd5e3e3edbf9f67ccb479573dd538a75d4426a1d086edf0a310043
+69
View File
@@ -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/[email protected]": crate::bindings::openidea::plugin::types,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::editor,
"openidea:plugin/[email protected]": 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::<T, _>(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out)
}
pub fn service_detail<T: Plugin + crate::services::Services + Default>(
view: String,
key: String,
ctx: &b::invocation::Invocation,
) -> Result<Vec<crate::services_bindings::openidea::plugin::service_types::Tab>, 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::<T, _>(|p| p.detail(&view, &key, &ctx)).map(|tabs| tabs.into_iter().map(tab).collect()).map_err(error_out)
}
pub fn view_event<T: Plugin + v::Views + Default>(
view: String,
event: vt::ViewEvent,
+76
View File
@@ -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<String>,
}
/// A program the IDE runs (with the plugin's `process:<program>` 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<String>,
pub toggles: Vec<LogToggle>,
}
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<ProcessLog> 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<Pane>) -> 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<Vec<Tab>, Error>;
}
fn strings(args: &[&str]) -> Vec<String> {
args.iter().map(|a| (*a).to_owned()).collect()
}
+12
View File
@@ -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;
}
+50
View File
@@ -0,0 +1,50 @@
package openidea:[email protected];
/// 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<string>,
}
/// 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:<program>` grant; runs in the project root.
record process-log {
program: string,
args: list<string>,
toggles: list<log-toggle>,
}
/// 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<invocation>) -> result<list<tab>, error>;
}
+2
View File
@@ -489,6 +489,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+48
View File
@@ -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<Vec<ide_plugin::ServiceTab>> {
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<String>,
on_line: Channel<ide_host::plugins::PluginOutputLine>,
) -> IdeResult<Option<i32>> {
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]
+40
View File
@@ -1090,6 +1090,14 @@ export const commands = {
language: string,
selections: Range[],
} | null) => typedError<null, IdeError>(__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<ServiceTab[], IdeError>(__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<PluginOutputLine>) => typedError<number | null, IdeError>(__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<WebPage, IdeError>(__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,
@@ -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<typeof vi.fn>[] = [];
const b = {
...createMemoryPluginsBackend([]),
serviceDetail: vi.fn(async () => detail()),
serviceLog: vi.fn<PluginsBackend["serviceLog"]>(() => {
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(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
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(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
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(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
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(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
expect(await screen.findByText("no cluster")).toBeTruthy();
});
});
@@ -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(<Detail item={node} />);
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");
});
});
+34 -1
View File
@@ -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<View>;
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
/** The Services pane of node `key` of a plugin's services view. */
serviceDetail(pluginId: string, view: string, key: string): Promise<ServiceTab[]>;
/**
* 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<string>,
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<string>;
/** 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<PluginOutputLine>();
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` });
},
+7 -1
View File
@@ -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,
+7 -7
View File
@@ -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<string, Node>();
const roots: Node[] = [];
export function treeNodes(items: readonly ViewTreeItem[]): TreeNode[] {
const byKey = new Map<string, TreeNode>();
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 (
<Tree<Node>
<Tree<TreeNode>
roots={roots}
getKey={(n) => n.item.key}
getLabel={(n) => n.item.label}
@@ -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;
}
@@ -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(<view>)` asks for the pane again.
const revision = useViewRevision(pluginId, view);
const [tabs, setTabs] = useState<ServiceTab[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [picked, setPicked] = useState<string | null>(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 <EmptyState title={error} />;
if (!tabs) return <Spinner />;
const active = tabs.find((t) => t.id === picked) ?? tabs[0];
if (!active) return <EmptyState title="Nothing to show" />;
return (
<div className={styles.pane}>
<Tabs
variant="compact"
activeId={active.id}
onSelect={setPicked}
items={tabs.map((t) => ({ id: t.id, label: t.label }))}
/>
<div className={styles.body}>
<TabBody key={active.id} tab={active} pluginId={pluginId} view={view} itemKey={itemKey} />
</div>
</div>
);
}
function TabBody({ tab, pluginId, view, itemKey }: ServicePaneProps & { tab: ServiceTab }) {
const body = tab.body;
switch (body.kind) {
case "message":
return <EmptyState title={body.value} />;
case "text":
return (
<pre role="document" tabIndex={0} className={styles.text} onKeyDown={copySelectionOnKeyDown}>
{body.value}
</pre>
);
case "log":
return (
<LogPane
// A refresh that changes what runs starts it again.
sourceKey={JSON.stringify([pluginId, view, itemKey, tab.id, body.value])}
live
toggles={body.value.toggles}
ariaLabel={tab.label}
start={(onLine, onError, on, onExit) =>
pluginsBackend().serviceLog(pluginId, view, itemKey, tab.id, on, onLine, onError, onExit)
}
/>
);
}
}
@@ -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<View | null>(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 <ServicePane pluginId={pluginId} view={decl.id} itemKey={rawKey(item)} />;
}
}
+1 -14
View File
@@ -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;
}
+42 -1
View File
@@ -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 }) => <div data-testid="detail">{item.label}</div>,
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) => (
<ToolWindowShownProvider value={shown}>
<ServicesTreeView />
</ToolWindowShownProvider>
);
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(<ServicesTreeView />);
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"));
+27 -8
View File
@@ -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<string>) => {
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)))}
/>
<IconButton
icon="collapse-all"
@@ -167,7 +180,7 @@ export function ServicesTreeView() {
iconSize={16}
title="Collapse All"
disabled={!roots.length}
onClick={() => useServicesTree.setState({ expanded: new Set() })}
onClick={() => setExpanded(new Set())}
/>
{actions.length > 0 && <span className={styles.sep} />}
{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) => <Row item={n} state={state} />}
onActivate={(n) => {
const p = index.get(n.key);
@@ -246,7 +265,7 @@ export function ServicesTreeView() {
</SplitPanel>
<SplitHandle />
<SplitPanel minSize="200px">
{selected && Detail ? (
{selected && Detail && shown ? (
<Detail key={selected.item.key} item={selected.item} />
) : (
<EmptyState title="Select service to view details" />
+18 -97
View File
@@ -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 (
<LogStream
key={`${running}:${timestamps}`}
endpoint={endpointOf(conn)}
id={id}
running={running}
timestamps={timestamps}
setTimestamps={setTimestamps}
wrap={wrap}
setWrap={setWrap}
/>
);
}
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<DockerLogLine[]>([]);
const [error, setError] = useState<string | null>(null);
const scroller = useRef<HTMLDivElement>(null);
const stick = useRef(true);
const ep = useRef(endpoint);
useEffect(() => {
const request = nextRequestId();
const channel = new Channel<DockerLogLine>();
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 (
<div className={styles.detailBody}>
<div className={styles.subToolbar}>
<Toggle checked={timestamps} onChange={setTimestamps} label="Timestamps" />
<Toggle checked={wrap} onChange={setWrap} label="Soft-wrap" />
<span className={styles.flex} />
<IconButton icon="trash" size="small" title="Clear" onClick={() => setLines([])} />
</div>
<div
ref={scroller}
className={clsx(styles.log, wrap && styles.wrap)}
tabIndex={0}
role="log"
onKeyDown={copySelectionOnKeyDown}
aria-label={tx("Container log")}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
<LogPane
sourceKey={`${conn}:${id}:${running}`}
live={running}
toggles={[{ id: "timestamps", label: "Timestamps" }]}
ariaLabel={tx("Container log")}
start={(onLine, onError, on) => {
const request = nextRequestId();
const channel = new Channel<DockerLogLine>();
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) => (
<div key={i} className={clsx(l.stderr && styles.stderr)}>
{l.text || " "}
</div>
))}
{error && <div className={styles.stderr}>{error}</div>}
{!lines.length && !error && (
<div className={styles.dim}>{tx(running ? "Waiting for output…" : "No output")}</div>
)}
</div>
/>
</div>
);
}
+2 -2
View File
@@ -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);
+48
View File
@@ -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);
}
+108
View File
@@ -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<typeof vi.fn>[] = [];
const start = vi.fn(
(
onLine: Push,
_onError: (message: string) => void,
_on: ReadonlySet<string>,
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(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
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(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
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(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
s.push("\x1b[31mred\x1b[0m");
runFrames();
expect(screen.getByRole("log").textContent).toBe("red");
});
it("keeps the last 5000 lines", () => {
const s = source();
render(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
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(
<LogPane
start={s.start}
sourceKey="a"
live
ariaLabel="Log"
toggles={[{ id: "timestamps", label: "Timestamps" }]}
/>,
);
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(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
unmount();
expect(s.stops.at(0)).toHaveBeenCalled();
});
});
+152
View File
@@ -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<string>,
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<ReadonlySet<string>>(() => new Set());
const [wrap, setWrap] = useState(true);
const onKey = [...on].sort().join("\0");
// A new source (and buffer) per option set.
return <LogStream key={`${sourceKey}\0${onKey}`} {...props} on={on} setOn={setOn} wrap={wrap} setWrap={setWrap} />;
}
function LogStream(
props: Omit<LogPaneProps, "sourceKey"> & {
on: ReadonlySet<string>;
setOn: (on: ReadonlySet<string>) => void;
wrap: boolean;
setWrap: (wrap: boolean) => void;
},
) {
const tx = useTx();
const { toggles = [], live, ariaLabel, on, setOn, wrap, setWrap } = props;
const [lines, setLines] = useState<LogLine[]>([]);
const [error, setError] = useState<string | null>(null);
const [exit, setExit] = useState<number | null | undefined>(undefined);
const scroller = useRef<HTMLDivElement>(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 (
<div className={styles.pane}>
<div className={styles.toolbar}>
{toggles.map((t) => (
<Toggle key={t.id} checked={on.has(t.id)} onChange={(checked) => toggle(t.id, checked)} label={t.label} />
))}
<Toggle checked={wrap} onChange={setWrap} label="Soft-wrap" />
<span className={styles.flex} />
<IconButton icon="trash" size="small" title="Clear" onClick={() => setLines([])} />
</div>
<div
ref={scroller}
className={clsx(styles.log, wrap && styles.wrap)}
tabIndex={0}
role="log"
onKeyDown={copySelectionOnKeyDown}
aria-label={ariaLabel}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
}}
>
{lines.map((l, i) => (
<div key={i} className={clsx(l.stderr && styles.stderr)}>
{l.text || " "}
</div>
))}
{error && <div className={styles.stderr}>{error}</div>}
{exit !== undefined && (
<div className={styles.dim}>
{exit === null ? "Process finished" : `Process finished with exit code ${exit}`}
</div>
)}
{!lines.length && !error && exit === undefined && (
<div className={styles.dim}>{tx(live ? "Waiting for output…" : "No output")}</div>
)}
</div>
</div>
);
}
+1
View File
@@ -38,3 +38,4 @@ export { selectableText, copyMenuFor, copyText } from "./selectableText";
export { useSelectionBoundary } from "./useSelectionBoundary";
export { useSanitizedField } from "./useSanitizedField";
export * from "./rowSelection";
export * from "./LogPane";
+11
View File
@@ -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);
+9 -2
View File
@@ -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
)}
<div className={styles.body} data-toolwindow-body="">
<ToolWindowFrame.Provider value={frame}>
<Content id={tw.id} />
<ToolWindowShownProvider value={!hidden}>
<Content id={tw.id} />
</ToolWindowShownProvider>
</ToolWindowFrame.Provider>
</div>
</section>
+12
View File
@@ -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;
}
+50
View File
@@ -0,0 +1,50 @@
package openidea:[email protected];
/// 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<string>,
}
/// 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:<program>` grant; runs in the project root.
record process-log {
program: string,
args: list<string>,
toggles: list<log-toggle>,
}
/// 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<invocation>) -> result<list<tab>, error>;
}