From 112ed773fb455d9af957f3dd41a91bbe3b20fa8a Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:35:36 +0100 Subject: [PATCH] feat(plugins): notifications with command buttons (#1974) Adds API 1.3 `ui.notify(level, title, body, actions) -> result<_, error>`: up to three buttons, each running one of the plugin's declared commands with an optional argument in the balloon's window and dismissing the balloon. An invalid action or an undeclared command fails the call and shows nothing. The plugin `Notification` event carries the actions; SDK `host::notify_with_actions` and `NotificationAction`. Closes #1955. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/api.rs | 42 +++++++++++++-- crates/ide-host/src/plugins/market.rs | 1 + crates/ide-host/src/plugins/mod.rs | 3 ++ crates/ide-host/src/plugins/types.rs | 9 +++- crates/ide-host/tests/plugins.rs | 30 +++++++++++ crates/ide-plugin/src/runtime.rs | 10 +++- crates/ide-plugin/src/types.rs | 9 ++++ crates/ide-plugin/src/wit_host.rs | 43 ++++++++++++--- .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/dist/plugin.wasm | Bin 147175 -> 150169 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/plugin.toml | 5 ++ .../ide-plugin/tests/fixtures/jobs/src/lib.rs | 17 ++++++ .../misbehave/dist/plugin.wasm.sources | 2 +- .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../fixtures/storage/dist/plugin.wasm.sources | 2 +- .../fixtures/tables/dist/plugin.wasm.sources | 2 +- e2e/pluginNotifications.spec.ts | 40 ++++++++++++++ plugins/README.md | 10 ++++ .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/host.rs | 34 ++++++++++++ sdk/rust/openidea-plugin/src/mock.rs | 50 ++++++++++++++++++ sdk/rust/openidea-plugin/src/types.rs | 19 +++++++ sdk/rust/openidea-plugin/wit/host.wit | 14 +++++ src/bindings.ts | 11 +++- .../plugins/__tests__/plugins.test.tsx | 33 +++++++++++- src/features/plugins/backend.ts | 9 +++- src/features/plugins/bridge.ts | 13 ++++- wit/host.wit | 14 +++++ 43 files changed, 419 insertions(+), 39 deletions(-) create mode 100644 e2e/pluginNotifications.spec.ts diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 434a276be..4c55f35e4 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -715,10 +715,23 @@ fn failed(e: impl std::fmt::Display) -> HostError { } impl HostApi for PluginApi { - fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String) { - self.shared - .sink - .emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body }); + fn notify( + &self, + client: Option<&str>, + level: NotificationLevel, + title: String, + body: String, + actions: Vec, + ) -> Result<(), HostError> { + if !actions.is_empty() { + let commands = self.shared.ui.items("commands", Some(self.id())); + if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) { + return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command))); + } + } + let plugin_id = self.id().to_owned(); + self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions }); + Ok(()) } fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> { @@ -1429,6 +1442,27 @@ mod tests { assert_eq!(api.storage_get("other").unwrap().as_deref(), Some("value")); } + #[test] + fn notification_actions_run_the_plugins_own_commands() { + let tmp = tempfile::tempdir().unwrap(); + let api = api(tmp.path(), Arc::default()); + let _command = api.shared.ui.insert(ide_extension::UiContribution { + plugin_id: api.id().into(), + point: "commands".into(), + key: "run".into(), + decl: serde_json::json!({}), + }); + let action = |command: &str| ide_plugin::NotificationAction { + label: "Run".into(), + command: command.into(), + argument: Some("a\nb".into()), + }; + let notify = |actions| api.notify(Some("main"), NotificationLevel::Info, "Done".into(), "".into(), actions); + notify(vec![]).unwrap(); + notify(vec![action("run")]).unwrap(); + assert!(matches!(notify(vec![action("run"), action("other.command")]), Err(HostError::NotFound(_)))); + } + #[test] fn repository_and_search_paths_match_ipc_events() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index 789d3800b..e31476bc5 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -429,6 +429,7 @@ impl PluginHost { level: ide_plugin::NotificationLevel::Warning, title: format!("{} was disabled", plugin.manifest().name), body: format!("The marketplace revoked this plugin: {reason}"), + actions: Vec::new(), }, ); } diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 380f3e4c0..8f47579ad 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -913,6 +913,7 @@ impl PluginHost { level: ide_plugin::NotificationLevel::Warning, title: format!("{name} update rolled back"), body: message.clone(), + actions: Vec::new(), }, ); self.changed(); @@ -2129,6 +2130,7 @@ impl PluginHost { level: NotificationLevel::Warning, title: format!("Plugin \u{201c}{}\u{201d} crashed", plugin.manifest().name), body: format!("{error}\nIt restarts the next time it is needed."), + actions: Vec::new(), }, ); } @@ -2190,6 +2192,7 @@ impl PluginHost { level: NotificationLevel::Error, title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest().name), body: error.to_owned(), + actions: Vec::new(), }, ); self.changed(); diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index 0cf9b8b20..da5885f05 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -172,7 +172,14 @@ pub enum PluginEvent { /// once per set of versions). UpdatesAvailable { updates: Vec }, #[serde(rename_all = "camelCase")] - Notification { plugin_id: String, level: NotificationLevel, title: String, body: String }, + Notification { + plugin_id: String, + level: NotificationLevel, + title: String, + body: String, + /// Buttons running the plugin's own commands (`ui.notify`). + actions: Vec, + }, /// A plugin's status bar item was set (`item`) or removed. #[serde(rename_all = "camelCase")] StatusItemChanged { plugin_id: String, id: String, item: Option }, diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index c84170442..1bb51692f 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -3353,6 +3353,36 @@ fn invalid_plugin_icons_refuse_the_package() { } } +#[test] +fn notification_actions_keep_commands_and_window_scope() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + f.take_events(); + f.host + .run_command("jobs", "notify-actions", None, Invocation { client: Some("second".into()), ..f.inv() }) + .unwrap(); + let notifications = f + .take_events() + .into_iter() + .filter_map(|(client, event)| match event { + PluginEvent::Notification { plugin_id, actions, .. } => Some((client, plugin_id, actions)), + _ => None, + }) + .collect::>(); + assert_eq!(notifications.len(), 1); + assert_eq!(notifications[0].0.as_deref(), Some("second")); + assert_eq!(notifications[0].1, "jobs"); + assert_eq!(notifications[0].2.len(), 3); + assert_eq!(notifications[0].2[0].command, "context"); + assert_eq!(notifications[0].2[0].argument.as_deref(), Some("notification-action=1\nvalue")); + for invalid in ["four", "unknown"] { + let error = f.run("jobs", "notify-actions", Some(invalid)).unwrap_err(); + assert!(error.contains(if invalid == "four" { "at most 3" } else { "declares no command" }), "{error}"); + assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::Notification { .. }))); + } + assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running"); +} + #[test] fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() { use ide_plugin::Prompt; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index ce2a59298..fc13fe8e9 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -60,7 +60,15 @@ pub const CANCELLED: &str = "was cancelled"; /// which routes notifications and status items to windows and edits to open /// documents. Capabilities are already checked when these are called. pub trait HostApi: Send + Sync { - fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String); + /// Shows a notification; `actions` name commands the plugin declares. + fn notify( + &self, + client: Option<&str>, + level: NotificationLevel, + title: String, + body: String, + actions: Vec, + ) -> Result<(), HostError>; fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> { let _ = (client, text); Ok(()) diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 02e1b86dd..168753aca 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -645,3 +645,12 @@ pub enum ViewEvent { // as an `other` view-event, kind `sort`). See `TableSort`. Sort { column: u32, descending: bool }, } + +/// A notification button that runs one of the publishing plugin's commands. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct NotificationAction { + pub label: String, + pub command: String, + pub argument: Option, +} diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index acdba792d..9ab31d453 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -154,6 +154,14 @@ pub(crate) fn to_wit(e: HostError) -> wit::Error { } } +fn notification_level(level: wit::NotificationLevel) -> NotificationLevel { + match level { + wit::NotificationLevel::Info => NotificationLevel::Info, + wit::NotificationLevel::Warning => NotificationLevel::Warning, + wit::NotificationLevel::Error => NotificationLevel::Error, + } +} + /// A guest's `error` as text (`denied: net:example.com`, `stale`, …). impl From for crate::runtime::GuestError { fn from(e: wit::Error) -> Self { @@ -266,19 +274,40 @@ impl StoreData { } impl ui::Host for StoreData { + fn notify( + &mut self, + level: wit::NotificationLevel, + title: String, + body: String, + actions: Vec, + ) -> Fallible<()> { + const MAX_ACTIONS: usize = 3; + if actions.len() > MAX_ACTIONS { + return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions")))); + } + let mut checked = Vec::with_capacity(actions.len()); + for a in actions { + if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) { + return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label)))); + } + if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) { + return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into()))); + } + checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument }); + } + let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10)); + Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit)) + } + fn show_notification( &mut self, level: wit::NotificationLevel, title: String, body: String, ) -> wasmtime::Result<()> { - let level = match level { - wit::NotificationLevel::Info => NotificationLevel::Info, - wit::NotificationLevel::Warning => NotificationLevel::Warning, - wit::NotificationLevel::Error => NotificationLevel::Error, - }; - let max = crate::runtime::MAX_TITLE; - self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10)); + let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10)); + // A plain notification has nothing the host could refuse. + let _ = self.host.notify(self.client(), notification_level(level), title, body, Vec::new()); Ok(()) } 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 fe3499425..f86b4c3bb 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 @@ -2ddbea56d198b52839c6506c9d03a87ea1c0cd8b518c0f3ee58f4eecf9d320e2 +effa9ede0252cc746bffdb3cc092b04749c050cd14263530da37c0c539183288 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 b42bca3d3..034cd7b3b 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 @@ -6709ed49ec442d7deca083534c13c35a1ca0355de8d073c55609ce784fe65db7 +4bf56a70aa09995f9acb935434b14954bd89094ba196b645c7e372b3a6403c2e 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 b42508379..31e2cce52 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 @@ -1915812d45316cf499611f471cf7abfdad47aa158295493bf81362f690ace866 +c2d10319579f7a4b441d00b380a97c24fbc7ad8fac20791c90fe6f864e4f2027 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 1f0182d59..d85f352cd 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 @@ -b55985f72b2636a84441f8effef10f8c4f638bd65be3f2f247d9a636c20e767b +003e8d2c175727e7bd0239aed217c8c344e8136b020a214079c530d236d75ec1 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 c39baa1ef..6d32b97fc 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 @@ -62888af0c9fb057ec4a696f0bfd5fd6fd07495a16a3021b4143de74b5cd30e26 +8c959561aec1b2e1390d2f9df269f0070af2c5586c906045bf0018078e6b6eed diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 1becbb272460a10f00f4ec7b9339e46e8d966e5e..0b8deed22903b9452de03b8b9ba3298053647566 100644 GIT binary patch delta 28672 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
D&#`-D!a2G0u55kIaF^v` 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 OK4VZ&jWhVi68;Z05%Mws delta 26456 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 { + if arg == Some("plain") { + host::notify(NotificationLevel::Info, "Choose an action", "A plugin notification"); + return Ok(None); + } + use openidea_plugin::NotificationAction; + let command = if arg == Some("unknown") { "not-declared" } else { "context" }; + let count = if arg == Some("four") { 4 } else { 3 }; + let actions = (0..count) + .map(|i| { + NotificationAction::new(format!("Action {}", i + 1), command) + .argument(format!("notification-action={}\nvalue", i + 1)) + }) + .collect::>(); + host::notify_with_actions(NotificationLevel::Info, "Choose an action", "A plugin notification", &actions)?; + Ok(None) + } "copy" => { host::copy_to_clipboard(arg.unwrap_or_default())?; Ok(None) 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 78e0147a0..14438862a 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 @@ -6c12157f6d1040ce3f9495aaef0e0ea89818ab8af1ded6e2655e3d9304131255 +951b70f49d8851524bdbe02c870f7ad1ce371358050152af701173816c48c548 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 c1d39c41d..c722a242a 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 @@ -9db9019b6322a9f007ca49b6e3bf918f33df02407827ba797854e2486301733a +24e59b772e9d0b3a61fa3ed11f6d0db3dfdf26b228e6f801a0ee0533aa65bd8e 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 6337c0927..f4afbd269 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 @@ -fea7a5340fed353c8d09d7b7caba0c5ca23763fe9c6ece930c61d84e5b7dc0d5 +f62bd72d6ba9d0085473e75e8035ae8e71427ccf6238c7e1a64675653e61aa7d 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 f58a78ab7..fb9532efb 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 @@ -5bf76257aac231326bdcb618b511d4f94ed711f217a15ecad10e4dd15525d840 +e0c93213ddc6b7a86f1ea9a82e200facfeb9832d2afbdadef9347e71ee041949 diff --git a/e2e/pluginNotifications.spec.ts b/e2e/pluginNotifications.spec.ts new file mode 100644 index 000000000..131569673 --- /dev/null +++ b/e2e/pluginNotifications.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "./fixtures"; + +test("plugin notification actions run their command and dismiss the balloon", async ({ workbench: page }) => { + await page.evaluate(async () => { + const backendPath = "/src/features/plugins/backend.ts"; + const { createMemoryPluginsBackend, setPluginsBackend } = await import(/* @vite-ignore */ backendPath); + const state = window as unknown as { notificationCalls: unknown[] }; + state.notificationCalls = []; + setPluginsBackend({ + ...createMemoryPluginsBackend(), + runCommand: async (...args: unknown[]) => { + state.notificationCalls.push(args); + return null; + }, + }); + const bridgePath = "/src/features/plugins/bridge.ts"; + const { handlePluginEvent } = await import(/* @vite-ignore */ bridgePath); + handlePluginEvent({ + type: "notification", + pluginId: "hello", + level: "info", + title: "Build complete", + body: "Choose the next step", + actions: [ + { label: "Review changes", command: "word-count", argument: "first\nsecond" }, + { label: "Open results", command: "insert-date", argument: null }, + { label: "Undo change", command: "insert-date", argument: "undo" }, + ], + }); + }); + const review = page.getByRole("button", { name: "Review changes", exact: true }); + await expect(review).toBeVisible(); + await expect(page.getByRole("button", { name: "Open results", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Undo change", exact: true })).toBeVisible(); + await review.click(); + await expect(review).toBeHidden(); + await expect + .poll(() => page.evaluate(() => (window as unknown as { notificationCalls: unknown[] }).notificationCalls)) + .toEqual([["hello", "word-count", "first\nsecond", null, []]]); +}); diff --git a/plugins/README.md b/plugins/README.md index b87015afb..1097322da 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1268,3 +1268,13 @@ reused as usual, and the normal trust prompt and unsaved-change guard apply. The call acknowledges the request; cancellation or opening errors are handled by the window. Neither API needs a capability. Paths belong to the UI machine, including when plugin code runs on a remote host. + +### Notifications with commands + +`host::notify_with_actions(level, title, body, actions)` shows up to three +buttons. Build each with `NotificationAction::new(label, command)` and an +optional `.argument(text)`. Commands must be declared by that plugin; choosing +one dismisses the balloon and runs the command in the receiving window with +its normal editor and project context. No capability is needed. Invalid or +undeclared actions reject the whole notification. Labels are limited to 256 +bytes and arguments to 64 KiB. Plain `host::notify` behaves as before. diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index e57057dab..bdb860332 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -2d93f4f7eb0529528591ac1b7057a02cae7ed978eb21fe2be2f188529560b972 +2c583e91b023c13c13f59dc4e332c7c68296485da3d6febc0648811306e6d136 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index e79f0de1f..f7806c87f 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -af52d666db0224b43e19193d90331d54c9645ebd63c183ede65abc5666c15fd7 +ce627cd77f3e27c1f3dcac9ec8c5470a349a937730db3fee4a2cb327cfa399a1 diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index e2b69d6f9..235c0be73 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -a5bcffff3f4bb1c01840bd7b16d5e90908b293ef7676ffd81264a2fa850f91c7 +976bb84b7ea7e3c3de84ba51a2e7173334fe7a8b316cd1fb8e3e82b51800bf41 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index f7fe17cce..9c15b9a23 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -d20a458237ecdd0d34de2be8e4df527f2ed647627b784bf5df1206e18beba2b1 +89c9e4cc9d455190d33139651388013f127752b196a29715b651f77b4d629ee5 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index e930ae3a1..94cb748f0 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -d9b33e747442c539541778b2a907fc02226e9af120962d4f384820b76674d40a +b0ce44f054cb3481cede4bf77a57a3254bf67627cbe617c59632185c20fb687c diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 9d3a94175..7e650af2a 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -b8c276a0d40a1615a658dc643f81e67683cfea57eb47282ff155c8df148fd771 +aafd97ca04b7e946d0d5a1fee7f0348242a49ebc2e31c43fedc090e83081e74c diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 1ef873589..2bb0b757d 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -c12eaf6b64a2482c83dab1958a195c939c38574d0bc15af1afcdaf811cf311d5 +4616c4a6a7c0bea36ca5ef0e0eae1bee283679edc691671b9911a4adec499210 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 6dd517787..504f78743 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -fa12ebc743e31fa57b31c0a623708133a232754d110bd687ef16d1037be62b19 +61fd51343e7341b05de2f599ba54e9e6b5de3e8c80bdf91d3dad1604cde772bb diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index e48b05177..59d9dc3bd 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -53271d32104c682e510b4b5a8edb3467c77b39ee58d9b14c891513a494526180 +e33af7d9f19fe5242b389ccbb820ac737e81c5793e37da67bf743ed57972de19 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 609942a06..13c6569f9 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -2eb51fed9362021834f237f80553979a56ed1d8301d78b551e5911768b6e443c +c571c6a7a4f796631d27faaeaa67b60e0ba9c62e410d4363b5744cac3aa3eda1 diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 1d1abe1b9..a389f5fa7 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -82547cce827ac884aafed961134e5d54be3dcd937990a3c372dfcdd8102aea23 +45764bde01e5475ec2eaf67db4b369f405f92800877c72b9f3dbef2e4b317a28 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index dce8a446b..0d9d9b01f 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -60,6 +60,28 @@ mod imp { wit::ui::show_notification(level, title, body) } + pub fn notify_with_actions( + level: NotificationLevel, + title: &str, + body: &str, + actions: &[NotificationAction], + ) -> Result<(), Error> { + let level = match level { + NotificationLevel::Info => wit::types::NotificationLevel::Info, + NotificationLevel::Warning => wit::types::NotificationLevel::Warning, + NotificationLevel::Error => wit::types::NotificationLevel::Error, + }; + let actions: Vec<_> = actions + .iter() + .map(|a| wit::ui::NotificationAction { + label: a.label.clone(), + command: a.command.clone(), + argument: a.argument.clone(), + }) + .collect(); + wit::ui::notify(level, title, body, &actions).map_err(error) + } + pub fn set_status_item(id: &str, item: &StatusItem) { let item = wit::ui::StatusItem { text: item.text.clone(), @@ -820,3 +842,15 @@ pub fn pick_directory(title: &str, initial: Option<&str>) -> Result Result<(), Error> { imp::open_project(path, new_window) } + +/// [`notify`] with up to three buttons; each runs one of this plugin's +/// declared commands in the balloon's window and dismisses the balloon. An +/// invalid action or an undeclared command is an error and nothing is shown. +pub fn notify_with_actions( + level: NotificationLevel, + title: &str, + body: &str, + actions: &[NotificationAction], +) -> Result<(), Error> { + imp::notify_with_actions(level, title, body, actions) +} diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 4466a43e0..fb0c05b44 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -19,6 +19,13 @@ pub struct Notification { pub body: String, } +/// A notification with command buttons, recorded by the mock. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActionNotification { + pub notification: Notification, + pub actions: Vec, +} + /// A task the plugin began. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MockTask { @@ -59,6 +66,7 @@ struct State { editor: Option, selected_paths: Vec, notifications: Vec, + action_notifications: Vec, logs: Vec<(LogLevel, String)>, status: BTreeMap, context: BTreeMap, @@ -201,6 +209,11 @@ impl MockHost { self.state.borrow().notifications.clone() } + /// Notifications that carried command buttons, in publication order. + pub fn notifications_with_actions(&self) -> Vec { + self.state.borrow().action_notifications.clone() + } + pub fn logs(&self) -> Vec<(LogLevel, String)> { self.state.borrow().logs.clone() } @@ -436,6 +449,32 @@ pub mod current { with(|s| s.notifications.push(Notification { level, title: title.into(), body: body.into() })) } + /// The host's checks, except that the commands are declared (the mock has no manifest). + pub fn notify_with_actions( + level: NotificationLevel, + title: &str, + body: &str, + actions: &[NotificationAction], + ) -> Result<(), Error> { + if actions.len() > 3 { + return Err(Error::Invalid("a notification has at most 3 actions".into())); + } + for a in actions { + if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) { + return Err(Error::Invalid(format!("invalid action label {:?}", a.label))); + } + if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) { + return Err(Error::Invalid("an action argument is limited to 64 KiB".into())); + } + } + with(|s| { + let notification = Notification { level, title: title.into(), body: body.into() }; + s.notifications.push(notification.clone()); + s.action_notifications.push(ActionNotification { notification, actions: actions.to_vec() }); + }); + Ok(()) + } + pub fn set_status_item(id: &str, item: &StatusItem) { with(|s| s.status.insert(id.to_owned(), item.clone())); } @@ -1037,6 +1076,17 @@ mod tests { assert_eq!(mock.editor_version(), 3); } + #[test] + fn notification_buttons_are_recorded_and_bounded() { + let mock = MockHost::install(); + let action = NotificationAction::new("Open", "open").argument("first\nsecond"); + host::notify_with_actions(NotificationLevel::Info, "Done", "", std::slice::from_ref(&action)).unwrap(); + assert_eq!(mock.notifications_with_actions()[0].actions, vec![action.clone()]); + let too_many: [_; 4] = std::array::from_fn(|_| action.clone()); + assert!(host::notify_with_actions(NotificationLevel::Info, "Done", "", &too_many).is_err()); + assert_eq!(mock.notifications().len(), 1); + } + #[test] fn missing_capabilities_are_denied() { let mock = MockHost::install().with_capabilities(&["project.read"]); diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs index 6f93cca82..129947b40 100644 --- a/sdk/rust/openidea-plugin/src/types.rs +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -465,3 +465,22 @@ mod tests { assert_eq!((d.code.as_deref(), d.unnecessary, d.related.len()), (Some("c"), false, 0)); } } + +/// A notification button invoking a command declared by this plugin. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NotificationAction { + pub label: String, + pub command: String, + pub argument: Option, +} +impl NotificationAction { + /// A button labelled `label`, invoking the plugin command `command`. + pub fn new(label: impl Into, command: impl Into) -> Self { + Self { label: label.into(), command: command.into(), argument: None } + } + /// Passes this text to the command when selected. + pub fn argument(mut self, argument: impl Into) -> Self { + self.argument = Some(argument.into()); + self + } +} diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index 06a8885c5..7a89bb019 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -33,9 +33,23 @@ interface ui { text(string), } + /// A notification button that runs one of the plugin's commands. + record notification-action { + /// Up to 256 bytes, one line. + label: string, + /// The id of a command the plugin declares. + command: string, + /// Passed to the command as its argument (up to 64 KiB). + argument: option, + } + /// Shows a notification balloon in the window the current command or /// event came from (every window when there is none). show-notification: func(level: notification-level, title: string, body: string); + /// `show-notification` with up to three action buttons. Choosing one + /// dismisses the balloon and runs the command in its window. An invalid + /// action or an undeclared command fails the call and shows nothing. + notify: func(level: notification-level, title: string, body: string, actions: list) -> result<_, error>; /// Shows or updates the plugin's status bar item `id` in every window. set-status-item: func(id: string, item: status-item); remove-status-item: func(id: string); diff --git a/src/bindings.ts b/src/bindings.ts index 620e4676b..f15f5c5fb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5730,6 +5730,13 @@ export type NotebookSnapshot = { cells: NotebookCell[], }; +/** A notification button that runs one of the publishing plugin's commands. */ +export type NotificationAction = { + label: string, + command: string, + argument: string | null, +}; + export type NotificationGroupSettings = { /** `balloon` | `sticky` | `none` */ display?: string, @@ -5944,7 +5951,9 @@ export type PluginEvent = * The marketplace offers newer versions of installed plugins (announced * once per set of versions). */ -{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } | +{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string; +/** Buttons running the plugin's own commands (`ui.notify`). */ +actions: NotificationAction[] } | /** A plugin's status bar item was set (`item`) or removed. */ { type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } | /** diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 096c45140..5407f6832 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -488,12 +488,41 @@ describe("bridge events", () => { expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true); }); + it("runs notification buttons as commands of the publishing plugin with their arguments", async () => { + const runCommand = vi.fn().mockResolvedValue(null); + setPluginsBackend({ ...backend, runCommand }); + handlePluginEvent({ + type: "notification", + pluginId: "hello", + level: "info", + title: "Done", + body: "Pick an action", + actions: [ + { label: "Open", command: "insert-date", argument: "first\nsecond" }, + { label: "Refresh", command: "word-count", argument: null }, + { label: "Undo", command: "insert-date", argument: "undo" }, + ], + }); + expect(notes[0]?.actions?.map((a) => a.label)).toEqual(["Open", "Refresh", "Undo"]); + notes[0]!.actions![0]!.run(); + await waitFor(() => expect(runCommand).toHaveBeenCalledWith("hello", "insert-date", "first\nsecond", null, [])); + }); + it("shows plugin notifications, copies to clipboard, opens URLs and tracks theme changes", () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); const open = vi.spyOn(window, "open").mockImplementation(() => null); - handlePluginEvent({ type: "notification", pluginId: "p", level: "info", title: "Done", body: "3 words" }); - expect(notes).toEqual([{ group: "plugins", level: "info", title: "Done", body: "3 words", verbatim: true }]); + handlePluginEvent({ + type: "notification", + pluginId: "p", + level: "info", + title: "Done", + body: "3 words", + actions: [], + }); + expect(notes).toEqual([ + { group: "plugins", level: "info", title: "Done", body: "3 words", verbatim: true, actions: [] }, + ]); handlePluginEvent({ type: "copyToClipboard", pluginId: "p", text: "https://github.com/o/r" }); expect(writeText).toHaveBeenCalledWith("https://github.com/o/r"); handlePluginEvent({ type: "openUrl", pluginId: "p", url: "https://github.com/o/r/blob/main/a.rs" }); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index ede2d0e8b..e753e145b 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -540,7 +540,14 @@ export function createMemoryPluginsBackend( if (commandId === "word-count") { if (!activeFile) throw new IpcError({ kind: "internal", message: "no file is open in the editor" }); const name = activeFile.split("/").pop() ?? activeFile; - emit({ type: "notification", pluginId, level: "info", title: `Word count: ${name}`, body: "(simulated)" }); + emit({ + type: "notification", + pluginId, + level: "info", + title: `Word count: ${name}`, + body: "(simulated)", + actions: [], + }); return null; } throw new IpcError({ kind: "internal", message: `unknown command: ${commandId}` }); diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index ce80d444b..2b8690aa1 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -36,7 +36,14 @@ import { defineBuiltinUiPoints } from "./points"; import { localizeContributions, loadPluginCatalogs, usePluginCatalogs } from "./pluginText"; import { clearUiContributions, syncUiContributions } from "./projection"; import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi"; -import { grantPlugin, refreshPlugins, selectPluginTheme, updateAllPlugins, usePlugins } from "./store"; +import { + grantPlugin, + runPluginCommand, + refreshPlugins, + selectPluginTheme, + updateAllPlugins, + usePlugins, +} from "./store"; export { pluginActionId } from "./points"; @@ -284,6 +291,10 @@ export function handlePluginEvent(e: PluginEvent) { title: e.title || name, body: e.body || undefined, verbatim: true, + actions: e.actions.map((a) => ({ + label: a.label, + run: () => void runPluginCommand(e.pluginId, a.command, a.argument), + })), }); break; } diff --git a/wit/host.wit b/wit/host.wit index 06a8885c5..7a89bb019 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -33,9 +33,23 @@ interface ui { text(string), } + /// A notification button that runs one of the plugin's commands. + record notification-action { + /// Up to 256 bytes, one line. + label: string, + /// The id of a command the plugin declares. + command: string, + /// Passed to the command as its argument (up to 64 KiB). + argument: option, + } + /// Shows a notification balloon in the window the current command or /// event came from (every window when there is none). show-notification: func(level: notification-level, title: string, body: string); + /// `show-notification` with up to three action buttons. Choosing one + /// dismisses the balloon and runs the command in its window. An invalid + /// action or an undeclared command fails the call and shows nothing. + notify: func(level: notification-level, title: string, body: string, actions: list) -> result<_, error>; /// Shows or updates the plugin's status bar item `id` in every window. set-status-item: func(id: string, item: status-item); remove-status-item: func(id: string);