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 <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-07 22:35:36 +08:00
1 parent f3837e0fe7
commit 112ed773fb
43 files changed
+419 -39

No files matched your search

+38 -4
View File
@@ -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<ide_plugin::NotificationAction>,
) -> 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();
+1
View File
@@ -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(),
},
);
}
+3
View File
@@ -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();
+8 -1
View File
@@ -172,7 +172,14 @@ pub enum PluginEvent {
/// once per set of versions).
UpdatesAvailable { updates: Vec<PluginUpdate> },
#[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<ide_plugin::NotificationAction>,
},
/// A plugin's status bar item was set (`item`) or removed.
#[serde(rename_all = "camelCase")]
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
+30
View File
@@ -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::<Vec<_>>();
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;
+9 -1
View File
@@ -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<crate::NotificationAction>,
) -> Result<(), HostError>;
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
+9
View File
@@ -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<String>,
}
+36 -7
View File
@@ -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<wit::Error> 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<ui::NotificationAction>,
) -> 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(())
}
@@ -1 +1 @@
2ddbea56d198b52839c6506c9d03a87ea1c0cd8b518c0f3ee58f4eecf9d320e2
effa9ede0252cc746bffdb3cc092b04749c050cd14263530da37c0c539183288
@@ -1 +1 @@
6709ed49ec442d7deca083534c13c35a1ca0355de8d073c55609ce784fe65db7
4bf56a70aa09995f9acb935434b14954bd89094ba196b645c7e372b3a6403c2e
@@ -1 +1 @@
1915812d45316cf499611f471cf7abfdad47aa158295493bf81362f690ace866
c2d10319579f7a4b441d00b380a97c24fbc7ad8fac20791c90fe6f864e4f2027
@@ -1 +1 @@
b55985f72b2636a84441f8effef10f8c4f638bd65be3f2f247d9a636c20e767b
003e8d2c175727e7bd0239aed217c8c344e8136b020a214079c530d236d75ec1
@@ -1 +1 @@
62888af0c9fb057ec4a696f0bfd5fd6fd07495a16a3021b4143de74b5cd30e26
8c959561aec1b2e1390d2f9df269f0070af2c5586c906045bf0018078e6b6eed
Binary file not shown.
@@ -1 +1 @@
79a67b0af9e0118961a83eecd644c2e1b87a98043d34b96bc49d4042af5d895f
6485327fb8702f49600cec510359b3aba4c468df53d695f84cb03a9e41b96a89
+5
View File
@@ -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"
+17
View File
@@ -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::<Vec<_>>();
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)
@@ -1 +1 @@
6c12157f6d1040ce3f9495aaef0e0ea89818ab8af1ded6e2655e3d9304131255
951b70f49d8851524bdbe02c870f7ad1ce371358050152af701173816c48c548
@@ -1 +1 @@
9db9019b6322a9f007ca49b6e3bf918f33df02407827ba797854e2486301733a
24e59b772e9d0b3a61fa3ed11f6d0db3dfdf26b228e6f801a0ee0533aa65bd8e
@@ -1 +1 @@
fea7a5340fed353c8d09d7b7caba0c5ca23763fe9c6ece930c61d84e5b7dc0d5
f62bd72d6ba9d0085473e75e8035ae8e71427ccf6238c7e1a64675653e61aa7d
@@ -1 +1 @@
5bf76257aac231326bdcb618b511d4f94ed711f217a15ecad10e4dd15525d840
e0c93213ddc6b7a86f1ea9a82e200facfeb9832d2afbdadef9347e71ee041949
+40
View File
@@ -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, []]]);
});
+10
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
2d93f4f7eb0529528591ac1b7057a02cae7ed978eb21fe2be2f188529560b972
2c583e91b023c13c13f59dc4e332c7c68296485da3d6febc0648811306e6d136
+1 -1
View File
@@ -1 +1 @@
af52d666db0224b43e19193d90331d54c9645ebd63c183ede65abc5666c15fd7
ce627cd77f3e27c1f3dcac9ec8c5470a349a937730db3fee4a2cb327cfa399a1
+1 -1
View File
@@ -1 +1 @@
a5bcffff3f4bb1c01840bd7b16d5e90908b293ef7676ffd81264a2fa850f91c7
976bb84b7ea7e3c3de84ba51a2e7173334fe7a8b316cd1fb8e3e82b51800bf41
+1 -1
View File
@@ -1 +1 @@
d20a458237ecdd0d34de2be8e4df527f2ed647627b784bf5df1206e18beba2b1
89c9e4cc9d455190d33139651388013f127752b196a29715b651f77b4d629ee5
+1 -1
View File
@@ -1 +1 @@
d9b33e747442c539541778b2a907fc02226e9af120962d4f384820b76674d40a
b0ce44f054cb3481cede4bf77a57a3254bf67627cbe617c59632185c20fb687c
+1 -1
View File
@@ -1 +1 @@
b8c276a0d40a1615a658dc643f81e67683cfea57eb47282ff155c8df148fd771
aafd97ca04b7e946d0d5a1fee7f0348242a49ebc2e31c43fedc090e83081e74c
+1 -1
View File
@@ -1 +1 @@
c12eaf6b64a2482c83dab1958a195c939c38574d0bc15af1afcdaf811cf311d5
4616c4a6a7c0bea36ca5ef0e0eae1bee283679edc691671b9911a4adec499210
+1 -1
View File
@@ -1 +1 @@
fa12ebc743e31fa57b31c0a623708133a232754d110bd687ef16d1037be62b19
61fd51343e7341b05de2f599ba54e9e6b5de3e8c80bdf91d3dad1604cde772bb
+1 -1
View File
@@ -1 +1 @@
53271d32104c682e510b4b5a8edb3467c77b39ee58d9b14c891513a494526180
e33af7d9f19fe5242b389ccbb820ac737e81c5793e37da67bf743ed57972de19
+1 -1
View File
@@ -1 +1 @@
2eb51fed9362021834f237f80553979a56ed1d8301d78b551e5911768b6e443c
c571c6a7a4f796631d27faaeaa67b60e0ba9c62e410d4363b5744cac3aa3eda1
+1 -1
View File
@@ -1 +1 @@
82547cce827ac884aafed961134e5d54be3dcd937990a3c372dfcdd8102aea23
45764bde01e5475ec2eaf67db4b369f405f92800877c72b9f3dbef2e4b317a28
+34
View File
@@ -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<Option<Strin
pub fn open_project(path: &str, new_window: bool) -> 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)
}
+50
View File
@@ -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<NotificationAction>,
}
/// A task the plugin began.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MockTask {
@@ -59,6 +66,7 @@ struct State {
editor: Option<Editor>,
selected_paths: Vec<String>,
notifications: Vec<Notification>,
action_notifications: Vec<ActionNotification>,
logs: Vec<(LogLevel, String)>,
status: BTreeMap<String, StatusItem>,
context: BTreeMap<String, ContextValue>,
@@ -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<ActionNotification> {
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"]);
+19
View File
@@ -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<String>,
}
impl NotificationAction {
/// A button labelled `label`, invoking the plugin command `command`.
pub fn new(label: impl Into<String>, command: impl Into<String>) -> 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<String>) -> Self {
self.argument = Some(argument.into());
self
}
}
+14
View File
@@ -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<string>,
}
/// 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<notification-action>) -> 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);
+10 -1
View File
@@ -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 } |
/**
@@ -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" });
+8 -1
View File
@@ -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}` });
+12 -1
View File
@@ -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;
}
+14
View File
@@ -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<string>,
}
/// 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<notification-action>) -> 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);