дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms
Цей коміт міститься в:
коміт
4f71920960
52 files changed
+550
-46
No files matched your search
@@ -715,10 +715,23 @@ fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
}
|
||||
|
||||
impl HostApi for PluginApi {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String) {
|
||||
self.shared
|
||||
.sink
|
||||
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
|
||||
fn notify(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
if !actions.is_empty() {
|
||||
let commands = self.shared.ui.items("commands", Some(self.id()));
|
||||
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
|
||||
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
|
||||
}
|
||||
}
|
||||
let plugin_id = self.id().to_owned();
|
||||
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
@@ -1429,6 +1442,27 @@ mod tests {
|
||||
assert_eq!(api.storage_get("other").unwrap().as_deref(), Some("value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_actions_run_the_plugins_own_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let _command = api.shared.ui.insert(ide_extension::UiContribution {
|
||||
plugin_id: api.id().into(),
|
||||
point: "commands".into(),
|
||||
key: "run".into(),
|
||||
decl: serde_json::json!({}),
|
||||
});
|
||||
let action = |command: &str| ide_plugin::NotificationAction {
|
||||
label: "Run".into(),
|
||||
command: command.into(),
|
||||
argument: Some("a\nb".into()),
|
||||
};
|
||||
let notify = |actions| api.notify(Some("main"), NotificationLevel::Info, "Done".into(), "".into(), actions);
|
||||
notify(vec![]).unwrap();
|
||||
notify(vec![action("run")]).unwrap();
|
||||
assert!(matches!(notify(vec![action("run"), action("other.command")]), Err(HostError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_and_search_paths_match_ipc_events() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -429,6 +429,7 @@ impl PluginHost {
|
||||
level: ide_plugin::NotificationLevel::Warning,
|
||||
title: format!("{} was disabled", plugin.manifest().name),
|
||||
body: format!("The marketplace revoked this plugin: {reason}"),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -913,6 +913,7 @@ impl PluginHost {
|
||||
level: ide_plugin::NotificationLevel::Warning,
|
||||
title: format!("{name} update rolled back"),
|
||||
body: message.clone(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
self.changed();
|
||||
@@ -2129,6 +2130,7 @@ impl PluginHost {
|
||||
level: NotificationLevel::Warning,
|
||||
title: format!("Plugin \u{201c}{}\u{201d} crashed", plugin.manifest().name),
|
||||
body: format!("{error}\nIt restarts the next time it is needed."),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2190,6 +2192,7 @@ impl PluginHost {
|
||||
level: NotificationLevel::Error,
|
||||
title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest().name),
|
||||
body: error.to_owned(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
self.changed();
|
||||
|
||||
@@ -172,7 +172,14 @@ pub enum PluginEvent {
|
||||
/// once per set of versions).
|
||||
UpdatesAvailable { updates: Vec<PluginUpdate> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
|
||||
Notification {
|
||||
plugin_id: String,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
/// Buttons running the plugin's own commands (`ui.notify`).
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
},
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
|
||||
@@ -3353,6 +3353,36 @@ fn invalid_plugin_icons_refuse_the_package() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_actions_keep_commands_and_window_scope() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.take_events();
|
||||
f.host
|
||||
.run_command("jobs", "notify-actions", None, Invocation { client: Some("second".into()), ..f.inv() })
|
||||
.unwrap();
|
||||
let notifications = f
|
||||
.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, event)| match event {
|
||||
PluginEvent::Notification { plugin_id, actions, .. } => Some((client, plugin_id, actions)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
assert_eq!(notifications[0].0.as_deref(), Some("second"));
|
||||
assert_eq!(notifications[0].1, "jobs");
|
||||
assert_eq!(notifications[0].2.len(), 3);
|
||||
assert_eq!(notifications[0].2[0].command, "context");
|
||||
assert_eq!(notifications[0].2[0].argument.as_deref(), Some("notification-action=1\nvalue"));
|
||||
for invalid in ["four", "unknown"] {
|
||||
let error = f.run("jobs", "notify-actions", Some(invalid)).unwrap_err();
|
||||
assert!(error.contains(if invalid == "four" { "at most 3" } else { "declares no command" }), "{error}");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::Notification { .. })));
|
||||
}
|
||||
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
|
||||
use ide_plugin::Prompt;
|
||||
|
||||
@@ -1475,7 +1475,20 @@ impl LspManager {
|
||||
};
|
||||
let native =
|
||||
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
|
||||
let (configuration, result) = futures::join!(configuration, native);
|
||||
futures::pin_mut!(configuration, native);
|
||||
let (configuration, result) = match futures::future::select(configuration, native).await {
|
||||
futures::future::Either::Left((configuration, native)) => (configuration, native.await),
|
||||
futures::future::Either::Right((result, configuration)) => {
|
||||
// The configuration index is rebuilt after Java changes, which takes seconds in a
|
||||
// project with many dependencies: the usages found meanwhile are shown first.
|
||||
if let (Some(partial), Ok(found)) = (partial, &result)
|
||||
&& !found.locations.is_empty()
|
||||
{
|
||||
partial(found.clone());
|
||||
}
|
||||
(configuration.await, result)
|
||||
}
|
||||
};
|
||||
let configuration = configuration?;
|
||||
let mut result = result?;
|
||||
if let Some(configuration) = configuration {
|
||||
|
||||
@@ -4128,6 +4128,52 @@ async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_
|
||||
assert_eq!(refs.incomplete, None);
|
||||
}
|
||||
|
||||
/// The configuration index is rebuilt after Java changes, which can take seconds: Show Usages
|
||||
/// shows the Java usages meanwhile, then adds the configuration ones.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_usages_arrive_before_configuration_usages() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages", "openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let yaml = h.root.join("application.yml");
|
||||
std::fs::write(&yaml, "app:\n target: 1\n").unwrap();
|
||||
let property = json!({"uri": uri_of(&yaml), "range": {
|
||||
"start": {"line": 1, "character": 2}, "end": {"line": 1, "character": 8}}});
|
||||
let gate = Arc::new(Gate::default());
|
||||
let held = gate.clone();
|
||||
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
|
||||
(method == "workspace/executeCommand").then(|| {
|
||||
if params["command"] == "openidea.java.configuration" {
|
||||
held.hold();
|
||||
return json!({"locations": [property.clone()], "keys": [{"name": "app.target", "relaxed": true}], "roots": []});
|
||||
}
|
||||
let docs = s.docs.lock();
|
||||
let rows = docs.iter().flat_map(|(uri, text)| occurrences(uri, text));
|
||||
Value::Array(rows.map(|location| json!({"location": location, "kind": "unknown"})).collect())
|
||||
})
|
||||
}));
|
||||
let (doc, a) = h.open("a.java", "int target;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let partials = Mutex::new(Vec::<References>::new());
|
||||
let push = |r| partials.lock().push(r);
|
||||
let search = h.mgr.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), Some(&push));
|
||||
let release = async {
|
||||
eventually(|| !partials.lock().is_empty()).await;
|
||||
assert!(gate.entered(), "shown before the configuration lookup started");
|
||||
gate.open();
|
||||
};
|
||||
let (refs, ()) = tokio::join!(search, release);
|
||||
let refs = refs.unwrap();
|
||||
let partials = partials.into_inner();
|
||||
assert_eq!(partials.len(), 1);
|
||||
let paths = |r: &References| r.locations.iter().map(|l| l.path.to_path_buf()).collect::<Vec<_>>();
|
||||
assert_eq!(paths(&partials[0]), std::slice::from_ref(&a));
|
||||
assert_eq!(partials[0].classified.len(), 1);
|
||||
assert_eq!(paths(&refs), [a, yaml]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
let mut caps = incremental_caps();
|
||||
|
||||
@@ -60,7 +60,15 @@ pub const CANCELLED: &str = "was cancelled";
|
||||
/// which routes notifications and status items to windows and edits to open
|
||||
/// documents. Capabilities are already checked when these are called.
|
||||
pub trait HostApi: Send + Sync {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
|
||||
/// Shows a notification; `actions` name commands the plugin declares.
|
||||
fn notify(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<crate::NotificationAction>,
|
||||
) -> Result<(), HostError>;
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let _ = (client, text);
|
||||
Ok(())
|
||||
|
||||
@@ -645,3 +645,12 @@ pub enum ViewEvent {
|
||||
// as an `other` view-event, kind `sort`). See `TableSort`.
|
||||
Sort { column: u32, descending: bool },
|
||||
}
|
||||
|
||||
/// A notification button that runs one of the publishing plugin's commands.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationAction {
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
@@ -154,6 +154,14 @@ pub(crate) fn to_wit(e: HostError) -> wit::Error {
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_level(level: wit::NotificationLevel) -> NotificationLevel {
|
||||
match level {
|
||||
wit::NotificationLevel::Info => NotificationLevel::Info,
|
||||
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
||||
wit::NotificationLevel::Error => NotificationLevel::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
|
||||
impl From<wit::Error> for crate::runtime::GuestError {
|
||||
fn from(e: wit::Error) -> Self {
|
||||
@@ -266,19 +274,40 @@ impl StoreData {
|
||||
}
|
||||
|
||||
impl ui::Host for StoreData {
|
||||
fn notify(
|
||||
&mut self,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ui::NotificationAction>,
|
||||
) -> Fallible<()> {
|
||||
const MAX_ACTIONS: usize = 3;
|
||||
if actions.len() > MAX_ACTIONS {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
|
||||
}
|
||||
let mut checked = Vec::with_capacity(actions.len());
|
||||
for a in actions {
|
||||
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
||||
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
|
||||
}
|
||||
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
||||
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
|
||||
}
|
||||
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
|
||||
}
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn show_notification(
|
||||
&mut self,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
) -> wasmtime::Result<()> {
|
||||
let level = match level {
|
||||
wit::NotificationLevel::Info => NotificationLevel::Info,
|
||||
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
||||
wit::NotificationLevel::Error => NotificationLevel::Error,
|
||||
};
|
||||
let max = crate::runtime::MAX_TITLE;
|
||||
self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10));
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
// A plain notification has nothing the host could refuse.
|
||||
let _ = self.host.notify(self.client(), notification_level(level), title, body, Vec::new());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
2ddbea56d198b52839c6506c9d03a87ea1c0cd8b518c0f3ee58f4eecf9d320e2
|
||||
effa9ede0252cc746bffdb3cc092b04749c050cd14263530da37c0c539183288
|
||||
@@ -1 +1 @@
|
||||
6709ed49ec442d7deca083534c13c35a1ca0355de8d073c55609ce784fe65db7
|
||||
4bf56a70aa09995f9acb935434b14954bd89094ba196b645c7e372b3a6403c2e
|
||||
@@ -1 +1 @@
|
||||
1915812d45316cf499611f471cf7abfdad47aa158295493bf81362f690ace866
|
||||
c2d10319579f7a4b441d00b380a97c24fbc7ad8fac20791c90fe6f864e4f2027
|
||||
@@ -1 +1 @@
|
||||
b55985f72b2636a84441f8effef10f8c4f638bd65be3f2f247d9a636c20e767b
|
||||
003e8d2c175727e7bd0239aed217c8c344e8136b020a214079c530d236d75ec1
|
||||
+1
-1
@@ -1 +1 @@
|
||||
62888af0c9fb057ec4a696f0bfd5fd6fd07495a16a3021b4143de74b5cd30e26
|
||||
8c959561aec1b2e1390d2f9df269f0070af2c5586c906045bf0018078e6b6eed
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
79a67b0af9e0118961a83eecd644c2e1b87a98043d34b96bc49d4042af5d895f
|
||||
6485327fb8702f49600cec510359b3aba4c468df53d695f84cb03a9e41b96a89
|
||||
@@ -39,6 +39,11 @@ category = "Jobs"
|
||||
title = "Jobs"
|
||||
schema = "settings.schema.json"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "notify-actions"
|
||||
title = "Jobs: Show Notification Actions"
|
||||
category = "Jobs"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "pick-directory"
|
||||
title = "Pick directory"
|
||||
|
||||
@@ -53,6 +53,23 @@ impl Plugin for Jobs {
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
"notify-actions" => {
|
||||
if arg == Some("plain") {
|
||||
host::notify(NotificationLevel::Info, "Choose an action", "A plugin notification");
|
||||
return Ok(None);
|
||||
}
|
||||
use openidea_plugin::NotificationAction;
|
||||
let command = if arg == Some("unknown") { "not-declared" } else { "context" };
|
||||
let count = if arg == Some("four") { 4 } else { 3 };
|
||||
let actions = (0..count)
|
||||
.map(|i| {
|
||||
NotificationAction::new(format!("Action {}", i + 1), command)
|
||||
.argument(format!("notification-action={}\nvalue", i + 1))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
host::notify_with_actions(NotificationLevel::Info, "Choose an action", "A plugin notification", &actions)?;
|
||||
Ok(None)
|
||||
}
|
||||
"copy" => {
|
||||
host::copy_to_clipboard(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
|
||||
@@ -1 +1 @@
|
||||
6c12157f6d1040ce3f9495aaef0e0ea89818ab8af1ded6e2655e3d9304131255
|
||||
951b70f49d8851524bdbe02c870f7ad1ce371358050152af701173816c48c548
|
||||
+1
-1
@@ -1 +1 @@
|
||||
9db9019b6322a9f007ca49b6e3bf918f33df02407827ba797854e2486301733a
|
||||
24e59b772e9d0b3a61fa3ed11f6d0db3dfdf26b228e6f801a0ee0533aa65bd8e
|
||||
@@ -1 +1 @@
|
||||
fea7a5340fed353c8d09d7b7caba0c5ca23763fe9c6ece930c61d84e5b7dc0d5
|
||||
f62bd72d6ba9d0085473e75e8035ae8e71427ccf6238c7e1a64675653e61aa7d
|
||||
@@ -1 +1 @@
|
||||
5bf76257aac231326bdcb618b511d4f94ed711f217a15ecad10e4dd15525d840
|
||||
e0c93213ddc6b7a86f1ea9a82e200facfeb9832d2afbdadef9347e71ee041949
|
||||
@@ -1202,7 +1202,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.7.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.8.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("plugin notification actions run their command and dismiss the balloon", async ({ workbench: page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const backendPath = "/src/features/plugins/backend.ts";
|
||||
const { createMemoryPluginsBackend, setPluginsBackend } = await import(/* @vite-ignore */ backendPath);
|
||||
const state = window as unknown as { notificationCalls: unknown[] };
|
||||
state.notificationCalls = [];
|
||||
setPluginsBackend({
|
||||
...createMemoryPluginsBackend(),
|
||||
runCommand: async (...args: unknown[]) => {
|
||||
state.notificationCalls.push(args);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const bridgePath = "/src/features/plugins/bridge.ts";
|
||||
const { handlePluginEvent } = await import(/* @vite-ignore */ bridgePath);
|
||||
handlePluginEvent({
|
||||
type: "notification",
|
||||
pluginId: "hello",
|
||||
level: "info",
|
||||
title: "Build complete",
|
||||
body: "Choose the next step",
|
||||
actions: [
|
||||
{ label: "Review changes", command: "word-count", argument: "first\nsecond" },
|
||||
{ label: "Open results", command: "insert-date", argument: null },
|
||||
{ label: "Undo change", command: "insert-date", argument: "undo" },
|
||||
],
|
||||
});
|
||||
});
|
||||
const review = page.getByRole("button", { name: "Review changes", exact: true });
|
||||
await expect(review).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Open results", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Undo change", exact: true })).toBeVisible();
|
||||
await review.click();
|
||||
await expect(review).toBeHidden();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { notificationCalls: unknown[] }).notificationCalls))
|
||||
.toEqual([["hello", "word-count", "first\nsecond", null, []]]);
|
||||
});
|
||||
@@ -78,7 +78,13 @@ changes; a fingerprint of working copies with unsaved changes covers edits befor
|
||||
they are saved. Opening or closing an editor, reconciling, building, and non-Java
|
||||
resource changes (configuration files are parsed by the client; metadata files are
|
||||
checked by timestamp) keep it. This is **snapshot invalidation**, not a fully
|
||||
incremental Java semantic index.
|
||||
incremental Java semantic index. A rebuild reuses each dependency JAR's metadata
|
||||
associations (up to 100,000 in all) while the JAR's timestamp and size and the project's
|
||||
classpath are unchanged: resolving a Spring Boot JAR's thousands of properties to library
|
||||
members takes seconds, and a source change cannot affect them.
|
||||
|
||||
Find Usages shows the Java usages as soon as they are found, then adds the configuration
|
||||
ones when a rebuild of the snapshot takes longer.
|
||||
|
||||
In YAML/properties, ⌘/Ctrl-hover underlines the whole key, hyphens and dots
|
||||
included, and doesn't open the documentation popup. Hover and Go to Definition share
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.7.0
|
||||
Bundle-Version: 1.8.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
@@ -69,7 +69,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.7.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.8.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -103,6 +103,10 @@ Version 1.7.0 adds the `"sources"` scope to `openidea.java.usages`, searches eac
|
||||
file's decompiled source on its own and returns the line (`text`) of each usage in a library
|
||||
class; its reproducible SHA-256 is
|
||||
`56d35619d5be937b028ac143ba748365a66a74c803bc112e4788a7dce98b44fc`.
|
||||
Version 1.8.0 keeps each dependency JAR's configuration metadata across rebuilds of the
|
||||
configuration index (until the JAR or the project's classpath changes), so a source change no
|
||||
longer resolves every Spring Boot property again; its reproducible SHA-256 is
|
||||
`728ab33b5e01fa5e496818f25ddbc487458a92339026899b0e22abda5adfd67b`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
|
||||
Бінарний файл не відображається.
@@ -41,7 +41,41 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
* own stamps), do not change the index either. */
|
||||
private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY
|
||||
| IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED;
|
||||
static { JavaCore.addElementChangedListener(event -> { if (affects(event.getDelta())) REVISION.incrementAndGet(); }, ElementChangedEvent.POST_CHANGE); }
|
||||
/** Entries read from each library's Spring metadata, by project and archive. They depend
|
||||
* on the archive and the project's classpath only, so a source change rebuilds the index
|
||||
* without reading every dependency's metadata again: resolving its thousands of properties
|
||||
* to library members takes seconds. A changed archive stamp or classpath drops them. */
|
||||
private static final Map<String, Archive> ARCHIVES = new HashMap<>();
|
||||
private record Archive(FileStamp stamp, List<Entry> entries) {}
|
||||
static {
|
||||
JavaCore.addElementChangedListener(event -> {
|
||||
if (affects(event.getDelta())) REVISION.incrementAndGet();
|
||||
if (classpathChanged(event.getDelta())) synchronized (ARCHIVES) { ARCHIVES.clear(); }
|
||||
}, ElementChangedEvent.POST_CHANGE);
|
||||
}
|
||||
/** A project or package fragment root added, removed or reconfigured (not a change of
|
||||
* the sources or resources inside it). */
|
||||
static boolean classpathChanged(IJavaElementDelta delta) {
|
||||
if (delta.getElement().getElementType() > IJavaElement.PACKAGE_FRAGMENT_ROOT) return false;
|
||||
int flags = delta.getFlags() & ~(IGNORED_FLAGS | IJavaElementDelta.F_CONTENT);
|
||||
if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true;
|
||||
for (IJavaElementDelta child : delta.getAffectedChildren()) if (classpathChanged(child)) return true;
|
||||
return false;
|
||||
}
|
||||
private static List<Entry> archive(String key, FileStamp stamp) {
|
||||
synchronized (ARCHIVES) {
|
||||
Archive known = ARCHIVES.get(key);
|
||||
return known != null && known.stamp.equals(stamp) ? known.entries : null;
|
||||
}
|
||||
}
|
||||
private static void remember(String key, FileStamp stamp, List<Entry> entries) {
|
||||
synchronized (ARCHIVES) {
|
||||
ARCHIVES.remove(key);
|
||||
if (ARCHIVES.values().stream().mapToInt(known -> known.entries.size()).sum() + entries.size() > MAX_ENTRIES)
|
||||
ARCHIVES.clear();
|
||||
if (entries.size() <= MAX_ENTRIES) ARCHIVES.put(key, new Archive(stamp, entries));
|
||||
}
|
||||
}
|
||||
static boolean affects(IJavaElementDelta delta) {
|
||||
int flags = delta.getFlags() & ~IGNORED_FLAGS;
|
||||
if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT;
|
||||
@@ -465,8 +499,18 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
Path path = root.getPath().toFile().toPath();
|
||||
if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null)
|
||||
path = root.getResource().getLocation().toFile().toPath();
|
||||
files.put(path, stamp(path));
|
||||
FileStamp stamp = stamp(path);
|
||||
files.put(path, stamp);
|
||||
if (!Files.isRegularFile(path)) return;
|
||||
String key = project.getHandleIdentifier() + '\n' + path;
|
||||
List<Entry> known = archive(key, stamp);
|
||||
if (known != null && entries.size() + known.size() <= MAX_ENTRIES) {
|
||||
entries.addAll(known);
|
||||
return;
|
||||
}
|
||||
int start = entries.size();
|
||||
boolean before = incomplete;
|
||||
incomplete = false;
|
||||
try (ZipFile zip = new ZipFile(path.toFile())) {
|
||||
for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) {
|
||||
var entry = zip.getEntry("META-INF/" + filename);
|
||||
@@ -481,6 +525,9 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
metadataObject(metadata, project);
|
||||
}
|
||||
} catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; }
|
||||
// Only a complete reading is reused; an incomplete one is read again next time.
|
||||
if (!incomplete) remember(key, stamp, List.copyOf(entries.subList(start, entries.size())));
|
||||
incomplete |= before;
|
||||
}
|
||||
void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException {
|
||||
JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray();
|
||||
|
||||
@@ -244,6 +244,14 @@ try:
|
||||
while not query('added.size')['locations']:
|
||||
assert time.monotonic() < deadline, 'a new source on disk did not reach the index'
|
||||
time.sleep(0.2)
|
||||
# That rebuild reused the library's metadata; a changed archive is read again.
|
||||
assert query('vendor.port')['locations'], 'a source rebuild lost the library metadata'
|
||||
metadata['properties'].append({'name': 'vendor.speed', 'type': 'java.lang.Integer', 'sourceType': 'vendor.LibrarySettings'})
|
||||
with zipfile.ZipFile(library, 'w') as z:
|
||||
z.write(classes / 'vendor/LibrarySettings.class', 'vendor/LibrarySettings.class')
|
||||
z.writestr('META-INF/spring-configuration-metadata.json', json.dumps(metadata))
|
||||
assert query('vendor.speed')['locations'], 'a changed library archive kept its old metadata'
|
||||
assert query('vendor.port')['locations']
|
||||
# Unsaved changes must replace old keys, and closing must restore disk keys.
|
||||
changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}')
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}})
|
||||
|
||||
@@ -1268,3 +1268,13 @@ reused as usual, and the normal trust prompt and unsaved-change guard apply.
|
||||
The call acknowledges the request; cancellation or opening errors are handled by
|
||||
the window. Neither API needs a capability. Paths belong to the UI machine,
|
||||
including when plugin code runs on a remote host.
|
||||
|
||||
### Notifications with commands
|
||||
|
||||
`host::notify_with_actions(level, title, body, actions)` shows up to three
|
||||
buttons. Build each with `NotificationAction::new(label, command)` and an
|
||||
optional `.argument(text)`. Commands must be declared by that plugin; choosing
|
||||
one dismisses the balloon and runs the command in the receiving window with
|
||||
its normal editor and project context. No capability is needed. Invalid or
|
||||
undeclared actions reject the whole notification. Labels are limited to 256
|
||||
bytes and arguments to 64 KiB. Plain `host::notify` behaves as before.
|
||||
@@ -1 +1 @@
|
||||
2d93f4f7eb0529528591ac1b7057a02cae7ed978eb21fe2be2f188529560b972
|
||||
2c583e91b023c13c13f59dc4e332c7c68296485da3d6febc0648811306e6d136
|
||||
@@ -1 +1 @@
|
||||
af52d666db0224b43e19193d90331d54c9645ebd63c183ede65abc5666c15fd7
|
||||
ce627cd77f3e27c1f3dcac9ec8c5470a349a937730db3fee4a2cb327cfa399a1
|
||||
@@ -1 +1 @@
|
||||
a5bcffff3f4bb1c01840bd7b16d5e90908b293ef7676ffd81264a2fa850f91c7
|
||||
976bb84b7ea7e3c3de84ba51a2e7173334fe7a8b316cd1fb8e3e82b51800bf41
|
||||
@@ -1 +1 @@
|
||||
d20a458237ecdd0d34de2be8e4df527f2ed647627b784bf5df1206e18beba2b1
|
||||
89c9e4cc9d455190d33139651388013f127752b196a29715b651f77b4d629ee5
|
||||
@@ -1 +1 @@
|
||||
d9b33e747442c539541778b2a907fc02226e9af120962d4f384820b76674d40a
|
||||
b0ce44f054cb3481cede4bf77a57a3254bf67627cbe617c59632185c20fb687c
|
||||
@@ -1 +1 @@
|
||||
b8c276a0d40a1615a658dc643f81e67683cfea57eb47282ff155c8df148fd771
|
||||
aafd97ca04b7e946d0d5a1fee7f0348242a49ebc2e31c43fedc090e83081e74c
|
||||
@@ -1 +1 @@
|
||||
c12eaf6b64a2482c83dab1958a195c939c38574d0bc15af1afcdaf811cf311d5
|
||||
4616c4a6a7c0bea36ca5ef0e0eae1bee283679edc691671b9911a4adec499210
|
||||
@@ -1 +1 @@
|
||||
fa12ebc743e31fa57b31c0a623708133a232754d110bd687ef16d1037be62b19
|
||||
61fd51343e7341b05de2f599ba54e9e6b5de3e8c80bdf91d3dad1604cde772bb
|
||||
@@ -1 +1 @@
|
||||
53271d32104c682e510b4b5a8edb3467c77b39ee58d9b14c891513a494526180
|
||||
e33af7d9f19fe5242b389ccbb820ac737e81c5793e37da67bf743ed57972de19
|
||||
@@ -1 +1 @@
|
||||
2eb51fed9362021834f237f80553979a56ed1d8301d78b551e5911768b6e443c
|
||||
c571c6a7a4f796631d27faaeaa67b60e0ba9c62e410d4363b5744cac3aa3eda1
|
||||
@@ -1 +1 @@
|
||||
82547cce827ac884aafed961134e5d54be3dcd937990a3c372dfcdd8102aea23
|
||||
45764bde01e5475ec2eaf67db4b369f405f92800877c72b9f3dbef2e4b317a28
|
||||
@@ -60,6 +60,28 @@ mod imp {
|
||||
wit::ui::show_notification(level, title, body)
|
||||
}
|
||||
|
||||
pub fn notify_with_actions(
|
||||
level: NotificationLevel,
|
||||
title: &str,
|
||||
body: &str,
|
||||
actions: &[NotificationAction],
|
||||
) -> Result<(), Error> {
|
||||
let level = match level {
|
||||
NotificationLevel::Info => wit::types::NotificationLevel::Info,
|
||||
NotificationLevel::Warning => wit::types::NotificationLevel::Warning,
|
||||
NotificationLevel::Error => wit::types::NotificationLevel::Error,
|
||||
};
|
||||
let actions: Vec<_> = actions
|
||||
.iter()
|
||||
.map(|a| wit::ui::NotificationAction {
|
||||
label: a.label.clone(),
|
||||
command: a.command.clone(),
|
||||
argument: a.argument.clone(),
|
||||
})
|
||||
.collect();
|
||||
wit::ui::notify(level, title, body, &actions).map_err(error)
|
||||
}
|
||||
|
||||
pub fn set_status_item(id: &str, item: &StatusItem) {
|
||||
let item = wit::ui::StatusItem {
|
||||
text: item.text.clone(),
|
||||
@@ -820,3 +842,15 @@ pub fn pick_directory(title: &str, initial: Option<&str>) -> Result<Option<Strin
|
||||
pub fn open_project(path: &str, new_window: bool) -> Result<(), Error> {
|
||||
imp::open_project(path, new_window)
|
||||
}
|
||||
|
||||
/// [`notify`] with up to three buttons; each runs one of this plugin's
|
||||
/// declared commands in the balloon's window and dismisses the balloon. An
|
||||
/// invalid action or an undeclared command is an error and nothing is shown.
|
||||
pub fn notify_with_actions(
|
||||
level: NotificationLevel,
|
||||
title: &str,
|
||||
body: &str,
|
||||
actions: &[NotificationAction],
|
||||
) -> Result<(), Error> {
|
||||
imp::notify_with_actions(level, title, body, actions)
|
||||
}
|
||||
@@ -19,6 +19,13 @@ pub struct Notification {
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// A notification with command buttons, recorded by the mock.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ActionNotification {
|
||||
pub notification: Notification,
|
||||
pub actions: Vec<NotificationAction>,
|
||||
}
|
||||
|
||||
/// A task the plugin began.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MockTask {
|
||||
@@ -59,6 +66,7 @@ struct State {
|
||||
editor: Option<Editor>,
|
||||
selected_paths: Vec<String>,
|
||||
notifications: Vec<Notification>,
|
||||
action_notifications: Vec<ActionNotification>,
|
||||
logs: Vec<(LogLevel, String)>,
|
||||
status: BTreeMap<String, StatusItem>,
|
||||
context: BTreeMap<String, ContextValue>,
|
||||
@@ -201,6 +209,11 @@ impl MockHost {
|
||||
self.state.borrow().notifications.clone()
|
||||
}
|
||||
|
||||
/// Notifications that carried command buttons, in publication order.
|
||||
pub fn notifications_with_actions(&self) -> Vec<ActionNotification> {
|
||||
self.state.borrow().action_notifications.clone()
|
||||
}
|
||||
|
||||
pub fn logs(&self) -> Vec<(LogLevel, String)> {
|
||||
self.state.borrow().logs.clone()
|
||||
}
|
||||
@@ -436,6 +449,32 @@ pub mod current {
|
||||
with(|s| s.notifications.push(Notification { level, title: title.into(), body: body.into() }))
|
||||
}
|
||||
|
||||
/// The host's checks, except that the commands are declared (the mock has no manifest).
|
||||
pub fn notify_with_actions(
|
||||
level: NotificationLevel,
|
||||
title: &str,
|
||||
body: &str,
|
||||
actions: &[NotificationAction],
|
||||
) -> Result<(), Error> {
|
||||
if actions.len() > 3 {
|
||||
return Err(Error::Invalid("a notification has at most 3 actions".into()));
|
||||
}
|
||||
for a in actions {
|
||||
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
||||
return Err(Error::Invalid(format!("invalid action label {:?}", a.label)));
|
||||
}
|
||||
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
||||
return Err(Error::Invalid("an action argument is limited to 64 KiB".into()));
|
||||
}
|
||||
}
|
||||
with(|s| {
|
||||
let notification = Notification { level, title: title.into(), body: body.into() };
|
||||
s.notifications.push(notification.clone());
|
||||
s.action_notifications.push(ActionNotification { notification, actions: actions.to_vec() });
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_status_item(id: &str, item: &StatusItem) {
|
||||
with(|s| s.status.insert(id.to_owned(), item.clone()));
|
||||
}
|
||||
@@ -1037,6 +1076,17 @@ mod tests {
|
||||
assert_eq!(mock.editor_version(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_buttons_are_recorded_and_bounded() {
|
||||
let mock = MockHost::install();
|
||||
let action = NotificationAction::new("Open", "open").argument("first\nsecond");
|
||||
host::notify_with_actions(NotificationLevel::Info, "Done", "", std::slice::from_ref(&action)).unwrap();
|
||||
assert_eq!(mock.notifications_with_actions()[0].actions, vec![action.clone()]);
|
||||
let too_many: [_; 4] = std::array::from_fn(|_| action.clone());
|
||||
assert!(host::notify_with_actions(NotificationLevel::Info, "Done", "", &too_many).is_err());
|
||||
assert_eq!(mock.notifications().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_capabilities_are_denied() {
|
||||
let mock = MockHost::install().with_capabilities(&["project.read"]);
|
||||
|
||||
@@ -465,3 +465,22 @@ mod tests {
|
||||
assert_eq!((d.code.as_deref(), d.unnecessary, d.related.len()), (Some("c"), false, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// A notification button invoking a command declared by this plugin.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NotificationAction {
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
impl NotificationAction {
|
||||
/// A button labelled `label`, invoking the plugin command `command`.
|
||||
pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self {
|
||||
Self { label: label.into(), command: command.into(), argument: None }
|
||||
}
|
||||
/// Passes this text to the command when selected.
|
||||
pub fn argument(mut self, argument: impl Into<String>) -> Self {
|
||||
self.argument = Some(argument.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,23 @@ interface ui {
|
||||
text(string),
|
||||
}
|
||||
|
||||
/// A notification button that runs one of the plugin's commands.
|
||||
record notification-action {
|
||||
/// Up to 256 bytes, one line.
|
||||
label: string,
|
||||
/// The id of a command the plugin declares.
|
||||
command: string,
|
||||
/// Passed to the command as its argument (up to 64 KiB).
|
||||
argument: option<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);
|
||||
/// `show-notification` with up to three action buttons. Choosing one
|
||||
/// dismisses the balloon and runs the command in its window. An invalid
|
||||
/// action or an undeclared command fails the call and shows nothing.
|
||||
notify: func(level: notification-level, title: string, body: string, actions: list<notification-action>) -> result<_, error>;
|
||||
/// 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);
|
||||
|
||||
+10
-1
@@ -5739,6 +5739,13 @@ export type NotebookSnapshot = {
|
||||
cells: NotebookCell[],
|
||||
};
|
||||
|
||||
/** A notification button that runs one of the publishing plugin's commands. */
|
||||
export type NotificationAction = {
|
||||
label: string,
|
||||
command: string,
|
||||
argument: string | null,
|
||||
};
|
||||
|
||||
export type NotificationGroupSettings = {
|
||||
/** `balloon` | `sticky` | `none` */
|
||||
display?: string,
|
||||
@@ -5953,7 +5960,9 @@ export type PluginEvent =
|
||||
* The marketplace offers newer versions of installed plugins (announced
|
||||
* once per set of versions).
|
||||
*/
|
||||
{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } |
|
||||
{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string;
|
||||
/** Buttons running the plugin's own commands (`ui.notify`). */
|
||||
actions: NotificationAction[] } |
|
||||
/** A plugin's status bar item was set (`item`) or removed. */
|
||||
{ type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } |
|
||||
/**
|
||||
|
||||
@@ -488,12 +488,41 @@ describe("bridge events", () => {
|
||||
expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true);
|
||||
});
|
||||
|
||||
it("runs notification buttons as commands of the publishing plugin with their arguments", async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue(null);
|
||||
setPluginsBackend({ ...backend, runCommand });
|
||||
handlePluginEvent({
|
||||
type: "notification",
|
||||
pluginId: "hello",
|
||||
level: "info",
|
||||
title: "Done",
|
||||
body: "Pick an action",
|
||||
actions: [
|
||||
{ label: "Open", command: "insert-date", argument: "first\nsecond" },
|
||||
{ label: "Refresh", command: "word-count", argument: null },
|
||||
{ label: "Undo", command: "insert-date", argument: "undo" },
|
||||
],
|
||||
});
|
||||
expect(notes[0]?.actions?.map((a) => a.label)).toEqual(["Open", "Refresh", "Undo"]);
|
||||
notes[0]!.actions![0]!.run();
|
||||
await waitFor(() => expect(runCommand).toHaveBeenCalledWith("hello", "insert-date", "first\nsecond", null, []));
|
||||
});
|
||||
|
||||
it("shows plugin notifications, copies to clipboard, opens URLs and tracks theme changes", () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
handlePluginEvent({ type: "notification", pluginId: "p", level: "info", title: "Done", body: "3 words" });
|
||||
expect(notes).toEqual([{ group: "plugins", level: "info", title: "Done", body: "3 words", verbatim: true }]);
|
||||
handlePluginEvent({
|
||||
type: "notification",
|
||||
pluginId: "p",
|
||||
level: "info",
|
||||
title: "Done",
|
||||
body: "3 words",
|
||||
actions: [],
|
||||
});
|
||||
expect(notes).toEqual([
|
||||
{ group: "plugins", level: "info", title: "Done", body: "3 words", verbatim: true, actions: [] },
|
||||
]);
|
||||
handlePluginEvent({ type: "copyToClipboard", pluginId: "p", text: "https://github.com/o/r" });
|
||||
expect(writeText).toHaveBeenCalledWith("https://github.com/o/r");
|
||||
handlePluginEvent({ type: "openUrl", pluginId: "p", url: "https://github.com/o/r/blob/main/a.rs" });
|
||||
|
||||
@@ -540,7 +540,14 @@ export function createMemoryPluginsBackend(
|
||||
if (commandId === "word-count") {
|
||||
if (!activeFile) throw new IpcError({ kind: "internal", message: "no file is open in the editor" });
|
||||
const name = activeFile.split("/").pop() ?? activeFile;
|
||||
emit({ type: "notification", pluginId, level: "info", title: `Word count: ${name}`, body: "(simulated)" });
|
||||
emit({
|
||||
type: "notification",
|
||||
pluginId,
|
||||
level: "info",
|
||||
title: `Word count: ${name}`,
|
||||
body: "(simulated)",
|
||||
actions: [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
throw new IpcError({ kind: "internal", message: `unknown command: ${commandId}` });
|
||||
|
||||
@@ -36,7 +36,14 @@ import { defineBuiltinUiPoints } from "./points";
|
||||
import { localizeContributions, loadPluginCatalogs, usePluginCatalogs } from "./pluginText";
|
||||
import { clearUiContributions, syncUiContributions } from "./projection";
|
||||
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, updateAllPlugins, usePlugins } from "./store";
|
||||
import {
|
||||
grantPlugin,
|
||||
runPluginCommand,
|
||||
refreshPlugins,
|
||||
selectPluginTheme,
|
||||
updateAllPlugins,
|
||||
usePlugins,
|
||||
} from "./store";
|
||||
|
||||
export { pluginActionId } from "./points";
|
||||
|
||||
@@ -284,6 +291,10 @@ export function handlePluginEvent(e: PluginEvent) {
|
||||
title: e.title || name,
|
||||
body: e.body || undefined,
|
||||
verbatim: true,
|
||||
actions: e.actions.map((a) => ({
|
||||
label: a.label,
|
||||
run: () => void runPluginCommand(e.pluginId, a.command, a.argument),
|
||||
})),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -33,9 +33,23 @@ interface ui {
|
||||
text(string),
|
||||
}
|
||||
|
||||
/// A notification button that runs one of the plugin's commands.
|
||||
record notification-action {
|
||||
/// Up to 256 bytes, one line.
|
||||
label: string,
|
||||
/// The id of a command the plugin declares.
|
||||
command: string,
|
||||
/// Passed to the command as its argument (up to 64 KiB).
|
||||
argument: option<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);
|
||||
/// `show-notification` with up to three action buttons. Choosing one
|
||||
/// dismisses the balloon and runs the command in its window. An invalid
|
||||
/// action or an undeclared command fails the call and shows nothing.
|
||||
notify: func(level: notification-level, title: string, body: string, actions: list<notification-action>) -> result<_, error>;
|
||||
/// 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);
|
||||
|
||||
Reference in new issue
Заблокувати користувача