From a3d5336642a2e8d5509b20a2954d7f96d3c2e6c5 Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:02:34 +0100 Subject: [PATCH] feat(plugins): let plugin code set context keys for its when clauses (#1762) Plugin API 1.2 adds `ui.set-context(key, value)` and `ui.remove-context(key)`: a plugin sets `plugin..` for `when` clauses, typed as `context-value` (flag, number or text), in the window the call came from or every window outside a command or event; a window's value wins over the global one. Keys are 1-64 of `A-Za-z0-9_-`, at most 100 per plugin, kept across window reloads and idle reclaim, dropped when the plugin stops or the window closes; remote windows get them through the dispatcher. The Rust SDK takes `impl Into`. All components are rebuilt for the WIT change. Closes #1622 Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/api.rs | 82 +++++++++++- crates/ide-host/src/plugins/mod.rs | 17 +++ crates/ide-host/src/plugins/remote_host.rs | 8 +- crates/ide-host/src/plugins/types.rs | 13 ++ crates/ide-host/tests/plugins.rs | 125 ++++++++++++++++++ crates/ide-host/tests/remote_plugins.rs | 17 +++ crates/ide-plugin/src/runtime.rs | 6 + crates/ide-plugin/src/wit_host.rs | 30 +++++ .../tests/fixtures/hello/dist/plugin.wasm | Bin 133858 -> 133858 bytes .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/dist/plugin.wasm | Bin 134456 -> 136679 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/plugin.toml | 5 + .../ide-plugin/tests/fixtures/jobs/src/lib.rs | 13 ++ .../misbehave/dist/plugin.wasm.sources | 2 +- plugins/README.md | 5 + .../ai-completion/dist/plugin.wasm.sources | 2 +- plugins/official/checkstyle/dist/plugin.wasm | Bin 169526 -> 169526 bytes .../checkstyle/dist/plugin.wasm.sources | 2 +- plugins/official/csv-editor/dist/plugin.wasm | Bin 133389 -> 133389 bytes .../csv-editor/dist/plugin.wasm.sources | 2 +- plugins/official/git-toolbox/dist/plugin.wasm | Bin 110063 -> 110063 bytes .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../official/key-promoter/dist/plugin.wasm | Bin 100179 -> 100179 bytes .../key-promoter/dist/plugin.wasm.sources | 2 +- .../official/license-header/dist/plugin.wasm | Bin 152598 -> 152598 bytes .../license-header/dist/plugin.wasm.sources | 2 +- plugins/official/mybatis/dist/plugin.wasm | Bin 168367 -> 168367 bytes .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../official/string-tools/dist/plugin.wasm | Bin 165779 -> 165779 bytes .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/host.rs | 26 ++++ sdk/rust/openidea-plugin/src/mock.rs | 21 +++ sdk/rust/openidea-plugin/src/types.rs | 34 +++++ sdk/rust/openidea-plugin/wit/host.wit | 17 +++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin.rs | 11 +- src-tauri/src/lib.rs | 1 + src-tauri/src/windows.rs | 3 + src/bindings.ts | 14 ++ .../plugins/__tests__/plugins.test.tsx | 95 ++++++++++++- src/features/plugins/backend.ts | 4 + src/features/plugins/runtimeUi.tsx | 67 +++++++++- src/platform/context.test.ts | 19 ++- src/platform/context.ts | 7 + wit/host.wit | 17 +++ 50 files changed, 664 insertions(+), 24 deletions(-) diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index ad05517be..5da41db08 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -1,7 +1,7 @@ //! [`HostApi`] for one plugin's code: what its imports reach in the IDE. //! Capabilities were checked by the runtime before any of this runs. -use super::{PluginEvent, PluginEventSink}; +use super::{PluginContextKey, PluginEvent, PluginEventSink}; use ide_core::ipc_path::to_ipc; use ide_core::{CancellationToken, TextEdit}; use ide_extension::Package; @@ -22,6 +22,10 @@ const MAX_STORAGE: usize = 1 << 20; const MAX_KEY: usize = 256; /// Most status bar items per plugin. const MAX_STATUS_ITEMS: usize = 8; +const MAX_CONTEXT_KEYS: usize = 100; + +/// (window or `None` for every window, plugin, key). +type ContextKey = (Option, String, String); /// What plugins see of the open projects; provided by the app /// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop. @@ -156,6 +160,9 @@ pub(super) struct Shared { next_task: AtomicU64, /// (plugin, item id) → item, for windows opened later. status: Mutex>, + /// (scope, plugin, key) → JSON value. + contexts: Mutex>, + closed_clients: Mutex>, /// plugin → kinds subscribed at run time (kept across idle reclaim). pub subscriptions: Mutex>>, /// plugin → files it shows problems for, withdrawn when it stops. @@ -184,6 +191,8 @@ impl Shared { tasks: Mutex::default(), next_task: AtomicU64::new(1), status: Mutex::default(), + contexts: Mutex::default(), + closed_clients: Mutex::default(), subscriptions: Mutex::default(), diagnostics: Mutex::default(), }) @@ -226,6 +235,34 @@ impl Shared { self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect() } + pub fn context_keys(&self, client: Option<&str>) -> Vec { + let contexts = self.contexts.lock(); + let mut keys: BTreeMap<(&str, &str), &str> = BTreeMap::new(); + for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.is_none()) { + keys.insert((plugin, key), value); + } + if let Some(client) = client { + for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.as_deref() == Some(client)) { + keys.insert((plugin, key), value); + } + } + keys.into_iter() + .map(|((p, k), v)| PluginContextKey { plugin_id: p.to_owned(), key: k.to_owned(), value: v.to_owned() }) + .collect() + } + + /// Drops the context keys scoped to window `client` (it closed), without events, and + /// refuses further writes for it (late commands) until [`Self::reopen_client`]. + pub fn forget_client(&self, client: &str) { + self.closed_clients.lock().insert(client.to_owned()); + self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client)); + } + + /// A window labelled `client` is being created (labels are reused after a close). + pub fn reopen_client(&self, client: &str) { + self.closed_clients.lock().remove(client); + } + /// Withdraws a plugin's run-time state (it stopped or failed). Its /// subscriptions go only with `subscriptions`, as they survive restarts. pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) { @@ -240,6 +277,18 @@ impl Shared { for id in removed { self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None }); } + let removed: Vec<(Option, String)> = { + let mut contexts = self.contexts.lock(); + let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect(); + for key in &keys { + contexts.remove(key); + } + keys.into_iter().map(|(scope, _, key)| (scope, key)).collect() + }; + for (scope, key) in removed { + self.sink + .emit(scope.as_deref(), PluginEvent::ContextChanged { plugin_id: plugin.to_owned(), key, value: None }); + } let tasks: Vec = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect(); for id in tasks { self.end_task(id); @@ -371,6 +420,37 @@ impl HostApi for PluginApi { .emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item }); } + fn set_context(&self, client: Option<&str>, key: &str, value: Option) -> Result<(), HostError> { + let map_key = (client.map(str::to_owned), self.id().to_owned(), key.to_owned()); + { + let mut contexts = self.shared.contexts.lock(); + if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) { + return Ok(()); + } + match &value { + Some(value) => { + if contexts.get(&map_key) == Some(value) { + return Ok(()); + } + let count = contexts.keys().filter(|(_, p, _)| p == self.id()).count(); + if !contexts.contains_key(&map_key) && count >= MAX_CONTEXT_KEYS { + return Err(HostError::Invalid(format!("more than {MAX_CONTEXT_KEYS} context keys"))); + } + contexts.insert(map_key, value.clone()); + } + None => { + if contexts.remove(&map_key).is_none() { + return Ok(()); + } + } + } + } + self.shared + .sink + .emit(client, PluginEvent::ContextChanged { plugin_id: self.id().to_owned(), key: key.to_owned(), value }); + Ok(()) + } + fn projects(&self) -> Vec { self.shared.workspace().map(|w| w.projects()).unwrap_or_default() } diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index cfb7de9e3..7756a8bbb 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -1260,6 +1260,23 @@ impl PluginHost { self.shared.status_items() } + /// Plugin context keys in effect for window `client`: global keys overlaid with its own. + pub fn context_keys(&self, client: Option<&str>) -> Vec { + self.shared.context_keys(client) + } + + /// A window labelled `label` is about to exist again (labels are reused). + pub fn reopen_client(&self, label: &str) { + self.shared.reopen_client(label); + } + + /// Window `label` closed: drops its context keys and queued events, and ignores + /// context writes of commands still running for it. + pub fn forget_client(&self, label: &str) { + self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label)); + self.shared.forget_client(label); + } + /// The user cancelled a plugin task (`task_id` from `TaskStarted`). pub fn cancel_task(&self, task_id: &str) -> bool { task_id.parse().is_ok_and(|id| self.shared.cancel_task(id)) diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 166021f74..558ed2369 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -13,7 +13,7 @@ //! //! The commands a window needs at run time (`plugin_run_command`, //! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`, -//! `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`, +//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`, //! `plugin_view_render`, `plugin_view_event`) //! carry the desktop commands' names and arguments, so a remote window's UI //! routes them here unchanged. Plugin results come back as `plugin` @@ -107,6 +107,7 @@ pub fn forwarded(event: &PluginEvent) -> bool { event, PluginEvent::Notification { .. } | PluginEvent::StatusItemChanged { .. } + | PluginEvent::ContextChanged { .. } | PluginEvent::TaskStarted { .. } | PluginEvent::TaskProgress { .. } | PluginEvent::TaskEnded { .. } @@ -402,6 +403,11 @@ impl RemotePlugins { } }); let h = host.clone(); + d.add("plugin_context_keys", move |c| { + let h = h.clone(); + async move { Ok(alive(&h)?.context_keys(Some(&c.window))) } + }); + let h = host.clone(); d.add("plugin_cancel_task", move |c| { let h = h.clone(); async move { Ok(alive(&h)?.cancel_task(&c.arg::("taskId")?)) } diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index 63bb778f9..a944fb3e8 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -166,6 +166,10 @@ pub enum PluginEvent { /// A plugin's status bar item was set (`item`) or removed. #[serde(rename_all = "camelCase")] StatusItemChanged { plugin_id: String, id: String, item: Option }, + /// A plugin's context key was set (`value`: JSON text) or removed, in the + /// emitted window (every window when the client is `None`). + #[serde(rename_all = "camelCase")] + ContextChanged { plugin_id: String, key: String, value: Option }, /// A plugin began a task: show it with the background tasks. #[serde(rename_all = "camelCase")] TaskStarted { plugin_id: String, task_id: String, title: String, cancellable: bool }, @@ -254,6 +258,15 @@ pub struct PluginStatusItem { pub item: StatusItem, } +/// A plugin's context key for `when` clauses (`value` is JSON text). +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginContextKey { + pub plugin_id: String, + pub key: String, + pub value: String, +} + /// A gutter marker from a plugin's `line-markers` provider. #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index ff160d73a..64a37a562 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -2691,6 +2691,131 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() { assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint"))); } +#[test] +fn plugins_set_their_own_context_keys() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + f.take_events(); + f.run("jobs", "context", Some("connected=true")).unwrap(); + let changed: Vec<_> = f + .take_events() + .into_iter() + .filter_map(|(client, e)| match e { + PluginEvent::ContextChanged { plugin_id, key, value } => Some((client, plugin_id, key, value)), + _ => None, + }) + .collect(); + assert_eq!(changed, [(Some("main".into()), "jobs".into(), "connected".into(), Some("true".into()))]); + assert_eq!(f.host.context_keys(Some("main")).len(), 1); + assert!(f.host.context_keys(Some("other")).is_empty(), "window-scoped"); + + for bad in ["bad key=true", "a.b=true", "x=NaN"] { + assert!(f.run("jobs", "context", Some(bad)).unwrap_err().contains("invalid"), "{bad}"); + } + + f.run("jobs", "context", Some("connected=")).unwrap(); + assert!(f.host.context_keys(Some("main")).is_empty()); + + f.run("jobs", "context", Some("connected=true")).unwrap(); + f.host.set_enabled("jobs", false).unwrap(); + assert!(f.host.context_keys(Some("main")).is_empty(), "cleared when the plugin stops"); +} + +#[test] +fn context_keys_are_capped_and_windows_overlay_global_ones() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + for i in 0..100 { + f.run("jobs", "context", Some(&format!("k{i}=1"))).unwrap(); + } + assert!(f.run("jobs", "context", Some("k100=1")).unwrap_err().contains("invalid")); + f.run("jobs", "context", Some("k0=2")).unwrap(); + for i in 1..4 { + f.run("jobs", "context", Some(&format!("k{i}="))).unwrap(); + } + + let global = Invocation { client: None, ..f.inv() }; + f.host.run_command("jobs", "context", Some("g=1"), global.clone()).unwrap(); + f.host.run_command("jobs", "context", Some("w=global"), global).unwrap(); + f.run("jobs", "context", Some("w=window")).unwrap(); + let w = |client| f.host.context_keys(Some(client)).into_iter().find(|k| k.key == "w").map(|k| k.value); + assert_eq!(w("main").as_deref(), Some("\"window\"")); + assert_eq!(w("other").as_deref(), Some("\"global\"")); + + f.run("jobs", "context", Some("w=")).unwrap(); + assert_eq!(w("main").as_deref(), Some("\"global\""), "removing the window key reveals the global one"); +} + +#[test] +fn context_values_are_stored_as_json_and_bounded() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + let value = |arg: &str| { + f.run("jobs", "context", Some(arg)).unwrap(); + f.host.context_keys(Some("main")).into_iter().find(|k| k.key == "x").unwrap().value + }; + assert_eq!(value("x=true"), "true"); + assert_eq!(value("x=2.5"), "2.5"); + assert_eq!(value("x=say \"hi\""), "\"say \\\"hi\\\"\""); + assert_eq!(value("x=1"), "1"); + f.take_events(); + f.run("jobs", "context", Some("x=1.0")).unwrap(); + assert!(f.take_events().is_empty(), "the same value emits nothing"); + let long = format!("x={}", "a".repeat(1025)); + assert!(f.run("jobs", "context", Some(&long)).unwrap_err().contains("invalid")); +} + +#[test] +fn forgetting_a_window_drops_its_context_keys_silently() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + f.run("jobs", "context", Some("w=1")).unwrap(); + f.take_events(); + f.host.forget_client("main"); + assert!(f.host.context_keys(Some("main")).is_empty()); + assert!(f.take_events().is_empty()); +} + +#[test] +fn a_closed_window_ignores_late_context_writes_until_its_label_is_reused() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + f.host.forget_client("main"); + f.take_events(); + f.run("jobs", "context", Some("late=1")).unwrap(); + assert!(f.host.context_keys(Some("main")).is_empty()); + let events = f.take_events(); + assert!( + !events.iter().any(|(_, e)| matches!(e, PluginEvent::ContextChanged { .. })), + "no context event: {events:?}" + ); + + f.host.reopen_client("main"); + f.run("jobs", "context", Some("fresh=1")).unwrap(); + assert_eq!(f.host.context_keys(Some("main"))[0].key, "fresh"); +} + +#[test] +fn stopping_a_plugin_withdraws_each_context_key_in_its_own_scope() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + let global = Invocation { client: None, ..f.inv() }; + f.host.run_command("jobs", "context", Some("g=1"), global).unwrap(); + f.run("jobs", "context", Some("w=1")).unwrap(); + f.take_events(); + f.host.set_enabled("jobs", false).unwrap(); + let mut removed: Vec<_> = f + .take_events() + .into_iter() + .filter_map(|(client, e)| match e { + PluginEvent::ContextChanged { key, value: None, .. } => Some((client, key)), + _ => None, + }) + .collect(); + removed.sort(); + assert_eq!(removed, [(None, "g".to_owned()), (Some("main".to_owned()), "w".to_owned())]); +} + /// A data-only package `id` in `dir` whose manifest ends with `extra`, plus `files`. fn icon_package(dir: &Path, id: &str, extra: &str, files: &[(&str, &str)]) -> PathBuf { let pkg = dir.join(id); diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs index cf0959b50..9153b8ccf 100644 --- a/crates/ide-host/tests/remote_plugins.rs +++ b/crates/ide-host/tests/remote_plugins.rs @@ -301,6 +301,23 @@ async fn remote_projects_get_plugin_views_and_custom_editors() { }) .await; + invoke( + &peer, + "plugin_run_command", + json!({ "pluginId": "jobs", "commandId": "context", "arg": "mode=true", "editor": null }), + ) + .await + .unwrap(); + until("the context key's event", 5, || { + client.plugin_events.lock().iter().any(|m| { + m.window.as_deref() == Some("main") + && matches!(&m.event, PluginEvent::ContextChanged { key, value, .. } if key == "mode" && value.as_deref() == Some("true")) + }) + }) + .await; + let keys = invoke(&peer, "plugin_context_keys", json!({})).await.unwrap(); + assert_eq!(keys, json!([{ "pluginId": "jobs", "key": "mode", "value": "true" }]), "{keys}"); + let selected = invoke( &peer, "plugin_run_command", diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index ff442ab02..f9c09f894 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -57,6 +57,12 @@ pub trait HostApi: Send + Sync { } /// Sets (`Some`) or removes the plugin's status bar item `id`. fn status_item(&self, id: &str, item: Option); + /// Sets (`Some`, validated JSON text) or removes the plugin's context key, + /// for window `client` (every window if `None`). + fn set_context(&self, client: Option<&str>, key: &str, value: Option) -> Result<(), HostError> { + let _ = (client, key, value); + Ok(()) + } /// Roots of the open projects. fn projects(&self) -> Vec; /// Unsaved text of an open editor's file. diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index ed4fc02d3..ab03ef032 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -244,6 +244,36 @@ impl ui::Host for StoreData { Ok(()) } + fn set_context(&mut self, key: String, value: ui::ContextValue) -> Fallible<()> { + let valid_key = + (1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')); + if !valid_key { + return Ok(Err(wit::Error::Invalid(format!("context key {key:?}: expected 1-64 of A-Za-z0-9_-")))); + } + // Kept as JSON text, which the windows parse into their context. + let json = match value { + ui::ContextValue::Flag(b) => b.to_string(), + ui::ContextValue::Number(n) if n.fract() == 0.0 && n.abs() < 9e15 => (n as i64).to_string(), + ui::ContextValue::Number(n) => match serde_json::Number::from_f64(n) { + Some(n) => n.to_string(), + None => return Ok(Err(wit::Error::Invalid("context value: not a finite number".into()))), + }, + ui::ContextValue::Text(t) if t.len() <= 1024 => serde_json::Value::String(t).to_string(), + ui::ContextValue::Text(_) => { + return Ok(Err(wit::Error::Invalid("context value: text longer than 1024 bytes".into()))); + } + }; + let client = self.invocation.client.clone(); + Ok(self.host.set_context(client.as_deref(), &key, Some(json)).map_err(to_wit)) + } + + fn remove_context(&mut self, key: String) -> wasmtime::Result<()> { + let client = self.invocation.client.clone(); + // Nothing to remove under an invalid key. + let _ = self.host.set_context(client.as_deref(), &key, None); + Ok(()) + } + fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> { if let Err(e) = self.require(Capability::Clipboard) { return Ok(Err(e)); diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index b818841e36d2a74004a15e0dcf6747e777d512c1..76da2d1232883362a4b4dc9820cd99342e8f31dc 100644 GIT binary patch delta 120 zcmaDfmE+M=jt#!zteniu%&eP(#0xcK_ck^(FetD&Dr5nP2Mk#X%#IW1A8266a%2Vx=#58&3{=J(c4j*G?JlHIM Le7pQ{#>374Y7i_* delta 120 zcmaDfmE+M=jt#!ztgM{O%*>mE#0xcK_cb;&FetD&Dr5nP2Mk#X%#IW1A8266a%2 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 de090043a..2eb61bbe5 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 @@ -7a84aaf2ad8fcb0bf2f9fc0eeff6ad8664512a3fa6d9c4c205c19eff9a3e1315 +406afd6a36f1cf2ff59da5826c155b84516daef6f1709fbd7f7618e0eaad852b diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 489e534c1..b15e63de5 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -92aece4d9f24c4429f2dcfc64e65a53219103267130731af49b65c7c581048d4 +060b79a6fb25455d67dffd3623984f1d22836b908570a0537142f83d11cd5522 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 3121b3a5ac6fdd64bcdcaa28ba82e4108952d2e3..47daa6a003277f61df07001f249ca7e2661f2760 100644 GIT binary patch delta 20661 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 rt$~Lq&P=IwWfLSa)vf53=2o<-;|H?P% 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 E5B4pbYXATM 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 706af4df6..5805ea8ef 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 @@ -17136d5359a8f5153ca2650e4c4b2c8f86eb31d0b99ef17974e0217c04f9a6e5 +cadf7d5b651792b502e51587c43d49cc0f87477439291d93858ceaf3692b1879 diff --git a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml index 3bd3396e8..6da670a06 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml @@ -25,6 +25,11 @@ id = "follow" title = "Jobs: Follow Selected Job" category = "Jobs" +[[contributes.commands]] +id = "context" +title = "Jobs: Set Context" +category = "Jobs" + [[contributes.commands]] id = "selection" title = "Jobs: Show Selected Paths" diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs index 7e4e0070f..a775686d8 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs @@ -40,6 +40,19 @@ impl Plugin for Jobs { let remotes = project.vcs_remotes()?; Ok(Some(remotes.into_iter().map(|(n, u)| format!("{n}={u}")).collect::>().join("\n"))) } + "context" => { + // `key=` removes; `true` / `false`, numbers and other text set. + let (key, value) = arg.unwrap_or_default().split_once('=').ok_or("expected key=value")?; + match value { + "" => host::remove_context(key), + "true" | "false" => host::set_context(key, value == "true")?, + _ => match value.parse::() { + Ok(n) => host::set_context(key, n)?, + Err(_) => host::set_context(key, value)?, + }, + } + Ok(None) + } "copy" => { host::copy_to_clipboard(arg.unwrap_or_default())?; Ok(None) 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 4310ad831..05b7bdc8a 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 @@ -b697a2aa34b0de05db3e89bb8449ab6619119fa003b19fabe36b0932246a26fe +f9b1d2af61bac2bac97ad11b629ecaa062d53aeba730e3fcb686ecaa514bc1b6 diff --git a/plugins/README.md b/plugins/README.md index 43131745d..9ff4e991d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -187,6 +187,10 @@ The path is exact (no globs) and relative to the project root; in a workspace th key is true when any of its projects contains the path. It updates when files change. An IDE that doesn't know the key treats it as false: the plugin still installs and the key reads as false there. +Plugins can set their own keys, `plugin..`, with `host::set_context`: +a key set from a command or event applies to that window, one set outside any +applies to every window, and a window's value wins over the global one. Keys are +kept across a window reload and idle reclaim, and dropped when the plugin stops. ### Background associations @@ -349,6 +353,7 @@ then run `openidea-plugin check`. | `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::set_context(key, value)`, `remove_context` | `ui.{set,remove}-context` | Sets `plugin..` for `when` clauses in the window the call came from (`true`, `3.5`, `"text"`; `ContextValue`). Key 1–64 of `A-Za-z0-9_-`, 100 per plugin; gone when the plugin stops. | | `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). | | `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. | diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 50d9693cc..779c9984b 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -f63e6547849ce8f5a0de4384eb44a903583135dea7e5a82829daf3a395e98465 +b143041299679ec90d16a35c6eaae4c2525648bc33f06c40d72fba7f63fb1ab2 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index c7944b6628853897e6224a44db0552ac25c957b4..afb7fd661c852d999d999ffcb304e9c3b99beeb5 100644 GIT binary patch delta 823 zcmY*XT}YEr7~XTf&ChRJI;bX8Yty7sGc|~^n)Q*|(r7K3md%h*A|Zni4GEe-^s`Xw zCDu%>i;4s67&va@MHH_!Wf&ikC_J)Ir;&JO*JO=h#A zSM-tu1#ysNNO7_(DGH-f^Mge&n<43pqNbGj9M=%-)nY}AyzjCZX`)#4Q&%_(IFkg9GL z=u@2t@b~Ke7!l+-BB{m*@?pnL#FPO?O+8dx;KMx{Ef~QAdRTZ8ecZWa5_riAwHJ;ObcZtA- zMRqa5Q)IyquPag+?$e!}9e7OpcU?g@#g%Np3+^mg4?LvC-OYGPsU9DCsnfF-gM8fc zR-&u|8}BbOXc6YG%Wvw%8=4O0>5P9>iU`6#R;MHJZzihSSX9LSb)$@$9CT4tjaA&$ zP!k7)skJs=jvkCRkXk*LrfSDTaqO^L5@yqpM`A3+*RPoFF@nB(&3sS&Cka8m@%UNW z3=K*32paU^sx4)jg``%`EXpQLFU%&Xxs^%GG*us2C^L2Olxy}`V#q$eV*y&|&WkGC zrnFGRcZAx7KOPzo*gKey79RWZ3=_TQUo{BQJo)vU2>0waD@Abd&GLBB(f0WSLn|%* zGK}!KuX99U?pL9AGcW$;ia{%HAI|`SG(3?gE}fk4{qx&@Y{qRq@ncmC26)<}3WWH9 zpYvn20rBPhgMFCozobPA0{(S6EJYw#p+ic7zt93ZE(T9oU}64-1-Pqa8pCx`D-wde gR$MlqEx0!gzqS6lOmzFpGBNK&b{2x!S?pK&A2&`exc~qF delta 823 zcmY*XTS!z<6m{J@Wn6)nVCVDrPd|ig{Cy-V+;u;64D=0KZ3>}3=A_j zH8iOOl_dBP9B{M;ldmi@Uzuk8^pHS_!pEPfof$Rge5|wg+57Ch&f2H5L(|!zd6H@{ z2(m`jh$3WoLy{nROOhzdRAlJirG~)(QO83nNtOgj5FqPdfGkUrS}pJ;vn2mdMm%H2 z*f4Ro(`vPPm^>6+SqSvbD=r86r|#WD2`$sR0{3$!V^m8!lusX?d^}dc)*d3|!XtP;5p$`lv7C8wRND#5KHP(I;1c z_bf6q1b9uESxFozYZjAKb7}@dl#?BWw`9#$IQC{IprG$RgfRxLM&?WnQF^sY3f}_A7RTSDWOd3l)dg-?1 z2qxHs<)g@kinS^@*;GllhQFb)B3Z55QpqI<`&t==;H?w4ZVPS^|JRM8s}j*oW*fpkJ?b4OGx~vxv5YZM?~Ob~*qp^nA1& z4vHLe*=b|#++G|T;n*{tf)?gAab4iKw*5|aeULj9)BAjQM(d9{0j(4^ zHv?zWye|*<&2z2qvNJ!kJ<-bA7o&kLnpq0wMOT*U|Jm)!>2R>6u7i2&*N30qqehuK)l5 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 5d43c4829..a021644d0 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -af6dbfa8c34e5cb62851f02479f86350ee915c18028993331b4372a739074b80 +f87528deb3a497c841456b158d46f22fbc05909dfe4b886ed222c2e86dc824ba diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 788f3fc2f1ba64025f919382b877852e3537af32..e5df94c316c38ad7794130a5ebbad453a7b1eb19 100644 GIT binary patch delta 105 zcmeC3#L+v6V?(3}Gb1DG<`|JQ8O9xx9gWmCzcL78WZbdY(a6@9ap&avdQTvEvfhVr z=Vq~na8V#9Zz@P=$5cC@(D$i<+(626;a1JbyHAHq{&#BcX8tp~1sQiWpT4{O^j$_L FZ2&{nEdKxi delta 105 zcmeC3#L+v6V?(3}GbvW#HB%E!XN!N|eM#Kg$K$H>9K$;ryf#KE-LTa1mB@$2RwX=QfC z37gB5wlXqKoUEhb&p2^&jY=gekn>Hm2*}CQI>^S@zggQbh>!8xyjj)OJ;hOB4;m`Pe^VE#n%#6P_7v_jF sPVPwGySXDbn3eJO<~v3ISSEidUpVvaF0?z{kSD!N|eM#Kg$K$H>9K$;ryf#Kf`LTa1mB@!RGgX=QfC ziJQxmwlXsIPu5ZKXYAixqf*HVAm>9oJ-sc@WInUc4%-RezvdCu|BTx~iUmzn;N3?%8v7v!s^1lSf&8H%!G6OXy#B2hR!m%1aBWz-QxF(;@**m!--JkKt<{Rm^nHhgi vmdOsA%$0L+b4PA4E92MAcZ&Y8Os=kwntZE#;bf+jk$e diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index a47653ca4..8a28f41d7 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -eda75943217c5273773ef0f2934c20d6bda50411670d99145b50e45d104509cc +8225d25b08f9ec36b36a850d1a99a43643a3c06a3e9d6c835e75fb269eeac61a diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 8659340b2187876cf5cdc826b1234c1f849805b9..c33b356c2a7da431cb286cd219a6970c812eb91c 100644 GIT binary patch delta 321 zcmbQXf^*sm&JF&Otc)Ct%&eP3Bt@kdA8gJtKE%lQXtJ*9HO8NlSsyqvG5+4X)b1V&syqvG5*=S)b1V&|aejy9vgUJiG7*GDcSs2I?-qH)^=K)E9t!_XveCr0Vh}&epZMr}$HQQ!$0?m{- zz^OV}_W9n;ickMAvM@1lub5ug#3;J?^K%t3#`}{c{#~6c^Yt&Z<_~t@m;`i61W{=3DlOIjQN%Zp(l+Auw1a84^o0i|%?s)lF zp_2zP*PA%kAWj`3>Y&QIh!Yc%%3eR-?22O?0f8>NP!QEBLl(5S4vTU+iJ%rK+VbF* TmL-hPO9{4UTY}P-)c5`$-$;lU delta 361 zcmZ3#m}~uFt_?>7*;yGG*%_IcH=h=iR%Kn*)X=~%xyM3cGqZU!Bjd8o?G}5P8CPz0 zwK*osIAt?WlphP@%+1kp2bqDCY?2@^7%?)goSawc$+%+kj#6HJ zpxCjN%_2Zv)yzqnjMFFc9?oN2Ho4}o43ItZ@JgWR&PU#I0EGH(V2jL+{F zr%i7Ea)5EhW{)F zE5JB&dz~2LL+R-|O&RxYk2hkBVrFGx;9kKv{a_KJ==KR_jB#v?lc&G8Vcab#$-v~G oz@Wjzpv2%<@5alZz#!1r01Q0t=__p+_1Zt$GH(B9%d{^T0Nr+rG5`Po diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 8ba5cafe9..015a005dd 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -3984d6b51bea7212bbabadc2a5a632c4cba7a2a988033fa7918e33c203fcfa23 +e9cd442b032d0521e0b3c733a3a9866a203ddf5bacb381396f5a0a6c508de5ca diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 6b356d89a..f3ae578f2 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -2a70078e8edf29c9a4b590d661a1a67cb605dca3781a89ffcf6dc8acb0d183b0 +3e4bd05682cf1b748ab7189b17b8937eaeb332641804da59ee684db9ad848579 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 0cf74655f..6e16a9e06 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -4449ee6a1ccee1a71ebed1073bb4e30df1686ebd0a2812bdd579ad876d0a43cf +3b2892a7629ed255add98ebd88e4a1fc454720ee9cae9bf4ffa74fa9b570adff diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 8ad1974a609df31bb99e0779653496644dd02bc0..f283f1601034ce4140a401c8f571a3c8513f178e 100644 GIT binary patch delta 426 zcmbQ-&o#N9Yr``^W=2M~%`XLOSQ+n6?vb_Hyg>Xlkh!@>*4~8i-sUrbY>bTeCZ7q^ z-25jf2PBahQ56ZKo@|U@0#deHY8ZjkuB|OVYO?+IZ9vYq?T49I?l(3xO#gU@QF!u& zU2}kfxx00N>L<_MTMXpz?*rO;f3om?9U#YH|7Va9CI`Wk#^Fkk)S@HxKvL-_$h@0J zQ<;Gr`;%b9Pd0G?IhB`of%II?25}bL&IWRL?$ogWIS-#oOjfM)~Q>q!~@8pORwSyZtth%gp$o{kI(B_TO?$Gco{LOV42d delta 421 zcmbQ-&o#N9Yr``^W;RB~%`XLOSQ#Ho?vb_Hyg>Xlkh!@>*4~8i{^m1*K8XFp>e>}t}Jo&<| zIY7bO-MT>alV|TO26FiKF@ux~@7Dox9QJ<(8DVk|Olcgh1W7GAQV%4Rj)Kg)c{G(7 z$gw{OHvD802ar>FX%|S(4yhv!Zm3y|~hsnq194`u^djt^adWcx#1Ah{Jx zIzE(`{2R`4d}IjJR{ZD>$ijjru|V?N6FngL7fk9r1+k;SWY^O$Ap7Cdoj?tPWJfP2xOi5dJstVe47Xs1(KULd^ZPr oY4eL;kL0JzNi*)9eoBflWczJtMtNq&d+opF7`OkHW15ix0DU~or~m)} diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index eb256aec5..89999ffdc 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -adf2ab3e93e0147350b6db899ec2e44e07862e99f49609aecbc2b866e5282c3d +6183df09a561bc715ec353223b6ae76d0280c179191621e2d0ef809b7b3b2d32 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 8c796e51a..1741f1397 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -73,6 +73,19 @@ mod imp { 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) } @@ -555,6 +568,19 @@ 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) diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 28332df01..ec7784b98 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -60,6 +60,7 @@ struct State { notifications: Vec, logs: Vec<(LogLevel, String)>, status: BTreeMap, + context: BTreeMap, storage: BTreeMap, subscriptions: BTreeSet, tasks: Vec, @@ -191,6 +192,10 @@ impl MockHost { self.state.borrow().status.clone() } + pub fn context_keys(&self) -> BTreeMap { + self.state.borrow().context.clone() + } + pub fn storage(&self) -> BTreeMap { self.state.borrow().storage.clone() } @@ -383,6 +388,15 @@ pub mod current { with(|s| s.status.remove(id)); } + pub fn set_context(key: &str, value: ContextValue) -> Result<(), Error> { + with(|s| s.context.insert(key.to_owned(), value)); + Ok(()) + } + + pub fn remove_context(key: &str) { + with(|s| s.context.remove(key)); + } + pub fn copy_to_clipboard(text: &str) -> Result<(), Error> { with(|s| { require(s, "clipboard")?; @@ -751,6 +765,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"); + host::set_context("a", 1.0).unwrap(); + host::set_context("b", "x").unwrap(); + assert_eq!(mock.context_keys()["a"], ContextValue::Number(1.0)); + assert_eq!(mock.context_keys()["b"], ContextValue::Text("x".into())); + host::remove_context("a"); + host::remove_context("b"); + assert!(mock.context_keys().is_empty()); 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(); diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs index 6a6fbd7a5..03a41256f 100644 --- a/sdk/rust/openidea-plugin/src/types.rs +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -143,6 +143,40 @@ pub struct StatusItem { pub command: Option, } +/// A context key's value, as `when` clauses compare it ([`crate::host::set_context`]). +#[derive(Clone, Debug, PartialEq)] +pub enum ContextValue { + Flag(bool), + /// A finite number. + Number(f64), + /// At most 1024 bytes. + Text(String), +} + +impl From for ContextValue { + fn from(value: bool) -> Self { + Self::Flag(value) + } +} + +impl From for ContextValue { + fn from(value: f64) -> Self { + Self::Number(value) + } +} + +impl From<&str> for ContextValue { + fn from(value: &str) -> Self { + Self::Text(value.to_owned()) + } +} + +impl From for ContextValue { + fn from(value: String) -> Self { + Self::Text(value) + } +} + /// Kinds of workbench events; later SDKs add more. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[non_exhaustive] diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index 484e6c309..ccf14224c 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -17,12 +17,29 @@ interface ui { command: option, } + /// A context key's value, as `when` clauses compare it. + variant context-value { + flag(bool), + /// A finite number. + number(f64), + /// At most 1024 bytes. + text(string), + } + /// Shows a notification balloon in the window the current command or /// event came from (every window when there is none). show-notification: func(level: notification-level, title: string, body: string); /// 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); + /// Sets the plugin's context key `key`, which `when` clauses test as + /// `plugin..`, in the window the current command or event came + /// from (every window when there is none). A window's value takes + /// precedence over the global one. `key` is 1-64 of `A-Za-z0-9_-`; at most + /// 100 keys; they go away when the plugin stops. + set-context: func(key: string, value: context-value) -> result<_, error>; + /// Removes the key `set-context` set in the same scope. + remove-context: func(key: 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. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 39b86e93b..e0d14aa8e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -480,6 +480,7 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_post_events, plugin::plugin_event_kinds, plugin::plugin_status_items, + plugin::plugin_context_keys, plugin::plugin_cancel_task, plugin::plugin_view_render, plugin::plugin_view_event, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 5c38e419a..0b8e1ece0 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -8,8 +8,8 @@ use ide_core::{IdeError, IdeResult}; use ide_extension::ActivationEvent; use ide_host::plugins::intellij::{self, InstalledIntellijTheme, IntellijThemeSource}; use ide_host::plugins::{ - ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PackageFile, PluginEditor, - PluginEvent, PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent, + ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PackageFile, PluginContextKey, + PluginEditor, PluginEvent, PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent, }; use ide_plugin::{Invocation, Limits}; use parking_lot::Mutex; @@ -371,6 +371,13 @@ pub fn plugin_status_items(state: State<'_, AppState>) -> Vec .collect() } +/// Plugin context keys in effect for this window (later changes arrive as `contextChanged`). +#[tauri::command] +#[specta::specta] +pub fn plugin_context_keys(window: Window, state: State<'_, AppState>) -> Vec { + state.plugins.context_keys(Some(window.label())) +} + /// Cancels a plugin task (`taskStarted` with `cancellable`). #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca91034e6..303627713 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -209,6 +209,7 @@ pub fn run() { windows::close_auxiliary_windows(window.app_handle(), window.label()); if let Some(state) = window.try_state::() { state.close_project(window.label()); + state.plugins.forget_client(window.label()); } } if let Some(w) = window.try_state::() { diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 6a0690ba4..f7bbfa289 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -482,6 +482,9 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< config.visible = false; } let state = app.try_state::(); + if let Some(state) = &state { + state.plugins.reopen_client(&config.label); + } let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance); // A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame. let plugin_frame = state.as_ref().and_then(|s| s.plugins.startup_frame()).and_then(|c| hex_color(&c)); diff --git a/src/bindings.ts b/src/bindings.ts index 17e583d0c..8496a1418 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1030,6 +1030,8 @@ export const commands = { pluginEventKinds: () => __TAURI_INVOKE("plugin_event_kinds"), /** Every plugin status bar item (later changes arrive as `statusItemChanged`). */ pluginStatusItems: () => __TAURI_INVOKE("plugin_status_items"), + /** Plugin context keys in effect for this window (later changes arrive as `contextChanged`). */ + pluginContextKeys: () => __TAURI_INVOKE("plugin_context_keys"), /** Cancels a plugin task (`taskStarted` with `cancellable`). */ pluginCancelTask: (taskId: string) => __TAURI_INVOKE("plugin_cancel_task", { taskId }), /** @@ -5659,6 +5661,13 @@ export type PhpSettings = { phpstanConfig?: string, }; +/** A plugin's context key for `when` clauses (`value` is JSON text). */ +export type PluginContextKey = { + pluginId: string, + key: string, + value: string, +}; + /** The focused editor when a plugin command is invoked. */ export type PluginEditor = { path: string, @@ -5679,6 +5688,11 @@ export type PluginEvent = { type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } | /** A plugin's status bar item was set (`item`) or removed. */ { type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } | +/** + * A plugin's context key was set (`value`: JSON text) or removed, in the + * emitted window (every window when the client is `None`). + */ +{ type: "contextChanged"; pluginId: string; key: string; value: string | null } | /** A plugin began a task: show it with the background tasks. */ { type: "taskStarted"; pluginId: string; taskId: string; title: string; cancellable: boolean } | { type: "taskProgress"; pluginId: string; taskId: string; done: number; total: number | null; message: string | null } | { type: "taskEnded"; pluginId: string; taskId: string } | /** diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 5606169dc..3e4c05ee8 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -26,7 +26,8 @@ import { import { iconResolver, selectedIconTheme, useIconThemes } from "../iconThemes"; import { markerIcon, placeMarkers } from "../lineMarkerGutter"; import { SecretsDetails } from "../secrets"; -import { usePluginStatusItems } from "../runtimeUi"; +import { handleRuntimeEvent, startRuntimeUi, usePluginStatusItems } from "../runtimeUi"; +import { evaluateWhen, getContext } from "@/platform/context"; import { useTasks } from "@/workbench/tasks"; import { defineBuiltinUiPoints } from "../points"; import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection"; @@ -1075,3 +1076,95 @@ describe("catalog helpers", () => { expect(enabled()).toEqual({ a: true, b: true, d: true }); }); }); + +describe("plugin context keys", () => { + it("applies plugin context keys from the backend", async () => { + let keys = [ + { pluginId: "p", key: "isEntity", value: "true" }, + { pluginId: "p", key: "kind", value: '"pod"' }, + ]; + setPluginsBackend({ + ...createMemoryPluginsBackend(), + contextKeys: async () => keys, + }); + const changed = { + type: "contextChanged", + pluginId: "p", + key: "isEntity", + value: "true", + } as const; + expect(handleRuntimeEvent(changed)).toBe(true); + await waitFor(() => expect(evaluateWhen("plugin.p.isEntity")).toBe(true)); + expect(evaluateWhen("plugin.p.kind == pod")).toBe(true); + keys = keys.slice(1); + handleRuntimeEvent({ ...changed, value: null }); + await waitFor(() => expect(evaluateWhen("plugin.p.isEntity")).toBe(false)); + expect(evaluateWhen("plugin.p.kind == pod")).toBe(true); + keys = []; + handleRuntimeEvent({ ...changed, key: "kind", value: null }); + await waitFor(() => expect(evaluateWhen("plugin.p.kind == pod")).toBe(false)); + expect("plugin.p.kind" in getContext()).toBe(false); + expect("plugin.p.isEntity" in getContext()).toBe(false); + }); + + it("refreshes the keys when another backend publishes a project with the same root", async () => { + let keys = [{ pluginId: "p", key: "backend", value: '"a"' }]; + setPluginsBackend({ ...createMemoryPluginsBackend(), contextKeys: async () => keys }); + const project = { root: "/p", trusted: true } as never; + act(() => useProject.setState({ project })); + const stop = startRuntimeUi(); + try { + keys = [{ pluginId: "p", key: "backend", value: '"b"' }]; + act(() => useProject.setState({ project: { root: "/p", trusted: true } as never })); + await waitFor(() => expect(evaluateWhen("plugin.p.backend == b")).toBe(true)); + } finally { + stop(); + act(() => useProject.setState({ project: null })); + } + }); + + it("coalesces a burst of changes into one follow-up fetch", async () => { + type Keys = { pluginId: string; key: string; value: string }[]; + const pending: ((k: Keys) => void)[] = []; + setPluginsBackend({ + ...createMemoryPluginsBackend(), + contextKeys: () => new Promise((resolve) => pending.push(resolve)), + }); + const event = { type: "contextChanged", pluginId: "p", key: "a", value: "true" } as const; + for (let i = 0; i < 5; i++) handleRuntimeEvent(event); + expect(pending).toHaveLength(1); + pending[0]!([{ pluginId: "q", key: "b", value: "true" }]); + await waitFor(() => expect(pending).toHaveLength(2)); + pending[1]!([ + { pluginId: "p", key: "a", value: "true" }, + { pluginId: "q", key: "b", value: "true" }, + ]); + await waitFor(() => expect(evaluateWhen("plugin.p.a")).toBe(true)); + expect(evaluateWhen("plugin.q.b")).toBe(true); + await Promise.resolve(); + expect(pending).toHaveLength(2); + + handleRuntimeEvent({ ...event, value: null }); + expect(pending).toHaveLength(3); + pending[2]!([{ pluginId: "q", key: "b", value: "true" }]); + await waitFor(() => expect(evaluateWhen("plugin.p.a")).toBe(false)); + expect(evaluateWhen("plugin.q.b")).toBe(true); + }); + + it("fetches again after a failed fetch when changes arrived meanwhile", async () => { + type Keys = { pluginId: string; key: string; value: string }[]; + const pending: { resolve: (k: Keys) => void; reject: () => void }[] = []; + setPluginsBackend({ + ...createMemoryPluginsBackend(), + contextKeys: () => + new Promise((resolve, reject) => pending.push({ resolve, reject: () => reject(new Error("x")) })), + }); + const event = { type: "contextChanged", pluginId: "p", key: "f", value: "true" } as const; + handleRuntimeEvent(event); + handleRuntimeEvent(event); + pending[0]!.reject(); + await waitFor(() => expect(pending).toHaveLength(2)); + pending[1]!.resolve([{ pluginId: "p", key: "f", value: "true" }]); + await waitFor(() => expect(evaluateWhen("plugin.p.f")).toBe(true)); + }); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 84476bdee..d949e2068 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -14,6 +14,7 @@ import type { InstallPreview, IntellijThemeSource, MarketPlugin, + PluginContextKey, PluginEditor, PluginEvent, PluginInfo, @@ -61,6 +62,7 @@ export interface PluginsBackend { eventKinds(): Promise; /** Plugin status bar items (later changes arrive as `statusItemChanged`). */ statusItems(): Promise; + contextKeys(): Promise; cancelTask(taskId: string): Promise; /** Marketplace plugins: the index read again (`refresh`) or the last one read. */ market(refresh: boolean): Promise; @@ -158,6 +160,7 @@ export const tauriPluginsBackend: PluginsBackend = { }, eventKinds: () => commands.pluginEventKinds(), statusItems: () => commands.pluginStatusItems(), + contextKeys: () => commands.pluginContextKeys(), cancelTask: async (taskId) => { await commands.pluginCancelTask(taskId); }, @@ -500,6 +503,7 @@ export function createMemoryPluginsBackend( postEvents: async () => {}, eventKinds: async () => [], statusItems: async () => [], + contextKeys: async () => [], cancelTask: async () => {}, market: async () => [], marketUrl: async () => marketUrl, diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index f8de39bfa..73a4e7632 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -5,9 +5,10 @@ * batches. */ import { create } from "zustand"; -import type { EventKind, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings"; +import type { EventKind, PluginContextKey, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings"; import { onEditorEvent } from "@/editor/api"; import { executeAction, getAction, onActionExecuted } from "@/platform/actions"; +import { clearContext, setContext, type ContextValue } from "@/platform/context"; import { onIdeEvent } from "@/platform/ipc"; import { openExternalUrl } from "@/platform/openUrl"; import { useProject } from "@/platform/project"; @@ -122,6 +123,7 @@ function projectStatusItems(): () => void { useDeclaredStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()), ]; refreshStatusItems(); + refreshContextKeys(); return () => { unsubscribe.forEach((u) => u()); for (const r of registered.values()) r.dispose(); @@ -141,6 +143,60 @@ function refreshStatusItems() { .catch(() => {}); } +function applyPluginContext(pluginId: string, key: string, value: string | null) { + const name = `plugin.${pluginId}.${key}`; + let parsed: ContextValue; + try { + parsed = value === null ? undefined : JSON.parse(value); + } catch { + parsed = undefined; + } + if (parsed === undefined) clearContext(name); + else setContext(name, parsed); +} + +const appliedContextKeys = new Map>(); +let contextFetching = false; +let contextDirty = false; + +function applyContextKeys(keys: PluginContextKey[]) { + const fresh = new Map>(); + for (const k of keys) { + applyPluginContext(k.pluginId, k.key, k.value); + if (!fresh.has(k.pluginId)) fresh.set(k.pluginId, new Set()); + fresh.get(k.pluginId)!.add(k.key); + } + for (const [id, applied] of appliedContextKeys) { + for (const key of applied) { + if (!fresh.get(id)?.has(key)) applyPluginContext(id, key, null); + } + } + appliedContextKeys.clear(); + for (const [id, set] of fresh) appliedContextKeys.set(id, set); +} + +/** + * Re-reads the effective context keys of every plugin from the backend (the event does + * not say whether a change was window- or global-scoped) and drops keys it no longer + * reports. One fetch at a time: changes that arrive meanwhile cost a single follow-up. + */ +function refreshContextKeys() { + if (contextFetching) { + contextDirty = true; + return; + } + contextFetching = true; + contextDirty = false; + pluginsBackend() + .contextKeys() + .then(applyContextKeys) + .catch(() => {}) + .finally(() => { + contextFetching = false; + if (contextDirty) refreshContextKeys(); + }); +} + // --- tasks -------------------------------------------------------------------- const taskId = (id: string) => `plugin:${id}`; @@ -239,6 +295,9 @@ function trackWorkbenchEvents(): () => void { /** Handles the runtime UI events; returns whether `e` was one. */ export function handleRuntimeEvent(e: PluginEvent): boolean { switch (e.type) { + case "contextChanged": + refreshContextKeys(); + return true; case "statusItemChanged": setStatusItem(e.pluginId, e.id, e.item); return true; @@ -277,13 +336,15 @@ export function handleRuntimeEvent(e: PluginEvent): boolean { /** * The backend refuses the event kinds and status items until this window's * project is open and trusted (`authorize_command`), but startup asks before - * that: ask again when it changes. + * that: ask again when it changes. Any new project object counts, even with the same + * root and trust: connecting another backend publishes one and changes where calls go. */ function refreshOnProjectChange(): () => void { return useProject.subscribe((s, prev) => { - if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return; + if (s.project === prev.project) return; refreshEventKinds(); refreshStatusItems(); + refreshContextKeys(); }); } diff --git a/src/platform/context.test.ts b/src/platform/context.test.ts index 3972ad86e..94de5f479 100644 --- a/src/platform/context.test.ts +++ b/src/platform/context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { evaluateWhen } from "./context"; +import { clearContext, evaluateWhen, getContext, setContext } from "./context"; describe("when clauses", () => { const ctx = { editorFocus: true, lang: "rust", count: 2, debug: false }; @@ -24,3 +24,20 @@ describe("when clauses", () => { expect(() => evaluateWhen("(a", ctx)).toThrow(); }); }); + +describe("clearContext", () => { + it("deletes the key and keeps the others", () => { + setContext("a.x", 1); + setContext("a.y", 2); + clearContext("a.x"); + expect("a.x" in getContext()).toBe(false); + expect(getContext()["a.y"]).toBe(2); + clearContext("a.y"); + }); + + it("does nothing for an absent key", () => { + const before = getContext(); + clearContext("a.never"); + expect(getContext()).toBe(before); + }); +}); diff --git a/src/platform/context.ts b/src/platform/context.ts index b89479f88..7a08fdbd8 100644 --- a/src/platform/context.ts +++ b/src/platform/context.ts @@ -18,6 +18,13 @@ export function setContext(key: string, value: ContextValue) { if (useContextKeys.getState()[key] !== value) useContextKeys.setState({ [key]: value }); } +export function clearContext(key: string) { + const next = { ...useContextKeys.getState() }; + if (!(key in next)) return; + delete next[key]; + useContextKeys.setState(next, true); +} + export function getContext(): ContextKeys { return useContextKeys.getState(); } diff --git a/wit/host.wit b/wit/host.wit index 484e6c309..ccf14224c 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -17,12 +17,29 @@ interface ui { command: option, } + /// A context key's value, as `when` clauses compare it. + variant context-value { + flag(bool), + /// A finite number. + number(f64), + /// At most 1024 bytes. + text(string), + } + /// Shows a notification balloon in the window the current command or /// event came from (every window when there is none). show-notification: func(level: notification-level, title: string, body: string); /// 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); + /// Sets the plugin's context key `key`, which `when` clauses test as + /// `plugin..`, in the window the current command or event came + /// from (every window when there is none). A window's value takes + /// precedence over the global one. `key` is 1-64 of `A-Za-z0-9_-`; at most + /// 100 keys; they go away when the plugin stops. + set-context: func(key: string, value: context-value) -> result<_, error>; + /// Removes the key `set-context` set in the same scope. + remove-context: func(key: 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.