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

+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;
}