From f9100a63b5307087d5062fd99f4c8a53f12fbc79 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 2 Jul 2019 11:55:10 +0000 Subject: [PATCH 1/3] [vscode] support any activation event Signed-off-by: Anton Kosyakov --- packages/plugin-ext/src/api/plugin-api.ts | 3 + .../plugin-ext/src/common/plugin-protocol.ts | 1 + .../src/hosted/browser/hosted-plugin.ts | 173 ++++++++---------- .../plugin-ext/src/plugin/plugin-manager.ts | 80 ++++++-- 4 files changed, 142 insertions(+), 115 deletions(-) diff --git a/packages/plugin-ext/src/api/plugin-api.ts b/packages/plugin-ext/src/api/plugin-api.ts index 161f112701f61..cc3771e05d407 100644 --- a/packages/plugin-ext/src/api/plugin-api.ts +++ b/packages/plugin-ext/src/api/plugin-api.ts @@ -80,6 +80,7 @@ export interface PluginInitData { workspaceState: KeysToKeysToAnyValue; env: EnvInit; extApi?: ExtPluginApi[]; + activationEvents: string[] } export interface PreferenceData { @@ -163,6 +164,8 @@ export interface PluginManagerExt { $init(pluginInit: PluginInitData, configStorage: ConfigStorage): PromiseLike; $updateStoragePath(path: string | undefined): PromiseLike; + + $activateByEvent(event: string): Promise; } export interface CommandRegistryMain { diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 71da6eb4ddaa7..c29e6e6a93c56 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -49,6 +49,7 @@ export interface PluginPackage { description: string; contributes?: PluginPackageContribution; packagePath: string; + activationEvents?: string[]; } export namespace PluginPackage { export function toPluginUrl(pck: PluginPackage, relativePath: string): string { diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 8beca2a39792e..da80219fd6a61 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -16,11 +16,10 @@ // tslint:disable:no-any -import { injectable, inject, interfaces, named } from 'inversify'; +import { injectable, inject, interfaces, named, postConstruct } from 'inversify'; import { PluginWorker } from '../../main/browser/plugin-worker'; import { HostedPluginServer, PluginMetadata, getPluginId } from '../../common/plugin-protocol'; import { HostedPluginWatcher } from './hosted-plugin-watcher'; -import { MAIN_RPC_CONTEXT, ConfigStorage, PluginManagerExt } from '../../api/plugin-api'; import { setUpPluginApi } from '../../main/browser/main-context'; import { RPCProtocol, RPCProtocolImpl } from '../../api/rpc-protocol'; import { ILogger, ContributionProvider } from '@theia/core'; @@ -35,6 +34,9 @@ import { getPreferences } from '../../main/browser/preference-registry-main'; import { PluginServer } from '../../common/plugin-protocol'; import { KeysToKeysToAnyValue } from '../../common/types'; import { FileStat } from '@theia/filesystem/lib/common/filesystem'; +import { PluginManagerExt, MAIN_RPC_CONTEXT } from '../../common'; + +export type PluginHost = 'frontend' | string; @injectable() export class HostedPluginSupport { @@ -62,26 +64,31 @@ export class HostedPluginSupport { @inject(PreferenceProviderProvider) protected readonly preferenceProviderProvider: PreferenceProviderProvider; + @inject(PreferenceServiceImpl) + private readonly preferenceServiceImpl: PreferenceServiceImpl; + + @inject(PluginPathsService) + private readonly pluginPathsService: PluginPathsService; + + @inject(StoragePathService) + private readonly storagePathService: StoragePathService; + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + private theiaReadyPromise: Promise; - private frontendExtManagerProxy: PluginManagerExt; - private backendExtManagerProxy: PluginManagerExt; + + protected readonly managers: PluginManagerExt[] = []; // loaded plugins per #id - private loadedPlugins: Set = new Set(); + private readonly loadedPlugins = new Set(); - // per #hostKey - private rpc: Map = new Map(); + protected readonly activationEvents = new Set(); - constructor( - @inject(PreferenceServiceImpl) private readonly preferenceServiceImpl: PreferenceServiceImpl, - @inject(PluginPathsService) private readonly pluginPathsService: PluginPathsService, - @inject(StoragePathService) private readonly storagePathService: StoragePathService, - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService, - ) { + @postConstruct() + protected init(): void { this.theiaReadyPromise = Promise.all([this.preferenceServiceImpl.ready, this.workspaceService.roots]); - this.storagePathService.onStoragePathChanged(path => { - this.updateStoragePath(path); - }); + this.storagePathService.onStoragePathChanged(path => this.updateStoragePath(path)); } checkAndLoadPlugin(container: interfaces.Container): void { @@ -112,93 +119,54 @@ export class HostedPluginSupport { }).catch(e => console.error(e)); } - loadPlugins(initData: PluginsInitializationData, container: interfaces.Container): void { + async loadPlugins(initData: PluginsInitializationData, container: interfaces.Container): Promise { // don't load plugins twice initData.plugins = initData.plugins.filter(value => !this.loadedPlugins.has(value.model.id)); - const confStorage: ConfigStorage = { - hostLogPath: initData.logPath, - hostStoragePath: initData.storagePath || '' - }; - const [frontend, backend] = this.initContributions(initData.plugins); - this.theiaReadyPromise.then(() => { - if (frontend) { - const worker = new PluginWorker(); - const hostedExtManager = worker.rpc.getProxy(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT); - hostedExtManager.$init({ - plugins: initData.plugins, - preferences: getPreferences(this.preferenceProviderProvider, initData.roots), - globalState: initData.globalStates, - workspaceState: initData.workspaceStates, - env: { queryParams: getQueryParameters(), language: navigator.language }, - extApi: initData.pluginAPIs - }, confStorage); - setUpPluginApi(worker.rpc, container); - this.mainPluginApiProviders.getContributions().forEach(p => p.initialize(worker.rpc, container)); - this.frontendExtManagerProxy = hostedExtManager; - } - - if (backend) { - // sort plugins per host - const pluginsPerHost = initData.plugins.reduce((map: any, pluginMetadata) => { - const host = pluginMetadata.host; - if (!map[host]) { - map[host] = [pluginMetadata]; - } else { - map[host].push(pluginMetadata); - } - return map; - }, {}); - - // create one RPC per host and init. - Object.keys(pluginsPerHost).forEach(hostKey => { - const plugins: PluginMetadata[] = pluginsPerHost[hostKey]; - let pluginID = hostKey; - if (plugins.length >= 1) { - pluginID = getPluginId(plugins[0].model); - } - - let rpc = this.rpc.get(hostKey); - if (!rpc) { - rpc = this.createServerRpc(pluginID, hostKey); - setUpPluginApi(rpc, container); - this.rpc.set(hostKey, rpc); - } - - const hostedExtManager = rpc.getProxy(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT); - hostedExtManager.$init({ - plugins: plugins, - preferences: getPreferences(this.preferenceProviderProvider, initData.roots), - globalState: initData.globalStates, - workspaceState: initData.workspaceStates, - env: { queryParams: getQueryParameters(), language: navigator.language }, - extApi: initData.pluginAPIs - }, confStorage); - this.mainPluginApiProviders.getContributions().forEach(p => p.initialize(rpc!, container)); - this.backendExtManagerProxy = hostedExtManager; - }); - } - - // update list with loaded plugins - initData.plugins.forEach(value => this.loadedPlugins.add(value.model.id)); - }); - } - - private initContributions(pluginsMetadata: PluginMetadata[]): [boolean, boolean] { - const result: [boolean, boolean] = [false, false]; - for (const plugin of pluginsMetadata) { - if (plugin.model.entryPoint.frontend) { - result[0] = true; - } else { - result[1] = true; - } - + const hostToPlugins = new Map(); + for (const plugin of initData.plugins) { + const host = plugin.model.entryPoint.frontend ? 'frontend' : plugin.host; + const plugins = hostToPlugins.get(plugin.host) || []; + plugins.push(plugin); + hostToPlugins.set(host, plugins); if (plugin.model.contributes) { this.contributionHandler.handleContributions(plugin.model.contributes); } } + await this.theiaReadyPromise; + for (const [host, plugins] of hostToPlugins) { + const pluginId = getPluginId(plugins[0].model); + const rpc = this.initRpc(host, pluginId, container); + this.initPluginHostManager(rpc, { ...initData, plugins }); + } + + // update list with loaded plugins + initData.plugins.forEach(value => this.loadedPlugins.add(value.model.id)); + } - return result; + protected initRpc(host: PluginHost, pluginId: string, container: interfaces.Container): RPCProtocol { + const rpc = host === 'frontend' ? new PluginWorker().rpc : this.createServerRpc(pluginId, host); + setUpPluginApi(rpc, container); + this.mainPluginApiProviders.getContributions().forEach(p => p.initialize(rpc, container)); + return rpc; + } + + protected initPluginHostManager(rpc: RPCProtocol, data: PluginsInitializationData): void { + const manager = rpc.getProxy(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT); + this.managers.push(manager); + + manager.$init({ + plugins: data.plugins, + preferences: getPreferences(this.preferenceProviderProvider, data.roots), + globalState: data.globalStates, + workspaceState: data.workspaceStates, + env: { queryParams: getQueryParameters(), language: navigator.language }, + extApi: data.pluginAPIs, + activationEvents: [...this.activationEvents] + }, { + hostLogPath: data.logPath, + hostStoragePath: data.storagePath || '' + }); } private createServerRpc(pluginID: string, hostID: string): RPCProtocol { @@ -214,11 +182,18 @@ export class HostedPluginSupport { } private updateStoragePath(path: string | undefined): void { - if (this.frontendExtManagerProxy) { - this.frontendExtManagerProxy.$updateStoragePath(path); + for (const manager of this.managers) { + manager.$updateStoragePath(path); + } + } + + activateByEvent(activationEvent: string): void { + if (this.activationEvents.has(activationEvent)) { + return; } - if (this.backendExtManagerProxy) { - this.backendExtManagerProxy.$updateStoragePath(path); + this.activationEvents.add(activationEvent); + for (const manager of this.managers) { + manager.$activateByEvent(activationEvent); } } diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index 4728a654ace17..a8d1cfeff7eb5 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -61,8 +61,12 @@ class ActivatedPlugin { export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { - private registry = new Map(); - private activatedPlugins = new Map(); + static SUPPORTED_ACTIVATION_EVENTS = new Set(['*']); + + private readonly registry = new Map(); + private readonly activations = new Map Promise)[] | undefined>(); + private readonly loadedPlugins = new Set(); + private readonly activatedPlugins = new Map(); private pluginActivationPromises = new Map>(); private pluginContextsMap: Map = new Map(); private storageProxy: KeyValueStorageProxy; @@ -121,34 +125,67 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { const [plugins, foreignPlugins] = this.host.init(pluginInit.plugins); // add foreign plugins for (const plugin of foreignPlugins) { - this.registry.set(plugin.model.id, plugin); + this.registerPlugin(plugin, configStorage); } // add own plugins, before initialization for (const plugin of plugins) { - this.registry.set(plugin.model.id, plugin); + this.registerPlugin(plugin, configStorage); } - // run plugins - for (const plugin of plugins) { - if (!plugin.pluginPath) { - continue; - } - const pluginMain = this.host.loadPlugin(plugin); - // able to load the plug-in ? - if (pluginMain !== undefined) { - await this.startPlugin(plugin, configStorage, pluginMain); - } else { - console.error(`Unable to load a plugin from "${plugin.pluginPath}"`); - } + // run eager plugins + await this.$activateByEvent('*'); + for (const activationEvent of pluginInit.activationEvents) { + await this.$activateByEvent(activationEvent); } + // TODO eager activate by `workspaceContains` if (this.host.loadTests) { return this.host.loadTests(); } this.fireOnDidChange(); + return Promise.resolve(); } + protected registerPlugin(plugin: Plugin, configStorage: ConfigStorage): void { + this.registry.set(plugin.model.id, plugin); + if (plugin.pluginPath && Array.isArray(plugin.rawModel.activationEvents)) { + 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`); + this.setActivation('*', activation); + } else { + for (let activationEvent of plugin.rawModel.activationEvents) { + if (activationEvent === 'onUri') { + activationEvent = `onUri:${plugin.model.id}`; + } + this.setActivation(activationEvent, activation); + } + } + } + } + protected setActivation(activationEvent: string, activation: () => Promise) { + const activations = this.activations.get(activationEvent) || []; + activations.push(activation); + this.activations.set(activationEvent, activations); + } + + protected async loadPlugin(plugin: Plugin, configStorage: ConfigStorage): Promise { + if (this.loadedPlugins.has(plugin.model.id)) { + return; + } + this.loadedPlugins.add(plugin.model.id); + + const pluginMain = this.host.loadPlugin(plugin); + // able to load the plug-in ? + if (pluginMain !== undefined) { + await this.startPlugin(plugin, configStorage, pluginMain); + } else { + console.error(`Unable to load a plugin from "${plugin.pluginPath}"`); + } + } + $updateStoragePath(path: string | undefined): PromiseLike { this.pluginContextsMap.forEach((pluginContext: theia.PluginContext, pluginId: string) => { pluginContext.storagePath = path ? join(path, pluginId) : undefined; @@ -156,6 +193,17 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { return Promise.resolve(); } + async $activateByEvent(activationEvent: string): Promise { + const activations = this.activations.get(activationEvent); + if (!activations) { + return; + } + this.activations.set(activationEvent, undefined); + while (activations.length) { + await activations.pop()!(); + } + } + // tslint:disable-next-line:no-any private async startPlugin(plugin: Plugin, configStorage: ConfigStorage, pluginMain: any): Promise { const subscriptions: theia.Disposable[] = []; From 324dcea17dc3f90d88297d204a89632023dee247 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 2 Jul 2019 11:57:37 +0000 Subject: [PATCH 2/3] [vscode] support `onLanguage` activation event Signed-off-by: Anton Kosyakov --- CHANGELOG.md | 2 + .../textmate/monaco-textmate-service.ts | 54 +++++++++++-------- .../src/hosted/browser/hosted-plugin.ts | 13 +++++ .../plugin-ext/src/plugin/plugin-manager.ts | 2 +- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdbd26c011c20..56d817e21bd97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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) + - `HostedPluginSupport` is refactored to support multiple `PluginManagerExt` properly ## v0.8.0 diff --git a/packages/monaco/src/browser/textmate/monaco-textmate-service.ts b/packages/monaco/src/browser/textmate/monaco-textmate-service.ts index 846949cb99a93..7e2a2415e4c22 100644 --- a/packages/monaco/src/browser/textmate/monaco-textmate-service.ts +++ b/packages/monaco/src/browser/textmate/monaco-textmate-service.ts @@ -16,7 +16,7 @@ import { injectable, inject, named } from 'inversify'; import { Registry, IOnigLib, IRawGrammar, parseRawGrammar } from 'vscode-textmate'; -import { ILogger, ContributionProvider } from '@theia/core'; +import { ILogger, ContributionProvider, Emitter } from '@theia/core'; import { FrontendApplicationContribution, isBasicWasmSupported } from '@theia/core/lib/browser'; import { ThemeService } from '@theia/core/lib/browser/theming'; import { LanguageGrammarDefinitionContribution, getEncodedLanguageId } from './textmate-contribution'; @@ -30,6 +30,14 @@ export type OnigasmPromise = Promise; @injectable() export class MonacoTextmateService implements FrontendApplicationContribution { + protected readonly _activatedLanguages = new Set(); + get activatedLanguages(): ReadonlySet { + return this._activatedLanguages; + } + + protected readonly onDidActivateLanguageEmitter = new Emitter(); + readonly onDidActivateLanguage = this.onDidActivateLanguageEmitter.event; + protected grammarRegistry: Registry; @inject(ContributionProvider) @named(LanguageGrammarDefinitionContribution) @@ -97,35 +105,39 @@ export class MonacoTextmateService implements FrontendApplicationContribution { } }); - const registered = new Set(); for (const { id } of monaco.languages.getLanguages()) { - if (!registered.has(id)) { - monaco.languages.onLanguage(id, () => this.activateLanguage(id)); - registered.add(id); - } + monaco.languages.onLanguage(id, () => this.activateLanguage(id)); } } async activateLanguage(languageId: string) { - const scopeName = this.textmateRegistry.getScope(languageId); - if (!scopeName) { - return; - } - const provider = this.textmateRegistry.getProvider(scopeName); - if (!provider) { + if (this._activatedLanguages.has(languageId)) { return; } + this._activatedLanguages.add(languageId); + try { + const scopeName = this.textmateRegistry.getScope(languageId); + if (!scopeName) { + return; + } + const provider = this.textmateRegistry.getProvider(scopeName); + if (!provider) { + return; + } - const configuration = this.textmateRegistry.getGrammarConfiguration(languageId); - const initialLanguage = getEncodedLanguageId(languageId); + const configuration = this.textmateRegistry.getGrammarConfiguration(languageId); + const initialLanguage = getEncodedLanguageId(languageId); - await this.onigasmPromise; - try { - const grammar = await this.grammarRegistry.loadGrammarWithConfiguration(scopeName, initialLanguage, configuration); - const options = configuration.tokenizerOption ? configuration.tokenizerOption : TokenizerOption.DEFAULT; - monaco.languages.setTokensProvider(languageId, createTextmateTokenizer(grammar, options)); - } catch (error) { - this.logger.warn('No grammar for this language id', languageId, error); + await this.onigasmPromise; + try { + const grammar = await this.grammarRegistry.loadGrammarWithConfiguration(scopeName, initialLanguage, configuration); + const options = configuration.tokenizerOption ? configuration.tokenizerOption : TokenizerOption.DEFAULT; + monaco.languages.setTokensProvider(languageId, createTextmateTokenizer(grammar, options)); + } catch (error) { + this.logger.warn('No grammar for this language id', languageId, error); + } + } finally { + this.onDidActivateLanguageEmitter.fire(languageId); } } } diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index da80219fd6a61..c2030746aa846 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -35,6 +35,7 @@ import { PluginServer } from '../../common/plugin-protocol'; 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'; export type PluginHost = 'frontend' | string; @@ -76,6 +77,9 @@ export class HostedPluginSupport { @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(MonacoTextmateService) + protected readonly monacoTextmateService: MonacoTextmateService; + private theiaReadyPromise: Promise; protected readonly managers: PluginManagerExt[] = []; @@ -89,6 +93,11 @@ export class HostedPluginSupport { protected init(): void { this.theiaReadyPromise = Promise.all([this.preferenceServiceImpl.ready, this.workspaceService.roots]); this.storagePathService.onStoragePathChanged(path => this.updateStoragePath(path)); + + for (const id of this.monacoTextmateService.activatedLanguages) { + this.activateByLanguage(id); + } + this.monacoTextmateService.onDidActivateLanguage(id => this.activateByLanguage(id)); } checkAndLoadPlugin(container: interfaces.Container): void { @@ -197,6 +206,10 @@ export class HostedPluginSupport { } } + activateByLanguage(languageId: string): void { + this.activateByEvent(`onLanguage:${languageId}`); + } + } interface PluginsInitializationData { diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index a8d1cfeff7eb5..53d5201da0f9c 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(['*']); + static SUPPORTED_ACTIVATION_EVENTS = new Set(['*', 'onLanguage']); private readonly registry = new Map(); private readonly activations = new Map Promise)[] | undefined>(); From 6e63ef7d6a766744e0d2771386990348a22b9a4a Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 3 Jul 2019 10:38:47 +0000 Subject: [PATCH 3/3] [vscode] support `onCommand` activation event Signed-off-by: Anton Kosyakov --- CHANGELOG.md | 2 +- packages/core/src/common/command.ts | 43 ++++++++++++++++++ .../src/node/plugin-vscode-init.ts | 10 +++-- .../src/hosted/browser/hosted-plugin.ts | 43 +++++++++++++++--- .../src/main/browser/command-registry-main.ts | 22 ++++----- .../browser/plugin-contribution-handler.ts | 45 ++++++++++++++++++- .../plugin-ext/src/plugin/plugin-manager.ts | 7 +-- 7 files changed, 145 insertions(+), 27 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..5ac86154e0ef2 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 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-vscode/src/node/plugin-vscode-init.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts index 78148ce675fed..c5236dd403956 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts @@ -36,10 +36,14 @@ export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIF // 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, thisArg?: any): any { + vscode.commands.registerCommand = function (command: theia.CommandDescription | string, handler?: (...args: any[]) => T | Thenable, thisArg?: any): any { // use of the ID when registering commands - if (typeof command === 'string' && handler) { - return vscode.commands.registerHandler(command, handler, thisArg); + if (typeof command === 'string') { + const commands = plugin.model.contributes && plugin.model.contributes.commands; + if (handler && commands && commands.some(item => item.command === command)) { + return vscode.commands.registerHandler(command, handler, thisArg); + } + return registerCommand({ id: command }, handler, thisArg); } return registerCommand(command, handler, thisArg); }; diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index c2030746aa846..52e8c95a80a92 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,46 @@ 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.commands.getCommand(event.commandId) && + (!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);