//! The IDE functions and handles a plugin may use. Gated ones need the //! capability in `plugin.toml` (`[capabilities] required = [...]`) and the //! user's grant; without it they return [`Error::Denied`]. use crate::types::*; #[cfg(target_arch = "wasm32")] mod imp { //! Calls through the `openidea:plugin@1.0.0` world. use crate::bindings::openidea::plugin as wit; use crate::types::*; pub type InvocationRef<'a> = &'a wit::invocation::Invocation; pub type ProjectRes = wit::workspace::Project; pub type EditorRes = wit::editor::TextEditor; pub type TaskRes = wit::tasks::Task; pub fn error(e: wit::types::Error) -> Error { use wit::types::Error as E; match e { E::Denied(c) => Error::Denied(c), E::Stale => Error::Stale, E::NotFound(m) => Error::NotFound(m), E::Invalid(m) => Error::Invalid(m), E::Cancelled => Error::Cancelled, E::Failed(m) => Error::Failed(m), } } fn range_in(r: wit::types::Range) -> Range { Range::new(Position::new(r.start.line, r.start.character), Position::new(r.end.line, r.end.character)) } fn range_out(r: Range) -> wit::types::Range { wit::types::Range { start: wit::types::Position { line: r.start.line, character: r.start.character }, end: wit::types::Position { line: r.end.line, character: r.end.character }, } } pub fn log(level: LogLevel, message: &str) { use wit::types::LogLevel as L; let level = match level { LogLevel::Trace => L::Trace, LogLevel::Debug => L::Debug, LogLevel::Info => L::Info, LogLevel::Warn => L::Warn, LogLevel::Error => L::Error, }; wit::log::log(level, message) } pub fn notify(level: NotificationLevel, title: &str, body: &str) { use wit::types::NotificationLevel as N; let level = match level { NotificationLevel::Info => N::Info, NotificationLevel::Warning => N::Warning, NotificationLevel::Error => N::Error, }; 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(), tooltip: item.tooltip.clone(), command: item.command.clone(), }; wit::ui::set_status_item(id, &item) } pub fn remove_status_item(id: &str) { wit::ui::remove_status_item(id) } pub fn set_context(key: &str, value: ContextValue) -> Result<(), Error> { let value = match value { ContextValue::Flag(b) => wit::ui::ContextValue::Flag(b), ContextValue::Number(n) => wit::ui::ContextValue::Number(n), ContextValue::Text(t) => wit::ui::ContextValue::Text(t), }; wit::ui::set_context(key, &value).map_err(error) } pub fn remove_context(key: &str) { wit::ui::remove_context(key) } pub fn copy_to_clipboard(text: &str) -> Result<(), Error> { wit::ui::copy_to_clipboard(text).map_err(error) } pub fn open_url(url: &str) -> Result<(), Error> { wit::ui::open_url(url).map_err(error) } fn pick_items(items: &[PickItem]) -> Vec { items .iter() .map(|i| wit::ui::PickItem { key: i.key.clone(), label: i.label.clone(), description: i.description.clone(), }) .collect() } pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { wit::ui::pick(title, &pick_items(items)).map_err(error) } pub fn pick_directory(title: &str, initial: Option<&str>) -> Result, Error> { wit::ui::pick_directory(title, initial).map_err(error) } pub fn open_project(path: &str, new_window: bool) -> Result<(), Error> { wit::workspace::open_project(path, new_window).map_err(error) } pub fn pick_many(title: &str, items: &[PickItem]) -> Result>, Error> { wit::ui::pick_many(title, &pick_items(items)).map_err(error) } pub fn input(prompt: &str, value: Option<&str>) -> Result, Error> { wit::ui::input(prompt, value).map_err(error) } pub fn input_multiline(prompt: &str, value: Option<&str>) -> Result, Error> { wit::ui::input_multiline(prompt, value).map_err(error) } pub fn confirm(title: &str, body: &str) -> Result { wit::ui::confirm(title, body).map_err(error) } pub fn reveal_view(view: &str) -> Result<(), Error> { wit::views::reveal(view).map_err(error) } pub fn locale() -> String { wit::ui::locale() } pub fn storage_get(key: &str) -> Result, Error> { wit::storage::get(key).map_err(error) } pub fn storage_set(key: &str, value: &str) -> Result<(), Error> { wit::storage::set(key, value).map_err(error) } pub fn storage_delete(key: &str) -> Result<(), Error> { wit::storage::delete(key).map_err(error) } pub fn storage_keys(prefix: &str) -> Result, Error> { wit::storage::keys(prefix).map_err(error) } pub fn projects() -> Result, Error> { wit::workspace::projects().map_err(error) } pub fn project_root(p: &ProjectRes) -> Result { p.root().map_err(error) } pub fn project_read(p: &ProjectRes, path: &str) -> Result { p.read_file(path).map_err(error) } pub fn invocation_project(i: InvocationRef<'_>) -> Option { i.project() } pub fn invocation_editor(i: InvocationRef<'_>) -> Option { i.editor() } pub fn invocation_selected_paths(i: InvocationRef<'_>) -> Vec { i.selected_paths() } pub fn editor_path(e: &EditorRes) -> String { e.path() } pub fn editor_language(e: &EditorRes) -> String { e.language() } pub fn editor_document(e: &EditorRes) -> Result { e.document().map(|d| Document { version: d.version, text: d.text }).map_err(error) } pub fn editor_selections(e: &EditorRes) -> Result, Error> { e.selections().map(|s| s.into_iter().map(range_in).collect()).map_err(error) } pub fn editor_apply(e: &EditorRes, version: u64, edits: &[TextEdit]) -> Result { let edits: Vec = edits.iter().map(|t| wit::types::TextEdit { range: range_out(t.range), text: t.text.clone() }).collect(); e.apply_edits(version, &edits).map_err(error) } pub fn vcs_status(p: &ProjectRes) -> Result, Error> { wit::vcs::status(p) .map(|s| { s.map(|s| RepositoryStatus { root: s.root, branch: s.branch, commit: s.commit, upstream: s.upstream, ahead: s.ahead, behind: s.behind, operation: s.operation, changed_files: s.changed_files, }) }) .map_err(error) } pub fn vcs_fetch(p: &ProjectRes) -> Result<(), Error> { wit::vcs::fetch(p).map_err(error) } pub fn vcs_remotes(p: &ProjectRes) -> Result, Error> { wit::vcs::remotes(p).map_err(error) } pub fn pending_commit() -> Result, Error> { wit::commit::pending_commit().map(|d| d.map(__draft_in)).map_err(error) } pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> { wit::commit::set_commit_message(revision, message).map_err(error) } #[doc(hidden)] pub fn __draft_in(d: wit::commit::CommitDraft) -> CommitDraft { CommitDraft { revision: d.revision, message: d.message, amend: d.amend, changes: d .changes .into_iter() .map(|c| CommitChange { repository: c.repository, path: c.path, old_path: c.old_path, status: c.status, diff: c.diff, truncated: c.truncated, }) .collect(), } } pub fn subscribe(k: EventKind) -> Result<(), Error> { wit::events::subscribe(k.name()).map_err(error) } pub fn unsubscribe(k: EventKind) { wit::events::unsubscribe(k.name()) } pub fn begin_task(title: &str, cancellable: bool) -> TaskRes { wit::tasks::begin(title, cancellable) } pub fn task_report(t: &TaskRes, done: u32, total: Option, message: Option<&str>) { t.report(done, total, message) } pub fn task_cancelled(t: &TaskRes) -> bool { t.is_cancelled() } pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result, Error> { p.find_files(glob, limit).map_err(error) } pub fn project_exists(p: &ProjectRes, path: &str) -> Result { p.exists(path).map_err(error) } pub fn project_stat(p: &ProjectRes, path: &str) -> Result { use wit::workspace::FileKind as K; p.stat(path) .map(|s| FileStat { kind: match s.kind { K::File => FileKind::File, K::Directory => FileKind::Directory, K::Symlink => FileKind::Symlink, K::Other => FileKind::Other, }, size: s.size, modified: s.modified, }) .map_err(error) } pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { use wit::diagnostics::{Diagnostic as D, RelatedInformation as R, Severity as S}; let items: Vec = items .iter() .map(|d| D { range: range_out(d.range), severity: match d.severity { Severity::Error => S::Error, Severity::Warning => S::Warning, Severity::Info => S::Info, Severity::Hint => S::Hint, }, message: d.message.clone(), code: d.code.clone(), source: d.source.clone(), tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")] .into_iter() .filter(|(on, _)| *on) .map(|(_, tag)| tag.to_owned()) .collect(), related: d .related .iter() .map(|r| R { location: wit::types::Location { path: r.location.path.clone(), range: range_out(r.location.range), }, message: r.message.clone(), }) .collect(), }) .collect(); wit::diagnostics::publish(path, &items).map_err(error) } pub fn fetch(req: &HttpRequest) -> Result { let r = wit::net::Request { method: req.method.clone(), url: req.url.clone(), headers: req.headers.iter().map(|(n, v)| wit::net::Header { name: n.clone(), value: v.clone() }).collect(), body: req.body.clone(), timeout_ms: req.timeout_ms, }; wit::net::fetch(&r) .map(|r| HttpResponse { status: r.status, headers: r.headers.into_iter().map(|h| (h.name, h.value)).collect(), body: r.body, }) .map_err(error) } pub fn secret_is_set(name: &str) -> bool { wit::secrets::is_set(name) } pub fn refresh_view(view: &str) { wit::views::refresh(view) } pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> { wit::views::post_message(view, message).map_err(error) } pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { wit::views::open_terminal(title, program, args).map_err(error) } pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> { wit::views::open_location(path, line, column).map_err(error) } pub fn setting(key: &str) -> Option { wit::settings::get(key) } pub fn run_process( program: &str, args: &[String], cwd: Option<&ProjectRes>, stdin: Option<&str>, ) -> Result { wit::process::run(program, args, cwd, stdin) .map(|o| ProcessOutput { status: o.status, stdout: o.stdout, stderr: o.stderr }) .map_err(error) } pub fn range_from(r: wit::types::Range) -> Range { range_in(r) } pub fn range_to(r: Range) -> wit::types::Range { range_out(r) } pub fn event(e: wit::events::Event) -> Event { use wit::events::Event as E; match e { E::FileOpened(p) => Event::FileOpened(p), E::FileSaved(p) => Event::FileSaved(p), E::EditorFocused(p) => Event::EditorFocused(p), E::VcsChanged(p) => Event::VcsChanged(p), E::Scheduled => Event::Scheduled, E::ActionPerformed(a) => Event::ActionPerformed(ActionPerformed { action: a.action, title: a.title, source: ActionSource::parse(&a.source), shortcut: a.shortcut, }), E::Other(x) => Event::Other { kind: x.kind, data: x.data }, } } } #[cfg(not(target_arch = "wasm32"))] use crate::mock::current as imp; /// Where a command or event batch comes from: the window's project and /// focused editor. pub struct Invocation<'a> { inner: imp::InvocationRef<'a>, } impl<'a> Invocation<'a> { #[doc(hidden)] pub fn __new(inner: imp::InvocationRef<'a>) -> Self { Self { inner } } /// The window's project. pub fn project(&self) -> Option { imp::invocation_project(self.inner).map(|inner| Project { inner }) } /// The window's focused editor. pub fn editor(&self) -> Option { imp::invocation_editor(self.inner).map(|inner| TextEditor { inner }) } /// The files and folders the command was run on, as absolute paths. Empty when it was not /// run on any, or when the menu was for a tab without a file (a virtual document). pub fn selected_paths(&self) -> Vec { imp::invocation_selected_paths(self.inner) } } /// A project open in the IDE. pub struct Project { inner: imp::ProjectRes, } impl Project { /// Absolute path of the root. Needs `project.read`. pub fn root(&self) -> Result { imp::project_root(&self.inner) } /// Reads a UTF-8 file inside the project (absolute or root-relative); /// unsaved editor contents win. A missing path is `Error::NotFound`. /// Needs `project.read`. pub fn read_file(&self, path: &str) -> Result { imp::project_read(&self.inner, path) } /// Whether `path` names an existing file or directory inside the project /// — including hidden and ignored ones — without reading any content. /// Needs `project.read`. pub fn exists(&self, path: &str) -> Result { imp::project_exists(&self.inner, path) } /// Metadata of an existing path inside the project; a missing path is /// `Error::NotFound`. Needs `project.read`. pub fn stat(&self, path: &str) -> Result { imp::project_stat(&self.inner, path) } /// The repository containing the project (`None`: not under git). Needs `vcs.read`. pub fn vcs_status(&self) -> Result, Error> { imp::vcs_status(&self.inner) } /// Configured remotes (`(name, fetch_url)`), in order. Needs `vcs.read`. pub fn vcs_remotes(&self) -> Result, Error> { imp::vcs_remotes(&self.inner) } /// Fetches all remotes. Needs `vcs.write`; long-running, so call it in a [`Task`]. pub fn vcs_fetch(&self) -> Result<(), Error> { imp::vcs_fetch(&self.inner) } /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), /// at most `limit`, as absolute paths; ignored files are skipped. Needs `project.read`. pub fn find_files(&self, glob: &str, limit: u32) -> Result, Error> { imp::project_find(&self.inner, glob, limit) } /// Runs the approved `process:` in this project's root. pub fn run_process(&self, program: &str, args: &[String], stdin: Option<&str>) -> Result { imp::run_process(program, args, Some(&self.inner), stdin) } } #[cfg(target_arch = "wasm32")] #[doc(hidden)] pub use imp::__draft_in; /// The commit being prepared in the window the command or event came from: /// the included files and changed blocks only, as Commit would commit them /// (unsaved editors of those files are saved first). `None` when the window's /// project has no repository. Only from a command or event of a window, in a /// trusted project. Needs `vcs.read`. pub fn pending_commit() -> Result, Error> { imp::pending_commit() } /// Replaces the commit message of that window, only while its draft is still /// at `revision` ([`CommitDraft::revision`]); [`Error::Stale`] when the user /// changed the message or the included changes meanwhile, and nothing changes. /// Needs `vcs.write`. pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> { imp::set_commit_message(revision, message) } /// Replaces the plugin's diagnostics for `path` (absolute, in an open /// project); an empty slice clears them. Needs `project.read`. pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { imp::publish_diagnostics(path, items) } /// HTTP(S) to a host granted with `net:` (plain http only to /// localhost). `{{secret:}}` in a header is filled in by the IDE (needs /// `secrets`). `Error::Cancelled` when the call was superseded. pub fn fetch(request: &HttpRequest) -> Result { imp::fetch(request) } /// Whether the user stored secret `name` (declared under `[[contributes.secrets]]`). pub fn secret_is_set(name: &str) -> bool { imp::secret_is_set(name) } /// The content of view (or custom editor) `view` changed: the IDE renders it again. pub fn refresh_view(view: &str) { imp::refresh_view(view) } /// Sends `message` to the script of view `view`'s HTML body. pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> { imp::post_view_message(view, message) } /// Opens a terminal tab in the invoking window running the approved /// `process:` in the project root: logs that keep coming, /// interactive sessions (`kubectl exec -it …`). pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { imp::open_terminal(title, program, args) } pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> { imp::open_location(path, line, column) } /// One of the plugin's settings (`[contributes.settings]`) as JSON: the /// user's value (project over global) or the schema's default. pub fn setting(key: &str) -> Option { imp::setting(key) } /// A boolean setting. pub fn setting_bool(key: &str) -> Option { setting(key).and_then(|v| v.parse().ok()) } /// A string setting. pub fn setting_string(key: &str) -> Option { let v = setting(key)?; let inner = v.strip_prefix('"')?.strip_suffix('"')?; Some(crate::text::unescape_json(inner)) } /// A number setting. pub fn setting_number(key: &str) -> Option { setting(key).and_then(|v| v.parse().ok()) } /// Runs the approved `process:` in the plugin's data directory /// (see [`Project::run_process`] to run it in a project). pub fn run_process(program: &str, args: &[String], stdin: Option<&str>) -> Result { imp::run_process(program, args, None, stdin) } /// Projects open in any window. Needs `project.read`. pub fn projects() -> Result, Error> { Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect()) } /// A file open in an editor. pub struct TextEditor { inner: imp::EditorRes, } impl TextEditor { pub fn path(&self) -> String { imp::editor_path(&self.inner) } pub fn language(&self) -> String { imp::editor_language(&self.inner) } /// Current version and text. Needs `editor.read`. pub fn document(&self) -> Result { imp::editor_document(&self.inner) } /// Selections when the command was invoked. Needs `editor.read`. pub fn selections(&self) -> Result, Error> { imp::editor_selections(&self.inner) } /// Applies `edits` (in `version`'s coordinates, not overlapping) as one /// undoable change; [`Error::Stale`] if the document changed meanwhile. /// Returns the new version. Needs `editor.write`. pub fn apply_edits(&self, version: u64, edits: &[TextEdit]) -> Result { imp::editor_apply(&self.inner, version, edits) } /// Replaces every selection with `f(selected text)` in one undoable /// change, retrying once if the user typed in between. Cursors get /// `f("")` inserted. Needs `editor.read` and `editor.write`. pub fn transform_selections(&self, mut f: impl FnMut(&str) -> String) -> Result { let selections = self.selections()?; let mut last = Error::Stale; for _ in 0..2 { let doc = self.document()?; let mut edits = Vec::with_capacity(selections.len()); for r in &selections { let old = doc.text_in(*r).ok_or_else(|| Error::Invalid("selection outside the document".into()))?; let new = f(old); if new != old { edits.push(TextEdit { range: *r, text: new }); } } match self.apply_edits(doc.version, &edits) { Err(Error::Stale) => last = Error::Stale, other => return other, } } Err(last) } } /// Long-running work shown in the status bar; dropping it ends it. While a /// task is open, the current call may run for up to 60 s instead of 5 s. pub struct Task { inner: imp::TaskRes, } impl Task { /// `done` of `total` units (`None`: indeterminate), with a short status. pub fn report(&self, done: u32, total: Option, message: Option<&str>) { imp::task_report(&self.inner, done, total, message) } /// The user cancelled it: stop soon and return. pub fn is_cancelled(&self) -> bool { imp::task_cancelled(&self.inner) } } /// Starts a task (see [`Task`]). pub fn begin_task(title: &str, cancellable: bool) -> Task { Task { inner: imp::begin_task(title, cancellable) } } /// Writes to the IDE log, tagged with the plugin id. pub fn log(level: LogLevel, message: &str) { imp::log(level, message) } /// Shows a notification balloon in the window the command or events came /// from (every window when there is none). pub fn notify(level: NotificationLevel, title: &str, body: &str) { imp::notify(level, title, body) } /// Shows or updates the plugin's status bar item `id` (at most 8). pub fn set_status_item(id: &str, item: &StatusItem) { imp::set_status_item(id, item) } pub fn remove_status_item(id: &str) { imp::remove_status_item(id) } /// Sets the plugin's context key that `when` clauses test as `plugin..`, in /// the window the current command or event came from (every window when there is /// none): `set_context("connected", true)`. A window's value takes precedence over /// the global one. The key is 1-64 of `A-Za-z0-9_-`; at most 100 keys. pub fn set_context(key: &str, value: impl Into) -> Result<(), Error> { imp::set_context(key, value.into()) } /// Removes the key [`set_context`] set in the same scope. pub fn remove_context(key: &str) { imp::remove_context(key) } /// Copies `text` to the system clipboard. Needs `clipboard`. pub fn copy_to_clipboard(text: &str) -> Result<(), Error> { imp::copy_to_clipboard(text) } /// Opens an `http://` or `https://` URL in the user's default browser. pub fn open_url(url: &str) -> Result<(), Error> { imp::open_url(url) } /// Shows a list in the window the current command or event came from and /// waits for the key of the chosen item, or `None` when the user dismisses /// it. Needs no capability; [`Error::Invalid`] outside a command or event of a /// window (and in editor providers). The wait doesn't count against the /// call's time budget, but the plugin handles nothing else meanwhile. pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { imp::pick(title, items) } /// Like [`pick`], with any number of items chosen. pub fn pick_many(title: &str, items: &[PickItem]) -> Result>, Error> { imp::pick_many(title, items) } /// Asks for a line of text, pre-filled with `value`; `None` when dismissed. /// Same rules as [`pick`]. pub fn input(prompt: &str, value: Option<&str>) -> Result, Error> { imp::input(prompt, value) } /// Asks for text over several lines, pre-filled with `value`; `None` when /// dismissed. Same rules as [`input`]; the dialog confirms on Cmd/Ctrl+Enter. pub fn input_multiline(prompt: &str, value: Option<&str>) -> Result, Error> { imp::input_multiline(prompt, value) } /// Asks for confirmation: `true` on OK, `false` on Cancel or Escape. /// Same rules as [`pick`]. pub fn confirm(title: &str, body: &str) -> Result { imp::confirm(title, body) } /// Brings one of the plugin's declared views to the front in the window the /// current command or event came from. pub fn reveal_view(view: &str) -> Result<(), Error> { imp::reveal_view(view) } /// The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`, …), for the /// text the plugin builds itself (notifications, error messages, HTML). The /// strings of the manifest and of views are translated by the IDE from the /// package's `locales/.json`. Read it when the text is made: it follows /// a change of language. pub fn locale() -> String { imp::locale() } /// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of /// the session (also after the instance is reclaimed: an event activates the /// plugin again). Needs `events` (`events.input` for `ActionPerformed`). pub fn subscribe(kind: EventKind) -> Result<(), Error> { imp::subscribe(kind) } pub fn unsubscribe(kind: EventKind) { imp::unsubscribe(kind) } /// The plugin's persistent key-value store (≤ 64 MiB of UTF-8 keys and values). /// Needs `storage`; rejected writes preserve the stored data. pub mod storage { use super::imp; use crate::types::Error; pub fn get(key: &str) -> Result, Error> { imp::storage_get(key) } pub fn set(key: &str, value: &str) -> Result<(), Error> { imp::storage_set(key, value) } pub fn delete(key: &str) -> Result<(), Error> { imp::storage_delete(key) } /// Keys starting with `prefix`, sorted. pub fn keys(prefix: &str) -> Result, Error> { imp::storage_keys(prefix) } } #[cfg(target_arch = "wasm32")] #[doc(hidden)] pub use imp::{event as __event, range_from as __range_from, range_to as __range_to}; /// Chooses a directory on the UI machine. `None` when dismissed; no capability. /// Same window/command/event rules as [`pick`]. pub fn pick_directory(title: &str, initial: Option<&str>) -> Result, Error> { imp::pick_directory(title, initial) } /// Requests the normal File > Open flow, including project trust, in the /// invoking window (`false`) or a new window (`true`). No capability. /// Only commands/events of a window can request this; errors opening the /// project are shown by that window. `path` belongs to the UI machine. 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) }