diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 6dc3dbc5b..c17c4c387 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -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, diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 6c8ba2dfc..4cf8fdbeb 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -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( diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index b02846219..8396d6d86 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -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); diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index 3a6697da5..ea7ceacd3 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -315,11 +315,12 @@ impl PluginHost { plugin_id: &str, doc: &ProviderDocument, action: CodeAction, + invocation: ide_plugin::Invocation, ) -> ide_core::IdeResult { 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) }) } diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 41ba72535..7d6157ae5 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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\"") diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 97557abfd..38496f0bf 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -453,8 +453,10 @@ pub struct StoreData { pub(crate) granted: Vec, pub(crate) host: Arc, pub(crate) invocation: Invocation, - /// The running call is a command or an event, so it may ask the user. + /// 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, /// Tasks begun by the running call and not ended yet. pub(crate) open_tasks: Vec, pub(crate) last_report: Option, @@ -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 { 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); diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 2d1da0fb6..607558473 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -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>, 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(()); diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 06b81cb17..49e137553 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 45bef8203..aabcea499 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -95de177eb6e9db39e0a9d5acb86ac1b0f19d91c954590135748774ba83638b53 +4a49382f47d12b2921869044e0da4f25ece0ec6e7dea048e25f37f55e1345d75 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm index 5e7760fe0..441cca97d 100644 Binary files a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index a25fb3e02..3bfe310f1 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -0f9992726659db0400c43192a623ecf97977766e0d7282f99e5ba800fdf247c1 +892b7ca523ef251eb626aece060acd54258afaf8204fe02713b9e57b5ca9a67e diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm index 55dd80d41..98c7aed3c 100644 Binary files a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources index 4957164bc..a89462c27 100644 --- a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -1 +1 @@ -1dda19524ab7711b56b67f4b6ebcf62d3c29799aa8276ce7af188b5cabe956ad +6981c76c96e2e7ab086be65847010bb22cb012c259cdec075b2285253c73f637 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index fa3faf4cf..9f94b36de 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 44b18a78c..49b2b081f 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -7a77d431efd04f0fd5b864cd30216e655b67f126d8a4dd4506dcf805a3454076 +07c6f0ce91539ad93b8bbd4535f4288cce1334f6c44e4820268663688623e104 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index 6a760fe02..901f8f4f9 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 70d501b2d..42b5b3577 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -5cf52999f65c757d0eb793934c28ae6d9f453053514c2b4c8058d6e70384eb9a +00293224c09af5a08ea8862e4fd6263ea36f500750752b428f8e6ff61747a524 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 9c353983a..5c3d36db5 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index f557e5034..a4d584396 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -07839dc01f3d528a81969703271d6c7ad1397b142bdc3aff68d7384249a15818 +53f907d6f0415cf7fd09d09bf13aef1954339462eeb44e322a53ee44af9655c2 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 1618e48b5..3ff02f704 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index 901180000..3290bbb05 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -7ef04eaa3f6d506291c89877de26a11b6a27df900c223ce53bf1749523839ff6 +ad8fb651c09a49be8254bfe5fbaf7463f80e23ec712038e78141e3e32abb93eb diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 4fda3080c..07cb72bd3 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index f163bf61a..e265a35ae 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -6d3880d15cbc1e1f04364514e7cb61fc6d300b177c074b7b079cebc28b404a1e +7fde1c70e9e17693144c7229e53dac200f18c281e0bc83fffc21f3d18671d1be diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index 72a0a88b3..d6136a572 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 52c1aa140..43a3ab2fd 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -95835b883dec1cf9b053e455533ed0329b6fb1b9270832865b7d8fd05fbaff5a +47623924eee80c6d1aa9dcc22825ea70b92e1b1a41ee5741f20d4d20cdf49eb3 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 647628cf1..843d83d4b 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -158,9 +158,46 @@ impl syntax_provider::Guest for Misbehave { } } +fn action_answer(kind: &str) -> Result { + 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) -> Vec { + 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 { + 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 { + 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 { 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 { 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"), diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm index ab5f37c09..40456de8d 100644 Binary files a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index 33e47a9f2..168763eef 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -b6c96915f0b0668134bc1b197ad20545f85bd2cb0941a0e83a9e06b688c96434 +174df83a18c8cc350df3a1fd7869fd832fd45a3f535d2795ad82814f8136454c diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index 346356a45..940be52a9 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 07ffd1a8b..155ac2426 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -9c0017584418a02395ddf532454ee7440fb044c4b4a77cdf0b554dbd0d84b52c +94c6ed6a516973e8ab90a0c550ea700f795eb61f1941c514c0d033db09321a87 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index bbc46d28e..d42b64cee 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 44bab9153..a8db144e6 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -b90537a7745d8e3f8bb9fa0b8d5aa5e06465cf951ab25f5c4881efbeec46dd44 +34a1df80de1db3a04363f81b8f3501cb59dc0aee9a0754912b84a34c76ea77d0 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 71460d4df..8ba901fb3 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 65c90e30d..81c493224 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -6a548f486d0d2accf510e2fe2ea598b26be81f73a2a010c41c9316646d4422b2 +b3537c1754f80177a3bf46150da3ebab4853d61b8cad1aad41da3fdeee5a7c61 diff --git a/e2e/codeInsight.spec.ts b/e2e/codeInsight.spec.ts index ed4e4c7f6..851297410 100644 --- a/e2e/codeInsight.spec.ts +++ b/e2e/codeInsight.spec.ts @@ -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: [] } } }); +}); diff --git a/plugins/README.md b/plugins/README.md index e96b57845..dcb3bb618 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 157cdf643..23c245083 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 43e452db6..d8d365a4b 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -35df068fc8ad60d011ac0280618fd4db7afddbd799f9d5cca0dab6798e85a4a7 +5325c5c915e66d12cc47afc55b29e18e531a3f533e69c51933413f4f77a74438 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 8d002969b..67ce3f892 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 3f6bbe5cc..644823b61 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -01349e8fa8cba37979443c72aab247a49e85c8402ac67eae3e7e8d952b4715e7 +d23b9b067928d6e7648a4989b09e201cade971a67ab769b4d583a274f4e071ac diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 3668c9b51..7fb1195c2 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index beed6c54f..e291976cd 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -1abd2598304ddfca641d022de63709a5e4914b7968a57aad40c317137a59514d +a49ab90f826a214d7de7058164f1fd886d9b762482c94953ee07495833742f92 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 5b6ec3ad9..25469c2f4 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index b96139a94..b24ebe75c 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -97efbeb8209eed4449ca850e985acefac08b44bb1f13853b313b836e996583a3 +29f7f3bd8b35edf079fdac3c4cc67c5b5ffd57c999064a9ee55793f4f22b6c6b diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 32960f3d7..d10ee67e3 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index feb3ba0e2..872be9822 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -0f5e465f98d98f8ff129bd72b98109fb8246d45ff00104d48375168ed8fe2086 +a008cd053e851f86be07576b21c0a0a763cb8e239e7bba03127de0d7c5ab3a4b diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 130b49e65..6e8cf42bf 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index ecbb3fb4b..eda0ec3d2 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -6e23467236adfdca5025184e8cad45df47bba736f010a46d13ca88dc0ac2e897 +a598e79d3e15b7ce4c5fef6b0af56d7d17e572bc1736806bbfa93349126dfb33 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index d7c80ac18..b6f0a21d2 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 6ee056556..c13c22baa 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -b0ee4c5d79f32b5420930ef767c983ee405157893ecdc340211b8777f8a02e44 +481910e4fe978ad5060cc70dd9d66dda029f4f73a0e8cd172b78df85dfa6dcd1 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 4174e6a1a..e3b53f44a 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index f2f6b200c..3f3755266 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -009452617b429df61dfaed6b1a96b0906342f3e0f0ccd8fb4b57ae74f3621b6f +a667fe84ceda6c32f4e74e86b39c39f9c0c7654e07ff911a294dae44a3e15c23 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 7712f2323..421c25f6b 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 48a8a88c2..bb6160660 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -1c55ec95537d31885c1f860a2c3a02ea4f840b4d97cd78977df5701a3a439c8f +a59169bbd1e95017f28f92c9b8796059f0804c9361f1ec39d9ff2ec3fc7ca3ab diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index bf9127080..115761e01 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 262256fba..fbc066fed 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -8d0eff09f96dc06f83af99d768e1c13d189b9b20cf481933c4f0bc6c47d5081a +fc5e1ed12c0238b7d9bc0621e41beb89bd62191fe0f30b8c66554207cde9cdf7 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index b91e377d3..6247cdcc1 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 0d59fa996..936eca41a 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -6a3d219b86eb112ee16aa846f97a17df4f3270ddfa90b5aa7ebe073b3a426d35 +343c6d46b9bf07f8631f16dbc737301a02a6b229b17eb0a455207509823680e1 diff --git a/sdk/rust/openidea-plugin/src/file_actions.rs b/sdk/rust/openidea-plugin/src/file_actions.rs index b0d911df7..7ec6dd9dd 100644 --- a/sdk/rust/openidea-plugin/src/file_actions.rs +++ b/sdk/rust/openidea-plugin/src/file_actions.rs @@ -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 { Ok(action) } diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 7fdfe4822..45f493694 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -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, Error> { imp::pick(title, items) } @@ -818,7 +818,8 @@ pub fn input(prompt: &str, value: Option<&str>) -> Result, 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, 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, Error> { imp::pick_directory(title, initial) } diff --git a/sdk/rust/openidea-plugin/src/providers.rs b/sdk/rust/openidea-plugin/src/providers.rs index 2cf808502..88219ceba 100644 --- a/sdk/rust/openidea-plugin/src/providers.rs +++ b/sdk/rust/openidea-plugin/src/providers.rs @@ -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 { Ok(action) } diff --git a/sdk/rust/openidea-plugin/wit/file-actions.wit b/sdk/rust/openidea-plugin/wit/file-actions.wit index 2d08b9b06..477809180 100644 --- a/sdk/rust/openidea-plugin/wit/file-actions.wit +++ b/sdk/rust/openidea-plugin/wit/file-actions.wit @@ -74,6 +74,7 @@ interface file-actions { code-actions: func(doc: document, selection: range) -> list; /// 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; } diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index 7a89bb019..91c3e12fc 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -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) -> result, 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) -> result, error>; /// Like `pick`, with any number of items chosen. pick-many: func(title: string, items: list) -> result>, error>; @@ -80,7 +79,8 @@ interface ui { /// Same rules as `pick`. input: func(prompt: string, value: option) -> result, 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) -> result, error>; /// Asks for confirmation with OK and Cancel: `true` on OK, `false` on /// Cancel or Escape. Same rules as `pick`. diff --git a/sdk/rust/openidea-plugin/wit/providers.wit b/sdk/rust/openidea-plugin/wit/providers.wit index 2140eb742..317eb81d1 100644 --- a/sdk/rust/openidea-plugin/wit/providers.wit +++ b/sdk/rust/openidea-plugin/wit/providers.wit @@ -75,6 +75,9 @@ interface providers { code-actions: func(doc: document, selection: range) -> list; /// 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; definition: func(doc: document, at: position) -> list; line-markers: func(doc: document) -> list; diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts index a76d9b4a1..1fe189199 100644 --- a/src/editor/lsp/intentions.test.ts +++ b/src/editor/lsp/intentions.test.ts @@ -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(); - 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>(); + 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({ diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 7dd584134..49fcc4c86 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -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 { 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; diff --git a/src/editor/lsp/interactiveCommand.test.tsx b/src/editor/lsp/interactiveCommand.test.tsx index 288fc2ae7..6ab43f5d6 100644 --- a/src/editor/lsp/interactiveCommand.test.tsx +++ b/src/editor/lsp/interactiveCommand.test.tsx @@ -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>) => 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())) diff --git a/wit/file-actions.wit b/wit/file-actions.wit index 2d08b9b06..477809180 100644 --- a/wit/file-actions.wit +++ b/wit/file-actions.wit @@ -74,6 +74,7 @@ interface file-actions { code-actions: func(doc: document, selection: range) -> list; /// 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; } diff --git a/wit/host.wit b/wit/host.wit index 7a89bb019..91c3e12fc 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -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) -> result, 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) -> result, error>; /// Like `pick`, with any number of items chosen. pick-many: func(title: string, items: list) -> result>, error>; @@ -80,7 +79,8 @@ interface ui { /// Same rules as `pick`. input: func(prompt: string, value: option) -> result, 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) -> result, error>; /// Asks for confirmation with OK and Cancel: `true` on OK, `false` on /// Cancel or Escape. Same rules as `pick`. diff --git a/wit/providers.wit b/wit/providers.wit index 2140eb742..317eb81d1 100644 --- a/wit/providers.wit +++ b/wit/providers.wit @@ -75,6 +75,9 @@ interface providers { code-actions: func(doc: document, selection: range) -> list; /// 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; definition: func(doc: document, at: position) -> list; line-markers: func(doc: document) -> list;