mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
0b88599463
commit
9e6cb7b93e
70 files changed
+559
-82
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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\"")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
35df068fc8ad60d011ac0280618fd4db7afddbd799f9d5cca0dab6798e85a4a7
|
||||
5325c5c915e66d12cc47afc55b29e18e531a3f533e69c51933413f4f77a74438
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
01349e8fa8cba37979443c72aab247a49e85c8402ac67eae3e7e8d952b4715e7
|
||||
d23b9b067928d6e7648a4989b09e201cade971a67ab769b4d583a274f4e071ac
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
1abd2598304ddfca641d022de63709a5e4914b7968a57aad40c317137a59514d
|
||||
a49ab90f826a214d7de7058164f1fd886d9b762482c94953ee07495833742f92
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
97efbeb8209eed4449ca850e985acefac08b44bb1f13853b313b836e996583a3
|
||||
29f7f3bd8b35edf079fdac3c4cc67c5b5ffd57c999064a9ee55793f4f22b6c6b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
0f5e465f98d98f8ff129bd72b98109fb8246d45ff00104d48375168ed8fe2086
|
||||
a008cd053e851f86be07576b21c0a0a763cb8e239e7bba03127de0d7c5ab3a4b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
6e23467236adfdca5025184e8cad45df47bba736f010a46d13ca88dc0ac2e897
|
||||
a598e79d3e15b7ce4c5fef6b0af56d7d17e572bc1736806bbfa93349126dfb33
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
b0ee4c5d79f32b5420930ef767c983ee405157893ecdc340211b8777f8a02e44
|
||||
481910e4fe978ad5060cc70dd9d66dda029f4f73a0e8cd172b78df85dfa6dcd1
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
009452617b429df61dfaed6b1a96b0906342f3e0f0ccd8fb4b57ae74f3621b6f
|
||||
a667fe84ceda6c32f4e74e86b39c39f9c0c7654e07ff911a294dae44a3e15c23
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
1c55ec95537d31885c1f860a2c3a02ea4f840b4d97cd78977df5701a3a439c8f
|
||||
a59169bbd1e95017f28f92c9b8796059f0804c9361f1ec39d9ff2ec3fc7ca3ab
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
8d0eff09f96dc06f83af99d768e1c13d189b9b20cf481933c4f0bc6c47d5081a
|
||||
fc5e1ed12c0238b7d9bc0621e41beb89bd62191fe0f30b8c66554207cde9cdf7
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
6a3d219b86eb112ee16aa846f97a17df4f3270ddfa90b5aa7ebe073b3a426d35
|
||||
343c6d46b9bf07f8631f16dbc737301a02a6b229b17eb0a455207509823680e1
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user