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
5291 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/repositories/title',
153
id: MenuId.SCMSourceControlTitle,
154
description: localize('menus.scmSourceControlTitle', "The Source Control Repositories title menu"),
155
proposed: 'contribSourceControlTitleMenu'
156
},
157
{
158
key: 'scm/repository',
159
id: MenuId.SCMSourceControlInline,
160
description: localize('menus.scmSourceControlInline', "The Source Control repository menu"),
161
},
162
{
163
key: 'scm/resourceState/context',
164
id: MenuId.SCMResourceContext,
165
description: localize('menus.resourceStateContext', "The Source Control resource state context menu")
166
},
167
{
168
key: 'scm/resourceFolder/context',
169
id: MenuId.SCMResourceFolderContext,
170
description: localize('menus.resourceFolderContext', "The Source Control resource folder context menu")
171
},
172
{
173
key: 'scm/resourceGroup/context',
174
id: MenuId.SCMResourceGroupContext,
175
description: localize('menus.resourceGroupContext', "The Source Control resource group context menu")
176
},
177
{
178
key: 'scm/change/title',
179
id: MenuId.SCMChangeContext,
180
description: localize('menus.changeTitle', "The Source Control inline change menu")
181
},
182
{
183
key: 'scm/inputBox',
184
id: MenuId.SCMInputBox,
185
description: localize('menus.input', "The Source Control input box menu"),
186
proposed: 'contribSourceControlInputBoxMenu'
187
},
188
{
189
key: 'scm/history/title',
190
id: MenuId.SCMHistoryTitle,
191
description: localize('menus.scmHistoryTitle', "The Source Control History title menu"),
192
proposed: 'contribSourceControlHistoryTitleMenu'
193
},
194
{
195
key: 'scm/historyItem/context',
196
id: MenuId.SCMHistoryItemContext,
197
description: localize('menus.historyItemContext', "The Source Control history item context menu"),
198
proposed: 'contribSourceControlHistoryItemMenu'
199
},
200
{
201
key: 'scm/historyItemRef/context',
202
id: MenuId.SCMHistoryItemRefContext,
203
description: localize('menus.historyItemRefContext', "The Source Control history item reference context menu"),
204
proposed: 'contribSourceControlHistoryItemMenu'
205
},
206
{
207
key: 'scm/artifactGroup/context',
208
id: MenuId.SCMArtifactGroupContext,
209
description: localize('menus.artifactGroupContext', "The Source Control artifact group context menu"),
210
proposed: 'contribSourceControlArtifactGroupMenu'
211
},
212
{
213
key: 'scm/artifact/context',
214
id: MenuId.SCMArtifactContext,
215
description: localize('menus.artifactContext', "The Source Control artifact context menu"),
216
proposed: 'contribSourceControlArtifactMenu'
217
},
218
{
219
key: 'statusBar/remoteIndicator',
220
id: MenuId.StatusBarRemoteIndicatorMenu,
221
description: localize('menus.statusBarRemoteIndicator', "The remote indicator menu in the status bar"),
222
supportsSubmenus: false
223
},
224
{
225
key: 'terminal/context',
226
id: MenuId.TerminalInstanceContext,
227
description: localize('menus.terminalContext', "The terminal context menu")
228
},
229
{
230
key: 'terminal/title/context',
231
id: MenuId.TerminalTabContext,
232
description: localize('menus.terminalTabContext', "The terminal tabs context menu")
233
},
234
{
235
key: 'view/title',
236
id: MenuId.ViewTitle,
237
description: localize('view.viewTitle', "The contributed view title menu")
238
},
239
{
240
key: 'viewContainer/title',
241
id: MenuId.ViewContainerTitle,
242
description: localize('view.containerTitle', "The contributed view container title menu"),
243
proposed: 'contribViewContainerTitle'
244
},
245
{
246
key: 'view/item/context',
247
id: MenuId.ViewItemContext,
248
description: localize('view.itemContext', "The contributed view item context menu")
249
},
250
{
251
key: 'comments/comment/editorActions',
252
id: MenuId.CommentEditorActions,
253
description: localize('commentThread.editorActions', "The contributed comment editor actions"),
254
proposed: 'contribCommentEditorActionsMenu'
255
},
256
{
257
key: 'comments/commentThread/title',
258
id: MenuId.CommentThreadTitle,
259
description: localize('commentThread.title', "The contributed comment thread title menu")
260
},
261
{
262
key: 'comments/commentThread/context',
263
id: MenuId.CommentThreadActions,
264
description: localize('commentThread.actions', "The contributed comment thread context menu, rendered as buttons below the comment editor"),
265
supportsSubmenus: false
266
},
267
{
268
key: 'comments/commentThread/additionalActions',
269
id: MenuId.CommentThreadAdditionalActions,
270
description: localize('commentThread.actions', "The contributed comment thread context menu, rendered as buttons below the comment editor"),
271
supportsSubmenus: true,
272
proposed: 'contribCommentThreadAdditionalMenu'
273
},
274
{
275
key: 'comments/commentThread/title/context',
276
id: MenuId.CommentThreadTitleContext,
277
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."),
278
proposed: 'contribCommentPeekContext'
279
},
280
{
281
key: 'comments/comment/title',
282
id: MenuId.CommentTitle,
283
description: localize('comment.title', "The contributed comment title menu")
284
},
285
{
286
key: 'comments/comment/context',
287
id: MenuId.CommentActions,
288
description: localize('comment.actions', "The contributed comment context menu, rendered as buttons below the comment editor"),
289
supportsSubmenus: false
290
},
291
{
292
key: 'comments/commentThread/comment/context',
293
id: MenuId.CommentThreadCommentContext,
294
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."),
295
proposed: 'contribCommentPeekContext'
296
},
297
{
298
key: 'commentsView/commentThread/context',
299
id: MenuId.CommentsViewThreadActions,
300
description: localize('commentsView.threadActions', "The contributed comment thread context menu in the comments view"),
301
proposed: 'contribCommentsViewThreadMenus'
302
},
303
{
304
key: 'notebook/toolbar',
305
id: MenuId.NotebookToolbar,
306
description: localize('notebook.toolbar', "The contributed notebook toolbar menu")
307
},
308
{
309
key: 'notebook/kernelSource',
310
id: MenuId.NotebookKernelSource,
311
description: localize('notebook.kernelSource', "The contributed notebook kernel sources menu"),
312
proposed: 'notebookKernelSource'
313
},
314
{
315
key: 'notebook/cell/title',
316
id: MenuId.NotebookCellTitle,
317
description: localize('notebook.cell.title', "The contributed notebook cell title menu")
318
},
319
{
320
key: 'notebook/cell/execute',
321
id: MenuId.NotebookCellExecute,
322
description: localize('notebook.cell.execute', "The contributed notebook cell execution menu")
323
},
324
{
325
key: 'interactive/toolbar',
326
id: MenuId.InteractiveToolbar,
327
description: localize('interactive.toolbar', "The contributed interactive toolbar menu"),
328
},
329
{
330
key: 'interactive/cell/title',
331
id: MenuId.InteractiveCellTitle,
332
description: localize('interactive.cell.title', "The contributed interactive cell title menu"),
333
},
334
{
335
key: 'issue/reporter',
336
id: MenuId.IssueReporter,
337
description: localize('issue.reporter', "The contributed issue reporter menu")
338
},
339
{
340
key: 'testing/item/context',
341
id: MenuId.TestItem,
342
description: localize('testing.item.context', "The contributed test item menu"),
343
},
344
{
345
key: 'testing/item/gutter',
346
id: MenuId.TestItemGutter,
347
description: localize('testing.item.gutter.title', "The menu for a gutter decoration for a test item"),
348
},
349
{
350
key: 'testing/profiles/context',
351
id: MenuId.TestProfilesContext,
352
description: localize('testing.profiles.context.title', "The menu for configuring testing profiles."),
353
},
354
{
355
key: 'testing/item/result',
356
id: MenuId.TestPeekElement,
357
description: localize('testing.item.result.title', "The menu for an item in the Test Results view or peek."),
358
},
359
{
360
key: 'testing/message/context',
361
id: MenuId.TestMessageContext,
362
description: localize('testing.message.context.title', "A prominent button overlaying editor content where the message is displayed"),
363
},
364
{
365
key: 'testing/message/content',
366
id: MenuId.TestMessageContent,
367
description: localize('testing.message.content.title', "Context menu for the message in the results tree"),
368
},
369
{
370
key: 'extension/context',
371
id: MenuId.ExtensionContext,
372
description: localize('menus.extensionContext', "The extension context menu")
373
},
374
{
375
key: 'timeline/title',
376
id: MenuId.TimelineTitle,
377
description: localize('view.timelineTitle', "The Timeline view title menu")
378
},
379
{
380
key: 'timeline/item/context',
381
id: MenuId.TimelineItemContext,
382
description: localize('view.timelineContext', "The Timeline view item context menu")
383
},
384
{
385
key: 'ports/item/context',
386
id: MenuId.TunnelContext,
387
description: localize('view.tunnelContext', "The Ports view item context menu")
388
},
389
{
390
key: 'ports/item/origin/inline',
391
id: MenuId.TunnelOriginInline,
392
description: localize('view.tunnelOriginInline', "The Ports view item origin inline menu")
393
},
394
{
395
key: 'ports/item/port/inline',
396
id: MenuId.TunnelPortInline,
397
description: localize('view.tunnelPortInline', "The Ports view item port inline menu")
398
},
399
{
400
key: 'file/newFile',
401
id: MenuId.NewFile,
402
description: localize('file.newFile', "The 'New File...' quick pick, shown on welcome page and File menu."),
403
supportsSubmenus: false,
404
},
405
{
406
key: 'webview/context',
407
id: MenuId.WebviewContext,
408
description: localize('webview.context', "The webview context menu")
409
},
410
{
411
key: 'file/share',
412
id: MenuId.MenubarShare,
413
description: localize('menus.share', "Share submenu shown in the top level File menu."),
414
proposed: 'contribShareMenu'
415
},
416
{
417
key: 'editor/inlineCompletions/actions',
418
id: MenuId.InlineCompletionsActions,
419
description: localize('inlineCompletions.actions', "The actions shown when hovering on an inline completion"),
420
supportsSubmenus: false,
421
proposed: 'inlineCompletionsAdditions'
422
},
423
{
424
key: 'editor/content',
425
id: MenuId.EditorContent,
426
description: localize('merge.toolbar', "The prominent button in an editor, overlays its content"),
427
proposed: 'contribEditorContentMenu'
428
},
429
{
430
key: 'editor/lineNumber/context',
431
id: MenuId.EditorLineNumberContext,
432
description: localize('editorLineNumberContext', "The contributed editor line number context menu")
433
},
434
{
435
key: 'mergeEditor/result/title',
436
id: MenuId.MergeInputResultToolbar,
437
description: localize('menus.mergeEditorResult', "The result toolbar of the merge editor"),
438
proposed: 'contribMergeEditorMenus'
439
},
440
{
441
key: 'multiDiffEditor/content',
442
id: MenuId.MultiDiffEditorContent,
443
description: localize('menus.multiDiffEditorContent', "A prominent button overlaying the multi diff editor"),
444
proposed: 'contribEditorContentMenu'
445
},
446
{
447
key: 'multiDiffEditor/resource/title',
448
id: MenuId.MultiDiffEditorFileToolbar,
449
description: localize('menus.multiDiffEditorResource', "The resource toolbar in the multi diff editor"),
450
proposed: 'contribMultiDiffEditorMenus'
451
},
452
{
453
key: 'diffEditor/gutter/hunk',
454
id: MenuId.DiffEditorHunkToolbar,
455
description: localize('menus.diffEditorGutterToolBarMenus', "The gutter toolbar in the diff editor"),
456
proposed: 'contribDiffEditorGutterToolBarMenus'
457
},
458
{
459
key: 'diffEditor/gutter/selection',
460
id: MenuId.DiffEditorSelectionToolbar,
461
description: localize('menus.diffEditorGutterToolBarMenus', "The gutter toolbar in the diff editor"),
462
proposed: 'contribDiffEditorGutterToolBarMenus'
463
},
464
{
465
key: 'searchPanel/aiResults/commands',
466
id: MenuId.SearchActionMenu,
467
description: localize('searchPanel.aiResultsCommands', "The commands that will contribute to the menu rendered as buttons next to the AI search title"),
468
},
469
{
470
key: 'editor/context/chat',
471
id: MenuId.ChatTextEditorMenu,
472
description: localize('menus.chatTextEditor', "The Chat submenu in the text editor context menu."),
473
supportsSubmenus: false,
474
proposed: 'chatParticipantPrivate'
475
},
476
{
477
key: 'chat/input/editing/sessionToolbar',
478
id: MenuId.ChatEditingSessionChangesToolbar,
479
description: localize('menus.chatEditingSessionChangesToolbar', "The Chat Editing widget toolbar menu for session changes."),
480
proposed: 'chatSessionsProvider'
481
},
482
{
483
// TODO: rename this to something like: `chatSessions/item/inline`
484
key: 'chat/chatSessions',
485
id: MenuId.AgentSessionsContext,
486
description: localize('menus.chatSessions', "The Chat Sessions menu."),
487
supportsSubmenus: false,
488
proposed: 'chatSessionsProvider'
489
},
490
{
491
key: 'chatSessions/newSession',
492
id: MenuId.AgentSessionsCreateSubMenu,
493
description: localize('menus.chatSessionsNewSession', "Menu for new chat sessions."),
494
supportsSubmenus: false,
495
proposed: 'chatSessionsProvider'
496
},
497
{
498
key: 'chat/multiDiff/context',
499
id: MenuId.ChatMultiDiffContext,
500
description: localize('menus.chatMultiDiffContext', "The Chat Multi-Diff context menu."),
501
supportsSubmenus: false,
502
proposed: 'chatSessionsProvider',
503
},
504
{
505
key: 'chat/editor/inlineGutter',
506
id: MenuId.ChatEditorInlineGutter,
507
description: localize('menus.chatEditorInlineGutter', "The inline gutter menu in the chat editor."),
508
supportsSubmenus: false,
509
proposed: 'contribChatEditorInlineGutterMenu',
510
},
511
{
512
key: 'chat/contextUsage/actions',
513
id: MenuId.ChatContextUsageActions,
514
description: localize('menus.chatContextUsageActions', "Actions in the chat context usage details popup."),
515
proposed: 'chatParticipantAdditions'
516
},
517
{
518
key: 'chat/newSession',
519
id: MenuId.ChatNewMenu,
520
description: localize('menus.chatNewSession', "The Chat new session menu."),
521
proposed: 'chatSessionsProvider'
522
},
523
];
524
525
namespace schema {
526
527
// --- menus, submenus contribution point
528
529
export interface IUserFriendlyMenuItem {
530
command: string;
531
alt?: string;
532
when?: string;
533
group?: string;
534
}
535
536
export interface IUserFriendlySubmenuItem {
537
submenu: string;
538
when?: string;
539
group?: string;
540
}
541
542
export interface IUserFriendlySubmenu {
543
id: string;
544
label: string;
545
icon?: IUserFriendlyIcon;
546
}
547
548
export function isMenuItem(item: IUserFriendlyMenuItem | IUserFriendlySubmenuItem): item is IUserFriendlyMenuItem {
549
return typeof (item as IUserFriendlyMenuItem).command === 'string';
550
}
551
552
export function isValidMenuItem(item: IUserFriendlyMenuItem, collector: ExtensionMessageCollector): boolean {
553
if (typeof item.command !== 'string') {
554
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));
555
return false;
556
}
557
if (item.alt && typeof item.alt !== 'string') {
558
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'alt'));
559
return false;
560
}
561
if (item.when && typeof item.when !== 'string') {
562
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));
563
return false;
564
}
565
if (item.group && typeof item.group !== 'string') {
566
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group'));
567
return false;
568
}
569
570
return true;
571
}
572
573
export function isValidSubmenuItem(item: IUserFriendlySubmenuItem, collector: ExtensionMessageCollector): boolean {
574
if (typeof item.submenu !== 'string') {
575
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'submenu'));
576
return false;
577
}
578
if (item.when && typeof item.when !== 'string') {
579
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));
580
return false;
581
}
582
if (item.group && typeof item.group !== 'string') {
583
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group'));
584
return false;
585
}
586
587
return true;
588
}
589
590
export function isValidItems(items: (IUserFriendlyMenuItem | IUserFriendlySubmenuItem)[], collector: ExtensionMessageCollector): boolean {
591
if (!Array.isArray(items)) {
592
collector.error(localize('requirearray', "submenu items must be an array"));
593
return false;
594
}
595
596
for (const item of items) {
597
if (isMenuItem(item)) {
598
if (!isValidMenuItem(item, collector)) {
599
return false;
600
}
601
} else {
602
if (!isValidSubmenuItem(item, collector)) {
603
return false;
604
}
605
}
606
}
607
608
return true;
609
}
610
611
export function isValidSubmenu(submenu: IUserFriendlySubmenu, collector: ExtensionMessageCollector): boolean {
612
if (typeof submenu !== 'object') {
613
collector.error(localize('require', "submenu items must be an object"));
614
return false;
615
}
616
617
if (typeof submenu.id !== 'string') {
618
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'id'));
619
return false;
620
}
621
if (typeof submenu.label !== 'string') {
622
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'label'));
623
return false;
624
}
625
626
return true;
627
}
628
629
const menuItem: IJSONSchema = {
630
type: 'object',
631
required: ['command'],
632
properties: {
633
command: {
634
description: localize('vscode.extension.contributes.menuItem.command', 'Identifier of the command to execute. The command must be declared in the \'commands\'-section'),
635
type: 'string'
636
},
637
alt: {
638
description: localize('vscode.extension.contributes.menuItem.alt', 'Identifier of an alternative command to execute. The command must be declared in the \'commands\'-section'),
639
type: 'string'
640
},
641
when: {
642
description: localize('vscode.extension.contributes.menuItem.when', 'Condition which must be true to show this item'),
643
type: 'string'
644
},
645
group: {
646
description: localize('vscode.extension.contributes.menuItem.group', 'Group into which this item belongs'),
647
type: 'string'
648
}
649
}
650
};
651
652
const submenuItem: IJSONSchema = {
653
type: 'object',
654
required: ['submenu'],
655
properties: {
656
submenu: {
657
description: localize('vscode.extension.contributes.menuItem.submenu', 'Identifier of the submenu to display in this item.'),
658
type: 'string'
659
},
660
when: {
661
description: localize('vscode.extension.contributes.menuItem.when', 'Condition which must be true to show this item'),
662
type: 'string'
663
},
664
group: {
665
description: localize('vscode.extension.contributes.menuItem.group', 'Group into which this item belongs'),
666
type: 'string'
667
}
668
}
669
};
670
671
const submenu: IJSONSchema = {
672
type: 'object',
673
required: ['id', 'label'],
674
properties: {
675
id: {
676
description: localize('vscode.extension.contributes.submenu.id', 'Identifier of the menu to display as a submenu.'),
677
type: 'string'
678
},
679
label: {
680
description: localize('vscode.extension.contributes.submenu.label', 'The label of the menu item which leads to this submenu.'),
681
type: 'string'
682
},
683
icon: {
684
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)"'),
685
anyOf: [{
686
type: 'string'
687
},
688
{
689
type: 'object',
690
properties: {
691
light: {
692
description: localize('vscode.extension.contributes.submenu.icon.light', 'Icon path when a light theme is used'),
693
type: 'string'
694
},
695
dark: {
696
description: localize('vscode.extension.contributes.submenu.icon.dark', 'Icon path when a dark theme is used'),
697
type: 'string'
698
}
699
}
700
}]
701
}
702
}
703
};
704
705
export const menusContribution: IJSONSchema = {
706
description: localize('vscode.extension.contributes.menus', "Contributes menu items to the editor"),
707
type: 'object',
708
properties: index(apiMenus, menu => menu.key, menu => ({
709
markdownDescription: menu.proposed ? localize('proposed', "Proposed API, requires `enabledApiProposal: [\"{0}\"]` - {1}", menu.proposed, menu.description) : menu.description,
710
type: 'array',
711
items: menu.supportsSubmenus === false ? menuItem : { oneOf: [menuItem, submenuItem] }
712
})),
713
additionalProperties: {
714
description: 'Submenu',
715
type: 'array',
716
items: { oneOf: [menuItem, submenuItem] }
717
}
718
};
719
720
export const submenusContribution: IJSONSchema = {
721
description: localize('vscode.extension.contributes.submenus', "Contributes submenu items to the editor"),
722
type: 'array',
723
items: submenu
724
};
725
726
// --- commands contribution point
727
728
export interface IUserFriendlyCommand {
729
command: string;
730
title: string | ILocalizedString;
731
shortTitle?: string | ILocalizedString;
732
enablement?: string;
733
category?: string | ILocalizedString;
734
icon?: IUserFriendlyIcon;
735
}
736
737
export type IUserFriendlyIcon = string | { light: string; dark: string };
738
739
export function isValidCommand(command: IUserFriendlyCommand, collector: ExtensionMessageCollector): boolean {
740
if (!command) {
741
collector.error(localize('nonempty', "expected non-empty value."));
742
return false;
743
}
744
if (isFalsyOrWhitespace(command.command)) {
745
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));
746
return false;
747
}
748
if (!isValidLocalizedString(command.title, collector, 'title')) {
749
return false;
750
}
751
if (command.shortTitle && !isValidLocalizedString(command.shortTitle, collector, 'shortTitle')) {
752
return false;
753
}
754
if (command.enablement && typeof command.enablement !== 'string') {
755
collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'precondition'));
756
return false;
757
}
758
if (command.category && !isValidLocalizedString(command.category, collector, 'category')) {
759
return false;
760
}
761
if (!isValidIcon(command.icon, collector)) {
762
return false;
763
}
764
return true;
765
}
766
767
function isValidIcon(icon: IUserFriendlyIcon | undefined, collector: ExtensionMessageCollector): boolean {
768
if (typeof icon === 'undefined') {
769
return true;
770
}
771
if (typeof icon === 'string') {
772
return true;
773
} else if (typeof icon.dark === 'string' && typeof icon.light === 'string') {
774
return true;
775
}
776
collector.error(localize('opticon', "property `icon` can be omitted or must be either a string or a literal like `{dark, light}`"));
777
return false;
778
}
779
780
function isValidLocalizedString(localized: string | ILocalizedString, collector: ExtensionMessageCollector, propertyName: string): boolean {
781
if (typeof localized === 'undefined') {
782
collector.error(localize('requireStringOrObject', "property `{0}` is mandatory and must be of type `string` or `object`", propertyName));
783
return false;
784
} else if (typeof localized === 'string' && isFalsyOrWhitespace(localized)) {
785
collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", propertyName));
786
return false;
787
} else if (typeof localized !== 'string' && (isFalsyOrWhitespace(localized.original) || isFalsyOrWhitespace(localized.value))) {
788
collector.error(localize('requirestrings', "properties `{0}` and `{1}` are mandatory and must be of type `string`", `${propertyName}.value`, `${propertyName}.original`));
789
return false;
790
}
791
792
return true;
793
}
794
795
const commandType: IJSONSchema = {
796
type: 'object',
797
required: ['command', 'title'],
798
properties: {
799
command: {
800
description: localize('vscode.extension.contributes.commandType.command', 'Identifier of the command to execute'),
801
type: 'string'
802
},
803
title: {
804
description: localize('vscode.extension.contributes.commandType.title', 'Title by which the command is represented in the UI'),
805
type: 'string'
806
},
807
shortTitle: {
808
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.'),
809
type: 'string'
810
},
811
category: {
812
description: localize('vscode.extension.contributes.commandType.category', '(Optional) Category string by which the command is grouped in the UI'),
813
type: 'string'
814
},
815
enablement: {
816
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.'),
817
type: 'string'
818
},
819
icon: {
820
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)"'),
821
anyOf: [{
822
type: 'string'
823
},
824
{
825
type: 'object',
826
properties: {
827
light: {
828
description: localize('vscode.extension.contributes.commandType.icon.light', 'Icon path when a light theme is used'),
829
type: 'string'
830
},
831
dark: {
832
description: localize('vscode.extension.contributes.commandType.icon.dark', 'Icon path when a dark theme is used'),
833
type: 'string'
834
}
835
}
836
}]
837
}
838
}
839
};
840
841
export const commandsContribution: IJSONSchema = {
842
description: localize('vscode.extension.contributes.commands', "Contributes commands to the command palette."),
843
oneOf: [
844
commandType,
845
{
846
type: 'array',
847
items: commandType
848
}
849
]
850
};
851
}
852
853
const _commandRegistrations = new DisposableStore();
854
855
export const commandsExtensionPoint = ExtensionsRegistry.registerExtensionPoint<schema.IUserFriendlyCommand | schema.IUserFriendlyCommand[]>({
856
extensionPoint: 'commands',
857
jsonSchema: schema.commandsContribution,
858
activationEventsGenerator: function* (contribs: readonly schema.IUserFriendlyCommand[]) {
859
for (const contrib of contribs) {
860
if (contrib.command) {
861
yield `onCommand:${contrib.command}`;
862
}
863
}
864
}
865
});
866
867
commandsExtensionPoint.setHandler(extensions => {
868
869
function handleCommand(userFriendlyCommand: schema.IUserFriendlyCommand, extension: IExtensionPointUser<unknown>) {
870
871
if (!schema.isValidCommand(userFriendlyCommand, extension.collector)) {
872
return;
873
}
874
875
const { icon, enablement, category, title, shortTitle, command } = userFriendlyCommand;
876
877
let absoluteIcon: { dark: URI; light?: URI } | ThemeIcon | undefined;
878
if (icon) {
879
if (typeof icon === 'string') {
880
absoluteIcon = ThemeIcon.fromString(icon) ?? { dark: resources.joinPath(extension.description.extensionLocation, icon), light: resources.joinPath(extension.description.extensionLocation, icon) };
881
882
} else {
883
absoluteIcon = {
884
dark: resources.joinPath(extension.description.extensionLocation, icon.dark),
885
light: resources.joinPath(extension.description.extensionLocation, icon.light)
886
};
887
}
888
}
889
890
const existingCmd = MenuRegistry.getCommand(command);
891
if (existingCmd) {
892
if (existingCmd.source) {
893
extension.collector.info(localize('dup1', "Command `{0}` already registered by {1} ({2})", userFriendlyCommand.command, existingCmd.source.title, existingCmd.source.id));
894
} else {
895
extension.collector.info(localize('dup0', "Command `{0}` already registered", userFriendlyCommand.command));
896
}
897
}
898
_commandRegistrations.add(MenuRegistry.addCommand({
899
id: command,
900
title,
901
source: { id: extension.description.identifier.value, title: extension.description.displayName ?? extension.description.name },
902
shortTitle,
903
tooltip: title,
904
category,
905
precondition: ContextKeyExpr.deserialize(enablement),
906
icon: absoluteIcon
907
}));
908
}
909
910
// remove all previous command registrations
911
_commandRegistrations.clear();
912
913
for (const extension of extensions) {
914
const { value } = extension;
915
if (Array.isArray(value)) {
916
for (const command of value) {
917
handleCommand(command, extension);
918
}
919
} else {
920
handleCommand(value, extension);
921
}
922
}
923
});
924
925
interface IRegisteredSubmenu {
926
readonly id: MenuId;
927
readonly label: string;
928
readonly icon?: { dark: URI; light?: URI } | ThemeIcon;
929
}
930
931
const _submenus = new Map<string, IRegisteredSubmenu>();
932
933
const submenusExtensionPoint = ExtensionsRegistry.registerExtensionPoint<schema.IUserFriendlySubmenu[]>({
934
extensionPoint: 'submenus',
935
jsonSchema: schema.submenusContribution
936
});
937
938
submenusExtensionPoint.setHandler(extensions => {
939
940
_submenus.clear();
941
942
for (const extension of extensions) {
943
const { value, collector } = extension;
944
945
for (const [, submenuInfo] of Object.entries(value)) {
946
947
if (!schema.isValidSubmenu(submenuInfo, collector)) {
948
continue;
949
}
950
951
if (!submenuInfo.id) {
952
collector.warn(localize('submenuId.invalid.id', "`{0}` is not a valid submenu identifier", submenuInfo.id));
953
continue;
954
}
955
if (_submenus.has(submenuInfo.id)) {
956
collector.info(localize('submenuId.duplicate.id', "The `{0}` submenu was already previously registered.", submenuInfo.id));
957
continue;
958
}
959
if (!submenuInfo.label) {
960
collector.warn(localize('submenuId.invalid.label', "`{0}` is not a valid submenu label", submenuInfo.label));
961
continue;
962
}
963
964
let absoluteIcon: { dark: URI; light?: URI } | ThemeIcon | undefined;
965
if (submenuInfo.icon) {
966
if (typeof submenuInfo.icon === 'string') {
967
absoluteIcon = ThemeIcon.fromString(submenuInfo.icon) || { dark: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon) };
968
} else {
969
absoluteIcon = {
970
dark: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon.dark),
971
light: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon.light)
972
};
973
}
974
}
975
976
const item: IRegisteredSubmenu = {
977
id: MenuId.for(`api:${submenuInfo.id}`),
978
label: submenuInfo.label,
979
icon: absoluteIcon
980
};
981
982
_submenus.set(submenuInfo.id, item);
983
}
984
}
985
});
986
987
const _apiMenusByKey = new Map(apiMenus.map(menu => ([menu.key, menu])));
988
const _menuRegistrations = new DisposableStore();
989
const _submenuMenuItems = new Map<string /* menu id */, Set<string /* submenu id */>>();
990
991
const menusExtensionPoint = ExtensionsRegistry.registerExtensionPoint<{ [loc: string]: (schema.IUserFriendlyMenuItem | schema.IUserFriendlySubmenuItem)[] }>({
992
extensionPoint: 'menus',
993
jsonSchema: schema.menusContribution,
994
deps: [submenusExtensionPoint]
995
});
996
997
menusExtensionPoint.setHandler(extensions => {
998
999
// remove all previous menu registrations
1000
_menuRegistrations.clear();
1001
_submenuMenuItems.clear();
1002
1003
for (const extension of extensions) {
1004
const { value, collector } = extension;
1005
1006
for (const entry of Object.entries(value)) {
1007
if (!schema.isValidItems(entry[1], collector)) {
1008
continue;
1009
}
1010
1011
let menu = _apiMenusByKey.get(entry[0]);
1012
1013
if (!menu) {
1014
const submenu = _submenus.get(entry[0]);
1015
1016
if (submenu) {
1017
menu = {
1018
key: entry[0],
1019
id: submenu.id,
1020
description: ''
1021
};
1022
}
1023
}
1024
1025
if (!menu) {
1026
continue;
1027
}
1028
1029
if (menu.proposed && !isProposedApiEnabled(extension.description, menu.proposed)) {
1030
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));
1031
continue;
1032
}
1033
1034
for (const menuItem of entry[1]) {
1035
let item: IMenuItem | ISubmenuItem;
1036
1037
if (schema.isMenuItem(menuItem)) {
1038
const command = MenuRegistry.getCommand(menuItem.command);
1039
const alt = menuItem.alt && MenuRegistry.getCommand(menuItem.alt) || undefined;
1040
1041
if (!command) {
1042
collector.error(localize('missing.command', "Menu item references a command `{0}` which is not defined in the 'commands' section.", menuItem.command));
1043
continue;
1044
}
1045
if (menuItem.alt && !alt) {
1046
collector.warn(localize('missing.altCommand', "Menu item references an alt-command `{0}` which is not defined in the 'commands' section.", menuItem.alt));
1047
}
1048
if (menuItem.command === menuItem.alt) {
1049
collector.info(localize('dupe.command', "Menu item references the same command as default and alt-command"));
1050
}
1051
1052
item = { command, alt, group: undefined, order: undefined, when: undefined };
1053
} else {
1054
if (menu.supportsSubmenus === false) {
1055
collector.error(localize('unsupported.submenureference', "Menu item references a submenu for a menu which doesn't have submenu support."));
1056
continue;
1057
}
1058
1059
const submenu = _submenus.get(menuItem.submenu);
1060
1061
if (!submenu) {
1062
collector.error(localize('missing.submenu', "Menu item references a submenu `{0}` which is not defined in the 'submenus' section.", menuItem.submenu));
1063
continue;
1064
}
1065
1066
let submenuRegistrations = _submenuMenuItems.get(menu.id.id);
1067
1068
if (!submenuRegistrations) {
1069
submenuRegistrations = new Set();
1070
_submenuMenuItems.set(menu.id.id, submenuRegistrations);
1071
}
1072
1073
if (submenuRegistrations.has(submenu.id.id)) {
1074
collector.warn(localize('submenuItem.duplicate', "The `{0}` submenu was already contributed to the `{1}` menu.", menuItem.submenu, entry[0]));
1075
continue;
1076
}
1077
1078
submenuRegistrations.add(submenu.id.id);
1079
1080
item = { submenu: submenu.id, icon: submenu.icon, title: submenu.label, group: undefined, order: undefined, when: undefined };
1081
}
1082
1083
if (menuItem.group) {
1084
const idx = menuItem.group.lastIndexOf('@');
1085
if (idx > 0) {
1086
item.group = menuItem.group.substr(0, idx);
1087
item.order = Number(menuItem.group.substr(idx + 1)) || undefined;
1088
} else {
1089
item.group = menuItem.group;
1090
}
1091
}
1092
1093
if (menu.id === MenuId.ViewContainerTitle && !menuItem.when?.includes('viewContainer == workbench.view.debug')) {
1094
// Not a perfect check but enough to communicate that this proposed extension point is currently only for the debug view container
1095
collector.error(localize('viewContainerTitle.when', "The {0} menu contribution must check {1} in its {2} clause.", '`viewContainer/title`', '`viewContainer == workbench.view.debug`', '"when"'));
1096
continue;
1097
}
1098
1099
item.when = ContextKeyExpr.deserialize(menuItem.when);
1100
_menuRegistrations.add(MenuRegistry.appendMenuItem(menu.id, item));
1101
}
1102
}
1103
}
1104
});
1105
1106
class CommandsTableRenderer extends Disposable implements IExtensionFeatureTableRenderer {
1107
1108
readonly type = 'table';
1109
1110
constructor(
1111
@IKeybindingService private readonly _keybindingService: IKeybindingService
1112
) { super(); }
1113
1114
shouldRender(manifest: IExtensionManifest): boolean {
1115
return !!manifest.contributes?.commands;
1116
}
1117
1118
render(manifest: IExtensionManifest): IRenderedData<ITableData> {
1119
const rawCommands = manifest.contributes?.commands || [];
1120
const commands = rawCommands.map(c => ({
1121
id: c.command,
1122
title: c.title,
1123
keybindings: [] as ResolvedKeybinding[],
1124
menus: [] as string[]
1125
}));
1126
1127
const byId = index(commands, c => c.id);
1128
1129
const menus = manifest.contributes?.menus || {};
1130
1131
// Add to commandPalette array any commands not explicitly contributed to it
1132
const implicitlyOnCommandPalette = index(commands, c => c.id);
1133
if (menus['commandPalette']) {
1134
for (const command of menus['commandPalette']) {
1135
delete implicitlyOnCommandPalette[command.command];
1136
}
1137
}
1138
1139
if (Object.keys(implicitlyOnCommandPalette).length) {
1140
if (!menus['commandPalette']) {
1141
menus['commandPalette'] = [];
1142
}
1143
for (const command in implicitlyOnCommandPalette) {
1144
menus['commandPalette'].push({ command });
1145
}
1146
}
1147
1148
for (const context in menus) {
1149
for (const menu of menus[context]) {
1150
1151
// This typically happens for the commandPalette context
1152
if (menu.when === 'false') {
1153
continue;
1154
}
1155
if (menu.command) {
1156
let command = byId[menu.command];
1157
if (command) {
1158
if (!command.menus.includes(context)) {
1159
command.menus.push(context);
1160
}
1161
} else {
1162
command = { id: menu.command, title: '', keybindings: [], menus: [context] };
1163
byId[command.id] = command;
1164
commands.push(command);
1165
}
1166
}
1167
}
1168
}
1169
1170
const rawKeybindings = manifest.contributes?.keybindings ? (Array.isArray(manifest.contributes.keybindings) ? manifest.contributes.keybindings : [manifest.contributes.keybindings]) : [];
1171
1172
rawKeybindings.forEach(rawKeybinding => {
1173
const keybinding = this.resolveKeybinding(rawKeybinding);
1174
1175
if (!keybinding) {
1176
return;
1177
}
1178
1179
let command = byId[rawKeybinding.command];
1180
1181
if (command) {
1182
command.keybindings.push(keybinding);
1183
} else {
1184
command = { id: rawKeybinding.command, title: '', keybindings: [keybinding], menus: [] };
1185
byId[command.id] = command;
1186
commands.push(command);
1187
}
1188
});
1189
1190
if (!commands.length) {
1191
return { data: { headers: [], rows: [] }, dispose: () => { } };
1192
}
1193
1194
const headers = [
1195
localize('command name', "ID"),
1196
localize('command title', "Title"),
1197
localize('keyboard shortcuts', "Keyboard Shortcuts"),
1198
localize('menuContexts', "Menu Contexts")
1199
];
1200
1201
const rows: IRowData[][] = commands.sort((a, b) => a.id.localeCompare(b.id))
1202
.map(command => {
1203
return [
1204
new MarkdownString().appendMarkdown(`\`${command.id}\``),
1205
typeof command.title === 'string' ? command.title : command.title.value,
1206
command.keybindings,
1207
new MarkdownString().appendMarkdown(`${command.menus.sort((a, b) => a.localeCompare(b)).map(menu => `\`${menu}\``).join('&nbsp;')}`),
1208
];
1209
});
1210
1211
return {
1212
data: {
1213
headers,
1214
rows
1215
},
1216
dispose: () => { }
1217
};
1218
}
1219
1220
private resolveKeybinding(rawKeyBinding: IKeyBinding): ResolvedKeybinding | undefined {
1221
let key: string | undefined;
1222
1223
switch (platform) {
1224
case 'win32': key = rawKeyBinding.win; break;
1225
case 'linux': key = rawKeyBinding.linux; break;
1226
case 'darwin': key = rawKeyBinding.mac; break;
1227
}
1228
1229
return this._keybindingService.resolveUserBinding(key ?? rawKeyBinding.key)[0];
1230
}
1231
1232
}
1233
1234
Registry.as<IExtensionFeaturesRegistry>(ExtensionFeaturesExtensions.ExtensionFeaturesRegistry).registerExtensionFeature({
1235
id: 'commands',
1236
label: localize('commands', "Commands"),
1237
access: {
1238
canToggle: false,
1239
},
1240
renderer: new SyncDescriptor(CommandsTableRenderer),
1241
});
1242
1243