diff --git a/crates/ide-host/src/plugins/index_ops.rs b/crates/ide-host/src/plugins/index_ops.rs index ebc1b5d9b..9047a9ff9 100644 --- a/crates/ide-host/src/plugins/index_ops.rs +++ b/crates/ide-host/src/plugins/index_ops.rs @@ -121,7 +121,12 @@ pub async fn plugin_navigation_index( plugins.index_navigation( &project.root, crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope }, - Invocation { client: Some(client.clone()), workspace_root: Some(project.root.clone()), editor: None }, + Invocation { + client: Some(client.clone()), + workspace_root: Some(project.root.clone()), + editor: None, + selected_paths: Vec::new(), + }, )?; // A document edit while WASM was executing must not publish stale ranges. if cancel.is_cancelled() || document.is_some_and(|id| project.documents.version(id).ok() != version) { diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 78abae291..cfb7de9e3 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -1393,6 +1393,7 @@ impl PluginHost { workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)), client: client.clone(), editor: None, + selected_paths: Vec::new(), }; let kinds: BTreeSet = events.iter().map(WorkbenchEvent::kind).collect(); { diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index ce18c9b0a..166021f74 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -367,10 +367,12 @@ impl RemotePlugins { let command_id: String = c.arg("commandId")?; let arg: Option = c.arg("arg")?; let editor: Option = c.arg("editor")?; + let paths: Option> = c.arg("paths")?; let invocation = Invocation { client: Some(c.window.clone()), workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()), editor: editor.map(PluginEditor::context), + selected_paths: paths.unwrap_or_default(), }; blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await } diff --git a/crates/ide-host/src/plugins/views.rs b/crates/ide-host/src/plugins/views.rs index a62484ea0..f12e31572 100644 --- a/crates/ide-host/src/plugins/views.rs +++ b/crates/ide-host/src/plugins/views.rs @@ -25,7 +25,12 @@ pub fn view_invocation( Some(EditorContext { path: info.path.0, language: info.language.0, selections: Vec::new() }) } }; - Ok(Invocation { client: Some(window.to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor }) + Ok(Invocation { + client: Some(window.to_owned()), + workspace_root: project.as_ref().map(|p| p.root.clone()), + editor, + ..Invocation::default() + }) } async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs index 74ae75e5d..ff23881f5 100644 --- a/crates/ide-host/tests/distribution.rs +++ b/crates/ide-host/tests/distribution.rs @@ -58,7 +58,7 @@ fn fixture() -> Fixture { } fn inv() -> Invocation { - Invocation { client: Some("main".into()), workspace_root: None, editor: None } + Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() } } fn misbehave() -> PathBuf { diff --git a/crates/ide-host/tests/plugin_indexing.rs b/crates/ide-host/tests/plugin_indexing.rs index 015136f91..045531a54 100644 --- a/crates/ide-host/tests/plugin_indexing.rs +++ b/crates/ide-host/tests/plugin_indexing.rs @@ -171,7 +171,12 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() { .index_navigation( &f.root, Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" }, - Invocation { client: Some(client.into()), workspace_root: Some(f.root.clone()), editor: None }, + Invocation { + client: Some(client.into()), + workspace_root: Some(f.root.clone()), + editor: None, + selected_paths: Vec::new(), + }, ) .unwrap(); } diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 9f13fb9bb..ff160d73a 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -231,7 +231,12 @@ fn fixture(limits: Limits) -> Fixture { impl Fixture { fn inv(&self) -> Invocation { - Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), editor: None } + Invocation { + client: Some("main".into()), + workspace_root: Some(self.project.clone()), + editor: None, + selected_paths: Vec::new(), + } } fn install(&self, dir: &Path) -> PluginInfo { @@ -357,7 +362,7 @@ fn read_file_is_confined_to_the_project() { assert!(err.contains("outside the project root"), "{err}"); assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err()); // Without a project there is nothing to read. - let inv = Invocation { client: None, workspace_root: None, editor: None }; + let inv = Invocation::default(); let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message; assert!(err.contains("no project is open"), "{err}"); assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project)); @@ -2142,6 +2147,17 @@ fn views_open_project_files_at_a_line() { assert!(opened.is_none()); } +#[test] +fn commands_see_the_paths_they_were_run_on() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + let (a, b) = (f.project.join("src/a.txt"), f.project.join("src/b.txt")); + let inv = Invocation { selected_paths: vec![a.clone(), b.clone()], ..f.inv() }; + let out = f.host.run_command("jobs", "selection", Some(&to_ipc(&a)), inv).unwrap(); + assert_eq!(out.as_deref(), Some(format!("{}\n{}", to_ipc(&a), to_ipc(&b)).as_str())); + assert_eq!(f.run("jobs", "selection", None).unwrap().as_deref(), Some("")); +} + #[test] fn vcs_remotes_clipboard_and_open_url() { let f = fixture(Limits::default()); diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs index dd3bd868e..cf0959b50 100644 --- a/crates/ide-host/tests/remote_plugins.rs +++ b/crates/ide-host/tests/remote_plugins.rs @@ -300,5 +300,14 @@ async fn remote_projects_get_plugin_views_and_custom_editors() { }) }) .await; + + let selected = invoke( + &peer, + "plugin_run_command", + json!({ "pluginId": "jobs", "commandId": "selection", "arg": null, "editor": null, "paths": ["/srv/src/a.txt"] }), + ) + .await + .unwrap(); + assert_eq!(selected, "/srv/src/a.txt"); plugins.host().shutdown(); } diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index b1d7b215d..bd43473c8 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -28,6 +28,8 @@ pub struct Invocation { pub client: Option, pub workspace_root: Option, pub editor: Option, + /// Files and folders the command was run on. + pub selected_paths: Vec, } /// A failed host call, as the plugin sees it (WIT `types.error`). diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index c2154f365..ed4fc02d3 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -419,6 +419,10 @@ impl invocation::HostInvocation for StoreData { } } + fn selected_paths(&mut self, _this: Resource) -> wasmtime::Result> { + Ok(self.invocation.selected_paths.iter().take(MAX_FIND as usize).map(|p| to_ipc(p)).collect()) + } + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { // Owned by the host for the duration of the call. let _ = rep; 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 961a27e8e..de090043a 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 @@ -42044c4b2e3d768b922d6e5026ff4e725d37b265896ea7f383f1d737e2feffb7 +7a84aaf2ad8fcb0bf2f9fc0eeff6ad8664512a3fa6d9c4c205c19eff9a3e1315 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 0c492615c..489e534c1 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 @@ -87eb2a28875abbc649b98d2a7f87263da217f9ad28f1395d96a61db5f781d9f0 +92aece4d9f24c4429f2dcfc64e65a53219103267130731af49b65c7c581048d4 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 8b2e04da2..3121b3a5a 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 deff9f831..706af4df6 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 @@ -3d3e43d4e49efa7a97984335f7f2df2cb286321bd0368251f37818aa6c1b70ad +17136d5359a8f5153ca2650e4c4b2c8f86eb31d0b99ef17974e0217c04f9a6e5 diff --git a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml index 7453c9ec5..3bd3396e8 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 = "selection" +title = "Jobs: Show Selected Paths" +category = "Jobs" + [contributes.settings] title = "Jobs" schema = "settings.schema.json" diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs index 131ef2cce..7e4e0070f 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs @@ -4,7 +4,7 @@ use openidea_plugin::host; use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; -use openidea_plugin::{Error, Invocation, Plugin, register_plugin}; +use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, register_plugin}; pub const VIEW: &str = "jobs"; @@ -48,6 +48,11 @@ impl Plugin for Jobs { host::open_url(arg.unwrap_or_default())?; Ok(None) } + "selection" => { + let paths = ctx.selected_paths().join("\n"); + host::notify(NotificationLevel::Info, "Selected paths", &paths); + Ok(Some(paths)) + } _ => Err(Error::NotFound(format!("command {id}"))), } } 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 b6b627bd4..4310ad831 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 @@ -1d8561e04cb777efbafc6f64963473b9b69654a72ebb2be34f49c6c53c6f76e2 +b697a2aa34b0de05db3e89bb8449ab6619119fa003b19fabe36b0932246a26fe diff --git a/plugins/README.md b/plugins/README.md index fdce7ff71..43131745d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -364,7 +364,7 @@ then run `openidea-plugin check`. | `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. | `run-command` receives an optional string argument: `executeAction("plugin..", "arg")` -from another feature, or nothing when run from Search Everywhere / a shortcut. +from another feature, or nothing when run from Search Everywhere / a shortcut. `ctx.selected_paths()` lists the files and folders a Project view or editor tab menu ran the command on (the argument is the first of them); it is empty otherwise. No capability is needed. ### Events diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index ae7c9f9e4..50d9693cc 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -5a707c0a9efb6f98f43d93f1b4207cc0ab21ac84d693e0fd9981d143e7552a1c +f63e6547849ce8f5a0de4384eb44a903583135dea7e5a82829daf3a395e98465 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 9b9e9e3da..5d43c4829 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -34728307b2054d6a494040f0523caf6f62b96c0f67f5b5e9a7e4508370e79419 +af6dbfa8c34e5cb62851f02479f86350ee915c18028993331b4372a739074b80 diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index e0795c324..f0f217a64 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -c8de2df9607fef2a7e75023e78db06ac83de8598a43632b684756d4e0df6b51b +920da064d50f59aa34825f5dc8c59442b7185891bbded8f7f1c87c8192c093ce diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 4ed042a00..61ca0e559 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -2523894a6fb5a75d29429ab096fdd9acf150c05eb4ea13c9e92920c8d8ca4459 +7c9279246766fd92f854fe8dc33f0165236f616a448782dfa59ea9c05463cf8a diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 07811c63d..dc541c60e 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -b534a088809e38ff210ed169b6968f3e793fa0fdcb12d022d2bcee5a58834514 +55cc148dc0eddf924e97074a46707736e563b0fe6d348a09610b1822ed085d6b diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index e0b0643a4..a47653ca4 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -145bad8868c73368d4e83087861293fad309aaa87263bb12508a6acf693c25da +eda75943217c5273773ef0f2934c20d6bda50411670d99145b50e45d104509cc diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index a280e4a5c..a0c7e5687 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -f2f4a7d3fbe4b86369e35969782dd66150df6ae4451e375122d3c1b232caeac1 +5a83f6c626f01c80910e1e74e6452bc2bbef8de3fe402d5540f506dc6dd02ed0 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 5c7d59bc8..4ebdb24f6 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 26af2f4b7..8ba5cafe9 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -5e7f7adc2c49503d2278ed2a446f18f69561ce51df264100f77f50d69804c277 +3984d6b51bea7212bbabadc2a5a632c4cba7a2a988033fa7918e33c203fcfa23 diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 46f075233..6b356d89a 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -01c6f9e9b1e76b3328294f6a31ba4d8f3545d04a4fa56cc8069c59aa37a13f27 +2a70078e8edf29c9a4b590d661a1a67cb605dca3781a89ffcf6dc8acb0d183b0 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 6c4e41b3c..0cf74655f 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -745e791fe2fde2b4d7b58b40a33a8037999aac947865a8f4a5127021b7f16156 +4449ee6a1ccee1a71ebed1073bb4e30df1686ebd0a2812bdd579ad876d0a43cf diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 5e33d0308..eb256aec5 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -4fff5b261e8255aa2dac8342694dee129ea4a8c9520105c719d0c16a16846517 +adf2ab3e93e0147350b6db899ec2e44e07862e99f49609aecbc2b866e5282c3d diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 6b93a1e7e..8c796e51a 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -117,6 +117,10 @@ mod imp { i.editor() } + pub fn invocation_selected_paths(i: InvocationRef<'_>) -> Vec { + i.selected_paths() + } + pub fn editor_path(e: &EditorRes) -> String { e.path() } @@ -327,6 +331,12 @@ impl<'a> Invocation<'a> { pub fn editor(&self) -> Option { imp::invocation_editor(self.inner).map(|inner| TextEditor { inner }) } + + /// The files and folders the command was run on, as absolute paths. Empty when it was not + /// run on any, or when the menu was for a tab without a file (a virtual document). + pub fn selected_paths(&self) -> Vec { + imp::invocation_selected_paths(self.inner) + } } /// A project open in the IDE. diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index a011569eb..28332df01 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -56,6 +56,7 @@ struct State { other_projects: Vec, files: BTreeMap, editor: Option, + selected_paths: Vec, notifications: Vec, logs: Vec<(LogLevel, String)>, status: BTreeMap, @@ -144,6 +145,11 @@ impl MockHost { self.state.borrow_mut().files.insert(path.to_owned(), text.to_owned()); } + /// Sets the paths invocations report as selected. + pub fn select_paths(&self, paths: &[&str]) { + self.state.borrow_mut().selected_paths = paths.iter().map(|p| (*p).to_owned()).collect(); + } + /// Focuses an editor on `text` with selections `(line, col, line, col)`. pub fn open_editor(&self, path: &str, language: &str, text: &str, selections: &[(u32, u32, u32, u32)]) { let selections = @@ -473,6 +479,10 @@ pub mod current { }) } + pub fn invocation_selected_paths(_: InvocationRef<'_>) -> Vec { + with(|s| s.selected_paths.clone()) + } + pub fn editor_path(_: &EditorRes) -> String { editor(|_, e| e.path.clone()) } @@ -717,6 +727,14 @@ mod tests { use super::*; use crate::host; + #[test] + fn invocations_carry_selected_paths() { + let host = MockHost::install(); + assert!(host.invocation().selected_paths().is_empty()); + host.select_paths(&["/p/a", "/p/b"]); + assert_eq!(host.invocation().selected_paths(), ["/p/a", "/p/b"]); + } + #[test] fn records_and_enforces() { let mock = MockHost::install(); diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index 4563344d9..aa7214fb1 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -59,6 +59,11 @@ interface invocation { project: func() -> option; /// The window's focused editor. editor: func() -> option; + /// The files and folders the command was run on (a multi-selection in + /// a file tree, for instance), as absolute paths. + /// Empty when it was not run on any, or when the menu was for a tab + /// without a file (a virtual document). + selected-paths: func() -> list; } } diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 989c906e1..5c38e419a 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -325,6 +325,7 @@ pub async fn plugin_run_command( command_id: String, arg: Option, editor: Option, + paths: Option>, ) -> IdeResult> { let host = state.plugins.clone(); let project = state.project(window.label()).ok(); @@ -332,6 +333,7 @@ pub async fn plugin_run_command( client: Some(window.label().to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor: editor.map(PluginEditor::context), + selected_paths: paths.unwrap_or_default(), }; blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await } diff --git a/src/bindings.ts b/src/bindings.ts index 7696f16ed..17e583d0c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1023,7 +1023,7 @@ export const commands = { path: string, language: string, selections: Range[], -} | null) => typedError(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor })), +} | null, paths: string[] | null) => typedError(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths })), /** Workbench events from this window (only the kinds of `plugin_event_kinds`). */ pluginPostEvents: (events: WorkbenchEvent[]) => __TAURI_INVOKE("plugin_post_events", { events }), /** Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). */ diff --git a/src/features/macros/model.test.ts b/src/features/macros/model.test.ts index 37fdf6466..02c948813 100644 --- a/src/features/macros/model.test.ts +++ b/src/features/macros/model.test.ts @@ -57,7 +57,7 @@ describe("macros", () => { localStorage.setItem( STORAGE_KEY, JSON.stringify({ - last: [{ kind: "text", text: "a" }, { kind: "action" }, { kind: "action", id: "x", arg: {} }], + last: [{ kind: "text", text: "a" }, { kind: "action" }, { kind: "action", id: "x", arg: { a: { b: 1 } } }], saved: [{ name: "" }], }), ); @@ -96,7 +96,6 @@ describe("macros", () => { expect(storableArg("x")).toBe("x"); expect(storableArg(2)).toBe(2); expect(storableArg(undefined)).toBeUndefined(); - expect(storableArg({ path: "/a" })).toBeNull(); expect(storableArg(Number.NaN)).toBeNull(); const title = (id: string) => (id === "a" ? "Action A" : id); expect(describeStep({ kind: "action", id: "a", arg: "x" }, title)).toBe("Action A (x)"); @@ -104,6 +103,29 @@ describe("macros", () => { expect(describeStep({ kind: "key", key: "Enter" }, title)).toBe("Press Enter"); }); + it("stores a selection payload and rejects anything that is not plain data", () => { + const selection = { path: "/a", paths: ["/a", "/b"] }; + expect(storableArg(selection)).toEqual(selection); + expect(storableArg(["/a"])).toEqual(["/a"]); + expect(storableArg(true)).toBe(true); + expect(storableArg({ a: { b: 1 } })).toBeNull(); + expect(storableArg([1])).toBeNull(); + expect(storableArg(new Date())).toBeNull(); + expect(storableArg(() => 1)).toBeNull(); + expect(storableArg({ n: Number.NaN })).toBeNull(); + expect(storableArg(Object.create(null))).toEqual({}); + }); + + it("round-trips an object argument through storage and describes it", () => { + const arg = { path: "/a", paths: ["/a", "/b"] }; + saveMacro("Sel", [{ kind: "action", id: "p.run", arg }]); + _reloadMacros(); + expect(useMacros.getState().saved[0]?.steps).toEqual([{ kind: "action", id: "p.run", arg }]); + expect(describeStep({ kind: "action", id: "p.run", arg }, (id) => id)).toBe("p.run (/a)"); + expect(describeStep({ kind: "action", id: "p.run", arg: ["/a", "/b"] }, (id) => id)).toBe("p.run (/a, /b)"); + expect(describeStep({ kind: "action", id: "p.run", arg: { n: 1 } }, (id) => id)).toBe('p.run ({"n":1})'); + }); + it("names the editor keys a macro records", () => { const k = (key: string, mods: Partial> = {}) => editorKeyName({ key, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...mods }); diff --git a/src/features/macros/model.ts b/src/features/macros/model.ts index 061ab2dce..2df71dc4e 100644 --- a/src/features/macros/model.ts +++ b/src/features/macros/model.ts @@ -6,9 +6,26 @@ */ import { create } from "zustand"; +/** Plain data an action can be run with: primitives, string lists, or a flat object of those. */ +export type MacroArg = MacroValue | { [key: string]: MacroValue }; +type MacroValue = string | number | boolean | string[]; + +const isMacroValue = (v: unknown): v is MacroValue => + typeof v === "string" || + typeof v === "boolean" || + (typeof v === "number" && Number.isFinite(v)) || + (Array.isArray(v) && v.every((x) => typeof x === "string")); + +function isMacroArg(v: unknown): v is MacroArg { + if (isMacroValue(v)) return true; + if (!v || typeof v !== "object") return false; + const proto = Object.getPrototypeOf(v); + return (proto === Object.prototype || proto === null) && Object.values(v).every(isMacroValue); +} + /** An editor key replayed as a CodeMirror command (see `editorKeys.ts`). */ export type MacroStep = - | { kind: "action"; id: string; arg?: string | number | boolean } + | { kind: "action"; id: string; arg?: MacroArg } | { kind: "text"; text: string } | { kind: "key"; key: string }; @@ -32,8 +49,7 @@ export const STORAGE_KEY = "openidea.macros.v1"; function isStep(v: unknown): v is MacroStep { if (!v || typeof v !== "object") return false; const s = v as Record; - if (s.kind === "action") - return typeof s.id === "string" && ["undefined", "string", "number", "boolean"].includes(typeof s.arg); + if (s.kind === "action") return typeof s.id === "string" && (s.arg === undefined || isMacroArg(s.arg)); if (s.kind === "text") return typeof s.text === "string"; return s.kind === "key" && typeof s.key === "string"; } @@ -82,12 +98,10 @@ export function recordStep(step: MacroStep) { useMacros.setState({ recording: next }); } -/** An action's argument when it can be stored (plain values only). */ -export function storableArg(arg: unknown): string | number | boolean | undefined | null { +/** An action's argument when it can be stored (plain data only). */ +export function storableArg(arg: unknown): MacroArg | undefined | null { if (arg === undefined) return undefined; - if (typeof arg === "string" || typeof arg === "boolean" || (typeof arg === "number" && Number.isFinite(arg))) - return arg; - return null; + return isMacroArg(arg) ? arg : null; } export function startRecording() { @@ -127,11 +141,17 @@ export function deleteMacro(name: string) { persist(); } +function describeArg(arg: MacroArg): string { + if (Array.isArray(arg)) return arg.join(", "); + if (typeof arg === "object") return typeof arg.path === "string" ? arg.path : JSON.stringify(arg); + return String(arg); +} + /** One line per step, for Edit Macros. */ export function describeStep(step: MacroStep, title: (id: string) => string): string { switch (step.kind) { case "action": - return step.arg === undefined ? title(step.id) : `${title(step.id)} (${String(step.arg)})`; + return step.arg === undefined ? title(step.id) : `${title(step.id)} (${describeArg(step.arg)})`; case "text": return `Type "${step.text.replace(/\n/g, "↵")}"`; case "key": diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 5e0ab8a88..5606169dc 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -446,10 +446,32 @@ describe("bridge events", () => { language: "plaintext", selections: [{ start: { line: 1, col: 0 }, end: { line: 1, col: 4 } }], }, + [], ], ]); }); + it("forwards a menu's path and selection to the command", async () => { + const calls: unknown[][] = []; + setPluginsBackend({ + ...backend, + runCommand: async (...args) => { + calls.push(args); + return null; + }, + }); + undefinePoints(); + undefinePoints = () => {}; + const stop = startPluginBridge(); + await refreshPlugins(); + await act(() => + executeAction("plugin.hello.insert-date", { path: "/p/a.java", paths: ["/p/a.java", "/p/b.java"] }), + ); + expect(calls[0]?.[2]).toBe("/p/a.java"); + expect(calls[0]?.[4]).toEqual(["/p/a.java", "/p/b.java"]); + stop(); + }); + it("shows status items and tasks of running plugin code", () => { const item = { text: "main ↑1", tooltip: "Branch", command: "refresh" }; handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item }); diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index 1bff35352..f4a30924d 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -6,7 +6,7 @@ import type { View, ViewEvent } from "@/bindings"; import { registerAction } from "@/platform/actions"; import { bindingsFor, effectiveBindings, registerExtensionBindings, useKeymap } from "@/platform/keymap"; import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend"; -import { defineBuiltinUiPoints } from "../points"; +import { commandArgs, defineBuiltinUiPoints } from "../points"; import { clearUiContributions, syncUiContributions } from "../projection"; import { pluginMenuItems, pluginViewItemMenuItems, withPluginSetting } from "../uiPoints"; import { matchesPattern } from "../views/customEditor"; @@ -162,6 +162,13 @@ describe("plugin keymaps", () => { }); describe("plugin menus", () => { + it("unpacks command arguments", () => { + expect(commandArgs("x")).toEqual(["x", []]); + expect(commandArgs({ path: "/a", paths: ["/a", "/b"] })).toEqual(["/a", ["/a", "/b"]]); + expect(commandArgs(undefined)).toEqual([null, []]); + expect(commandArgs({ path: 3 })).toEqual([null, []]); + }); + it("projects context menu entries for their command's action", () => { const off = registerAction({ id: "plugin.p.check", title: "Check", run: () => {} }); const plugin = { @@ -188,9 +195,26 @@ describe("plugin menus", () => { [plugin], ); expect(pluginMenuItems("projectView", ["/p/a.java"])).toEqual([ - { kind: "action", action: "plugin.p.check", arg: "/p/a.java" }, + { kind: "action", action: "plugin.p.check", arg: { path: "/p/a.java", paths: ["/p/a.java"] } }, ]); + expect(pluginMenuItems("projectView", ["/p/a.java", "/p/b.java"])[0]).toMatchObject({ + arg: { path: "/p/a.java", paths: ["/p/a.java", "/p/b.java"] }, + }); expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([]); + syncUiContributions( + [ + { + pluginId: "p", + point: "menus", + key: "editor.context/always", + decl: { menu: "editor.context", command: "check" }, + }, + ], + [plugin], + ); + expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([ + { kind: "action", action: "plugin.p.check", arg: undefined }, + ]); off(); }); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 753bf93c4..84476bdee 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -53,6 +53,7 @@ export interface PluginsBackend { commandId: string, arg: string | null, editor: PluginEditor | null, + paths: string[], ): Promise; /** Workbench events plugins subscribed to (see `eventKinds`). */ postEvents(events: WorkbenchEvent[]): Promise; @@ -150,7 +151,8 @@ export const tauriPluginsBackend: PluginsBackend = { await call(commands.pluginUninstall(id)); }, reload: (id) => call(commands.pluginReload(id)), - runCommand: (pluginId, commandId, arg, editor) => call(commands.pluginRunCommand(pluginId, commandId, arg, editor)), + runCommand: (pluginId, commandId, arg, editor, paths) => + call(commands.pluginRunCommand(pluginId, commandId, arg, editor, paths)), postEvents: async (events) => { await commands.pluginPostEvents(events); }, diff --git a/src/features/plugins/points.tsx b/src/features/plugins/points.tsx index 0e033f714..a2820ff90 100644 --- a/src/features/plugins/points.tsx +++ b/src/features/plugins/points.tsx @@ -17,6 +17,14 @@ import { defineViewUiPoints, pluginSettingsPageId } from "./uiPoints"; import { isLive, runPluginCommand } from "./store"; import styles from "./PluginsPage.module.css"; +/** Unpacks an action argument: a bare path, or a menu's `{ path, paths }`. */ +export function commandArgs(arg: unknown): [string | null, string[]] { + if (typeof arg === "string") return [arg, []]; + const { path, paths } = (arg ?? {}) as { path?: unknown; paths?: unknown }; + if (typeof path !== "string" || !Array.isArray(paths)) return [null, []]; + return [path, paths.filter((p): p is string => typeof p === "string")]; +} + export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`; interface CommandDecl { @@ -148,7 +156,8 @@ export function defineBuiltinUiPoints(): () => void { keywords: ["plugin", plugin.name, pluginId], description: tx`From plugin ${plugin.name}`, run: async ({ arg }) => { - await runPluginCommand(pluginId, decl.id, typeof arg === "string" ? arg : null); + const [path, paths] = commandArgs(arg); + await runPluginCommand(pluginId, decl.id, path, paths); }, }), Details: CommandsDetails, diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts index 047a4ce16..da26d38b6 100644 --- a/src/features/plugins/store.ts +++ b/src/features/plugins/store.ts @@ -392,10 +392,15 @@ export function commandTitle(pluginId: string, commandId: string): string { } /** Runs a plugin command from an action; failures become notifications. */ -export async function runPluginCommand(pluginId: string, commandId: string, arg: string | null = null) { +export async function runPluginCommand( + pluginId: string, + commandId: string, + arg: string | null = null, + paths: string[] = [], +) { const title = commandTitle(pluginId, commandId); try { - return await pluginsBackend().runCommand(pluginId, commandId, arg, editorContext()); + return await pluginsBackend().runCommand(pluginId, commandId, arg, editorContext(), paths); } catch (e) { await refreshPlugins(); const status = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status; diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index eb0f05292..d4139cf62 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -86,7 +86,11 @@ export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]): const action = pluginActionId(pluginId, decl.command); if (!getAction(action)) continue; // The editor menu runs with the focused editor; the others name their file. - out.push({ kind: "action", action, arg: menu === "editor" ? undefined : paths[0] }); + out.push({ + kind: "action", + action, + arg: menu === "editor" ? undefined : { path: paths[0], paths: [...paths] }, + }); } return out; } diff --git a/wit/workspace.wit b/wit/workspace.wit index 4563344d9..aa7214fb1 100644 --- a/wit/workspace.wit +++ b/wit/workspace.wit @@ -59,6 +59,11 @@ interface invocation { project: func() -> option; /// The window's focused editor. editor: func() -> option; + /// The files and folders the command was run on (a multi-selection in + /// a file tree, for instance), as absolute paths. + /// Empty when it was not run on any, or when the menu was for a tab + /// without a file (a virtual document). + selected-paths: func() -> list; } }