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 <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-06 23:18:30 +08:00
1 parent 52eed40fc8
commit abdbc39d0d
43 files changed
+880 -42

No files matched your search

+96 -2
View File
@@ -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<Option<Vec<String>>>,
}
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
@@ -158,6 +164,10 @@ pub(super) struct Shared {
pub data_dir: PathBuf,
tasks: Mutex<HashMap<u64, TaskEntry>>,
next_task: AtomicU64,
prompts: Mutex<HashMap<u64, PromptEntry>>,
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
prompts_blocked: Mutex<BTreeSet<String>>,
next_prompt: AtomicU64,
/// (plugin, item id) → item, for windows opened later.
status: Mutex<BTreeMap<(String, String), StatusItem>>,
/// (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>, 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<u64> = {
let mut prompts = self.prompts.lock();
let ids: Vec<u64> = 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<Vec<String>>) -> 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<Option<Vec<String>>, 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<StatusItem>) {
let key = (self.id().to_owned(), id.to_owned());
{
+12
View File
@@ -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<Vec<String>>) -> 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<EventKind> {
@@ -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);
}
+10 -2
View File
@@ -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::<String>("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::<String>("promptId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
async move {
+13 -1
View File
@@ -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
+120
View File
@@ -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<Option<String>, 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());
+26 -1
View File
@@ -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<Option<Vec<String>>, 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<StatusItem>);
/// Sets (`Some`, validated JSON text) or removes the plugin's context key,
@@ -364,6 +382,8 @@ pub struct StoreData {
pub(crate) granted: Vec<Capability>,
pub(crate) host: Arc<dyn HostApi>,
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<u64>,
pub(crate) last_report: Option<Instant>,
@@ -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<Option<String>, 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<wit_events::Event> = 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);
+21
View File
@@ -68,6 +68,27 @@ pub struct StatusItem {
pub command: Option<String>,
}
/// 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<String>,
}
/// 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<PickItem>, multiple: bool },
#[serde(rename_all = "camelCase")]
Input { prompt: String, value: Option<String> },
#[serde(rename_all = "camelCase")]
Confirm { title: String, message: String },
}
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
+79 -2
View File
@@ -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<T> = wasmtime::Result<Result<T, wit::Error>>;
@@ -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<Option<Vec<String>>, 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<ui::PickItem>,
multiple: bool,
) -> Result<Option<Vec<String>>, 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<ui::PickItem>) -> Fallible<Option<String>> {
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<ui::PickItem>) -> Fallible<Option<Vec<String>>> {
Ok(self.pick_prompt(title, items, true))
}
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
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<bool> {
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"))));
@@ -1 +1 @@
406afd6a36f1cf2ff59da5826c155b84516daef6f1709fbd7f7618e0eaad852b
20ce53ecb41ad94e9c7d59cd4ac51f63081c9f150832fd7febe07887fa3c07ab
@@ -1 +1 @@
060b79a6fb25455d67dffd3623984f1d22836b908570a0537142f83d11cd5522
2e7d8932329d9383ecd4938225a111851f8a9805c6da558431dea84b7cecbe1c
Binary file not shown.
@@ -1 +1 @@
cadf7d5b651792b502e51587c43d49cc0f87477439291d93858ceaf3692b1879
7f58bace9b60d8b6b87c97e78dd8cca4955bd13d5f0737efe9d9a362cd1552bf
+17 -1
View File
@@ -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);
@@ -1 +1 @@
f9b1d2af61bac2bac97ad11b629ecaa062d53aeba730e3fcb686ecaa514bc1b6
ee5ba9209eaf963ccc423828ca2e8ba221345c9c246635283214562668a872ad
+4
View File
@@ -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:<program>` 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`. |
+1 -1
View File
@@ -1 +1 @@
b143041299679ec90d16a35c6eaae4c2525648bc33f06c40d72fba7f63fb1ab2
fd5d8c0b9c6828bbeb0a93e394a0468abe83f051d1c20e50a7ac91fe12d5ab68
+1 -1
View File
@@ -1 +1 @@
f87528deb3a497c841456b158d46f22fbc05909dfe4b886ed222c2e86dc824ba
eb4d8c5d84dd2ea0171297f756cdf56bf72d3db88b8f4736bc2b3ab8346bd5eb
+1 -1
View File
@@ -1 +1 @@
ec737f2490193fb319e28ec7d44f49cb64e6c75fdea67b44288899f7a6385e95
5db8c9213f6bef54f92c39c994717c8dfd4623a46b615293fe10b7a2990014a9
+1 -1
View File
@@ -1 +1 @@
130a82376feeb0a3879d110b4ec2417b0ae6839088b6a8ba42b101869c6248d8
b95b4b5c2e4451e670d1b013ad03662469456de03fabdbd8a3c62eccd93fb0f0
+1 -1
View File
@@ -1 +1 @@
9d7d6995e4f6eb96a669a11f9c75e2c77f2663eb905275346e77d20c6dd5d929
6b800f268041ebb169117dfa31eead60315f51ebd92e409751f2670c92eac473
+1 -1
View File
@@ -1 +1 @@
8225d25b08f9ec36b36a850d1a99a43643a3c06a3e9d6c835e75fb269eeac61a
6a2c0c1f354b9c8defbcc9ec4a098fd956991dfb656ef2a4df92a219e60102d7
+1 -1
View File
@@ -1 +1 @@
8de2b1598c2b9f8438a901c70197ba90ef6e85c3f510125e8bb72b002d25d7f6
341466108d3a98c295a33d56c6f2a11736ced33dc0c4358d395cb376c24be663
+1 -1
View File
@@ -1 +1 @@
e9cd442b032d0521e0b3c733a3a9866a203ddf5bacb381396f5a0a6c508de5ca
b3c715c1d3489db1c9f6067f66aa31623ffd23ac6ad9d9fb9cc3b5d548088473
+1 -1
View File
@@ -1 +1 @@
3e4bd05682cf1b748ab7189b17b8937eaeb332641804da59ee684db9ad848579
6e1490c35f7094221a856cf23ba2b6dab92bf8e8c130b156aad1a0ef68a52086
+1 -1
View File
@@ -1 +1 @@
3b2892a7629ed255add98ebd88e4a1fc454720ee9cae9bf4ffa74fa9b570adff
ce28fb9dfa83dfc46bc6215f9517e2b339b34b40abb85fe12b9534fd63a76899
+1 -1
View File
@@ -1 +1 @@
6183df09a561bc715ec353223b6ae76d0280c179191621e2d0ef809b7b3b2d32
d2b50d6ee2fe2a0991b7a0006c4899930cfb884f32db231f99c3144ef7f7d0d0
+63
View File
@@ -94,6 +94,37 @@ mod imp {
wit::ui::open_url(url).map_err(error)
}
fn pick_items(items: &[PickItem]) -> Vec<wit::ui::PickItem> {
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<Option<String>, Error> {
wit::ui::pick(title, &pick_items(items)).map_err(error)
}
pub fn pick_many(title: &str, items: &[PickItem]) -> Result<Option<Vec<String>>, Error> {
wit::ui::pick_many(title, &pick_items(items)).map_err(error)
}
pub fn input(prompt: &str, value: Option<&str>) -> Result<Option<String>, Error> {
wit::ui::input(prompt, value).map_err(error)
}
pub fn confirm(title: &str, body: &str) -> Result<bool, Error> {
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<Option<String>, 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<Option<String>, Error> {
imp::pick(title, items)
}
/// Like [`pick`], with any number of items chosen.
pub fn pick_many(title: &str, items: &[PickItem]) -> Result<Option<Vec<String>>, 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<Option<String>, 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<bool, Error> {
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`).
+62 -1
View File
@@ -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<Error>,
clipboard: Option<String>,
opened_urls: Vec<String>,
answers: VecDeque<Option<Vec<String>>>,
revealed_views: Vec<String>,
diagnostics: BTreeMap<String, Vec<Diagnostic>>,
secrets: BTreeMap<String, String>,
net: Option<NetHandler>,
@@ -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<String> {
self.state.borrow().revealed_views.clone()
}
/// Makes `vcs-fetch` fail.
pub fn fail_fetch(&self, error: Option<Error>) {
self.state.borrow_mut().fetch_error = error;
@@ -416,6 +431,36 @@ pub mod current {
})
}
pub fn pick(title: &str, items: &[PickItem]) -> Result<Option<String>, 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<Option<Vec<String>>, 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<Option<String>, Error> {
Ok(with(|s| s.answers.pop_front().flatten()).and_then(|a| a.into_iter().next()))
}
pub fn confirm(_title: &str, _body: &str) -> Result<bool, Error> {
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<Option<String>, 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);
+20
View File
@@ -143,6 +143,26 @@ pub struct StatusItem {
pub command: Option<String>,
}
/// 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<String>,
}
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 {
+21
View File
@@ -17,6 +17,13 @@ interface ui {
command: option<string>,
}
record pick-item {
/// Returned when the item is chosen.
key: string,
label: string,
description: option<string>,
}
/// 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<pick-item>) -> result<option<string>, error>;
/// Like `pick`, with any number of items chosen.
pick-many: func(title: string, items: list<pick-item>) -> result<option<list<string>>, error>;
/// Asks for a line of text, pre-filled with `value`; `none` when dismissed.
/// Same rules as `pick`.
input: func(prompt: string, value: option<string>) -> result<option<string>, 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<bool, error>;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
+3
View File
@@ -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
+1
View File
@@ -482,6 +482,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+8
View File
@@ -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<Vec<String>>) -> bool {
state.plugins.answer_prompt(&prompt_id, answer)
}
/// Gutter markers of plugin `line-markers` providers for an open document.
#[tauri::command]
#[specta::specta]
+25 -1
View File
@@ -1034,6 +1034,11 @@ export const commands = {
pluginContextKeys: () => __TAURI_INVOKE<PluginContextKey[]>("plugin_context_keys"),
/** Cancels a plugin task (`taskStarted` with `cancellable`). */
pluginCancelTask: (taskId: string) => __TAURI_INVOKE<boolean>("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<boolean>("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 =
@@ -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(<InputDialogsHost />);
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(<InputDialogsHost />);
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(<InputDialogsHost />);
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();
+6
View File
@@ -64,6 +64,8 @@ export interface PluginsBackend {
statusItems(): Promise<PluginStatusItem[]>;
contextKeys(): Promise<PluginContextKey[]>;
cancelTask(taskId: string): Promise<void>;
/** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */
answerPrompt(promptId: string, answer: string[] | null): Promise<void>;
/** Marketplace plugins: the index read again (`refresh`) or the last one read. */
market(refresh: boolean): Promise<MarketPlugin[]>;
marketUrl(): Promise<string | null>;
@@ -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) => {
+48 -3
View File
@@ -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<string, AbortController>();
/** The user's answer in the shape `plugin_answer_prompt` takes: `null` when dismissed. */
async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] | null> {
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;
}
+1 -2
View File
@@ -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),
+3
View File
@@ -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<string, number> }>(() => ({ revisions: {} }));
/** The plugin's view content changed: rendered views fetch it again. */
+104 -11
View File
@@ -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<string | null> {
function open<T>(
make: (id: number, finish: (v: T | null) => void) => Request,
signal?: AbortSignal,
): Promise<T | null> {
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<string | null> {
return open((id, resolve) => ({ kind: "prompt", id, options, resolve }));
return open<string>((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<string | null> {
return open((id, resolve) => ({ kind: "choose", id, options, resolve }));
return open<string>((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<string[] | null> {
return open<string[]>((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" ? (
<PromptDialog key={r.id} options={r.options} done={r.resolve} />
) : (
) : r.kind === "choose" ? (
<ChooseDialog key={r.id} options={r.options} done={r.resolve} />
) : (
<PickDialog key={r.id} options={r.options} done={r.resolve} />
),
)}
</>
@@ -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:
</Dialog>
);
}
function PickDialog({ options, done }: { options: PickItemsOptions; done: (v: string[] | null) => void }) {
const [active, setActive] = useState(0);
const [checked, setChecked] = useState<ReadonlySet<string>>(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 (
<Dialog
open
title={options.title}
onClose={() => done(null)}
width={420}
buttons={[
{
label: "OK",
role: "default",
disabled: options.multiple ? false : activeKey === undefined,
onClick: confirm,
},
{ label: t("common.cancel"), role: "cancel" },
]}
>
<div className={styles.form}>
<List
items={options.items}
getKey={(item) => 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={
<div className={styles.hint}>
<T>Nothing to choose from</T>
</div>
}
renderItem={(item, state) => (
<div className={state.active ? styles.activeRow : styles.row}>
{options.multiple ? (
<Checkbox checked={checked.has(item.key)} onChange={() => toggle(item.key)} label={item.label} />
) : (
item.label
)}
{item.description && <div className={styles.hint}>{item.description}</div>}
</div>
)}
/>
</div>
</Dialog>
);
}
+3
View File
@@ -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;
}
+21
View File
@@ -17,6 +17,13 @@ interface ui {
command: option<string>,
}
record pick-item {
/// Returned when the item is chosen.
key: string,
label: string,
description: option<string>,
}
/// 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<pick-item>) -> result<option<string>, error>;
/// Like `pick`, with any number of items chosen.
pick-many: func(title: string, items: list<pick-item>) -> result<option<list<string>>, error>;
/// Asks for a line of text, pre-filled with `value`; `none` when dismissed.
/// Same rules as `pick`.
input: func(prompt: string, value: option<string>) -> result<option<string>, 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<bool, error>;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
+3
View File
@@ -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