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 <77437157+arnaud-ritti@users.noreply.github.com>
This commit is contained in:
Arnaud Ritti
2026-11-06 22:47:40 +08:00
committed by GitHub
parent e65825e661
commit 4f65f030ce
6 changed files with 258 additions and 13 deletions
+28 -3
View File
@@ -5,9 +5,10 @@
//!
//! ```toml
//! [[contributes.menus]]
//! menu = "editor.context" # projectView.context, editorTab.context, tools
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
//! command = "check"
//! when = "editorLang == java"
//! # view = "<view id>" # required for (only allowed on) viewItem.context
//!
//! [[contributes.keymaps]]
//! command = "check"
@@ -63,7 +64,8 @@ pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"];
const VIEW_ITEM_MENU: &str = "viewItem.context";
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
@@ -77,6 +79,10 @@ fn commands(pkg: &Package) -> BTreeSet<String> {
.collect()
}
fn declared_view(pkg: &Package, id: &str) -> Option<toml::Value> {
pkg.manifest.declarations(VIEWS).into_iter().find(|d| d.get("id").and_then(|v| v.as_str()) == Some(id))
}
fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> {
if commands(pkg).contains(command) {
Ok(())
@@ -103,6 +109,8 @@ struct MenuDecl {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
view: Option<String>,
}
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
@@ -114,7 +122,24 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
Ok(UiDecl::new(format!("{}/{}", d.menu, d.command), d.command.clone(), json(&d)?))
let key = match (d.menu == VIEW_ITEM_MENU, &d.view) {
(true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")),
(true, Some(v)) => {
pkg.manifest.needs_api(&format!("contributes.menus: {VIEW_ITEM_MENU}"), 2)?;
let Some(view) = declared_view(pkg, v) else {
return Err(format!("contributes.menus: view {v:?} is not declared under [[contributes.views]]"));
};
if view.get("web").is_some() {
return Err(format!(
"contributes.menus: view {v:?} is a web view (viewItem.context needs a tree or list the plugin renders)"
));
}
format!("{VIEW_ITEM_MENU}/{v}/{}", d.command)
}
(false, Some(_)) => return Err(format!("contributes.menus: `view` is only for {VIEW_ITEM_MENU}")),
(false, None) => format!("{}/{}", d.menu, d.command),
};
Ok(UiDecl::new(key, d.command.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
+63
View File
@@ -1935,6 +1935,69 @@ fn official_csv_editor_edits_the_shared_document() {
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
}
#[test]
fn view_item_menus_name_a_declared_view() {
/// The menu is new in API 1.2.
fn api_1_2(m: &str) -> String {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.2\"")
}
fn with_menu(extra: &str) -> impl Fn(&str) -> String + '_ {
move |m: &str| format!("{}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n", api_1_2(m))
}
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let second_view =
"[[contributes.views]]\nid = \"more\"\ntitle = \"More\"\nicon = \"plugin\"\nlocation = \"right\"\n";
let ok = hello_variant(tmp.path(), |m| {
let on_greetings =
with_menu("menu = \"viewItem.context\"\nview = \"greetings\"\nwhen = \"viewItemKind == English\"")(m);
let on_more = with_menu("menu = \"viewItem.context\"\nview = \"more\"")("");
format!("{on_greetings}\n{second_view}\n{on_more}")
});
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
let item = f.host.ui_items("menus").into_iter().find(|i| i.decl["menu"] == "viewItem.context").unwrap();
assert_eq!(
(item.key.as_str(), item.decl["view"].as_str()),
("viewItem.context/greetings/word-count", Some("greetings"))
);
let keys: Vec<String> = f.host.ui_items("menus").into_iter().map(|i| i.key).collect();
for view in ["greetings", "more"] {
assert!(keys.contains(&format!("viewItem.context/{view}/word-count")), "{keys:?}");
}
f.host.uninstall("hello").unwrap();
for (extra, message) in [
("menu = \"viewItem.context\"", "needs `view`"),
("menu = \"viewItem.context\"\nview = \"nope\"", "view \"nope\" is not declared"),
("menu = \"projectView.context\"\nview = \"greetings\"", "`view` is only for viewItem.context"),
] {
let dir = tempfile::tempdir().unwrap();
let err = f.host.install_from_dir(&hello_variant(dir.path(), with_menu(extra)), &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{extra}: {}", err.message);
}
let dir = tempfile::tempdir().unwrap();
let web_view = "[[contributes.views]]\nid = \"page\"\ntitle = \"Page\"\nicon = \"plugin\"\nlocation = \"right\"\nweb = \"web/index.html\"\n";
let pkg = hello_variant(dir.path(), |m| {
let on_page = with_menu("menu = \"viewItem.context\"\nview = \"page\"")("");
format!("{}\n{web_view}\n{on_page}", api_1_2(m))
});
std::fs::create_dir_all(pkg.join("web")).unwrap();
std::fs::write(pkg.join("web/index.html"), "<p>hi</p>").unwrap();
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("is a web view"), "{}", err.message);
let dir = tempfile::tempdir().unwrap();
let old = hello_variant(dir.path(), |m| {
format!(
"{m}\n[[contributes.menus]]\nmenu = \"viewItem.context\"\nview = \"greetings\"\ncommand = \"word-count\"\n"
)
});
let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message);
}
/// The sample plugin makes a tool window with a tree view.
/// Its menus, shortcut and status bar item are declarations, listed before
/// any code runs; the tree is rendered by the code, which loads on first show.
+20 -1
View File
@@ -146,7 +146,7 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
@@ -155,6 +155,25 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
A `viewItem.context` menu needs `view`, the id of one of the plugin's own
views. It adds the commands to the right-click menu of the items in that
view's `tree` or `list`, and each command gets the item's key as its argument.
`when` clauses can use `viewItem` (the key) and `viewItemKind` (the key up to
its first `:`, so `pod` for `pod:default/nginx`; the whole key when it has no
`:`). Only the menu's `when` sees them, not the command's own `when`; a command
whose `when` is false shows greyed out. Other menus refuse `view`, a web view
has no item menu, and no menu opens when no declaration matches the item.
The menu is new in API 1.2, so a plugin using it declares
`engines.openidea = "^1.2"`. For example:
```toml
[[contributes.menus]]
menu = "viewItem.context"
view = "pods"
command = "kube.logs"
when = "viewItemKind == pod"
```
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
themes can be applied, settings pages are there — **before** any plugin code
+89 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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}
/>
);
}