feat(plugins): allow prompts while resolving code actions (#2025)

Closes #1945

Resolving a lazy code action or file action dropped the invoking window, so a plugin couldn't ask the user anything (input, pick, confirm) while building its edit. Resolution now runs with that window, so prompts work during resolution; previews and Actions on Save never prompt, cancellation keeps the text unchanged, and stale edits are rejected. WIT and SDK docs describe the new behaviour; the released API surface is unchanged.

Co-authored-by: arnaud-ritti <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-08 02:26:26 +08:00
1 parent 0b88599463
commit 9e6cb7b93e
70 files changed
+559 -82

No files matched your search

+3 -2
View File
@@ -561,7 +561,8 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
file_action: r.file,
};
let plugin = r.plugin.clone();
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
let invocation = ide_plugin::Invocation { client: Some(window.to_owned()), ..Default::default() };
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending, invocation)).await?;
let root = host.project(window)?.root.clone();
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
}
@@ -586,7 +587,7 @@ pub async fn lsp_code_action_query(
Ok(result)
}
/// Resolves a code action's edit without applying it (intention preview).
/// Resolves a code action's edit without applying it.
pub async fn lsp_resolve_code_action(
host: &Host,
window: &str,
+23 -11
View File
@@ -167,6 +167,7 @@ struct TaskEntry {
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
struct PromptEntry {
plugin: String,
client: String,
answer: Waiter,
}
@@ -286,10 +287,13 @@ impl Shared {
.collect()
}
/// Drops the context keys scoped to window `client` (it closed), without events, and
/// refuses further writes for it (late commands) until [`Self::reopen_client`].
/// Cancels prompts and drops context keys scoped to window `client` (it closed).
/// Refuses further writes for it (late commands) until [`Self::reopen_client`].
pub fn forget_client(&self, client: &str) {
self.closed_clients.lock().insert(client.to_owned());
let mut closed = self.closed_clients.lock();
closed.insert(client.to_owned());
self.prompts.lock().retain(|_, entry| entry.client != client);
drop(closed);
self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client));
}
@@ -643,14 +647,18 @@ impl PluginApi {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
if closed.contains(client) {
return Err(HostError::Cancelled);
}
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: Waiter::Commit(answer) });
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.id().to_owned(), client: client.to_owned(), answer: Waiter::Commit(answer) },
);
}
let request_id = id.to_string();
self.shared.sink.emit(
@@ -769,14 +777,18 @@ impl HostApi for PluginApi {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
if closed.contains(client) {
return Err(HostError::Cancelled);
}
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: Waiter::Prompt(answer) });
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.id().to_owned(), client: client.to_owned(), answer: Waiter::Prompt(answer) },
);
}
let prompt_id = id.to_string();
self.shared.sink.emit(
+2 -2
View File
@@ -1365,8 +1365,8 @@ impl PluginHost {
self.shared.reopen_client(label);
}
/// Window `label` closed: drops its context keys and queued events, and ignores
/// context writes of commands still running for it.
/// Window `label` closed: cancels its prompts, drops its context keys and queued
/// events, and ignores context writes of commands still running for it.
pub fn forget_client(&self, label: &str) {
self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label));
self.shared.forget_client(label);
+3 -2
View File
@@ -315,11 +315,12 @@ impl PluginHost {
plugin_id: &str,
doc: &ProviderDocument,
action: CodeAction,
invocation: ide_plugin::Invocation,
) -> ide_core::IdeResult<CodeAction> {
let d = Arc::new(doc.clone());
let timeout = self.limits.timeout;
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
rt.resolve_code_action(&d, &action, timeout)
self.code_call(plugin_id, invocation, "resolving the code action", move |rt, inv| {
rt.resolve_code_action(&d, &action, inv, timeout)
})
}
+201 -3
View File
@@ -1092,6 +1092,25 @@ fn untrusted_or_unconfirmed_project_plugins_never_run() {
assert!(f.host.reload("misbehave").is_err());
assert!(f.host.set_enabled("misbehave", true).is_err());
assert!(f.run("misbehave", "echo", None).is_err());
let doc = ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: "prompts".into(),
};
let action = ide_plugin::CodeAction {
title: "Ask input".into(),
kind: None,
preferred: false,
edits: Vec::new(),
operations: Vec::new(),
open: None,
data: Some("input".into()),
file_action: false,
};
let error = f.host.resolve_code_action("misbehave", &doc, action, f.inv()).unwrap_err();
assert!(error.message.contains("Restricted Mode"), "{}", error.message);
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
@@ -3206,10 +3225,10 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
let actions = f.host.provide_code_actions(&doc("lazy"), ide_core::Range::default());
let (plugin, action) = actions.into_iter().next().expect("offered");
assert!(action.edits.is_empty() && action.data.as_deref() == Some("fix"));
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone()).unwrap();
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone(), f.inv()).unwrap();
assert_eq!(resolved.edits[0].1[0].new_text, "fixed ");
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign, f.inv()).is_err());
// File operations come from the `file-actions` export, next to the providers' own.
let mut actions = f.host.provide_code_actions(&doc("files"), ide_core::Range::default()).into_iter();
@@ -3228,7 +3247,7 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
assert_eq!(action.open, Some(dir.join("created.txt")));
// Listed with `data` only: `resolve` on the same export computes its operations.
assert!(later.file_action && later.operations.is_empty() && later.data.is_some());
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later).unwrap();
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later, f.inv()).unwrap();
assert_eq!(
resolved.operations,
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
@@ -3248,6 +3267,185 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
}
fn interactive_actions_fixture() -> (Fixture, ide_plugin::ProviderDocument) {
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
let pkg = f._tmp.path().join("interactive-actions");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml"))
.unwrap()
.replace("required = [\"project.read\"]", "required = [\"project.read\", \"clipboard\"]");
std::fs::write(pkg.join("plugin.toml"), format!(
"{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"
)).unwrap();
f.install(&pkg);
f.host.run_command("misbehave", "echo", None, f.inv()).unwrap();
let doc = ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: "prompts".into(),
};
(f, doc)
}
fn action_prompt(f: &Fixture) -> (String, ide_plugin::Prompt) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some((client, id, prompt)) = f.take_events().into_iter().find_map(|(client, event)| match event {
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
_ => None,
}) {
assert_eq!(client.as_deref(), Some("editor-window"));
return (id, prompt);
}
assert!(Instant::now() < deadline, "code-action resolution never asked");
std::thread::sleep(Duration::from_millis(10));
}
}
fn action_invocation() -> Invocation {
Invocation { client: Some("editor-window".into()), ..Default::default() }
}
#[test]
fn code_action_resolution_prompts_in_the_editor_window_under_the_command_budget() {
use ide_plugin::Prompt;
let (f, doc) = interactive_actions_fixture();
let actions = f.host.provide_code_actions(&doc, ide_core::Range::default());
assert_eq!(actions.len(), 10);
for (kind, answer, expected) in [
("pick", vec!["b"], "b"),
("pick-many", vec!["a", "b"], "a,b"),
("input", vec!["Service😀"], "Service😀"),
("confirm", vec![], "confirmed"),
] {
let action = actions.iter().find(|(_, a)| a.data.as_deref() == Some(kind)).unwrap().1.clone();
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, prompt) = action_prompt(&f);
assert!(match kind {
"pick" => matches!(prompt, Prompt::QuickPick { multiple: false, .. }),
"pick-many" => matches!(prompt, Prompt::QuickPick { multiple: true, .. }),
"input" => matches!(prompt, Prompt::Input { multiline: false, .. }),
_ => matches!(prompt, Prompt::Confirm { .. }),
});
// More than the command budget; waiting for the user is excluded.
std::thread::sleep(Duration::from_millis(650));
assert!(f.host.answer_prompt(&id, Some(answer.iter().map(|s| (*s).into()).collect())));
let resolved = running.join().unwrap().unwrap();
assert_eq!(resolved.edits[0].1[0].new_text, expected);
});
}
for kind in ["input-multiline", "reveal", "open-url", "clipboard", "pick-directory", "open-project"] {
let action = actions.iter().find(|(_, a)| a.data.as_deref() == Some(kind)).unwrap().1.clone();
let err = std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let mut prompted = false;
while !running.is_finished() {
for (_, event) in f.take_events() {
if let PluginEvent::PromptRequested { prompt_id, .. } = event {
prompted = true;
f.host.answer_prompt(&prompt_id, None);
}
}
std::thread::sleep(Duration::from_millis(10));
}
let result = running.join().unwrap();
assert!(!prompted, "{kind} must remain restricted to commands and events");
result.unwrap_err()
});
assert_eq!(err.kind, ErrorKind::InvalidInput, "{kind}: {}", err.message);
}
let doc = ide_plugin::ProviderDocument { text: "prompt-provider".into(), ..doc };
assert!(eventually(|| !f.host.provide_completion(&doc, ide_core::Pos::new(0, 0), None).is_empty()));
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
}
#[test]
fn code_action_prompts_dismiss_cancel_and_close_without_edits_or_hanging() {
for (how, kind) in [
("dismiss", "pick"),
("dismiss", "pick-many"),
("dismiss", "input"),
("dismiss", "confirm"),
("disable", "pick"),
("close", "pick"),
("close-reopen", "pick"),
("no-window", "pick"),
] {
let (f, doc) = interactive_actions_fixture();
let action = f
.host
.provide_code_actions(&doc, ide_core::Range::default())
.into_iter()
.find(|(_, action)| action.data.as_deref() == Some(kind))
.unwrap()
.1;
if how == "no-window" {
assert!(f.host.resolve_code_action("misbehave", &doc, action, Invocation::default()).is_err());
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
continue;
}
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
let started = Instant::now();
match how {
"dismiss" => {
assert!(f.host.answer_prompt(&id, None));
}
"disable" => {
f.host.set_enabled("misbehave", false).unwrap();
}
"close" => f.host.forget_client("editor-window"),
"close-reopen" => {
f.host.forget_client("editor-window");
f.host.reopen_client("editor-window");
}
_ => unreachable!(),
}
assert!(running.join().unwrap().is_err());
assert!(started.elapsed() < Duration::from_secs(5));
assert!(!f.host.answer_prompt(&id, Some(vec!["late".into()])));
});
assert!(!f.project.join("a.txt").exists(), "resolution must only return edits");
if how == "close-reopen" {
let action = f.host.provide_code_actions(&doc, ide_core::Range::default()).remove(0).1;
std::thread::scope(|scope| {
let running =
scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
assert!(f.host.answer_prompt(&id, Some(vec!["b".into()])));
assert_eq!(running.join().unwrap().unwrap().edits[0].1[0].new_text, "b");
});
}
}
}
#[test]
fn file_action_resolution_can_ask_before_returning_resource_operations() {
let (f, doc) = interactive_actions_fixture();
let doc = ide_plugin::ProviderDocument { text: "prompt-files".into(), ..doc };
let action = f.host.provide_code_actions(&doc, ide_core::Range::default()).remove(0).1;
assert!(action.file_action && action.operations.is_empty());
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
assert!(f.host.answer_prompt(&id, Some(vec!["Service".into()])));
let resolved = running.join().unwrap().unwrap();
assert_eq!(
resolved.operations,
vec![ide_plugin::FileOperation::Create {
path: f.project.join("Service.txt"),
text: "Service".into(),
overwrite: false,
}]
);
assert_eq!(resolved.open, Some(f.project.join("Service.txt")));
assert!(!f.project.join("Service.txt").exists(), "resolution does not apply the edit");
});
}
/// `hello` as a services view, which needs API 1.3.
fn services_variant(text: &str) -> String {
text.replace("location = \"right\"", "location = \"services\"")
+14 -2
View File
@@ -453,8 +453,10 @@ 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.
/// The running call is a command or an event.
pub(crate) interactive: bool,
/// Editor window for pick, pick-many, input and confirm during action resolution.
pub(crate) action_prompt_client: Option<String>,
/// Tasks begun by the running call and not ended yet.
pub(crate) open_tasks: Vec<u64>,
pub(crate) last_report: Option<Instant>,
@@ -643,6 +645,7 @@ impl Runtime {
host: setup.host,
invocation: Invocation::default(),
interactive: false,
action_prompt_client: None,
open_tasks: Vec::new(),
last_report: None,
};
@@ -944,6 +947,7 @@ impl Runtime {
&mut self,
doc: &ProviderDocument,
action: &CodeAction,
invocation: Invocation,
timeout: Duration,
) -> Result<CodeAction, CallError> {
if action.file_action {
@@ -953,13 +957,20 @@ impl Runtime {
let d = file_action_doc(doc);
let a = file_action_to_wit(action);
let resolved = self.call(Invocation::default(), timeout, |e, store| {
store.data_mut().action_prompt_client = invocation.client;
e.file_actions.as_ref().expect("checked").call_resolve(store, &d, &a)
})?;
return resolved.map(file_action_from_wit).map_err(|e| CallError::Guest(e.into()));
}
let d = doc_to_wit(doc);
let a = code_action_to_wit(action);
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
if self.exports.providers.is_none() {
return Err(CallError::missing_export("providers"));
}
let resolved = self.call(Invocation::default(), timeout, |e, store| {
store.data_mut().action_prompt_client = invocation.client;
e.providers.as_ref().expect("checked").call_resolve_code_action(store, &d, &a)
})?;
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
}
@@ -1281,6 +1292,7 @@ impl Runtime {
let data = self.store.data_mut();
data.invocation = Invocation::default();
data.interactive = false;
data.action_prompt_client = None;
// Tasks don't outlive the call that began them.
for id in std::mem::take(&mut data.open_tasks) {
data.host.task_end(id);
+11 -3
View File
@@ -246,8 +246,16 @@ 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 client = match (&prompt, &self.action_prompt_client) {
(
Prompt::QuickPick { .. } | Prompt::Input { multiline: false, .. } | Prompt::Confirm { .. },
Some(client),
) => Some(client.clone()),
_ => {
self.require_interactive()?;
self.invocation.client.clone()
}
};
let started = Instant::now();
let answer = self.host.ask(client.as_deref(), prompt, &self.cancel.token());
let waited = started.elapsed();
@@ -279,7 +287,7 @@ impl StoreData {
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.
/// APIs reserved for commands and events exclude code-action resolution.
fn require_interactive(&self) -> Result<(), wit::Error> {
if self.interactive {
return Ok(());
Binary file not shown.
@@ -1 +1 @@
95de177eb6e9db39e0a9d5acb86ac1b0f19d91c954590135748774ba83638b53
4a49382f47d12b2921869044e0da4f25ece0ec6e7dea048e25f37f55e1345d75
Binary file not shown.
@@ -1 +1 @@
0f9992726659db0400c43192a623ecf97977766e0d7282f99e5ba800fdf247c1
892b7ca523ef251eb626aece060acd54258afaf8204fe02713b9e57b5ca9a67e
Binary file not shown.
@@ -1 +1 @@
1dda19524ab7711b56b67f4b6ebcf62d3c29799aa8276ce7af188b5cabe956ad
6981c76c96e2e7ab086be65847010bb22cb012c259cdec075b2285253c73f637
Binary file not shown.
@@ -1 +1 @@
7a77d431efd04f0fd5b864cd30216e655b67f126d8a4dd4506dcf805a3454076
07c6f0ce91539ad93b8bbd4535f4288cce1334f6c44e4820268663688623e104
Binary file not shown.
@@ -1 +1 @@
5cf52999f65c757d0eb793934c28ae6d9f453053514c2b4c8058d6e70384eb9a
00293224c09af5a08ea8862e4fd6263ea36f500750752b428f8e6ff61747a524
Binary file not shown.
@@ -1 +1 @@
07839dc01f3d528a81969703271d6c7ad1397b142bdc3aff68d7384249a15818
53f907d6f0415cf7fd09d09bf13aef1954339462eeb44e322a53ee44af9655c2
Binary file not shown.
@@ -1 +1 @@
7ef04eaa3f6d506291c89877de26a11b6a27df900c223ce53bf1749523839ff6
ad8fb651c09a49be8254bfe5fbaf7463f80e23ec712038e78141e3e32abb93eb
Binary file not shown.
@@ -1 +1 @@
6d3880d15cbc1e1f04364514e7cb61fc6d300b177c074b7b079cebc28b404a1e
7fde1c70e9e17693144c7229e53dac200f18c281e0bc83fffc21f3d18671d1be
Binary file not shown.
@@ -1 +1 @@
95835b883dec1cf9b053e455533ed0329b6fb1b9270832865b7d8fd05fbaff5a
47623924eee80c6d1aa9dcc22825ea70b92e1b1a41ee5741f20d4d20cdf49eb3
+100 -1
View File
@@ -158,9 +158,46 @@ impl syntax_provider::Guest for Misbehave {
}
}
fn action_answer(kind: &str) -> Result<String, types::Error> {
use openidea::plugin::{ui, views};
let items = vec![
ui::PickItem { key: "a".into(), label: "Alpha".into(), description: None },
ui::PickItem { key: "b".into(), label: "Beta".into(), description: None },
];
let answer = match kind {
"pick" => ui::pick("Choose translation file", &items)?,
"pick-many" => ui::pick_many("Choose services", &items)?.map(|keys| keys.join(",")),
"input" => ui::input("Service name", Some("Service"))?,
"confirm" => ui::confirm("Rewrite", "Apply this rewrite?")?.then(|| "confirmed".into()),
"input-multiline" => ui::input_multiline("Not allowed", None)?,
"reveal" => {
views::reveal("missing")?;
None
}
"open-url" => {
ui::open_url("https://example.com")?;
None
}
"clipboard" => {
ui::copy_to_clipboard("not allowed")?;
None
}
"pick-directory" => ui::pick_directory("Not allowed", None)?,
"open-project" => {
openidea::plugin::workspace::open_project("/not-allowed", false)?;
None
}
_ => return Err(types::Error::Invalid("unknown prompt".into())),
};
answer.ok_or(types::Error::Cancelled)
}
/// Completion: one item, labelled with the document's path, after [`behave`].
impl providers::Guest for Misbehave {
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
if doc.text == "prompt-provider" {
assert!(matches!(action_answer("input"), Err(types::Error::Invalid(_))));
}
behave(&doc.text);
// `rich`: a snippet that also adds an import.
let rich = doc.text == "rich";
@@ -189,6 +226,29 @@ impl providers::Guest for Misbehave {
/// `lazy`: one action whose edits come from `resolve-code-action`.
fn code_actions(doc: Document, _selection: types::Range) -> Vec<CodeAction> {
if doc.text == "prompts" {
return [
"pick",
"pick-many",
"input",
"confirm",
"input-multiline",
"reveal",
"open-url",
"clipboard",
"pick-directory",
"open-project",
]
.into_iter()
.map(|kind| CodeAction {
title: format!("Ask {kind}"),
kind: Some("quickfix".into()),
preferred: false,
edits: Vec::new(),
data: Some(kind.into()),
})
.collect();
}
if doc.text != "lazy" {
return Vec::new();
}
@@ -202,6 +262,19 @@ impl providers::Guest for Misbehave {
}
fn resolve_code_action(doc: Document, action: CodeAction) -> Result<CodeAction, types::Error> {
if doc.text == "prompts" {
let kind = action.data.as_deref().unwrap_or_default();
let answer = action_answer(kind)?;
// Longer than the 50 ms provider budget, within the command budget.
behave("sleep:80");
let start = types::Position { line: 0, character: 0 };
let edit = types::TextEdit { range: types::Range { start, end: start }, text: answer };
return Ok(CodeAction {
edits: vec![FileEdit { path: doc.path, edits: vec![edit] }],
data: None,
..action
});
}
if action.data.as_deref() != Some("fix") {
return Err(types::Error::Invalid("not one of mine".into()));
}
@@ -227,6 +300,17 @@ impl exports::openidea::plugin::file_actions::Guest for Misbehave {
_selection: types::Range,
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
if doc.text == "prompt-files" {
return vec![FileAction {
title: "Create a service".into(),
kind: Some("quickfix".into()),
preferred: false,
operations: Vec::new(),
edits: Vec::new(),
open: None,
data: Some("input".into()),
}];
}
if doc.text != "files" {
return Vec::new();
}
@@ -265,10 +349,25 @@ impl exports::openidea::plugin::file_actions::Guest for Misbehave {
}
fn resolve(
_doc: exports::openidea::plugin::file_actions::Document,
doc: exports::openidea::plugin::file_actions::Document,
action: exports::openidea::plugin::file_actions::FileAction,
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
if doc.text == "prompt-files" {
let name = action_answer("input")?;
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir);
let path = format!("{dir}/{name}.txt");
return Ok(exports::openidea::plugin::file_actions::FileAction {
operations: vec![FileOperation::Create(CreateFile {
path: path.clone(),
text: name,
overwrite: false,
})],
open: Some(path),
data: None,
..action
});
}
let dir = action.data.clone().ok_or_else(|| types::Error::Invalid("no data".into()))?;
let operations = vec![FileOperation::Create(CreateFile {
path: format!("{dir}/later.txt"),
Binary file not shown.
@@ -1 +1 @@
b6c96915f0b0668134bc1b197ad20545f85bd2cb0941a0e83a9e06b688c96434
174df83a18c8cc350df3a1fd7869fd832fd45a3f535d2795ad82814f8136454c
Binary file not shown.
@@ -1 +1 @@
9c0017584418a02395ddf532454ee7440fb044c4b4a77cdf0b554dbd0d84b52c
94c6ed6a516973e8ab90a0c550ea700f795eb61f1941c514c0d033db09321a87
Binary file not shown.
@@ -1 +1 @@
b90537a7745d8e3f8bb9fa0b8d5aa5e06465cf951ab25f5c4881efbeec46dd44
34a1df80de1db3a04363f81b8f3501cb59dc0aee9a0754912b84a34c76ea77d0
Binary file not shown.
@@ -1 +1 @@
6a548f486d0d2accf510e2fe2ea598b26be81f73a2a010c41c9316646d4422b2
b3537c1754f80177a3bf46150da3ebab4853d61b8cad1aad41da3fdeee5a7c61
+34
View File
@@ -217,3 +217,37 @@ test("diagnostics underline the range and ⌥⏎ applies the preferred quick fix
const [applied] = await ipcCalls(page, "lsp_execute_code_action");
expect(applied).toMatchObject({ action: { title: "Import 'com.acme.orders.model.Order'" } });
});
test("lazy plugin actions resolve only when invoked, never for their intention preview", async ({
workbench: page,
}) => {
const editor = await openSample(page);
await page.evaluate(() => {
const action = {
title: "Choose translation file",
kind: "quickfix",
isPreferred: true,
disabledReason: null,
edit: null,
command: null,
data: { plugin: "test.interactive-actions", data: "pick", file: false },
server: 0,
};
window.__ipc.handlers.lsp_code_actions = () => [action];
window.__ipc.handlers.lsp_resolve_code_action = () => ({ ...action, data: null, edit: { files: [] } });
window.__ipc.handlers.lsp_execute_code_action = () => [];
});
await editor.locator(".cm-line", { hasText: "val order: Order" }).click();
await page.keyboard.press("Alt+Enter");
const fix = page.getByRole("option", { name: "Choose translation file", exact: true });
await expect(fix).toBeVisible();
await fix.hover();
await expect(page.getByText("Preview isn't available for this action", { exact: true })).toBeVisible();
expect(await ipcCalls(page, "lsp_resolve_code_action")).toHaveLength(0);
expect(await ipcCalls(page, "lsp_execute_code_action")).toHaveLength(0);
await fix.click();
await expect.poll(() => ipcCalls(page, "lsp_resolve_code_action")).toHaveLength(1);
await expect.poll(() => ipcCalls(page, "lsp_execute_code_action")).toHaveLength(1);
const [applied] = await ipcCalls(page, "lsp_execute_code_action");
expect(applied).toMatchObject({ action: { title: "Choose translation file", edit: { files: [] } } });
});
+9 -4
View File
@@ -400,9 +400,9 @@ 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::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Picks key(s) from `PickItem`s, or `None` when dismissed. From a command/event or code-action resolution in its editor window (else `invalid`); waiting is excluded from the budget. |
| `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. |
| `host::input_multiline(prompt, value)` | `ui.input-multiline` | A multi-line text box pre-filled with `value`; `None` when dismissed; Cmd/Ctrl+Enter confirms. Same rules as `input`. |
| `host::input_multiline(prompt, value)` | `ui.input-multiline` | A multi-line text box pre-filled with `value`; `None` when dismissed; Cmd/Ctrl+Enter confirms. Only from a command or event of a window. |
| `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. |
| `host::locale()` | `ui.locale` | The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`); no capability. See _Translations_. |
| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
@@ -522,7 +522,12 @@ can be a `snippet` (`$1`, `${1:name}`, `$0`), match on `filter_text` instead
of its label, and bring `additional_edits` elsewhere in the document (an
import). A code action can leave `edits` empty and set `data`: its edits are
then computed by `resolve_code_action` when the user picks it, under the
command budget instead of the 50 ms. A quick fix that creates, renames or
command budget instead of the 50 ms. During resolution, `pick`, `pick_many`,
`input` and `confirm` ask in the editor's window; waiting for an answer does
not consume that budget. Dismissal returns `None` (or `false` for `confirm`):
return `Error::Cancelled` or no edits to abandon the fix. Lazy plugin actions
resolve only when invoked, never for the intention preview or Actions on Save. Commands/events-only
APIs remain unavailable during resolution. A quick fix that creates, renames or
deletes files implements `file_actions::FileActions` too
(`register_plugin!(T, providers, file_actions)`; the `code-actions` kind
covers both): a `FileAction` carries `operations` (`FileOperation::Create {
@@ -531,7 +536,7 @@ workspace edit, so a failure changes nothing and one undo reverses it. A path
outside the project root disables the action; `open` shows a file once the
action is applied. Like a code action, it can leave `operations` and `edits`
empty and set `data`: `resolve_file_action` computes them when the user picks
it. File actions are new in API 1.3. Problems (`publish_diagnostics`) can name
it, with the same prompts and editor window as `resolve_code_action`. File actions are new in API 1.3. Problems (`publish_diagnostics`) can name
the tool that found them (`source`) and be `unnecessary` (faded) or
`deprecated` (struck through).
Editor providers run under the edit-path budget: 50 ms, after which the
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
35df068fc8ad60d011ac0280618fd4db7afddbd799f9d5cca0dab6798e85a4a7
5325c5c915e66d12cc47afc55b29e18e531a3f533e69c51933413f4f77a74438
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
01349e8fa8cba37979443c72aab247a49e85c8402ac67eae3e7e8d952b4715e7
d23b9b067928d6e7648a4989b09e201cade971a67ab769b4d583a274f4e071ac
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1abd2598304ddfca641d022de63709a5e4914b7968a57aad40c317137a59514d
a49ab90f826a214d7de7058164f1fd886d9b762482c94953ee07495833742f92
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
97efbeb8209eed4449ca850e985acefac08b44bb1f13853b313b836e996583a3
29f7f3bd8b35edf079fdac3c4cc67c5b5ffd57c999064a9ee55793f4f22b6c6b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0f5e465f98d98f8ff129bd72b98109fb8246d45ff00104d48375168ed8fe2086
a008cd053e851f86be07576b21c0a0a763cb8e239e7bba03127de0d7c5ab3a4b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6e23467236adfdca5025184e8cad45df47bba736f010a46d13ca88dc0ac2e897
a598e79d3e15b7ce4c5fef6b0af56d7d17e572bc1736806bbfa93349126dfb33
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b0ee4c5d79f32b5420930ef767c983ee405157893ecdc340211b8777f8a02e44
481910e4fe978ad5060cc70dd9d66dda029f4f73a0e8cd172b78df85dfa6dcd1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
009452617b429df61dfaed6b1a96b0906342f3e0f0ccd8fb4b57ae74f3621b6f
a667fe84ceda6c32f4e74e86b39c39f9c0c7654e07ff911a294dae44a3e15c23
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1c55ec95537d31885c1f860a2c3a02ea4f840b4d97cd78977df5701a3a439c8f
a59169bbd1e95017f28f92c9b8796059f0804c9361f1ec39d9ff2ec3fc7ca3ab
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
8d0eff09f96dc06f83af99d768e1c13d189b9b20cf481933c4f0bc6c47d5081a
fc5e1ed12c0238b7d9bc0621e41beb89bd62191fe0f30b8c66554207cde9cdf7
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6a3d219b86eb112ee16aa846f97a17df4f3270ddfa90b5aa7ebe073b3a426d35
343c6d46b9bf07f8631f16dbc737301a02a6b229b17eb0a455207509823680e1
+2 -1
View File
@@ -52,7 +52,8 @@ pub trait FileActions {
/// The operations and edits of an action [`FileActions::file_actions`]
/// returned with `data`, when the user picks it (the command budget
/// applies, not 50 ms).
/// applies, not 50 ms). The same four prompts as
/// [`crate::providers::Providers::resolve_code_action`] work here.
fn resolve_file_action(&mut self, _doc: &Document, action: FileAction) -> Result<FileAction, crate::Error> {
Ok(action)
}
+8 -7
View File
@@ -797,11 +797,11 @@ 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.
/// Shows a list and waits for the chosen key, or `None` when dismissed.
/// Works in a command/event of a window, or while resolving a selected code
/// or file action in its editor's window; [`Error::Invalid`] otherwise.
/// Needs no capability. Waiting doesn't count against the call's budget,
/// but the plugin handles nothing else meanwhile.
pub fn pick(title: &str, items: &[PickItem]) -> Result<Option<String>, Error> {
imp::pick(title, items)
}
@@ -818,7 +818,8 @@ pub fn input(prompt: &str, value: Option<&str>) -> Result<Option<String>, Error>
}
/// Asks for text over several lines, pre-filled with `value`; `None` when
/// dismissed. Same rules as [`input`]; the dialog confirms on Cmd/Ctrl+Enter.
/// dismissed. Only from a command or event of a window; the dialog confirms
/// on Cmd/Ctrl+Enter.
pub fn input_multiline(prompt: &str, value: Option<&str>) -> Result<Option<String>, Error> {
imp::input_multiline(prompt, value)
}
@@ -949,7 +950,7 @@ pub mod storage {
pub use imp::{event as __event, range_from as __range_from, range_to as __range_to};
/// Chooses a directory on the UI machine. `None` when dismissed; no capability.
/// Same window/command/event rules as [`pick`].
/// Only from a command or event of a window; waiting is excluded from the budget.
pub fn pick_directory(title: &str, initial: Option<&str>) -> Result<Option<String>, Error> {
imp::pick_directory(title, initial)
}
@@ -171,6 +171,10 @@ pub trait Providers {
/// The edits of an action [`Providers::code_actions`] returned with
/// `data`, when the user picks it (the command budget applies, not 50 ms).
/// `host::pick`, `pick_many`, `input` and `confirm` ask in the editor's
/// window, excluding the wait from that budget. Dismissal returns `None`
/// (or `false` for `confirm`); return `Error::Cancelled` or no edits to stop.
/// Other command/event-only APIs remain unavailable.
fn resolve_code_action(&mut self, _doc: &Document, action: CodeAction) -> Result<CodeAction, crate::Error> {
Ok(action)
}
@@ -74,6 +74,7 @@ interface file-actions {
code-actions: func(doc: document, selection: range) -> list<file-action>;
/// The operations and edits of an action listed with `data` and nothing
/// to apply, once the user picks it (the command budget applies, not the
/// editor's 50 ms).
/// editor's 50 ms). The same four prompts as `providers.resolve-code-action`
/// work here, in the editor's window and excluding the wait from the budget.
resolve: func(doc: document, action: file-action) -> result<file-action, error>;
}
+7 -7
View File
@@ -65,14 +65,13 @@ 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.
/// Shows a list and waits for the chosen key, or `none` when dismissed.
/// Works in a command/event of a window, or while resolving a selected code
/// or file action in its editor's window; `invalid` otherwise.
/// Needs no capability. Waiting doesn't count against the call's budget.
pick: func(title: string, items: list<pick-item>) -> result<option<string>, error>;
/// Native folder chooser; an absolute path, or `none` when dismissed.
/// Same window, command/event and budget rules as `pick`; no capability.
/// Only from a command or event of a window; waiting is excluded from the budget.
pick-directory: func(title: string, initial: option<string>) -> 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>;
@@ -80,7 +79,8 @@ interface ui {
/// Same rules as `pick`.
input: func(prompt: string, value: option<string>) -> result<option<string>, error>;
/// Asks for text over several lines, pre-filled with `value`; `none` when
/// dismissed. Same rules as `input`; the dialog confirms on Cmd/Ctrl+Enter.
/// dismissed. Only from a command or event of a window; the dialog confirms
/// on Cmd/Ctrl+Enter.
input-multiline: 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`.
@@ -75,6 +75,9 @@ interface providers {
code-actions: func(doc: document, selection: range) -> list<code-action>;
/// The edits of an action `code-actions` returned with `data`, when the
/// user picks it (the command budget applies, not the editor's 50 ms).
/// `ui.pick`, `pick-many`, `input` and `confirm` ask in the editor's window;
/// waiting is excluded from the budget. Other command/event-only APIs
/// remain unavailable.
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
definition: func(doc: document, at: position) -> list<location>;
line-markers: func(doc: document) -> list<line-marker>;
+50 -1
View File
@@ -23,13 +23,20 @@ const resolved: CodeAction = {
data: null,
server: 1,
};
const lspCodeActions = vi.fn();
const lspApplyCodeAction = vi.fn();
const lspResolveCodeAction = vi.fn(async () => ({ status: "ok" as const, data: resolved }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } };
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, lspResolveCodeAction, lspCodeActions, lspApplyCodeAction },
};
});
const {
applyKindSilently,
bulbLine,
changedLines,
indentUnderBrace,
@@ -115,7 +122,49 @@ describe("inspection controls in ⌥↵ (issue #42)", () => {
);
});
describe("silent code actions", () => {
it("does not run lazy plugin actions that could prompt during Actions on Save", async () => {
const view = new EditorView({
state: EditorState.create({
doc: "f(1);",
extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })],
}),
});
views.push(view);
lspCodeActions.mockResolvedValueOnce({
status: "ok",
data: [{ ...resolved, kind: "source.fixAll", server: 0, edit: null }],
});
expect(await applyKindSilently(view, "source.fixAll")).toBe(false);
expect(lspApplyCodeAction).not.toHaveBeenCalled();
expect(lspResolveCodeAction).not.toHaveBeenCalled();
});
});
describe("intention preview", () => {
it("does not resolve lazy plugin actions while previewing", async () => {
const view = new EditorView({
state: EditorState.create({
doc: "f(1);",
extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })],
}),
});
views.push(view);
const cache = new Map<CodeAction, Promise<CodeAction>>();
const preview = await intentionPreview(
view,
{ ...resolved, server: 0, edit: null },
cache,
new AbortController().signal,
);
expect(preview).toEqual({ kind: "text", text: "Preview isn't available for this action" });
expect(lspResolveCodeAction).not.toHaveBeenCalled();
expect(cache.size).toBe(0);
expect(await intentionPreview(view, { ...resolved, server: 0 }, cache, new AbortController().signal)).toMatchObject(
{ kind: "diff" },
);
});
it("resolves the action once and shows its diff for this file", async () => {
const view = new EditorView({
state: EditorState.create({
+11 -1
View File
@@ -281,7 +281,9 @@ export async function applyKindSilently(view: EditorView, kind: string): Promise
if (!doc || doc.readOnly) return false;
let actions: CodeAction[];
try {
actions = (await fetchActions(view, [kind], true)).filter((a) => a.kind?.startsWith(kind) && !a.disabledReason);
actions = (await fetchActions(view, [kind], true)).filter(
(a) => a.kind?.startsWith(kind) && !a.disabledReason && !isLazyPluginAction(a),
);
} catch {
return false;
}
@@ -323,6 +325,10 @@ function actionItems(
}));
}
function isLazyPluginAction(action: CodeAction): boolean {
return action.server === 0 && !action.edit && !action.command;
}
/** The action with its edit (resolved once per popup; servers may compute edits lazily). */
function resolveOnce(
doc: number,
@@ -350,6 +356,10 @@ export async function intentionPreview(
): Promise<ListPreview | null> {
const ctx = docOf(view);
if (!ctx) return null;
// Lazy plugin actions can ask the user; resolve only after invocation.
if (isLazyPluginAction(action)) {
return { kind: "text", text: "Preview isn't available for this action" };
}
const doc = view.state.doc;
const full = await resolveOnce(ctx.doc, action, cache);
if (signal.aborted || view.state.doc !== doc) return null;
+36 -2
View File
@@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { CodeAction, CommandForm } from "@/bindings";
const { resolve, execute, cancel, openFile } = vi.hoisted(() => ({
const { resolve, resolveAction, execute, cancel, openFile } = vi.hoisted(() => ({
resolve: vi.fn(),
resolveAction: vi.fn(),
execute: vi.fn(),
cancel: vi.fn(async () => {}),
openFile: vi.fn(async () => {}),
@@ -13,7 +14,13 @@ vi.mock("@/platform/ipc", async (orig) => {
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, lspResolveCommand: resolve, lspExecuteCodeAction: execute, cancelRequest: cancel },
commands: {
...actual.commands,
lspResolveCommand: resolve,
lspResolveCodeAction: resolveAction,
lspExecuteCodeAction: execute,
cancelRequest: cancel,
},
};
});
vi.mock("@/workbench/contributions", async (orig) => ({
@@ -53,6 +60,33 @@ beforeEach(() => {
afterEach(cleanup);
describe("interactive code actions", () => {
it("waits for a lazy plugin action to finish prompting before applying its resolved edit", async () => {
let finish!: (value: ReturnType<typeof ok<CodeAction>>) => void;
resolveAction.mockImplementationOnce(
() =>
new Promise((r) => {
finish = r;
}),
);
const lazy: CodeAction = { ...action, server: 0, command: null, data: "input" };
const result = runInteractiveCodeAction(7, lazy);
await waitFor(() => expect(resolveAction).toHaveBeenCalledWith(7, lazy));
expect(execute).not.toHaveBeenCalled();
const full: CodeAction = { ...lazy, edit: { files: [] } };
await act(async () => finish(ok(full)));
expect(await result).toBe(true);
expect(execute.mock.calls[0]!.slice(1)).toEqual([7, full, []]);
expect(resolve).not.toHaveBeenCalled();
});
it("does not execute an action whose plugin prompt was dismissed", async () => {
resolveAction.mockResolvedValueOnce({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
await expect(runInteractiveCodeAction(7, { ...action, server: 0, command: null })).rejects.toMatchObject({
kind: "cancelled",
});
expect(execute).not.toHaveBeenCalled();
});
it("keeps server errors in the prompt, retries, and executes only validated answers", async () => {
resolve
.mockResolvedValueOnce(ok(form()))
+2 -1
View File
@@ -74,6 +74,7 @@ interface file-actions {
code-actions: func(doc: document, selection: range) -> list<file-action>;
/// The operations and edits of an action listed with `data` and nothing
/// to apply, once the user picks it (the command budget applies, not the
/// editor's 50 ms).
/// editor's 50 ms). The same four prompts as `providers.resolve-code-action`
/// work here, in the editor's window and excluding the wait from the budget.
resolve: func(doc: document, action: file-action) -> result<file-action, error>;
}
+7 -7
View File
@@ -65,14 +65,13 @@ 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.
/// Shows a list and waits for the chosen key, or `none` when dismissed.
/// Works in a command/event of a window, or while resolving a selected code
/// or file action in its editor's window; `invalid` otherwise.
/// Needs no capability. Waiting doesn't count against the call's budget.
pick: func(title: string, items: list<pick-item>) -> result<option<string>, error>;
/// Native folder chooser; an absolute path, or `none` when dismissed.
/// Same window, command/event and budget rules as `pick`; no capability.
/// Only from a command or event of a window; waiting is excluded from the budget.
pick-directory: func(title: string, initial: option<string>) -> 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>;
@@ -80,7 +79,8 @@ interface ui {
/// Same rules as `pick`.
input: func(prompt: string, value: option<string>) -> result<option<string>, error>;
/// Asks for text over several lines, pre-filled with `value`; `none` when
/// dismissed. Same rules as `input`; the dialog confirms on Cmd/Ctrl+Enter.
/// dismissed. Only from a command or event of a window; the dialog confirms
/// on Cmd/Ctrl+Enter.
input-multiline: 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`.
+3
View File
@@ -75,6 +75,9 @@ interface providers {
code-actions: func(doc: document, selection: range) -> list<code-action>;
/// The edits of an action `code-actions` returned with `data`, when the
/// user picks it (the command budget applies, not the editor's 50 ms).
/// `ui.pick`, `pick-many`, `input` and `confirm` ask in the editor's window;
/// waiting is excluded from the budget. Other command/event-only APIs
/// remain unavailable.
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
definition: func(doc: document, at: position) -> list<location>;
line-markers: func(doc: document) -> list<line-marker>;