diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 5da41db08..fc4084047 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -7,8 +7,8 @@ use ide_core::{CancellationToken, TextEdit}; use ide_extension::Package; use ide_language::ProgramPin; use ide_plugin::{ - EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus, - StatusItem, + EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt, + RepositoryStatus, StatusItem, }; use parking_lot::{Mutex, RwLock}; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -150,6 +150,12 @@ struct TaskEntry { cancelled: bool, } +/// A question a plugin's call waits on. +struct PromptEntry { + plugin: String, + answer: std::sync::mpsc::SyncSender>>, +} + /// State shared by the host and every plugin's [`PluginApi`]. pub(super) struct Shared { pub sink: Arc, @@ -158,6 +164,10 @@ pub(super) struct Shared { pub data_dir: PathBuf, tasks: Mutex>, next_task: AtomicU64, + prompts: Mutex>, + /// Plugins being stopped: their calls may not open new prompts (taken before `prompts`). + prompts_blocked: Mutex>, + next_prompt: AtomicU64, /// (plugin, item id) → item, for windows opened later. status: Mutex>, /// (scope, plugin, key) → JSON value. @@ -190,6 +200,9 @@ impl Shared { data_dir, tasks: Mutex::default(), next_task: AtomicU64::new(1), + prompts: Mutex::default(), + prompts_blocked: Mutex::default(), + next_prompt: AtomicU64::new(1), status: Mutex::default(), contexts: Mutex::default(), closed_clients: Mutex::default(), @@ -277,6 +290,7 @@ impl Shared { for id in removed { self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None }); } + self.withdraw_prompts(plugin); let removed: Vec<(Option, String)> = { let mut contexts = self.contexts.lock(); let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect(); @@ -314,6 +328,40 @@ impl Shared { true } + /// Closes the prompts `plugin` waits on: the calls waiting for the user + /// wake up cancelled (dropping the senders). + fn withdraw_prompts(&self, plugin: &str) { + let ids: Vec = { + let mut prompts = self.prompts.lock(); + let ids: Vec = prompts.iter().filter(|(_, p)| p.plugin == plugin).map(|(id, _)| *id).collect(); + for id in &ids { + prompts.remove(id); + } + ids + }; + for id in ids { + self.sink.emit(None, PluginEvent::PromptClosed { prompt_id: id.to_string() }); + } + } + + /// `plugin` is about to be stopped: withdraws its prompts, and its queued + /// calls can't open new ones (they'd keep the stop waiting) until + /// [`allow_prompts`](Self::allow_prompts). + pub fn block_prompts(&self, plugin: &str) { + self.prompts_blocked.lock().insert(plugin.to_owned()); + self.withdraw_prompts(plugin); + } + + pub fn allow_prompts(&self, plugin: &str) { + self.prompts_blocked.lock().remove(plugin); + } + + /// The user answered (`Some`) or dismissed (`None`) a prompt. + pub fn answer_prompt(&self, id: u64, answer: Option>) -> bool { + let Some(prompt) = self.prompts.lock().remove(&id) else { return false }; + prompt.answer.send(answer).is_ok() + } + fn end_task(&self, id: u64) { if let Some(t) = self.tasks.lock().remove(&id) { self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() }); @@ -392,6 +440,52 @@ impl HostApi for PluginApi { Ok(()) } + fn ask( + &self, + client: Option<&str>, + prompt: Prompt, + cancel: &CancellationToken, + ) -> Result>, HostError> { + let client = from_window(client, "ask the user")?; + let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed); + let (answer, answers) = std::sync::mpsc::sync_channel(1); + { + let blocked = self.shared.prompts_blocked.lock(); + if blocked.contains(self.id()) { + return Err(HostError::Cancelled); + } + self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer }); + } + let prompt_id = id.to_string(); + self.shared.sink.emit( + Some(client), + PluginEvent::PromptRequested { plugin_id: self.id().to_owned(), prompt_id: prompt_id.clone(), prompt }, + ); + loop { + match answers.recv_timeout(Duration::from_millis(100)) { + Ok(answer) => return Ok(answer), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) if !cancel.is_cancelled() => {} + Err(_) => { + self.shared.prompts.lock().remove(&id); + self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id }); + return Err(HostError::Cancelled); + } + } + } + } + + fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> { + let client = from_window(client, "reveal a view")?; + let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view); + if !declared { + return Err(HostError::NotFound(format!("the plugin declares no view {view:?}"))); + } + self.shared + .sink + .emit(Some(client), PluginEvent::RevealView { plugin_id: self.id().to_owned(), view: view.to_owned() }); + Ok(()) + } + fn status_item(&self, id: &str, item: Option) { let key = (self.id().to_owned(), id.to_owned()); { diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 7756a8bbb..3c46f5463 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -1282,6 +1282,12 @@ impl PluginHost { task_id.parse().is_ok_and(|id| self.shared.cancel_task(id)) } + /// The user answered (`Some`) or dismissed (`None`) a prompt + /// (`PromptRequested`); false when it is no longer waiting. + pub fn answer_prompt(&self, prompt_id: &str, answer: Option>) -> bool { + prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer)) + } + /// Kinds of workbench events some running or activatable plugin wants; /// the UI reports only these. pub fn event_kinds(&self) -> BTreeSet { @@ -1585,6 +1591,9 @@ impl PluginHost { /// Deactivates every plugin (also done on drop). pub fn shutdown(&self) { let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect(); + for id in self.plugins.read().keys() { + self.shared.block_prompts(id); + } for a in &actors { a.stop(false); } @@ -1898,6 +1907,7 @@ impl PluginHost { Err(e) => return Err(IdeError::internal(format!("plugin {:?}: {e}", plugin.id()))), }; *runtime = Some(actor.clone()); + self.shared.allow_prompts(plugin.id()); let mut meta = plugin.meta.lock(); meta.status = PluginStatus::Active; meta.has_syntax = has_syntax; @@ -1995,6 +2005,8 @@ impl PluginHost { fn stop(&self, plugin: &LoadedPlugin) { self.clear_plugin_navigation(plugin.id()); + // A call waiting on the user would keep the join below waiting. + self.shared.block_prompts(plugin.id()); if let Some(actor) = plugin.runtime.lock().take() { actor.stop(true); } diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 558ed2369..60379b13d 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -13,8 +13,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_set_active_file`, `plugin_line_markers`, -//! `plugin_view_render`, `plugin_view_event`) +//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`, +//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`) //! 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 @@ -118,6 +118,9 @@ pub fn forwarded(event: &PluginEvent) -> bool { | PluginEvent::OpenLocation { .. } | PluginEvent::CopyToClipboard { .. } | PluginEvent::OpenUrl { .. } + | PluginEvent::PromptRequested { .. } + | PluginEvent::PromptClosed { .. } + | PluginEvent::RevealView { .. } ) } @@ -413,6 +416,11 @@ impl RemotePlugins { async move { Ok(alive(&h)?.cancel_task(&c.arg::("taskId")?)) } }); let h = host.clone(); + d.add("plugin_answer_prompt", move |c| { + let h = h.clone(); + async move { Ok(alive(&h)?.answer_prompt(&c.arg::("promptId")?, c.arg("answer")?)) } + }); + let h = host.clone(); d.add("plugin_set_active_file", move |c| { let h = h.clone(); async move { diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index a944fb3e8..ffd3bf262 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -3,7 +3,9 @@ use super::theme::ThemeDef; use ide_extension::package::PluginIcon; use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution}; -pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent}; +pub use ide_plugin::{ + ActionPerformed, ActionSource, EventKind, NotificationLevel, PickItem, Prompt, StatusItem, WorkbenchEvent, +}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -212,6 +214,16 @@ pub enum PluginEvent { /// Open `url` (`http(s)://`) in the system browser. #[serde(rename_all = "camelCase")] OpenUrl { plugin_id: String, url: String }, + /// A command waits for the user's answer: show `prompt`, then report it with + /// `plugin_answer_prompt(prompt_id, …)`. + #[serde(rename_all = "camelCase")] + PromptRequested { plugin_id: String, prompt_id: String, prompt: Prompt }, + /// The call that asked gave up (cancelled, or its plugin stopped): close the prompt. + #[serde(rename_all = "camelCase")] + PromptClosed { prompt_id: String }, + /// Bring the tool window of the plugin's view `view` to the front. + #[serde(rename_all = "camelCase")] + RevealView { plugin_id: String, view: String }, } /// Delivers events to the UI. `client` is the window that invoked the plugin diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 64a37a562..7ce801f62 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -2214,6 +2214,126 @@ fn vcs_remotes_clipboard_and_open_url() { } } +/// Runs `cmd` of the jobs plugin; once it asks the user, answers after `delay` +/// (`None`: dismisses). Returns the command's result and what it asked. +fn run_and_answer( + f: &Fixture, + cmd: &str, + arg: Option<&str>, + answer: Option<&[&str]>, + delay: Duration, +) -> (Result, String>, ide_plugin::Prompt) { + std::thread::scope(|scope| { + let running = scope.spawn(|| f.run("jobs", cmd, arg)); + let deadline = Instant::now() + Duration::from_secs(10); + let (client, prompt_id, prompt) = loop { + let asked = f.take_events().into_iter().find_map(|(client, e)| match e { + PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)), + _ => None, + }); + if let Some(asked) = asked { + break asked; + } + assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked"); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command"); + std::thread::sleep(delay); + let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect()); + assert!(f.host.answer_prompt(&prompt_id, answer)); + assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once"); + (running.join().unwrap(), prompt) + }) +} + +#[test] +fn commands_ask_the_user_and_reveal_their_views() { + use ide_plugin::{PickItem, Prompt}; + let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() }); + let info = f.install(&sample("jobs")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let none = Duration::ZERO; + + let (result, prompt) = run_and_answer(&f, "pick", None, Some(&["b"]), none); + assert_eq!(result.unwrap().as_deref(), Some("b")); + let items = vec![ + PickItem { key: "a".into(), label: "Alpha".into(), description: Some("first".into()) }, + PickItem { key: "b".into(), label: "Beta".into(), description: None }, + PickItem { key: "c".into(), label: "Gamma".into(), description: None }, + ]; + assert_eq!(prompt, Prompt::QuickPick { title: "Pick".into(), items, multiple: false }); + + let (result, prompt) = run_and_answer(&f, "pick-many", None, Some(&["a", "c"]), none); + assert_eq!(result.unwrap().as_deref(), Some("a,c")); + assert!(matches!(prompt, Prompt::QuickPick { multiple: true, .. })); + assert_eq!(run_and_answer(&f, "pick", None, None, none).0.unwrap().as_deref(), Some("none")); + + let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none); + assert_eq!(result.unwrap().as_deref(), Some("Other")); + assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) }); + assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none")); + + let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none); + assert_eq!(result.unwrap().as_deref(), Some("true")); + assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() }); + assert_eq!(run_and_answer(&f, "confirm", None, None, none).0.unwrap().as_deref(), Some("false")); + + // The wait is not part of the call's 500 ms budget. + let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::from_millis(1200)); + assert_eq!(result.unwrap().as_deref(), Some("true")); + + // Keys come back as given: one that doesn't fit is refused, not cut. + f.take_events(); + let err = f.run("jobs", "pick", Some("long-key")).unwrap_err(); + assert!(err.contains("item key is longer"), "{err}"); + assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. }))); + + // Without a window there is nothing to attach the dialog to. + f.take_events(); + let no_window = Invocation { client: None, ..f.inv() }; + for cmd in ["pick", "input", "confirm"] { + let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message; + assert!(err.contains("from a command the user invoked"), "{cmd}: {err}"); + } + assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. }))); + + // `views.reveal` only knows the plugin's own declared views. + f.take_events(); + f.run("jobs", "reveal", Some("jobs")).unwrap(); + let revealed = f.take_events().into_iter().find_map(|(client, e)| match e { + PluginEvent::RevealView { plugin_id, view } => Some((client, plugin_id, view)), + _ => None, + }); + assert_eq!(revealed, Some((Some("main".into()), "jobs".into(), "jobs".into()))); + for view in ["greetings", ""] { + let err = f.run("jobs", "reveal", Some(view)).unwrap_err(); + assert!(err.contains("declares no view"), "{view:?}: {err}"); + } + assert!(f.run("jobs", "reveal", Some("jobs")).is_ok(), "the instance survives"); +} + +#[test] +fn disabling_a_plugin_that_waits_for_an_answer_does_not_hang() { + let f = fixture(Limits::default()); + let info = f.install(&sample("jobs")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + std::thread::scope(|scope| { + let running = scope.spawn(|| f.run("jobs", "confirm", None)); + let deadline = Instant::now() + Duration::from_secs(10); + while !f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })) { + assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked"); + std::thread::sleep(Duration::from_millis(10)); + } + let started = Instant::now(); + f.host.set_enabled("jobs", false).unwrap(); + assert!(started.elapsed() < Duration::from_secs(5), "disabling waited for the user"); + assert!(running.join().unwrap().is_err(), "the waiting command ends with an error"); + }); + f.host.set_enabled("jobs", true).unwrap(); + let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::ZERO); + assert_eq!(result.unwrap().as_deref(), Some("true"), "prompts work again after the restart"); +} + #[test] fn views_can_use_an_svg_icon_from_the_package() { let f = fixture(Limits::default()); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index f9c09f894..09cb3a54e 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -5,7 +5,7 @@ use crate::types::{CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument}; use crate::types::{ - EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, + EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt, RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent, }; use crate::types::{ @@ -55,6 +55,24 @@ pub trait HostApi: Send + Sync { let _ = (client, url); Ok(()) } + /// Shows `prompt` in `client`'s window and waits for the answer until the + /// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the + /// text (`Input`, one element) or any value (`Confirm`: OK); `None` when + /// dismissed. + fn ask( + &self, + client: Option<&str>, + prompt: Prompt, + cancel: &CancellationToken, + ) -> Result>, HostError> { + let _ = (client, prompt, cancel); + Ok(None) + } + /// Brings the plugin's declared view to the front in `client`'s window. + fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> { + let _ = (client, view); + Ok(()) + } /// Sets (`Some`) or removes the plugin's status bar item `id`. fn status_item(&self, id: &str, item: Option); /// Sets (`Some`, validated JSON text) or removes the plugin's context key, @@ -364,6 +382,8 @@ pub struct StoreData { pub(crate) granted: Vec, pub(crate) host: Arc, pub(crate) invocation: Invocation, + /// The running call is a command or an event, so it may ask the user. + pub(crate) interactive: bool, /// Tasks begun by the running call and not ended yet. pub(crate) open_tasks: Vec, pub(crate) last_report: Option, @@ -544,6 +564,7 @@ impl Runtime { granted: setup.granted, host: setup.host, invocation: Invocation::default(), + interactive: false, open_tasks: Vec::new(), last_report: None, }; @@ -843,6 +864,7 @@ impl Runtime { ) -> Result, CallError> { let result = self.call(invocation, self.limits.timeout, |e, store| { let ctx = store.data_mut().new_invocation()?; + store.data_mut().interactive = true; let r = e.plugin.openidea_plugin_commands().call_run( &mut *store, id, @@ -893,6 +915,7 @@ impl Runtime { let event = event_to_wit_view(event); let result = self.call(invocation, self.limits.timeout, |e, store| { let ctx = store.data_mut().new_invocation()?; + store.data_mut().interactive = true; let views = e.views.as_ref().expect("checked"); let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep())); store.data_mut().drop_invocation(ctx); @@ -906,6 +929,7 @@ impl Runtime { let events: Vec = events.into_iter().take(MAX_BATCH).map(event_to_wit).collect(); self.call(invocation, self.limits.timeout, |e, store| { let ctx = store.data_mut().new_invocation()?; + store.data_mut().interactive = true; let r = e.plugin.openidea_plugin_event_handler().call_handle( &mut *store, &events, @@ -942,6 +966,7 @@ impl Runtime { let result = f(&self.exports, &mut self.store); let data = self.store.data_mut(); data.invocation = Invocation::default(); + data.interactive = false; // Tasks don't outlive the call that began them. for id in std::mem::take(&mut data.open_tasks) { data.host.task_end(id); diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index bd43473c8..c5086c19e 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -68,6 +68,27 @@ pub struct StatusItem { pub command: Option, } +/// An entry of a plugin's `pick` or `pick-many`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PickItem { + pub key: String, + pub label: String, + pub description: Option, +} + +/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Prompt { + #[serde(rename_all = "camelCase")] + QuickPick { title: String, items: Vec, multiple: bool }, + #[serde(rename_all = "camelCase")] + Input { prompt: String, value: Option }, + #[serde(rename_all = "camelCase")] + Confirm { title: String, message: String }, +} + /// Kinds of workbench events a plugin can subscribe to (`on-event:`). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] #[serde(rename_all = "kebab-case")] diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index ab03ef032..d5910fe43 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -2,9 +2,11 @@ //! types and the implementation of every import. Capabilities are checked //! here; a missing one returns `error::denied` (the plugin can degrade). -use crate::runtime::{StoreData, clip}; +use crate::runtime::{MAX_TITLE, StoreData, clip}; use crate::sandbox; -use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host}; +use crate::types::{ + EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, Prompt, StatusItem, net_host, +}; use ide_core::ipc_path::to_ipc; use ide_extension::Capability; use std::path::{Path, PathBuf}; @@ -133,6 +135,8 @@ const MAX_DIAGNOSTICS: usize = 5_000; const MAX_REQUEST_BODY: usize = 8 << 20; const MAX_FIND: u32 = 10_000; const MAX_STATUS_TEXT: usize = 128; +const MAX_PICK_ITEMS: usize = 5000; +const MAX_PICK_KEY: usize = 256; type Fallible = wasmtime::Result>; @@ -212,6 +216,52 @@ impl log::Host for StoreData { } } +impl StoreData { + /// Asks the user and waits; the wait doesn't count against the call's + /// wall-clock budget. + fn ask(&mut self, prompt: Prompt) -> Result>, wit::Error> { + self.require_interactive()?; + let client = self.invocation.client.clone(); + let started = Instant::now(); + let answer = self.host.ask(client.as_deref(), prompt, &self.cancel.token()); + let waited = started.elapsed(); + self.deadline += waited; + self.call_started += waited; + answer.map_err(to_wit) + } + + fn pick_prompt( + &mut self, + title: String, + items: Vec, + multiple: bool, + ) -> Result>, wit::Error> { + if items.len() > MAX_PICK_ITEMS { + return Err(wit::Error::Invalid(format!("more than {MAX_PICK_ITEMS} items"))); + } + if items.iter().any(|i| i.key.len() > MAX_PICK_KEY) { + return Err(wit::Error::Invalid(format!("an item key is longer than {MAX_PICK_KEY} bytes"))); + } + let items = items + .into_iter() + .map(|i| PickItem { + key: i.key, + label: clip(i.label, MAX_TITLE), + description: i.description.map(|d| clip(d, 1024)), + }) + .collect(); + self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple }) + } + + /// Dialogs belong to commands and events; rendering and providers have no one to ask. + fn require_interactive(&self) -> Result<(), wit::Error> { + if self.interactive { + return Ok(()); + } + Err(wit::Error::Invalid("can only ask the user from a command or event".into())) + } +} + impl ui::Host for StoreData { fn show_notification( &mut self, @@ -291,6 +341,25 @@ impl ui::Host for StoreData { let client = self.invocation.client.clone(); Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit)) } + + fn pick(&mut self, title: String, items: Vec) -> Fallible> { + let answer = self.pick_prompt(title, items, false); + Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next()))) + } + + fn pick_many(&mut self, title: String, items: Vec) -> Fallible>> { + Ok(self.pick_prompt(title, items, true)) + } + + fn input(&mut self, prompt: String, value: Option) -> Fallible> { + let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) }; + Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next()))) + } + + fn confirm(&mut self, title: String, body: String) -> Fallible { + let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) }; + Ok(self.ask(prompt).map(|answer| answer.is_some())) + } } impl storage::Host for StoreData { @@ -683,6 +752,14 @@ impl views::Host for StoreData { Ok(()) } + fn reveal(&mut self, view: String) -> Fallible<()> { + if let Err(e) = self.require_interactive() { + return Ok(Err(e)); + } + let client = self.invocation.client.clone(); + Ok(self.host.reveal_view(client.as_deref(), &clip(view, 64)).map_err(to_wit)) + } + fn post_message(&mut self, view: String, message: String) -> Fallible<()> { if message.len() > MAX_VIEW_MESSAGE { return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes")))); diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 2eb61bbe5..0e80819ba 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -406afd6a36f1cf2ff59da5826c155b84516daef6f1709fbd7f7618e0eaad852b +20ce53ecb41ad94e9c7d59cd4ac51f63081c9f150832fd7febe07887fa3c07ab diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index b15e63de5..0f7c60a7d 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -060b79a6fb25455d67dffd3623984f1d22836b908570a0537142f83d11cd5522 +2e7d8932329d9383ecd4938225a111851f8a9805c6da558431dea84b7cecbe1c diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 47daa6a00..affe6ec5c 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 5805ea8ef..1ac079970 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -cadf7d5b651792b502e51587c43d49cc0f87477439291d93858ceaf3692b1879 +7f58bace9b60d8b6b87c97e78dd8cca4955bd13d5f0737efe9d9a362cd1552bf diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs index a775686d8..939ba0aee 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs @@ -4,7 +4,7 @@ use openidea_plugin::host; use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; -use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, register_plugin}; +use openidea_plugin::{Error, Invocation, NotificationLevel, PickItem, Plugin, register_plugin}; pub const VIEW: &str = "jobs"; @@ -61,6 +61,22 @@ impl Plugin for Jobs { host::open_url(arg.unwrap_or_default())?; Ok(None) } + "pick" | "pick-many" => { + let long_key = "k".repeat(300); + let items = [ + PickItem::new(if arg == Some("long-key") { &long_key } else { "a" }, "Alpha").description("first"), + PickItem::new("b", "Beta"), + PickItem::new("c", "Gamma"), + ]; + let picked = match id { + "pick" => host::pick("Pick", &items)?.map(|key| vec![key]), + _ => host::pick_many("Pick", &items)?, + }; + Ok(Some(picked.map_or("none".to_owned(), |keys| keys.join(",")))) + } + "input" => Ok(Some(host::input("Name", arg)?.unwrap_or_else(|| "none".to_owned()))), + "confirm" => Ok(Some(host::confirm("Delete", "Really?")?.to_string())), + "reveal" => host::reveal_view(arg.unwrap_or_default()).map(|()| None), "selection" => { let paths = ctx.selected_paths().join("\n"); host::notify(NotificationLevel::Info, "Selected paths", &paths); diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 05b7bdc8a..37f964f17 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -f9b1d2af61bac2bac97ad11b629ecaa062d53aeba730e3fcb686ecaa514bc1b6 +ee5ba9209eaf963ccc423828ca2e8ba221345c9c246635283214562668a872ad diff --git a/plugins/README.md b/plugins/README.md index 9ff4e991d..7a3608abf 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -357,12 +357,16 @@ then run `openidea-plugin check`. | `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). | | `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. | | `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. | +| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Asks the user to pick from `PickItem`s and waits: the chosen key(s), or `None` when dismissed. Only from a command or event of a window (else `invalid`); the wait isn't charged to the time limit. | +| `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. | +| `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. | | `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. | | `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. | | `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | | `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. | | `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. | | `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. | +| `host::reveal_view(view)` | `views.reveal` | Brings one of your declared `[[contributes.views]]` to the front in the calling window. | | `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. | | `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:` in the project root: output that keeps coming, interactive sessions. | | `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. | diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 779c9984b..0eec9c1d9 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -b143041299679ec90d16a35c6eaae4c2525648bc33f06c40d72fba7f63fb1ab2 +fd5d8c0b9c6828bbeb0a93e394a0468abe83f051d1c20e50a7ac91fe12d5ab68 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index a021644d0..cc8c88bc3 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -f87528deb3a497c841456b158d46f22fbc05909dfe4b886ed222c2e86dc824ba +eb4d8c5d84dd2ea0171297f756cdf56bf72d3db88b8f4736bc2b3ab8346bd5eb diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 70dadd99e..b2fd47d90 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -ec737f2490193fb319e28ec7d44f49cb64e6c75fdea67b44288899f7a6385e95 +5db8c9213f6bef54f92c39c994717c8dfd4623a46b615293fe10b7a2990014a9 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index dc2ce64ab..8ec643cda 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -130a82376feeb0a3879d110b4ec2417b0ae6839088b6a8ba42b101869c6248d8 +b95b4b5c2e4451e670d1b013ad03662469456de03fabdbd8a3c62eccd93fb0f0 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 87792c863..ec0414f6f 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -9d7d6995e4f6eb96a669a11f9c75e2c77f2663eb905275346e77d20c6dd5d929 +6b800f268041ebb169117dfa31eead60315f51ebd92e409751f2670c92eac473 diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 8a28f41d7..423c3420e 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -8225d25b08f9ec36b36a850d1a99a43643a3c06a3e9d6c835e75fb269eeac61a +6a2c0c1f354b9c8defbcc9ec4a098fd956991dfb656ef2a4df92a219e60102d7 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 92703d8bd..72b98a126 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -8de2b1598c2b9f8438a901c70197ba90ef6e85c3f510125e8bb72b002d25d7f6 +341466108d3a98c295a33d56c6f2a11736ced33dc0c4358d395cb376c24be663 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 015a005dd..f336bc551 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -e9cd442b032d0521e0b3c733a3a9866a203ddf5bacb381396f5a0a6c508de5ca +b3c715c1d3489db1c9f6067f66aa31623ffd23ac6ad9d9fb9cc3b5d548088473 diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index f3ae578f2..e3ca60a8b 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -3e4bd05682cf1b748ab7189b17b8937eaeb332641804da59ee684db9ad848579 +6e1490c35f7094221a856cf23ba2b6dab92bf8e8c130b156aad1a0ef68a52086 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 6e16a9e06..515fe1c2a 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -3b2892a7629ed255add98ebd88e4a1fc454720ee9cae9bf4ffa74fa9b570adff +ce28fb9dfa83dfc46bc6215f9517e2b339b34b40abb85fe12b9534fd63a76899 diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 89999ffdc..dbfb2fea5 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -6183df09a561bc715ec353223b6ae76d0280c179191621e2d0ef809b7b3b2d32 +d2b50d6ee2fe2a0991b7a0006c4899930cfb884f32db231f99c3144ef7f7d0d0 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 1741f1397..76b998a19 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -94,6 +94,37 @@ mod imp { wit::ui::open_url(url).map_err(error) } + fn pick_items(items: &[PickItem]) -> Vec { + items + .iter() + .map(|i| wit::ui::PickItem { + key: i.key.clone(), + label: i.label.clone(), + description: i.description.clone(), + }) + .collect() + } + + pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { + wit::ui::pick(title, &pick_items(items)).map_err(error) + } + + pub fn pick_many(title: &str, items: &[PickItem]) -> Result>, Error> { + wit::ui::pick_many(title, &pick_items(items)).map_err(error) + } + + pub fn input(prompt: &str, value: Option<&str>) -> Result, Error> { + wit::ui::input(prompt, value).map_err(error) + } + + pub fn confirm(title: &str, body: &str) -> Result { + wit::ui::confirm(title, body).map_err(error) + } + + pub fn reveal_view(view: &str) -> Result<(), Error> { + wit::views::reveal(view).map_err(error) + } + pub fn storage_get(key: &str) -> Result, Error> { wit::storage::get(key).map_err(error) } @@ -591,6 +622,38 @@ pub fn open_url(url: &str) -> Result<(), Error> { imp::open_url(url) } +/// Shows a list in the window the current command or event came from and +/// waits for the key of the chosen item, or `None` when the user dismisses +/// it. Needs no capability; [`Error::Invalid`] outside a command or event of a +/// window (and in editor providers). The wait doesn't count against the +/// call's time budget, but the plugin handles nothing else meanwhile. +pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { + imp::pick(title, items) +} + +/// Like [`pick`], with any number of items chosen. +pub fn pick_many(title: &str, items: &[PickItem]) -> Result>, Error> { + imp::pick_many(title, items) +} + +/// Asks for a line of text, pre-filled with `value`; `None` when dismissed. +/// Same rules as [`pick`]. +pub fn input(prompt: &str, value: Option<&str>) -> Result, Error> { + imp::input(prompt, value) +} + +/// Asks for confirmation: `true` on OK, `false` on Cancel or Escape. +/// Same rules as [`pick`]. +pub fn confirm(title: &str, body: &str) -> Result { + imp::confirm(title, body) +} + +/// Brings one of the plugin's declared views to the front in the window the +/// current command or event came from. +pub fn reveal_view(view: &str) -> Result<(), Error> { + imp::reveal_view(view) +} + /// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of /// the session (also after the instance is reclaimed: an event activates the /// plugin again). Needs `events` (`events.input` for `ActionPerformed`). diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index ec7784b98..aa4efa1c6 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -8,7 +8,7 @@ use crate::host::Invocation; use crate::types::*; use std::cell::RefCell; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::marker::PhantomData; use std::rc::Rc; @@ -70,6 +70,8 @@ struct State { fetch_error: Option, clipboard: Option, opened_urls: Vec, + answers: VecDeque>>, + revealed_views: Vec, diagnostics: BTreeMap>, secrets: BTreeMap, net: Option, @@ -235,6 +237,19 @@ impl MockHost { self.state.borrow().opened_urls.clone() } + /// The answer the next [`crate::host::pick`], [`crate::host::input`] or + /// [`crate::host::confirm`] gets: the chosen keys, the text (one element), + /// anything for OK, or `None` for dismissed. Unscripted prompts are dismissed. + pub fn answer_next_prompt(&self, answer: Option<&[&str]>) { + let answer = answer.map(|a| a.iter().map(|&s| s.to_owned()).collect()); + self.state.borrow_mut().answers.push_back(answer); + } + + /// Views brought forward with [`crate::host::reveal_view`], in order. + pub fn revealed_views(&self) -> Vec { + self.state.borrow().revealed_views.clone() + } + /// Makes `vcs-fetch` fail. pub fn fail_fetch(&self, error: Option) { self.state.borrow_mut().fetch_error = error; @@ -416,6 +431,36 @@ pub mod current { }) } + pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { + let keys = pick_many(title, items)?; + match keys { + Some(keys) if keys.len() > 1 => Err(Error::Invalid("more than one item chosen".into())), + keys => Ok(keys.and_then(|keys| keys.into_iter().next())), + } + } + + pub fn pick_many(_title: &str, items: &[PickItem]) -> Result>, Error> { + let answer = with(|s| s.answers.pop_front().flatten()); + let Some(keys) = answer else { return Ok(None) }; + if let Some(unknown) = keys.iter().find(|k| !items.iter().any(|i| &i.key == *k)) { + return Err(Error::Invalid(format!("{unknown:?} is not one of the items"))); + } + Ok(Some(keys)) + } + + pub fn input(_prompt: &str, _value: Option<&str>) -> Result, Error> { + Ok(with(|s| s.answers.pop_front().flatten()).and_then(|a| a.into_iter().next())) + } + + pub fn confirm(_title: &str, _body: &str) -> Result { + Ok(with(|s| s.answers.pop_front().flatten()).is_some()) + } + + pub fn reveal_view(view: &str) -> Result<(), Error> { + with(|s| s.revealed_views.push(view.to_owned())); + Ok(()) + } + pub fn storage_get(key: &str) -> Result, Error> { with(|s| { require(s, "storage")?; @@ -779,6 +824,22 @@ mod tests { host::open_url("https://example.com/a").unwrap(); assert_eq!(mock.opened_urls(), ["https://example.com/a"]); assert!(matches!(host::open_url("file:///etc/passwd"), Err(Error::Invalid(_)))); + let items = [PickItem::new("a", "A"), PickItem::new("b", "B").description("second")]; + assert_eq!(host::pick("Pick", &items).unwrap(), None); + mock.answer_next_prompt(Some(&["b"])); + assert_eq!(host::pick("Pick", &items).unwrap().as_deref(), Some("b")); + mock.answer_next_prompt(Some(&["a", "b"])); + assert_eq!(host::pick_many("Pick", &items).unwrap(), Some(vec!["a".to_owned(), "b".to_owned()])); + mock.answer_next_prompt(Some(&["zzz"])); + assert!(matches!(host::pick_many("Pick", &items), Err(Error::Invalid(_)))); + mock.answer_next_prompt(Some(&["x.txt"])); + assert_eq!(host::input("Name", Some("a.txt")).unwrap().as_deref(), Some("x.txt")); + mock.answer_next_prompt(Some(&[])); + assert!(host::confirm("Sure?", "Really").unwrap()); + mock.answer_next_prompt(None); + assert!(!host::confirm("Sure?", "Really").unwrap()); + host::reveal_view("panel").unwrap(); + assert_eq!(mock.revealed_views(), ["panel"]); { let task = host::begin_task("Work", true); task.report(1, Some(2), None); diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs index 03a41256f..4e0eb1140 100644 --- a/sdk/rust/openidea-plugin/src/types.rs +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -143,6 +143,26 @@ pub struct StatusItem { pub command: Option, } +/// An entry of [`crate::host::pick`] and [`crate::host::pick_many`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickItem { + /// Returned when the item is chosen. + pub key: String, + pub label: String, + pub description: Option, +} + +impl PickItem { + pub fn new(key: &str, label: &str) -> Self { + Self { key: key.to_owned(), label: label.to_owned(), description: None } + } + + pub fn description(mut self, description: &str) -> Self { + self.description = Some(description.to_owned()); + self + } +} + /// A context key's value, as `when` clauses compare it ([`crate::host::set_context`]). #[derive(Clone, Debug, PartialEq)] pub enum ContextValue { diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index ccf14224c..357a9f53e 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -17,6 +17,13 @@ interface ui { command: option, } + record pick-item { + /// Returned when the item is chosen. + key: string, + label: string, + description: option, + } + /// A context key's value, as `when` clauses compare it. variant context-value { flag(bool), @@ -44,6 +51,20 @@ interface ui { copy-to-clipboard: func(text: string) -> result<_, error>; /// Opens an `http://` or `https://` URL in the user's default browser. open-url: func(url: string) -> result<_, error>; + /// Shows a list in the window the current command or event came from and + /// waits for the key of the chosen item, or `none` when the user dismisses + /// it. Needs no capability. Returns `invalid` outside a command or event of + /// a window (including in editor providers). Waiting doesn't count against + /// the call's budget. + pick: func(title: string, items: list) -> result, error>; + /// Like `pick`, with any number of items chosen. + pick-many: func(title: string, items: list) -> result>, error>; + /// Asks for a line of text, pre-filled with `value`; `none` when dismissed. + /// Same rules as `pick`. + input: func(prompt: string, value: option) -> result, error>; + /// Asks for confirmation with OK and Cancel: `true` on OK, `false` on + /// Cancel or Escape. Same rules as `pick`. + confirm: func(title: string, body: string) -> result; } /// Persistent key-value storage of the plugin (survives restarts and idle diff --git a/sdk/rust/openidea-plugin/wit/views.wit b/sdk/rust/openidea-plugin/wit/views.wit index 74fe9b505..945b600f9 100644 --- a/sdk/rust/openidea-plugin/wit/views.wit +++ b/sdk/rust/openidea-plugin/wit/views.wit @@ -127,6 +127,9 @@ interface views { /// The view (or every custom editor of that id) shows outdated content: /// the IDE renders it again. refresh: func(view: string); + /// Brings one of the plugin's own views to the front in the window the + /// current command or event came from; `invalid` outside one. + reveal: func(view: string) -> result<_, error>; /// Sends a message to the script of a view's `html` body. post-message: func(view: string, message: string) -> result<_, error>; /// Opens a terminal tab in the window the call came from, running diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e0d14aa8e..5fdae039e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -482,6 +482,7 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_status_items, plugin::plugin_context_keys, plugin::plugin_cancel_task, + plugin::plugin_answer_prompt, plugin::plugin_view_render, plugin::plugin_view_event, plugin::plugin_web_page, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 0b8e1ece0..d82e1a0b9 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -385,6 +385,14 @@ pub fn plugin_cancel_task(state: State<'_, AppState>, task_id: String) -> bool { state.plugins.cancel_task(&task_id) } +/// Reports the user's answer to a plugin's `promptRequested`: the chosen keys +/// (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed. +#[tauri::command] +#[specta::specta] +pub fn plugin_answer_prompt(state: State<'_, AppState>, prompt_id: String, answer: Option>) -> bool { + state.plugins.answer_prompt(&prompt_id, answer) +} + /// Gutter markers of plugin `line-markers` providers for an open document. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 8496a1418..9965fa679 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1034,6 +1034,11 @@ export const commands = { pluginContextKeys: () => __TAURI_INVOKE("plugin_context_keys"), /** Cancels a plugin task (`taskStarted` with `cancellable`). */ pluginCancelTask: (taskId: string) => __TAURI_INVOKE("plugin_cancel_task", { taskId }), + /** + * Reports the user's answer to a plugin's `promptRequested`: the chosen keys + * (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed. + */ + pluginAnswerPrompt: (promptId: string, answer: string[] | null) => __TAURI_INVOKE("plugin_answer_prompt", { promptId, answer }), /** * Content of a plugin view (`doc`: the document of a custom editor; `editor`: for * a tool window, the window's active editor, as commands get it). @@ -5661,6 +5666,13 @@ export type PhpSettings = { phpstanConfig?: string, }; +/** An entry of a plugin's `pick` or `pick-many`. */ +export type PickItem = { + key: string, + label: string, + description: string | null, +}; + /** A plugin's context key for `when` clauses (`value` is JSON text). */ export type PluginContextKey = { pluginId: string, @@ -5719,7 +5731,16 @@ export type PluginEvent = /** Copy `text` to the system clipboard. */ { type: "copyToClipboard"; pluginId: string; text: string } | /** Open `url` (`http(s)://`) in the system browser. */ -{ type: "openUrl"; pluginId: string; url: string }; +{ type: "openUrl"; pluginId: string; url: string } | +/** + * A command waits for the user's answer: show `prompt`, then report it with + * `plugin_answer_prompt(prompt_id, …)`. + */ +{ type: "promptRequested"; pluginId: string; promptId: string; prompt: Prompt } | +/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */ +{ type: "promptClosed"; promptId: string } | +/** Bring the tool window of the plugin's view `view` to the front. */ +{ type: "revealView"; pluginId: string; view: string }; /** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */ export type PluginIcon = { @@ -6056,6 +6077,9 @@ export type ProjectStructureSettings = { excludeFiles?: string, }; +/** What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`). */ +export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null } | { kind: "confirm"; title: string; message: string }; + export type ProxyKind = "http" | "socks"; export type ProxyMode = diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 3e4c05ee8..343c609f7 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -504,6 +504,81 @@ describe("bridge events", () => { }); }); +describe("plugin prompts", () => { + it("shows a plugin's question and reports the answer or the dismissal", async () => { + const user = userEvent.setup(); + const answerPrompt = vi.fn().mockResolvedValue(undefined); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt }); + render(); + + act(() => { + handlePluginEvent({ + type: "promptRequested", + pluginId: "p", + promptId: "1", + prompt: { + kind: "quickPick", + title: "Pick targets", + multiple: true, + items: [ + { key: "a", label: "Alpha", description: null }, + { key: "b", label: "Beta", description: "second" }, + ], + }, + }); + }); + await user.click(await screen.findByRole("checkbox", { name: "Beta" })); + await user.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("1", ["b"])); + + act(() => { + handlePluginEvent({ + type: "promptRequested", + pluginId: "p", + promptId: "2", + prompt: { kind: "input", prompt: "Name", value: "Main" }, + }); + }); + await user.click(await screen.findByRole("button", { name: "Cancel" })); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("2", null)); + }); + + it("returns the typed text untrimmed", async () => { + const user = userEvent.setup(); + const answerPrompt = vi.fn().mockResolvedValue(undefined); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt }); + render(); + act(() => { + handlePluginEvent({ + type: "promptRequested", + pluginId: "p", + promptId: "4", + prompt: { kind: "input", prompt: "Name", value: " Main " }, + }); + }); + await user.click(await screen.findByRole("button", { name: "OK" })); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("4", [" Main "])); + }); + + it("closes a question the plugin gave up on without answering", async () => { + const answerPrompt = vi.fn().mockResolvedValue(undefined); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt }); + render(); + act(() => { + handlePluginEvent({ + type: "promptRequested", + pluginId: "p", + promptId: "3", + prompt: { kind: "input", prompt: "Name", value: null }, + }); + }); + await screen.findByRole("dialog"); + act(() => handlePluginEvent({ type: "promptClosed", promptId: "3" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(answerPrompt).not.toHaveBeenCalled(); + }); +}); + describe("PluginsPage", () => { it("lists plugins, shows details, toggles and applies themes", async () => { const user = userEvent.setup(); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index d949e2068..62ceef6e6 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -64,6 +64,8 @@ export interface PluginsBackend { statusItems(): Promise; contextKeys(): Promise; cancelTask(taskId: string): Promise; + /** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */ + answerPrompt(promptId: string, answer: string[] | null): Promise; /** Marketplace plugins: the index read again (`refresh`) or the last one read. */ market(refresh: boolean): Promise; marketUrl(): Promise; @@ -164,6 +166,9 @@ export const tauriPluginsBackend: PluginsBackend = { cancelTask: async (taskId) => { await commands.pluginCancelTask(taskId); }, + answerPrompt: async (promptId, answer) => { + await commands.pluginAnswerPrompt(promptId, answer); + }, market: (refresh) => call(commands.pluginMarket(refresh)), marketUrl: () => commands.pluginMarketUrl(), setMarketUrl: async (url) => { @@ -505,6 +510,7 @@ export function createMemoryPluginsBackend( statusItems: async () => [], contextKeys: async () => [], cancelTask: async () => {}, + answerPrompt: async () => {}, market: async () => [], marketUrl: async () => marketUrl, setMarketUrl: async (url) => { diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index 73a4e7632..45ddd8a49 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -5,7 +5,7 @@ * batches. */ import { create } from "zustand"; -import type { EventKind, PluginContextKey, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings"; +import type { EventKind, PluginContextKey, PluginEvent, Prompt, StatusItem, WorkbenchEvent } from "@/bindings"; import { onEditorEvent } from "@/editor/api"; import { executeAction, getAction, onActionExecuted } from "@/platform/actions"; import { clearContext, setContext, type ContextValue } from "@/platform/context"; @@ -14,10 +14,12 @@ import { openExternalUrl } from "@/platform/openUrl"; import { useProject } from "@/platform/project"; import { shortcutLabel } from "@/platform/keymap"; import { BarButton } from "@/ui/BarButton"; -import { maybeEditorService, registerStatusItem } from "@/workbench/contributions"; +import { maybeEditorService, registerStatusItem, toolWindowService } from "@/workbench/contributions"; +import { pickItems, promptInput } from "@/workbench/inputDialogs"; +import { confirm } from "@/workbench/messages"; import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; import { pluginsBackend } from "./backend"; -import { bumpView, deliverViewMessage } from "./views/viewStore"; +import { bumpView, deliverViewMessage, pluginToolWindowId } from "./views/viewStore"; import { runPluginCommand, usePlugins } from "./store"; // --- status bar items --------------------------------------------------------- @@ -292,6 +294,39 @@ function trackWorkbenchEvents(): () => void { }; } +const openPrompts = new Map(); + +/** The user's answer in the shape `plugin_answer_prompt` takes: `null` when dismissed. */ +async function askUser(prompt: Prompt, signal: AbortSignal): Promise { + switch (prompt.kind) { + case "quickPick": + return pickItems({ title: prompt.title, items: prompt.items, multiple: prompt.multiple, signal }); + case "input": { + const text = await promptInput({ + title: prompt.prompt, + initial: prompt.value ?? "", + validate: () => null, + keepWhitespace: true, + signal, + }); + return text === null ? null : [text]; + } + case "confirm": + return (await confirm({ title: prompt.title, message: prompt.message, ok: "OK", signal })) ? [] : null; + } +} + +async function answerPrompt(promptId: string, prompt: Prompt) { + const controller = new AbortController(); + openPrompts.set(promptId, controller); + const answer = await askUser(prompt, controller.signal); + openPrompts.delete(promptId); + if (controller.signal.aborted) return; + await pluginsBackend() + .answerPrompt(promptId, answer) + .catch(() => {}); +} + /** Handles the runtime UI events; returns whether `e` was one. */ export function handleRuntimeEvent(e: PluginEvent): boolean { switch (e.type) { @@ -328,6 +363,16 @@ export function handleRuntimeEvent(e: PluginEvent): boolean { case "openUrl": void openExternalUrl(e.url); return true; + case "promptRequested": + void answerPrompt(e.promptId, e.prompt); + return true; + case "promptClosed": + openPrompts.get(e.promptId)?.abort(); + openPrompts.delete(e.promptId); + return true; + case "revealView": + toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true); + return true; default: return false; } diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index d4139cf62..4a96c2ee0 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -46,6 +46,7 @@ import { usePlugins } from "./store"; import { pluginActionId } from "./points"; import { defineUiPoint } from "./projection"; import { declareStatusItem } from "./runtimeUi"; +import { pluginToolWindowId } from "./views/viewStore"; import { CUSTOM_EDITOR_KIND, CustomEditor, @@ -307,8 +308,6 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void { }; } -export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`; - function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void { return registerToolWindow({ id: pluginToolWindowId(pluginId, decl.id), diff --git a/src/features/plugins/views/viewStore.ts b/src/features/plugins/views/viewStore.ts index e8573d521..433f636d7 100644 --- a/src/features/plugins/views/viewStore.ts +++ b/src/features/plugins/views/viewStore.ts @@ -7,6 +7,9 @@ import { create } from "zustand"; export const viewKey = (pluginId: string, view: string) => `${pluginId}/${view}`; +/** The tool window a plugin's declared view is shown in. */ +export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`; + export const useViewRevisions = create<{ revisions: Record }>(() => ({ revisions: {} })); /** The plugin's view content changed: rendered views fetch it again. */ diff --git a/src/workbench/inputDialogs.tsx b/src/workbench/inputDialogs.tsx index 6b5b311fa..ae03df847 100644 --- a/src/workbench/inputDialogs.tsx +++ b/src/workbench/inputDialogs.tsx @@ -10,7 +10,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { create } from "zustand"; import { t } from "@/platform/i18n"; -import { Dialog, Input, List, T } from "@/ui"; +import { Checkbox, Dialog, Input, List, T } from "@/ui"; import styles from "./inputDialogs.module.css"; export interface PromptInputOptions { @@ -27,6 +27,10 @@ export interface PromptInputOptions { error?: string; /** Extra content under the field (hints). */ hint?: ReactNode; + /** Closes the dialog as cancelled when aborted. */ + signal?: AbortSignal; + /** Resolve with the text as typed instead of trimmed. */ + keepWhitespace?: boolean; } export interface ChooseItemOptions { @@ -36,32 +40,53 @@ export interface ChooseItemOptions { empty?: string; } +export interface PickItemsOptions { + title: string; + items: readonly { key: string; label: string; description?: string | null }[]; + multiple: boolean; + /** Closes the dialog as cancelled when aborted. */ + signal?: AbortSignal; +} + type Request = | { kind: "prompt"; id: number; options: PromptInputOptions; resolve: (v: string | null) => void } - | { kind: "choose"; id: number; options: ChooseItemOptions; resolve: (v: string | null) => void }; + | { kind: "choose"; id: number; options: ChooseItemOptions; resolve: (v: string | null) => void } + | { kind: "pick"; id: number; options: PickItemsOptions; resolve: (v: string[] | null) => void }; const useInputDialogs = create<{ stack: Request[] }>(() => ({ stack: [] })); let seq = 0; -function open(make: (id: number, finish: (v: string | null) => void) => Request): Promise { +function open( + make: (id: number, finish: (v: T | null) => void) => Request, + signal?: AbortSignal, +): Promise { return new Promise((resolve) => { + if (signal?.aborted) return resolve(null); const id = ++seq; - const finish = (v: string | null) => { + const finish = (v: T | null) => { + signal?.removeEventListener("abort", cancel); useInputDialogs.setState((s) => ({ stack: s.stack.filter((r) => r.id !== id) })); resolve(v); }; - useInputDialogs.setState((s) => ({ stack: [...s.stack, make(id, finish)] })); + const cancel = () => finish(null); + signal?.addEventListener("abort", cancel); + useInputDialogs.setState((s) => ({ stack: [...s.stack, make(id, finish as never)] })); }); } /** A text field dialog; resolves with the trimmed value, or null when cancelled. */ export function promptInput(options: PromptInputOptions): Promise { - return open((id, resolve) => ({ kind: "prompt", id, options, resolve })); + return open((id, resolve) => ({ kind: "prompt", id, options, resolve }), options.signal); } /** A list to pick one entry from; resolves with it, or null when cancelled. */ export function chooseItem(options: ChooseItemOptions): Promise { - return open((id, resolve) => ({ kind: "choose", id, options, resolve })); + return open((id, resolve) => ({ kind: "choose", id, options, resolve })); +} + +/** A list to pick one or more entries from; resolves with their keys, or null when cancelled. */ +export function pickItems(options: PickItemsOptions): Promise { + return open((id, resolve) => ({ kind: "pick", id, options, resolve }), options.signal); } export function InputDialogsHost() { @@ -71,8 +96,10 @@ export function InputDialogsHost() { {stack.map((r) => r.kind === "prompt" ? ( - ) : ( + ) : r.kind === "choose" ? ( + ) : ( + ), )} @@ -97,15 +124,16 @@ function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v const submit = async () => { setTouched(true); if (localError || validation.current) return; - if (!options.submit) return done(value.trim()); + const result = options.keepWhitespace ? value : value.trim(); + if (!options.submit) return done(result); const controller = new AbortController(); validation.current = controller; setPending(true); try { - const message = await options.submit(value.trim(), controller.signal); + const message = await options.submit(result, controller.signal); if (controller.signal.aborted) return; setServerError(message); - if (!message) done(value.trim()); + if (!message) done(result); } catch (e) { if (!controller.signal.aborted) setServerError(e instanceof Error ? e.message : String(e)); } finally { @@ -202,3 +230,68 @@ function ChooseDialog({ options, done }: { options: ChooseItemOptions; done: (v: ); } + +function PickDialog({ options, done }: { options: PickItemsOptions; done: (v: string[] | null) => void }) { + const [active, setActive] = useState(0); + const [checked, setChecked] = useState>(new Set()); + const toggle = (key: string) => + setChecked((prev) => { + const next = new Set(prev); + if (!next.delete(key)) next.add(key); + return next; + }); + const picked = options.multiple ? options.items.filter((i) => checked.has(i.key)).map((i) => i.key) : []; + const activeKey = options.items[active]?.key; + const confirm = () => { + if (options.multiple) return done(picked); + if (activeKey !== undefined) done([activeKey]); + }; + return ( + done(null)} + width={420} + buttons={[ + { + label: "OK", + role: "default", + disabled: options.multiple ? false : activeKey === undefined, + onClick: confirm, + }, + { label: t("common.cancel"), role: "cancel" }, + ]} + > +
+ item.key} + activeIndex={active} + onActiveChange={setActive} + onActivate={(item, _index, e) => { + if (!options.multiple) return done([item.key]); + if (e.type === "keydown") toggle(item.key); + }} + rowHeight={options.items.some((i) => i.description) ? 36 : 24} + className={styles.list} + aria-label={options.title} + empty={ +
+ Nothing to choose from +
+ } + renderItem={(item, state) => ( +
+ {options.multiple ? ( + toggle(item.key)} label={item.label} /> + ) : ( + item.label + )} + {item.description &&
{item.description}
} +
+ )} + /> +
+
+ ); +} diff --git a/src/workbench/messages.tsx b/src/workbench/messages.tsx index 6ccf89f96..c4e0e22f2 100644 --- a/src/workbench/messages.tsx +++ b/src/workbench/messages.tsx @@ -81,6 +81,8 @@ export interface ConfirmOptions { checkbox?: string; /** Initial state of `checkbox`. */ checked?: boolean; + /** Closes the dialog as cancelled when aborted. */ + signal?: AbortSignal; } /** OK / Cancel question; resolves `null` when cancelled, else the checkbox state. */ @@ -92,6 +94,7 @@ export async function confirmChecked(opts: ConfirmOptions): Promise<{ checked: b checkbox: opts.checkbox, checked: opts.checked, buttons: [{ id: "ok", label: opts.ok, primary: true }], + signal: opts.signal, }); return r.button === "ok" ? { checked: r.checked } : null; } diff --git a/wit/host.wit b/wit/host.wit index ccf14224c..357a9f53e 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -17,6 +17,13 @@ interface ui { command: option, } + record pick-item { + /// Returned when the item is chosen. + key: string, + label: string, + description: option, + } + /// A context key's value, as `when` clauses compare it. variant context-value { flag(bool), @@ -44,6 +51,20 @@ interface ui { copy-to-clipboard: func(text: string) -> result<_, error>; /// Opens an `http://` or `https://` URL in the user's default browser. open-url: func(url: string) -> result<_, error>; + /// Shows a list in the window the current command or event came from and + /// waits for the key of the chosen item, or `none` when the user dismisses + /// it. Needs no capability. Returns `invalid` outside a command or event of + /// a window (including in editor providers). Waiting doesn't count against + /// the call's budget. + pick: func(title: string, items: list) -> result, error>; + /// Like `pick`, with any number of items chosen. + pick-many: func(title: string, items: list) -> result>, error>; + /// Asks for a line of text, pre-filled with `value`; `none` when dismissed. + /// Same rules as `pick`. + input: func(prompt: string, value: option) -> result, error>; + /// Asks for confirmation with OK and Cancel: `true` on OK, `false` on + /// Cancel or Escape. Same rules as `pick`. + confirm: func(title: string, body: string) -> result; } /// Persistent key-value storage of the plugin (survives restarts and idle diff --git a/wit/views.wit b/wit/views.wit index 74fe9b505..945b600f9 100644 --- a/wit/views.wit +++ b/wit/views.wit @@ -127,6 +127,9 @@ interface views { /// The view (or every custom editor of that id) shows outdated content: /// the IDE renders it again. refresh: func(view: string); + /// Brings one of the plugin's own views to the front in the window the + /// current command or event came from; `invalid` outside one. + reveal: func(view: string) -> result<_, error>; /// Sends a message to the script of a view's `html` body. post-message: func(view: string, message: string) -> result<_, error>; /// Opens a terminal tab in the window the call came from, running