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 1becbb272..0b8deed22 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 850d179fd..b94a6b5ce 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 @@ -79a67b0af9e0118961a83eecd644c2e1b87a98043d34b96bc49d4042af5d895f +6485327fb8702f49600cec510359b3aba4c468df53d695f84cb03a9e41b96a89 diff --git a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml index 9f5e3ee51..1b8dd1a23 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml @@ -39,6 +39,11 @@ category = "Jobs" title = "Jobs" schema = "settings.schema.json" +[[contributes.commands]] +id = "notify-actions" +title = "Jobs: Show Notification Actions" +category = "Jobs" + [[contributes.commands]] id = "pick-directory" title = "Pick directory" diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs index 9d03b33a2..e18e3991c 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs @@ -53,6 +53,23 @@ impl Plugin for Jobs { } Ok(None) } + "notify-actions" => { + 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);