feat(plugins): right-click menu on the items of plugin tree and list views (#1746)

A plugin can declare `[[contributes.menus]]` with `menu = "viewItem.context"` and `view = "<its view>"`. Right-clicking an item of that tree or list view lists the matching commands, each run with the item's key as argument; `when` sees `viewItem` (the key) and `viewItemKind` (the key up to its first `:`). Validation refuses the menu without `view`, with an undeclared or web view, `view` on another menu, and (new in API 1.2) a manifest whose engines.openidea still admits older IDEs.

Closes #1618

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-06 22:47:40 +08:00
1 джерело e65825e661
коміт 4f65f030ce
6 files changed
+258 -13

No files matched your search

+89 -1
Переглянути файл
@@ -1,13 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContextMenuHost, hideContextMenu } from "@/ui";
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 { clearUiContributions, syncUiContributions } from "../projection";
import { pluginMenuItems, withPluginSetting } from "../uiPoints";
import { pluginMenuItems, pluginViewItemMenuItems, withPluginSetting } from "../uiPoints";
import { matchesPattern } from "../views/customEditor";
import { FRAME_CSP, FRAME_TAG, frameDocument, frameTheme, type FrameTheme } from "../views/frame";
import { PluginView, treeNodes, wantedViewport } from "../views/PluginView";
@@ -192,9 +193,43 @@ describe("plugin menus", () => {
expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([]);
off();
});
it("projects view item menu entries with the item key as argument", () => {
const off = registerAction({ id: "plugin.k.logs", title: "Logs", run: () => {} });
syncUiContributions(
[
{
pluginId: "k",
point: "menus",
key: "viewItem.context/res/logs",
decl: {
menu: "viewItem.context",
view: "res",
command: "logs",
when: "viewItemKind == pod",
},
},
],
[
{ id: "k", name: "K", status: "active", enabled: true } as unknown as Parameters<
typeof syncUiContributions
>[1][number],
],
);
expect(pluginViewItemMenuItems("k", "res", "pod:default:nginx")).toEqual([
{ kind: "action", action: "plugin.k.logs", arg: "pod:default:nginx" },
]);
expect(pluginViewItemMenuItems("k", "res", "svc:default/web")).toEqual([]);
expect(pluginViewItemMenuItems("k", "other", "pod:x")).toEqual([]);
expect(pluginViewItemMenuItems("other", "res", "pod:x")).toEqual([]);
expect(pluginMenuItems("projectView", ["/p/a"])).toEqual([]);
off();
});
});
describe("PluginView", () => {
afterEach(() => hideContextMenu());
it("renders a tree and sends expand / activate", async () => {
const events: ViewEvent[] = [];
const tree: View = {
@@ -224,6 +259,59 @@ describe("PluginView", () => {
await waitFor(() => expect(events).toContainEqual({ kind: "button", id: "refresh" }));
});
it("opens the item menu on right-click", async () => {
const tree: View = {
toolbar: [],
body: {
kind: "tree",
value: [
{ key: "ns", parent: null, label: "default", icon: null, detail: null, expandable: false, expanded: false },
],
},
status: null,
};
setPluginsBackend(backendWith(() => tree));
const itemMenu = vi.fn((key: string) => (key === "ns" ? [{ label: "Describe", run: () => {} }] : []));
render(
<>
<PluginView pluginId="jobs" view="jobs" itemMenu={itemMenu} />
<ContextMenuHost />
</>,
);
await userEvent.pointer({ keys: "[MouseRight]", target: await screen.findByText("default") });
expect(itemMenu).toHaveBeenCalledWith("ns");
expect(await screen.findByText("Describe")).toBeTruthy();
});
it("opens the item menu on a list row, and none when it has no entries", async () => {
const list: View = {
toolbar: [],
body: {
kind: "list",
value: [
{ key: "a", label: "Alpha", icon: null, detail: null },
{ key: "b", label: "Beta", icon: null, detail: null },
],
},
status: null,
};
setPluginsBackend(backendWith(() => list));
const itemMenu = vi.fn((key: string) => (key === "a" ? [{ label: "Open Alpha", run: () => {} }] : []));
render(
<>
<PluginView pluginId="jobs" view="jobs" itemMenu={itemMenu} />
<ContextMenuHost />
</>,
);
await userEvent.pointer({ keys: "[MouseRight]", target: await screen.findByText("Alpha") });
expect(itemMenu).toHaveBeenCalledWith("a");
expect(await screen.findByText("Open Alpha")).toBeTruthy();
hideContextMenu();
await userEvent.pointer({ keys: "[MouseRight]", target: screen.getByText("Beta") });
expect(itemMenu).toHaveBeenCalledWith("b");
expect(screen.queryByRole("menu")).toBeNull();
});
it("edits a table cell and renders again when the plugin refreshes", async () => {
const events: ViewEvent[] = [];
let city = "London";
+23 -3
Переглянути файл
@@ -15,7 +15,7 @@
import { useMemo } from "react";
import type { PluginInfo } from "@/bindings";
import { getAction } from "@/platform/actions";
import { evaluateWhen } from "@/platform/context";
import { evaluateWhen, getContext } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { isMacHost, registerExtensionBindings } from "@/platform/keymap";
import {
@@ -63,7 +63,8 @@ import styles from "./PluginsPage.module.css";
// --- menus ------------------------------------------------------------------------
interface MenuDecl {
menu: "editor.context" | "projectView.context" | "editorTab.context" | "tools";
menu: "editor.context" | "projectView.context" | "editorTab.context" | "viewItem.context" | "tools";
view?: string | null;
command: string;
when?: string | null;
}
@@ -90,6 +91,20 @@ export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]):
return out;
}
/** Plugin entries of a view item's context menu, with the item key as argument. */
export function pluginViewItemMenuItems(pluginId: string, view: string, key: string): MenuItem[] {
const ctx = { ...getContext(), viewItem: key, viewItemKind: key.split(":")[0] };
const out: MenuItem[] = [];
for (const { pluginId: owner, decl } of contextItems.values()) {
if (owner !== pluginId || decl.menu !== "viewItem.context" || decl.view !== view) continue;
if (!evaluateWhen(decl.when ?? undefined, ctx)) continue;
const action = pluginActionId(pluginId, decl.command);
if (!getAction(action)) continue;
out.push({ kind: "action", action, arg: key });
}
return out;
}
function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void {
if (decl.menu === "tools") {
const action = pluginActionId(pluginId, decl.command);
@@ -301,7 +316,12 @@ function registerView(pluginId: string, decl: ViewDecl, icon: string): () => voi
decl.web ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} />
<PluginView
pluginId={pluginId}
view={decl.id}
label={decl.title}
itemMenu={(key) => pluginViewItemMenuItems(pluginId, decl.id, key)}
/>
),
});
}
+35 -5
Переглянути файл
@@ -14,6 +14,7 @@ import {
useState,
useSyncExternalStore,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
import { tx } from "@/platform/i18n";
@@ -44,13 +45,15 @@ export interface PluginViewProps {
revision?: number;
/** Called after an event was handled (custom editors re-render on the document instead). */
label?: string;
/** Context menu entries of a tree or list item, by item key. */
itemMenu?: (key: string) => MenuItem[];
}
/** Table rows asked for beyond the visible ones (each side). */
const OVERSCAN = 50;
const ROW_HEIGHT = 24;
export function PluginView({ pluginId, view, doc = null, revision = 0, label }: PluginViewProps) {
export function PluginView({ pluginId, view, doc = null, revision = 0, label, itemMenu }: PluginViewProps) {
const [content, setContent] = useState<View | null>(null);
const [error, setError] = useState<string | null>(null);
const [viewport, setViewport] = useState<Viewport | null>(null);
@@ -127,7 +130,14 @@ export function PluginView({ pluginId, view, doc = null, revision = 0, label }:
</div>
)}
<div className={styles.body}>
<Body content={content} send={send} pluginId={pluginId} view={view} onViewport={setViewport} />
<Body
content={content}
send={send}
pluginId={pluginId}
view={view}
onViewport={setViewport}
itemMenu={itemMenu}
/>
</div>
{content.status && <div className={styles.status}>{content.status}</div>}
</div>
@@ -140,15 +150,16 @@ interface BodyProps {
pluginId: string;
view: string;
onViewport: (v: Viewport) => void;
itemMenu?: (key: string) => MenuItem[];
}
function Body({ content, send, pluginId, view, onViewport }: BodyProps) {
function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps) {
const body = content.body;
switch (body.kind) {
case "message":
return <EmptyState className={styles.empty} title={body.value} />;
case "tree":
return <TreeBody items={body.value} send={send} />;
return <TreeBody items={body.value} send={send} itemMenu={itemMenu} />;
case "list":
return (
<ul className={styles.list} role="listbox">
@@ -162,6 +173,7 @@ function Body({ content, send, pluginId, view, onViewport }: BodyProps) {
onClick={() => send({ kind: "select", key: i.key })}
onDoubleClick={() => send({ kind: "activate", key: i.key })}
onKeyDown={(e) => e.key === "Enter" && send({ kind: "activate", key: i.key })}
onContextMenu={(e) => openItemMenu(e, itemMenu, i.key)}
>
{i.icon && <Icon name={i.icon} size={14} />}
<span>{i.label}</span>
@@ -205,7 +217,24 @@ export function treeNodes(items: readonly ViewTreeItem[]): Node[] {
return roots;
}
function TreeBody({ items, send }: { items: ViewTreeItem[]; send: (e: ViewEvent) => void }) {
function openItemMenu(
e: ReactMouseEvent | MouseEvent,
itemMenu: ((key: string) => MenuItem[]) | undefined,
key: string,
) {
const items = itemMenu?.(key) ?? [];
if (items.length === 0) return;
e.preventDefault();
showContextMenu(e, items);
}
interface TreeBodyProps {
items: ViewTreeItem[];
send: (e: ViewEvent) => void;
itemMenu?: (key: string) => MenuItem[];
}
function TreeBody({ items, send, itemMenu }: TreeBodyProps) {
const roots = useMemo(() => treeNodes(items), [items]);
const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]);
return (
@@ -224,6 +253,7 @@ function TreeBody({ items, send }: { items: ViewTreeItem[]; send: (e: ViewEvent)
onSelectionChange={(_, lead) => lead && send({ kind: "select", key: lead.item.key })}
onActivate={(n) => send({ kind: "activate", key: n.item.key })}
multiSelect={false}
onContextMenu={itemMenu ? (node, _, e) => node && openItemMenu(e, itemMenu, node.item.key) : undefined}
/>
);
}