From 1ca8d99ee30a092b545f65751f82a103f3df7d74 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 3 Jul 2019 10:38:47 +0000 Subject: [PATCH] [vscode] support `onCommand` activation event Signed-off-by: Anton Kosyakov --- CHANGELOG.md | 2 +- packages/core/src/common/command.ts | 43 ++++++++++++++++++ .../src/hosted/browser/hosted-plugin.ts | 41 ++++++++++++++--- .../src/main/browser/command-registry-main.ts | 22 ++++----- .../browser/plugin-contribution-handler.ts | 45 ++++++++++++++++++- .../plugin-ext/src/plugin/plugin-manager.ts | 7 +-- 6 files changed, 136 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d817e21bd97..d64ae2aef9032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Breaking changes: - [plugin] fixed typo in 'HostedInstanceState' enum from RUNNNING to RUNNING in `plugin-dev` extension - [plugin] removed member `processOptions` from `AbstractHostedInstanceManager` as it is not initialized or used -- [plugin] added basic support of activation events [#5622](https://github.com/theia-ide/theia/pull/5622) +- [plugin] added support of activation events [#5622](https://github.com/theia-ide/theia/pull/5622) - `HostedPluginSupport` is refactored to support multiple `PluginManagerExt` properly ## v0.8.0 diff --git a/packages/core/src/common/command.ts b/packages/core/src/common/command.ts index a18eb47f70b76..a430df8ac3074 100644 --- a/packages/core/src/common/command.ts +++ b/packages/core/src/common/command.ts @@ -15,6 +15,7 @@ ********************************************************************************/ import { injectable, inject, named } from 'inversify'; +import { Event, Emitter } from './event'; import { Disposable, DisposableCollection } from './disposable'; import { ContributionProvider } from './contribution-provider'; @@ -117,6 +118,19 @@ export interface CommandContribution { registerCommands(commands: CommandRegistry): void; } +export interface WillExecuteCommandEvent { + commandId: string; + // tslint:disable:no-any + /** + * Allows to pause the command execution + * in order to a register or activate a command handler. + * + * *Note:* It can only be called during event dispatch and not in an asynchronous manner + */ + waitUntil(thenable: Promise): void; + // tslint:enable:no-any +} + export const commandServicePath = '/services/commands'; export const CommandService = Symbol('CommandService'); /** @@ -130,6 +144,12 @@ export interface CommandService { */ // tslint:disable-next-line:no-any executeCommand(command: string, ...args: any[]): Promise; + /** + * An event is emmited when a command is about to be executed. + * + * It can be used to install or activate a command handler. + */ + readonly onWillExecuteCommand: Event; } /** @@ -144,6 +164,9 @@ export class CommandRegistry implements CommandService { // List of recently used commands. protected _recent: Command[] = []; + protected readonly onWillExecuteCommandEmitter = new Emitter(); + readonly onWillExecuteCommand = this.onWillExecuteCommandEmitter.event; + constructor( @inject(ContributionProvider) @named(CommandContribution) protected readonly contributionProvider: ContributionProvider @@ -255,6 +278,7 @@ export class CommandRegistry implements CommandService { */ // tslint:disable-next-line:no-any async executeCommand(commandId: string, ...args: any[]): Promise { + await this.fireWillExecuteCommand(commandId); const handler = this.getActiveHandler(commandId, ...args); if (handler) { const result = await handler.execute(...args); @@ -268,6 +292,25 @@ export class CommandRegistry implements CommandService { throw new Error(`The command '${commandId}' cannot be executed. There are no active handlers available for the command.${argsMessage}`); } + protected async fireWillExecuteCommand(commandId: string): Promise { + const waitables: Promise[] = []; + this.onWillExecuteCommandEmitter.fire({ + commandId, + waitUntil: (thenable: Promise) => { + if (Object.isFrozen(waitables)) { + throw new Error('waitUntil cannot be called asynchronously.'); + } + waitables.push(thenable); + } + }); + if (!waitables.length) { + return; + } + // Asynchronous calls to `waitUntil` should fail. + Object.freeze(waitables); + await Promise.race([Promise.all(waitables), new Promise(resolve => setTimeout(resolve, 30000))]); + } + /** * Get a visible handler for the given command or `undefined`. */ diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index c2030746aa846..43da442ab8822 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -22,7 +22,7 @@ import { HostedPluginServer, PluginMetadata, getPluginId } from '../../common/pl import { HostedPluginWatcher } from './hosted-plugin-watcher'; import { setUpPluginApi } from '../../main/browser/main-context'; import { RPCProtocol, RPCProtocolImpl } from '../../api/rpc-protocol'; -import { ILogger, ContributionProvider } from '@theia/core'; +import { ILogger, ContributionProvider, CommandRegistry, WillExecuteCommandEvent } from '@theia/core'; import { PreferenceServiceImpl, PreferenceProviderProvider } from '@theia/core/lib/browser'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { PluginContributionHandler } from '../../main/browser/plugin-contribution-handler'; @@ -36,6 +36,7 @@ import { KeysToKeysToAnyValue } from '../../common/types'; import { FileStat } from '@theia/filesystem/lib/common/filesystem'; import { PluginManagerExt, MAIN_RPC_CONTEXT } from '../../common'; import { MonacoTextmateService } from '@theia/monaco/lib/browser/textmate'; +import { Deferred } from '@theia/core/lib/common/promise-util'; export type PluginHost = 'frontend' | string; @@ -80,6 +81,9 @@ export class HostedPluginSupport { @inject(MonacoTextmateService) protected readonly monacoTextmateService: MonacoTextmateService; + @inject(CommandRegistry) + protected readonly commands: CommandRegistry; + private theiaReadyPromise: Promise; protected readonly managers: PluginManagerExt[] = []; @@ -98,6 +102,7 @@ export class HostedPluginSupport { this.activateByLanguage(id); } this.monacoTextmateService.onDidActivateLanguage(id => this.activateByLanguage(id)); + this.commands.onWillExecuteCommand(event => this.ensureCommandHandlerRegistration(event)); } checkAndLoadPlugin(container: interfaces.Container): void { @@ -196,18 +201,44 @@ export class HostedPluginSupport { } } - activateByEvent(activationEvent: string): void { + async activateByEvent(activationEvent: string): Promise { if (this.activationEvents.has(activationEvent)) { return; } this.activationEvents.add(activationEvent); + const activation: Promise[] = []; for (const manager of this.managers) { - manager.$activateByEvent(activationEvent); + activation.push(manager.$activateByEvent(activationEvent)); } + await Promise.all(activation); + } + + async activateByLanguage(languageId: string): Promise { + await this.activateByEvent(`onLanguage:${languageId}`); + } + + async activateByCommand(commandId: string): Promise { + await this.activateByEvent(`onCommand:${commandId}`); } - activateByLanguage(languageId: string): void { - this.activateByEvent(`onLanguage:${languageId}`); + protected ensureCommandHandlerRegistration(event: WillExecuteCommandEvent): void { + const activation = this.activateByCommand(event.commandId); + if (!this.contributionHandler.hasCommand(event.commandId) || this.contributionHandler.hasCommandHandler(event.commandId)) { + return; + } + const waitForCommandHandler = new Deferred(); + const listener = this.contributionHandler.onDidRegisterCommandHandler(id => { + if (id === event.commandId) { + listener.dispose(); + waitForCommandHandler.resolve(); + } + }); + const p = Promise.all([ + activation, + waitForCommandHandler.promise + ]); + p.then(() => listener.dispose(), () => listener.dispose()); + event.waitUntil(p); } } 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 077b631d68528..cbaa6416f6fc4 100644 --- a/packages/plugin-ext/src/main/browser/command-registry-main.ts +++ b/packages/plugin-ext/src/main/browser/command-registry-main.ts @@ -21,22 +21,25 @@ 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 { PluginContributionHandler } from './plugin-contribution-handler'; export class CommandRegistryMainImpl implements CommandRegistryMain { private proxy: CommandRegistryExt; private readonly commands = new Map(); private readonly handlers = new Map(); - private delegate: CommandRegistry; - private keyBinding: KeybindingRegistry; + private readonly delegate: CommandRegistry; + private readonly keyBinding: KeybindingRegistry; + private readonly contributions: PluginContributionHandler; constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.COMMAND_REGISTRY_EXT); this.delegate = container.get(CommandRegistry); this.keyBinding = container.get(KeybindingRegistry); + this.contributions = container.get(PluginContributionHandler); } $registerCommand(command: theia.CommandDescription): void { - this.commands.set(command.id, this.delegate.registerCommand(command)); + this.commands.set(command.id, this.contributions.registerCommand(command)); } $unregisterCommand(id: string): void { const command = this.commands.get(id); @@ -47,16 +50,9 @@ export class CommandRegistryMainImpl implements CommandRegistryMain { } $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; } - })); + this.handlers.set(id, this.contributions.registerCommandHandler(id, (...args) => + this.proxy.$executeCommand(id, ...args) + )); } $unregisterHandler(id: string): void { const handler = this.handlers.get(id); 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 f01d6989cdd12..3147b36a3904d 100644 --- a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts @@ -25,7 +25,9 @@ import { PreferenceSchema, PreferenceSchemaProperties } from '@theia/core/lib/br 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 { CommandRegistry, Command, CommandHandler } from '@theia/core/lib/common/command'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Emitter } from '@theia/core/lib/common/event'; @injectable() export class PluginContributionHandler { @@ -59,6 +61,11 @@ export class PluginContributionHandler { @inject(PluginSharedStyle) protected readonly style: PluginSharedStyle; + protected readonly commandHandlers = new Map(); + + protected readonly onDidRegisterCommandHandlerEmitter = new Emitter(); + readonly onDidRegisterCommandHandler = this.onDidRegisterCommandHandlerEmitter.event; + handleContributions(contributions: PluginContribution): void { if (contributions.configuration) { this.updateConfigurationSchema(contributions.configuration); @@ -157,7 +164,7 @@ export class PluginContributionHandler { } for (const { iconUrl, command, category, title } of contribution.commands) { const iconClass = iconUrl ? this.style.toIconClass(iconUrl) : undefined; - this.commands.registerCommand({ + this.registerCommand({ id: command, category, label: title, @@ -166,6 +173,40 @@ export class PluginContributionHandler { } } + registerCommand(command: Command): Disposable { + const toDispose = new DisposableCollection(); + toDispose.push(this.commands.registerCommand(command, { + execute: async (...args) => { + const handler = this.commandHandlers.get(command.id); + if (!handler) { + throw new Error(`command '${command.id}' not found`); + } + return handler(...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; } + })); + this.commandHandlers.set(command.id, undefined); + toDispose.push(Disposable.create(() => this.commandHandlers.delete(command.id))); + return toDispose; + } + + registerCommandHandler(id: string, execute: CommandHandler['execute']): Disposable { + this.commandHandlers.set(id, execute); + this.onDidRegisterCommandHandlerEmitter.fire(id); + return Disposable.create(() => this.commandHandlers.set(id, undefined)); + } + + hasCommand(id: string): boolean { + return this.commandHandlers.has(id); + } + + hasCommandHandler(id: string): boolean { + return !!this.commandHandlers.get(id); + } + private updateConfigurationSchema(schema: PreferenceSchema): void { this.validateConfigurationSchema(schema); this.preferenceSchemaProvider.setSchema(schema); diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index 53d5201da0f9c..31bcb5f0d2172 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -61,7 +61,7 @@ class ActivatedPlugin { export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { - static SUPPORTED_ACTIVATION_EVENTS = new Set(['*', 'onLanguage']); + static SUPPORTED_ACTIVATION_EVENTS = new Set(['*', 'onLanguage', 'onCommand']); private readonly registry = new Map(); private readonly activations = new Map Promise)[] | undefined>(); @@ -153,7 +153,8 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { const activation = () => this.loadPlugin(plugin, configStorage); const unsupportedActivationEvents = plugin.rawModel.activationEvents.filter(e => !PluginManagerExtImpl.SUPPORTED_ACTIVATION_EVENTS.has(e.split(':')[0])); if (unsupportedActivationEvents.length) { - console.warn(`Unsupported activation events: ${unsupportedActivationEvents}, please open an issue. ${plugin.model.id} extension will be activated eagerly`); + console.warn(`${plugin.model.id} extension will be activated eagerly.`); + console.warn(`Unsupported activation events: ${unsupportedActivationEvents}, please open an issue: https://github.com/theia-ide/theia/issues/new`); this.setActivation('*', activation); } else { for (let activationEvent of plugin.rawModel.activationEvents) { @@ -165,7 +166,7 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { } } } - protected setActivation(activationEvent: string, activation: () => Promise) { + protected setActivation(activationEvent: string, activation: () => Promise): void { const activations = this.activations.get(activationEvent) || []; activations.push(activation); this.activations.set(activationEvent, activations);