feat(plugins): group a plugin's context menu entries under a submenu (#1671)

Context menu entries of a plugin can name a submenu on [[contributes.menus]] (editor, Project view, editor tab and view item menus; API 1.3): entries with the same submenu are grouped under it, a submenu with one visible entry stays a plain entry, and the label goes through the plugin's translations. Opt-in rather than by command category, since the category is the Find Action/keymap prefix and many plugins share one across all commands.

Co-authored-by: lidwt721 <[email protected]>
Цей коміт міститься в:
lidwt721таlidwt721 authored and GitHub committed 2026-11-07 19:53:26 +08:00
1 джерело adc308eb99
коміт e68b35f534
7 files changed
+161 -5

No files matched your search

+11
Переглянути файл
@@ -122,6 +122,10 @@ struct MenuDecl {
when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
view: Option<String>,
/// Groups the plugin's entries of one context menu that name the same
/// submenu under it.
#[serde(default, skip_serializing_if = "Option::is_none")]
submenu: Option<String>,
}
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
@@ -136,6 +140,13 @@ 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}"))?;
}
if let Some(submenu) = &d.submenu {
if !d.menu.ends_with(".context") {
return Err(format!("contributes.menus: `submenu` is only for context menus, not {:?}", d.menu));
}
title(MENUS, submenu)?;
pkg.manifest.needs_api("contributes.menus: submenu", 3)?;
}
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)) => {
+29
Переглянути файл
@@ -1980,6 +1980,35 @@ fn official_csv_editor_edits_the_shared_document() {
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
}
#[test]
fn context_menu_entries_can_name_a_submenu() {
fn with_menu(version: &'static str, extra: &'static str) -> impl Fn(&str) -> String {
move |m: &str| {
let m = m.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{version}\""));
format!("{m}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n")
}
}
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
let ok = hello_variant(dir.path(), with_menu("^1.3", "menu = \"editor.context\"\nsubmenu = \"Words\""));
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
let submenus: Vec<_> =
f.host.ui_items("menus").into_iter().filter_map(|i| i.decl["submenu"].as_str().map(str::to_owned)).collect();
assert_eq!(submenus, ["Words"]);
f.host.uninstall("hello").unwrap();
for (version, extra, message) in [
("^1.3", "menu = \"tools\"\nsubmenu = \"Words\"", "only for context menus"),
("^1.3", "menu = \"editor.context\"\nsubmenu = \" \"", "titles are"),
("^1.2", "menu = \"editor.context\"\nsubmenu = \"Words\"", "1.3"),
] {
let dir = tempfile::tempdir().unwrap();
let pkg = hello_variant(dir.path(), with_menu(version, extra));
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{extra}: {}", err.message);
}
}
#[test]
fn view_item_menus_name_a_declared_view() {
/// The menu is new in API 1.2.
+15
Переглянути файл
@@ -179,6 +179,21 @@ A `commit.message` menu puts the command, with the plugin's icon, in the
toolbar above the commit message of the Commit tool window and the Commit
dialog (see _Commits_). It is new in API 1.3 (`engines.openidea = "^1.3"`).
In a context menu (`editor.context`, `projectView.context`, `editorTab.context`,
`viewItem.context`), entries can name a `submenu`: the plugin's entries of that
menu with the same `submenu` are shown under it (`Arthas` ▸ _Watch_ / _Trace_),
in declaration order; a submenu with one visible entry shows it as a plain
entry, and entries without `submenu` stay where they are. The label goes
through the plugin's translations. It is new in API 1.3
(`engines.openidea = "^1.3"`):
```toml
[[contributes.menus]]
menu = "editor.context"
command = "arthas.watch"
submenu = "Arthas"
```
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
+57
Переглянути файл
@@ -249,6 +249,63 @@ describe("plugin menus", () => {
expect(pluginMenuItems("projectView", ["/p/a"])).toEqual([]);
off();
});
it("groups one plugin's entries that name the same submenu", () => {
const off = [
registerAction({ id: "plugin.p.watch", title: "Watch", category: "P", run: () => {} }),
registerAction({ id: "plugin.p.trace", title: "Trace", category: "P", run: () => {} }),
registerAction({ id: "plugin.p.single", title: "Single", category: "P", run: () => {} }),
registerAction({ id: "plugin.p.plain", title: "Plain", category: "P", run: () => {} }),
];
const plugin = {
id: "p",
name: "P",
status: "active",
enabled: true,
} as unknown as Parameters<typeof syncUiContributions>[1][number];
syncUiContributions(
[
{
pluginId: "p",
point: "menus",
key: "editor.context/watch",
decl: { menu: "editor.context", command: "watch", submenu: "Arthas" },
},
{
pluginId: "p",
point: "menus",
key: "editor.context/trace",
decl: { menu: "editor.context", command: "trace", submenu: "Arthas" },
},
{
pluginId: "p",
point: "menus",
key: "editor.context/single",
decl: { menu: "editor.context", command: "single", submenu: "Other" },
},
{
pluginId: "p",
point: "menus",
key: "editor.context/plain",
decl: { menu: "editor.context", command: "plain" },
},
],
[plugin],
);
expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([
{
kind: "item",
label: "Arthas",
submenu: [
{ kind: "action", action: "plugin.p.watch", arg: undefined },
{ kind: "action", action: "plugin.p.trace", arg: undefined },
],
},
{ kind: "action", action: "plugin.p.single", arg: undefined },
{ kind: "action", action: "plugin.p.plain", arg: undefined },
]);
for (const dispose of off) dispose();
});
});
describe("PluginView", () => {
+5
Переглянути файл
@@ -56,6 +56,11 @@ describe("localizeContributions", () => {
});
});
it("translates a context menu entry's submenu label", () => {
const items = [item("a", "menus", { id: "m", menu: "editor.context", command: "x", submenu: "Refresh" })];
expect(localizeContributions(items, { a: fr })[0]!.decl).toMatchObject({ submenu: "Actualiser" });
});
it("gives a tools menu entry the translated title of its command", () => {
const items = [
item("a", "commands", { id: "x", title: "Refresh" }),
+4 -1
Переглянути файл
@@ -75,6 +75,7 @@ export function usePluginT(pluginId: string): PluginTranslate {
/** The keys of each point's declaration that are shown text; ids, names of secrets and option values are not. */
const TEXT_KEYS: Record<string, readonly string[]> = {
commands: ["title", "category"],
menus: ["submenu"],
views: ["title"],
customEditors: ["title"],
statusBar: ["text", "tooltip"],
@@ -102,7 +103,9 @@ export function localizeContributions(
if (Object.keys(catalogs).length === 0) return items;
const localized = items.map((item) => {
const keys = TEXT_KEYS[item.point];
return keys && ownEntry(catalogs, item.pluginId)
const decl = item.decl as Record<string, unknown> | null;
const hasText = !!decl && (keys?.some((k) => typeof decl[k] === "string") || Array.isArray(decl.properties));
return keys && hasText && ownEntry(catalogs, item.pluginId)
? { ...item, decl: localizeDecl(item.decl, keys, translatorOf(catalogs, item.pluginId)) }
: item;
});
+40 -4
Переглянути файл
@@ -81,6 +81,8 @@ interface MenuDecl {
view?: string | null;
command: string;
when?: string | null;
/** Groups the plugin's entries of one context menu under this label. */
submenu?: string | null;
/** The command's title in the plugin's language, when it ships one. */
commandTitle?: string;
}
@@ -94,35 +96,69 @@ const CONTEXT_MENUS: Record<string, ContextMenuId> = {
const contextItems = new Map<string, { pluginId: string; decl: MenuDecl }>();
let menuSeq = 0;
/**
* Adds `item` to `out`; the entries of one plugin that name the same
* `submenu` are collected under it.
*/
function addGrouped(
out: MenuItem[],
groups: Map<string, MenuItem[]>,
pluginId: string,
submenu: string | null | undefined,
item: MenuItem,
): void {
if (!submenu) {
out.push(item);
return;
}
const key = `${pluginId}\u0000${submenu}`;
let group = groups.get(key);
if (!group) {
group = [];
groups.set(key, group);
out.push({ kind: "item", label: submenu, submenu: group });
}
group.push(item);
}
/** A submenu holding one visible entry gains nothing from nesting. */
function flattenSingleGroups(items: MenuItem[]): MenuItem[] {
return items.map((item) =>
item.kind !== "action" && "submenu" in item && item.submenu?.length === 1 ? item.submenu[0]! : item,
);
}
/** Plugin items of a context menu: their command's action with the menu's file as argument. */
export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]): MenuItem[] {
const out: MenuItem[] = [];
const groups = new Map<string, MenuItem[]>();
for (const { pluginId, decl } of contextItems.values()) {
if (CONTEXT_MENUS[decl.menu] !== menu || !evaluateWhen(decl.when ?? undefined)) continue;
const action = pluginActionId(pluginId, decl.command);
if (!getAction(action)) continue;
// The editor menu runs with the focused editor; the others name their file.
out.push({
addGrouped(out, groups, pluginId, decl.submenu, {
kind: "action",
action,
arg: menu === "editor" ? undefined : { path: paths[0], paths: [...paths] },
});
}
return out;
return flattenSingleGroups(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[] = [];
const groups = new Map<string, 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 });
addGrouped(out, groups, pluginId, decl.submenu, { kind: "action", action, arg: key });
}
return out;
return flattenSingleGroups(out);
}
function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void {