feat(plugins): notifications targeted at one project (API 1.5) (#2375)

- Add ui.notify-project: it shows only in that project's window (the most recently focused one if there are several) and works without a calling window
- Button commands run in the window showing the notification; a closed project returns not-found, with nothing shown and no fallback to another window
- Bump the plugin API to 1.5.0 (wit/, SDK copy, ENGINE_VERSION, API_VERSION) and rebuild all components
- Add Project::notify and its mock to the SDK; update plugins/README.md

Co-authored-by: LU XIANGRUI <[email protected]>
Цей коміт міститься в:
LU XIANGRUIтаLU XIANGRUI authored and GitHub committed 2026-11-09 23:20:38 +08:00
1 джерело 5f970d97d5
коміт ff17c3753f
149 files changed
+449 -165

No files matched your search

+1 -1
Переглянути файл
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.4.0";
pub const ENGINE_VERSION: &str = "1.5.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
+32
Переглянути файл
@@ -28,6 +28,8 @@ pub struct Reports(RwLock<State>);
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
/// Windows in the order they were last focused, the most recent last.
focus_order: Vec<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
@@ -46,8 +48,16 @@ impl Reports {
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
state.focus_order.retain(|w| w != window);
state.focus_order.push(window.into());
}
}
/// Sorts `windows` by when they were last focused, the most recent first; windows
/// never focused keep their order after them.
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
let order = &self.0.read().focus_order;
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
@@ -68,6 +78,7 @@ impl Reports {
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
state.focus_order.retain(|w| w != window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
@@ -122,3 +133,24 @@ impl Reports {
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_sort_by_their_last_focus() {
let reports = Reports::default();
for window in ["a", "b", "a", "c"] {
reports.set_focused_editor(window, None, true);
}
reports.set_focused_editor("b", None, false);
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["c", "a", "b", "never"]);
reports.forget_window("c");
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["a", "b", "never", "c"]);
}
}
+40 -6
Переглянути файл
@@ -110,6 +110,10 @@ pub trait PluginWorkspace: Send + Sync {
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
fn project_of(&self, window: &str) -> Option<PathBuf>;
/// Windows showing the open project at `root`, the most recently focused first.
fn project_windows(&self, _root: &Path) -> Vec<String> {
Vec::new()
}
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
@@ -1758,17 +1762,31 @@ impl HostApi for PluginApi {
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)));
}
}
check_actions(self, &actions)?;
let plugin_id = self.id().to_owned();
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn notify_project(
&self,
root: &Path,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
check_actions(self, &actions)?;
let windows = self.shared.workspace().map(|w| w.project_windows(root)).unwrap_or_default();
let window = windows
.into_iter()
.find(|w| !self.shared.client_closed(w))
.ok_or_else(|| HostError::NotFound(format!("the project {} is not open", root.display())))?;
let plugin_id = self.id().to_owned();
self.shared.sink.emit(Some(&window), PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let client = from_window(client, "copy to the clipboard")?;
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
@@ -2718,6 +2736,15 @@ fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
}
}
/// Notification buttons must name commands the plugin declares.
fn check_actions(api: &PluginApi, actions: &[ide_plugin::NotificationAction]) -> Result<(), HostError> {
let commands = if actions.is_empty() { Vec::new() } else { api.shared.ui.items("commands", Some(api.id())) };
match actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
Some(a) => Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command))),
None => Ok(()),
}
}
/// [`PluginWorkspace`] over the desktop host's open projects.
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
@@ -3016,6 +3043,13 @@ impl PluginWorkspace for HostWorkspace {
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
}
fn project_windows(&self, root: &Path) -> Vec<String> {
let Some(host) = self.0.upgrade() else { return Vec::new() };
let mut windows = host.windows_for_root(root);
host.mcp_reports.most_recently_focused_first(&mut windows);
windows
}
fn publish_diagnostics(
&self,
source: &str,
+38
Переглянути файл
@@ -126,6 +126,10 @@ impl PluginWorkspace for TestWorkspace {
fn trusted(&self, _root: &Path) -> bool {
self.trusted
}
fn project_windows(&self, root: &Path) -> Vec<String> {
// `main` shows the fixture project; other open roots have no window left.
if root == self.root { vec!["closing".into(), "main".into()] } else { Vec::new() }
}
fn project_of(&self, _window: &str) -> Option<PathBuf> {
if let Some(left) = self.project_queries_left.lock().as_mut() {
if *left == 0 {
@@ -4801,6 +4805,40 @@ fn notification_actions_keep_commands_and_window_scope() {
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
}
#[test]
fn project_notifications_go_to_a_window_of_that_project_only() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.host.set_capability("jobs", "project.read", false).unwrap();
f.host.forget_client("closing");
let other = f.project.parent().unwrap().join("other");
std::fs::create_dir(&other).unwrap();
f.workspace.other_roots.lock().push(other.clone());
f.take_events();
let notifications = |f: &Fixture| {
f.take_events()
.into_iter()
.filter_map(|(client, event)| match event {
PluginEvent::Notification { title, actions, .. } => Some((client, title, actions)),
_ => None,
})
.collect::<Vec<_>>()
};
// A scheduled call has no invoking window; a closing window is skipped.
let path = f.project.join("notes.txt").to_string_lossy().into_owned();
f.host.run_command("jobs", "notify-project", Some(&path), Invocation::default()).unwrap();
let sent = notifications(&f);
assert_eq!(sent.len(), 1);
assert_eq!((sent[0].0.as_deref(), sent[0].1.as_str()), (Some("main"), "Project update"));
assert_eq!((sent[0].2[0].command.as_str(), sent[0].2[0].argument.as_deref()), ("context", Some(path.as_str())));
// A project without a window fails without falling back to another one, even when invoked from one.
let path = other.join("notes.txt").to_string_lossy().into_owned();
let error = f.host.run_command("jobs", "notify-project", Some(&path), f.inv()).unwrap_err().message;
assert!(error.contains("not open"), "{error}");
assert!(notifications(&f).is_empty());
assert_eq!(f.status("jobs"), PluginStatus::Active);
}
#[test]
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
use ide_plugin::Prompt;
+14 -1
Переглянути файл
@@ -54,7 +54,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.4";
pub const API_VERSION: &str = "1.5";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -259,6 +259,19 @@ pub trait HostApi: Send + Sync {
body: String,
actions: Vec<crate::NotificationAction>,
) -> Result<(), HostError>;
/// [`HostApi::notify`] in the most recently focused window of the open project at
/// `root`; not-found (and nothing shown) once it has closed.
fn notify_project(
&self,
root: &Path,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<crate::NotificationAction>,
) -> Result<(), HostError> {
let _ = (root, level, title, body, actions);
Err(HostError::NotFound("project notifications unavailable".into()))
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
+41 -15
Переглянути файл
@@ -362,6 +362,25 @@ impl StoreData {
}
}
/// Checks `ui.notify` buttons; the host checks that their commands are declared.
fn notification_actions(actions: Vec<ui::NotificationAction>) -> Result<Vec<crate::NotificationAction>, wit::Error> {
const MAX_ACTIONS: usize = 3;
if actions.len() > MAX_ACTIONS {
return 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 Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label)));
}
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
return 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 });
}
Ok(checked)
}
impl ui::Host for StoreData {
fn notify(
&mut self,
@@ -370,22 +389,29 @@ impl ui::Host for StoreData {
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 actions = match notification_actions(actions) {
Ok(actions) => actions,
Err(e) => return Ok(Err(e)),
};
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))
Ok(self.host.notify(self.client(), notification_level(level), title, body, actions).map_err(to_wit))
}
fn notify_project(
&mut self,
target: Resource<ProjectRes>,
level: wit::NotificationLevel,
title: String,
body: String,
actions: Vec<ui::NotificationAction>,
) -> Fallible<()> {
let actions = match notification_actions(actions) {
Ok(actions) => actions,
Err(e) => return Ok(Err(e)),
};
let root = self.table.get(&target)?.root.clone();
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
Ok(self.host.notify_project(&root, notification_level(level), title, body, actions).map_err(to_wit))
}
fn show_notification(
+5 -5
Переглянути файл
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.4.0");
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.4.0", "1.0.0-rc.1"] {
let host = v("1.5.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.5.0"), &host).unwrap_err();
assert!(newer.contains("1.5") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.6.0"), &host).unwrap_err();
assert!(newer.contains("1.6") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.4 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.5 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4647fa9aec9732e3dd824efccd61b1c6b9c36645a3586f402f6a7a47419db61f
9963d6606bb6b859bebe1f55892488944f6d5e3308e6c0109f38f4b1a67b01f5
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6787b195df1057176ea9fcf11d0fcaf8cd347fc182fab94fe3a229063deed831
7ab9937fdb2e78d0d62d9b85b78a0a6d9511224f85908c3a5a5e9c0d4b5e7e14
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c2d17076337b1225ef8b0323b335a3b03bf022485088957c520e0cb797d7df7d
cf1f91d8accf8339bf6e22bef5067bfe3cba75d38cd30d8748b0aa451ee2495b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
415a4cd17c573aeeb1b45d4b1af55834eab5e686c4f624495323e4fb63c3ac71
12ad7d649d752f2a40b28dee718a3e04b64597496ab43787c084e3cb774ccfe6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5f502b7650acd5d07bffba8016680248b5ceac0926013916ff351562e3c6d831
4bfce353b3c94490c5f95a046cd2371042b243c326bd63c65619e1489d7ab889
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
19e8d460269999aeac7b4021bfedb803ad0d68c24ce1c22f058070fb406a64c3
cfbd6aac217a7baea907affe4e1e7f89d1330843398e913e68b71f3a99cfb71b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
63809abb0f8a4f40b0c0f465062cc8ba0ebfd03d41a108ff19866e6565eda931
dcf00138820979f523bd93e1f6b08c627c6f061ae5b6e91b340646f905593cf3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
99c968b98d8f1d895c428b4048451275bd322e7070aa0a020737097efea17eb1
98e0e01e8405ae7501e89c0dab06e6672b5240a831b19e8fbf0604a3171b31e9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c40a0fd8c6cf791e70ba0335c6cf0b2c82b250670ebad59b185e3e8719172409
fcee11f27ff9ae0038e8067712e269ccd49423372f692e9351c24f82906aafad
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
162774155d8b49d53b0c0c055736bd098723c66b02c0e1dc4389985982c57282
5c777d7b146277fa803587d1788a7359b08478def00c4574dd565d784200e39a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
39e3de11f69c09348906878367318157fca9a777f9ac1c4fb6ed303fa058e5a2
825b9ee483796efd24fcfe358b94f4667d87e311475769ec9e41ba9af6d03a6a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4a3f595d2a6a9b79da7e316a3da86c287e9761ea670319c331467e7b10639663
45b8cbd2ed7635ce04651937574dde9cf441dd619d76b8996f522c671950eaee
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
bede1f0eaec356e3e35302e65e85531870625560bc9a3197f1acb58969beb4bd
e456b7ddf087ffbf2df62431bf9df47fad1929079c99a941a5deaf30599109f9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ad2dc0ad12adb9c6b25017b2cef78b7496e96d1bb6e88b24ea9b7b682120e786
e0d8b590ad6ffd9569c6362802b2be909cb34b37b18cc8cc8492f9c460610e20
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2c329cf7af36c9d65ed5b6be7c2a017c7a5a6214a4893cab7ae574acb76fe62f
d2512203db3e5c6df93e9ccd5ff8daed8f1ebd28462b8caf7d500e814d3b6671
+8
Переглянути файл
@@ -75,6 +75,14 @@ impl Plugin for Jobs {
)?;
Ok(None)
}
"notify-project" => {
// The argument is a path in the target project, as a scheduled check would have.
let path = arg.unwrap_or_default();
let project = host::project_of(path).ok_or("no project contains the path")?;
let action = openidea_plugin::NotificationAction::new("Disable", "context").argument(path);
project.notify(NotificationLevel::Info, "Project update", path, &[action])?;
Ok(None)
}
"copy" => {
host::copy_to_clipboard(arg.unwrap_or_default())?;
Ok(None)
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
39c85942177e937e48f32bcde86a0068dac484ba028a8d6785a5a2728db9e11b
b32da5137b552cfad2557716343ed8ac9236b34fec018e3f2facc7b78b67e72a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
30a8cc05a40c7b99a78700430ae87d025ad7567b44384fdb696119aa421f8174
ae58467cdb6e98a491d739884eee96d239f80451de749950d81f4fd480edba58
+3 -3
Переглянути файл
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.4.0;
export openidea:plugin/providers@1.4.0;
export openidea:plugin/file-actions@1.4.0;
include openidea:plugin/plugin-syntax@1.5.0;
export openidea:plugin/providers@1.5.0;
export openidea:plugin/file-actions@1.5.0;
}
",
path: "../../../../../wit",
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5f9c92b23c00269ec2b5dc8829ce526a8fc4c0d76ecb2925ec9a06ffeac5f0b4
e378c85a4fe395c7b172065a33a2f15ad76852ac42d4099d2cf1f64540e13143
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d695409029a66c8a44f524072be1d47ed974fc8810f6fe1d8d068765184d4028
23dc9ab1affb5b0c0166a5bb2bffb8a273a5c9eed0c06d402f16a1440b835639
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d5e81a43dc2c5298d0aaafd70c0bd91ab6cc040d70224d2936349de41253735f
d82fa182c8d5a029451addbf3b9792419446849911949e61052fca181d7758ce
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c29076023ccedf2da8fd9e5a851a47bbe7131b97713c8140a20cd55608a1cc0d
12337d77078aeb678567c0dc87c746a78454656ed52a2e51dd0bb81f19d90da3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
616157bd77dc272f9e2dfed4c54c9d65fca110e55ad01f92b7c4f8a6136537b4
9048b66162a39a518d117cd473f6192827bc05737ff5132324c54eb8295b060b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
022f216f0f4ca5da56f4b2fabf5ee4a987d5e9f9a752feecbb438f9b00b1acf4
274f7e74f19aabf9368c2b9a107494d82a2013d4adda645bde6d9ccda4007769
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
626f0e0c83e69704c0365c273b6f63d43278ee37786f38ed467ce908c30eadf7
e9082dc0f4c9c4fc682e16821d36f7fa42be9c82b75ac28edbee6a869dc86b8b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
aa141701049f5c6c0d045341c3337344ed7a368800f08237b1b7812cce216814
0028a0439e8bb234fb0cc5138e1c27d5c2c8443bed12963a69f0bd8b3818ec7b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d6c40099cc959ef2dead996beeb4acd73c1327597cd029674026c6f2bc878300
f3b633e9e34a5343f318e4e451ffe1c87a6d1ac6d0dc01a57588ea99380dd8d4
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3201f643cf868f7a358ba812f9c90e06b982d864ed141b6b4c3fdeee13cb1559
02fba523571a945b16055e83ee72400f69ecb64238556e933cfd5c2c92c8ea10
+38 -4
Переглянути файл
@@ -37,7 +37,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit, inlay-hints
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.4.0`, world
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.5.0`, world
`plugin`).
## 1. Quick start
@@ -365,9 +365,19 @@ are always available. Calling a gated function without its capability returns
## 3. Writing code (Rust SDK)
### API 1.5
API 1.5.0 is planned for the next OpenIDEA release. Use
`engines.openidea = "^1.5"` and the matching SDK. Released 1.4 plugins keep
working. New in 1.5:
- [Notifications for a project](#notifications-with-commands):
`Project::notify` shows a notification in a window of that project only,
also from scheduled or background work.
### API 1.4
API 1.4.0 is planned for the next OpenIDEA release. Use
API 1.4.0 shipped with OpenIDEA 0.1.40. Use
`engines.openidea = "^1.4"` and the matching SDK. Released 1.3 plugins keep
working. New in 1.4:
@@ -525,6 +535,7 @@ then run `openidea-plugin check`.
| `host::output_append(channel, text)`, `output_clear`, `output_reveal` | `output.{append,clear,reveal}` | API 1.4: read-only Output tabs; 32 named channels per plugin, each a 1 MiB UTF-8 ring buffer. Reveal requires the invoking window. |
| `host::set_context(key, value)`, `remove_context` | `ui.{set,remove}-context` | Sets `plugin.<id>.<key>` for `when` clauses in the window the call came from (`true`, `3.5`, `"text"`; `ContextValue`). Key 1–64 of `A-Za-z0-9_-`, 100 per plugin; gone when the plugin stops. |
| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
| `Project::notify(level, title, body, actions)` | `ui.notify-project` | API 1.5: balloon only in a window of that project (the most recently focused one), with or without an invoking window; see _Notifications with commands_. |
| `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. |
| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. |
| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Picks key(s) from `PickItem`s, or `None` when dismissed. From a command/event or code-action resolution in its editor window (else `invalid`); waiting is excluded from the budget. |
@@ -2254,8 +2265,8 @@ the API itself follows _Evolving the plugin API_ below.
## 10. Evolving the plugin API
Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36, 1.1.0
with 0.1.37, 1.2.0 with 0.1.38 and 1.3.0 with 0.1.39. API 1.4.0 is planned
with the next release. wasmtime decides what stays compatible: it resolves interface names
with 0.1.37, 1.2.0 with 0.1.38, 1.3.0 with 0.1.39 and 1.4.0 with 0.1.40.
API 1.5.0 is planned with the next release. wasmtime decides what stays compatible: it resolves interface names
by semver (a component importing `openidea:plugin/[email protected]` links against a
host defining `@1.1.0`, and the host finds its exports the same way), but
function types must match exactly — a record with another field count, or a
@@ -2382,6 +2393,29 @@ 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.
Both go to the window the command or event came from, or to every window when
there is none (a scheduled call, for instance). To reach one project from such
work, call `notify` on that [`Project`](#the-api) (API 1.5, `ui.notify-project`),
with the same actions and limits:
```rust
for project in host::projects()? {
if let Some(update) = check_for_update(&project)? {
let disable = NotificationAction::new("Disable automatic checks", "disable-checks")
.argument(project.root()?);
project.notify(NotificationLevel::Info, "Update available", &update, &[disable])?;
}
}
```
- It shows only in a window of that project: the most recently focused one
when the project is open in several.
- A button runs its command with an invocation from that window, so
`ctx.project()` is the notification's project.
- If the project has closed by the time of the call, the call fails with
`Error::NotFound` and shows nothing; it never falls back to another window.
A balloon already shown goes away with its window.
### Selecting plugin tests (API 1.4, unreleased)
A plugin using `framework = "plugin"` can implement `TestResults::select_tests`
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e3c11d8969a1b8b52e7e745b558d12f804ebf533e7f0f3fe614ffe6f7b9c9468
bb67a1b003840ecc2d0c056a0b0af3474b08ac095239c1f4d02b7a7aab5b526c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b28ccfd442c6348e0826680ec97391cbe4ec6a93a27ff02e40f47341e21586b4
a6289739f5b644ea559ba18298741205ea0bedce7bd1dd2b367309101e8ffb1e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d669a2600b0c59e91ac8a9b875a24bddf0eeddd404ce7b67da31733861a846b8
767ff8329456fe3a1f324f571b22124707244a61ff5b6ea2c7ed88f909d202e8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a5f0ebce6d30e0dc03769ebe5cb82d3704c5ccc32207aa2b3f5500de7b1a9152
edb2a28419861c6f2d7cd5c300fa9f3120df5c17fb94596814635a9d97bf56d7
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ee3ccb648ca4b98a0359521c4cd895b5e7336fd65101563f2ca82b8cbaeedc3d
debc0eccd6eb6a93bafecb0d6eca8feb26a3436d9154a278b877eaee567dce80
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
51f7167dd5ed21ed37e227bd3b100a5be5a57e7890a494c942fdd036b1307993
6e7cea8633d117d65b293c35bf332d8f4474ab1fdea88aedb37aa189e130857d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
cd860e7a652951aaa42029da23fdeee7a3c09ab1bfcbfb0d1bb6c89036fc8817
e3a0ef77e0c7b54307751d24cc899a0cf782149ebf98833fc1d7b990b369cced
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
db3ee94b5cf4234cee7d61e08a9f8629d7e7f6adbf11208b54cd4ea44cf1fc13
39e75e8fc6d9227dce2efc7e29ffc03d4b1a2364e4710d74a4b255850bef1928
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
76d13e772046b192485ea59c7720177b563986e0167475c449a9a5c7430ef908
bd06b9b59b6515a1fd8a71271a4d8bb5280b685f400e141eba7365e7daabc7ea
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a22c9e7ebf79b2d5926424a028edc09aef40293db9ebc959d9ececa1f0ebe4ca
3e04301385edee3c3693a89860e49b9d933ee3ad22046cd9cda320c10e64cec4
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fcc3d666fd1e89363fa8668dd10f89efc49f2376766282b734de4b719fdc9aff
e60ead150d753c9824eeed0279ebe0a14f7ad226a2b90977ebeba646397b407b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2eb742315f877e3251f992884160ed4d9a2940ee12a7d4d7a52669238d495930
b8d20ee5966943cd298ab16a61b1552ddc523b2669ec2d11ad97bb1a86045ce6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c2feb6a4cd0e9908f078a3d0074732cfce653cc57cce9d1907838598515048ee
65fa71854546a7b19f7afe86642bc1e2c4eee9a7a08a71f87d307c68c95410d0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
590b46034f6c39592a27cabd5b0b76809e7f7133d258f322604614ecb3479317
ce6fdce3010c8d836b452ebac7d0c7e42bf507b35f814ff448696a0541788d4a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0d068ea0f0851e6e3166af0a91f290da8dc3f4a852b86050e7f7ced214a39124
1d3689b3b18946e41753390f5e37cd6870374884831c5c5e0fd66606a1bfb659
+37
Переглянути файл
@@ -82,6 +82,29 @@ mod imp {
wit::ui::notify(level, title, body, &actions).map_err(error)
}
pub fn notify_project(
p: &ProjectRes,
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_project(p, 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(),
@@ -939,6 +962,20 @@ impl Project {
imp::vcs_diff(&self.inner, path, against)
}
/// [`notify_with_actions`] in a window of this project only (API 1.5), also from
/// scheduled or background work: the most recently focused one when it is open in
/// several, and its buttons run there. Once the project has closed this is
/// [`Error::NotFound`] and nothing is shown. Needs no capability.
pub fn notify(
&self,
level: NotificationLevel,
title: &str,
body: &str,
actions: &[NotificationAction],
) -> Result<(), Error> {
imp::notify_project(&self.inner, level, title, body, actions)
}
/// Absolute path of the root. Needs `project.read`.
pub fn root(&self) -> Result<String, Error> {
imp::project_root(&self.inner)
+31 -31
Переглянути файл
@@ -98,7 +98,7 @@ pub mod providers_bindings {
export_macro_name: "__export_editor_providers_world",
default_bindings_module: "openidea_plugin::providers_bindings",
with: {
"openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types,
},
});
}
@@ -115,10 +115,10 @@ pub mod views_bindings {
export_macro_name: "__export_views_world",
default_bindings_module: "openidea_plugin::views_bindings",
with: {
"openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.4.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.4.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.5.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.5.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.5.0": crate::bindings::openidea::plugin::invocation,
},
});
}
@@ -130,7 +130,7 @@ pub mod indexing_bindings {
world: "indexing", path: "wit", pub_export_macro: true,
export_macro_name: "__export_indexing_world",
default_bindings_module: "openidea_plugin::indexing_bindings",
with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types },
with: { "openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types },
});
}
@@ -142,10 +142,10 @@ pub mod details_bindings {
export_macro_name: "__export_details_world",
default_bindings_module: "openidea_plugin::details_bindings",
with: {
"openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.4.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.4.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.5.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.5.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.5.0": crate::bindings::openidea::plugin::invocation,
},
});
}
@@ -157,7 +157,7 @@ pub mod semantic_bindings {
world: "semantic-tokens", path: "wit", pub_export_macro: true,
export_macro_name: "__export_semantic_tokens_world",
default_bindings_module: "openidea_plugin::semantic_bindings",
with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types },
with: { "openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types },
});
}
@@ -169,8 +169,8 @@ pub mod diagnostics_bindings {
export_macro_name: "__export_diagnostics_world",
default_bindings_module: "openidea_plugin::diagnostics_bindings",
with: {
"openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/diagnostics@1.4.0": crate::bindings::openidea::plugin::diagnostics,
"openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/diagnostics@1.5.0": crate::bindings::openidea::plugin::diagnostics,
},
});
}
@@ -182,7 +182,7 @@ pub mod hover_bindings {
world: "hover", path: "wit", pub_export_macro: true,
export_macro_name: "__export_hover_world",
default_bindings_module: "openidea_plugin::hover_bindings",
with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types },
with: { "openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types },
});
}
@@ -194,11 +194,11 @@ pub mod commit_checks_bindings {
export_macro_name: "__export_commit_checks_world",
default_bindings_module: "openidea_plugin::commit_checks_bindings",
with: {
"openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.4.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.4.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/commit@1.4.0": crate::bindings::openidea::plugin::commit,
"openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.5.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.5.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.5.0": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/commit@1.5.0": crate::bindings::openidea::plugin::commit,
},
});
}
@@ -210,7 +210,7 @@ pub mod inlay_bindings {
world: "inlay-hints", path: "wit", pub_export_macro: true,
export_macro_name: "__export_inlay_hints_world",
default_bindings_module: "openidea_plugin::inlay_bindings",
with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types },
with: { "openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types },
});
}
@@ -221,7 +221,7 @@ pub mod file_actions_bindings {
world: "file-actions-provider", path: "wit", pub_export_macro: true,
export_macro_name: "__export_file_actions_world",
default_bindings_module: "openidea_plugin::file_actions_bindings",
with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types },
with: { "openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types },
});
}
@@ -233,16 +233,16 @@ pub mod endpoints_bindings {
export_macro_name: "__export_endpoints_world",
default_bindings_module: "openidea_plugin::endpoints_bindings",
with: {
"openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.4.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.4.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.5.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.5.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.5.0": crate::bindings::openidea::plugin::invocation,
},
});
}
/// The plugin API version this SDK targets.
pub const API_VERSION: &str = "1.3";
pub const API_VERSION: &str = "1.5";
/// A plugin. One instance lives as long as the plugin's Wasm instance (until
/// it is disabled, crashes or is reclaimed when idle).
@@ -1304,7 +1304,7 @@ pub mod run_configurations_bindings {
pub mod test_results_bindings {
wit_bindgen::generate!({ world: "test-results-provider", path: "wit", pub_export_macro: true,
export_macro_name: "__export_tests_world", default_bindings_module: "openidea_plugin::test_results_bindings",
with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types },
with: { "openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types },
});
}
#[doc(hidden)]
@@ -1342,7 +1342,7 @@ pub mod language_features;
pub mod language_features_bindings {
wit_bindgen::generate!({world: "editor-language-features", path: "wit", pub_export_macro: true,
export_macro_name: "__export_language_features_world", default_bindings_module: "openidea_plugin::language_features_bindings",
with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types },
with: { "openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types },
});
}
@@ -1374,7 +1374,7 @@ fn gutter_actions(doc: $crate::language_features_bindings::exports::openidea::pl
pub mod injections_bindings {
wit_bindgen::generate!({world: "language-injections", path: "wit", pub_export_macro: true,
export_macro_name: "__export_injections_world", default_bindings_module: "openidea_plugin::injections_bindings",
with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types },
with: { "openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types },
});
}
@@ -1404,8 +1404,8 @@ pub mod mcp_bindings {
wit_bindgen::generate!({ world: "mcp-tools", path: "wit", pub_export_macro: true,
export_macro_name: "__export_mcp_world", default_bindings_module: "openidea_plugin::mcp_bindings",
with: {
"openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/types@1.5.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/invocation@1.5.0": crate::bindings::openidea::plugin::invocation,
},
});
}
+53 -5
Переглянути файл
@@ -96,6 +96,7 @@ struct State {
selected_paths: Vec<String>,
notifications: Vec<Notification>,
action_notifications: Vec<ActionNotification>,
project_notifications: Vec<(String, ActionNotification)>,
logs: Vec<(LogLevel, String)>,
status: BTreeMap<String, StatusItem>,
window_status: BTreeMap<String, StatusItem>,
@@ -341,6 +342,11 @@ impl MockHost {
self.state.borrow().action_notifications.clone()
}
/// Notifications sent to a project (`Project::notify`), with its root, in publication order.
pub fn project_notifications(&self) -> Vec<(String, ActionNotification)> {
self.state.borrow().project_notifications.clone()
}
pub fn logs(&self) -> Vec<(LogLevel, String)> {
self.state.borrow().logs.clone()
}
@@ -773,6 +779,35 @@ pub mod current {
body: &str,
actions: &[NotificationAction],
) -> Result<(), Error> {
check_actions(actions)?;
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(())
}
/// The checks of `notify_with_actions`; a project no longer open is `NotFound`.
pub fn notify_project(
p: &ProjectRes,
level: NotificationLevel,
title: &str,
body: &str,
actions: &[NotificationAction],
) -> Result<(), Error> {
let open = with(|s| s.root.iter().chain(&s.other_projects).any(|root| *root == p.root));
if !open {
return Err(Error::NotFound(format!("the project {} is not open", p.root)));
}
check_actions(actions)?;
let notification = Notification { level, title: title.into(), body: body.into() };
let sent = ActionNotification { notification, actions: actions.to_vec() };
with(|s| s.project_notifications.push((p.root.clone(), sent)));
Ok(())
}
fn check_actions(actions: &[NotificationAction]) -> Result<(), Error> {
if actions.len() > 3 {
return Err(Error::Invalid("a notification has at most 3 actions".into()));
}
@@ -784,11 +819,6 @@ pub mod current {
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(())
}
@@ -2371,6 +2401,24 @@ mod tests {
assert_eq!(mock.notifications().len(), 1);
}
#[test]
fn project_notifications_need_an_open_project() {
let mock = MockHost::install();
mock.add_project("/other");
let action = NotificationAction::new("Disable", "disable").argument("/other");
let other = host::project_of("/other/a.txt").unwrap();
other.notify(NotificationLevel::Info, "Update", "", std::slice::from_ref(&action)).unwrap();
let sent = mock.project_notifications();
assert_eq!((sent[0].0.as_str(), &sent[0].1.actions), ("/other", &vec![action.clone()]));
assert!(mock.notifications().is_empty() && mock.notifications_with_actions().is_empty());
let too_many: [_; 4] = std::array::from_fn(|_| action.clone());
assert!(matches!(other.notify(NotificationLevel::Info, "Update", "", &too_many), Err(Error::Invalid(_))));
let project = host::project_of("/project/a.txt").unwrap();
mock.set_project_root(None);
assert!(matches!(project.notify(NotificationLevel::Info, "Update", "", &[]), Err(Error::NotFound(_))));
assert_eq!(mock.project_notifications().len(), 1);
}
#[test]
fn files_outside_the_project_need_an_fs_read_grant() {
let mock = MockHost::install().with_capabilities(&["fs.read:/opt/tool"]);
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.4.0;
package openidea:plugin@1.5.0;
/// The commit being prepared in the window the current command or event came
/// from (the Commit tool window, or the Commit dialog while it is open): the
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.4.0;
package openidea:plugin@1.5.0;
/// Plugin-owned project data sources. Requires database.sources and project trust.
/// Runs on the Database subsystem's host, including on remote project servers.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.4.0;
package openidea:plugin@1.5.0;
/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside
/// an open project. Sets belong to the plugin and disappear when it stops.
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more