Skip to content

Commit 762bb93

Browse files
committed
submenus api extension point
1 parent 0b9cffd commit 762bb93

1 file changed

Lines changed: 234 additions & 59 deletions

File tree

src/vs/workbench/api/common/menusExtensionPoint.ts

Lines changed: 234 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import { IJSONSchema } from 'vs/base/common/jsonSchema';
1010
import { forEach } from 'vs/base/common/collections';
1111
import { IExtensionPointUser, ExtensionMessageCollector, ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry';
1212
import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
13-
import { MenuId, MenuRegistry, ILocalizedString, IMenuItem, ICommandAction } from 'vs/platform/actions/common/actions';
13+
import { MenuId, MenuRegistry, ILocalizedString, IMenuItem, ICommandAction, ISubmenuItem } from 'vs/platform/actions/common/actions';
1414
import { URI } from 'vs/base/common/uri';
1515
import { DisposableStore } from 'vs/base/common/lifecycle';
1616
import { ThemeIcon } from 'vs/platform/theme/common/themeService';
1717

1818
namespace schema {
1919

20-
// --- menus contribution point
20+
// --- menus, submenus contribution point
2121

2222
export interface IUserFriendlyMenuItem {
2323
command: string;
@@ -26,6 +26,17 @@ namespace schema {
2626
group?: string;
2727
}
2828

29+
export interface IUserFriendlySubmenuItem {
30+
submenu: string;
31+
when?: string;
32+
group?: string;
33+
}
34+
35+
export interface IUserFriendlySubmenu {
36+
id: string;
37+
label: string;
38+
}
39+
2940
export function parseMenuId(value: string): MenuId | undefined {
3041
switch (value) {
3142
case 'commandPalette': return MenuId.CommandPalette;
@@ -70,34 +81,87 @@ namespace schema {
7081
return false;
7182
}
7283

73-
export function isValidMenuItems(menu: IUserFriendlyMenuItem[], collector: ExtensionMessageCollector): boolean {
74-
if (!Array.isArray(menu)) {
75-
collector.error(localize('requirearray', "menu items must be an array"));
84+
export function isMenuItem(item: IUserFriendlyMenuItem | IUserFriendlySubmenuItem): item is IUserFriendlyMenuItem {
85+
return typeof (item as IUserFriendlyMenuItem).command === 'string';
86+
}
87+
88+
export function isValidMenuItem(item: IUserFriendlyMenuItem, collector: ExtensionMessageCollector): boolean {
89+
if (typeof item.command !== 'string') {
90+
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));
91+
return false;
92+
}
93+
if (item.alt && typeof item.alt !== 'string') {
94+
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'alt'));
95+
return false;
96+
}
97+
if (item.when && typeof item.when !== 'string') {
98+
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));
99+
return false;
100+
}
101+
if (item.group && typeof item.group !== 'string') {
102+
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group'));
76103
return false;
77104
}
78105

79-
for (let item of menu) {
80-
if (typeof item.command !== 'string') {
81-
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));
82-
return false;
83-
}
84-
if (item.alt && typeof item.alt !== 'string') {
85-
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'alt'));
86-
return false;
87-
}
88-
if (item.when && typeof item.when !== 'string') {
89-
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));
90-
return false;
91-
}
92-
if (item.group && typeof item.group !== 'string') {
93-
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group'));
94-
return false;
106+
return true;
107+
}
108+
109+
export function isValidSubmenuItem(item: IUserFriendlySubmenuItem, collector: ExtensionMessageCollector): boolean {
110+
if (typeof item.submenu !== 'string') {
111+
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'submenu'));
112+
return false;
113+
}
114+
if (item.when && typeof item.when !== 'string') {
115+
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));
116+
return false;
117+
}
118+
if (item.group && typeof item.group !== 'string') {
119+
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group'));
120+
return false;
121+
}
122+
123+
return true;
124+
}
125+
126+
export function isValidItems(items: (IUserFriendlyMenuItem | IUserFriendlySubmenuItem)[], collector: ExtensionMessageCollector): boolean {
127+
if (!Array.isArray(items)) {
128+
collector.error(localize('requirearray', "submenu items must be an array"));
129+
return false;
130+
}
131+
132+
for (let item of items) {
133+
if (isMenuItem(item)) {
134+
if (!isValidMenuItem(item, collector)) {
135+
return false;
136+
}
137+
} else {
138+
if (!isValidSubmenuItem(item, collector)) {
139+
return false;
140+
}
95141
}
96142
}
97143

98144
return true;
99145
}
100146

