mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00: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 <77437157+arnaud-ritti@users.noreply.github.com>
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
Block a user