Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/services/actions/common/menusExtensionPoint.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { localize } from '../../../../nls.js';
7
import { isFalsyOrWhitespace } from '../../../../base/common/strings.js';
8
import * as resources from '../../../../base/common/resources.js';
9
import { IJSONSchema } from '../../../../base/common/jsonSchema.js';
10
import { IExtensionPointUser, ExtensionMessageCollector, ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.js';
11
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
12
import { MenuId, MenuRegistry, IMenuItem, ISubmenuItem } from '../../../../platform/actions/common/actions.js';
13
import { URI } from '../../../../base/common/uri.js';
14
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
15
import { ThemeIcon } from '../../../../base/common/themables.js';
16
import { index } from '../../../../base/common/arrays.js';
17
import { isProposedApiEnabled } from '../../extensions/common/extensions.js';
18
import { ILocalizedString } from '../../../../platform/action/common/action.js';
19
import { IExtensionFeatureTableRenderer, IExtensionFeaturesRegistry, IRenderedData, IRowData, ITableData, Extensions as ExtensionFeaturesExtensions } from '../../extensionManagement/common/extensionFeatures.js';
20
import { IExtensionManifest, IKeyBinding } from '../../../../platform/extensions/common/extensions.js';
21
import { Registry } from '../../../../platform/registry/common/platform.js';
22
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
23
import { platform } from '../../../../base/common/process.js';
24
import { MarkdownString } from '../../../../base/common/htmlContent.js';
25
import { ResolvedKeybinding } from '../../../../base/common/keybindings.js';
26
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
27
import { ApiProposalName } from '../../../../platform/extensions/common/extensionsApiProposals.js';
28
29
interface IAPIMenu {
30
readonly key: string;
31
readonly id: MenuId;
32
readonly description: string;
33
readonly proposed?: ApiProposalName;
34
readonly supportsSubmenus?: boolean; // defaults to true
35
}
36
37
const apiMenus: IAPIMenu[] = [
38
{
39
key: 'commandPalette',
40
id: MenuId.CommandPalette,
41
description: localize('menus.commandPalette', "The Command Palette"),
42
supportsSubmenus: false
43
},
44
{
45
key: 'touchBar',
46
id: MenuId.TouchBarContext,
47
description: localize('menus.touchBar', "The touch bar (macOS only)"),
48
supportsSubmenus: false
49
},
50
{
51
key: 'editor/title',
52
id: MenuId.EditorTitle,
53
description: localize('menus.editorTitle', "The editor title menu")
54
},
55
{
56
key: 'editor/title/run',
57
id: MenuId.EditorTitleRun,
58
description: localize('menus.editorTitleRun', "Run submenu inside the editor title menu")
59
},
60
{
61
key: 'editor/context',
62
id: MenuId.EditorContext,
63
description: localize('menus.editorContext', "The editor context menu")
64
},
65
{
66
key: 'editor/context/copy',
67
id: MenuId.EditorContextCopy,
68
description: localize('menus.editorContextCopyAs', "'Copy as' submenu in the editor context menu")
69
},
70
{
71
key: 'editor/context/share',
72
id: MenuId.EditorContextShare,
73
description: localize('menus.editorContextShare', "'Share' submenu in the editor context menu"),
74
proposed: 'contribShareMenu'
75
},
76
{
77
key: 'explorer/context',
78
id: MenuId.ExplorerContext,
79
description: localize('menus.explorerContext', "The file explorer context menu")
80
},
81
{
82
key: 'explorer/context/share',
83
id: MenuId.ExplorerContextShare,
84
description: localize('menus.explorerContextShare', "'Share' submenu in the file explorer context menu"),
85
proposed: 'contribShareMenu'
86
},
87
{
88
key: 'editor/title/context',
89
id: MenuId.EditorTitleContext,
90
description: localize('menus.editorTabContext', "The editor tabs context menu")
91
},
92
{
93
key: 'editor/title/context/share',
94
id: MenuId.EditorTitleContextShare,
95
description: localize('menus.editorTitleContextShare', "'Share' submenu inside the editor title context menu"),
96
proposed: 'contribShareMenu'
97
},
98
{
99
key: 'debug/callstack/context',
100
id: MenuId.DebugCallStackContext,
101
description: localize('menus.debugCallstackContext', "The debug callstack view context menu")
102
},
103
{
104
key: 'debug/variables/context',
105
id: MenuId.DebugVariablesContext,
106
description: localize('menus.debugVariablesContext', "The debug variables view context menu")
107
},
108
{
109
key: 'debug/watch/context',
110
id: MenuId.DebugWatchContext,
111
description: localize('menus.debugWatchContext', "The debug watch view context menu")
112
},
113
{
114
key: 'debug/toolBar',
115
id: MenuId.DebugToolBar,
116
description: localize('menus.debugToolBar', "The debug toolbar menu")
117
},
118
{
119
key: 'debug/createConfiguration',
120
id: MenuId.DebugCreateConfiguration,
121
proposed: 'contribDebugCreateConfiguration',
122
description: localize('menus.debugCreateConfiguation', "The debug create configuration menu")
123
},
124
{
125
key: 'notebook/variables/context',
126
id: MenuId.NotebookVariablesContext,
127
description: localize('menus.notebookVariablesContext', "The notebook variables view context menu")
128
},
129
{
130
key: 'menuBar/home',
131
id: MenuId.MenubarHomeMenu,
132
description: localize('menus.home', "The home indicator context menu (web only)"),
133
proposed: 'contribMenuBarHome',
134
supportsSubmenus: false
135
},
136
{
137
key: 'menuBar/edit/copy',
138
id: MenuId.MenubarCopy,
139
description: localize('menus.opy', "'Copy as' submenu in the top level Edit menu")
140
},
141
{
142
key: 'scm/title',
143
id: MenuId.SCMTitle,
144
description: localize('menus.scmTitle', "The Source Control title menu")
145
},
146
{
147
key: 'scm/sourceControl',
148
id: MenuId.SCMSourceControl,
149
description: localize('menus.scmSourceControl', "The Source Control menu")
150
},
151
{
152
key: 'scm/sourceControl/title',
153
id: MenuId.SCMSourceControlTitle,
154
description: localize('menus.scmSourceControlTitle', "The Source Control title menu"),
155
proposed: 'contribSourceControlTitleMenu'
156
},
157
{
158
key: 'scm/resourceState/context',
159
id: MenuId.SCMResourceContext,
160
description: localize('menus.resourceStateContext', "The Source Control resource state context menu")
161
},
162
{
163
key: 'scm/resourceFolder/context',
164
id: MenuId.SCMResourceFolderContext,
165
description: localize('menus.resourceFolderContext', "The Source Control resource folder context menu")
166
},
167
{
168
key: 'scm/resourceGroup/context',
169
id: MenuId.SCMResourceGroupContext,
170
description: localize('menus.resourceGroupContext', "The Source Control resource group context menu")
171
},
172
{
173
key: 'scm/change/title',
174
id: MenuId.SCMChangeContext,
175
description: localize('menus.changeTitle', "The Source Control inline change menu")
176
},
177
{
178
key: 'scm/inputBox',
179
id: MenuId.SCMInputBox,
180
description: localize('menus.input', "The Source Control input box menu"),
181
proposed: 'contribSourceControlInputBoxMenu'
182
},
183
{
184
key: 'scm/history/title',
185
id: MenuId.SCMHistoryTitle,
186
description: localize('menus.scmHistoryTitle', "The Source Control History title menu"),
187
proposed: 'contribSourceControlHistoryTitleMenu'
188
},
189
{
190
key: 'scm/historyItem/context',
191
id: MenuId.SCMHistoryItemContext,
192
description: localize('menus.historyItemContext', "The Source Control history item context menu"),
193
proposed: 'contribSourceControlHistoryItemMenu'
194
},
195
{
196
key: 'scm/historyItem/hover',
197
id: MenuId.SCMHistoryItemHover,
198
description: localize('menus.historyItemHover', "The Source Control history item hover menu"),
199
proposed: 'contribSourceControlHistoryItemMenu'
200
},
201
{
202
key: 'scm/historyItemRef/context',
203
id: MenuId.SCMHistoryItemRefContext,
204
description: localize('menus.historyItemRefContext', "The Source Control history item reference context menu"),
205
proposed: 'contribSourceControlHistoryItemMenu'
206
},
207
{
208
key: 'statusBar/remoteIndicator',
209
id: MenuId.StatusBarRemoteIndicatorMenu,
210
description: localize('menus.statusBarRemoteIndicator', "The remote indicator menu in the status bar"),
211
supportsSubmenus: false
212
},
213
{
214
key: 'terminal/context',
215
id: MenuId.TerminalInstanceContext,
216
description: localize('menus.terminalContext', "The terminal context menu")
217
},
218
{
219
key: 'terminal/title/context',
220
id: MenuId.TerminalTabContext,
221
description: localize('menus.terminalTabContext', "The terminal tabs context menu")
222
},
223
{
224
key: 'view/title',
225
id: MenuId.ViewTitle,
226
description: localize('view.viewTitle', "The contributed view title menu")
227
},
228
{
229
key: 'viewContainer/title',
230
id: MenuId.ViewContainerTitle,
231
description: localize('view.containerTitle', "The contributed view container title menu"),
232
proposed: 'contribViewContainerTitle'
233
},
234
{
235
key: 'view/item/context',
236
id: MenuId.ViewItemContext,
237
description: localize('view.itemContext', "The contributed view item context menu")
238
},
239
{
240
key: 'comments/comment/editorActions',
241
id: MenuId.CommentEditorActions,
242
description: localize('commentThread.editorActions', "The contributed comment editor actions"),
243
proposed: 'contribCommentEditorActionsMenu'
244
},
245
{
246
key: 'comments/commentThread/title',
247
id: MenuId.CommentThreadTitle,
248
description: localize('commentThread.title', "The contributed comment thread title menu")
249
},
250
{
251
key: 'comments/commentThread/context',
252
id: MenuId.CommentThreadActions,
253
description: localize('commentThread.actions', "The contributed comment thread context menu, rendered as buttons below the comment editor"),
254
supportsSubmenus: false
255
},
256
{
257
key: 'comments/commentThread/additionalActions',
258
id: MenuId.CommentThreadAdditionalActions,
259
description: localize('commentThread.actions', "The contributed comment thread context menu, rendered as buttons below the comment editor"),
260
supportsSubmenus: true,
261
proposed: 'contribCommentThreadAdditionalMenu'
262
},
263
{
264
key: 'comments/commentThread/title/context',
265
id: MenuId.CommentThreadTitleContext,
266
description: localize('commentThread.titleContext', "The contributed comment thread title's peek context menu, rendered as a right click menu on the comment thread's peek title."),
267
proposed: 'contribCommentPeekContext'
268
},
269
{
270
key: 'comments/comment/title',
271
id: MenuId.CommentTitle,
272
description: localize('comment.title', "The contributed comment title menu")
273
},
274
{
275
key: 'comments/comment/context',
276
id: MenuId.CommentActions,
277
description: localize('comment.actions', "The contributed comment context menu, rendered as buttons below the comment editor"),
278
supportsSubmenus: false
279
},
280
{
281
key: 'comments/commentThread/comment/context',
282
id: MenuId.CommentThreadCommentContext,
283
description: localize('comment.commentContext', "The contributed comment context menu, rendered as a right click menu on the an individual comment in the comment thread's peek view."),
284
proposed: 'contribCommentPeekContext'
285
},
286
{
287
key: 'commentsView/commentThread/context',
288
id: MenuId.CommentsViewThreadActions,
289
description: localize('commentsView.threadActions', "The contributed comment thread context menu in the comments view"),
290
proposed: 'contribCommentsViewThreadMenus'
291
},
292
{
293
key: 'notebook/toolbar',
294
id: MenuId.NotebookToolbar,
295
description: localize('notebook.toolbar', "The contributed notebook toolbar menu")
296
},
297
{
298
key: 'notebook/kernelSource',
299
id: MenuId.NotebookKernelSource,
300
description: localize('notebook.kernelSource', "The contributed notebook kernel sources menu"),
301
proposed: 'notebookKernelSource'
302
},
303
{
304
key: 'notebook/cell/title',
305
id: MenuId.NotebookCellTitle,
306
description: localize('notebook.cell.title', "The contributed notebook cell title menu")
307
},
308
{
309
key: 'notebook/cell/execute',
310
id: MenuId.NotebookCellExecute,
311
description: localize('notebook.cell.execute', "The contributed notebook cell execution menu")
312
},
313
{
314
key: 'interactive/toolbar',
315
id: MenuId.InteractiveToolbar,
316
description: localize('interactive.toolbar', "The contributed interactive toolbar menu"),
317
},
318
{
319
key: 'interactive/cell/title',
320
id: MenuId.InteractiveCellTitle,
321
description: localize('interactive.cell.title', "The contributed interactive cell title menu"),
322
},
323
{
324
key: 'issue/reporter',
325
id: MenuId.IssueReporter,
326
description: localize('issue.reporter', "The contributed issue reporter menu")
327
},
328
{
329
key: 'testing/item/context',
330
id: MenuId.TestItem,
331
description: localize('testing.item.context', "The contributed test item menu"),
332
},
333
{
334
key: 'testing/item/gutter',
335
id: MenuId.TestItemGutter,
336
description: localize('testing.item.gutter.title', "The menu for a gutter decoration for a test item"),
337
},
338
{
339
key: 'testing/profiles/context',
340
id: MenuId.TestProfilesContext,
341
description: localize('testing.profiles.context.title', "The menu for configuring testing profiles."),
342
},
343
{
344
key: 'testing/item/result',
345
id: MenuId.TestPeekElement,
346
description: localize('testing.item.result.title', "The menu for an item in the Test Results view or peek."),
347
},
348
{
349
key: 'testing/message/context',
350
id: MenuId.TestMessageContext,
351
description: localize('testing.message.context.title', "A prominent button overlaying editor content where the message is displayed"),
352
},
353
{
354
key: 'testing/message/content',
355
id: MenuId.TestMessageContent,
356
description: localize('testing.message.content.title', "Context menu for the message in the results tree"),
357
},
358
{
359
key: 'extension/context',
360
id: MenuId.ExtensionContext,
361
description: localize('menus.extensionContext', "The extension context menu")
362
},
363
{
364
key: 'timeline/title',
365
id: MenuId.TimelineTitle,
366
description: localize('view.timelineTitle', "The Timeline view title menu")
367
},
368
{
369
key: 'timeline/item/context',
370
id: MenuId.TimelineItemContext,
371
description: localize('view.timelineContext', "The Timeline view item context menu")
372
},
373
{
374
key: 'ports/item/context',
375
id: MenuId.TunnelContext,
376
description: localize('view.tunnelContext', "The Ports view item context menu")
377
},
378
{
379
key: 'ports/item/origin/inline',
380
id: MenuId.TunnelOriginInline,
381
description: localize('view.tunnelOriginInline', "The Ports view item origin inline menu")
382
},
383
{
384
key: 'ports/item/port/inline',
385
id: MenuId.TunnelPortInline,
386
description: localize('view.tunnelPortInline', "The Ports view item port inline menu")
387
},
388
{
389
key: 'file/newFile',
390
id: MenuId.NewFile,
391
description: localize('file.newFile', "The 'New File...' quick pick, shown on welcome page and File menu."),
392
supportsSubmenus: false,
393
},
394
{
395
key: 'webview/context',
396
id: MenuId.WebviewContext,
397
description: localize('webview.context', "The webview context menu")
398
},
399
{
400
key: 'file/share',
401
id: MenuId.MenubarShare,
402
description: localize('menus.share', "Share submenu shown in the top level File menu."),
403
proposed: 'contribShareMenu'
404
},
405
{
406
key: 'editor/inlineCompletions/actions',
407
id: MenuId.InlineCompletionsActions,
408
description: localize('inlineCompletions.actions', "The actions shown when hovering on an inline completion"),
409
supportsSubmenus: false,
410
proposed: 'inlineCompletionsAdditions'
411
},
412
{
413
key: 'editor/content',
414
id: MenuId.EditorContent,
415
description: localize('merge.toolbar', "The prominent button in an editor, overlays its content"),
416
proposed: 'contribEditorContentMenu'
417
},
418
{
419
key: 'editor/lineNumber/context',
420
id: MenuId.EditorLineNumberContext,
421
description: localize('editorLineNumberContext', "The contributed editor line number context menu")
422
},
423
{
424
key: 'mergeEditor/result/title',
425
id: MenuId.MergeInputResultToolbar,
426
description: localize('menus.mergeEditorResult', "The result toolbar of the merge editor"),
427
proposed: 'contribMergeEditorMenus'
428
},
429
{
430
key: 'multiDiffEditor/resource/title',
431
id: MenuId.MultiDiffEditorFileToolbar,
432
description: localize('menus.multiDiffEditorResource', "The resource toolbar in the multi diff editor"),
433
proposed: 'contribMultiDiffEditorMenus'
434
},
435
{
436
key: 'diffEditor/gutter/hunk',
437
id: MenuId.DiffEditorHunkToolbar,
438
description: localize('menus.diffEditorGutterToolBarMenus', "The gutter toolbar in the diff editor"),
439
proposed: 'contribDiffEditorGutterToolBarMenus'
440
},
441
{
442
key: 'diffEditor/gutter/selection',
443
id: MenuId.DiffEditorSelectionToolbar,
444
description: localize('menus.diffEditorGutterToolBarMenus', "The gutter toolbar in the diff editor"),
445
proposed: 'contribDiffEditorGutterToolBarMenus'
446
},
447
{
448
key: 'searchPanel/aiResults/commands',
449
id: MenuId.SearchActionMenu,
450
description: localize('searchPanel.aiResultsCommands', "The commands that will contribute to the menu rendered as buttons next to the AI search title"),
451
},
452
{
453
key: 'editor/context/chat',
454
id: MenuId.ChatTextEditorMenu,
455
description: localize('menus.chatTextEditor', "The Chat submenu in the text editor context menu."),
456
supportsSubmenus: false,
457
proposed: 'chatParticipantPrivate'
458
},
459
{
460
key: 'chat/chatSessions',
461
id: MenuId.ChatSessionsMenu,
462
description: localize('menus.chatSessions', "The Chat Sessions menu."),
463
supportsSubmenus: false,
464
proposed: 'chatSessionsProvider'
465
},
466
{
467
key: 'chat/multiDiff/context',
468
id: MenuId.ChatMultiDiffContext,
469
description: localize('menus.chatMultiDiffContext', "The Chat Multi-Diff context menu."),
470
supportsSubmenus: false,
471
proposed: 'chatSessionsProvider',
472
},
473
];
474
475
namespace schema {
476
477
// --- menus, submenus contribution point
478
479
export interface IUserFriendlyMenuItem {
480
command: string;
481
alt?: string;
482
when?: string;
483
group?: string;
484
}
485
486
export interface IUserFriendlySubmenuItem {
487
submenu: string;
488
when?: string;
489
group?: string;
490
}
491
492
export interface IUserFriendlySubmenu {
493
id: string;
494
label: string;
495
icon?: IUserFriendlyIcon;
496
}
497
498
export function isMenuItem(item: IUserFriendlyMenuItem | IUserFriendlySubmenuItem): item is IUserFriendlyMenuItem {
499
return typeof (item as IUserFriendlyMenuItem).command === 'string';
500
}
501
502
export function isValidMenuItem(item: IUserFriendlyMenuItem, collector: ExtensionMessageCollector): boolean {
503
if (typeof item.command !== 'string') {
504
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));
505
return false;
506
}
507
if (item.alt && typeof item.alt !== 'string') {
508
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'alt'));
509
return false;
510
}
511
if (item.when && typeof item.when !== 'string') {
512
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));
513
return false;
514
}
515
if (item.group && typeof item.group !== 'string') {
516
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group'));
517
return false;
518
}
519
520
return true;
521
}
522
523
export function isValidSubmenuItem(item: IUserFriendlySubmenuItem, collector: ExtensionMessageCollector): boolean {
524
if (typeof item.submenu !== 'string') {
525
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'submenu'));
526
return false;
527
}
528
if (item.when && typeof item.when !== 'string') {
529
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));
530
return false;
531
}
532
if (item.group && typeof item.group !== 'string') {
533
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group'));
534
return false;
535
}
536
537
return true;
538
}
539
540
export function isValidItems(items: (IUserFriendlyMenuItem | IUserFriendlySubmenuItem)[], collector: ExtensionMessageCollector): boolean {
541
if (!Array.isArray(items)) {
542
collector.error(localize('requirearray', "submenu items must be an array"));
543
return false;
544
}
545
546
for (const item of items) {
547
if (isMenuItem(item)) {
548
if (!isValidMenuItem(item, collector)) {
549
return false;
550
}
551
} else {
552
if (!isValidSubmenuItem(item, collector)) {
553
return false;
554
}
555
}
556
}
557
558
return true;
559
}
560
561
export function isValidSubmenu(submenu: IUserFriendlySubmenu, collector: ExtensionMessageCollector): boolean {
562
if (typeof submenu !== 'object') {
563
collector.error(localize('require', "submenu items must be an object"));
564
return false;
565
}
566
567
if (typeof submenu.id !== 'string') {
568
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'id'));
569
return false;
570
}
571
if (typeof submenu.label !== 'string') {
572
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'label'));
573
return false;
574
}
575
576
return true;
577
}
578
579
const menuItem: IJSONSchema = {
580
type: 'object',
581
required: ['command'],
582
properties: {
583
command: {
584
description: localize('vscode.extension.contributes.menuItem.command', 'Identifier of the command to execute. The command must be declared in the \'commands\'-section'),
585
type: 'string'
586
},
587
alt: {
588
description: localize('vscode.extension.contributes.menuItem.alt', 'Identifier of an alternative command to execute. The command must be declared in the \'commands\'-section'),
589
type: 'string'
590
},
591
when: {
592
description: localize('vscode.extension.contributes.menuItem.when', 'Condition which must be true to show this item'),
593
type: 'string'
594
},
595
group: {
596
description: localize('vscode.extension.contributes.menuItem.group', 'Group into which this item belongs'),
597
type: 'string'
598
}
599
}
600
};
601
602
const submenuItem: IJSONSchema = {
603
type: 'object',
604
required: ['submenu'],
605
properties: {
606
submenu: {
607
description: localize('vscode.extension.contributes.menuItem.submenu', 'Identifier of the submenu to display in this item.'),
608
type: 'string'
609
},
610
when: {
611
description: localize('vscode.extension.contributes.menuItem.when', 'Condition which must be true to show this item'),
612
type: 'string'
613
},
614
group: {
615
description: localize('vscode.extension.contributes.menuItem.group', 'Group into which this item belongs'),
616
type: 'string'
617
}
618
}
619
};
620
621
const submenu: IJSONSchema = {
622
type: 'object',
623
required: ['id', 'label'],
624
properties: {
625
id: {
626
description: localize('vscode.extension.contributes.submenu.id', 'Identifier of the menu to display as a submenu.'),
627
type: 'string'
628
},
629
label: {
630
description: localize('vscode.extension.contributes.submenu.label', 'The label of the menu item which leads to this submenu.'),
631
type: 'string'
632
},
633
icon: {
634
description: localize({ key: 'vscode.extension.contributes.submenu.icon', comment: ['do not translate or change "\\$(zap)", \\ in front of $ is important.'] }, '(Optional) Icon which is used to represent the submenu in the UI. Either a file path, an object with file paths for dark and light themes, or a theme icon references, like "\\$(zap)"'),
635
anyOf: [{
636
type: 'string'
637
},
638
{
639
type: 'object',
640
properties: {
641
light: {
642
description: localize('vscode.extension.contributes.submenu.icon.light', 'Icon path when a light theme is used'),
643
type: 'string'
644
},
645
dark: {
646
description: localize('vscode.extension.contributes.submenu.icon.dark', 'Icon path when a dark theme is used'),
647
type: 'string'
648
}
649
}
650
}]
651
}
652
}
653
};
654
655
export const menusContribution: IJSONSchema = {
656
description: localize('vscode.extension.contributes.menus', "Contributes menu items to the editor"),
657
type: 'object',
658
properties: index(apiMenus, menu => menu.key, menu => ({
659
markdownDescription: menu.proposed ? localize('proposed', "Proposed API, requires `enabledApiProposal: [\"{0}\"]` - {1}", menu.proposed, menu.description) : menu.description,
660
type: 'array',
661
items: menu.supportsSubmenus === false ? menuItem : { oneOf: [menuItem, submenuItem] }
662
})),
663
additionalProperties: {
664
description: 'Submenu',
665
type: 'array',
666
items: { oneOf: [menuItem, submenuItem] }
667
}
668
};
669
670
export const submenusContribution: IJSONSchema = {
671
description: localize('vscode.extension.contributes.submenus', "Contributes submenu items to the editor"),
672
type: 'array',
673
items: submenu
674
};
675
676
// --- commands contribution point
677
678
export interface IUserFriendlyCommand {
679
command: string;
680
title: string | ILocalizedString;
681
shortTitle?: string | ILocalizedString;
682
enablement?: string;
683
category?: string | ILocalizedString;
684
icon?: IUserFriendlyIcon;
685
}
686
687
export type IUserFriendlyIcon = string | { light: string; dark: string };
688
689
export function isValidCommand(command: IUserFriendlyCommand, collector: ExtensionMessageCollector): boolean {
690
if (!command) {
691
collector.error(localize('nonempty', "expected non-empty value."));
692
return false;
693
}
694
if (isFalsyOrWhitespace(command.command)) {
695
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));
696
return false;
697
}
698
if (!isValidLocalizedString(command.title, collector, 'title')) {
699
return false;
700
}
701
if (command.shortTitle && !isValidLocalizedString(command.shortTitle, collector, 'shortTitle')) {
702
return false;
703
}
704
if (command.enablement && typeof command.enablement !== 'string') {
705
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'precondition'));
706
return false;
707
}
708
if (command.category && !isValidLocalizedString(command.category, collector, 'category')) {
709
return false;
710
}
711
if (!isValidIcon(command.icon, collector)) {
712
return false;
713
}
714
return true;
715
}
716
717
function isValidIcon(icon: IUserFriendlyIcon | undefined, collector: ExtensionMessageCollector): boolean {
718
if (typeof icon === 'undefined') {
719
return true;
720
}
721
if (typeof icon === 'string') {
722
return true;
723
} else if (typeof icon.dark === 'string' && typeof icon.light === 'string') {
724
return true;
725
}
726
collector.error(localize('opticon', "property `icon` can be omitted or must be either a string or a literal like `{dark, light}`"));
727
return false;
728
}
729
730
function isValidLocalizedString(localized: string | ILocalizedString, collector: ExtensionMessageCollector, propertyName: string): boolean {
731
if (typeof localized === 'undefined') {
732
collector.error(localize('requireStringOrObject', "property `{0}` is mandatory and must be of type `string` or `object`", propertyName));
733
return false;
734
} else if (typeof localized === 'string' && isFalsyOrWhitespace(localized)) {
735
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", propertyName));
736
return false;
737
} else if (typeof localized !== 'string' && (isFalsyOrWhitespace(localized.original) || isFalsyOrWhitespace(localized.value))) {
738
collector.error(localize('requirestrings', "properties `{0}` and `{1}` are mandatory and must be of type `string`", `${propertyName}.value`, `${propertyName}.original`));
739
return false;
740
}
741
742
return true;
743
}
744
745
const commandType: IJSONSchema = {
746
type: 'object',
747
required: ['command', 'title'],
748
properties: {
749
command: {
750
description: localize('vscode.extension.contributes.commandType.command', 'Identifier of the command to execute'),
751
type: 'string'
752
},
753
title: {
754
description: localize('vscode.extension.contributes.commandType.title', 'Title by which the command is represented in the UI'),
755
type: 'string'
756
},
757
shortTitle: {
758
markdownDescription: localize('vscode.extension.contributes.commandType.shortTitle', '(Optional) Short title by which the command is represented in the UI. Menus pick either `title` or `shortTitle` depending on the context in which they show commands.'),
759
type: 'string'
760
},
761
category: {
762
description: localize('vscode.extension.contributes.commandType.category', '(Optional) Category string by which the command is grouped in the UI'),
763
type: 'string'
764
},
765
enablement: {
766
description: localize('vscode.extension.contributes.commandType.precondition', '(Optional) Condition which must be true to enable the command in the UI (menu and keybindings). Does not prevent executing the command by other means, like the `executeCommand`-api.'),
767
type: 'string'
768
},
769
icon: {
770
description: localize({ key: 'vscode.extension.contributes.commandType.icon', comment: ['do not translate or change "\\$(zap)", \\ in front of $ is important.'] }, '(Optional) Icon which is used to represent the command in the UI. Either a file path, an object with file paths for dark and light themes, or a theme icon references, like "\\$(zap)"'),
771
anyOf: [{
772
type: 'string'
773
},
774
{
775
type: 'object',
776
properties: {
777
light: {
778
description: localize('vscode.extension.contributes.commandType.icon.light', 'Icon path when a light theme is used'),
779
type: 'string'
780
},
781
dark: {
782
description: localize('vscode.extension.contributes.commandType.icon.dark', 'Icon path when a dark theme is used'),
783
type: 'string'
784
}
785
}
786
}]
787
}
788
}
789
};
790
791
export const commandsContribution: IJSONSchema = {
792
description: localize('vscode.extension.contributes.commands', "Contributes commands to the command palette."),
793
oneOf: [
794
commandType,
795
{
796
type: 'array',
797
items: commandType
798
}
799
]
800
};
801
}
802
803
const _commandRegistrations = new DisposableStore();
804
805
export const commandsExtensionPoint = ExtensionsRegistry.registerExtensionPoint<schema.IUserFriendlyCommand | schema.IUserFriendlyCommand[]>({
806
extensionPoint: 'commands',
807
jsonSchema: schema.commandsContribution,
808
activationEventsGenerator: (contribs: schema.IUserFriendlyCommand[], result: { push(item: string): void }) => {
809
for (const contrib of contribs) {
810
if (contrib.command) {
811
result.push(`onCommand:${contrib.command}`);
812
}
813
}
814
}
815
});
816
817
commandsExtensionPoint.setHandler(extensions => {
818
819
function handleCommand(userFriendlyCommand: schema.IUserFriendlyCommand, extension: IExtensionPointUser<any>) {
820
821
if (!schema.isValidCommand(userFriendlyCommand, extension.collector)) {
822
return;
823
}
824
825
const { icon, enablement, category, title, shortTitle, command } = userFriendlyCommand;
826
827
let absoluteIcon: { dark: URI; light?: URI } | ThemeIcon | undefined;
828
if (icon) {
829
if (typeof icon === 'string') {
830
absoluteIcon = ThemeIcon.fromString(icon) ?? { dark: resources.joinPath(extension.description.extensionLocation, icon), light: resources.joinPath(extension.description.extensionLocation, icon) };
831
832
} else {
833
absoluteIcon = {
834
dark: resources.joinPath(extension.description.extensionLocation, icon.dark),
835
light: resources.joinPath(extension.description.extensionLocation, icon.light)
836
};
837
}
838
}
839
840
const existingCmd = MenuRegistry.getCommand(command);
841
if (existingCmd) {
842
if (existingCmd.source) {
843
extension.collector.info(localize('dup1', "Command `{0}` already registered by {1} ({2})", userFriendlyCommand.command, existingCmd.source.title, existingCmd.source.id));
844
} else {
845
extension.collector.info(localize('dup0', "Command `{0}` already registered", userFriendlyCommand.command));
846
}
847
}
848
_commandRegistrations.add(MenuRegistry.addCommand({
849
id: command,
850
title,
851
source: { id: extension.description.identifier.value, title: extension.description.displayName ?? extension.description.name },
852
shortTitle,
853
tooltip: title,
854
category,
855
precondition: ContextKeyExpr.deserialize(enablement),
856
icon: absoluteIcon
857
}));
858
}
859
860
// remove all previous command registrations
861
_commandRegistrations.clear();
862
863
for (const extension of extensions) {
864
const { value } = extension;
865
if (Array.isArray(value)) {
866
for (const command of value) {
867
handleCommand(command, extension);
868
}
869
} else {
870
handleCommand(value, extension);
871
}
872
}
873
});
874
875
interface IRegisteredSubmenu {
876
readonly id: MenuId;
877
readonly label: string;
878
readonly icon?: { dark: URI; light?: URI } | ThemeIcon;
879
}
880
881
const _submenus = new Map<string, IRegisteredSubmenu>();
882
883
const submenusExtensionPoint = ExtensionsRegistry.registerExtensionPoint<schema.IUserFriendlySubmenu[]>({
884
extensionPoint: 'submenus',
885
jsonSchema: schema.submenusContribution
886
});
887
888
submenusExtensionPoint.setHandler(extensions => {
889
890
_submenus.clear();
891
892
for (const extension of extensions) {
893
const { value, collector } = extension;
894
895
for (const [, submenuInfo] of Object.entries(value)) {
896
897
if (!schema.isValidSubmenu(submenuInfo, collector)) {
898
continue;
899
}
900
901
if (!submenuInfo.id) {
902
collector.warn(localize('submenuId.invalid.id', "`{0}` is not a valid submenu identifier", submenuInfo.id));
903
continue;
904
}
905
if (_submenus.has(submenuInfo.id)) {
906
collector.info(localize('submenuId.duplicate.id', "The `{0}` submenu was already previously registered.", submenuInfo.id));
907
continue;
908
}
909
if (!submenuInfo.label) {
910
collector.warn(localize('submenuId.invalid.label', "`{0}` is not a valid submenu label", submenuInfo.label));
911
continue;
912
}
913
914
let absoluteIcon: { dark: URI; light?: URI } | ThemeIcon | undefined;
915
if (submenuInfo.icon) {
916
if (typeof submenuInfo.icon === 'string') {
917
absoluteIcon = ThemeIcon.fromString(submenuInfo.icon) || { dark: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon) };
918
} else {
919
absoluteIcon = {
920
dark: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon.dark),
921
light: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon.light)
922
};
923
}
924
}
925
926
const item: IRegisteredSubmenu = {
927
id: MenuId.for(`api:${submenuInfo.id}`),
928
label: submenuInfo.label,
929
icon: absoluteIcon
930
};
931
932
_submenus.set(submenuInfo.id, item);
933
}
934
}
935
});
936
937
const _apiMenusByKey = new Map(apiMenus.map(menu => ([menu.key, menu])));
938
const _menuRegistrations = new DisposableStore();
939
const _submenuMenuItems = new Map<string /* menu id */, Set<string /* submenu id */>>();
940
941
const menusExtensionPoint = ExtensionsRegistry.registerExtensionPoint<{ [loc: string]: (schema.IUserFriendlyMenuItem | schema.IUserFriendlySubmenuItem)[] }>({
942
extensionPoint: 'menus',
943
jsonSchema: schema.menusContribution,
944
deps: [submenusExtensionPoint]
945
});
946
947
menusExtensionPoint.setHandler(extensions => {
948
949
// remove all previous menu registrations
950
_menuRegistrations.clear();
951
_submenuMenuItems.clear();
952
953
for (const extension of extensions) {
954
const { value, collector } = extension;
955
956
for (const entry of Object.entries(value)) {
957
if (!schema.isValidItems(entry[1], collector)) {
958
continue;
959
}
960
961
let menu = _apiMenusByKey.get(entry[0]);
962
963
if (!menu) {
964
const submenu = _submenus.get(entry[0]);
965
966
if (submenu) {
967
menu = {
968
key: entry[0],
969
id: submenu.id,
970
description: ''
971
};
972
}
973
}
974
975
if (!menu) {
976
continue;
977
}
978
979
if (menu.proposed && !isProposedApiEnabled(extension.description, menu.proposed)) {
980
collector.error(localize('proposedAPI.invalid', "{0} is a proposed menu identifier. It requires 'package.json#enabledApiProposals: [\"{1}\"]' and is only available when running out of dev or with the following command line switch: --enable-proposed-api {2}", entry[0], menu.proposed, extension.description.identifier.value));
981
continue;
982
}
983
984
for (const menuItem of entry[1]) {
985
let item: IMenuItem | ISubmenuItem;
986
987
if (schema.isMenuItem(menuItem)) {
988
const command = MenuRegistry.getCommand(menuItem.command);
989
const alt = menuItem.alt && MenuRegistry.getCommand(menuItem.alt) || undefined;
990
991
if (!command) {
992
collector.error(localize('missing.command', "Menu item references a command `{0}` which is not defined in the 'commands' section.", menuItem.command));
993
continue;
994
}
995
if (menuItem.alt && !alt) {
996
collector.warn(localize('missing.altCommand', "Menu item references an alt-command `{0}` which is not defined in the 'commands' section.", menuItem.alt));
997
}
998
if (menuItem.command === menuItem.alt) {
999
collector.info(localize('dupe.command', "Menu item references the same command as default and alt-command"));
1000
}
1001
1002
item = { command, alt, group: undefined, order: undefined, when: undefined };
1003
} else {
1004
if (menu.supportsSubmenus === false) {
1005
collector.error(localize('unsupported.submenureference', "Menu item references a submenu for a menu which doesn't have submenu support."));
1006
continue;
1007
}
1008
1009
const submenu = _submenus.get(menuItem.submenu);
1010
1011
if (!submenu) {
1012
collector.error(localize('missing.submenu', "Menu item references a submenu `{0}` which is not defined in the 'submenus' section.", menuItem.submenu));
1013
continue;
1014
}
1015
1016
let submenuRegistrations = _submenuMenuItems.get(menu.id.id);
1017
1018
if (!submenuRegistrations) {
1019
submenuRegistrations = new Set();
1020
_submenuMenuItems.set(menu.id.id, submenuRegistrations);
1021
}
1022
1023
if (submenuRegistrations.has(submenu.id.id)) {
1024
collector.warn(localize('submenuItem.duplicate', "The `{0}` submenu was already contributed to the `{1}` menu.", menuItem.submenu, entry[0]));
1025
continue;
1026
}
1027
1028
submenuRegistrations.add(submenu.id.id);
1029
1030
item = { submenu: submenu.id, icon: submenu.icon, title: submenu.label, group: undefined, order: undefined, when: undefined };
1031
}
1032
1033
if (menuItem.group) {
1034
const idx = menuItem.group.lastIndexOf('@');
1035
if (idx > 0) {
1036
item.group = menuItem.group.substr(0, idx);
1037
item.order = Number(menuItem.group.substr(idx + 1)) || undefined;
1038
} else {
1039
item.group = menuItem.group;
1040
}
1041
}
1042
1043
if (menu.id === MenuId.ViewContainerTitle && !menuItem.when?.includes('viewContainer == workbench.view.debug')) {
1044
// Not a perfect check but enough to communicate that this proposed extension point is currently only for the debug view container
1045
collector.error(localize('viewContainerTitle.when', "The {0} menu contribution must check {1} in its {2} clause.", '`viewContainer/title`', '`viewContainer == workbench.view.debug`', '"when"'));
1046
continue;
1047
}
1048
1049
item.when = ContextKeyExpr.deserialize(menuItem.when);
1050
_menuRegistrations.add(MenuRegistry.appendMenuItem(menu.id, item));
1051
}
1052
}
1053
}
1054
});
1055
1056
class CommandsTableRenderer extends Disposable implements IExtensionFeatureTableRenderer {
1057
1058
readonly type = 'table';
1059
1060
constructor(
1061
@IKeybindingService private readonly _keybindingService: IKeybindingService
1062
) { super(); }
1063
1064
shouldRender(manifest: IExtensionManifest): boolean {
1065
return !!manifest.contributes?.commands;
1066
}
1067
1068
render(manifest: IExtensionManifest): IRenderedData<ITableData> {
1069
const rawCommands = manifest.contributes?.commands || [];
1070
const commands = rawCommands.map(c => ({
1071
id: c.command,
1072
title: c.title,
1073
keybindings: [] as ResolvedKeybinding[],
1074
menus: [] as string[]
1075
}));
1076
1077
const byId = index(commands, c => c.id);
1078
1079
const menus = manifest.contributes?.menus || {};
1080
1081
// Add to commandPalette array any commands not explicitly contributed to it
1082
const implicitlyOnCommandPalette = index(commands, c => c.id);
1083
if (menus['commandPalette']) {
1084
for (const command of menus['commandPalette']) {
1085
delete implicitlyOnCommandPalette[command.command];
1086
}
1087
}
1088
1089
if (Object.keys(implicitlyOnCommandPalette).length) {
1090
if (!menus['commandPalette']) {
1091
menus['commandPalette'] = [];
1092
}
1093
for (const command in implicitlyOnCommandPalette) {
1094
menus['commandPalette'].push({ command });
1095
}
1096
}
1097
1098
for (const context in menus) {
1099
for (const menu of menus[context]) {
1100
1101
// This typically happens for the commandPalette context
1102
if (menu.when === 'false') {
1103
continue;
1104
}
1105
if (menu.command) {
1106
let command = byId[menu.command];
1107
if (command) {
1108
if (!command.menus.includes(context)) {
1109
command.menus.push(context);
1110
}
1111
} else {
1112
command = { id: menu.command, title: '', keybindings: [], menus: [context] };
1113
byId[command.id] = command;
1114
commands.push(command);
1115
}
1116
}
1117
}
1118
}
1119
1120
const rawKeybindings = manifest.contributes?.keybindings ? (Array.isArray(manifest.contributes.keybindings) ? manifest.contributes.keybindings : [manifest.contributes.keybindings]) : [];
1121
1122
rawKeybindings.forEach(rawKeybinding => {
1123
const keybinding = this.resolveKeybinding(rawKeybinding);
1124
1125
if (!keybinding) {
1126
return;
1127
}
1128
1129
let command = byId[rawKeybinding.command];
1130
1131
if (command) {
1132
command.keybindings.push(keybinding);
1133
} else {
1134
command = { id: rawKeybinding.command, title: '', keybindings: [keybinding], menus: [] };
1135
byId[command.id] = command;
1136
commands.push(command);
1137
}
1138
});
1139
1140
if (!commands.length) {
1141
return { data: { headers: [], rows: [] }, dispose: () => { } };
1142
}
1143
1144
const headers = [
1145
localize('command name', "ID"),
1146
localize('command title', "Title"),
1147
localize('keyboard shortcuts', "Keyboard Shortcuts"),
1148
localize('menuContexts', "Menu Contexts")
1149
];
1150
1151
const rows: IRowData[][] = commands.sort((a, b) => a.id.localeCompare(b.id))
1152
.map(command => {
1153
return [
1154
new MarkdownString().appendMarkdown(`\`${command.id}\``),
1155
typeof command.title === 'string' ? command.title : command.title.value,
1156
command.keybindings,
1157
new MarkdownString().appendMarkdown(`${command.menus.sort((a, b) => a.localeCompare(b)).map(menu => `\`${menu}\``).join('&nbsp;')}`),
1158
];
1159
});
1160
1161
return {
1162
data: {
1163
headers,
1164
rows
1165
},
1166
dispose: () => { }
1167
};
1168
}
1169
1170
private resolveKeybinding(rawKeyBinding: IKeyBinding): ResolvedKeybinding | undefined {
1171
let key: string | undefined;
1172
1173
switch (platform) {
1174
case 'win32': key = rawKeyBinding.win; break;
1175
case 'linux': key = rawKeyBinding.linux; break;
1176
case 'darwin': key = rawKeyBinding.mac; break;
1177
}
1178
1179
return this._keybindingService.resolveUserBinding(key ?? rawKeyBinding.key)[0];
1180
}
1181
1182
}
1183
1184
Registry.as<IExtensionFeaturesRegistry>(ExtensionFeaturesExtensions.ExtensionFeaturesRegistry).registerExtensionFeature({
1185
id: 'commands',
1186
label: localize('commands', "Commands"),
1187
access: {
1188
canToggle: false,
1189
},
1190
renderer: new SyncDescriptor(CommandsTableRenderer),
1191
});
1192
1193