@@ -10,14 +10,14 @@ import { IJSONSchema } from 'vs/base/common/jsonSchema';
1010import { forEach } from 'vs/base/common/collections' ;
1111import { IExtensionPointUser , ExtensionMessageCollector , ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry' ;
1212import { 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' ;
1414import { URI } from 'vs/base/common/uri' ;
1515import { DisposableStore } from 'vs/base/common/lifecycle' ;
1616import { ThemeIcon } from 'vs/platform/theme/common/themeService' ;
1717
1818namespace 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+
433583const _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