147+
export function isValidSubmenu(submenu: IUserFriendlySubmenu, collector: ExtensionMessageCollector): boolean {
148+
if (typeof submenu !== 'object') {
149+
collector.error(localize('require', "submenu items must be an object"));
150+
return false;
151+
}
152+
153+
if (typeof submenu.id !== 'string') {
154+
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'id'));
155+
return false;
156+
}
157+
if (typeof submenu.label !== 'string') {
158+
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'label'));
159+
return false;
160+
}
161+
162+
return true;
163+
}
164+
101165
const menuItem: IJSONSchema = {
102166
type: 'object',
103167
properties: {
@@ -120,6 +184,38 @@ namespace schema {
120184
}
121185
};
122186

187+
const submenuItem: IJSONSchema = {
188+
type: 'object',
189+
properties: {
190+
submenu: {
191+
description: localize('vscode.extension.contributes.menuItem.submenu', 'Identifier of the submenu to display in this item.'),
192+
type: 'string'
193+
},
194+
when: {
195+
description: localize('vscode.extension.contributes.menuItem.when', 'Condition which must be true to show this item'),
196+
type: 'string'
197+
},
198+
group: {
199+
description: localize('vscode.extension.contributes.menuItem.group', 'Group into which this command belongs'),
200+
type: 'string'
201+
}
202+
}
203+
};
204+
205+
const submenu: IJSONSchema = {
206+
type: 'object',
207+
properties: {
208+
id: {
209+
description: localize('submenu.id', 'Identifier of the menu to display as a submenu.'),
210+
type: 'string'
211+
},
212+
label: {
213+
description: localize('submenu.label', 'The label of the menu item which leads to this submenu.'),
214+
type: 'string'
215+
}
216+
}
217+
};
218+
123219
export const menusContribution: IJSONSchema = {
124220
description: localize('vscode.extension.contributes.menus', "Contributes menu items to the editor"),
125221
type: 'object',
@@ -142,7 +238,7 @@ namespace schema {
142238
'editor/context': {
143239
description: localize('menus.editorContext', "The editor context menu"),
144240
type: 'array',
145-
items: menuItem
241+
items: [menuItem, submenuItem]
146242
},
147243
'explorer/context': {
148244
description: localize('menus.explorerContext', "The file explorer context menu"),
@@ -252,6 +348,12 @@ namespace schema {
252348
}
253349
};
254350

351+
export const submenusContribution: IJSONSchema = {
352+
description: localize('vscode.extension.contributes.submenus', "Contributes submenu items to the editor"),
353+
type: 'array',
354+
items: submenu
355+
};
356+
255357
// --- commands contribution point
256358

257359
export interface IUserFriendlyCommand {
@@ -430,74 +532,147 @@ commandsExtensionPoint.setHandler(extensions => {
430532
_commandRegistrations.add(MenuRegistry.addCommands(newCommands));
431533
});
432534

535+
interface IRegisteredSubmenu {
536+
readonly id: MenuId;
537+
readonly label: string;
538+
}
539+
540+
const _submenus = new Map<string, IRegisteredSubmenu>();
541+
542+
const submenusExtensionPoint = ExtensionsRegistry.registerExtensionPoint<schema.IUserFriendlySubmenu[]>({
543+
extensionPoint: 'submenus',
544+
jsonSchema: schema.submenusContribution
545+
});
546+
547+
submenusExtensionPoint.setHandler(extensions => {
548+
549+
_submenus.clear();
550+
551+
for (let extension of extensions) {
552+
const { value, collector } = extension;
553+
554+
forEach(value, entry => {
555+
if (!schema.isValidSubmenu(entry.value, collector)) {
556+
return;
557+
}
558+
559+
if (!entry.value.id) {
560+
collector.warn(localize('submenuId.invalid.id', "`{0}` is not a valid submenu identifier", entry.value.id));
561+
return;
562+
}
563+
if (!entry.value.label) {
564+
collector.warn(localize('submenuId.invalid.label', "`{0}` is not a valid submenu label", entry.value.label));
565+
return;
566+
}
567+
568+
if (!extension.description.enableProposedApi) {
569+
collector.error(localize('submenu.proposedAPI.invalid', "Submenus are proposed API and are only available when running out of dev or with the following command line switch: --enable-proposed-api {1}", extension.description.identifier.value));
570+
return;
571+
}
572+
573+
const item: IRegisteredSubmenu = {
574+
id: new MenuId(`api:${entry.value.id}`),
575+
label: entry.value.label
576+
};
577+
578+
_submenus.set(entry.value.id, item);
579+
});
580+
}
581+
});
582+
433583
const _menuRegistrations = new DisposableStore();
434584

435-
ExtensionsRegistry.registerExtensionPoint<{ [loc: string]: schema.IUserFriendlyMenuItem[] }>({
585+
const menusExtensionPoint = ExtensionsRegistry.registerExtensionPoint<{ [loc: string]: (schema.IUserFriendlyMenuItem | schema.IUserFriendlySubmenuItem)[] }>({
436586
extensionPoint: 'menus',
437-
jsonSchema: schema.menusContribution
438-
}).setHandler(extensions => {
587+
jsonSchema: schema.menusContribution,
588+
deps: [submenusExtensionPoint]
589+
});
590+
591+
menusExtensionPoint.setHandler(extensions => {
439592

440593
// remove all previous menu registrations
441594
_menuRegistrations.clear();
442595

443-
const items: { id: MenuId, item: IMenuItem }[] = [];
596+
const items: { id: MenuId, item: IMenuItem | ISubmenuItem }[] = [];
444597

445598
for (let extension of extensions) {
446599
const { value, collector } = extension;
447600

448601
forEach(value, entry => {
449-
if (!schema.isValidMenuItems(entry.value, collector)) {
602+
if (!schema.isValidItems(entry.value, collector)) {
450603
return;
451604
}
452605

453-
const menu = schema.parseMenuId(entry.key);
454-
if (typeof menu === 'undefined') {
606+
let id = schema.parseMenuId(entry.key);
607+
let isSubmenu = false;
608+
609+
if (!id) {
610+
id = _submenus.get(entry.key)?.id;
611+
isSubmenu = true;
612+
}
613+
614+
if (!id) {
455615
collector.warn(localize('menuId.invalid', "`{0}` is not a valid menu identifier", entry.key));
456616
return;
457617
}
458618

459-
if (schema.isProposedAPI(menu) && !extension.description.enableProposedApi) {
619+
if (schema.isProposedAPI(id) && !extension.description.enableProposedApi) {
460620
collector.error(localize('proposedAPI.invalid', "{0} is a proposed menu identifier and is only available when running out of dev or with the following command line switch: --enable-proposed-api {1}", entry.key, extension.description.identifier.value));
461621
return;
462622
}
463623

464-
for (let item of entry.value) {
465-
let command = MenuRegistry.getCommand(item.command);
466-
let alt = item.alt && MenuRegistry.getCommand(item.alt) || undefined;
624+
if (isSubmenu && !extension.description.enableProposedApi) {
625+
collector.error(localize('proposedAPI.invalid.submenu', "{0} is a submenu identifier and is only available when running out of dev or with the following command line switch: --enable-proposed-api {1}", entry.key, extension.description.identifier.value));
626+
return;
627+
}
628+
629+
for (const menuItem of entry.value) {
630+
let item: IMenuItem | ISubmenuItem;
467631

468-
if (!command) {
469-
collector.error(localize('missing.command', "Menu item references a command `{0}` which is not defined in the 'commands' section.", item.command));
470-
continue;
471-
}
472-
if (item.alt && !alt) {
473-
collector.warn(localize('missing.altCommand', "Menu item references an alt-command `{0}` which is not defined in the 'commands' section.", item.alt));
474-
}
475-
if (item.command === item.alt) {
476-
collector.info(localize('dupe.command', "Menu item references the same command as default and alt-command"));
632+
if (schema.isMenuItem(menuItem)) {
633+
const command = MenuRegistry.getCommand(menuItem.command);
634+
const alt = menuItem.alt && MenuRegistry.getCommand(menuItem.alt) || undefined;
635+
636+
if (!command) {
637+
collector.error(localize('missing.command', "Menu item references a command `{0}` which is not defined in the 'commands' section.", menuItem.command));
638+
continue;
639+
}
640+
if (menuItem.alt && !alt) {
641+
collector.warn(localize('missing.altCommand', "Menu item references an alt-command `{0}` which is not defined in the 'commands' section.", menuItem.alt));
642+
}
643+
if (menuItem.command === menuItem.alt) {
644+
collector.info(localize('dupe.command', "Menu item references the same command as default and alt-command"));
645+
}
646+
647+
item = { command, alt, group: undefined, order: undefined, when: undefined };
648+
} else {
649+
if (!extension.description.enableProposedApi) {
650+
collector.error(localize('proposedAPI.invalid.submenureference', "Menu item references a submenu which is only available when running out of dev or with the following command line switch: --enable-proposed-api {1}", entry.key, extension.description.identifier.value));
651+
continue;
652+
}
653+
654+
const submenu = _submenus.get(menuItem.submenu);
655+
656+
if (!submenu) {
657+
collector.error(localize('missing.submenu', "Menu item references a submenu `{0}` which is not defined in the 'submenus' section.", menuItem.submenu));
658+
continue;
659+
}
660+
661+
item = { submenu: submenu.id, title: submenu.label, group: undefined, order: undefined, when: undefined };
477662
}
478663

479-
let group: string | undefined;
480-
let order: number | undefined;
481-
if (item.group) {
482-
const idx = item.group.lastIndexOf('@');
664+
if (menuItem.group) {
665+
const idx = menuItem.group.lastIndexOf('@');
483666
if (idx > 0) {
484-
group = item.group.substr(0, idx);
485-
order = Number(item.group.substr(idx + 1)) || undefined;
667+
item.group = menuItem.group.substr(0, idx);
668+
item.order = Number(menuItem.group.substr(idx + 1)) || undefined;
486669
} else {
487-
group = item.group;
670+
item.group = menuItem.group;
488671
}
489672
}
490673

491-
items.push({
492-
id: menu,
493-
item: {
494-
command,
495-
alt,
496-
group,
497-
order,
498-
when: ContextKeyExpr.deserialize(item.when)
499-
}
500-
});
674+
item.when = ContextKeyExpr.deserialize(menuItem.when);
675+
items.push({ id, item });
501676
}
502677
});
503678
}

0 commit comments

Comments
 (0)