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 b818841e3..76da2d123 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 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 3121b3a5a..47daa6a00 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 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 c7944b662..afb7fd661 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 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 788f3fc2f..e5df94c31 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 f0f217a64..70dadd99e 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -920da064d50f59aa34825f5dc8c59442b7185891bbded8f7f1c87c8192c093ce +ec737f2490193fb319e28ec7d44f49cb64e6c75fdea67b44288899f7a6385e95 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 0121a070d..e3a195a6a 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 61ca0e559..dc2ce64ab 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -7c9279246766fd92f854fe8dc33f0165236f616a448782dfa59ea9c05463cf8a +130a82376feeb0a3879d110b4ec2417b0ae6839088b6a8ba42b101869c6248d8 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index dc541c60e..87792c863 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -55cc148dc0eddf924e97074a46707736e563b0fe6d348a09610b1822ed085d6b +9d7d6995e4f6eb96a669a11f9c75e2c77f2663eb905275346e77d20c6dd5d929 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 76b901d4a..44d817b6c 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 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 8659340b2..c33b356c2 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 a0c7e5687..92703d8bd 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -5a83f6c626f01c80910e1e74e6452bc2bbef8de3fe402d5540f506dc6dd02ed0 +8de2b1598c2b9f8438a901c70197ba90ef6e85c3f510125e8bb72b002d25d7f6 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 4ebdb24f6..0966aff50 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 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 8ad1974a6..f283f1601 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 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.