mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
1 parent
f3837e0fe7
commit
112ed773fb
43 files changed
+419
-39
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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, []]]);
|
||||
});
|
||||
@@ -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
@@ -1 +1 @@
|
||||
2d93f4f7eb0529528591ac1b7057a02cae7ed978eb21fe2be2f188529560b972
|
||||
2c583e91b023c13c13f59dc4e332c7c68296485da3d6febc0648811306e6d136
|
||||
+1
-1
@@ -1 +1 @@
|
||||
af52d666db0224b43e19193d90331d54c9645ebd63c183ede65abc5666c15fd7
|
||||
ce627cd77f3e27c1f3dcac9ec8c5470a349a937730db3fee4a2cb327cfa399a1
|
||||
+1
-1
@@ -1 +1 @@
|
||||
a5bcffff3f4bb1c01840bd7b16d5e90908b293ef7676ffd81264a2fa850f91c7
|
||||
976bb84b7ea7e3c3de84ba51a2e7173334fe7a8b316cd1fb8e3e82b51800bf41
|
||||
+1
-1
@@ -1 +1 @@
|
||||
d20a458237ecdd0d34de2be8e4df527f2ed647627b784bf5df1206e18beba2b1
|
||||
89c9e4cc9d455190d33139651388013f127752b196a29715b651f77b4d629ee5
|
||||
+1
-1
@@ -1 +1 @@
|
||||
d9b33e747442c539541778b2a907fc02226e9af120962d4f384820b76674d40a
|
||||
b0ce44f054cb3481cede4bf77a57a3254bf67627cbe617c59632185c20fb687c
|
||||
+1
-1
@@ -1 +1 @@
|
||||
b8c276a0d40a1615a658dc643f81e67683cfea57eb47282ff155c8df148fd771
|
||||
aafd97ca04b7e946d0d5a1fee7f0348242a49ebc2e31c43fedc090e83081e74c
|
||||
@@ -1 +1 @@
|
||||
c12eaf6b64a2482c83dab1958a195c939c38574d0bc15af1afcdaf811cf311d5
|
||||
4616c4a6a7c0bea36ca5ef0e0eae1bee283679edc691671b9911a4adec499210
|
||||
+1
-1
@@ -1 +1 @@
|
||||
fa12ebc743e31fa57b31c0a623708133a232754d110bd687ef16d1037be62b19
|
||||
61fd51343e7341b05de2f599ba54e9e6b5de3e8c80bdf91d3dad1604cde772bb
|
||||
+1
-1
@@ -1 +1 @@
|
||||
53271d32104c682e510b4b5a8edb3467c77b39ee58d9b14c891513a494526180
|
||||
e33af7d9f19fe5242b389ccbb820ac737e81c5793e37da67bf743ed57972de19
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2eb51fed9362021834f237f80553979a56ed1d8301d78b551e5911768b6e443c
|
||||
c571c6a7a4f796631d27faaeaa67b60e0ba9c62e410d4363b5744cac3aa3eda1
|
||||
+1
-1
@@ -1 +1 @@
|
||||
82547cce827ac884aafed961134e5d54be3dcd937990a3c372dfcdd8102aea23
|
||||
45764bde01e5475ec2eaf67db4b369f405f92800877c72b9f3dbef2e4b317a28
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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}` });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user