дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
e65825e661
коміт
4f65f030ce
6 files changed
+258
-13
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача