дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(plugins): let a command read every file selected in the Project view (#1744)
Plugin API 1.2 adds `invocation.selected-paths`: the files and folders a Project view or editor tab menu ran the command on, as absolute paths (the command's argument stays the first of them, so existing plugins see no change). The frontend passes the menu's selection to `plugin_run_command` on the desktop and through the remote dispatcher; the Rust SDK and its mock gain `Invocation::selected_paths`. All components are rebuilt for the WIT change. Closes #1621 Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
1 джерело
068dcfdb0a
коміт
708f1b051b
44 files changed
+249
-42
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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<EventKind> = events.iter().map(WorkbenchEvent::kind).collect();
|
||||
{
|
||||
|
||||
@@ -367,10 +367,12 @@ impl RemotePlugins {
|
||||
let command_id: String = c.arg("commandId")?;
|
||||
let arg: Option<String> = c.arg("arg")?;
|
||||
let editor: Option<PluginEditor> = c.arg("editor")?;
|
||||
let paths: Option<Vec<PathBuf>> = 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
|
||||
}
|
||||
|
||||
@@ -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<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -28,6 +28,8 @@ pub struct Invocation {
|
||||
pub client: Option<String>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
pub editor: Option<EditorContext>,
|
||||
/// Files and folders the command was run on.
|
||||
pub selected_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// A failed host call, as the plugin sees it (WIT `types.error`).
|
||||
|
||||
@@ -419,6 +419,10 @@ impl invocation::HostInvocation for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_paths(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Vec<String>> {
|
||||
Ok(self.invocation.selected_paths.iter().take(MAX_FIND as usize).map(|p| to_ipc(p)).collect())
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<InvocationRes>) -> wasmtime::Result<()> {
|
||||
// Owned by the host for the duration of the call.
|
||||
let _ = rep;
|
||||
|
||||
@@ -1 +1 @@
|
||||
42044c4b2e3d768b922d6e5026ff4e725d37b265896ea7f383f1d737e2feffb7
|
||||
7a84aaf2ad8fcb0bf2f9fc0eeff6ad8664512a3fa6d9c4c205c19eff9a3e1315
|
||||
@@ -1 +1 @@
|
||||
87eb2a28875abbc649b98d2a7f87263da217f9ad28f1395d96a61db5f781d9f0
|
||||
92aece4d9f24c4429f2dcfc64e65a53219103267130731af49b65c7c581048d4
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3d3e43d4e49efa7a97984335f7f2df2cb286321bd0368251f37818aa6c1b70ad
|
||||
17136d5359a8f5153ca2650e4c4b2c8f86eb31d0b99ef17974e0217c04f9a6e5
|
||||
@@ -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"
|
||||
+6
-1
@@ -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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
1d8561e04cb777efbafc6f64963473b9b69654a72ebb2be34f49c6c53c6f76e2
|
||||
b697a2aa34b0de05db3e89bb8449ab6619119fa003b19fabe36b0932246a26fe
|
||||
@@ -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.<plugin id>.<command id>", "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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
5a707c0a9efb6f98f43d93f1b4207cc0ab21ac84d693e0fd9981d143e7552a1c
|
||||
f63e6547849ce8f5a0de4384eb44a903583135dea7e5a82829daf3a395e98465
|
||||
@@ -1 +1 @@
|
||||
34728307b2054d6a494040f0523caf6f62b96c0f67f5b5e9a7e4508370e79419
|
||||
af6dbfa8c34e5cb62851f02479f86350ee915c18028993331b4372a739074b80
|
||||
@@ -1 +1 @@
|
||||
c8de2df9607fef2a7e75023e78db06ac83de8598a43632b684756d4e0df6b51b
|
||||
920da064d50f59aa34825f5dc8c59442b7185891bbded8f7f1c87c8192c093ce
|
||||
@@ -1 +1 @@
|
||||
2523894a6fb5a75d29429ab096fdd9acf150c05eb4ea13c9e92920c8d8ca4459
|
||||
7c9279246766fd92f854fe8dc33f0165236f616a448782dfa59ea9c05463cf8a
|
||||
@@ -1 +1 @@
|
||||
b534a088809e38ff210ed169b6968f3e793fa0fdcb12d022d2bcee5a58834514
|
||||
55cc148dc0eddf924e97074a46707736e563b0fe6d348a09610b1822ed085d6b
|
||||
@@ -1 +1 @@
|
||||
145bad8868c73368d4e83087861293fad309aaa87263bb12508a6acf693c25da
|
||||
eda75943217c5273773ef0f2934c20d6bda50411670d99145b50e45d104509cc
|
||||
@@ -1 +1 @@
|
||||
f2f4a7d3fbe4b86369e35969782dd66150df6ae4451e375122d3c1b232caeac1
|
||||
5a83f6c626f01c80910e1e74e6452bc2bbef8de3fe402d5540f506dc6dd02ed0
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
5e7f7adc2c49503d2278ed2a446f18f69561ce51df264100f77f50d69804c277
|
||||
3984d6b51bea7212bbabadc2a5a632c4cba7a2a988033fa7918e33c203fcfa23
|
||||
@@ -1 +1 @@
|
||||
01c6f9e9b1e76b3328294f6a31ba4d8f3545d04a4fa56cc8069c59aa37a13f27
|
||||
2a70078e8edf29c9a4b590d661a1a67cb605dca3781a89ffcf6dc8acb0d183b0
|
||||
@@ -1 +1 @@
|
||||
745e791fe2fde2b4d7b58b40a33a8037999aac947865a8f4a5127021b7f16156
|
||||
4449ee6a1ccee1a71ebed1073bb4e30df1686ebd0a2812bdd579ad876d0a43cf
|
||||
@@ -1 +1 @@
|
||||
4fff5b261e8255aa2dac8342694dee129ea4a8c9520105c719d0c16a16846517
|
||||
adf2ab3e93e0147350b6db899ec2e44e07862e99f49609aecbc2b866e5282c3d
|
||||
@@ -117,6 +117,10 @@ mod imp {
|
||||
i.editor()
|
||||
}
|
||||
|
||||
pub fn invocation_selected_paths(i: InvocationRef<'_>) -> Vec<String> {
|
||||
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<TextEditor> {
|
||||
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<String> {
|
||||
imp::invocation_selected_paths(self.inner)
|
||||
}
|
||||
}
|
||||
|
||||
/// A project open in the IDE.
|
||||
|
||||
@@ -56,6 +56,7 @@ struct State {
|
||||
other_projects: Vec<String>,
|
||||
files: BTreeMap<String, String>,
|
||||
editor: Option<Editor>,
|
||||
selected_paths: Vec<String>,
|
||||
notifications: Vec<Notification>,
|
||||
logs: Vec<(LogLevel, String)>,
|
||||
status: BTreeMap<String, StatusItem>,
|
||||
@@ -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<String> {
|
||||
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();
|
||||
|
||||
@@ -59,6 +59,11 @@ interface invocation {
|
||||
project: func() -> option<project>;
|
||||
/// The window's focused editor.
|
||||
editor: func() -> option<text-editor>;
|
||||
/// 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<string>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ pub async fn plugin_run_command(
|
||||
command_id: String,
|
||||
arg: Option<String>,
|
||||
editor: Option<PluginEditor>,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
) -> IdeResult<Option<String>> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1023,7 +1023,7 @@ export const commands = {
|
||||
path: string,
|
||||
language: string,
|
||||
selections: Range[],
|
||||
} | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor })),
|
||||
} | null, paths: string[] | null) => typedError<string | null, IdeError>(__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<void>("plugin_post_events", { events }),
|
||||
/** Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). */
|
||||
|
||||
@@ -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<Record<"altKey" | "ctrlKey" | "metaKey" | "shiftKey", boolean>> = {}) =>
|
||||
editorKeyName({ key, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...mods });
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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":
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface PluginsBackend {
|
||||
commandId: string,
|
||||
arg: string | null,
|
||||
editor: PluginEditor | null,
|
||||
paths: string[],
|
||||
): Promise<string | null>;
|
||||
/** Workbench events plugins subscribed to (see `eventKinds`). */
|
||||
postEvents(events: WorkbenchEvent[]): Promise<void>;
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ interface invocation {
|
||||
project: func() -> option<project>;
|
||||
/// The window's focused editor.
|
||||
editor: func() -> option<text-editor>;
|
||||
/// 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<string>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача