feat(plugins): add vcs.remotes, ui.copy-to-clipboard and ui.open-url (#1366)

Plugins get three host calls in API 1.1: vcs.remotes (under vcs.read) lists the
repository's remotes, ui.copy-to-clipboard and ui.open-url act on the window that
invoked the plugin's command.

Remote URLs are returned without the user:password part they may carry, and the
two UI calls fail when there is no invoking window instead of reaching every window.

Co-authored-by: dengliu <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-05 13:49:59 +08:00
1 джерело 47f7e07a20
коміт 4557811ef8
63 files changed
+348 -54

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.0.0";
pub const ENGINE_VERSION: &str = "1.1.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.
+57
Переглянути файл
@@ -324,6 +324,18 @@ impl HostApi for PluginApi {
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
}
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 });
Ok(())
}
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
let client = from_window(client, "open a URL")?;
self.shared.sink.emit(Some(client), PluginEvent::OpenUrl { plugin_id: self.id().to_owned(), url });
Ok(())
}
fn status_item(&self, id: &str, item: Option<StatusItem>) {
let key = (self.id().to_owned(), id.to_owned());
{
@@ -418,6 +430,12 @@ impl HostApi for PluginApi {
}))
}
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(Vec::new()) };
let remotes = repo.remotes().map_err(failed)?;
Ok(remotes.into_iter().map(|r| (r.name, without_credentials(&r.url))).collect())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
let workspace = self.shared.workspace();
if !workspace.is_some_and(|w| w.trusted(root)) {
@@ -812,3 +830,42 @@ mod tests {
);
}
}
/// The window a user action invoked the plugin from: browser tabs and clipboard writes
/// follow what the user did, never background work (which would reach every window).
fn from_window<'a>(client: Option<&'a str>, what: &str) -> Result<&'a str, HostError> {
client.ok_or_else(|| HostError::Invalid(format!("can only {what} from a command the user invoked")))
}
/// `url` without the `user:password@` a remote may carry (`https://user:token@host/…`):
/// `vcs.read` must not hand out credentials. scp-style `git@host:path` has no password.
fn without_credentials(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else { return url.to_owned() };
let authority_end = rest.find('/').unwrap_or(rest.len());
match rest[..authority_end].rfind('@') {
Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
None => url.to_owned(),
}
}
#[cfg(test)]
mod credential_tests {
use super::*;
#[test]
fn remote_urls_lose_their_credentials() {
for (url, expected) in [
("https://user:[email protected]/o/r.git", "https://github.com/o/r.git"),
("https://[email protected]/o/r", "https://gitlab.com/o/r"),
("ssh://git@host:2222/o/r.git", "ssh://host:2222/o/r.git"),
("https://github.com/o/r.git", "https://github.com/o/r.git"),
("https://github.com/o/r@v1", "https://github.com/o/r@v1"),
("[email protected]:o/r.git", "[email protected]:o/r.git"),
("/srv/git/r.git", "/srv/git/r.git"),
] {
assert_eq!(without_credentials(url), expected, "{url}");
}
assert!(matches!(from_window(None, "open a URL"), Err(HostError::Invalid(_))));
assert_eq!(from_window(Some("main"), "open a URL").unwrap(), "main");
}
}
+2
Переглянути файл
@@ -115,6 +115,8 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
)
}
+6
Переглянути файл
@@ -197,6 +197,12 @@ pub enum PluginEvent {
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
/// Copy `text` to the system clipboard.
#[serde(rename_all = "camelCase")]
CopyToClipboard { plugin_id: String, text: String },
/// Open `url` (`http(s)://`) in the system browser.
#[serde(rename_all = "camelCase")]
OpenUrl { plugin_id: String, url: String },
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
+56
Переглянути файл
@@ -2077,6 +2077,62 @@ fn views_open_project_files_at_a_line() {
assert!(opened.is_none());
}
#[test]
fn vcs_remotes_clipboard_and_open_url() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
// Not a git repository yet: `vcs-remotes` returns an empty list.
assert_eq!(f.run("jobs", "remotes", None).unwrap().as_deref(), Some(""));
git(&f.project, &["init", "-b", "main"]);
git(&f.project, &["remote", "add", "origin", "[email protected]:l0ng-ai/openidea.git"]);
git(&f.project, &["remote", "add", "upstream", "https://github.com/org/openidea"]);
assert_eq!(
f.run("jobs", "remotes", None).unwrap().as_deref(),
Some("[email protected]:l0ng-ai/openidea.git\nupstream=https://github.com/org/openidea")
);
f.host.set_capability("jobs", "vcs.read", false).unwrap();
assert!(f.run("jobs", "remotes", None).unwrap_err().contains("vcs.read"), "needs vcs.read");
// `ui.copy-to-clipboard` requires `clipboard` and addresses the calling window.
f.take_events();
f.run("jobs", "copy", Some("https://github.com/l0ng-ai/openidea")).unwrap();
let copied = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::CopyToClipboard { plugin_id, text } => Some((client, plugin_id, text)),
_ => None,
});
assert_eq!(copied, Some((Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea".into())));
f.host.set_capability("jobs", "clipboard", false).unwrap();
f.take_events();
assert!(f.run("jobs", "copy", Some("secret")).unwrap_err().contains("clipboard"), "needs clipboard");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::CopyToClipboard { .. })));
// `ui.open-url` accepts http(s) URLs and rejects other schemes.
f.take_events();
f.run("jobs", "open-url", Some("https://github.com/l0ng-ai/openidea/blob/main/README.md#L1")).unwrap();
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenUrl { plugin_id, url } => Some((client, plugin_id, url)),
_ => None,
});
assert_eq!(
opened,
Some(
(Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea/blob/main/README.md#L1".into(),)
)
);
for bad in ["javascript:alert(1)", "file:///etc/passwd", ""] {
f.take_events();
let err = f.run("jobs", "open-url", Some(bad)).unwrap_err();
assert!(err.contains("http:// or https://"), "{bad:?}: {err}");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::OpenUrl { .. })));
}
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
+13 -1
Переглянути файл
@@ -32,7 +32,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.0";
pub const API_VERSION: &str = "1.1";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -45,6 +45,14 @@ pub const CANCELLED: &str = "was cancelled";
/// 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);
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
}
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
let _ = (client, url);
Ok(())
}
/// Sets (`Some`) or removes the plugin's status bar item `id`.
fn status_item(&self, id: &str, item: Option<StatusItem>);
/// Roots of the open projects.
@@ -66,6 +74,10 @@ pub trait HostApi: Send + Sync {
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError>;
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError>;
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError>;
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
let _ = root;
Ok(Vec::new())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
/// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`.
+26
Переглянути файл
@@ -242,6 +242,24 @@ impl ui::Host for StoreData {
self.host.status_item(&clip(id, 64), None);
Ok(())
}
fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::Clipboard) {
return Ok(Err(e));
}
let client = self.invocation.client.clone();
Ok(self.host.copy_to_clipboard(client.as_deref(), clip(text, crate::runtime::MAX_TEXT)).map_err(to_wit))
}
fn open_url(&mut self, url: String) -> Fallible<()> {
let trimmed = url.trim();
let valid = (trimmed.starts_with("https://") || trimmed.starts_with("http://")) && trimmed.len() <= 8192;
if !valid {
return Ok(Err(wit::Error::Invalid(format!("{url:?}: expected an http:// or https:// URL"))));
}
let client = self.invocation.client.clone();
Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit))
}
}
impl storage::Host for StoreData {
@@ -438,6 +456,14 @@ impl vcs::Host for StoreData {
let token = self.cancel.token();
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
}
fn remotes(&mut self, project: Resource<ProjectRes>) -> Fallible<Vec<(String, String)>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
Ok(self.host.vcs_remotes(&root).map_err(to_wit))
}
}
impl events::Host for StoreData {
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
+1 -1
Переглянути файл
@@ -12,7 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
optional = ["project.read"]
optional = ["project.read", "vcs.read", "clipboard"]
[[contributes.views]]
id = "jobs"
+14 -1
Переглянути файл
@@ -32,9 +32,22 @@ impl Jobs {
}
impl Plugin for Jobs {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"follow" => self.follow().map(|()| None),
"remotes" => {
let project = ctx.project().ok_or("no project is open")?;
let remotes = project.vcs_remotes()?;
Ok(Some(remotes.into_iter().map(|(n, u)| format!("{n}={u}")).collect::<Vec<_>>().join("\n")))
}
"copy" => {
host::copy_to_clipboard(arg.unwrap_or_default())?;
Ok(None)
}
"open-url" => {
host::open_url(arg.unwrap_or_default())?;
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
+2 -2
Переглянути файл
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.0.0;
export openidea:plugin/providers@1.0.0;
include openidea:plugin/plugin-syntax@1.1.0;
export openidea:plugin/providers@1.1.0;
}
",
path: "../../../../../wit",
+6 -4
Переглянути файл
@@ -36,7 +36,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
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.0.0`, world
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.1.0`, world
`plugin`).
## 1. Quick start
@@ -183,7 +183,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `editor.read` | low | `TextEditor::document`, `selections` |
| `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes) |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` |
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
| `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events |
@@ -191,7 +191,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
| `clipboard` | medium | the clipboard API in the plugin's web views (keyboard copy / paste work without it) |
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
`process:` capabilities are also derived from what the contributions start: a
language's `language-servers` and `debugger` commands and a run detector's
@@ -299,12 +299,14 @@ then run `openidea-plugin check`.
| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. |
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
| `Project::vcs_status()`, `vcs_fetch()` | `vcs` | Fetch runs git, so only in trusted projects; call it inside a task. |
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
| `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. |
| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
| `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::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. |
| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. |
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
12c9f58ab05321fa3966a0179c085e2972db278adf20b917b50f30ce048aa67d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
74167252b0e306f94eb00ac9b467a5f30c373b9f15e223b21afa71c67c1dec0b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
66e7f43eaf7bbadf1c5c817c6ce8da3adca0543ce5e12550440b4039b235581d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
04cd70714c6bac560783492b3bd31d22f7056b9d8809df14163be935785dd114
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
674f439626f08e931842c0c108a57c899ea38d2b5b2de38debd3b4b46feb5777
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
227a30ef0028f167a20825bdd775b15392161e641f6c68030e01d0c79e263bb5
ee40771fe778d4af1f762c1512155ea375ee3ed238390c65a5461b077fed6f8b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
046f769822102de0d4ecec99ad41027cb792941065226716f38a6e64c50ff364
b9cc6408738acd7a89ff76632d91ec49680c7a5ff6bee292efa77d182003ec25
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0b9c3572390a3caab7d47f9b975439f8dde3feff6b9044579d99b8334ac2a692
6c9c35547a7ce1c240be83bbb30418a28ab8e2e4034401205e35ec2dee87abbf
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0af62bcd4583d126a616cafbf2ddc80f039a3ead2b5dcb7144222b0ebc673432
56af2a764333e739d53fb5c8346835b4796cf76611c36b4685a428b49cc4eae2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
24ef150beefb05622212228e1c44cb487fffe6526776762d713f3ebdf4c02ac0
ac745c20866ed959252778116af068d7264187a53df64e2de090522b7ba5b722
+27
Переглянути файл
@@ -73,6 +73,14 @@ mod imp {
wit::ui::remove_status_item(id)
}
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)
}
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
wit::storage::get(key).map_err(error)
}
@@ -152,6 +160,10 @@ mod imp {
wit::vcs::fetch(p).map_err(error)
}
pub fn vcs_remotes(p: &ProjectRes) -> Result<Vec<(String, String)>, Error> {
wit::vcs::remotes(p).map_err(error)
}
pub fn subscribe(k: EventKind) -> Result<(), Error> {
wit::events::subscribe(k.name()).map_err(error)
}
@@ -339,6 +351,11 @@ impl Project {
imp::vcs_status(&self.inner)
}
/// Configured remotes (`(name, fetch_url)`), in order. Needs `vcs.read`.
pub fn vcs_remotes(&self) -> Result<Vec<(String, String)>, 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)
@@ -528,6 +545,16 @@ pub fn remove_status_item(id: &str) {
imp::remove_status_item(id)
}
/// 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)
}
/// 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`).
+6 -6
Переглянути файл
@@ -89,7 +89,7 @@ pub mod providers_bindings {
export_macro_name: "__export_editor_providers_world",
default_bindings_module: "openidea_plugin::providers_bindings",
with: {
"openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types,
},
});
}
@@ -106,16 +106,16 @@ pub mod views_bindings {
export_macro_name: "__export_views_world",
default_bindings_module: "openidea_plugin::views_bindings",
with: {
"openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.0.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.0.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.0.0": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.1.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.1.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.1.0": crate::bindings::openidea::plugin::invocation,
},
});
}
/// The plugin API version this SDK targets.
pub const API_VERSION: &str = "1.0";
pub const API_VERSION: &str = "1.1";
/// A plugin. One instance lives as long as the plugin's Wasm instance (until
/// it is disabled, crashes or is reclaimed when idle).
+64 -2
Переглянути файл
@@ -63,8 +63,11 @@ struct State {
subscriptions: BTreeSet<EventKind>,
tasks: Vec<MockTask>,
vcs: Option<RepositoryStatus>,
remotes: Vec<(String, String)>,
fetches: u32,
fetch_error: Option<Error>,
clipboard: Option<String>,
opened_urls: Vec<String>,
diagnostics: BTreeMap<String, Vec<Diagnostic>>,
secrets: BTreeMap<String, String>,
net: Option<NetHandler>,
@@ -96,8 +99,17 @@ pub struct MockHost {
}
/// Every capability a plugin can hold, except `process:`/`net:`/`fs.read:`.
const ALL: &[&str] =
&["project.read", "editor.read", "editor.write", "vcs.read", "vcs.write", "storage", "events", "events.input"];
const ALL: &[&str] = &[
"project.read",
"editor.read",
"editor.write",
"vcs.read",
"vcs.write",
"storage",
"events",
"events.input",
"clipboard",
];
impl MockHost {
/// Installs a mock granting the usual capabilities, with project `/project`.
@@ -197,6 +209,21 @@ impl MockHost {
self.state.borrow_mut().vcs = status;
}
/// Remotes `vcs-remotes` returns for every project.
pub fn set_vcs_remotes(&self, remotes: &[(&str, &str)]) {
self.state.borrow_mut().remotes = remotes.iter().map(|&(n, u)| (n.to_owned(), u.to_owned())).collect();
}
/// Last text copied with [`crate::host::copy_to_clipboard`].
pub fn clipboard(&self) -> Option<String> {
self.state.borrow().clipboard.clone()
}
/// URLs opened with [`crate::host::open_url`], in order.
pub fn opened_urls(&self) -> Vec<String> {
self.state.borrow().opened_urls.clone()
}
/// Makes `vcs-fetch` fail.
pub fn fail_fetch(&self, error: Option<Error>) {
self.state.borrow_mut().fetch_error = error;
@@ -350,6 +377,25 @@ pub mod current {
with(|s| s.status.remove(id));
}
pub fn copy_to_clipboard(text: &str) -> Result<(), Error> {
with(|s| {
require(s, "clipboard")?;
s.clipboard = Some(text.to_owned());
Ok(())
})
}
pub fn open_url(url: &str) -> Result<(), Error> {
let trimmed = url.trim();
if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
return Err(Error::Invalid(format!("{url:?}: expected an http:// or https:// URL")));
}
with(|s| {
s.opened_urls.push(trimmed.to_owned());
Ok(())
})
}
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
with(|s| {
require(s, "storage")?;
@@ -496,6 +542,13 @@ pub mod current {
})
}
pub fn vcs_remotes(_: &ProjectRes) -> Result<Vec<(String, String)>, Error> {
with(|s| {
require(s, "vcs.read")?;
Ok(s.remotes.clone())
})
}
pub fn subscribe(kind: EventKind) -> Result<(), Error> {
with(|s| {
let cap = match kind {
@@ -680,6 +733,13 @@ mod tests {
assert_eq!(host::storage::keys("").unwrap(), ["k"]);
host::set_status_item("s", &StatusItem { text: "x".into(), tooltip: None, command: None });
assert_eq!(mock.status_items()["s"].text, "x");
mock.set_vcs_remotes(&[("origin", "[email protected]:o/r.git")]);
assert_eq!(project.vcs_remotes().unwrap(), [("origin".into(), "[email protected]:o/r.git".into())]);
host::copy_to_clipboard("copied").unwrap();
assert_eq!(mock.clipboard().as_deref(), Some("copied"));
host::open_url("https://example.com/a").unwrap();
assert_eq!(mock.opened_urls(), ["https://example.com/a"]);
assert!(matches!(host::open_url("file:///etc/passwd"), Err(Error::Invalid(_))));
{
let task = host::begin_task("Work", true);
task.report(1, Some(2), None);
@@ -709,6 +769,8 @@ mod tests {
assert_eq!(editor.apply_edits(1, &[]), Err(Error::Denied("editor.write".into())));
assert_eq!(host::storage::get("k"), Err(Error::Denied("storage".into())));
assert_eq!(host::subscribe(EventKind::ActionPerformed), Err(Error::Denied("events.input".into())));
assert_eq!(mock.invocation().project().unwrap().vcs_remotes(), Err(Error::Denied("vcs.read".into())));
assert_eq!(host::copy_to_clipboard("x"), Err(Error::Denied("clipboard".into())));
}
#[test]
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface lifecycle {
use types.{context, error};
+6 -2
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -8,7 +8,7 @@ interface log {
}
interface ui {
use types.{notification-level};
use types.{error, notification-level};
record status-item {
text: string,
@@ -23,6 +23,10 @@ interface ui {
/// 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);
/// Copies `text` to the system clipboard. Requires `clipboard`.
copy-to-clipboard: func(text: string) -> result<_, error>;
/// Opens an `http://` or `https://` URL in the user's default browser.
open-url: func(url: string) -> result<_, error>;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
+1 -1
Переглянути файл
@@ -7,7 +7,7 @@
/// or processes beyond WASI's clocks and randomness; everything else goes
/// through the imports below, gated by the capabilities granted in
/// `plugin.toml` (a missing one returns `error::denied`).
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
world plugin {
import log;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
+3 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface workspace {
use types.{error};
@@ -84,6 +84,8 @@ interface vcs {
/// `none` when the project is not in a repository. Requires `vcs.read`.
status: func(project: borrow<project>) -> result<option<repository-status>, error>;
/// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`.
remotes: func(project: borrow<project>) -> result<list<tuple<string, string>>, error>;
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
/// Long-running: call it inside a task.
fetch: func(project: borrow<project>) -> result<_, error>;
+5 -1
Переглянути файл
@@ -5510,7 +5510,11 @@ export type PluginEvent =
* Plugin languages, or the servers they may start, changed: re-fetch
* `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
*/
{ type: "languagesChanged" };
{ type: "languagesChanged" } |
/** Copy `text` to the system clipboard. */
{ type: "copyToClipboard"; pluginId: string; text: string } |
/** Open `url` (`http(s)://`) in the system browser. */
{ type: "openUrl"; pluginId: string; url: string };
export type PluginInfo = {
id: string,
+9 -1
Переглянути файл
@@ -464,9 +464,17 @@ describe("bridge events", () => {
expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true);
});
it("shows plugin notifications and tracks theme changes", () => {
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" }]);
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" });
expect(open).toHaveBeenCalledWith("https://github.com/o/r/blob/main/a.rs", "_blank", "noopener");
open.mockRestore();
handlePluginEvent({ type: "themeChanged", theme: null });
expect(usePlugins.getState().activeTheme).toBeNull();
});
+7
Переглянути файл
@@ -9,6 +9,7 @@ import type { EventKind, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindi
import { onEditorEvent } from "@/editor/api";
import { executeAction, getAction, onActionExecuted } from "@/platform/actions";
import { onIdeEvent } from "@/platform/ipc";
import { openExternalUrl } from "@/platform/openUrl";
import { useProject } from "@/platform/project";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
@@ -262,6 +263,12 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
case "openLocation":
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
return true;
case "copyToClipboard":
void navigator.clipboard?.writeText(e.text);
return true;
case "openUrl":
void openExternalUrl(e.url);
return true;
default:
return false;
}
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface lifecycle {
use types.{context, error};
+6 -2
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -8,7 +8,7 @@ interface log {
}
interface ui {
use types.{notification-level};
use types.{error, notification-level};
record status-item {
text: string,
@@ -23,6 +23,10 @@ interface ui {
/// 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);
/// Copies `text` to the system clipboard. Requires `clipboard`.
copy-to-clipboard: func(text: string) -> result<_, error>;
/// Opens an `http://` or `https://` URL in the user's default browser.
open-url: func(url: string) -> result<_, error>;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
+1 -1
Переглянути файл
@@ -7,7 +7,7 @@
/// or processes beyond WASI's clocks and randomness; everything else goes
/// through the imports below, gated by the capabilities granted in
/// `plugin.toml` (a missing one returns `error::denied`).
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
world plugin {
import log;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
+3 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface workspace {
use types.{error};
@@ -84,6 +84,8 @@ interface vcs {
/// `none` when the project is not in a repository. Requires `vcs.read`.
status: func(project: borrow<project>) -> result<option<repository-status>, error>;
/// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`.
remotes: func(project: borrow<project>) -> result<list<tuple<string, string>>, error>;
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
/// Long-running: call it inside a task.
fetch: func(project: borrow<project>) -> result<_, error>;