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]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-06 22:54:44 +08:00
1 джерело 068dcfdb0a
коміт 708f1b051b
44 files changed
+249 -42

No files matched your search

+6 -1
Переглянути файл
@@ -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) {
+1
Переглянути файл
@@ -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();
{
+2
Переглянути файл
@@ -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
}
+6 -1
Переглянути файл
@@ -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> {
+1 -1
Переглянути файл
@@ -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 {
+6 -1
Переглянути файл
@@ -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();
}
+18 -2
Переглянути файл
@@ -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());
+9
Переглянути файл
@@ -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();
}
+2
Переглянути файл
@@ -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`).
+4
Переглянути файл
@@ -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
Переглянути файл
@@ -1 +1 @@
42044c4b2e3d768b922d6e5026ff4e725d37b265896ea7f383f1d737e2feffb7
7a84aaf2ad8fcb0bf2f9fc0eeff6ad8664512a3fa6d9c4c205c19eff9a3e1315
+1 -1
Переглянути файл
@@ -1 +1 @@
87eb2a28875abbc649b98d2a7f87263da217f9ad28f1395d96a61db5f781d9f0
92aece4d9f24c4429f2dcfc64e65a53219103267130731af49b65c7c581048d4
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3d3e43d4e49efa7a97984335f7f2df2cb286321bd0368251f37818aa6c1b70ad
17136d5359a8f5153ca2650e4c4b2c8f86eb31d0b99ef17974e0217c04f9a6e5
+5
Переглянути файл
@@ -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
Переглянути файл
@@ -1 +1 @@
1d8561e04cb777efbafc6f64963473b9b69654a72ebb2be34f49c6c53c6f76e2
b697a2aa34b0de05db3e89bb8449ab6619119fa003b19fabe36b0932246a26fe
+1 -1
Переглянути файл
@@ -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
Переглянути файл
@@ -1 +1 @@
5a707c0a9efb6f98f43d93f1b4207cc0ab21ac84d693e0fd9981d143e7552a1c
f63e6547849ce8f5a0de4384eb44a903583135dea7e5a82829daf3a395e98465
+1 -1
Переглянути файл
@@ -1 +1 @@
34728307b2054d6a494040f0523caf6f62b96c0f67f5b5e9a7e4508370e79419
af6dbfa8c34e5cb62851f02479f86350ee915c18028993331b4372a739074b80
+1 -1
Переглянути файл
@@ -1 +1 @@
c8de2df9607fef2a7e75023e78db06ac83de8598a43632b684756d4e0df6b51b
920da064d50f59aa34825f5dc8c59442b7185891bbded8f7f1c87c8192c093ce
+1 -1
Переглянути файл
@@ -1 +1 @@
2523894a6fb5a75d29429ab096fdd9acf150c05eb4ea13c9e92920c8d8ca4459
7c9279246766fd92f854fe8dc33f0165236f616a448782dfa59ea9c05463cf8a
+1 -1
Переглянути файл
@@ -1 +1 @@
b534a088809e38ff210ed169b6968f3e793fa0fdcb12d022d2bcee5a58834514
55cc148dc0eddf924e97074a46707736e563b0fe6d348a09610b1822ed085d6b
+1 -1
Переглянути файл
@@ -1 +1 @@
145bad8868c73368d4e83087861293fad309aaa87263bb12508a6acf693c25da
eda75943217c5273773ef0f2934c20d6bda50411670d99145b50e45d104509cc
+1 -1
Переглянути файл
@@ -1 +1 @@
f2f4a7d3fbe4b86369e35969782dd66150df6ae4451e375122d3c1b232caeac1
5a83f6c626f01c80910e1e74e6452bc2bbef8de3fe402d5540f506dc6dd02ed0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5e7f7adc2c49503d2278ed2a446f18f69561ce51df264100f77f50d69804c277
3984d6b51bea7212bbabadc2a5a632c4cba7a2a988033fa7918e33c203fcfa23
+1 -1
Переглянути файл
@@ -1 +1 @@
01c6f9e9b1e76b3328294f6a31ba4d8f3545d04a4fa56cc8069c59aa37a13f27
2a70078e8edf29c9a4b590d661a1a67cb605dca3781a89ffcf6dc8acb0d183b0
+1 -1
Переглянути файл
@@ -1 +1 @@
745e791fe2fde2b4d7b58b40a33a8037999aac947865a8f4a5127021b7f16156
4449ee6a1ccee1a71ebed1073bb4e30df1686ebd0a2812bdd579ad876d0a43cf
+1 -1
Переглянути файл
@@ -1 +1 @@
4fff5b261e8255aa2dac8342694dee129ea4a8c9520105c719d0c16a16846517
adf2ab3e93e0147350b6db899ec2e44e07862e99f49609aecbc2b866e5282c3d
+10
Переглянути файл
@@ -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.
+18
Переглянути файл
@@ -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();
+5
Переглянути файл
@@ -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>;
}
}
+2
Переглянути файл
@@ -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
}
+1 -1
Переглянути файл
@@ -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`). */
+24 -2
Переглянути файл
@@ -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 });
+29 -9
Переглянути файл
@@ -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":
+22
Переглянути файл
@@ -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 });
+26 -2
Переглянути файл
@@ -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();
});
+3 -1
Переглянути файл
@@ -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);
},
+10 -1
Переглянути файл
@@ -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,
+7 -2
Переглянути файл
@@ -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;
+5 -1
Переглянути файл
@@ -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;
}
+5
Переглянути файл
@@ -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>;
}
}