diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts index 08231d6ea2d7b..8dcc36d7b8925 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts @@ -32,26 +32,12 @@ let pluginApiFactory: PluginAPIFactory; export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIFactory, plugin: Plugin) => { const vscode = apiFactory(plugin); - // register the commands that are in the package.json file - const contributes: any = plugin.rawModel.contributes; - if (contributes && contributes.commands) { - contributes.commands.forEach((commandItem: any) => { - let commandLabel: string; - if (commandItem.category) { // if VS Code command has category we will add it before title, so label will looks like 'category: title' - commandLabel = commandItem.category + ': ' + commandItem.title; - } else { - commandLabel = commandItem.title; - } - vscode.commands.registerCommand({ id: commandItem.command, label: commandLabel }); - }); - } - // replace command API as it will send only the ID as a string parameter const registerCommand = vscode.commands.registerCommand; vscode.commands.registerCommand = function (command: any, handler?: (...args: any[]) => T | Thenable): any { // use of the ID when registering commands if (typeof command === 'string' && handler) { - return registerCommand({ id: command }, handler); + return vscode.commands.registerHandler(command, handler); } return registerCommand(command, handler); }; diff --git a/packages/plugin-ext/src/api/plugin-api.ts b/packages/plugin-ext/src/api/plugin-api.ts index 630e3ffc895b1..fd039529d876a 100644 --- a/packages/plugin-ext/src/api/plugin-api.ts +++ b/packages/plugin-ext/src/api/plugin-api.ts @@ -151,8 +151,11 @@ export interface PluginManagerExt { export interface CommandRegistryMain { $registerCommand(command: theia.Command): void; - $unregisterCommand(id: string): void; + + $registerHandler(id: string): void; + $unregisterHandler(id: string): void; + $executeCommand(id: string, ...args: any[]): PromiseLike; $getCommands(): PromiseLike; $getKeyBinding(commandId: string): PromiseLike; diff --git a/packages/plugin-ext/src/main/browser/command-registry-main.ts b/packages/plugin-ext/src/main/browser/command-registry-main.ts index eca1da41f551d..31e4b79f159f0 100644 --- a/packages/plugin-ext/src/main/browser/command-registry-main.ts +++ b/packages/plugin-ext/src/main/browser/command-registry-main.ts @@ -21,11 +21,11 @@ import { Disposable } from '@theia/core/lib/common/disposable'; import { CommandRegistryMain, CommandRegistryExt, MAIN_RPC_CONTEXT } from '../../api/plugin-api'; import { RPCProtocol } from '../../api/rpc-protocol'; import { KeybindingRegistry } from '@theia/core/lib/browser'; -import URI from '@theia/core/lib/common/uri'; export class CommandRegistryMainImpl implements CommandRegistryMain { private proxy: CommandRegistryExt; - private disposables = new Map(); + private readonly commands = new Map(); + private readonly handlers = new Map(); private delegate: CommandRegistry; private keyBinding: KeybindingRegistry; @@ -36,28 +36,36 @@ export class CommandRegistryMainImpl implements CommandRegistryMain { } $registerCommand(command: theia.Command): void { - this.disposables.set( - command.id, - this.delegate.registerCommand(command, { - // tslint:disable-next-line:no-any - execute: (...args: any[]) => { - // plugin command handlers cannot handle Theia URI, only VS Code URI - const resolvedArgs = (args || []).map(arg => arg instanceof URI ? arg['codeUri'] : arg); - this.proxy.$executeCommand(command.id, ...resolvedArgs); - }, - // Always enabled - a command can be executed programmatically or via the commands palette. - isEnabled() { return true; }, - // Visibility rules are defined via the `menus` contribution point. - isVisible() { return true; } - })); + this.commands.set(command.id, this.delegate.registerCommand(command)); } $unregisterCommand(id: string): void { - const dis = this.disposables.get(id); - if (dis) { - dis.dispose(); - this.disposables.delete(id); + const command = this.commands.get(id); + if (command) { + command.dispose(); + this.commands.delete(id); } } + + $registerHandler(id: string): void { + this.handlers.set(id, this.delegate.registerHandler(id, { + // tslint:disable-next-line:no-any + execute: (...args: any[]) => { + this.proxy.$executeCommand(id, ...args); + }, + // Always enabled - a command can be executed programmatically or via the commands palette. + isEnabled() { return true; }, + // Visibility rules are defined via the `menus` contribution point. + isVisible() { return true; } + })); + } + $unregisterHandler(id: string): void { + const handler = this.handlers.get(id); + if (handler) { + handler.dispose(); + this.handlers.delete(id); + } + } + // tslint:disable-next-line:no-any $executeCommand(id: string, ...args: any[]): PromiseLike { try { diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index 00de9ad252b88..2c1a010564626 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -14,17 +14,18 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any + import { injectable, inject } from 'inversify'; -import { MenuPath, ILogger, CommandRegistry } from '@theia/core'; +import CoreURI from '@theia/core/lib/common/uri'; +import { MenuPath, ILogger, CommandRegistry, Command, Mutable, MenuAction } from '@theia/core'; import { EDITOR_CONTEXT_MENU, EditorWidget } from '@theia/editor/lib/browser'; import { MenuModelRegistry } from '@theia/core/lib/common'; -import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming'; -import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; import { QuickCommandService } from '@theia/core/lib/browser/quick-open/quick-command-service'; import { VIEW_ITEM_CONTEXT_MENU } from '../view/tree-views-main'; -import { PluginContribution, Menu, PluginCommand } from '../../../common'; -import { PluginSharedStyle } from '../plugin-shared-style'; +import { PluginContribution, Menu } from '../../../common'; import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stack-frames-widget'; import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget'; @@ -46,9 +47,6 @@ export class MenusContributionPointHandler { @inject(TabBarToolbarRegistry) protected readonly tabBarToolbar: TabBarToolbarRegistry; - @inject(PluginSharedStyle) - protected readonly style: PluginSharedStyle; - handle(contributions: PluginContribution): void { const allMenus = contributions.menus; if (!allMenus) { @@ -62,7 +60,9 @@ export class MenusContributionPointHandler { } } } else if (location === 'editor/title') { - this.registerEditorTitleActions(allMenus[location], contributions); + for (const action of allMenus[location]) { + this.registerEditorTitleAction(action); + } } else if (allMenus.hasOwnProperty(location)) { const menuPaths = MenusContributionPointHandler.parseMenuPaths(location); if (!menuPaths.length) { @@ -79,42 +79,23 @@ export class MenusContributionPointHandler { } } - protected registerEditorTitleActions(actions: Menu[], contributions: PluginContribution): void { - if (!contributions.commands || !actions.length) { - return; - } - const commands = new Map(contributions.commands.map(c => [c.command, c] as [string, PluginCommand])); - for (const action of actions) { - const pluginCommand = commands.get(action.command); - if (pluginCommand) { - this.registerEditorTitleAction(action, pluginCommand); - } - } - } + protected registerEditorTitleAction(action: Menu): void { + const id = this.createSyntheticCommandId(action, { prefix: '__plugin.editor.title.action.' }); + const command: Command = { id }; + this.commands.registerCommand(command, { + execute: widget => widget instanceof EditorWidget && this.commands.executeCommand(action.command, widget.editor.uri['codeUri']), + isEnabled: widget => widget instanceof EditorWidget && this.commands.isEnabled(action.command, widget.editor.uri['codeUri']), + isVisible: widget => widget instanceof EditorWidget && this.commands.isVisible(action.command, widget.editor.uri['codeUri']) + }); - protected editorTitleActionId = 0; - protected registerEditorTitleAction(action: Menu, pluginCommand: PluginCommand): void { - const id = pluginCommand.command; - const command = '__editor.title.' + id; - const tooltip = pluginCommand.title; - const iconClass = 'plugin-editor-title-action-' + this.editorTitleActionId++; const { group, when } = action; + const item: Mutable = { id, command: id, group, when }; + this.tabBarToolbar.registerItem(item); - const { iconUrl } = pluginCommand; - const darkIconUrl = typeof iconUrl === 'object' ? iconUrl.dark : iconUrl; - const lightIconUrl = typeof iconUrl === 'object' ? iconUrl.light : iconUrl; - this.style.insertRule('.' + iconClass, theme => ` - width: 16px; - height: 16px; - background: no-repeat url("${theme.id === BuiltinThemeProvider.lightTheme.id ? lightIconUrl : darkIconUrl}"); - `); - - this.commands.registerCommand({ id: command, iconClass }, { - execute: widget => widget instanceof EditorWidget && this.commands.executeCommand(id, widget.editor.uri), - isEnabled: widget => widget instanceof EditorWidget, - isVisible: widget => widget instanceof EditorWidget + this.onDidRegisterCommand(action.command, pluginCommand => { + command.iconClass = pluginCommand.iconClass; + item.tooltip = pluginCommand.label; }); - this.tabBarToolbar.registerItem({ id, command, tooltip, group, when }); } protected static parseMenuPaths(value: string): MenuPath[] { @@ -128,17 +109,50 @@ export class MenusContributionPointHandler { } protected registerMenuAction(menuPath: MenuPath, menu: Menu): void { + const commandId = this.createSyntheticCommandId(menu, { prefix: '__plugin.menu.action.' }); + const command: Command = { id: commandId }; + // convert Core URI of a selected resource to a plugin (VS Code) URI format + const resolveArgs = (...args: any[]) => (args || []).map(arg => arg instanceof CoreURI ? arg['codeUri'] : arg); + this.commands.registerCommand(command, { + execute: (...args: any[]) => this.commands.executeCommand(menu.command, ...resolveArgs(...args)), + isEnabled: (...args: any[]) => this.commands.isEnabled(menu.command, ...resolveArgs(...args)), + isVisible: (...args: any[]) => this.commands.isVisible(menu.command, ...resolveArgs(...args)) + }); + + const { when } = menu; const [group = '', order = undefined] = (menu.group || '').split('@'); - // Registering a menu action requires the related command to be already registered. - // But Theia plugin registers the commands dynamically via the Commands API. - // Let's wait for ~2 sec. It should be enough to finish registering all the contributed commands. - // FIXME: remove this workaround (timer) once the https://github.com/theia-ide/theia/issues/3344 is fixed - setTimeout(() => { - this.menuRegistry.registerMenuAction([...menuPath, group], { - commandId: menu.command, - order, - when: menu.when - }); - }, 2000); + const action: MenuAction = { commandId, order, when }; + this.menuRegistry.registerMenuAction([...menuPath, group], action); + + this.onDidRegisterCommand(menu.command, pluginCommand => { + command.category = pluginCommand.category; + action.label = pluginCommand.label; + action.icon = pluginCommand.iconClass; + }); + } + + protected createSyntheticCommandId(menu: Menu, { prefix }: { prefix: string }): string { + const command = menu.command; + let id = prefix + command; + let index = 0; + while (this.commands.getCommand(id)) { + id = prefix + command + ':' + index; + index++; + } + return id; } + + protected onDidRegisterCommand(id: string, cb: (command: Command) => void): void { + const command = this.commands.getCommand(id); + if (command) { + cb(command); + } else { + // Registering a menu action requires the related command to be already registered. + // But Theia plugin registers the commands dynamically via the Commands API. + // Let's wait for ~2 sec. It should be enough to finish registering all the contributed commands. + // FIXME: remove this workaround (timer) once the https://github.com/theia-ide/theia/issues/3344 is fixed + setTimeout(() => this.onDidRegisterCommand(id, cb), 2000); + } + } + } diff --git a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts index 0a86d0eb635cd..7cad8d6d98f8e 100644 --- a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts @@ -24,6 +24,9 @@ import { PreferenceSchemaProvider } from '@theia/core/lib/browser'; import { PreferenceSchema } from '@theia/core/lib/browser/preferences'; import { KeybindingsContributionPointHandler } from './keybindings/keybindings-contribution-handler'; import { MonacoSnippetSuggestProvider } from '@theia/monaco/lib/browser/monaco-snippet-suggest-provider'; +import { PluginSharedStyle } from './plugin-shared-style'; +import { CommandRegistry } from '@theia/core'; +import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming'; @injectable() export class PluginContributionHandler { @@ -51,6 +54,12 @@ export class PluginContributionHandler { @inject(MonacoSnippetSuggestProvider) protected readonly snippetSuggestProvider: MonacoSnippetSuggestProvider; + @inject(CommandRegistry) + protected readonly commands: CommandRegistry; + + @inject(PluginSharedStyle) + protected readonly style: PluginSharedStyle; + handleContributions(contributions: PluginContribution): void { if (contributions.configuration) { this.updateConfigurationSchema(contributions.configuration); @@ -133,6 +142,7 @@ export class PluginContributionHandler { } } + this.registerCommands(contributions); this.menusContributionHandler.handle(contributions); this.keybindingsContributionHandler.handle(contributions); if (contributions.snippets) { @@ -145,6 +155,32 @@ export class PluginContributionHandler { } } + protected pluginCommandIconId = 0; + protected registerCommands(contribution: PluginContribution): void { + if (!contribution.commands) { + return; + } + for (const { iconUrl, command, category, title } of contribution.commands) { + let iconClass: string | undefined; + if (iconUrl) { + iconClass = 'plugin-command-icon-' + this.pluginCommandIconId++; + const darkIconUrl = typeof iconUrl === 'object' ? iconUrl.dark : iconUrl; + const lightIconUrl = typeof iconUrl === 'object' ? iconUrl.light : iconUrl; + this.style.insertRule('.' + iconClass, theme => ` + width: 16px; + height: 16px; + background: no-repeat url("${theme.id === BuiltinThemeProvider.lightTheme.id ? lightIconUrl : darkIconUrl}"); + `); + } + this.commands.registerCommand({ + id: command, + category, + label: title, + iconClass + }); + } + } + private updateConfigurationSchema(schema: PreferenceSchema): void { this.preferenceSchemaProvider.setSchema(schema); } diff --git a/packages/plugin-ext/src/plugin/command-registry.ts b/packages/plugin-ext/src/plugin/command-registry.ts index d00c63f8f7eaa..dcaca62ada770 100644 --- a/packages/plugin-ext/src/plugin/command-registry.ts +++ b/packages/plugin-ext/src/plugin/command-registry.ts @@ -27,7 +27,8 @@ export type Handler = (...args: any[]) => T | PromiseLike; export class CommandRegistryImpl implements CommandRegistryExt { private proxy: CommandRegistryMain; - private commands = new Map(); + private readonly commands = new Set(); + private readonly handlers = new Map(); private converter: CommandsConverter; private cache = new Map(); private delegatingCommandId: string; @@ -39,7 +40,7 @@ export class CommandRegistryImpl implements CommandRegistryExt { this.proxy = rpc.getProxy(Ext.COMMAND_REGISTRY_MAIN); // register internal VS Code commands - this.registerHandler('vscode.previewHtml', CommandRegistryImpl.EMPTY_HANDLER); + this.registerCommand({ id: 'vscode.previewHtml' }, CommandRegistryImpl.EMPTY_HANDLER); } getConverter(): CommandsConverter { @@ -60,26 +61,29 @@ export class CommandRegistryImpl implements CommandRegistryExt { if (this.commands.has(command.id)) { throw new Error(`Command ${command.id} already exist`); } - if (handler) { - this.commands.set(command.id, handler); - } + this.commands.add(command.id); this.proxy.$registerCommand(command); - return Disposable.create(() => { + const toDispose: Disposable[] = []; + if (handler) { + toDispose.push(this.registerHandler(command.id, handler)); + } + toDispose.push(Disposable.create(() => { this.commands.delete(command.id); this.proxy.$unregisterCommand(command.id); - }); - + })); + return Disposable.from(...toDispose); } registerHandler(commandId: string, handler: Handler): Disposable { - if (this.commands.has(commandId)) { - throw new Error(`Command ${commandId} already has handler`); + if (this.handlers.has(commandId)) { + throw new Error(`Command "${commandId}" already has handler`); } - this.commands.set(commandId, handler); + this.proxy.$registerHandler(commandId); + this.handlers.set(commandId, handler); return Disposable.create(() => { - this.commands.delete(commandId); - this.proxy.$unregisterCommand(commandId); + this.handlers.delete(commandId); + this.proxy.$unregisterHandler(commandId); }); } @@ -89,7 +93,7 @@ export class CommandRegistryImpl implements CommandRegistryExt { // tslint:disable-next-line:no-any $executeCommand(id: string, ...args: any[]): PromiseLike { - if (this.commands.has(id)) { + if (this.handlers.has(id)) { return this.executeLocalCommand(id, ...args); } else { return Promise.reject(`Command: ${id} does not exist.`); @@ -98,7 +102,7 @@ export class CommandRegistryImpl implements CommandRegistryExt { // tslint:disable-next-line:no-any executeCommand(id: string, ...args: any[]): PromiseLike { - if (this.commands.has(id)) { + if (this.handlers.has(id)) { return this.executeLocalCommand(id, ...args); } else { return this.proxy.$executeCommand(id, ...args); @@ -111,7 +115,7 @@ export class CommandRegistryImpl implements CommandRegistryExt { // tslint:disable-next-line:no-any private executeLocalCommand(id: string, ...args: any[]): PromiseLike { - const handler = this.commands.get(id); + const handler = this.handlers.get(id); if (handler) { const result = id === this.delegatingCommandId ? handler(this, ...args) diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index d42ed271df4b3..6df12ab144531 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -2003,6 +2003,8 @@ declare module '@theia/plugin' { * * @param commandId a given command id * @param handler a command handler + * + * Throw if a handler for the given command identifier is already registered. */ export function registerHandler(commandId: string, handler: (...args: any[]) => any): Disposable;