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