diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 0ff0d7c82..b98f220c2 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -122,6 +122,10 @@ struct MenuDecl { when: Option, #[serde(default, skip_serializing_if = "Option::is_none")] view: Option, + /// 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, } pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result { @@ -136,6 +140,13 @@ 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}"))?; } + 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)) => { diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 103e53aba..c51620c7d 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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. diff --git a/plugins/README.md b/plugins/README.md index c0fab4d91..9c3e646b3 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index f4a30924d..1aa0a3d66 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -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[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", () => { diff --git a/src/features/plugins/pluginText.test.ts b/src/features/plugins/pluginText.test.ts index 9deca5ced..1436b9065 100644 --- a/src/features/plugins/pluginText.test.ts +++ b/src/features/plugins/pluginText.test.ts @@ -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" }), diff --git a/src/features/plugins/pluginText.ts b/src/features/plugins/pluginText.ts index 22d7eda63..eb3b9a591 100644 --- a/src/features/plugins/pluginText.ts +++ b/src/features/plugins/pluginText.ts @@ -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 = { 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 | 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; }); diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index 483b403f2..7957219fe 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -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 = { const contextItems = new Map(); 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, + 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(); 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(); 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 {