diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index f0fb8f5a..0fa1dd92 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -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 = "" # 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 { .collect() } +fn declared_view(pkg: &Package, id: &str) -> Option { + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + view: Option, } pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result { @@ -114,7 +122,24 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result { 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)] diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 01d23f35..9f13fb9b 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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 = 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"), "

hi

").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. diff --git a/plugins/README.md b/plugins/README.md index 7854ec9c..fdce7ff7 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index 3258e4c2..1bff3535 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -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( + <> + + + , + ); + 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( + <> + + + , + ); + 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"; diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index d00ed2d2..eb0f0529 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -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 ? ( ) : ( - + pluginViewItemMenuItems(pluginId, decl.id, key)} + /> ), }); } diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index 2fac0b40..3cebc9ae 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -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(null); const [error, setError] = useState(null); const [viewport, setViewport] = useState(null); @@ -127,7 +130,14 @@ export function PluginView({ pluginId, view, doc = null, revision = 0, label }: )}
- +
{content.status &&
{content.status}
} @@ -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 ; case "tree": - return ; + return ; case "list": return (
    @@ -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 && } {i.label} @@ -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} /> ); }