diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs index d079cff2d..aaeeabf67 100644 --- a/crates/ide-extension/src/manifest.rs +++ b/crates/ide-extension/src/manifest.rs @@ -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. diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 1b1c997eb..375ab1829 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -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) { let key = (self.id().to_owned(), id.to_owned()); { @@ -418,6 +430,12 @@ impl HostApi for PluginApi { })) } + fn vcs_remotes(&self, root: &Path) -> Result, 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:ghp_token@github.com/o/r.git", "https://github.com/o/r.git"), + ("https://token@gitlab.com/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"), + ("git@github.com:o/r.git", "git@github.com: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"); + } +} diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 9c8690239..7b845a84d 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -115,6 +115,8 @@ pub fn forwarded(event: &PluginEvent) -> bool { | PluginEvent::ViewMessage { .. } | PluginEvent::OpenTerminal { .. } | PluginEvent::OpenLocation { .. } + | PluginEvent::CopyToClipboard { .. } + | PluginEvent::OpenUrl { .. } ) } diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index bd5e197ce..53c737e54 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -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 diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 299ece223..575584f08 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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", "git@github.com: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("origin=git@github.com: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()); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 55c38e33f..21bfb5696 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -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); /// Roots of the open projects. @@ -66,6 +74,10 @@ pub trait HostApi: Send + Sync { fn storage_set(&self, key: &str, value: Option) -> Result<(), HostError>; fn storage_keys(&self, prefix: &str) -> Result, HostError>; fn vcs_status(&self, root: &Path) -> Result, HostError>; + fn vcs_remotes(&self, root: &Path) -> Result, 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`. diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index bcd53cc81..b274a088c 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -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) -> Fallible> { + 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 { diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 368de83b3..22be24d04 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index f811c07c0..a78a811bf 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3 +455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 0899f53e8..6cc5e751a 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 3a0de87f3..30532e592 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14 +f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3 diff --git a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml index 8fc682061..7453c9ec5 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs index fe28d779a..131ef2cce 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs @@ -32,9 +32,22 @@ impl Jobs { } impl Plugin for Jobs { - fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result, 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::>().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}"))), } } diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index 37db117ec..70f536a98 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index f95fc1ae3..d08b0b0e8 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8 +12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 890bf845b..35be7a7a0 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -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", diff --git a/plugins/README.md b/plugins/README.md index 8a74a1d12..a43761b57 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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:` | high | `Project::run_process`, language servers, debug adapters, runs | | `net:` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | | `secrets` | medium | `{{secret:}}` 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:`. | | `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. | diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 4fc5e7719..dd6b1b115 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index d07cfcec4..5cd3a6b0d 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50 +12c9f58ab05321fa3966a0179c085e2972db278adf20b917b50f30ce048aa67d diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index e719df8d3..e6c41ba8b 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index f7e63f8f2..9d7525f74 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457 +2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 752aba906..2b7172c52 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 4f6159a02..25087c64f 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319 +74167252b0e306f94eb00ac9b467a5f30c373b9f15e223b21afa71c67c1dec0b diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 5e26ce4e1..c00455582 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 0131a6b6f..b2bdee8e8 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d +66e7f43eaf7bbadf1c5c817c6ce8da3adca0543ce5e12550440b4039b235581d diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 912edbd6c..d74e615fe 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 1657b2d52..5c61c4c01 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32 +04cd70714c6bac560783492b3bd31d22f7056b9d8809df14163be935785dd114 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 99400b9b3..8d431a4f7 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 84b4dfaae..a824dc2ab 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d +674f439626f08e931842c0c108a57c899ea38d2b5b2de38debd3b4b46feb5777 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index fdcbdd184..ecdc7cca6 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index e5ee06b9e..b89a5e83f 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -227a30ef0028f167a20825bdd775b15392161e641f6c68030e01d0c79e263bb5 +ee40771fe778d4af1f762c1512155ea375ee3ed238390c65a5461b077fed6f8b diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index b31649e93..5a9d2d01d 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 34acc2672..0d4ec6985 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -046f769822102de0d4ecec99ad41027cb792941065226716f38a6e64c50ff364 +b9cc6408738acd7a89ff76632d91ec49680c7a5ff6bee292efa77d182003ec25 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index a8e10a846..e9cec69d8 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index df4b262ac..a727fdce8 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -0b9c3572390a3caab7d47f9b975439f8dde3feff6b9044579d99b8334ac2a692 +6c9c35547a7ce1c240be83bbb30418a28ab8e2e4034401205e35ec2dee87abbf diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index cef826bba..7a36721c8 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 09d619246..937d87db1 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -0af62bcd4583d126a616cafbf2ddc80f039a3ead2b5dcb7144222b0ebc673432 +56af2a764333e739d53fb5c8346835b4796cf76611c36b4685a428b49cc4eae2 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 3ae03a508..204171108 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index c511466f2..a934a4a48 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -24ef150beefb05622212228e1c44cb487fffe6526776762d713f3ebdf4c02ac0 +ac745c20866ed959252778116af068d7264187a53df64e2de090522b7ba5b722 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 88cef0e32..6b93a1e7e 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -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, 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, 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, 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`). diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 57aebe86e..1172d78f5 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -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). diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 334da48fb..a011569eb 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -63,8 +63,11 @@ struct State { subscriptions: BTreeSet, tasks: Vec, vcs: Option, + remotes: Vec<(String, String)>, fetches: u32, fetch_error: Option, + clipboard: Option, + opened_urls: Vec, diagnostics: BTreeMap>, secrets: BTreeMap, net: Option, @@ -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 { + self.state.borrow().clipboard.clone() + } + + /// URLs opened with [`crate::host::open_url`], in order. + pub fn opened_urls(&self) -> Vec { + self.state.borrow().opened_urls.clone() + } + /// Makes `vcs-fetch` fail. pub fn fail_fetch(&self, error: Option) { 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, Error> { with(|s| { require(s, "storage")?; @@ -496,6 +542,13 @@ pub mod current { }) } + pub fn vcs_remotes(_: &ProjectRes) -> Result, 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", "git@github.com:o/r.git")]); + assert_eq!(project.vcs_remotes().unwrap(), [("origin".into(), "git@github.com: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] diff --git a/sdk/rust/openidea-plugin/wit/events.wit b/sdk/rust/openidea-plugin/wit/events.wit index 2d118d812..7da95eac5 100644 --- a/sdk/rust/openidea-plugin/wit/events.wit +++ b/sdk/rust/openidea-plugin/wit/events.wit @@ -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 { diff --git a/sdk/rust/openidea-plugin/wit/exports.wit b/sdk/rust/openidea-plugin/wit/exports.wit index 065ef9a5f..808377432 100644 --- a/sdk/rust/openidea-plugin/wit/exports.wit +++ b/sdk/rust/openidea-plugin/wit/exports.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; interface lifecycle { use types.{context, error}; diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index b6325cb71..c7ac7f916 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -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 diff --git a/sdk/rust/openidea-plugin/wit/io.wit b/sdk/rust/openidea-plugin/wit/io.wit index f1fe634a3..b93ea079e 100644 --- a/sdk/rust/openidea-plugin/wit/io.wit +++ b/sdk/rust/openidea-plugin/wit/io.wit @@ -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 { diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 938ee0842..e9fc0ac85 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -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; diff --git a/sdk/rust/openidea-plugin/wit/providers.wit b/sdk/rust/openidea-plugin/wit/providers.wit index 97dd4c7a6..17c1123a8 100644 --- a/sdk/rust/openidea-plugin/wit/providers.wit +++ b/sdk/rust/openidea-plugin/wit/providers.wit @@ -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 diff --git a/sdk/rust/openidea-plugin/wit/types.wit b/sdk/rust/openidea-plugin/wit/types.wit index 2e2b48f74..de35cadf6 100644 --- a/sdk/rust/openidea-plugin/wit/types.wit +++ b/sdk/rust/openidea-plugin/wit/types.wit @@ -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. diff --git a/sdk/rust/openidea-plugin/wit/views.wit b/sdk/rust/openidea-plugin/wit/views.wit index c4435eced..8a67e222e 100644 --- a/sdk/rust/openidea-plugin/wit/views.wit +++ b/sdk/rust/openidea-plugin/wit/views.wit @@ -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 diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index febca7c6f..180040471 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -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) -> result, error>; + /// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`. + remotes: func(project: borrow) -> result>, error>; /// Fetches all remotes (pruning deleted branches). Requires `vcs.write`. /// Long-running: call it inside a task. fetch: func(project: borrow) -> result<_, error>; diff --git a/src/bindings.ts b/src/bindings.ts index 52c9a9d13..ad38a6dd5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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, diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 72b65f4b9..5710fd334 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -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(); }); diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index c0b7efd0d..f8de39bfa 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -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; } diff --git a/wit/events.wit b/wit/events.wit index 2d118d812..7da95eac5 100644 --- a/wit/events.wit +++ b/wit/events.wit @@ -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 { diff --git a/wit/exports.wit b/wit/exports.wit index 065ef9a5f..808377432 100644 --- a/wit/exports.wit +++ b/wit/exports.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; interface lifecycle { use types.{context, error}; diff --git a/wit/host.wit b/wit/host.wit index b6325cb71..c7ac7f916 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -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 diff --git a/wit/io.wit b/wit/io.wit index f1fe634a3..b93ea079e 100644 --- a/wit/io.wit +++ b/wit/io.wit @@ -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 { diff --git a/wit/plugin.wit b/wit/plugin.wit index 938ee0842..e9fc0ac85 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -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; diff --git a/wit/providers.wit b/wit/providers.wit index 97dd4c7a6..17c1123a8 100644 --- a/wit/providers.wit +++ b/wit/providers.wit @@ -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 diff --git a/wit/types.wit b/wit/types.wit index 2e2b48f74..de35cadf6 100644 --- a/wit/types.wit +++ b/wit/types.wit @@ -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. diff --git a/wit/views.wit b/wit/views.wit index c4435eced..8a67e222e 100644 --- a/wit/views.wit +++ b/wit/views.wit @@ -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 diff --git a/wit/workspace.wit b/wit/workspace.wit index febca7c6f..180040471 100644 --- a/wit/workspace.wit +++ b/wit/workspace.wit @@ -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) -> result, error>; + /// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`. + remotes: func(project: borrow) -> result>, error>; /// Fetches all remotes (pruning deleted branches). Requires `vcs.write`. /// Long-running: call it inside a task. fetch: func(project: borrow) -> result<_, error>;