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.<id>.<key>` 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<ContextValue>`. All components are rebuilt for the WIT change.

Closes #1622

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-06 23:02:34 +08:00
1 джерело 708f1b051b
коміт a3d5336642
50 files changed
+664 -24

No files matched your search

+81 -1
Переглянути файл
@@ -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, 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<BTreeMap<(String, String), StatusItem>>,
/// (scope, plugin, key) → JSON value.
contexts: Mutex<BTreeMap<ContextKey, String>>,
closed_clients: Mutex<BTreeSet<String>>,
/// plugin → kinds subscribed at run time (kept across idle reclaim).
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
/// 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<PluginContextKey> {
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>, 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<u64> = 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<String>) -> 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<PathBuf> {
self.shared.workspace().map(|w| w.projects()).unwrap_or_default()
}
+17
Переглянути файл
@@ -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<PluginContextKey> {
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))
+7 -1
Переглянути файл
@@ -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::<String>("taskId")?)) }
+13
Переглянути файл
@@ -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<StatusItem> },
/// 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<String> },
/// 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")]
+125
Переглянути файл
@@ -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);
+17
Переглянути файл
@@ -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",
+6
Переглянути файл
@@ -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<StatusItem>);
/// 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<String>) -> Result<(), HostError> {
let _ = (client, key, value);
Ok(())
}
/// Roots of the open projects.
fn projects(&self) -> Vec<std::path::PathBuf>;
/// Unsaved text of an open editor's file.
+30
Переглянути файл
@@ -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));
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7a84aaf2ad8fcb0bf2f9fc0eeff6ad8664512a3fa6d9c4c205c19eff9a3e1315
406afd6a36f1cf2ff59da5826c155b84516daef6f1709fbd7f7618e0eaad852b
+1 -1
Переглянути файл
@@ -1 +1 @@
92aece4d9f24c4429f2dcfc64e65a53219103267130731af49b65c7c581048d4
060b79a6fb25455d67dffd3623984f1d22836b908570a0537142f83d11cd5522
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
17136d5359a8f5153ca2650e4c4b2c8f86eb31d0b99ef17974e0217c04f9a6e5
cadf7d5b651792b502e51587c43d49cc0f87477439291d93858ceaf3692b1879
+5
Переглянути файл
@@ -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"
+13
Переглянути файл
@@ -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::<Vec<_>>().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::<f64>() {
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)
+1 -1
Переглянути файл
@@ -1 +1 @@
b697a2aa34b0de05db3e89bb8449ab6619119fa003b19fabe36b0932246a26fe
f9b1d2af61bac2bac97ad11b629ecaa062d53aeba730e3fcb686ecaa514bc1b6
+5
Переглянути файл
@@ -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.<id>.<key>`, 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.<id>.<key>` 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. |
+1 -1
Переглянути файл
@@ -1 +1 @@
f63e6547849ce8f5a0de4384eb44a903583135dea7e5a82829daf3a395e98465
b143041299679ec90d16a35c6eaae4c2525648bc33f06c40d72fba7f63fb1ab2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
af6dbfa8c34e5cb62851f02479f86350ee915c18028993331b4372a739074b80
f87528deb3a497c841456b158d46f22fbc05909dfe4b886ed222c2e86dc824ba
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
920da064d50f59aa34825f5dc8c59442b7185891bbded8f7f1c87c8192c093ce
ec737f2490193fb319e28ec7d44f49cb64e6c75fdea67b44288899f7a6385e95
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7c9279246766fd92f854fe8dc33f0165236f616a448782dfa59ea9c05463cf8a
130a82376feeb0a3879d110b4ec2417b0ae6839088b6a8ba42b101869c6248d8
+1 -1
Переглянути файл
@@ -1 +1 @@
55cc148dc0eddf924e97074a46707736e563b0fe6d348a09610b1822ed085d6b
9d7d6995e4f6eb96a669a11f9c75e2c77f2663eb905275346e77d20c6dd5d929
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
eda75943217c5273773ef0f2934c20d6bda50411670d99145b50e45d104509cc
8225d25b08f9ec36b36a850d1a99a43643a3c06a3e9d6c835e75fb269eeac61a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5a83f6c626f01c80910e1e74e6452bc2bbef8de3fe402d5540f506dc6dd02ed0
8de2b1598c2b9f8438a901c70197ba90ef6e85c3f510125e8bb72b002d25d7f6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3984d6b51bea7212bbabadc2a5a632c4cba7a2a988033fa7918e33c203fcfa23
e9cd442b032d0521e0b3c733a3a9866a203ddf5bacb381396f5a0a6c508de5ca
+1 -1
Переглянути файл
@@ -1 +1 @@
2a70078e8edf29c9a4b590d661a1a67cb605dca3781a89ffcf6dc8acb0d183b0
3e4bd05682cf1b748ab7189b17b8937eaeb332641804da59ee684db9ad848579
+1 -1
Переглянути файл
@@ -1 +1 @@
4449ee6a1ccee1a71ebed1073bb4e30df1686ebd0a2812bdd579ad876d0a43cf
3b2892a7629ed255add98ebd88e4a1fc454720ee9cae9bf4ffa74fa9b570adff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
adf2ab3e93e0147350b6db899ec2e44e07862e99f49609aecbc2b866e5282c3d
6183df09a561bc715ec353223b6ae76d0280c179191621e2d0ef809b7b3b2d32
+26
Переглянути файл
@@ -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.<id>.<key>`, 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<ContextValue>) -> 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)
+21
Переглянути файл
@@ -60,6 +60,7 @@ struct State {
notifications: Vec<Notification>,
logs: Vec<(LogLevel, String)>,
status: BTreeMap<String, StatusItem>,
context: BTreeMap<String, ContextValue>,
storage: BTreeMap<String, String>,
subscriptions: BTreeSet<EventKind>,
tasks: Vec<MockTask>,
@@ -191,6 +192,10 @@ impl MockHost {
self.state.borrow().status.clone()
}
pub fn context_keys(&self) -> BTreeMap<String, ContextValue> {
self.state.borrow().context.clone()
}
pub fn storage(&self) -> BTreeMap<String, String> {
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", "[email protected]:o/r.git")]);
assert_eq!(project.vcs_remotes().unwrap(), [("origin".into(), "[email protected]:o/r.git".into())]);
host::copy_to_clipboard("copied").unwrap();
+34
Переглянути файл
@@ -143,6 +143,40 @@ pub struct StatusItem {
pub command: Option<String>,
}
/// 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<bool> for ContextValue {
fn from(value: bool) -> Self {
Self::Flag(value)
}
}
impl From<f64> 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<String> 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]
+17
Переглянути файл
@@ -17,12 +17,29 @@ interface ui {
command: option<string>,
}
/// 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.<id>.<key>`, 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.
+1
Переглянути файл
@@ -480,6 +480,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+9 -2
Переглянути файл
@@ -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<PluginStatusItem>
.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<PluginContextKey> {
state.plugins.context_keys(Some(window.label()))
}
/// Cancels a plugin task (`taskStarted` with `cancellable`).
#[tauri::command]
#[specta::specta]
+1
Переглянути файл
@@ -209,6 +209,7 @@ pub fn run() {
windows::close_auxiliary_windows(window.app_handle(), window.label());
if let Some(state) = window.try_state::<AppState>() {
state.close_project(window.label());
state.plugins.forget_client(window.label());
}
}
if let Some(w) = window.try_state::<WindowsState>() {
+3
Переглянути файл
@@ -482,6 +482,9 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
config.visible = false;
}
let state = app.try_state::<AppState>();
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));
+14
Переглянути файл
@@ -1030,6 +1030,8 @@ export const commands = {
pluginEventKinds: () => __TAURI_INVOKE<EventKind[]>("plugin_event_kinds"),
/** Every plugin status bar item (later changes arrive as `statusItemChanged`). */
pluginStatusItems: () => __TAURI_INVOKE<PluginStatusItem[]>("plugin_status_items"),
/** Plugin context keys in effect for this window (later changes arrive as `contextChanged`). */
pluginContextKeys: () => __TAURI_INVOKE<PluginContextKey[]>("plugin_context_keys"),
/** Cancels a plugin task (`taskStarted` with `cancellable`). */
pluginCancelTask: (taskId: string) => __TAURI_INVOKE<boolean>("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 } |
/**
+94 -1
Переглянути файл
@@ -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<Keys>((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<Keys>((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));
});
});
+4
Переглянути файл
@@ -14,6 +14,7 @@ import type {
InstallPreview,
IntellijThemeSource,
MarketPlugin,
PluginContextKey,
PluginEditor,
PluginEvent,
PluginInfo,
@@ -61,6 +62,7 @@ export interface PluginsBackend {
eventKinds(): Promise<EventKind[]>;
/** Plugin status bar items (later changes arrive as `statusItemChanged`). */
statusItems(): Promise<PluginStatusItem[]>;
contextKeys(): Promise<PluginContextKey[]>;
cancelTask(taskId: string): Promise<void>;
/** Marketplace plugins: the index read again (`refresh`) or the last one read. */
market(refresh: boolean): Promise<MarketPlugin[]>;
@@ -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,
+64 -3
Переглянути файл
@@ -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<string, Set<string>>();
let contextFetching = false;
let contextDirty = false;
function applyContextKeys(keys: PluginContextKey[]) {
const fresh = new Map<string, Set<string>>();
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();
});
}
+18 -1
Переглянути файл
@@ -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);
});
});
+7
Переглянути файл
@@ -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();
}
+17
Переглянути файл
@@ -17,12 +17,29 @@ interface ui {
command: option<string>,
}
/// 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.<id>.<key>`, 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.