From abdbc39d0dc14d3b97af0311c1787ffbacdacc08 Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:18:30 +0100 Subject: [PATCH] feat(plugins): let a command ask the user with a picker, an input box or a confirmation (#1779) Plugin API 1.2 adds `ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm` and `views.reveal`. The dialogs show in the window whose command or event asked and block that call until the user answers; the wait is added to the call's deadline, so it doesn't use up the time budget. They need no capability and return `invalid` without a window (events without a client, rendering, editor providers). Prompts are withdrawn when the call is cancelled or the plugin stops, and reach remote windows through the dispatcher (`plugin_answer_prompt`). `views.reveal` brings one of the plugin's declared views to the front. The Rust SDK and its mock gain `pick`, `pick_many`, `input`, `confirm` and `reveal_view`. All components are rebuilt for the WIT change. Closes #1768 Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/api.rs | 98 +++++++++++++- crates/ide-host/src/plugins/mod.rs | 12 ++ crates/ide-host/src/plugins/remote_host.rs | 12 +- crates/ide-host/src/plugins/types.rs | 14 +- crates/ide-host/tests/plugins.rs | 120 ++++++++++++++++++ crates/ide-plugin/src/runtime.rs | 27 +++- crates/ide-plugin/src/types.rs | 21 +++ crates/ide-plugin/src/wit_host.rs | 81 +++++++++++- .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/dist/plugin.wasm | Bin 136679 -> 144319 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../ide-plugin/tests/fixtures/jobs/src/lib.rs | 18 ++- .../misbehave/dist/plugin.wasm.sources | 2 +- plugins/README.md | 4 + .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/host.rs | 63 +++++++++ sdk/rust/openidea-plugin/src/mock.rs | 63 ++++++++- sdk/rust/openidea-plugin/src/types.rs | 20 +++ sdk/rust/openidea-plugin/wit/host.wit | 21 +++ sdk/rust/openidea-plugin/wit/views.wit | 3 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin.rs | 8 ++ src/bindings.ts | 26 +++- .../plugins/__tests__/plugins.test.tsx | 75 +++++++++++ src/features/plugins/backend.ts | 6 + src/features/plugins/runtimeUi.tsx | 51 +++++++- src/features/plugins/uiPoints.tsx | 3 +- src/features/plugins/views/viewStore.ts | 3 + src/workbench/inputDialogs.tsx | 115 +++++++++++++++-- src/workbench/messages.tsx | 3 + wit/host.wit | 21 +++ wit/views.wit | 3 + 43 files changed, 880 insertions(+), 42 deletions(-) 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 47daa6a003277f61df07001f249ca7e2661f2760..affe6ec5c2661d1cb380cbb80b1e543fa391b43e 100644 GIT binary patch delta 26542 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

#@$*AA#%nFz zwL*URUBP*_*n5<4X^;h|CTs#M0>X+fwLF7&xZ@_fqs=pQWMfl$k5%=koV>rOH}Z1l z{%-W5d~1J3Q)P1fsgN8BYawA zh4bpWkNJLB-VYX!_@*)ZP+ViMEcf$_A$)zNwB6xbyQOm0o5?@EkD|@e^+TuRx4A9f z_5`;!HpssxNBoe`LV5gtNy>3H_$N&8*7*N86Zt^S_~CDO@a_*6VpaS@lYF`O$KBxg zbqD&d=_q? z2!bEwTFdMO`TU`Y;@}K(?Lc} zbnSn~n5{W(Q`}a9vKoA~%`I@W#T}ZqT-uMr%rTzbD6CL&oxmvdT?MGf*{-eCO za*yVOO{(fF#~*EX*#oS9SrU26pneFBO&^me2-)RQrxhB6!iM^=bR|SG?A8aghLUfS z(t>{QX#IXiK=J6mH%qiG(F*(?3$7Ce!6Fd;k@B9Yh_2y&&&WSgc)xVx@0Zhi^5fs9 zQKV?%9}_aH6Cd7<05e<$!T#M1TWA|SxFZ8yq zl_=*pIpt3Nmy;NKm$#<|*$B>tWunvK+I>Kn|0GQzaKqjTZVM^ExMm znm)j#HUf)P+CH=Sj^>ZVkBv_bwc&bRfi40pP>#>B^JTEXEp^Ev%XouOx zOSy@=88R<+X0D}%aPi#4r==1CpEannPcKOSTooch0+ufKQir%7xqz=!!HE?5Uh_*Y zUEKaBF2uDJ;m=SkGkQQMjVf$l4_Gy9c+dyJCiH#gSRc(g?;zLU5*NFz(fSwsW)RFU zqH@GjI%L&D2SAVNUqGe%1kJG1`(g>sr5~wA=1`?<@r&lG=Wt_g8K%F=}m_TQD zu^~DGP(lfa>fzp&H#*j)fe}XC8H$1~x2d&ZS>5<%(|Asg zH>ah^;oe>#?7)U2(%*b8i6(HfU^3PW(!t62stMAHWa`*1LK!F&yGytT_tT)`&P1($ zC)1TF;Q?QCg!q&qLTf;tm_i+Sga=b7ozwLxl%INwVxNa^G`RylJ{66OXEsTtfz87J zYaM(st{(r=RLT^$)R^;9!7HRMrBV+8j{G~7TJw9$G)m*NSDM9<@-zxXtI>iutJP8S zV48)`_B3neC(Er2iS3R^ShSonotGMbgKDEGUMv$g*`alrB!a({_kTyr8B07X?Xu}$g@N$T*4cx{& zL3Dxl^8vr&A~L>u+V{(#_6)2%gLZK`G}9WaDwA&F_v4vRnn>HWqCK4Y&a&EfKZ~~W zdu(e<{QI@0zWiR(n#M(@oCG(geyEdZ8mUTXRCRV1P3GP&WzoBw&dRn%c`ln?;rGdH zEUcEdv9JoY#V5c}eo0%jzoW(swWE>zKBXP}1f=)3v--c=jyCZ7Jvr8*{UFCGKaoQ# zBd9dS@|E_~rJKbwzR}6o1XPj`r+mW5bS!<75W*)W8q6`P16|Dn-rj+}^;%E@4s&EL zN;ZGWp*XI>*lFf#xwwRkmp&aK+DNbHXie~*j&vixM|Yy`oDS+l8##^dOrr!;;H1t_ zsz`CU=317pCV6yC?%mAf0B^(a-2i8hj}<+q;MsVN%kvf(yFRXL@8@3`jb#`l%~~0PJYr&0@!lZWg(p>1MIvy6)6FOcjl( zqOiEJ7YHJH(9IE48e==Jhoz9;??LI2MSUvasi#GF43%n5?@8}7(&zTF#>WVY0ya1J zMy+BcVY`kfW`s~?m);f+uI+8{;G5o-kfrpYtxWTu`cOYHx5n()m+s~F4Si{gxVy$& zP(Y(%6)j-ZVX1LeB%gUZmHsqa7{o_4=5T{LwVcmf0;PEGXI(l*4p9KM_Gq)zpu8yi zut{H7W9~7io4p_5U?O)^MBS)g`ew`;`VvmM5)#S9Rcc`BpZk)8k`G6BvSNUf@_RnCRl zhp?Mt2Od>474Nu+(J=nVZlkp=QWwDxavD+`umZ}yS~mh_nb{Rk?HdV9iD#DAZ%9GoBtX_FEfV|2Sf9l zHRi;@)GoRqRm>HGX`omv%>#oeRcu;f`i9Vbw8C6Ggsw(%dQCE8*eMMs5Tyy@#^rru*TBZR`%2(Bzqc4FY zR9kDlatT)Pv$baIrIag{FE$GftdHa>9~RlyiYDs;`O+B?iF;CcatR&kDDJ}L7#Zn zhTm#O(TmCOld$btVJ@9SJ)9f|G?&fB=VRv7t}~yQ z4psN-Y|>Nq-=}&Zzy|dbK!4o&hC!I1KU;VCWctP7z0)H0qjeWefs5r`9Dc37j>fsX zYwcI_&`n?vaJ=}2DacX{b@60CYAkF?NjDMGwDKl+WgN<0DP*hWertO z^wWxY3_!0nm&~FSVM4b5yZPKT8!Wcwo%T-zH(TC9{YL#^F~p_U@qjSQWG|PKR)y25 z;QJ1^iw|9_+WDQDp+z2T8iu`=E2*~>t@-LLG$rQ15`6!{sIFe8`B%!izPHi=mzjGz zjj0RWMH!Cx(S?%=M~|^gJcT=2SGX<2EJ`Q%(O28=B%!Pl6}rxZn9 zP*hShy0U0YKz&wt0NyH#&gT!IPU}0oaAMJfLZf8TwT1Y=aYf}d{fa6JM-M8TII(a9 zKCoQ(=Uj^Q*ELy8S$S@J6jAfnngv^^OK_1#*DlmF?M2eG45Z=rNAVuycXPoON{{)$ zrE4X4g~`-9na^&aW#SUIdBIj1o|qA>Yi&@KfdBJ9w9t~yHS4z0Qqk9AUb2nK6OYB{ zS{llma|8V2HU&e?&$m%0`o(OrovvbV72Dy;H1nE{kLVhR*4U5WR^K$-Y`TN`MfFY6 z@$&&)>z`y^wSx*{HoLUKiito{qfyc3<{gw5Z~;vo$|CK(W#4; z?9CiMaZ*`%Wd&(bw>nbHl{@KQRUGvRz)l(NcziCK?_$9H#0Q8qfBkQ0U_!m|F3sQ6 zZVS7|X2}=t*8GwFQQW^#AAh{(v_8@C-bStPqqZ1s76B7`8kjh)w-M9h!8<;eV3aL1 z0{F=mD@P+f-q!$5lLpgi%Duur5yEm#h-cC%DD8}9TN zG5GVj_-B@-I>p1_$CzD+!8tYRs*y(BG?zx*HMb@@=4wtnJ8B+0J851#JF9%2D(|B5 z_#`RHyJ|i>yQ%W-T+%5Qd}kJOzLVz1v$J~Usb?2F>-o%r;q?WGmwAeLXcskeHA`%s zlx*(&m^#*V`2>DWda@I;rBNsK?5v)7>e)r)6F%Vmuiex(-THR8=5c#S)#VEx5jn;c zRcc;$Z=d~LVI4FN{U5l~4mafHmhT9)S*W&8G5Uw})Hh8?-tB@BR^AWAb zN4Xh|%AyLbNu;8yH7))~cvG|Qr__!T%#u&xcy%+M`jlEyGqd9#)ZC{fIwzHtlxWS( zpFX7?O~4mS9E?#(N77>C&&>knWqYViFe&Zw@}i2eDdnS!a>kUGO&V1;r4k=^ ^ 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 T|EPR#*+rAPs}H5HGk*UCnld|{ 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