patched-vscode/src/vs/workbench/services/actions/common/menusExtensionPoint.ts (1,007 lines of code) (raw):

/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { localize } from 'vs/nls'; import { isFalsyOrWhitespace } from 'vs/base/common/strings'; import * as resources from 'vs/base/common/resources'; import { IJSONSchema } from 'vs/base/common/jsonSchema'; import { IExtensionPointUser, ExtensionMessageCollector, ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { MenuId, MenuRegistry, IMenuItem, ISubmenuItem } from 'vs/platform/actions/common/actions'; import { URI } from 'vs/base/common/uri'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { ThemeIcon } from 'vs/base/common/themables'; import { index } from 'vs/base/common/arrays'; import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import { ApiProposalName } from 'vs/workbench/services/extensions/common/extensionsApiProposals'; import { ILocalizedString } from 'vs/platform/action/common/action'; import { IExtensionFeatureTableRenderer, IExtensionFeaturesRegistry, IRenderedData, IRowData, ITableData, Extensions as ExtensionFeaturesExtensions } from 'vs/workbench/services/extensionManagement/common/extensionFeatures'; import { IExtensionManifest, IKeyBinding } from 'vs/platform/extensions/common/extensions'; import { Registry } from 'vs/platform/registry/common/platform'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { platform } from 'vs/base/common/process'; import { MarkdownString } from 'vs/base/common/htmlContent'; import { ResolvedKeybinding } from 'vs/base/common/keybindings'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; interface IAPIMenu { readonly key: string; readonly id: MenuId; readonly description: string; readonly proposed?: ApiProposalName; readonly supportsSubmenus?: boolean; // defaults to true } const apiMenus: IAPIMenu[] = [ { key: 'commandPalette', id: MenuId.CommandPalette, description: localize('menus.commandPalette', "The Command Palette"), supportsSubmenus: false }, { key: 'touchBar', id: MenuId.TouchBarContext, description: localize('menus.touchBar', "The touch bar (macOS only)"), supportsSubmenus: false }, { key: 'editor/title', id: MenuId.EditorTitle, description: localize('menus.editorTitle', "The editor title menu") }, { key: 'editor/title/run', id: MenuId.EditorTitleRun, description: localize('menus.editorTitleRun', "Run submenu inside the editor title menu") }, { key: 'editor/context', id: MenuId.EditorContext, description: localize('menus.editorContext', "The editor context menu") }, { key: 'editor/context/copy', id: MenuId.EditorContextCopy, description: localize('menus.editorContextCopyAs', "'Copy as' submenu in the editor context menu") }, { key: 'editor/context/share', id: MenuId.EditorContextShare, description: localize('menus.editorContextShare', "'Share' submenu in the editor context menu"), proposed: 'contribShareMenu' }, { key: 'explorer/context', id: MenuId.ExplorerContext, description: localize('menus.explorerContext', "The file explorer context menu") }, { key: 'explorer/context/share', id: MenuId.ExplorerContextShare, description: localize('menus.explorerContextShare', "'Share' submenu in the file explorer context menu"), proposed: 'contribShareMenu' }, { key: 'editor/title/context', id: MenuId.EditorTitleContext, description: localize('menus.editorTabContext', "The editor tabs context menu") }, { key: 'editor/title/context/share', id: MenuId.EditorTitleContextShare, description: localize('menus.editorTitleContextShare', "'Share' submenu inside the editor title context menu"), proposed: 'contribShareMenu' }, { key: 'debug/callstack/context', id: MenuId.DebugCallStackContext, description: localize('menus.debugCallstackContext', "The debug callstack view context menu") }, { key: 'debug/variables/context', id: MenuId.DebugVariablesContext, description: localize('menus.debugVariablesContext', "The debug variables view context menu") }, { key: 'debug/toolBar', id: MenuId.DebugToolBar, description: localize('menus.debugToolBar', "The debug toolbar menu") }, { key: 'notebook/variables/context', id: MenuId.NotebookVariablesContext, description: localize('menus.notebookVariablesContext', "The notebook variables view context menu") }, { key: 'menuBar/home', id: MenuId.MenubarHomeMenu, description: localize('menus.home', "The home indicator context menu (web only)"), proposed: 'contribMenuBarHome', supportsSubmenus: false }, { key: 'menuBar/edit/copy', id: MenuId.MenubarCopy, description: localize('menus.opy', "'Copy as' submenu in the top level Edit menu") }, { key: 'scm/title', id: MenuId.SCMTitle, description: localize('menus.scmTitle', "The Source Control title menu") }, { key: 'scm/sourceControl', id: MenuId.SCMSourceControl, description: localize('menus.scmSourceControl', "The Source Control menu") }, { key: 'scm/sourceControl/title', id: MenuId.SCMSourceControlTitle, description: localize('menus.scmSourceControlTitle', "The Source Control title menu"), proposed: 'contribSourceControlTitleMenu' }, { key: 'scm/resourceState/context', id: MenuId.SCMResourceContext, description: localize('menus.resourceStateContext', "The Source Control resource state context menu") }, { key: 'scm/resourceFolder/context', id: MenuId.SCMResourceFolderContext, description: localize('menus.resourceFolderContext', "The Source Control resource folder context menu") }, { key: 'scm/resourceGroup/context', id: MenuId.SCMResourceGroupContext, description: localize('menus.resourceGroupContext', "The Source Control resource group context menu") }, { key: 'scm/change/title', id: MenuId.SCMChangeContext, description: localize('menus.changeTitle', "The Source Control inline change menu") }, { key: 'scm/inputBox', id: MenuId.SCMInputBox, description: localize('menus.input', "The Source Control input box menu"), proposed: 'contribSourceControlInputBoxMenu' }, { key: 'scm/incomingChanges', id: MenuId.SCMIncomingChanges, description: localize('menus.incomingChanges', "The Source Control incoming changes menu"), proposed: 'contribSourceControlHistoryItemGroupMenu' }, { key: 'scm/incomingChanges/context', id: MenuId.SCMIncomingChangesContext, description: localize('menus.incomingChangesContext', "The Source Control incoming changes context menu"), proposed: 'contribSourceControlHistoryItemGroupMenu' }, { key: 'scm/outgoingChanges', id: MenuId.SCMOutgoingChanges, description: localize('menus.outgoingChanges', "The Source Control outgoing changes menu"), proposed: 'contribSourceControlHistoryItemGroupMenu' }, { key: 'scm/outgoingChanges/context', id: MenuId.SCMOutgoingChangesContext, description: localize('menus.outgoingChangesContext', "The Source Control outgoing changes context menu"), proposed: 'contribSourceControlHistoryItemGroupMenu' }, { key: 'scm/incomingChanges/allChanges/context', id: MenuId.SCMIncomingChangesAllChangesContext, description: localize('menus.incomingChangesAllChangesContext', "The Source Control all incoming changes context menu"), proposed: 'contribSourceControlHistoryItemMenu' }, { key: 'scm/incomingChanges/historyItem/context', id: MenuId.SCMIncomingChangesHistoryItemContext, description: localize('menus.incomingChangesHistoryItemContext', "The Source Control incoming changes history item context menu"), proposed: 'contribSourceControlHistoryItemMenu' }, { key: 'scm/outgoingChanges/allChanges/context', id: MenuId.SCMOutgoingChangesAllChangesContext, description: localize('menus.outgoingChangesAllChangesContext', "The Source Control all outgoing changes context menu"), proposed: 'contribSourceControlHistoryItemMenu' }, { key: 'scm/outgoingChanges/historyItem/context', id: MenuId.SCMOutgoingChangesHistoryItemContext, description: localize('menus.outgoingChangesHistoryItemContext', "The Source Control outgoing changes history item context menu"), proposed: 'contribSourceControlHistoryItemMenu' }, { key: 'statusBar/remoteIndicator', id: MenuId.StatusBarRemoteIndicatorMenu, description: localize('menus.statusBarRemoteIndicator', "The remote indicator menu in the status bar"), supportsSubmenus: false }, { key: 'terminal/context', id: MenuId.TerminalInstanceContext, description: localize('menus.terminalContext', "The terminal context menu") }, { key: 'terminal/title/context', id: MenuId.TerminalTabContext, description: localize('menus.terminalTabContext', "The terminal tabs context menu") }, { key: 'view/title', id: MenuId.ViewTitle, description: localize('view.viewTitle', "The contributed view title menu") }, { key: 'view/item/context', id: MenuId.ViewItemContext, description: localize('view.itemContext', "The contributed view item context menu") }, { key: 'comments/comment/editorActions', id: MenuId.CommentEditorActions, description: localize('commentThread.editorActions', "The contributed comment editor actions"), proposed: 'contribCommentEditorActionsMenu' }, { key: 'comments/commentThread/title', id: MenuId.CommentThreadTitle, description: localize('commentThread.title', "The contributed comment thread title menu") }, { key: 'comments/commentThread/context', id: MenuId.CommentThreadActions, description: localize('commentThread.actions', "The contributed comment thread context menu, rendered as buttons below the comment editor"), supportsSubmenus: false }, { key: 'comments/commentThread/additionalActions', id: MenuId.CommentThreadAdditionalActions, description: localize('commentThread.actions', "The contributed comment thread context menu, rendered as buttons below the comment editor"), supportsSubmenus: false, proposed: 'contribCommentThreadAdditionalMenu' }, { key: 'comments/commentThread/title/context', id: MenuId.CommentThreadTitleContext, 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."), proposed: 'contribCommentPeekContext' }, { key: 'comments/comment/title', id: MenuId.CommentTitle, description: localize('comment.title', "The contributed comment title menu") }, { key: 'comments/comment/context', id: MenuId.CommentActions, description: localize('comment.actions', "The contributed comment context menu, rendered as buttons below the comment editor"), supportsSubmenus: false }, { key: 'comments/commentThread/comment/context', id: MenuId.CommentThreadCommentContext, 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."), proposed: 'contribCommentPeekContext' }, { key: 'commentsView/commentThread/context', id: MenuId.CommentsViewThreadActions, description: localize('commentsView.threadActions', "The contributed comment thread context menu in the comments view"), proposed: 'contribCommentsViewThreadMenus' }, { key: 'notebook/toolbar', id: MenuId.NotebookToolbar, description: localize('notebook.toolbar', "The contributed notebook toolbar menu") }, { key: 'notebook/kernelSource', id: MenuId.NotebookKernelSource, description: localize('notebook.kernelSource', "The contributed notebook kernel sources menu"), proposed: 'notebookKernelSource' }, { key: 'notebook/cell/title', id: MenuId.NotebookCellTitle, description: localize('notebook.cell.title', "The contributed notebook cell title menu") }, { key: 'notebook/cell/execute', id: MenuId.NotebookCellExecute, description: localize('notebook.cell.execute', "The contributed notebook cell execution menu") }, { key: 'interactive/toolbar', id: MenuId.InteractiveToolbar, description: localize('interactive.toolbar', "The contributed interactive toolbar menu"), }, { key: 'interactive/cell/title', id: MenuId.InteractiveCellTitle, description: localize('interactive.cell.title', "The contributed interactive cell title menu"), }, { key: 'issue/reporter', id: MenuId.IssueReporter, description: localize('issue.reporter', "The contributed issue reporter menu"), proposed: 'contribIssueReporter' }, { key: 'testing/item/context', id: MenuId.TestItem, description: localize('testing.item.context', "The contributed test item menu"), }, { key: 'testing/item/gutter', id: MenuId.TestItemGutter, description: localize('testing.item.gutter.title', "The menu for a gutter decoration for a test item"), }, { key: 'testing/item/result', id: MenuId.TestPeekElement, description: localize('testing.item.result.title', "The menu for an item in the Test Results view or peek."), }, { key: 'testing/message/context', id: MenuId.TestMessageContext, description: localize('testing.message.context.title', "A prominent button overlaying editor content where the message is displayed"), }, { key: 'testing/message/content', id: MenuId.TestMessageContent, description: localize('testing.message.content.title', "Context menu for the message in the results tree"), }, { key: 'extension/context', id: MenuId.ExtensionContext, description: localize('menus.extensionContext', "The extension context menu") }, { key: 'timeline/title', id: MenuId.TimelineTitle, description: localize('view.timelineTitle', "The Timeline view title menu") }, { key: 'timeline/item/context', id: MenuId.TimelineItemContext, description: localize('view.timelineContext', "The Timeline view item context menu") }, { key: 'ports/item/context', id: MenuId.TunnelContext, description: localize('view.tunnelContext', "The Ports view item context menu") }, { key: 'ports/item/origin/inline', id: MenuId.TunnelOriginInline, description: localize('view.tunnelOriginInline', "The Ports view item origin inline menu") }, { key: 'ports/item/port/inline', id: MenuId.TunnelPortInline, description: localize('view.tunnelPortInline', "The Ports view item port inline menu") }, { key: 'file/newFile', id: MenuId.NewFile, description: localize('file.newFile', "The 'New File...' quick pick, shown on welcome page and File menu."), supportsSubmenus: false, }, { key: 'webview/context', id: MenuId.WebviewContext, description: localize('webview.context', "The webview context menu") }, { key: 'file/share', id: MenuId.MenubarShare, description: localize('menus.share', "Share submenu shown in the top level File menu."), proposed: 'contribShareMenu' }, { key: 'editor/inlineCompletions/actions', id: MenuId.InlineCompletionsActions, description: localize('inlineCompletions.actions', "The actions shown when hovering on an inline completion"), supportsSubmenus: false, proposed: 'inlineCompletionsAdditions' }, { key: 'editor/inlineEdit/actions', id: MenuId.InlineEditActions, description: localize('inlineEdit.actions', "The actions shown when hovering on an inline edit"), supportsSubmenus: false, proposed: 'inlineEdit' }, { key: 'editor/content', id: MenuId.EditorContent, description: localize('merge.toolbar', "The prominent button in an editor, overlays its content"), proposed: 'contribEditorContentMenu' }, { key: 'editor/lineNumber/context', id: MenuId.EditorLineNumberContext, description: localize('editorLineNumberContext', "The contributed editor line number context menu") }, { key: 'mergeEditor/result/title', id: MenuId.MergeInputResultToolbar, description: localize('menus.mergeEditorResult', "The result toolbar of the merge editor"), proposed: 'contribMergeEditorMenus' }, { key: 'multiDiffEditor/resource/title', id: MenuId.MultiDiffEditorFileToolbar, description: localize('menus.multiDiffEditorResource', "The resource toolbar in the multi diff editor"), proposed: 'contribMultiDiffEditorMenus' }, { key: 'diffEditor/gutter/hunk', id: MenuId.DiffEditorHunkToolbar, description: localize('menus.diffEditorGutterToolBarMenus', "The gutter toolbar in the diff editor"), proposed: 'contribDiffEditorGutterToolBarMenus' }, { key: 'diffEditor/gutter/selection', id: MenuId.DiffEditorSelectionToolbar, description: localize('menus.diffEditorGutterToolBarMenus', "The gutter toolbar in the diff editor"), proposed: 'contribDiffEditorGutterToolBarMenus' } ]; namespace schema { // --- menus, submenus contribution point export interface IUserFriendlyMenuItem { command: string; alt?: string; when?: string; group?: string; } export interface IUserFriendlySubmenuItem { submenu: string; when?: string; group?: string; } export interface IUserFriendlySubmenu { id: string; label: string; icon?: IUserFriendlyIcon; } export function isMenuItem(item: IUserFriendlyMenuItem | IUserFriendlySubmenuItem): item is IUserFriendlyMenuItem { return typeof (item as IUserFriendlyMenuItem).command === 'string'; } export function isValidMenuItem(item: IUserFriendlyMenuItem, collector: ExtensionMessageCollector): boolean { if (typeof item.command !== 'string') { collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command')); return false; } if (item.alt && typeof item.alt !== 'string') { collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'alt')); return false; } if (item.when && typeof item.when !== 'string') { collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when')); return false; } if (item.group && typeof item.group !== 'string') { collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group')); return false; } return true; } export function isValidSubmenuItem(item: IUserFriendlySubmenuItem, collector: ExtensionMessageCollector): boolean { if (typeof item.submenu !== 'string') { collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'submenu')); return false; } if (item.when && typeof item.when !== 'string') { collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when')); return false; } if (item.group && typeof item.group !== 'string') { collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'group')); return false; } return true; } export function isValidItems(items: (IUserFriendlyMenuItem | IUserFriendlySubmenuItem)[], collector: ExtensionMessageCollector): boolean { if (!Array.isArray(items)) { collector.error(localize('requirearray', "submenu items must be an array")); return false; } for (const item of items) { if (isMenuItem(item)) { if (!isValidMenuItem(item, collector)) { return false; } } else { if (!isValidSubmenuItem(item, collector)) { return false; } } } return true; } export function isValidSubmenu(submenu: IUserFriendlySubmenu, collector: ExtensionMessageCollector): boolean { if (typeof submenu !== 'object') { collector.error(localize('require', "submenu items must be an object")); return false; } if (typeof submenu.id !== 'string') { collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'id')); return false; } if (typeof submenu.label !== 'string') { collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'label')); return false; } return true; } const menuItem: IJSONSchema = { type: 'object', required: ['command'], properties: { command: { description: localize('vscode.extension.contributes.menuItem.command', 'Identifier of the command to execute. The command must be declared in the \'commands\'-section'), type: 'string' }, alt: { description: localize('vscode.extension.contributes.menuItem.alt', 'Identifier of an alternative command to execute. The command must be declared in the \'commands\'-section'), type: 'string' }, when: { description: localize('vscode.extension.contributes.menuItem.when', 'Condition which must be true to show this item'), type: 'string' }, group: { description: localize('vscode.extension.contributes.menuItem.group', 'Group into which this item belongs'), type: 'string' } } }; const submenuItem: IJSONSchema = { type: 'object', required: ['submenu'], properties: { submenu: { description: localize('vscode.extension.contributes.menuItem.submenu', 'Identifier of the submenu to display in this item.'), type: 'string' }, when: { description: localize('vscode.extension.contributes.menuItem.when', 'Condition which must be true to show this item'), type: 'string' }, group: { description: localize('vscode.extension.contributes.menuItem.group', 'Group into which this item belongs'), type: 'string' } } }; const submenu: IJSONSchema = { type: 'object', required: ['id', 'label'], properties: { id: { description: localize('vscode.extension.contributes.submenu.id', 'Identifier of the menu to display as a submenu.'), type: 'string' }, label: { description: localize('vscode.extension.contributes.submenu.label', 'The label of the menu item which leads to this submenu.'), type: 'string' }, icon: { 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)`'), anyOf: [{ type: 'string' }, { type: 'object', properties: { light: { description: localize('vscode.extension.contributes.submenu.icon.light', 'Icon path when a light theme is used'), type: 'string' }, dark: { description: localize('vscode.extension.contributes.submenu.icon.dark', 'Icon path when a dark theme is used'), type: 'string' } } }] } } }; export const menusContribution: IJSONSchema = { description: localize('vscode.extension.contributes.menus', "Contributes menu items to the editor"), type: 'object', properties: index(apiMenus, menu => menu.key, menu => ({ markdownDescription: menu.proposed ? localize('proposed', "Proposed API, requires `enabledApiProposal: [\"{0}\"]` - {1}", menu.proposed, menu.description) : menu.description, type: 'array', items: menu.supportsSubmenus === false ? menuItem : { oneOf: [menuItem, submenuItem] } })), additionalProperties: { description: 'Submenu', type: 'array', items: { oneOf: [menuItem, submenuItem] } } }; export const submenusContribution: IJSONSchema = { description: localize('vscode.extension.contributes.submenus', "Contributes submenu items to the editor"), type: 'array', items: submenu }; // --- commands contribution point export interface IUserFriendlyCommand { command: string; title: string | ILocalizedString; shortTitle?: string | ILocalizedString; enablement?: string; category?: string | ILocalizedString; icon?: IUserFriendlyIcon; } export type IUserFriendlyIcon = string | { light: string; dark: string }; export function isValidCommand(command: IUserFriendlyCommand, collector: ExtensionMessageCollector): boolean { if (!command) { collector.error(localize('nonempty', "expected non-empty value.")); return false; } if (isFalsyOrWhitespace(command.command)) { collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command')); return false; } if (!isValidLocalizedString(command.title, collector, 'title')) { return false; } if (command.shortTitle && !isValidLocalizedString(command.shortTitle, collector, 'shortTitle')) { return false; } if (command.enablement && typeof command.enablement !== 'string') { collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'precondition')); return false; } if (command.category && !isValidLocalizedString(command.category, collector, 'category')) { return false; } if (!isValidIcon(command.icon, collector)) { return false; } return true; } function isValidIcon(icon: IUserFriendlyIcon | undefined, collector: ExtensionMessageCollector): boolean { if (typeof icon === 'undefined') { return true; } if (typeof icon === 'string') { return true; } else if (typeof icon.dark === 'string' && typeof icon.light === 'string') { return true; } collector.error(localize('opticon', "property `icon` can be omitted or must be either a string or a literal like `{dark, light}`")); return false; } function isValidLocalizedString(localized: string | ILocalizedString, collector: ExtensionMessageCollector, propertyName: string): boolean { if (typeof localized === 'undefined') { collector.error(localize('requireStringOrObject', "property `{0}` is mandatory and must be of type `string` or `object`", propertyName)); return false; } else if (typeof localized === 'string' && isFalsyOrWhitespace(localized)) { collector.error(localize('requirestring', "property `{0}` is mandatory and must be of type `string`", propertyName)); return false; } else if (typeof localized !== 'string' && (isFalsyOrWhitespace(localized.original) || isFalsyOrWhitespace(localized.value))) { collector.error(localize('requirestrings', "properties `{0}` and `{1}` are mandatory and must be of type `string`", `${propertyName}.value`, `${propertyName}.original`)); return false; } return true; } const commandType: IJSONSchema = { type: 'object', required: ['command', 'title'], properties: { command: { description: localize('vscode.extension.contributes.commandType.command', 'Identifier of the command to execute'), type: 'string' }, title: { description: localize('vscode.extension.contributes.commandType.title', 'Title by which the command is represented in the UI'), type: 'string' }, shortTitle: { 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.'), type: 'string' }, category: { description: localize('vscode.extension.contributes.commandType.category', '(Optional) Category string by which the command is grouped in the UI'), type: 'string' }, enablement: { 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.'), type: 'string' }, icon: { 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)`'), anyOf: [{ type: 'string' }, { type: 'object', properties: { light: { description: localize('vscode.extension.contributes.commandType.icon.light', 'Icon path when a light theme is used'), type: 'string' }, dark: { description: localize('vscode.extension.contributes.commandType.icon.dark', 'Icon path when a dark theme is used'), type: 'string' } } }] } } }; export const commandsContribution: IJSONSchema = { description: localize('vscode.extension.contributes.commands', "Contributes commands to the command palette."), oneOf: [ commandType, { type: 'array', items: commandType } ] }; } const _commandRegistrations = new DisposableStore(); export const commandsExtensionPoint = ExtensionsRegistry.registerExtensionPoint<schema.IUserFriendlyCommand | schema.IUserFriendlyCommand[]>({ extensionPoint: 'commands', jsonSchema: schema.commandsContribution, activationEventsGenerator: (contribs: schema.IUserFriendlyCommand[], result: { push(item: string): void }) => { for (const contrib of contribs) { if (contrib.command) { result.push(`onCommand:${contrib.command}`); } } } }); commandsExtensionPoint.setHandler(extensions => { function handleCommand(userFriendlyCommand: schema.IUserFriendlyCommand, extension: IExtensionPointUser<any>) { if (!schema.isValidCommand(userFriendlyCommand, extension.collector)) { return; } const { icon, enablement, category, title, shortTitle, command } = userFriendlyCommand; let absoluteIcon: { dark: URI; light?: URI } | ThemeIcon | undefined; if (icon) { if (typeof icon === 'string') { absoluteIcon = ThemeIcon.fromString(icon) ?? { dark: resources.joinPath(extension.description.extensionLocation, icon), light: resources.joinPath(extension.description.extensionLocation, icon) }; } else { absoluteIcon = { dark: resources.joinPath(extension.description.extensionLocation, icon.dark), light: resources.joinPath(extension.description.extensionLocation, icon.light) }; } } const existingCmd = MenuRegistry.getCommand(command); if (existingCmd) { if (existingCmd.source) { extension.collector.info(localize('dup1', "Command `{0}` already registered by {1} ({2})", userFriendlyCommand.command, existingCmd.source.title, existingCmd.source.id)); } else { extension.collector.info(localize('dup0', "Command `{0}` already registered", userFriendlyCommand.command)); } } _commandRegistrations.add(MenuRegistry.addCommand({ id: command, title, source: { id: extension.description.identifier.value, title: extension.description.displayName ?? extension.description.name }, shortTitle, tooltip: title, category, precondition: ContextKeyExpr.deserialize(enablement), icon: absoluteIcon })); } // remove all previous command registrations _commandRegistrations.clear(); for (const extension of extensions) { const { value } = extension; if (Array.isArray(value)) { for (const command of value) { handleCommand(command, extension); } } else { handleCommand(value, extension); } } }); interface IRegisteredSubmenu { readonly id: MenuId; readonly label: string; readonly icon?: { dark: URI; light?: URI } | ThemeIcon; } const _submenus = new Map<string, IRegisteredSubmenu>(); const submenusExtensionPoint = ExtensionsRegistry.registerExtensionPoint<schema.IUserFriendlySubmenu[]>({ extensionPoint: 'submenus', jsonSchema: schema.submenusContribution }); submenusExtensionPoint.setHandler(extensions => { _submenus.clear(); for (const extension of extensions) { const { value, collector } = extension; for (const [, submenuInfo] of Object.entries(value)) { if (!schema.isValidSubmenu(submenuInfo, collector)) { continue; } if (!submenuInfo.id) { collector.warn(localize('submenuId.invalid.id', "`{0}` is not a valid submenu identifier", submenuInfo.id)); continue; } if (_submenus.has(submenuInfo.id)) { collector.info(localize('submenuId.duplicate.id', "The `{0}` submenu was already previously registered.", submenuInfo.id)); continue; } if (!submenuInfo.label) { collector.warn(localize('submenuId.invalid.label', "`{0}` is not a valid submenu label", submenuInfo.label)); continue; } let absoluteIcon: { dark: URI; light?: URI } | ThemeIcon | undefined; if (submenuInfo.icon) { if (typeof submenuInfo.icon === 'string') { absoluteIcon = ThemeIcon.fromString(submenuInfo.icon) || { dark: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon) }; } else { absoluteIcon = { dark: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon.dark), light: resources.joinPath(extension.description.extensionLocation, submenuInfo.icon.light) }; } } const item: IRegisteredSubmenu = { id: MenuId.for(`api:${submenuInfo.id}`), label: submenuInfo.label, icon: absoluteIcon }; _submenus.set(submenuInfo.id, item); } } }); const _apiMenusByKey = new Map(apiMenus.map(menu => ([menu.key, menu]))); const _menuRegistrations = new DisposableStore(); const _submenuMenuItems = new Map<string /* menu id */, Set<string /* submenu id */>>(); const menusExtensionPoint = ExtensionsRegistry.registerExtensionPoint<{ [loc: string]: (schema.IUserFriendlyMenuItem | schema.IUserFriendlySubmenuItem)[] }>({ extensionPoint: 'menus', jsonSchema: schema.menusContribution, deps: [submenusExtensionPoint] }); menusExtensionPoint.setHandler(extensions => { // remove all previous menu registrations _menuRegistrations.clear(); _submenuMenuItems.clear(); for (const extension of extensions) { const { value, collector } = extension; for (const entry of Object.entries(value)) { if (!schema.isValidItems(entry[1], collector)) { continue; } let menu = _apiMenusByKey.get(entry[0]); if (!menu) { const submenu = _submenus.get(entry[0]); if (submenu) { menu = { key: entry[0], id: submenu.id, description: '' }; } } if (!menu) { continue; } if (menu.proposed && !isProposedApiEnabled(extension.description, menu.proposed)) { 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)); continue; } for (const menuItem of entry[1]) { let item: IMenuItem | ISubmenuItem; if (schema.isMenuItem(menuItem)) { const command = MenuRegistry.getCommand(menuItem.command); const alt = menuItem.alt && MenuRegistry.getCommand(menuItem.alt) || undefined; if (!command) { collector.error(localize('missing.command', "Menu item references a command `{0}` which is not defined in the 'commands' section.", menuItem.command)); continue; } if (menuItem.alt && !alt) { collector.warn(localize('missing.altCommand', "Menu item references an alt-command `{0}` which is not defined in the 'commands' section.", menuItem.alt)); } if (menuItem.command === menuItem.alt) { collector.info(localize('dupe.command', "Menu item references the same command as default and alt-command")); } item = { command, alt, group: undefined, order: undefined, when: undefined }; } else { if (menu.supportsSubmenus === false) { collector.error(localize('unsupported.submenureference', "Menu item references a submenu for a menu which doesn't have submenu support.")); continue; } const submenu = _submenus.get(menuItem.submenu); if (!submenu) { collector.error(localize('missing.submenu', "Menu item references a submenu `{0}` which is not defined in the 'submenus' section.", menuItem.submenu)); continue; } let submenuRegistrations = _submenuMenuItems.get(menu.id.id); if (!submenuRegistrations) { submenuRegistrations = new Set(); _submenuMenuItems.set(menu.id.id, submenuRegistrations); } if (submenuRegistrations.has(submenu.id.id)) { collector.warn(localize('submenuItem.duplicate', "The `{0}` submenu was already contributed to the `{1}` menu.", menuItem.submenu, entry[0])); continue; } submenuRegistrations.add(submenu.id.id); item = { submenu: submenu.id, icon: submenu.icon, title: submenu.label, group: undefined, order: undefined, when: undefined }; } if (menuItem.group) { const idx = menuItem.group.lastIndexOf('@'); if (idx > 0) { item.group = menuItem.group.substr(0, idx); item.order = Number(menuItem.group.substr(idx + 1)) || undefined; } else { item.group = menuItem.group; } } item.when = ContextKeyExpr.deserialize(menuItem.when); _menuRegistrations.add(MenuRegistry.appendMenuItem(menu.id, item)); } } } }); class CommandsTableRenderer extends Disposable implements IExtensionFeatureTableRenderer { readonly type = 'table'; constructor( @IKeybindingService private readonly _keybindingService: IKeybindingService ) { super(); } shouldRender(manifest: IExtensionManifest): boolean { return !!manifest.contributes?.commands; } render(manifest: IExtensionManifest): IRenderedData<ITableData> { const rawCommands = manifest.contributes?.commands || []; const commands = rawCommands.map(c => ({ id: c.command, title: c.title, keybindings: [] as ResolvedKeybinding[], menus: [] as string[] })); const byId = index(commands, c => c.id); const menus = manifest.contributes?.menus || {}; for (const context in menus) { for (const menu of menus[context]) { if (menu.command) { let command = byId[menu.command]; if (command) { command.menus.push(context); } else { command = { id: menu.command, title: '', keybindings: [], menus: [context] }; byId[command.id] = command; commands.push(command); } } } } const rawKeybindings = manifest.contributes?.keybindings ? (Array.isArray(manifest.contributes.keybindings) ? manifest.contributes.keybindings : [manifest.contributes.keybindings]) : []; rawKeybindings.forEach(rawKeybinding => { const keybinding = this.resolveKeybinding(rawKeybinding); if (!keybinding) { return; } let command = byId[rawKeybinding.command]; if (command) { command.keybindings.push(keybinding); } else { command = { id: rawKeybinding.command, title: '', keybindings: [keybinding], menus: [] }; byId[command.id] = command; commands.push(command); } }); if (!commands.length) { return { data: { headers: [], rows: [] }, dispose: () => { } }; } const headers = [ localize('command name', "ID"), localize('command title', "Title"), localize('keyboard shortcuts', "Keyboard Shortcuts"), localize('menuContexts', "Menu Contexts") ]; const rows: IRowData[][] = commands.sort((a, b) => a.id.localeCompare(b.id)) .map(command => { return [ new MarkdownString().appendMarkdown(`\`${command.id}\``), typeof command.title === 'string' ? command.title : command.title.value, command.keybindings, new MarkdownString().appendMarkdown(`${command.menus.map(menu => `\`${menu}\``).join('&nbsp;')}`), ]; }); return { data: { headers, rows }, dispose: () => { } }; } private resolveKeybinding(rawKeyBinding: IKeyBinding): ResolvedKeybinding | undefined { let key: string | undefined; switch (platform) { case 'win32': key = rawKeyBinding.win; break; case 'linux': key = rawKeyBinding.linux; break; case 'darwin': key = rawKeyBinding.mac; break; } return this._keybindingService.resolveUserBinding(key ?? rawKeyBinding.key)[0]; } } Registry.as<IExtensionFeaturesRegistry>(ExtensionFeaturesExtensions.ExtensionFeaturesRegistry).registerExtensionFeature({ id: 'commands', label: localize('commands', "Commands"), access: { canToggle: false, }, renderer: new SyncDescriptor(CommandsTableRenderer), });