mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
52eed40fc8
commit
abdbc39d0d
43 files changed
+880
-42
No files matched your search
@@ -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());
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -1 +1 @@
|
||||
b143041299679ec90d16a35c6eaae4c2525648bc33f06c40d72fba7f63fb1ab2
|
||||
fd5d8c0b9c6828bbeb0a93e394a0468abe83f051d1c20e50a7ac91fe12d5ab68
|
||||
+1
-1
@@ -1 +1 @@
|
||||
f87528deb3a497c841456b158d46f22fbc05909dfe4b886ed222c2e86dc824ba
|
||||
eb4d8c5d84dd2ea0171297f756cdf56bf72d3db88b8f4736bc2b3ab8346bd5eb
|
||||
+1
-1
@@ -1 +1 @@
|
||||
ec737f2490193fb319e28ec7d44f49cb64e6c75fdea67b44288899f7a6385e95
|
||||
5db8c9213f6bef54f92c39c994717c8dfd4623a46b615293fe10b7a2990014a9
|
||||
+1
-1
@@ -1 +1 @@
|
||||
130a82376feeb0a3879d110b4ec2417b0ae6839088b6a8ba42b101869c6248d8
|
||||
b95b4b5c2e4451e670d1b013ad03662469456de03fabdbd8a3c62eccd93fb0f0
|
||||
+1
-1
@@ -1 +1 @@
|
||||
9d7d6995e4f6eb96a669a11f9c75e2c77f2663eb905275346e77d20c6dd5d929
|
||||
6b800f268041ebb169117dfa31eead60315f51ebd92e409751f2670c92eac473
|
||||
+1
-1
@@ -1 +1 @@
|
||||
8225d25b08f9ec36b36a850d1a99a43643a3c06a3e9d6c835e75fb269eeac61a
|
||||
6a2c0c1f354b9c8defbcc9ec4a098fd956991dfb656ef2a4df92a219e60102d7
|
||||
@@ -1 +1 @@
|
||||
8de2b1598c2b9f8438a901c70197ba90ef6e85c3f510125e8bb72b002d25d7f6
|
||||
341466108d3a98c295a33d56c6f2a11736ced33dc0c4358d395cb376c24be663
|
||||
+1
-1
@@ -1 +1 @@
|
||||
e9cd442b032d0521e0b3c733a3a9866a203ddf5bacb381396f5a0a6c508de5ca
|
||||
b3c715c1d3489db1c9f6067f66aa31623ffd23ac6ad9d9fb9cc3b5d548088473
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3e4bd05682cf1b748ab7189b17b8937eaeb332641804da59ee684db9ad848579
|
||||
6e1490c35f7094221a856cf23ba2b6dab92bf8e8c130b156aad1a0ef68a52086
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3b2892a7629ed255add98ebd88e4a1fc454720ee9cae9bf4ffa74fa9b570adff
|
||||
ce28fb9dfa83dfc46bc6215f9517e2b339b34b40abb85fe12b9534fd63a76899
|
||||
+1
-1
@@ -1 +1 @@
|
||||
6183df09a561bc715ec353223b6ae76d0280c179191621e2d0ef809b7b3b2d32
|
||||
d2b50d6ee2fe2a0991b7a0006c4899930cfb884f32db231f99c3144ef7f7d0d0
|
||||
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user