From 0a8a88c2fe325fa98e3cd3665139c09e135c3373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Sun, 5 May 2024 19:35:52 +0200 Subject: [PATCH 01/11] Refactor the plugin registry out of the application --- packages/application/src/application.ts | 296 +++++++ packages/application/src/index.ts | 976 +----------------------- packages/application/src/plugins.ts | 770 +++++++++++++++++++ 3 files changed, 1068 insertions(+), 974 deletions(-) create mode 100644 packages/application/src/application.ts create mode 100644 packages/application/src/plugins.ts diff --git a/packages/application/src/application.ts b/packages/application/src/application.ts new file mode 100644 index 000000000..38fe95a2b --- /dev/null +++ b/packages/application/src/application.ts @@ -0,0 +1,296 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import { CommandRegistry } from '@lumino/commands'; + +import { PromiseDelegate } from '@lumino/coreutils'; + +import { ContextMenu, Menu, Widget } from '@lumino/widgets'; +import { PluginRegistry } from './plugins'; + +/** + * A class for creating pluggable applications. + * + * @typeParam T - The type of the application shell. + * + * #### Notes + * The `Application` class is useful when creating large, complex + * UI applications with the ability to be safely extended by third + * party code via plugins. + */ +export class Application extends PluginRegistry { + /** + * Construct a new application. + * + * @param options - The options for creating the application. + */ + constructor(options: Application.IOptions) { + super(); + // Initialize the application state. + this.commands = new CommandRegistry(); + this.contextMenu = new ContextMenu({ + commands: this.commands, + renderer: options.contextMenuRenderer + }); + this.shell = options.shell; + } + + /** + * The application command registry. + */ + readonly commands: CommandRegistry; + + /** + * The application context menu. + */ + readonly contextMenu: ContextMenu; + + /** + * The application shell widget. + * + * #### Notes + * The shell widget is the root "container" widget for the entire + * application. It will typically expose an API which allows the + * application plugins to insert content in a variety of places. + */ + readonly shell: T; + + /** + * A promise which resolves after the application has started. + * + * #### Notes + * This promise will resolve after the `start()` method is called, + * when all the bootstrapping and shell mounting work is complete. + */ + get started(): Promise { + return this._delegate.promise; + } + + /** + * Start the application. + * + * @param options - The options for starting the application. + * + * @returns A promise which resolves when all bootstrapping work + * is complete and the shell is mounted to the DOM. + * + * #### Notes + * This should be called once by the application creator after all + * initial plugins have been registered. + * + * If a plugin fails to the load, the error will be logged and the + * other valid plugins will continue to be loaded. + * + * Bootstrapping the application consists of the following steps: + * 1. Activate the startup plugins + * 2. Wait for those plugins to activate + * 3. Attach the shell widget to the DOM + * 4. Add the application event listeners + */ + async start(options: Application.IStartOptions = {}): Promise { + // Return immediately if the application is already started. + if (this._started) { + return this._delegate.promise; + } + + // Mark the application as started; + this._started = true; + + this._bubblingKeydown = options.bubblingKeydown ?? false; + + // Parse the host ID for attaching the shell. + const hostID = options.hostID ?? ''; + + // Wait for the plugins to activate, then finalize startup. + await this.activatePlugins('startUp', options); + + this.attachShell(hostID); + this.addEventListeners(); + this._delegate.resolve(); + } + + /** + * Handle the DOM events for the application. + * + * @param event - The DOM event sent to the application. + * + * #### Notes + * This method implements the DOM `EventListener` interface and is + * called in response to events registered for the application. It + * should not be called directly by user code. + */ + handleEvent(event: Event): void { + switch (event.type) { + case 'resize': + this.evtResize(event); + break; + case 'keydown': + this.evtKeydown(event as KeyboardEvent); + break; + case 'keyup': + this.evtKeyup(event as KeyboardEvent); + break; + case 'contextmenu': + this.evtContextMenu(event as PointerEvent); + break; + } + } + + /** + * Attach the application shell to the DOM. + * + * @param id - The ID of the host node for the shell, or `''`. + * + * #### Notes + * If the ID is not provided, the document body will be the host. + * + * A subclass may reimplement this method as needed. + */ + protected attachShell(id: string): void { + Widget.attach( + this.shell, + (id && document.getElementById(id)) || document.body + ); + } + + /** + * Add the application event listeners. + * + * #### Notes + * The default implementation of this method adds listeners for + * `'keydown'` and `'resize'` events. + * + * A subclass may reimplement this method as needed. + */ + protected addEventListeners(): void { + document.addEventListener('contextmenu', this); + document.addEventListener('keydown', this, !this._bubblingKeydown); + document.addEventListener('keyup', this, !this._bubblingKeydown); + window.addEventListener('resize', this); + } + + /** + * A method invoked on a document `'keydown'` event. + * + * #### Notes + * The default implementation of this method invokes the key down + * processing method of the application command registry. + * + * A subclass may reimplement this method as needed. + */ + protected evtKeydown(event: KeyboardEvent): void { + this.commands.processKeydownEvent(event); + } + + /** + * A method invoked on a document `'keyup'` event. + * + * #### Notes + * The default implementation of this method invokes the key up + * processing method of the application command registry. + * + * A subclass may reimplement this method as needed. + */ + protected evtKeyup(event: KeyboardEvent): void { + this.commands.processKeyupEvent(event); + } + + /** + * A method invoked on a document `'contextmenu'` event. + * + * #### Notes + * The default implementation of this method opens the application + * `contextMenu` at the current mouse position. + * + * If the application context menu has no matching content *or* if + * the shift key is pressed, the default browser context menu will + * be opened instead. + * + * A subclass may reimplement this method as needed. + */ + protected evtContextMenu(event: PointerEvent): void { + if (event.shiftKey) { + return; + } + if (this.contextMenu.open(event)) { + event.preventDefault(); + event.stopPropagation(); + } + } + + /** + * A method invoked on a window `'resize'` event. + * + * #### Notes + * The default implementation of this method updates the shell. + * + * A subclass may reimplement this method as needed. + */ + protected evtResize(event: Event): void { + this.shell.update(); + } + + private _delegate = new PromiseDelegate(); + private _started = false; + private _bubblingKeydown = false; +} + +/** + * The namespace for the `Application` class statics. + */ +export namespace Application { + /** + * An options object for creating an application. + */ + export interface IOptions { + /** + * The shell widget to use for the application. + * + * This should be a newly created and initialized widget. + * + * The application will attach the widget to the DOM. + */ + shell: T; + + /** + * A custom renderer for the context menu. + */ + contextMenuRenderer?: Menu.IRenderer; + } + + /** + * An options object for application startup. + */ + export interface IStartOptions { + /** + * The ID of the DOM node to host the application shell. + * + * #### Notes + * If this is not provided, the document body will be the host. + */ + hostID?: string; + + /** + * The plugins to activate on startup. + * + * #### Notes + * These will be *in addition* to any `autoStart` plugins. + */ + startPlugins?: string[]; + + /** + * The plugins to **not** activate on startup. + * + * #### Notes + * This will override `startPlugins` and any `autoStart` plugins. + */ + ignorePlugins?: string[]; + + /** + * Whether to capture keydown event at bubbling or capturing (default) phase for + * keyboard shortcuts. + * + * @experimental + */ + bubblingKeydown?: boolean; + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 6c17dcaec..83feb6131 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -11,978 +11,6 @@ * @packageDocumentation * @module application */ -import { topologicSort } from '@lumino/algorithm'; -import { CommandRegistry } from '@lumino/commands'; - -import { PromiseDelegate, Token } from '@lumino/coreutils'; - -import { ContextMenu, Menu, Widget } from '@lumino/widgets'; - -/** - * A user-defined application plugin. - * - * @typeparam T - The type for the application. - * - * @typeparam U - The service type, if the plugin `provides` one. - * - * #### Notes - * Plugins are the foundation for building an extensible application. - * - * Plugins consume and provide "services", which are nothing more than - * concrete implementations of interfaces and/or abstract types. - * - * Unlike regular imports and exports, which tie the service consumer - * to a particular implementation of the service, plugins decouple the - * service producer from the service consumer, allowing an application - * to be easily customized by third parties in a type-safe fashion. - */ -export interface IPlugin { - /** - * The human readable ID of the plugin. - * - * #### Notes - * This must be unique within an application. - */ - id: string; - - /** - * Plugin description. - * - * #### Notes - * This can be used to provide user documentation on the feature - * brought by a plugin. - */ - description?: string; - - /** - * Whether the plugin should be activated on application start or waiting for being - * required. If the value is 'defer' then the plugin should be activated only after - * the application is started. - * - * #### Notes - * The default is `false`. - */ - autoStart?: boolean | 'defer'; - - /** - * The types of required services for the plugin, if any. - * - * #### Notes - * These tokens correspond to the services that are required by - * the plugin for correct operation. - * - * When the plugin is activated, a concrete instance of each type - * will be passed to the `activate()` function, in the order they - * are specified in the `requires` array. - */ - requires?: Token[]; - - /** - * The types of optional services for the plugin, if any. - * - * #### Notes - * These tokens correspond to the services that can be used by the - * plugin if available, but are not necessarily required. - * - * The optional services will be passed to the `activate()` function - * following all required services. If an optional service cannot be - * resolved, `null` will be passed in its place. - */ - optional?: Token[]; - - /** - * The type of service provided by the plugin, if any. - * - * #### Notes - * This token corresponds to the service exported by the plugin. - * - * When the plugin is activated, the return value of `activate()` - * is used as the concrete instance of the type. - */ - provides?: Token | null; - - /** - * A function invoked to activate the plugin. - * - * @param app - The application which owns the plugin. - * - * @param args - The services specified by the `requires` property. - * - * @returns The provided service, or a promise to the service. - * - * #### Notes - * This function will be called whenever the plugin is manually - * activated, or when another plugin being activated requires - * the service it provides. - * - * This function will not be called unless all of its required - * services can be fulfilled. - */ - activate: (app: T, ...args: any[]) => U | Promise; - - /** - * A function invoked to deactivate the plugin. - * - * @param app - The application which owns the plugin. - * - * @param args - The services specified by the `requires` property. - */ - deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; -} - -/** - * A class for creating pluggable applications. - * - * @typeparam T - The type of the application shell. - * - * #### Notes - * The `Application` class is useful when creating large, complex - * UI applications with the ability to be safely extended by third - * party code via plugins. - */ -export class Application { - /** - * Construct a new application. - * - * @param options - The options for creating the application. - */ - constructor(options: Application.IOptions) { - // Initialize the application state. - this.commands = new CommandRegistry(); - this.contextMenu = new ContextMenu({ - commands: this.commands, - renderer: options.contextMenuRenderer - }); - this.shell = options.shell; - } - - /** - * The application command registry. - */ - readonly commands: CommandRegistry; - - /** - * The application context menu. - */ - readonly contextMenu: ContextMenu; - - /** - * The application shell widget. - * - * #### Notes - * The shell widget is the root "container" widget for the entire - * application. It will typically expose an API which allows the - * application plugins to insert content in a variety of places. - */ - readonly shell: T; - - /** - * A promise which resolves after the application has started. - * - * #### Notes - * This promise will resolve after the `start()` method is called, - * when all the bootstrapping and shell mounting work is complete. - */ - get started(): Promise { - return this._delegate.promise; - } - - /** - * Get a plugin description. - * - * @param id - The ID of the plugin of interest. - * - * @returns The plugin description. - */ - getPluginDescription(id: string): string { - return this._plugins.get(id)?.description ?? ''; - } - - /** - * Test whether a plugin is registered with the application. - * - * @param id - The ID of the plugin of interest. - * - * @returns `true` if the plugin is registered, `false` otherwise. - */ - hasPlugin(id: string): boolean { - return this._plugins.has(id); - } - - /** - * Test whether a plugin is activated with the application. - * - * @param id - The ID of the plugin of interest. - * - * @returns `true` if the plugin is activated, `false` otherwise. - */ - isPluginActivated(id: string): boolean { - return this._plugins.get(id)?.activated ?? false; - } - - /** - * List the IDs of the plugins registered with the application. - * - * @returns A new array of the registered plugin IDs. - */ - listPlugins(): string[] { - return Array.from(this._plugins.keys()); - } - - /** - * Register a plugin with the application. - * - * @param plugin - The plugin to register. - * - * #### Notes - * An error will be thrown if a plugin with the same ID is already - * registered, or if the plugin has a circular dependency. - * - * If the plugin provides a service which has already been provided - * by another plugin, the new service will override the old service. - */ - registerPlugin(plugin: IPlugin): void { - // Throw an error if the plugin ID is already registered. - if (this._plugins.has(plugin.id)) { - throw new TypeError(`Plugin '${plugin.id}' is already registered.`); - } - - // Create the normalized plugin data. - const data = Private.createPluginData(plugin); - - // Ensure the plugin does not cause a cyclic dependency. - Private.ensureNoCycle(data, this._plugins, this._services); - - // Add the service token to the service map. - if (data.provides) { - this._services.set(data.provides, data.id); - } - - // Add the plugin to the plugin map. - this._plugins.set(data.id, data); - } - - /** - * Register multiple plugins with the application. - * - * @param plugins - The plugins to register. - * - * #### Notes - * This calls `registerPlugin()` for each of the given plugins. - */ - registerPlugins(plugins: IPlugin[]): void { - for (const plugin of plugins) { - this.registerPlugin(plugin); - } - } - - /** - * Deregister a plugin with the application. - * - * @param id - The ID of the plugin of interest. - * - * @param force - Whether to deregister the plugin even if it is active. - */ - deregisterPlugin(id: string, force?: boolean): void { - const plugin = this._plugins.get(id); - if (!plugin) { - return; - } - - if (plugin.activated && !force) { - throw new Error(`Plugin '${id}' is still active.`); - } - - this._plugins.delete(id); - } - - /** - * Activate the plugin with the given ID. - * - * @param id - The ID of the plugin of interest. - * - * @returns A promise which resolves when the plugin is activated - * or rejects with an error if it cannot be activated. - */ - async activatePlugin(id: string): Promise { - // Reject the promise if the plugin is not registered. - const plugin = this._plugins.get(id); - if (!plugin) { - throw new ReferenceError(`Plugin '${id}' is not registered.`); - } - - // Resolve immediately if the plugin is already activated. - if (plugin.activated) { - return; - } - - // Return the pending resolver promise if it exists. - if (plugin.promise) { - return plugin.promise; - } - - // Resolve the required services for the plugin. - const required = plugin.requires.map(t => this.resolveRequiredService(t)); - - // Resolve the optional services for the plugin. - const optional = plugin.optional.map(t => this.resolveOptionalService(t)); - - // Setup the resolver promise for the plugin. - plugin.promise = Promise.all([...required, ...optional]) - .then(services => plugin!.activate.apply(undefined, [this, ...services])) - .then(service => { - plugin!.service = service; - plugin!.activated = true; - plugin!.promise = null; - }) - .catch(error => { - plugin!.promise = null; - throw error; - }); - - // Return the pending resolver promise. - return plugin.promise; - } - - /** - * Deactivate the plugin and its downstream dependents if and only if the - * plugin and its dependents all support `deactivate`. - * - * @param id - The ID of the plugin of interest. - * - * @returns A list of IDs of downstream plugins deactivated with this one. - */ - async deactivatePlugin(id: string): Promise { - // Reject the promise if the plugin is not registered. - const plugin = this._plugins.get(id); - if (!plugin) { - throw new ReferenceError(`Plugin '${id}' is not registered.`); - } - - // Bail early if the plugin is not activated. - if (!plugin.activated) { - return []; - } - - // Check that this plugin can deactivate. - if (!plugin.deactivate) { - throw new TypeError(`Plugin '${id}'#deactivate() method missing`); - } - - // Find the optimal deactivation order for plugins downstream of this one. - const manifest = Private.findDependents(id, this._plugins, this._services); - const downstream = manifest.map(id => this._plugins.get(id)!); - - // Check that all downstream plugins can deactivate. - for (const plugin of downstream) { - if (!plugin.deactivate) { - throw new TypeError( - `Plugin ${plugin.id}#deactivate() method missing (depends on ${id})` - ); - } - } - - // Deactivate all downstream plugins. - for (const plugin of downstream) { - const services = [...plugin.requires, ...plugin.optional].map(service => { - const id = this._services.get(service); - return id ? this._plugins.get(id)!.service : null; - }); - - // Await deactivation so the next plugins only receive active services. - await plugin.deactivate!(this, ...services); - plugin.service = null; - plugin.activated = false; - } - - // Remove plugin ID and return manifest of deactivated plugins. - manifest.pop(); - return manifest; - } - - /** - * Resolve a required service of a given type. - * - * @param token - The token for the service type of interest. - * - * @returns A promise which resolves to an instance of the requested - * service, or rejects with an error if it cannot be resolved. - * - * #### Notes - * Services are singletons. The same instance will be returned each - * time a given service token is resolved. - * - * If the plugin which provides the service has not been activated, - * resolving the service will automatically activate the plugin. - * - * User code will not typically call this method directly. Instead, - * the required services for the user's plugins will be resolved - * automatically when the plugin is activated. - */ - async resolveRequiredService(token: Token): Promise { - // Reject the promise if there is no provider for the type. - const id = this._services.get(token); - if (!id) { - throw new TypeError(`No provider for: ${token.name}.`); - } - - // Activate the plugin if necessary. - const plugin = this._plugins.get(id)!; - if (!plugin.activated) { - await this.activatePlugin(id); - } - - return plugin.service; - } - - /** - * Resolve an optional service of a given type. - * - * @param token - The token for the service type of interest. - * - * @returns A promise which resolves to an instance of the requested - * service, or `null` if it cannot be resolved. - * - * #### Notes - * Services are singletons. The same instance will be returned each - * time a given service token is resolved. - * - * If the plugin which provides the service has not been activated, - * resolving the service will automatically activate the plugin. - * - * User code will not typically call this method directly. Instead, - * the optional services for the user's plugins will be resolved - * automatically when the plugin is activated. - */ - async resolveOptionalService(token: Token): Promise { - // Resolve with `null` if there is no provider for the type. - const id = this._services.get(token); - if (!id) { - return null; - } - - // Activate the plugin if necessary. - const plugin = this._plugins.get(id)!; - if (!plugin.activated) { - try { - await this.activatePlugin(id); - } catch (reason) { - console.error(reason); - return null; - } - } - - return plugin.service; - } - - /** - * Start the application. - * - * @param options - The options for starting the application. - * - * @returns A promise which resolves when all bootstrapping work - * is complete and the shell is mounted to the DOM. - * - * #### Notes - * This should be called once by the application creator after all - * initial plugins have been registered. - * - * If a plugin fails to the load, the error will be logged and the - * other valid plugins will continue to be loaded. - * - * Bootstrapping the application consists of the following steps: - * 1. Activate the startup plugins - * 2. Wait for those plugins to activate - * 3. Attach the shell widget to the DOM - * 4. Add the application event listeners - */ - start(options: Application.IStartOptions = {}): Promise { - // Return immediately if the application is already started. - if (this._started) { - return this._delegate.promise; - } - - // Mark the application as started; - this._started = true; - - this._bubblingKeydown = options.bubblingKeydown || false; - - // Parse the host ID for attaching the shell. - const hostID = options.hostID || ''; - - // Collect the ids of the startup plugins. - const startups = Private.collectStartupPlugins(this._plugins, options); - - // Generate the activation promises. - const promises = startups.map(id => { - return this.activatePlugin(id).catch(error => { - console.error(`Plugin '${id}' failed to activate.`); - console.error(error); - }); - }); - - // Wait for the plugins to activate, then finalize startup. - Promise.all(promises).then(() => { - this.attachShell(hostID); - this.addEventListeners(); - this._delegate.resolve(); - }); - - // Return the pending delegate promise. - return this._delegate.promise; - } - - /** - * The list of all the deferred plugins. - */ - get deferredPlugins(): string[] { - return Array.from(this._plugins) - .filter(([id, plugin]) => plugin.autoStart === 'defer') - .map(([id, plugin]) => id); - } - - /** - * Activate all the deferred plugins. - * - * @returns A promise which will resolve when each plugin is activated - * or rejects with an error if one cannot be activated. - */ - async activateDeferredPlugins(): Promise { - const promises = this.deferredPlugins - .filter(pluginId => this._plugins.get(pluginId)!.autoStart) - .map(pluginId => { - return this.activatePlugin(pluginId); - }); - await Promise.all(promises); - } - - /** - * Handle the DOM events for the application. - * - * @param event - The DOM event sent to the application. - * - * #### Notes - * This method implements the DOM `EventListener` interface and is - * called in response to events registered for the application. It - * should not be called directly by user code. - */ - handleEvent(event: Event): void { - switch (event.type) { - case 'resize': - this.evtResize(event); - break; - case 'keydown': - this.evtKeydown(event as KeyboardEvent); - break; - case 'keyup': - this.evtKeyup(event as KeyboardEvent); - break; - case 'contextmenu': - this.evtContextMenu(event as PointerEvent); - break; - } - } - - /** - * Attach the application shell to the DOM. - * - * @param id - The ID of the host node for the shell, or `''`. - * - * #### Notes - * If the ID is not provided, the document body will be the host. - * - * A subclass may reimplement this method as needed. - */ - protected attachShell(id: string): void { - Widget.attach( - this.shell, - (id && document.getElementById(id)) || document.body - ); - } - - /** - * Add the application event listeners. - * - * #### Notes - * The default implementation of this method adds listeners for - * `'keydown'` and `'resize'` events. - * - * A subclass may reimplement this method as needed. - */ - protected addEventListeners(): void { - document.addEventListener('contextmenu', this); - document.addEventListener('keydown', this, !this._bubblingKeydown); - document.addEventListener('keyup', this, !this._bubblingKeydown); - window.addEventListener('resize', this); - } - - /** - * A method invoked on a document `'keydown'` event. - * - * #### Notes - * The default implementation of this method invokes the key down - * processing method of the application command registry. - * - * A subclass may reimplement this method as needed. - */ - protected evtKeydown(event: KeyboardEvent): void { - this.commands.processKeydownEvent(event); - } - - /** - * A method invoked on a document `'keyup'` event. - * - * #### Notes - * The default implementation of this method invokes the key up - * processing method of the application command registry. - * - * A subclass may reimplement this method as needed. - */ - protected evtKeyup(event: KeyboardEvent): void { - this.commands.processKeyupEvent(event); - } - - /** - * A method invoked on a document `'contextmenu'` event. - * - * #### Notes - * The default implementation of this method opens the application - * `contextMenu` at the current mouse position. - * - * If the application context menu has no matching content *or* if - * the shift key is pressed, the default browser context menu will - * be opened instead. - * - * A subclass may reimplement this method as needed. - */ - protected evtContextMenu(event: PointerEvent): void { - if (event.shiftKey) { - return; - } - if (this.contextMenu.open(event)) { - event.preventDefault(); - event.stopPropagation(); - } - } - - /** - * A method invoked on a window `'resize'` event. - * - * #### Notes - * The default implementation of this method updates the shell. - * - * A subclass may reimplement this method as needed. - */ - protected evtResize(event: Event): void { - this.shell.update(); - } - - private _delegate = new PromiseDelegate(); - private _plugins = new Map(); - private _services = new Map, string>(); - private _started = false; - private _bubblingKeydown = false; -} - -/** - * The namespace for the `Application` class statics. - */ -export namespace Application { - /** - * An options object for creating an application. - */ - export interface IOptions { - /** - * The shell widget to use for the application. - * - * This should be a newly created and initialized widget. - * - * The application will attach the widget to the DOM. - */ - shell: T; - - /** - * A custom renderer for the context menu. - */ - contextMenuRenderer?: Menu.IRenderer; - } - - /** - * An options object for application startup. - */ - export interface IStartOptions { - /** - * The ID of the DOM node to host the application shell. - * - * #### Notes - * If this is not provided, the document body will be the host. - */ - hostID?: string; - - /** - * The plugins to activate on startup. - * - * #### Notes - * These will be *in addition* to any `autoStart` plugins. - */ - startPlugins?: string[]; - - /** - * The plugins to **not** activate on startup. - * - * #### Notes - * This will override `startPlugins` and any `autoStart` plugins. - */ - ignorePlugins?: string[]; - - /** - * Whether to capture keydown event at bubbling or capturing (default) phase for - * keyboard shortcuts. - * - * @experimental - */ - bubblingKeydown?: boolean; - } -} - -/** - * The namespace for the module implementation details. - */ -namespace Private { - /** - * An object which holds the full application state for a plugin. - */ - export interface IPluginData { - /** - * The human readable ID of the plugin. - */ - readonly id: string; - - /** - * The description of the plugin. - */ - readonly description: string; - - /** - * Whether the plugin should be activated on application start or waiting for being - * required. If the value is 'defer' then the plugin should be activated only after - * the application is started. - */ - readonly autoStart: boolean | 'defer'; - - /** - * The types of required services for the plugin, or `[]`. - */ - readonly requires: Token[]; - - /** - * The types of optional services for the the plugin, or `[]`. - */ - readonly optional: Token[]; - - /** - * The type of service provided by the plugin, or `null`. - */ - readonly provides: Token | null; - - /** - * The function which activates the plugin. - */ - readonly activate: (app: Application, ...args: any[]) => any; - - /** - * The optional function which deactivates the plugin. - */ - readonly deactivate: - | ((app: Application, ...args: any[]) => void | Promise) - | null; - - /** - * Whether the plugin has been activated. - */ - activated: boolean; - - /** - * The resolved service for the plugin, or `null`. - */ - service: any | null; - - /** - * The pending resolver promise, or `null`. - */ - promise: Promise | null; - } - - /** - * Create a normalized plugin data object for the given plugin. - */ - export function createPluginData(plugin: IPlugin): IPluginData { - return { - id: plugin.id, - description: plugin.description ?? '', - service: null, - promise: null, - activated: false, - activate: plugin.activate, - deactivate: plugin.deactivate ?? null, - provides: plugin.provides ?? null, - autoStart: plugin.autoStart ?? false, - requires: plugin.requires ? plugin.requires.slice() : [], - optional: plugin.optional ? plugin.optional.slice() : [] - }; - } - - /** - * Ensure no cycle is present in the plugin resolution graph. - * - * If a cycle is detected, an error will be thrown. - */ - export function ensureNoCycle( - plugin: IPluginData, - plugins: Map, - services: Map, string> - ): void { - const dependencies = [...plugin.requires, ...plugin.optional]; - const visit = (token: Token): boolean => { - if (token === plugin.provides) { - return true; - } - const id = services.get(token); - if (!id) { - return false; - } - const visited = plugins.get(id)!; - const dependencies = [...visited.requires, ...visited.optional]; - if (dependencies.length === 0) { - return false; - } - trace.push(id); - if (dependencies.some(visit)) { - return true; - } - trace.pop(); - return false; - }; - - // Bail early if there cannot be a cycle. - if (!plugin.provides || dependencies.length === 0) { - return; - } - - // Setup a stack to trace service resolution. - const trace = [plugin.id]; - - // Throw an exception if a cycle is present. - if (dependencies.some(visit)) { - throw new ReferenceError(`Cycle detected: ${trace.join(' -> ')}.`); - } - } - - /** - * Find dependents in deactivation order. - * - * @param id - The ID of the plugin of interest. - * - * @param plugins - The map containing all plugins. - * - * @param services - The map containing all services. - * - * @returns A list of dependent plugin IDs in order of deactivation - * - * #### Notes - * The final item of the returned list is always the plugin of interest. - */ - export function findDependents( - id: string, - plugins: Map, - services: Map, string> - ): string[] { - const edges = new Array<[string, string]>(); - const add = (id: string): void => { - const plugin = plugins.get(id)!; - // FIXME In the case of missing optional dependencies, we may consider - // deactivating and reactivating the plugin without the missing service. - const dependencies = [...plugin.requires, ...plugin.optional]; - edges.push( - ...dependencies.reduce<[string, string][]>((acc, dep) => { - const service = services.get(dep); - if (service) { - // An edge is oriented from dependent to provider. - acc.push([id, service]); - } - return acc; - }, []) - ); - }; - - for (const id of plugins.keys()) { - add(id); - } - - // Filter edges - // - Get all packages that dependent on the package to be deactivated - const newEdges = edges.filter(edge => edge[1] === id); - let oldSize = 0; - while (newEdges.length > oldSize) { - const previousSize = newEdges.length; - // Get all packages that dependent on packages that will be deactivated - const packagesOfInterest = new Set(newEdges.map(edge => edge[0])); - for (const poi of packagesOfInterest) { - edges - .filter(edge => edge[1] === poi) - .forEach(edge => { - // We check it is not already included to deal with circular dependencies - if (!newEdges.includes(edge)) { - newEdges.push(edge); - } - }); - } - oldSize = previousSize; - } - - const sorted = topologicSort(newEdges); - const index = sorted.findIndex(candidate => candidate === id); - - if (index === -1) { - return [id]; - } - - return sorted.slice(0, index + 1); - } - - /** - * Collect the IDs of the plugins to activate on startup. - */ - export function collectStartupPlugins( - plugins: Map, - options: Application.IStartOptions - ): string[] { - // Create a set to hold the plugin IDs. - const collection = new Set(); - - // Collect the auto-start (non deferred) plugins. - for (const id of plugins.keys()) { - if (plugins.get(id)!.autoStart === true) { - collection.add(id); - } - } - - // Add the startup plugins. - if (options.startPlugins) { - for (const id of options.startPlugins) { - collection.add(id); - } - } - - // Remove the ignored plugins. - if (options.ignorePlugins) { - for (const id of options.ignorePlugins) { - collection.delete(id); - } - } - - // Return the collected startup plugins. - return Array.from(collection); - } -} +export * from './application'; +export * from './plugins'; diff --git a/packages/application/src/plugins.ts b/packages/application/src/plugins.ts new file mode 100644 index 000000000..bb00bec21 --- /dev/null +++ b/packages/application/src/plugins.ts @@ -0,0 +1,770 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import { topologicSort } from '@lumino/algorithm'; + +import { Token } from '@lumino/coreutils'; + +/** + * A user-defined application plugin. + * + * @typeParam T - The type for the application. + * + * @typeParam U - The service type, if the plugin `provides` one. + * + * #### Notes + * Plugins are the foundation for building an extensible application. + * + * Plugins consume and provide "services", which are nothing more than + * concrete implementations of interfaces and/or abstract types. + * + * Unlike regular imports and exports, which tie the service consumer + * to a particular implementation of the service, plugins decouple the + * service producer from the service consumer, allowing an application + * to be easily customized by third parties in a type-safe fashion. + */ +export interface IPlugin { + /** + * The human readable ID of the plugin. + * + * #### Notes + * This must be unique within an application. + */ + id: string; + + /** + * Plugin description. + * + * #### Notes + * This can be used to provide user documentation on the feature + * brought by a plugin. + */ + description?: string; + + /** + * Whether the plugin should be activated on application start or waiting for being + * required. If the value is 'defer' then the plugin should be activated only after + * the application is started. + * + * #### Notes + * The default is `false`. + */ + autoStart?: boolean | 'defer'; + + /** + * The types of required services for the plugin, if any. + * + * #### Notes + * These tokens correspond to the services that are required by + * the plugin for correct operation. + * + * When the plugin is activated, a concrete instance of each type + * will be passed to the `activate()` function, in the order they + * are specified in the `requires` array. + */ + requires?: Token[]; + + /** + * The types of optional services for the plugin, if any. + * + * #### Notes + * These tokens correspond to the services that can be used by the + * plugin if available, but are not necessarily required. + * + * The optional services will be passed to the `activate()` function + * following all required services. If an optional service cannot be + * resolved, `null` will be passed in its place. + */ + optional?: Token[]; + + /** + * The type of service provided by the plugin, if any. + * + * #### Notes + * This token corresponds to the service exported by the plugin. + * + * When the plugin is activated, the return value of `activate()` + * is used as the concrete instance of the type. + */ + provides?: Token | null; + + /** + * A function invoked to activate the plugin. + * + * @param app - The registry which owns the plugin. + * + * @param args - The services specified by the `requires` property. + * + * @returns The provided service, or a promise to the service. + * + * #### Notes + * This function will be called whenever the plugin is manually + * activated, or when another plugin being activated requires + * the service it provides. + * + * This function will not be called unless all of its required + * services can be fulfilled. + */ + activate: (app: T, ...args: any[]) => U | Promise; + + /** + * A function invoked to deactivate the plugin. + * + * @param app - The registry which owns the plugin. + * + * @param args - The services specified by the `requires` property. + */ + deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; +} + +export class PluginRegistry { + private _isAllowed: (plugin: Private.IPluginData) => boolean = () => true; + + constructor(options: PluginRegistry.IOptions = {}) { + if ((options.allowedPlugins?.size ?? 0) > 0) { + console.info('Only allowed plugins will be registered.'); + this._isAllowed = (plugin: Private.IPluginData) => + options.allowedPlugins!.has(plugin.id); + } else { + if ((options.blockedPlugins?.size ?? 0) > 0) { + console.info('Some plugins are not allowed to be registered'); + this._isAllowed = (plugin: Private.IPluginData) => + !options.blockedPlugins!.has(plugin.id); + } + } + } + + /** + * The list of all the deferred plugins. + */ + get deferredPlugins(): string[] { + return Array.from(this._plugins) + .filter(([id, plugin]) => plugin.autoStart === 'defer') + .map(([id, plugin]) => id); + } + + /** + * Get a plugin description. + * + * @param id - The ID of the plugin of interest. + * + * @returns The plugin description. + */ + getPluginDescription(id: string): string { + return this._plugins.get(id)?.description ?? ''; + } + + /** + * Test whether a plugin is registered with the application. + * + * @param id - The ID of the plugin of interest. + * + * @returns `true` if the plugin is registered, `false` otherwise. + */ + hasPlugin(id: string): boolean { + return this._plugins.has(id); + } + + /** + * Test whether a plugin is activated with the application. + * + * @param id - The ID of the plugin of interest. + * + * @returns `true` if the plugin is activated, `false` otherwise. + */ + isPluginActivated(id: string): boolean { + return this._plugins.get(id)?.activated ?? false; + } + + /** + * List the IDs of the plugins registered with the application. + * + * @returns A new array of the registered plugin IDs. + */ + listPlugins(): string[] { + return Array.from(this._plugins.keys()); + } + + /** + * Register a plugin with the application. + * + * @param plugin - The plugin to register. + * + * #### Notes + * An error will be thrown if a plugin with the same ID is already + * registered, or if the plugin has a circular dependency. + * + * If the plugin provides a service which has already been provided + * by another plugin, the new service will override the old service. + */ + registerPlugin(plugin: IPlugin): void { + // Throw an error if the plugin ID is already registered. + if (this._plugins.has(plugin.id)) { + throw new TypeError(`Plugin '${plugin.id}' is already registered.`); + } + + // Create the normalized plugin data. + const data = Private.createPluginData(plugin); + + if (!this._isAllowed(data)) { + throw new Error(`Plugin '${plugin.id}' is not allowed.`); + } + + // Ensure the plugin does not cause a cyclic dependency. + Private.ensureNoCycle(data, this._plugins, this._services); + + // Add the service token to the service map. + if (data.provides) { + this._services.set(data.provides, data.id); + } + + // Add the plugin to the plugin map. + this._plugins.set(data.id, data); + } + + /** + * Register multiple plugins with the application. + * + * @param plugins - The plugins to register. + * + * #### Notes + * This calls `registerPlugin()` for each of the given plugins. + */ + registerPlugins(plugins: IPlugin[]): void { + for (const plugin of plugins) { + this.registerPlugin(plugin); + } + } + + /** + * Deregister a plugin with the application. + * + * @param id - The ID of the plugin of interest. + * + * @param force - Whether to deregister the plugin even if it is active. + */ + deregisterPlugin(id: string, force?: boolean): void { + const plugin = this._plugins.get(id); + if (!plugin) { + return; + } + + if (plugin.activated && !force) { + throw new Error(`Plugin '${id}' is still active.`); + } + + this._plugins.delete(id); + } + + /** + * Activate the plugin with the given ID. + * + * @param id - The ID of the plugin of interest. + * + * @returns A promise which resolves when the plugin is activated + * or rejects with an error if it cannot be activated. + */ + async activatePlugin(id: string): Promise { + // Reject the promise if the plugin is not registered. + const plugin = this._plugins.get(id); + if (!plugin) { + throw new ReferenceError(`Plugin '${id}' is not registered.`); + } + + // Resolve immediately if the plugin is already activated. + if (plugin.activated) { + return; + } + + // Return the pending resolver promise if it exists. + if (plugin.promise) { + return plugin.promise; + } + + // Resolve the required services for the plugin. + const required = plugin.requires.map(t => this.resolveRequiredService(t)); + + // Resolve the optional services for the plugin. + const optional = plugin.optional.map(t => this.resolveOptionalService(t)); + + // Setup the resolver promise for the plugin. + plugin.promise = Promise.all([...required, ...optional]) + .then(services => plugin!.activate.apply(undefined, [this, ...services])) + .then(service => { + plugin!.service = service; + plugin!.activated = true; + plugin!.promise = null; + }) + .catch(error => { + plugin!.promise = null; + throw error; + }); + + // Return the pending resolver promise. + return plugin.promise; + } + + /** + * Activate all the deferred plugins. + * + * @returns A promise which will resolve when each plugin is activated + * or rejects with an error if one cannot be activated. + */ + async activatePlugins( + kind: 'startUp' | 'defer', + options: PluginRegistry.IStartOptions = {} + ): Promise { + switch (kind) { + case 'defer': { + const promises = this.deferredPlugins + .filter(pluginId => this._plugins.get(pluginId)!.autoStart) + .map(pluginId => { + return this.activatePlugin(pluginId); + }); + await Promise.all(promises); + break; + } + case 'startUp': { + // Collect the ids of the startup plugins. + const startups = Private.collectStartupPlugins(this._plugins, options); + + // Generate the activation promises. + const promises = startups.map(async id => { + try { + return await this.activatePlugin(id); + } catch (error) { + console.error(`Plugin '${id}' failed to activate.`, error); + } + }); + await Promise.all(promises); + break; + } + } + } + + /** + * Activate all the deferred plugins. + * + * @returns A promise which will resolve when each plugin is activated + * or rejects with an error if one cannot be activated. + * + * @deprecated Use {@link activatePlugins} with options `'defer'` instead. + */ + async activateDeferredPlugins(): Promise { + await this.activatePlugins('defer'); + } + + /** + * Deactivate the plugin and its downstream dependents if and only if the + * plugin and its dependents all support `deactivate`. + * + * @param id - The ID of the plugin of interest. + * + * @returns A list of IDs of downstream plugins deactivated with this one. + */ + async deactivatePlugin(id: string): Promise { + // Reject the promise if the plugin is not registered. + const plugin = this._plugins.get(id); + if (!plugin) { + throw new ReferenceError(`Plugin '${id}' is not registered.`); + } + + // Bail early if the plugin is not activated. + if (!plugin.activated) { + return []; + } + + // Check that this plugin can deactivate. + if (!plugin.deactivate) { + throw new TypeError(`Plugin '${id}'#deactivate() method missing`); + } + + // Find the optimal deactivation order for plugins downstream of this one. + const manifest = Private.findDependents(id, this._plugins, this._services); + const downstream = manifest.map(id => this._plugins.get(id)!); + + // Check that all downstream plugins can deactivate. + for (const plugin of downstream) { + if (!plugin.deactivate) { + throw new TypeError( + `Plugin ${plugin.id}#deactivate() method missing (depends on ${id})` + ); + } + } + + // Deactivate all downstream plugins. + for (const plugin of downstream) { + const services = [...plugin.requires, ...plugin.optional].map(service => { + const id = this._services.get(service); + return id ? this._plugins.get(id)!.service : null; + }); + + // Await deactivation so the next plugins only receive active services. + await plugin.deactivate!(this, ...services); + plugin.service = null; + plugin.activated = false; + } + + // Remove plugin ID and return manifest of deactivated plugins. + manifest.pop(); + return manifest; + } + + /** + * Resolve a required service of a given type. + * + * @param token - The token for the service type of interest. + * + * @returns A promise which resolves to an instance of the requested + * service, or rejects with an error if it cannot be resolved. + * + * #### Notes + * Services are singletons. The same instance will be returned each + * time a given service token is resolved. + * + * If the plugin which provides the service has not been activated, + * resolving the service will automatically activate the plugin. + * + * User code will not typically call this method directly. Instead, + * the required services for the user's plugins will be resolved + * automatically when the plugin is activated. + */ + async resolveRequiredService(token: Token): Promise { + // Reject the promise if there is no provider for the type. + const id = this._services.get(token); + if (!id) { + throw new TypeError(`No provider for: ${token.name}.`); + } + + // Activate the plugin if necessary. + const plugin = this._plugins.get(id)!; + if (!plugin.activated) { + await this.activatePlugin(id); + } + + return plugin.service; + } + + /** + * Resolve an optional service of a given type. + * + * @param token - The token for the service type of interest. + * + * @returns A promise which resolves to an instance of the requested + * service, or `null` if it cannot be resolved. + * + * #### Notes + * Services are singletons. The same instance will be returned each + * time a given service token is resolved. + * + * If the plugin which provides the service has not been activated, + * resolving the service will automatically activate the plugin. + * + * User code will not typically call this method directly. Instead, + * the optional services for the user's plugins will be resolved + * automatically when the plugin is activated. + */ + async resolveOptionalService(token: Token): Promise { + // Resolve with `null` if there is no provider for the type. + const id = this._services.get(token); + if (!id) { + return null; + } + + // Activate the plugin if necessary. + const plugin = this._plugins.get(id)!; + if (!plugin.activated) { + try { + await this.activatePlugin(id); + } catch (reason) { + console.error(reason); + return null; + } + } + + return plugin.service; + } + + private _plugins = new Map(); + private _services = new Map, string>(); +} + +export namespace PluginRegistry { + export interface IOptions { + /** + * List of allowed plugins + * + * If defined, only allowed plugins will be able to be registered. + * + * This parameter takes precedence over {@link blockedPlugins}. + */ + allowedPlugins?: Set; + /** + * List of blocked plugins + * + * If defined, blocked plugins will not be able to be registered. + */ + blockedPlugins?: Set; + } + + /** + * An options object for application startup. + */ + export interface IStartOptions { + /** + * The plugins to activate on startup. + * + * #### Notes + * These will be *in addition* to any `autoStart` plugins. + */ + startPlugins?: string[]; + + /** + * The plugins to **not** activate on startup. + * + * #### Notes + * This will override `startPlugins` and any `autoStart` plugins. + */ + ignorePlugins?: string[]; + } +} + +/** + * The namespace for the module implementation details. + */ +namespace Private { + /** + * An object which holds the full application state for a plugin. + */ + export interface IPluginData { + /** + * The human readable ID of the plugin. + */ + readonly id: string; + + /** + * The description of the plugin. + */ + readonly description: string; + + /** + * Whether the plugin should be activated on application start or waiting for being + * required. If the value is 'defer' then the plugin should be activated only after + * the application is started. + */ + readonly autoStart: boolean | 'defer'; + + /** + * The types of required services for the plugin, or `[]`. + */ + readonly requires: Token[]; + + /** + * The types of optional services for the the plugin, or `[]`. + */ + readonly optional: Token[]; + + /** + * The type of service provided by the plugin, or `null`. + */ + readonly provides: Token | null; + + /** + * The function which activates the plugin. + */ + readonly activate: (app: PluginRegistry, ...args: any[]) => any; + + /** + * The optional function which deactivates the plugin. + */ + readonly deactivate: + | ((app: PluginRegistry, ...args: any[]) => void | Promise) + | null; + + /** + * Whether the plugin has been activated. + */ + activated: boolean; + + /** + * The resolved service for the plugin, or `null`. + */ + service: any | null; + + /** + * The pending resolver promise, or `null`. + */ + promise: Promise | null; + } + + /** + * Create a normalized plugin data object for the given plugin. + */ + export function createPluginData(plugin: IPlugin): IPluginData { + return Object.freeze({ + id: plugin.id, + description: plugin.description ?? '', + service: null, + promise: null, + activated: false, + activate: plugin.activate, + deactivate: plugin.deactivate ?? null, + provides: plugin.provides ?? null, + autoStart: plugin.autoStart ?? false, + requires: plugin.requires ? plugin.requires.slice() : [], + optional: plugin.optional ? plugin.optional.slice() : [] + }); + } + + /** + * Ensure no cycle is present in the plugin resolution graph. + * + * If a cycle is detected, an error will be thrown. + */ + export function ensureNoCycle( + plugin: IPluginData, + plugins: Map, + services: Map, string> + ): void { + const dependencies = [...plugin.requires, ...plugin.optional]; + const visit = (token: Token): boolean => { + if (token === plugin.provides) { + return true; + } + const id = services.get(token); + if (!id) { + return false; + } + const visited = plugins.get(id)!; + const dependencies = [...visited.requires, ...visited.optional]; + if (dependencies.length === 0) { + return false; + } + trace.push(id); + if (dependencies.some(visit)) { + return true; + } + trace.pop(); + return false; + }; + + // Bail early if there cannot be a cycle. + if (!plugin.provides || dependencies.length === 0) { + return; + } + + // Setup a stack to trace service resolution. + const trace = [plugin.id]; + + // Throw an exception if a cycle is present. + if (dependencies.some(visit)) { + throw new ReferenceError(`Cycle detected: ${trace.join(' -> ')}.`); + } + } + + /** + * Find dependents in deactivation order. + * + * @param id - The ID of the plugin of interest. + * + * @param plugins - The map containing all plugins. + * + * @param services - The map containing all services. + * + * @returns A list of dependent plugin IDs in order of deactivation + * + * #### Notes + * The final item of the returned list is always the plugin of interest. + */ + export function findDependents( + id: string, + plugins: Map, + services: Map, string> + ): string[] { + const edges = new Array<[string, string]>(); + const add = (id: string): void => { + const plugin = plugins.get(id)!; + // FIXME In the case of missing optional dependencies, we may consider + // deactivating and reactivating the plugin without the missing service. + const dependencies = [...plugin.requires, ...plugin.optional]; + edges.push( + ...dependencies.reduce<[string, string][]>((acc, dep) => { + const service = services.get(dep); + if (service) { + // An edge is oriented from dependent to provider. + acc.push([id, service]); + } + return acc; + }, []) + ); + }; + + for (const id of plugins.keys()) { + add(id); + } + + // Filter edges + // - Get all packages that dependent on the package to be deactivated + const newEdges = edges.filter(edge => edge[1] === id); + let oldSize = 0; + while (newEdges.length > oldSize) { + const previousSize = newEdges.length; + // Get all packages that dependent on packages that will be deactivated + const packagesOfInterest = new Set(newEdges.map(edge => edge[0])); + for (const poi of packagesOfInterest) { + edges + .filter(edge => edge[1] === poi) + .forEach(edge => { + // We check it is not already included to deal with circular dependencies + if (!newEdges.includes(edge)) { + newEdges.push(edge); + } + }); + } + oldSize = previousSize; + } + + const sorted = topologicSort(newEdges); + const index = sorted.findIndex(candidate => candidate === id); + + if (index === -1) { + return [id]; + } + + return sorted.slice(0, index + 1); + } + + /** + * Collect the IDs of the plugins to activate on startup. + */ + export function collectStartupPlugins( + plugins: Map, + options: PluginRegistry.IStartOptions + ): string[] { + // Create a set to hold the plugin IDs. + const collection = new Set(); + + // Collect the auto-start (non deferred) plugins. + for (const id of plugins.keys()) { + if (plugins.get(id)!.autoStart === true) { + collection.add(id); + } + } + + // Add the startup plugins. + if (options.startPlugins) { + for (const id of options.startPlugins) { + collection.add(id); + } + } + + // Remove the ignored plugins. + if (options.ignorePlugins) { + for (const id of options.ignorePlugins) { + collection.delete(id); + } + } + + // Return the collected startup plugins. + return Array.from(collection); + } +} From a10d181426d8d134eef930102bbf7665de5c7e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Sun, 19 May 2024 09:48:02 +0200 Subject: [PATCH 02/11] Start test for PluginRegistry --- packages/application/src/application.ts | 196 +++++- packages/application/src/plugins.ts | 176 ++++-- packages/application/tests/src/index.spec.ts | 603 ++++++++++++++++++- 3 files changed, 926 insertions(+), 49 deletions(-) diff --git a/packages/application/src/application.ts b/packages/application/src/application.ts index 38fe95a2b..ed8e9a33d 100644 --- a/packages/application/src/application.ts +++ b/packages/application/src/application.ts @@ -2,10 +2,11 @@ // Distributed under the terms of the Modified BSD License. import { CommandRegistry } from '@lumino/commands'; -import { PromiseDelegate } from '@lumino/coreutils'; +import { PromiseDelegate, type Token } from '@lumino/coreutils'; import { ContextMenu, Menu, Widget } from '@lumino/widgets'; -import { PluginRegistry } from './plugins'; + +import { type IPlugin, PluginRegistry } from './plugins'; /** * A class for creating pluggable applications. @@ -17,14 +18,22 @@ import { PluginRegistry } from './plugins'; * UI applications with the ability to be safely extended by third * party code via plugins. */ -export class Application extends PluginRegistry { +export class Application { + /** + * Application plugin registry. + */ + protected pluginRegistry: PluginRegistry; + /** * Construct a new application. * * @param options - The options for creating the application. */ constructor(options: Application.IOptions) { - super(); + this.pluginRegistry = + options.pluginRegistry ?? new PluginRegistry(options); + this.pluginRegistry.application = this; + // Initialize the application state. this.commands = new CommandRegistry(); this.contextMenu = new ContextMenu({ @@ -54,6 +63,13 @@ export class Application extends PluginRegistry { */ readonly shell: T; + /** + * The list of all the deferred plugins. + */ + get deferredPlugins(): string[] { + return this.pluginRegistry.deferredPlugins; + } + /** * A promise which resolves after the application has started. * @@ -65,6 +81,166 @@ export class Application extends PluginRegistry { return this._delegate.promise; } + /** + * Activate all the deferred plugins. + * + * @returns A promise which will resolve when each plugin is activated + * or rejects with an error if one cannot be activated. + */ + async activateDeferredPlugins(): Promise { + await this.pluginRegistry.activatePlugins('defer'); + } + + /** + * Activate the plugin with the given ID. + * + * @param id - The ID of the plugin of interest. + * + * @returns A promise which resolves when the plugin is activated + * or rejects with an error if it cannot be activated. + */ + async activatePlugin(id: string): Promise { + return this.pluginRegistry.activatePlugin(id); + } + + /** + * Deactivate the plugin and its downstream dependents if and only if the + * plugin and its dependents all support `deactivate`. + * + * @param id - The ID of the plugin of interest. + * + * @returns A list of IDs of downstream plugins deactivated with this one. + */ + async deactivatePlugin(id: string): Promise { + return this.pluginRegistry.deactivatePlugin(id); + } + + /** + * Deregister a plugin with the application. + * + * @param id - The ID of the plugin of interest. + * + * @param force - Whether to deregister the plugin even if it is active. + */ + deregisterPlugin(id: string, force?: boolean): void { + this.pluginRegistry.deregisterPlugin(id, force); + } + + /** + * Get a plugin description. + * + * @param id - The ID of the plugin of interest. + * + * @returns The plugin description. + */ + getPluginDescription(id: string): string { + return this.pluginRegistry.getPluginDescription(id); + } + + /** + * Test whether a plugin is registered with the application. + * + * @param id - The ID of the plugin of interest. + * + * @returns `true` if the plugin is registered, `false` otherwise. + */ + hasPlugin(id: string): boolean { + return this.pluginRegistry.hasPlugin(id); + } + + /** + * Test whether a plugin is activated with the application. + * + * @param id - The ID of the plugin of interest. + * + * @returns `true` if the plugin is activated, `false` otherwise. + */ + isPluginActivated(id: string): boolean { + return this.pluginRegistry.isPluginActivated(id); + } + + /** + * List the IDs of the plugins registered with the application. + * + * @returns A new array of the registered plugin IDs. + */ + listPlugins(): string[] { + return this.pluginRegistry.listPlugins(); + } + + /** + * Register a plugin with the application. + * + * @param plugin - The plugin to register. + * + * #### Notes + * An error will be thrown if a plugin with the same ID is already + * registered, or if the plugin has a circular dependency. + * + * If the plugin provides a service which has already been provided + * by another plugin, the new service will override the old service. + */ + registerPlugin(plugin: IPlugin): void { + this.pluginRegistry.registerPlugin(plugin); + } + + /** + * Register multiple plugins with the application. + * + * @param plugins - The plugins to register. + * + * #### Notes + * This calls `registerPlugin()` for each of the given plugins. + */ + registerPlugins(plugins: IPlugin[]): void { + this.pluginRegistry.registerPlugins(plugins); + } + + /** + * Resolve an optional service of a given type. + * + * @param token - The token for the service type of interest. + * + * @returns A promise which resolves to an instance of the requested + * service, or `null` if it cannot be resolved. + * + * #### Notes + * Services are singletons. The same instance will be returned each + * time a given service token is resolved. + * + * If the plugin which provides the service has not been activated, + * resolving the service will automatically activate the plugin. + * + * User code will not typically call this method directly. Instead, + * the optional services for the user's plugins will be resolved + * automatically when the plugin is activated. + */ + async resolveOptionalService(token: Token): Promise { + return this.pluginRegistry.resolveOptionalService(token); + } + + /** + * Resolve a required service of a given type. + * + * @param token - The token for the service type of interest. + * + * @returns A promise which resolves to an instance of the requested + * service, or rejects with an error if it cannot be resolved. + * + * #### Notes + * Services are singletons. The same instance will be returned each + * time a given service token is resolved. + * + * If the plugin which provides the service has not been activated, + * resolving the service will automatically activate the plugin. + * + * User code will not typically call this method directly. Instead, + * the required services for the user's plugins will be resolved + * automatically when the plugin is activated. + */ + async resolveRequiredService(token: Token): Promise { + return this.pluginRegistry.resolveRequiredService(token); + } /** * Start the application. * @@ -101,7 +277,7 @@ export class Application extends PluginRegistry { const hostID = options.hostID ?? ''; // Wait for the plugins to activate, then finalize startup. - await this.activatePlugins('startUp', options); + await this.pluginRegistry.activatePlugins('startUp', options); this.attachShell(hostID); this.addEventListeners(); @@ -241,7 +417,7 @@ export namespace Application { /** * An options object for creating an application. */ - export interface IOptions { + export interface IOptions extends PluginRegistry.IOptions { /** * The shell widget to use for the application. * @@ -255,6 +431,14 @@ export namespace Application { * A custom renderer for the context menu. */ contextMenuRenderer?: Menu.IRenderer; + + /** + * Application plugin registry. + * + * If defined the options related to the plugin registry will + * be ignored. + */ + pluginRegistry?: PluginRegistry; } /** diff --git a/packages/application/src/plugins.ts b/packages/application/src/plugins.ts index bb00bec21..2f447ef5c 100644 --- a/packages/application/src/plugins.ts +++ b/packages/application/src/plugins.ts @@ -22,7 +22,7 @@ import { Token } from '@lumino/coreutils'; * service producer from the service consumer, allowing an application * to be easily customized by third parties in a type-safe fashion. */ -export interface IPlugin { +export interface IPlugin { /** * The human readable ID of the plugin. * @@ -90,7 +90,7 @@ export interface IPlugin { /** * A function invoked to activate the plugin. * - * @param app - The registry which owns the plugin. + * @param app - The object returned by the method {@link PluginRegistry.getPluginApp} . * * @param args - The services specified by the `requires` property. * @@ -109,30 +109,49 @@ export interface IPlugin { /** * A function invoked to deactivate the plugin. * - * @param app - The registry which owns the plugin. + * @param app - The object returned by the method {@link PluginRegistry.getPluginApp} . * * @param args - The services specified by the `requires` property. */ deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; } -export class PluginRegistry { - private _isAllowed: (plugin: Private.IPluginData) => boolean = () => true; - +/** + * Abstract plugin registry. + */ +export class PluginRegistry { constructor(options: PluginRegistry.IOptions = {}) { if ((options.allowedPlugins?.size ?? 0) > 0) { console.info('Only allowed plugins will be registered.'); - this._isAllowed = (plugin: Private.IPluginData) => + this._isAllowed = (plugin: Private.IPluginData) => options.allowedPlugins!.has(plugin.id); + if ((options.blockedPlugins?.size ?? 0) > 0) { + console.warn( + 'Allowed and blocked plugins are defined simultaneously. The allowed list will take precedence.' + ); + } } else { if ((options.blockedPlugins?.size ?? 0) > 0) { console.info('Some plugins are not allowed to be registered'); - this._isAllowed = (plugin: Private.IPluginData) => + this._isAllowed = (plugin: Private.IPluginData) => !options.blockedPlugins!.has(plugin.id); } } } + get application(): T { + return this._application; + } + set application(v: any) { + if (this._application !== null) { + throw Error( + 'PluginRegistry.application is already set. It cannot be overridden.' + ); + } + + this._application = v; + } + /** * The list of all the deferred plugins. */ @@ -196,7 +215,7 @@ export class PluginRegistry { * If the plugin provides a service which has already been provided * by another plugin, the new service will override the old service. */ - registerPlugin(plugin: IPlugin): void { + registerPlugin(plugin: IPlugin): void { // Throw an error if the plugin ID is already registered. if (this._plugins.has(plugin.id)) { throw new TypeError(`Plugin '${plugin.id}' is already registered.`); @@ -229,7 +248,7 @@ export class PluginRegistry { * #### Notes * This calls `registerPlugin()` for each of the given plugins. */ - registerPlugins(plugins: IPlugin[]): void { + registerPlugins(plugins: IPlugin[]): void { for (const plugin of plugins) { this.registerPlugin(plugin); } @@ -288,7 +307,9 @@ export class PluginRegistry { // Setup the resolver promise for the plugin. plugin.promise = Promise.all([...required, ...optional]) - .then(services => plugin!.activate.apply(undefined, [this, ...services])) + .then(services => + plugin!.activate.apply(undefined, [this.application, ...services]) + ) .then(service => { plugin!.service = service; plugin!.activated = true; @@ -341,18 +362,6 @@ export class PluginRegistry { } } - /** - * Activate all the deferred plugins. - * - * @returns A promise which will resolve when each plugin is activated - * or rejects with an error if one cannot be activated. - * - * @deprecated Use {@link activatePlugins} with options `'defer'` instead. - */ - async activateDeferredPlugins(): Promise { - await this.activatePlugins('defer'); - } - /** * Deactivate the plugin and its downstream dependents if and only if the * plugin and its dependents all support `deactivate`. @@ -399,7 +408,7 @@ export class PluginRegistry { }); // Await deactivation so the next plugins only receive active services. - await plugin.deactivate!(this, ...services); + await plugin.deactivate!(this.application, ...services); plugin.service = null; plugin.activated = false; } @@ -484,7 +493,9 @@ export class PluginRegistry { return plugin.service; } - private _plugins = new Map(); + private _application: any = null; + private _isAllowed: (plugin: Private.IPluginData) => boolean = () => true; + private _plugins = new Map>(); private _services = new Map, string>(); } @@ -535,7 +546,7 @@ namespace Private { /** * An object which holds the full application state for a plugin. */ - export interface IPluginData { + export interface IPluginData { /** * The human readable ID of the plugin. */ @@ -571,13 +582,13 @@ namespace Private { /** * The function which activates the plugin. */ - readonly activate: (app: PluginRegistry, ...args: any[]) => any; + readonly activate: (app: T, ...args: any[]) => any; /** * The optional function which deactivates the plugin. */ readonly deactivate: - | ((app: PluginRegistry, ...args: any[]) => void | Promise) + | ((app: T, ...args: any[]) => void | Promise) | null; /** @@ -596,23 +607,104 @@ namespace Private { promise: Promise | null; } + class PluginData implements IPluginData { + private _activated = false; + private _promise: Promise | null = null; + private _service: U | null = null; + + constructor(plugin: IPlugin) { + this.id = plugin.id; + this.description = plugin.description ?? ''; + this.activate = plugin.activate; + this.deactivate = plugin.deactivate ?? null; + this.provides = plugin.provides ?? null; + this.autoStart = plugin.autoStart ?? false; + this.requires = plugin.requires ? plugin.requires.slice() : []; + this.optional = plugin.optional ? plugin.optional.slice() : []; + } + + /** + * The human readable ID of the plugin. + */ + readonly id: string; + + /** + * The description of the plugin. + */ + readonly description: string; + + /** + * Whether the plugin should be activated on application start or waiting for being + * required. If the value is 'defer' then the plugin should be activated only after + * the application is started. + */ + readonly autoStart: boolean | 'defer'; + + /** + * The types of required services for the plugin, or `[]`. + */ + readonly requires: Token[]; + + /** + * The types of optional services for the the plugin, or `[]`. + */ + readonly optional: Token[]; + + /** + * The type of service provided by the plugin, or `null`. + */ + readonly provides: Token | null; + + /** + * The function which activates the plugin. + */ + readonly activate: (app: T, ...args: any[]) => any; + + /** + * The optional function which deactivates the plugin. + */ + readonly deactivate: + | ((app: T, ...args: any[]) => void | Promise) + | null; + + /** + * Whether the plugin has been activated. + */ + get activated(): boolean { + return this._activated; + } + set activated(a: boolean) { + this._activated = a; + } + + /** + * The resolved service for the plugin, or `null`. + */ + get service(): U | null { + return this._service; + } + set service(s: U | null) { + this._service = s; + } + + /** + * The pending resolver promise, or `null`. + */ + get promise(): Promise | null { + return this._promise; + } + set promise(p: Promise | null) { + this._promise = p; + } + } + /** * Create a normalized plugin data object for the given plugin. */ - export function createPluginData(plugin: IPlugin): IPluginData { - return Object.freeze({ - id: plugin.id, - description: plugin.description ?? '', - service: null, - promise: null, - activated: false, - activate: plugin.activate, - deactivate: plugin.deactivate ?? null, - provides: plugin.provides ?? null, - autoStart: plugin.autoStart ?? false, - requires: plugin.requires ? plugin.requires.slice() : [], - optional: plugin.optional ? plugin.optional.slice() : [] - }); + export function createPluginData( + plugin: IPlugin + ): IPluginData { + return new PluginData(plugin); } /** diff --git a/packages/application/tests/src/index.spec.ts b/packages/application/tests/src/index.spec.ts index c73ef01c6..ba7a46c63 100644 --- a/packages/application/tests/src/index.spec.ts +++ b/packages/application/tests/src/index.spec.ts @@ -9,7 +9,7 @@ |----------------------------------------------------------------------------*/ import { expect } from 'chai'; -import { Application } from '@lumino/application'; +import { Application, PluginRegistry } from '@lumino/application'; import { ContextMenu, Widget } from '@lumino/widgets'; import { CommandRegistry } from '@lumino/commands'; import { Token } from '@lumino/coreutils'; @@ -596,4 +596,605 @@ describe('@lumino/application', () => { }); }); }); + + describe('PluginRegistry', () => { + describe('#constructor', () => { + it('should instantiate an plugin registry without options', () => { + const plugins = new PluginRegistry(); + + expect(plugins).to.be.instanceOf(PluginRegistry); + }); + + it('should accept allowed plugins list', () => { + const plugins = new PluginRegistry({ + allowedPlugins: new Set(['plugin1', 'plugin2']) + }); + + expect(plugins).to.be.instanceOf(PluginRegistry); + }); + + it('should accept blocked plugins list', () => { + const plugins = new PluginRegistry({ + blockedPlugins: new Set(['plugin1', 'plugin2']) + }); + + expect(plugins).to.be.instanceOf(PluginRegistry); + }); + + it('should accept allowed and blocked plugins lists', () => { + const plugins = new PluginRegistry({ + allowedPlugins: new Set(['plugin1', 'plugin2']), + blockedPlugins: new Set(['plugin3']) + }); + + expect(plugins).to.be.instanceOf(PluginRegistry); + }); + }); + + describe('#getPluginDescription', () => { + it('should return the plugin description', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + const description = 'Plugin 1 description'; + plugins.registerPlugin({ + id, + description, + activate: () => { + // no-op + } + }); + + expect(plugins.getPluginDescription(id)).to.equal(description); + }); + + it('should return an empty string if plugin has no description', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.getPluginDescription(id)).to.equal(''); + }); + + it('should return an empty string if plugin does not exist', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + + expect(plugins.getPluginDescription(id)).to.equal(''); + }); + }); + + describe('#hasPlugin', () => { + it('should be true for registered plugin', () => { + const pluginRegistry = new PluginRegistry(); + const id = 'plugin1'; + pluginRegistry.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(pluginRegistry.hasPlugin(id)).to.be.true; + }); + + it('should be false for unregistered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.hasPlugin('plugin2')).to.be.false; + }); + }); + + describe('#isPluginActivated', () => { + it('should be true for activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be true for an autoStart plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + }, + autoStart: true + }); + await plugins.activatePlugins('startUp'); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be false for not activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + expect(plugins.isPluginActivated(id)).to.be.false; + }); + + it('should be false for deferred plugin when application start', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + }, + autoStart: 'defer' + }); + await plugins.activatePlugins('startUp'); + expect(plugins.isPluginActivated(id)).to.be.false; + await plugins.activatePlugins('defer'); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be false for unregistered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated('no-registered')).to.be.false; + }); + }); + + describe('#listPlugins', () => { + it('should list the registered plugin', () => { + const plugins = new PluginRegistry(); + const ids = ['plugin1', 'plugin2']; + ids.forEach(id => { + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + }); + + expect(plugins.listPlugins()).to.deep.equal(ids); + }); + }); + + describe('#registerPlugin', () => { + it('should register a plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.hasPlugin(id)).to.be.true; + }); + + it('should not register an already registered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(function () { + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + }).to.throw(); + }); + + it('should not register a plugin introducing a cycle', () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + requires: [token3], + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + + expect(function () { + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + }).to.throw(); + }); + + it('should register a plugin defined by a class', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + const plugin = new (class { + readonly id = id; + activate = () => { + // Check this.id is accessible as expected + // as we are tearing a part the plugin object. + expect(this.id).to.equal(id); + }; + })(); + plugins.registerPlugin(plugin); + + expect(plugins.hasPlugin(id)).to.be.true; + }); + }); + + describe('#deregisterPlugin', () => { + it('should deregister a deactivated registered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + plugins.deregisterPlugin(id); + + expect(plugins.hasPlugin(id)).to.be.false; + }); + + it('should not deregister an activated registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + expect(() => { + plugins.deregisterPlugin(id); + }).to.throw(); + expect(plugins.hasPlugin(id)).to.be.true; + }); + + it('should force deregister an activated registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + plugins.deregisterPlugin(id, true); + expect(plugins.hasPlugin(id)).to.be.false; + }); + }); + + describe('#activatePlugin', () => { + it('should activate a registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should throw an error when activating a unregistered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + try { + await plugins.activatePlugin('other-id'); + } catch (reason) { + return; + } + + expect(false, 'app.activatePlugin did not throw').to.be.true; + }); + + it('should tolerate activating an activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + + await plugins.activatePlugin(id); + + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should activate all required services', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + expect(plugins.isPluginActivated(id3)).to.be.true; + expect(plugins.isPluginActivated(id1)).to.be.true; + expect(plugins.isPluginActivated(id2)).to.be.true; + }); + + it('should try activating all optional services', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + throw new Error(`Force failure during '${id2}' activation`); + }, + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + optional: [token1, token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + expect(plugins.isPluginActivated(id3)).to.be.true; + expect(plugins.isPluginActivated(id1)).to.be.true; + expect(plugins.isPluginActivated(id2)).to.be.false; + }); + }); + + describe('#deactivatePlugin', () => { + it('should call deactivate on the plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + let deactivated: boolean | null = null; + plugins.registerPlugin({ + id, + activate: () => { + deactivated = false; + }, + deactivate: () => { + deactivated = true; + } + }); + + await plugins.activatePlugin(id); + + expect(deactivated).to.be.false; + + const others = await plugins.deactivatePlugin(id); + + expect(deactivated).to.be.true; + expect(others.length).to.equal(0); + }); + + it('should throw an error if the plugin does not support deactivation', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + try { + await plugins.deactivatePlugin(id); + } catch (r) { + return; + } + + expect(true, 'app.deactivatePlugin did not throw').to.be.false; + }); + + it('should throw an error if the plugin has dependants not support deactivation', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + try { + await plugins.deactivatePlugin(id1); + } catch (r) { + return; + } + + expect(true, 'app.deactivatePlugin did not throw').to.be.false; + }); + + it('should deactivate all dependents (optional or not)', async () => { + const plugins = new PluginRegistry(); + let deactivated: boolean | null = null; + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + deactivated = false; + }, + deactivate: () => { + deactivated = true; + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + optional: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + const others = await plugins.deactivatePlugin(id1); + + expect(deactivated).to.be.true; + expect(others).to.deep.equal([id3, id2]); + expect(plugins.isPluginActivated(id2)).to.be.false; + expect(plugins.isPluginActivated(id3)).to.be.false; + }); + }); + }); }); From a227e96f94f09fbaa00ad0d3f605b0a402f66011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Sun, 19 May 2024 09:57:45 +0200 Subject: [PATCH 03/11] Add new tests --- packages/application/src/plugins.ts | 2 +- packages/application/tests/src/index.spec.ts | 90 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/application/src/plugins.ts b/packages/application/src/plugins.ts index 2f447ef5c..34f8982fa 100644 --- a/packages/application/src/plugins.ts +++ b/packages/application/src/plugins.ts @@ -117,7 +117,7 @@ export interface IPlugin { } /** - * Abstract plugin registry. + * Plugin registry. */ export class PluginRegistry { constructor(options: PluginRegistry.IOptions = {}) { diff --git a/packages/application/tests/src/index.spec.ts b/packages/application/tests/src/index.spec.ts index ba7a46c63..e688ad915 100644 --- a/packages/application/tests/src/index.spec.ts +++ b/packages/application/tests/src/index.spec.ts @@ -28,6 +28,35 @@ describe('@lumino/application', () => { expect(app.contextMenu).to.be.instanceOf(ContextMenu); expect(app.shell).to.equal(shell); }); + + it('should accept an external plugin registry', async () => { + const shell = new Widget(); + const pluginRegistry = new PluginRegistry(); + const id1 = 'plugin1'; + pluginRegistry.registerPlugin({ + id: id1, + activate: () => { + // no-op + } + }); + const id2 = 'plugin2'; + pluginRegistry.registerPlugin({ + id: id2, + activate: () => { + // no-op + } + }); + + const app = new Application({ + shell, + pluginRegistry + }); + + await pluginRegistry.activatePlugin(id2); + + expect(app.hasPlugin(id1)).to.be.true; + expect(app.isPluginActivated(id2)).to.be.true; + }); }); describe('#getPluginDescription', () => { @@ -869,6 +898,67 @@ describe('@lumino/application', () => { expect(plugins.hasPlugin(id)).to.be.true; }); + + it('should refuse to register not allowed plugins', async () => { + const plugins = new PluginRegistry({ + allowedPlugins: new Set(['id1']) + }); + expect(function () { + plugins.registerPlugin({ + id: 'id', + activate: () => { + /* no-op */ + } + }); + }).to.throw(); + plugins.registerPlugin({ + id: 'id1', + activate: () => { + /* no-op */ + } + }); + }); + + it('should refuse to register blocked plugins', async () => { + const plugins = new PluginRegistry({ + blockedPlugins: new Set(['id1']) + }); + expect(function () { + plugins.registerPlugin({ + id: 'id1', + activate: () => { + /* no-op */ + } + }); + }).to.throw(); + plugins.registerPlugin({ + id: 'id2', + activate: () => { + /* no-op */ + } + }); + }); + + it('should use allowed list over blocked list of plugins', async () => { + const plugins = new PluginRegistry({ + allowedPlugins: new Set(['id1', 'id2']), + blockedPlugins: new Set(['id2']) + }); + expect(function () { + plugins.registerPlugin({ + id: 'id', + activate: () => { + /* no-op */ + } + }); + }).to.throw(); + plugins.registerPlugin({ + id: 'id2', + activate: () => { + /* no-op */ + } + }); + }); }); describe('#deregisterPlugin', () => { From cdf4eaaa3e3ce8a9dcbf5de47d373a6ce57272ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Sun, 19 May 2024 10:05:13 +0200 Subject: [PATCH 04/11] Add test for `PluginRegistry.application` --- packages/application/tests/src/index.spec.ts | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/application/tests/src/index.spec.ts b/packages/application/tests/src/index.spec.ts index e688ad915..0898a3b1b 100644 --- a/packages/application/tests/src/index.spec.ts +++ b/packages/application/tests/src/index.spec.ts @@ -660,6 +660,36 @@ describe('@lumino/application', () => { }); }); + describe('#application', () => { + it('should be null by default', () => { + const plugins = new PluginRegistry(); + + expect(plugins.application).to.be.null; + }); + + it('should accept any object', () => { + const plugins = new PluginRegistry(); + + const app = Object.freeze({}); + plugins.application = app; + + expect(plugins.application).to.be.equal(app); + }); + + it('cannot be overridden', () => { + const plugins = new PluginRegistry(); + + const app = Object.freeze({}); + plugins.application = app; + + expect(plugins.application).to.be.equal(app); + + expect(function () { + plugins.application = Object.freeze({}); + }).to.throw(); + }); + }); + describe('#getPluginDescription', () => { it('should return the plugin description', () => { const plugins = new PluginRegistry(); From 9e28c2d334013474cf7f14f00fe6e1c64f180e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Sun, 19 May 2024 10:13:59 +0200 Subject: [PATCH 05/11] Minor tweaks --- packages/application/src/application.ts | 9 +++++---- packages/application/src/plugins.ts | 21 +++++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/application/src/application.ts b/packages/application/src/application.ts index ed8e9a33d..51f8d794c 100644 --- a/packages/application/src/application.ts +++ b/packages/application/src/application.ts @@ -19,10 +19,6 @@ import { type IPlugin, PluginRegistry } from './plugins'; * party code via plugins. */ export class Application { - /** - * Application plugin registry. - */ - protected pluginRegistry: PluginRegistry; /** * Construct a new application. @@ -241,6 +237,7 @@ export class Application { async resolveRequiredService(token: Token): Promise { return this.pluginRegistry.resolveRequiredService(token); } + /** * Start the application. * @@ -405,6 +402,10 @@ export class Application { this.shell.update(); } + /** + * Application plugin registry. + */ + protected pluginRegistry: PluginRegistry; private _delegate = new PromiseDelegate(); private _started = false; private _bubblingKeydown = false; diff --git a/packages/application/src/plugins.ts b/packages/application/src/plugins.ts index 34f8982fa..e6b169034 100644 --- a/packages/application/src/plugins.ts +++ b/packages/application/src/plugins.ts @@ -90,7 +90,7 @@ export interface IPlugin { /** * A function invoked to activate the plugin. * - * @param app - The object returned by the method {@link PluginRegistry.getPluginApp} . + * @param app - The application provided by {@link PluginRegistry.application} . * * @param args - The services specified by the `requires` property. * @@ -109,7 +109,7 @@ export interface IPlugin { /** * A function invoked to deactivate the plugin. * - * @param app - The object returned by the method {@link PluginRegistry.getPluginApp} . + * @param app - The application {@link PluginRegistry.application} . * * @param args - The services specified by the `requires` property. */ @@ -139,6 +139,16 @@ export class PluginRegistry { } } + /** + * The application object. + * + * It will be provided as first argument to the + * plugins activation and deactivation functions. + * + * It can only be set once. + * + * By default, it is `null`. + */ get application(): T { return this._application; } @@ -499,7 +509,14 @@ export class PluginRegistry { private _services = new Map, string>(); } +/** + * PluginRegistry namespace + */ export namespace PluginRegistry { + + /** + * PluginRegistry constructor options. + */ export interface IOptions { /** * List of allowed plugins From f6604179d31cc270d5a57bd32595c8f89d6dd421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Sun, 19 May 2024 10:17:28 +0200 Subject: [PATCH 06/11] Update API and lint --- packages/application/src/application.ts | 3 +- packages/application/src/plugins.ts | 1 - review/api/application.api.md | 38 +++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/application/src/application.ts b/packages/application/src/application.ts index 51f8d794c..2d51eef11 100644 --- a/packages/application/src/application.ts +++ b/packages/application/src/application.ts @@ -19,7 +19,6 @@ import { type IPlugin, PluginRegistry } from './plugins'; * party code via plugins. */ export class Application { - /** * Construct a new application. * @@ -435,7 +434,7 @@ export namespace Application { /** * Application plugin registry. - * + * * If defined the options related to the plugin registry will * be ignored. */ diff --git a/packages/application/src/plugins.ts b/packages/application/src/plugins.ts index e6b169034..7fe9b73ee 100644 --- a/packages/application/src/plugins.ts +++ b/packages/application/src/plugins.ts @@ -513,7 +513,6 @@ export class PluginRegistry { * PluginRegistry namespace */ export namespace PluginRegistry { - /** * PluginRegistry constructor options. */ diff --git a/review/api/application.api.md b/review/api/application.api.md index 7c884b252..898956581 100644 --- a/review/api/application.api.md +++ b/review/api/application.api.md @@ -31,6 +31,7 @@ export class Application { hasPlugin(id: string): boolean; isPluginActivated(id: string): boolean; listPlugins(): string[]; + protected pluginRegistry: PluginRegistry; registerPlugin(plugin: IPlugin): void; registerPlugins(plugins: IPlugin[]): void; resolveOptionalService(token: Token): Promise; @@ -42,8 +43,9 @@ export class Application { // @public export namespace Application { - export interface IOptions { + export interface IOptions extends PluginRegistry.IOptions { contextMenuRenderer?: Menu.IRenderer; + pluginRegistry?: PluginRegistry; shell: T; } export interface IStartOptions { @@ -55,7 +57,7 @@ export namespace Application { } // @public -export interface IPlugin { +export interface IPlugin { activate: (app: T, ...args: any[]) => U | Promise; autoStart?: boolean | 'defer'; deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; @@ -66,6 +68,36 @@ export interface IPlugin { requires?: Token[]; } -// (No @packageDocumentation comment for this package) +// @public +export class PluginRegistry { + constructor(options?: PluginRegistry.IOptions); + activatePlugin(id: string): Promise; + activatePlugins(kind: 'startUp' | 'defer', options?: PluginRegistry.IStartOptions): Promise; + get application(): T; + set application(v: any); + deactivatePlugin(id: string): Promise; + get deferredPlugins(): string[]; + deregisterPlugin(id: string, force?: boolean): void; + getPluginDescription(id: string): string; + hasPlugin(id: string): boolean; + isPluginActivated(id: string): boolean; + listPlugins(): string[]; + registerPlugin(plugin: IPlugin): void; + registerPlugins(plugins: IPlugin[]): void; + resolveOptionalService(token: Token): Promise; + resolveRequiredService(token: Token): Promise; +} + +// @public +export namespace PluginRegistry { + export interface IOptions { + allowedPlugins?: Set; + blockedPlugins?: Set; + } + export interface IStartOptions { + ignorePlugins?: string[]; + startPlugins?: string[]; + } +} ``` From e7aba13fd35130d4407b8d0ef1d81cb0de8a1571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Mon, 20 May 2024 10:18:31 +0200 Subject: [PATCH 07/11] Switch from allowed/blocked list to validate function for plugins --- packages/application/src/plugins.ts | 47 +++++--------- packages/application/tests/src/index.spec.ts | 68 ++------------------ 2 files changed, 22 insertions(+), 93 deletions(-) diff --git a/packages/application/src/plugins.ts b/packages/application/src/plugins.ts index 7fe9b73ee..8de285891 100644 --- a/packages/application/src/plugins.ts +++ b/packages/application/src/plugins.ts @@ -121,21 +121,9 @@ export interface IPlugin { */ export class PluginRegistry { constructor(options: PluginRegistry.IOptions = {}) { - if ((options.allowedPlugins?.size ?? 0) > 0) { - console.info('Only allowed plugins will be registered.'); - this._isAllowed = (plugin: Private.IPluginData) => - options.allowedPlugins!.has(plugin.id); - if ((options.blockedPlugins?.size ?? 0) > 0) { - console.warn( - 'Allowed and blocked plugins are defined simultaneously. The allowed list will take precedence.' - ); - } - } else { - if ((options.blockedPlugins?.size ?? 0) > 0) { - console.info('Some plugins are not allowed to be registered'); - this._isAllowed = (plugin: Private.IPluginData) => - !options.blockedPlugins!.has(plugin.id); - } + if (options.validatePlugin) { + console.info('Plugins may be rejected by the custom validation plugin method.'); + this._validatePlugin = options.validatePlugin; } } @@ -231,13 +219,13 @@ export class PluginRegistry { throw new TypeError(`Plugin '${plugin.id}' is already registered.`); } + if (!this._validatePlugin(plugin)) { + throw new Error(`Plugin '${plugin.id}' is not valid.`); + } + // Create the normalized plugin data. const data = Private.createPluginData(plugin); - if (!this._isAllowed(data)) { - throw new Error(`Plugin '${plugin.id}' is not allowed.`); - } - // Ensure the plugin does not cause a cyclic dependency. Private.ensureNoCycle(data, this._plugins, this._services); @@ -504,7 +492,7 @@ export class PluginRegistry { } private _application: any = null; - private _isAllowed: (plugin: Private.IPluginData) => boolean = () => true; + private _validatePlugin: (plugin: IPlugin) => boolean = () => true; private _plugins = new Map>(); private _services = new Map, string>(); } @@ -518,19 +506,18 @@ export namespace PluginRegistry { */ export interface IOptions { /** - * List of allowed plugins + * Validate that a plugin is allowed to be registered. * - * If defined, only allowed plugins will be able to be registered. + * Default is `() => true`. * - * This parameter takes precedence over {@link blockedPlugins}. - */ - allowedPlugins?: Set; - /** - * List of blocked plugins - * - * If defined, blocked plugins will not be able to be registered. + * @param plugin The plugin to validate + * @returns Whether the plugin can be registered or not. + * + * #### Notes + * We recommend you print a console message with the reason + * a plugin is invalid. */ - blockedPlugins?: Set; + validatePlugin?: (plugin: IPlugin) => boolean; } /** diff --git a/packages/application/tests/src/index.spec.ts b/packages/application/tests/src/index.spec.ts index 0898a3b1b..346eb897b 100644 --- a/packages/application/tests/src/index.spec.ts +++ b/packages/application/tests/src/index.spec.ts @@ -9,7 +9,7 @@ |----------------------------------------------------------------------------*/ import { expect } from 'chai'; -import { Application, PluginRegistry } from '@lumino/application'; +import { Application, PluginRegistry, type IPlugin } from '@lumino/application'; import { ContextMenu, Widget } from '@lumino/widgets'; import { CommandRegistry } from '@lumino/commands'; import { Token } from '@lumino/coreutils'; @@ -634,26 +634,9 @@ describe('@lumino/application', () => { expect(plugins).to.be.instanceOf(PluginRegistry); }); - it('should accept allowed plugins list', () => { + it('should accept validation function', () => { const plugins = new PluginRegistry({ - allowedPlugins: new Set(['plugin1', 'plugin2']) - }); - - expect(plugins).to.be.instanceOf(PluginRegistry); - }); - - it('should accept blocked plugins list', () => { - const plugins = new PluginRegistry({ - blockedPlugins: new Set(['plugin1', 'plugin2']) - }); - - expect(plugins).to.be.instanceOf(PluginRegistry); - }); - - it('should accept allowed and blocked plugins lists', () => { - const plugins = new PluginRegistry({ - allowedPlugins: new Set(['plugin1', 'plugin2']), - blockedPlugins: new Set(['plugin3']) + validatePlugin: (plugin: IPlugin) => !['plugin1', 'plugin2'].includes(plugin.id) }); expect(plugins).to.be.instanceOf(PluginRegistry); @@ -929,9 +912,9 @@ describe('@lumino/application', () => { expect(plugins.hasPlugin(id)).to.be.true; }); - it('should refuse to register not allowed plugins', async () => { + it('should refuse to register invalid plugins', async () => { const plugins = new PluginRegistry({ - allowedPlugins: new Set(['id1']) + validatePlugin: (plugin: IPlugin) => ['id1'].includes(plugin.id) }); expect(function () { plugins.registerPlugin({ @@ -948,47 +931,6 @@ describe('@lumino/application', () => { } }); }); - - it('should refuse to register blocked plugins', async () => { - const plugins = new PluginRegistry({ - blockedPlugins: new Set(['id1']) - }); - expect(function () { - plugins.registerPlugin({ - id: 'id1', - activate: () => { - /* no-op */ - } - }); - }).to.throw(); - plugins.registerPlugin({ - id: 'id2', - activate: () => { - /* no-op */ - } - }); - }); - - it('should use allowed list over blocked list of plugins', async () => { - const plugins = new PluginRegistry({ - allowedPlugins: new Set(['id1', 'id2']), - blockedPlugins: new Set(['id2']) - }); - expect(function () { - plugins.registerPlugin({ - id: 'id', - activate: () => { - /* no-op */ - } - }); - }).to.throw(); - plugins.registerPlugin({ - id: 'id2', - activate: () => { - /* no-op */ - } - }); - }); }); describe('#deregisterPlugin', () => { From 4bdd1e9566dcfcea2f3dcdd4cf9413a9cf297fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Mon, 20 May 2024 10:19:34 +0200 Subject: [PATCH 08/11] Update API and lint --- packages/application/src/plugins.ts | 6 ++++-- packages/application/tests/src/index.spec.ts | 8 +++++--- review/api/application.api.md | 3 +-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/application/src/plugins.ts b/packages/application/src/plugins.ts index 8de285891..328b82f32 100644 --- a/packages/application/src/plugins.ts +++ b/packages/application/src/plugins.ts @@ -122,7 +122,9 @@ export interface IPlugin { export class PluginRegistry { constructor(options: PluginRegistry.IOptions = {}) { if (options.validatePlugin) { - console.info('Plugins may be rejected by the custom validation plugin method.'); + console.info( + 'Plugins may be rejected by the custom validation plugin method.' + ); this._validatePlugin = options.validatePlugin; } } @@ -512,7 +514,7 @@ export namespace PluginRegistry { * * @param plugin The plugin to validate * @returns Whether the plugin can be registered or not. - * + * * #### Notes * We recommend you print a console message with the reason * a plugin is invalid. diff --git a/packages/application/tests/src/index.spec.ts b/packages/application/tests/src/index.spec.ts index 346eb897b..4f4483439 100644 --- a/packages/application/tests/src/index.spec.ts +++ b/packages/application/tests/src/index.spec.ts @@ -9,7 +9,7 @@ |----------------------------------------------------------------------------*/ import { expect } from 'chai'; -import { Application, PluginRegistry, type IPlugin } from '@lumino/application'; +import { Application, type IPlugin, PluginRegistry } from '@lumino/application'; import { ContextMenu, Widget } from '@lumino/widgets'; import { CommandRegistry } from '@lumino/commands'; import { Token } from '@lumino/coreutils'; @@ -636,7 +636,8 @@ describe('@lumino/application', () => { it('should accept validation function', () => { const plugins = new PluginRegistry({ - validatePlugin: (plugin: IPlugin) => !['plugin1', 'plugin2'].includes(plugin.id) + validatePlugin: (plugin: IPlugin) => + !['plugin1', 'plugin2'].includes(plugin.id) }); expect(plugins).to.be.instanceOf(PluginRegistry); @@ -914,7 +915,8 @@ describe('@lumino/application', () => { it('should refuse to register invalid plugins', async () => { const plugins = new PluginRegistry({ - validatePlugin: (plugin: IPlugin) => ['id1'].includes(plugin.id) + validatePlugin: (plugin: IPlugin) => + ['id1'].includes(plugin.id) }); expect(function () { plugins.registerPlugin({ diff --git a/review/api/application.api.md b/review/api/application.api.md index 898956581..ba5539254 100644 --- a/review/api/application.api.md +++ b/review/api/application.api.md @@ -91,8 +91,7 @@ export class PluginRegistry { // @public export namespace PluginRegistry { export interface IOptions { - allowedPlugins?: Set; - blockedPlugins?: Set; + validatePlugin?: (plugin: IPlugin) => boolean; } export interface IStartOptions { ignorePlugins?: string[]; From 6f0a9214296af929484c8738af7ea90fd18b773b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Wed, 22 May 2024 10:22:21 +0200 Subject: [PATCH 09/11] Move PluginRegistry in coreutils --- packages/application/src/application.ts | 480 ------------- packages/application/src/index.ts | 489 ++++++++++++- packages/application/tests/src/index.spec.ts | 640 +---------------- packages/coreutils/package.json | 3 + packages/coreutils/src/index.common.ts | 1 + .../{application => coreutils}/src/plugins.ts | 2 +- packages/coreutils/src/typing.d.ts | 53 ++ packages/coreutils/tests/src/index.spec.ts | 1 + packages/coreutils/tests/src/plugins.spec.ts | 650 ++++++++++++++++++ review/api/application.api.md | 45 +- review/api/coreutils.api.md | 43 ++ yarn.lock | 1 + 12 files changed, 1245 insertions(+), 1163 deletions(-) delete mode 100644 packages/application/src/application.ts rename packages/{application => coreutils}/src/plugins.ts (99%) create mode 100644 packages/coreutils/src/typing.d.ts create mode 100644 packages/coreutils/tests/src/plugins.spec.ts diff --git a/packages/application/src/application.ts b/packages/application/src/application.ts deleted file mode 100644 index 2d51eef11..000000000 --- a/packages/application/src/application.ts +++ /dev/null @@ -1,480 +0,0 @@ -// Copyright (c) Jupyter Development Team. -// Distributed under the terms of the Modified BSD License. -import { CommandRegistry } from '@lumino/commands'; - -import { PromiseDelegate, type Token } from '@lumino/coreutils'; - -import { ContextMenu, Menu, Widget } from '@lumino/widgets'; - -import { type IPlugin, PluginRegistry } from './plugins'; - -/** - * A class for creating pluggable applications. - * - * @typeParam T - The type of the application shell. - * - * #### Notes - * The `Application` class is useful when creating large, complex - * UI applications with the ability to be safely extended by third - * party code via plugins. - */ -export class Application { - /** - * Construct a new application. - * - * @param options - The options for creating the application. - */ - constructor(options: Application.IOptions) { - this.pluginRegistry = - options.pluginRegistry ?? new PluginRegistry(options); - this.pluginRegistry.application = this; - - // Initialize the application state. - this.commands = new CommandRegistry(); - this.contextMenu = new ContextMenu({ - commands: this.commands, - renderer: options.contextMenuRenderer - }); - this.shell = options.shell; - } - - /** - * The application command registry. - */ - readonly commands: CommandRegistry; - - /** - * The application context menu. - */ - readonly contextMenu: ContextMenu; - - /** - * The application shell widget. - * - * #### Notes - * The shell widget is the root "container" widget for the entire - * application. It will typically expose an API which allows the - * application plugins to insert content in a variety of places. - */ - readonly shell: T; - - /** - * The list of all the deferred plugins. - */ - get deferredPlugins(): string[] { - return this.pluginRegistry.deferredPlugins; - } - - /** - * A promise which resolves after the application has started. - * - * #### Notes - * This promise will resolve after the `start()` method is called, - * when all the bootstrapping and shell mounting work is complete. - */ - get started(): Promise { - return this._delegate.promise; - } - - /** - * Activate all the deferred plugins. - * - * @returns A promise which will resolve when each plugin is activated - * or rejects with an error if one cannot be activated. - */ - async activateDeferredPlugins(): Promise { - await this.pluginRegistry.activatePlugins('defer'); - } - - /** - * Activate the plugin with the given ID. - * - * @param id - The ID of the plugin of interest. - * - * @returns A promise which resolves when the plugin is activated - * or rejects with an error if it cannot be activated. - */ - async activatePlugin(id: string): Promise { - return this.pluginRegistry.activatePlugin(id); - } - - /** - * Deactivate the plugin and its downstream dependents if and only if the - * plugin and its dependents all support `deactivate`. - * - * @param id - The ID of the plugin of interest. - * - * @returns A list of IDs of downstream plugins deactivated with this one. - */ - async deactivatePlugin(id: string): Promise { - return this.pluginRegistry.deactivatePlugin(id); - } - - /** - * Deregister a plugin with the application. - * - * @param id - The ID of the plugin of interest. - * - * @param force - Whether to deregister the plugin even if it is active. - */ - deregisterPlugin(id: string, force?: boolean): void { - this.pluginRegistry.deregisterPlugin(id, force); - } - - /** - * Get a plugin description. - * - * @param id - The ID of the plugin of interest. - * - * @returns The plugin description. - */ - getPluginDescription(id: string): string { - return this.pluginRegistry.getPluginDescription(id); - } - - /** - * Test whether a plugin is registered with the application. - * - * @param id - The ID of the plugin of interest. - * - * @returns `true` if the plugin is registered, `false` otherwise. - */ - hasPlugin(id: string): boolean { - return this.pluginRegistry.hasPlugin(id); - } - - /** - * Test whether a plugin is activated with the application. - * - * @param id - The ID of the plugin of interest. - * - * @returns `true` if the plugin is activated, `false` otherwise. - */ - isPluginActivated(id: string): boolean { - return this.pluginRegistry.isPluginActivated(id); - } - - /** - * List the IDs of the plugins registered with the application. - * - * @returns A new array of the registered plugin IDs. - */ - listPlugins(): string[] { - return this.pluginRegistry.listPlugins(); - } - - /** - * Register a plugin with the application. - * - * @param plugin - The plugin to register. - * - * #### Notes - * An error will be thrown if a plugin with the same ID is already - * registered, or if the plugin has a circular dependency. - * - * If the plugin provides a service which has already been provided - * by another plugin, the new service will override the old service. - */ - registerPlugin(plugin: IPlugin): void { - this.pluginRegistry.registerPlugin(plugin); - } - - /** - * Register multiple plugins with the application. - * - * @param plugins - The plugins to register. - * - * #### Notes - * This calls `registerPlugin()` for each of the given plugins. - */ - registerPlugins(plugins: IPlugin[]): void { - this.pluginRegistry.registerPlugins(plugins); - } - - /** - * Resolve an optional service of a given type. - * - * @param token - The token for the service type of interest. - * - * @returns A promise which resolves to an instance of the requested - * service, or `null` if it cannot be resolved. - * - * #### Notes - * Services are singletons. The same instance will be returned each - * time a given service token is resolved. - * - * If the plugin which provides the service has not been activated, - * resolving the service will automatically activate the plugin. - * - * User code will not typically call this method directly. Instead, - * the optional services for the user's plugins will be resolved - * automatically when the plugin is activated. - */ - async resolveOptionalService(token: Token): Promise { - return this.pluginRegistry.resolveOptionalService(token); - } - - /** - * Resolve a required service of a given type. - * - * @param token - The token for the service type of interest. - * - * @returns A promise which resolves to an instance of the requested - * service, or rejects with an error if it cannot be resolved. - * - * #### Notes - * Services are singletons. The same instance will be returned each - * time a given service token is resolved. - * - * If the plugin which provides the service has not been activated, - * resolving the service will automatically activate the plugin. - * - * User code will not typically call this method directly. Instead, - * the required services for the user's plugins will be resolved - * automatically when the plugin is activated. - */ - async resolveRequiredService(token: Token): Promise { - return this.pluginRegistry.resolveRequiredService(token); - } - - /** - * Start the application. - * - * @param options - The options for starting the application. - * - * @returns A promise which resolves when all bootstrapping work - * is complete and the shell is mounted to the DOM. - * - * #### Notes - * This should be called once by the application creator after all - * initial plugins have been registered. - * - * If a plugin fails to the load, the error will be logged and the - * other valid plugins will continue to be loaded. - * - * Bootstrapping the application consists of the following steps: - * 1. Activate the startup plugins - * 2. Wait for those plugins to activate - * 3. Attach the shell widget to the DOM - * 4. Add the application event listeners - */ - async start(options: Application.IStartOptions = {}): Promise { - // Return immediately if the application is already started. - if (this._started) { - return this._delegate.promise; - } - - // Mark the application as started; - this._started = true; - - this._bubblingKeydown = options.bubblingKeydown ?? false; - - // Parse the host ID for attaching the shell. - const hostID = options.hostID ?? ''; - - // Wait for the plugins to activate, then finalize startup. - await this.pluginRegistry.activatePlugins('startUp', options); - - this.attachShell(hostID); - this.addEventListeners(); - this._delegate.resolve(); - } - - /** - * Handle the DOM events for the application. - * - * @param event - The DOM event sent to the application. - * - * #### Notes - * This method implements the DOM `EventListener` interface and is - * called in response to events registered for the application. It - * should not be called directly by user code. - */ - handleEvent(event: Event): void { - switch (event.type) { - case 'resize': - this.evtResize(event); - break; - case 'keydown': - this.evtKeydown(event as KeyboardEvent); - break; - case 'keyup': - this.evtKeyup(event as KeyboardEvent); - break; - case 'contextmenu': - this.evtContextMenu(event as PointerEvent); - break; - } - } - - /** - * Attach the application shell to the DOM. - * - * @param id - The ID of the host node for the shell, or `''`. - * - * #### Notes - * If the ID is not provided, the document body will be the host. - * - * A subclass may reimplement this method as needed. - */ - protected attachShell(id: string): void { - Widget.attach( - this.shell, - (id && document.getElementById(id)) || document.body - ); - } - - /** - * Add the application event listeners. - * - * #### Notes - * The default implementation of this method adds listeners for - * `'keydown'` and `'resize'` events. - * - * A subclass may reimplement this method as needed. - */ - protected addEventListeners(): void { - document.addEventListener('contextmenu', this); - document.addEventListener('keydown', this, !this._bubblingKeydown); - document.addEventListener('keyup', this, !this._bubblingKeydown); - window.addEventListener('resize', this); - } - - /** - * A method invoked on a document `'keydown'` event. - * - * #### Notes - * The default implementation of this method invokes the key down - * processing method of the application command registry. - * - * A subclass may reimplement this method as needed. - */ - protected evtKeydown(event: KeyboardEvent): void { - this.commands.processKeydownEvent(event); - } - - /** - * A method invoked on a document `'keyup'` event. - * - * #### Notes - * The default implementation of this method invokes the key up - * processing method of the application command registry. - * - * A subclass may reimplement this method as needed. - */ - protected evtKeyup(event: KeyboardEvent): void { - this.commands.processKeyupEvent(event); - } - - /** - * A method invoked on a document `'contextmenu'` event. - * - * #### Notes - * The default implementation of this method opens the application - * `contextMenu` at the current mouse position. - * - * If the application context menu has no matching content *or* if - * the shift key is pressed, the default browser context menu will - * be opened instead. - * - * A subclass may reimplement this method as needed. - */ - protected evtContextMenu(event: PointerEvent): void { - if (event.shiftKey) { - return; - } - if (this.contextMenu.open(event)) { - event.preventDefault(); - event.stopPropagation(); - } - } - - /** - * A method invoked on a window `'resize'` event. - * - * #### Notes - * The default implementation of this method updates the shell. - * - * A subclass may reimplement this method as needed. - */ - protected evtResize(event: Event): void { - this.shell.update(); - } - - /** - * Application plugin registry. - */ - protected pluginRegistry: PluginRegistry; - private _delegate = new PromiseDelegate(); - private _started = false; - private _bubblingKeydown = false; -} - -/** - * The namespace for the `Application` class statics. - */ -export namespace Application { - /** - * An options object for creating an application. - */ - export interface IOptions extends PluginRegistry.IOptions { - /** - * The shell widget to use for the application. - * - * This should be a newly created and initialized widget. - * - * The application will attach the widget to the DOM. - */ - shell: T; - - /** - * A custom renderer for the context menu. - */ - contextMenuRenderer?: Menu.IRenderer; - - /** - * Application plugin registry. - * - * If defined the options related to the plugin registry will - * be ignored. - */ - pluginRegistry?: PluginRegistry; - } - - /** - * An options object for application startup. - */ - export interface IStartOptions { - /** - * The ID of the DOM node to host the application shell. - * - * #### Notes - * If this is not provided, the document body will be the host. - */ - hostID?: string; - - /** - * The plugins to activate on startup. - * - * #### Notes - * These will be *in addition* to any `autoStart` plugins. - */ - startPlugins?: string[]; - - /** - * The plugins to **not** activate on startup. - * - * #### Notes - * This will override `startPlugins` and any `autoStart` plugins. - */ - ignorePlugins?: string[]; - - /** - * Whether to capture keydown event at bubbling or capturing (default) phase for - * keyboard shortcuts. - * - * @experimental - */ - bubblingKeydown?: boolean; - } -} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 83feb6131..d205bcaf8 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -12,5 +12,490 @@ * @module application */ -export * from './application'; -export * from './plugins'; +import { CommandRegistry } from '@lumino/commands'; + +import { + type IPlugin, + PluginRegistry, + PromiseDelegate, + type Token +} from '@lumino/coreutils'; + +import { ContextMenu, Menu, Widget } from '@lumino/widgets'; + +// Export IPlugin for API backward compatibility +/** + * @deprecated You should import it from @lumino/coreutils. + */ +export { type IPlugin }; + +/** + * A class for creating pluggable applications. + * + * @typeParam T - The type of the application shell. + * + * #### Notes + * The `Application` class is useful when creating large, complex + * UI applications with the ability to be safely extended by third + * party code via plugins. + */ +export class Application { + /** + * Construct a new application. + * + * @param options - The options for creating the application. + */ + constructor(options: Application.IOptions) { + this.pluginRegistry = + options.pluginRegistry ?? new PluginRegistry(options); + this.pluginRegistry.application = this; + + // Initialize the application state. + this.commands = new CommandRegistry(); + this.contextMenu = new ContextMenu({ + commands: this.commands, + renderer: options.contextMenuRenderer + }); + this.shell = options.shell; + } + + /** + * The application command registry. + */ + readonly commands: CommandRegistry; + + /** + * The application context menu. + */ + readonly contextMenu: ContextMenu; + + /** + * The application shell widget. + * + * #### Notes + * The shell widget is the root "container" widget for the entire + * application. It will typically expose an API which allows the + * application plugins to insert content in a variety of places. + */ + readonly shell: T; + + /** + * The list of all the deferred plugins. + */ + get deferredPlugins(): string[] { + return this.pluginRegistry.deferredPlugins; + } + + /** + * A promise which resolves after the application has started. + * + * #### Notes + * This promise will resolve after the `start()` method is called, + * when all the bootstrapping and shell mounting work is complete. + */ + get started(): Promise { + return this._delegate.promise; + } + + /** + * Activate all the deferred plugins. + * + * @returns A promise which will resolve when each plugin is activated + * or rejects with an error if one cannot be activated. + */ + async activateDeferredPlugins(): Promise { + await this.pluginRegistry.activatePlugins('defer'); + } + + /** + * Activate the plugin with the given ID. + * + * @param id - The ID of the plugin of interest. + * + * @returns A promise which resolves when the plugin is activated + * or rejects with an error if it cannot be activated. + */ + async activatePlugin(id: string): Promise { + return this.pluginRegistry.activatePlugin(id); + } + + /** + * Deactivate the plugin and its downstream dependents if and only if the + * plugin and its dependents all support `deactivate`. + * + * @param id - The ID of the plugin of interest. + * + * @returns A list of IDs of downstream plugins deactivated with this one. + */ + async deactivatePlugin(id: string): Promise { + return this.pluginRegistry.deactivatePlugin(id); + } + + /** + * Deregister a plugin with the application. + * + * @param id - The ID of the plugin of interest. + * + * @param force - Whether to deregister the plugin even if it is active. + */ + deregisterPlugin(id: string, force?: boolean): void { + this.pluginRegistry.deregisterPlugin(id, force); + } + + /** + * Get a plugin description. + * + * @param id - The ID of the plugin of interest. + * + * @returns The plugin description. + */ + getPluginDescription(id: string): string { + return this.pluginRegistry.getPluginDescription(id); + } + + /** + * Test whether a plugin is registered with the application. + * + * @param id - The ID of the plugin of interest. + * + * @returns `true` if the plugin is registered, `false` otherwise. + */ + hasPlugin(id: string): boolean { + return this.pluginRegistry.hasPlugin(id); + } + + /** + * Test whether a plugin is activated with the application. + * + * @param id - The ID of the plugin of interest. + * + * @returns `true` if the plugin is activated, `false` otherwise. + */ + isPluginActivated(id: string): boolean { + return this.pluginRegistry.isPluginActivated(id); + } + + /** + * List the IDs of the plugins registered with the application. + * + * @returns A new array of the registered plugin IDs. + */ + listPlugins(): string[] { + return this.pluginRegistry.listPlugins(); + } + + /** + * Register a plugin with the application. + * + * @param plugin - The plugin to register. + * + * #### Notes + * An error will be thrown if a plugin with the same ID is already + * registered, or if the plugin has a circular dependency. + * + * If the plugin provides a service which has already been provided + * by another plugin, the new service will override the old service. + */ + registerPlugin(plugin: IPlugin): void { + this.pluginRegistry.registerPlugin(plugin); + } + + /** + * Register multiple plugins with the application. + * + * @param plugins - The plugins to register. + * + * #### Notes + * This calls `registerPlugin()` for each of the given plugins. + */ + registerPlugins(plugins: IPlugin[]): void { + this.pluginRegistry.registerPlugins(plugins); + } + + /** + * Resolve an optional service of a given type. + * + * @param token - The token for the service type of interest. + * + * @returns A promise which resolves to an instance of the requested + * service, or `null` if it cannot be resolved. + * + * #### Notes + * Services are singletons. The same instance will be returned each + * time a given service token is resolved. + * + * If the plugin which provides the service has not been activated, + * resolving the service will automatically activate the plugin. + * + * User code will not typically call this method directly. Instead, + * the optional services for the user's plugins will be resolved + * automatically when the plugin is activated. + */ + async resolveOptionalService(token: Token): Promise { + return this.pluginRegistry.resolveOptionalService(token); + } + + /** + * Resolve a required service of a given type. + * + * @param token - The token for the service type of interest. + * + * @returns A promise which resolves to an instance of the requested + * service, or rejects with an error if it cannot be resolved. + * + * #### Notes + * Services are singletons. The same instance will be returned each + * time a given service token is resolved. + * + * If the plugin which provides the service has not been activated, + * resolving the service will automatically activate the plugin. + * + * User code will not typically call this method directly. Instead, + * the required services for the user's plugins will be resolved + * automatically when the plugin is activated. + */ + async resolveRequiredService(token: Token): Promise { + return this.pluginRegistry.resolveRequiredService(token); + } + + /** + * Start the application. + * + * @param options - The options for starting the application. + * + * @returns A promise which resolves when all bootstrapping work + * is complete and the shell is mounted to the DOM. + * + * #### Notes + * This should be called once by the application creator after all + * initial plugins have been registered. + * + * If a plugin fails to the load, the error will be logged and the + * other valid plugins will continue to be loaded. + * + * Bootstrapping the application consists of the following steps: + * 1. Activate the startup plugins + * 2. Wait for those plugins to activate + * 3. Attach the shell widget to the DOM + * 4. Add the application event listeners + */ + async start(options: Application.IStartOptions = {}): Promise { + // Return immediately if the application is already started. + if (this._started) { + return this._delegate.promise; + } + + // Mark the application as started; + this._started = true; + + this._bubblingKeydown = options.bubblingKeydown ?? false; + + // Parse the host ID for attaching the shell. + const hostID = options.hostID ?? ''; + + // Wait for the plugins to activate, then finalize startup. + await this.pluginRegistry.activatePlugins('startUp', options); + + this.attachShell(hostID); + this.addEventListeners(); + this._delegate.resolve(); + } + + /** + * Handle the DOM events for the application. + * + * @param event - The DOM event sent to the application. + * + * #### Notes + * This method implements the DOM `EventListener` interface and is + * called in response to events registered for the application. It + * should not be called directly by user code. + */ + handleEvent(event: Event): void { + switch (event.type) { + case 'resize': + this.evtResize(event); + break; + case 'keydown': + this.evtKeydown(event as KeyboardEvent); + break; + case 'keyup': + this.evtKeyup(event as KeyboardEvent); + break; + case 'contextmenu': + this.evtContextMenu(event as PointerEvent); + break; + } + } + + /** + * Attach the application shell to the DOM. + * + * @param id - The ID of the host node for the shell, or `''`. + * + * #### Notes + * If the ID is not provided, the document body will be the host. + * + * A subclass may reimplement this method as needed. + */ + protected attachShell(id: string): void { + Widget.attach( + this.shell, + (id && document.getElementById(id)) || document.body + ); + } + + /** + * Add the application event listeners. + * + * #### Notes + * The default implementation of this method adds listeners for + * `'keydown'` and `'resize'` events. + * + * A subclass may reimplement this method as needed. + */ + protected addEventListeners(): void { + document.addEventListener('contextmenu', this); + document.addEventListener('keydown', this, !this._bubblingKeydown); + document.addEventListener('keyup', this, !this._bubblingKeydown); + window.addEventListener('resize', this); + } + + /** + * A method invoked on a document `'keydown'` event. + * + * #### Notes + * The default implementation of this method invokes the key down + * processing method of the application command registry. + * + * A subclass may reimplement this method as needed. + */ + protected evtKeydown(event: KeyboardEvent): void { + this.commands.processKeydownEvent(event); + } + + /** + * A method invoked on a document `'keyup'` event. + * + * #### Notes + * The default implementation of this method invokes the key up + * processing method of the application command registry. + * + * A subclass may reimplement this method as needed. + */ + protected evtKeyup(event: KeyboardEvent): void { + this.commands.processKeyupEvent(event); + } + + /** + * A method invoked on a document `'contextmenu'` event. + * + * #### Notes + * The default implementation of this method opens the application + * `contextMenu` at the current mouse position. + * + * If the application context menu has no matching content *or* if + * the shift key is pressed, the default browser context menu will + * be opened instead. + * + * A subclass may reimplement this method as needed. + */ + protected evtContextMenu(event: PointerEvent): void { + if (event.shiftKey) { + return; + } + if (this.contextMenu.open(event)) { + event.preventDefault(); + event.stopPropagation(); + } + } + + /** + * A method invoked on a window `'resize'` event. + * + * #### Notes + * The default implementation of this method updates the shell. + * + * A subclass may reimplement this method as needed. + */ + protected evtResize(event: Event): void { + this.shell.update(); + } + + /** + * Application plugin registry. + */ + protected pluginRegistry: PluginRegistry; + private _delegate = new PromiseDelegate(); + private _started = false; + private _bubblingKeydown = false; +} + +/** + * The namespace for the `Application` class statics. + */ +export namespace Application { + /** + * An options object for creating an application. + */ + export interface IOptions extends PluginRegistry.IOptions { + /** + * The shell widget to use for the application. + * + * This should be a newly created and initialized widget. + * + * The application will attach the widget to the DOM. + */ + shell: T; + + /** + * A custom renderer for the context menu. + */ + contextMenuRenderer?: Menu.IRenderer; + + /** + * Application plugin registry. + * + * If defined the options related to the plugin registry will + * be ignored. + */ + pluginRegistry?: PluginRegistry; + } + + /** + * An options object for application startup. + */ + export interface IStartOptions { + /** + * The ID of the DOM node to host the application shell. + * + * #### Notes + * If this is not provided, the document body will be the host. + */ + hostID?: string; + + /** + * The plugins to activate on startup. + * + * #### Notes + * These will be *in addition* to any `autoStart` plugins. + */ + startPlugins?: string[]; + + /** + * The plugins to **not** activate on startup. + * + * #### Notes + * This will override `startPlugins` and any `autoStart` plugins. + */ + ignorePlugins?: string[]; + + /** + * Whether to capture keydown event at bubbling or capturing (default) phase for + * keyboard shortcuts. + * + * @experimental + */ + bubblingKeydown?: boolean; + } +} diff --git a/packages/application/tests/src/index.spec.ts b/packages/application/tests/src/index.spec.ts index 4f4483439..f2e535eea 100644 --- a/packages/application/tests/src/index.spec.ts +++ b/packages/application/tests/src/index.spec.ts @@ -9,10 +9,10 @@ |----------------------------------------------------------------------------*/ import { expect } from 'chai'; -import { Application, type IPlugin, PluginRegistry } from '@lumino/application'; +import { Application } from '@lumino/application'; import { ContextMenu, Widget } from '@lumino/widgets'; import { CommandRegistry } from '@lumino/commands'; -import { Token } from '@lumino/coreutils'; +import { PluginRegistry, Token } from '@lumino/coreutils'; describe('@lumino/application', () => { describe('Application', () => { @@ -625,640 +625,4 @@ describe('@lumino/application', () => { }); }); }); - - describe('PluginRegistry', () => { - describe('#constructor', () => { - it('should instantiate an plugin registry without options', () => { - const plugins = new PluginRegistry(); - - expect(plugins).to.be.instanceOf(PluginRegistry); - }); - - it('should accept validation function', () => { - const plugins = new PluginRegistry({ - validatePlugin: (plugin: IPlugin) => - !['plugin1', 'plugin2'].includes(plugin.id) - }); - - expect(plugins).to.be.instanceOf(PluginRegistry); - }); - }); - - describe('#application', () => { - it('should be null by default', () => { - const plugins = new PluginRegistry(); - - expect(plugins.application).to.be.null; - }); - - it('should accept any object', () => { - const plugins = new PluginRegistry(); - - const app = Object.freeze({}); - plugins.application = app; - - expect(plugins.application).to.be.equal(app); - }); - - it('cannot be overridden', () => { - const plugins = new PluginRegistry(); - - const app = Object.freeze({}); - plugins.application = app; - - expect(plugins.application).to.be.equal(app); - - expect(function () { - plugins.application = Object.freeze({}); - }).to.throw(); - }); - }); - - describe('#getPluginDescription', () => { - it('should return the plugin description', () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - const description = 'Plugin 1 description'; - plugins.registerPlugin({ - id, - description, - activate: () => { - // no-op - } - }); - - expect(plugins.getPluginDescription(id)).to.equal(description); - }); - - it('should return an empty string if plugin has no description', () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - expect(plugins.getPluginDescription(id)).to.equal(''); - }); - - it('should return an empty string if plugin does not exist', () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - - expect(plugins.getPluginDescription(id)).to.equal(''); - }); - }); - - describe('#hasPlugin', () => { - it('should be true for registered plugin', () => { - const pluginRegistry = new PluginRegistry(); - const id = 'plugin1'; - pluginRegistry.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - expect(pluginRegistry.hasPlugin(id)).to.be.true; - }); - - it('should be false for unregistered plugin', () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - expect(plugins.hasPlugin('plugin2')).to.be.false; - }); - }); - - describe('#isPluginActivated', () => { - it('should be true for activated plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - await plugins.activatePlugin(id); - expect(plugins.isPluginActivated(id)).to.be.true; - }); - - it('should be true for an autoStart plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - }, - autoStart: true - }); - await plugins.activatePlugins('startUp'); - expect(plugins.isPluginActivated(id)).to.be.true; - }); - - it('should be false for not activated plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - expect(plugins.isPluginActivated(id)).to.be.false; - }); - - it('should be false for deferred plugin when application start', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - }, - autoStart: 'defer' - }); - await plugins.activatePlugins('startUp'); - expect(plugins.isPluginActivated(id)).to.be.false; - await plugins.activatePlugins('defer'); - expect(plugins.isPluginActivated(id)).to.be.true; - }); - - it('should be false for unregistered plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - await plugins.activatePlugin(id); - expect(plugins.isPluginActivated('no-registered')).to.be.false; - }); - }); - - describe('#listPlugins', () => { - it('should list the registered plugin', () => { - const plugins = new PluginRegistry(); - const ids = ['plugin1', 'plugin2']; - ids.forEach(id => { - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - }); - - expect(plugins.listPlugins()).to.deep.equal(ids); - }); - }); - - describe('#registerPlugin', () => { - it('should register a plugin', () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - expect(plugins.hasPlugin(id)).to.be.true; - }); - - it('should not register an already registered plugin', () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - expect(function () { - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - }).to.throw(); - }); - - it('should not register a plugin introducing a cycle', () => { - const plugins = new PluginRegistry(); - const id1 = 'plugin1'; - const token1 = new Token(id1); - const id2 = 'plugin2'; - const token2 = new Token(id2); - const id3 = 'plugin3'; - const token3 = new Token(id3); - plugins.registerPlugin({ - id: id1, - activate: () => { - // no-op - }, - requires: [token3], - provides: token1 - }); - plugins.registerPlugin({ - id: id2, - activate: () => { - // no-op - }, - requires: [token1], - provides: token2 - }); - - expect(function () { - plugins.registerPlugin({ - id: id3, - activate: () => { - // no-op - }, - requires: [token2], - provides: token3 - }); - }).to.throw(); - }); - - it('should register a plugin defined by a class', () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - const plugin = new (class { - readonly id = id; - activate = () => { - // Check this.id is accessible as expected - // as we are tearing a part the plugin object. - expect(this.id).to.equal(id); - }; - })(); - plugins.registerPlugin(plugin); - - expect(plugins.hasPlugin(id)).to.be.true; - }); - - it('should refuse to register invalid plugins', async () => { - const plugins = new PluginRegistry({ - validatePlugin: (plugin: IPlugin) => - ['id1'].includes(plugin.id) - }); - expect(function () { - plugins.registerPlugin({ - id: 'id', - activate: () => { - /* no-op */ - } - }); - }).to.throw(); - plugins.registerPlugin({ - id: 'id1', - activate: () => { - /* no-op */ - } - }); - }); - }); - - describe('#deregisterPlugin', () => { - it('should deregister a deactivated registered plugin', () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - plugins.deregisterPlugin(id); - - expect(plugins.hasPlugin(id)).to.be.false; - }); - - it('should not deregister an activated registered plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - await plugins.activatePlugin(id); - - expect(() => { - plugins.deregisterPlugin(id); - }).to.throw(); - expect(plugins.hasPlugin(id)).to.be.true; - }); - - it('should force deregister an activated registered plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - await plugins.activatePlugin(id); - - plugins.deregisterPlugin(id, true); - expect(plugins.hasPlugin(id)).to.be.false; - }); - }); - - describe('#activatePlugin', () => { - it('should activate a registered plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - await plugins.activatePlugin(id); - expect(plugins.isPluginActivated(id)).to.be.true; - }); - - it('should throw an error when activating a unregistered plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - try { - await plugins.activatePlugin('other-id'); - } catch (reason) { - return; - } - - expect(false, 'app.activatePlugin did not throw').to.be.true; - }); - - it('should tolerate activating an activated plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - await plugins.activatePlugin(id); - - await plugins.activatePlugin(id); - - expect(plugins.isPluginActivated(id)).to.be.true; - }); - - it('should activate all required services', async () => { - const plugins = new PluginRegistry(); - const id1 = 'plugin1'; - const token1 = new Token(id1); - const id2 = 'plugin2'; - const token2 = new Token(id2); - const id3 = 'plugin3'; - const token3 = new Token(id3); - plugins.registerPlugin({ - id: id1, - activate: () => { - // no-op - }, - provides: token1 - }); - plugins.registerPlugin({ - id: id2, - activate: () => { - // no-op - }, - requires: [token1], - provides: token2 - }); - plugins.registerPlugin({ - id: id3, - activate: () => { - // no-op - }, - requires: [token2], - provides: token3 - }); - - await plugins.activatePlugin(id3); - - expect(plugins.isPluginActivated(id3)).to.be.true; - expect(plugins.isPluginActivated(id1)).to.be.true; - expect(plugins.isPluginActivated(id2)).to.be.true; - }); - - it('should try activating all optional services', async () => { - const plugins = new PluginRegistry(); - const id1 = 'plugin1'; - const token1 = new Token(id1); - const id2 = 'plugin2'; - const token2 = new Token(id2); - const id3 = 'plugin3'; - const token3 = new Token(id3); - plugins.registerPlugin({ - id: id1, - activate: () => { - // no-op - }, - provides: token1 - }); - plugins.registerPlugin({ - id: id2, - activate: () => { - throw new Error(`Force failure during '${id2}' activation`); - }, - provides: token2 - }); - plugins.registerPlugin({ - id: id3, - activate: () => { - // no-op - }, - optional: [token1, token2], - provides: token3 - }); - - await plugins.activatePlugin(id3); - - expect(plugins.isPluginActivated(id3)).to.be.true; - expect(plugins.isPluginActivated(id1)).to.be.true; - expect(plugins.isPluginActivated(id2)).to.be.false; - }); - }); - - describe('#deactivatePlugin', () => { - it('should call deactivate on the plugin', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - let deactivated: boolean | null = null; - plugins.registerPlugin({ - id, - activate: () => { - deactivated = false; - }, - deactivate: () => { - deactivated = true; - } - }); - - await plugins.activatePlugin(id); - - expect(deactivated).to.be.false; - - const others = await plugins.deactivatePlugin(id); - - expect(deactivated).to.be.true; - expect(others.length).to.equal(0); - }); - - it('should throw an error if the plugin does not support deactivation', async () => { - const plugins = new PluginRegistry(); - const id = 'plugin1'; - plugins.registerPlugin({ - id, - activate: () => { - // no-op - } - }); - - await plugins.activatePlugin(id); - - try { - await plugins.deactivatePlugin(id); - } catch (r) { - return; - } - - expect(true, 'app.deactivatePlugin did not throw').to.be.false; - }); - - it('should throw an error if the plugin has dependants not support deactivation', async () => { - const plugins = new PluginRegistry(); - const id1 = 'plugin1'; - const token1 = new Token(id1); - const id2 = 'plugin2'; - const token2 = new Token(id2); - const id3 = 'plugin3'; - const token3 = new Token(id3); - plugins.registerPlugin({ - id: id1, - activate: () => { - // no-op - }, - deactivate: () => { - // no-op - }, - provides: token1 - }); - plugins.registerPlugin({ - id: id2, - activate: () => { - // no-op - }, - deactivate: () => { - // no-op - }, - requires: [token1], - provides: token2 - }); - plugins.registerPlugin({ - id: id3, - activate: () => { - // no-op - }, - requires: [token2], - provides: token3 - }); - - await plugins.activatePlugin(id3); - - try { - await plugins.deactivatePlugin(id1); - } catch (r) { - return; - } - - expect(true, 'app.deactivatePlugin did not throw').to.be.false; - }); - - it('should deactivate all dependents (optional or not)', async () => { - const plugins = new PluginRegistry(); - let deactivated: boolean | null = null; - const id1 = 'plugin1'; - const token1 = new Token(id1); - const id2 = 'plugin2'; - const token2 = new Token(id2); - const id3 = 'plugin3'; - const token3 = new Token(id3); - plugins.registerPlugin({ - id: id1, - activate: () => { - deactivated = false; - }, - deactivate: () => { - deactivated = true; - }, - provides: token1 - }); - plugins.registerPlugin({ - id: id2, - activate: () => { - // no-op - }, - deactivate: () => { - // no-op - }, - requires: [token1], - provides: token2 - }); - plugins.registerPlugin({ - id: id3, - activate: () => { - // no-op - }, - deactivate: () => { - // no-op - }, - optional: [token2], - provides: token3 - }); - - await plugins.activatePlugin(id3); - - const others = await plugins.deactivatePlugin(id1); - - expect(deactivated).to.be.true; - expect(others).to.deep.equal([id3, id2]); - expect(plugins.isPluginActivated(id2)).to.be.false; - expect(plugins.isPluginActivated(id3)).to.be.false; - }); - }); - }); }); diff --git a/packages/coreutils/package.json b/packages/coreutils/package.json index 55d9ded3b..66a98b86d 100644 --- a/packages/coreutils/package.json +++ b/packages/coreutils/package.json @@ -41,6 +41,9 @@ "test:webkit-headless": "cd tests && karma start --browsers=WebkitHeadless", "watch": "tsc --build --watch" }, + "dependencies": { + "@lumino/algorithm": "^2.0.1" + }, "devDependencies": { "@lumino/buildutils": "^2.0.1", "@microsoft/api-extractor": "^7.36.0", diff --git a/packages/coreutils/src/index.common.ts b/packages/coreutils/src/index.common.ts index d2dcb599c..872e0fbd4 100644 --- a/packages/coreutils/src/index.common.ts +++ b/packages/coreutils/src/index.common.ts @@ -9,5 +9,6 @@ |----------------------------------------------------------------------------*/ export * from './json'; export * from './mime'; +export * from './plugins'; export * from './promise'; export * from './token'; diff --git a/packages/application/src/plugins.ts b/packages/coreutils/src/plugins.ts similarity index 99% rename from packages/application/src/plugins.ts rename to packages/coreutils/src/plugins.ts index 328b82f32..6cd0e6074 100644 --- a/packages/application/src/plugins.ts +++ b/packages/coreutils/src/plugins.ts @@ -2,7 +2,7 @@ // Distributed under the terms of the Modified BSD License. import { topologicSort } from '@lumino/algorithm'; -import { Token } from '@lumino/coreutils'; +import { Token } from './token'; /** * A user-defined application plugin. diff --git a/packages/coreutils/src/typing.d.ts b/packages/coreutils/src/typing.d.ts new file mode 100644 index 000000000..a8214757b --- /dev/null +++ b/packages/coreutils/src/typing.d.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +/* + * Define typing for the console to restrict the DOM API to the minimal + * set compatible with the browser and Node.js + */ + +interface Console { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/assert_static) */ + assert(condition?: boolean, ...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) */ + clear(): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) */ + count(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) */ + countReset(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) */ + debug(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) */ + dir(item?: any, options?: any): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) */ + dirxml(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) */ + error(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) */ + group(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) */ + groupCollapsed(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) */ + groupEnd(): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) */ + info(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) */ + log(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) */ + table(tabularData?: any, properties?: string[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) */ + time(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) */ + timeEnd(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) */ + timeLog(label?: string, ...data: any[]): void; + timeStamp(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) */ + trace(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) */ + warn(...data: any[]): void; +} + +declare let console: Console; diff --git a/packages/coreutils/tests/src/index.spec.ts b/packages/coreutils/tests/src/index.spec.ts index fe508652b..c78c4543d 100644 --- a/packages/coreutils/tests/src/index.spec.ts +++ b/packages/coreutils/tests/src/index.spec.ts @@ -10,5 +10,6 @@ import './json.spec'; import './mime.spec'; +import './plugins.spec'; import './promise.spec'; import './token.spec'; diff --git a/packages/coreutils/tests/src/plugins.spec.ts b/packages/coreutils/tests/src/plugins.spec.ts new file mode 100644 index 000000000..ae8b8562e --- /dev/null +++ b/packages/coreutils/tests/src/plugins.spec.ts @@ -0,0 +1,650 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +/*----------------------------------------------------------------------------- +| Copyright (c) 2014-2017, PhosphorJS Contributors +| +| Distributed under the terms of the BSD 3-Clause License. +| +| The full license is in the file LICENSE, distributed with this software. +|----------------------------------------------------------------------------*/ +import { expect } from 'chai'; + +import { type IPlugin, PluginRegistry, Token } from '@lumino/coreutils'; + +describe('@lumino/coreutils', () => { + describe('PluginRegistry', () => { + describe('#constructor', () => { + it('should instantiate an plugin registry without options', () => { + const plugins = new PluginRegistry(); + + expect(plugins).to.be.instanceOf(PluginRegistry); + }); + + it('should accept validation function', () => { + const plugins = new PluginRegistry({ + validatePlugin: (plugin: IPlugin) => + !['plugin1', 'plugin2'].includes(plugin.id) + }); + + expect(plugins).to.be.instanceOf(PluginRegistry); + }); + }); + + describe('#application', () => { + it('should be null by default', () => { + const plugins = new PluginRegistry(); + + expect(plugins.application).to.be.null; + }); + + it('should accept any object', () => { + const plugins = new PluginRegistry(); + + const app = Object.freeze({}); + plugins.application = app; + + expect(plugins.application).to.be.equal(app); + }); + + it('cannot be overridden', () => { + const plugins = new PluginRegistry(); + + const app = Object.freeze({}); + plugins.application = app; + + expect(plugins.application).to.be.equal(app); + + expect(function () { + plugins.application = Object.freeze({}); + }).to.throw(); + }); + }); + + describe('#getPluginDescription', () => { + it('should return the plugin description', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + const description = 'Plugin 1 description'; + plugins.registerPlugin({ + id, + description, + activate: () => { + // no-op + } + }); + + expect(plugins.getPluginDescription(id)).to.equal(description); + }); + + it('should return an empty string if plugin has no description', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.getPluginDescription(id)).to.equal(''); + }); + + it('should return an empty string if plugin does not exist', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + + expect(plugins.getPluginDescription(id)).to.equal(''); + }); + }); + + describe('#hasPlugin', () => { + it('should be true for registered plugin', () => { + const pluginRegistry = new PluginRegistry(); + const id = 'plugin1'; + pluginRegistry.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(pluginRegistry.hasPlugin(id)).to.be.true; + }); + + it('should be false for unregistered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.hasPlugin('plugin2')).to.be.false; + }); + }); + + describe('#isPluginActivated', () => { + it('should be true for activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be true for an autoStart plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + }, + autoStart: true + }); + await plugins.activatePlugins('startUp'); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be false for not activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + expect(plugins.isPluginActivated(id)).to.be.false; + }); + + it('should be false for deferred plugin when application start', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + }, + autoStart: 'defer' + }); + await plugins.activatePlugins('startUp'); + expect(plugins.isPluginActivated(id)).to.be.false; + await plugins.activatePlugins('defer'); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be false for unregistered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated('no-registered')).to.be.false; + }); + }); + + describe('#listPlugins', () => { + it('should list the registered plugin', () => { + const plugins = new PluginRegistry(); + const ids = ['plugin1', 'plugin2']; + ids.forEach(id => { + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + }); + + expect(plugins.listPlugins()).to.deep.equal(ids); + }); + }); + + describe('#registerPlugin', () => { + it('should register a plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.hasPlugin(id)).to.be.true; + }); + + it('should not register an already registered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(function () { + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + }).to.throw(); + }); + + it('should not register a plugin introducing a cycle', () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + requires: [token3], + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + + expect(function () { + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + }).to.throw(); + }); + + it('should register a plugin defined by a class', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + const plugin = new (class { + readonly id = id; + activate = () => { + // Check this.id is accessible as expected + // as we are tearing a part the plugin object. + expect(this.id).to.equal(id); + }; + })(); + plugins.registerPlugin(plugin); + + expect(plugins.hasPlugin(id)).to.be.true; + }); + + it('should refuse to register invalid plugins', async () => { + const plugins = new PluginRegistry({ + validatePlugin: (plugin: IPlugin) => + ['id1'].includes(plugin.id) + }); + expect(function () { + plugins.registerPlugin({ + id: 'id', + activate: () => { + /* no-op */ + } + }); + }).to.throw(); + plugins.registerPlugin({ + id: 'id1', + activate: () => { + /* no-op */ + } + }); + }); + }); + + describe('#deregisterPlugin', () => { + it('should deregister a deactivated registered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + plugins.deregisterPlugin(id); + + expect(plugins.hasPlugin(id)).to.be.false; + }); + + it('should not deregister an activated registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + expect(() => { + plugins.deregisterPlugin(id); + }).to.throw(); + expect(plugins.hasPlugin(id)).to.be.true; + }); + + it('should force deregister an activated registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + plugins.deregisterPlugin(id, true); + expect(plugins.hasPlugin(id)).to.be.false; + }); + }); + + describe('#activatePlugin', () => { + it('should activate a registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should throw an error when activating a unregistered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + try { + await plugins.activatePlugin('other-id'); + } catch (reason) { + return; + } + + expect(false, 'app.activatePlugin did not throw').to.be.true; + }); + + it('should tolerate activating an activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + + await plugins.activatePlugin(id); + + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should activate all required services', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + expect(plugins.isPluginActivated(id3)).to.be.true; + expect(plugins.isPluginActivated(id1)).to.be.true; + expect(plugins.isPluginActivated(id2)).to.be.true; + }); + + it('should try activating all optional services', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + throw new Error(`Force failure during '${id2}' activation`); + }, + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + optional: [token1, token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + expect(plugins.isPluginActivated(id3)).to.be.true; + expect(plugins.isPluginActivated(id1)).to.be.true; + expect(plugins.isPluginActivated(id2)).to.be.false; + }); + }); + + describe('#deactivatePlugin', () => { + it('should call deactivate on the plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + let deactivated: boolean | null = null; + plugins.registerPlugin({ + id, + activate: () => { + deactivated = false; + }, + deactivate: () => { + deactivated = true; + } + }); + + await plugins.activatePlugin(id); + + expect(deactivated).to.be.false; + + const others = await plugins.deactivatePlugin(id); + + expect(deactivated).to.be.true; + expect(others.length).to.equal(0); + }); + + it('should throw an error if the plugin does not support deactivation', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + try { + await plugins.deactivatePlugin(id); + } catch (r) { + return; + } + + expect(true, 'app.deactivatePlugin did not throw').to.be.false; + }); + + it('should throw an error if the plugin has dependants not support deactivation', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + try { + await plugins.deactivatePlugin(id1); + } catch (r) { + return; + } + + expect(true, 'app.deactivatePlugin did not throw').to.be.false; + }); + + it('should deactivate all dependents (optional or not)', async () => { + const plugins = new PluginRegistry(); + let deactivated: boolean | null = null; + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + deactivated = false; + }, + deactivate: () => { + deactivated = true; + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + optional: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + const others = await plugins.deactivatePlugin(id1); + + expect(deactivated).to.be.true; + expect(others).to.deep.equal([id3, id2]); + expect(plugins.isPluginActivated(id2)).to.be.false; + expect(plugins.isPluginActivated(id3)).to.be.false; + }); + }); + }); +}); diff --git a/review/api/application.api.md b/review/api/application.api.md index ba5539254..cc30b57e9 100644 --- a/review/api/application.api.md +++ b/review/api/application.api.md @@ -6,7 +6,9 @@ import { CommandRegistry } from '@lumino/commands'; import { ContextMenu } from '@lumino/widgets'; +import { IPlugin } from '@lumino/coreutils'; import { Menu } from '@lumino/widgets'; +import { PluginRegistry } from '@lumino/coreutils'; import { Token } from '@lumino/coreutils'; import { Widget } from '@lumino/widgets'; @@ -56,47 +58,6 @@ export namespace Application { } } -// @public -export interface IPlugin { - activate: (app: T, ...args: any[]) => U | Promise; - autoStart?: boolean | 'defer'; - deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; - description?: string; - id: string; - optional?: Token[]; - provides?: Token | null; - requires?: Token[]; -} - -// @public -export class PluginRegistry { - constructor(options?: PluginRegistry.IOptions); - activatePlugin(id: string): Promise; - activatePlugins(kind: 'startUp' | 'defer', options?: PluginRegistry.IStartOptions): Promise; - get application(): T; - set application(v: any); - deactivatePlugin(id: string): Promise; - get deferredPlugins(): string[]; - deregisterPlugin(id: string, force?: boolean): void; - getPluginDescription(id: string): string; - hasPlugin(id: string): boolean; - isPluginActivated(id: string): boolean; - listPlugins(): string[]; - registerPlugin(plugin: IPlugin): void; - registerPlugins(plugins: IPlugin[]): void; - resolveOptionalService(token: Token): Promise; - resolveRequiredService(token: Token): Promise; -} - -// @public -export namespace PluginRegistry { - export interface IOptions { - validatePlugin?: (plugin: IPlugin) => boolean; - } - export interface IStartOptions { - ignorePlugins?: string[]; - startPlugins?: string[]; - } -} +export { IPlugin } ``` diff --git a/review/api/coreutils.api.md b/review/api/coreutils.api.md index 21b8893ae..413e27c55 100644 --- a/review/api/coreutils.api.md +++ b/review/api/coreutils.api.md @@ -4,6 +4,18 @@ ```ts +// @public +export interface IPlugin { + activate: (app: T, ...args: any[]) => U | Promise; + autoStart?: boolean | 'defer'; + deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; + description?: string; + id: string; + optional?: Token[]; + provides?: Token | null; + requires?: Token[]; +} + // @public export interface JSONArray extends Array { } @@ -66,6 +78,37 @@ export interface PartialJSONObject { // @public export type PartialJSONValue = JSONPrimitive | PartialJSONObject | PartialJSONArray; +// @public +export class PluginRegistry { + constructor(options?: PluginRegistry.IOptions); + activatePlugin(id: string): Promise; + activatePlugins(kind: 'startUp' | 'defer', options?: PluginRegistry.IStartOptions): Promise; + get application(): T; + set application(v: any); + deactivatePlugin(id: string): Promise; + get deferredPlugins(): string[]; + deregisterPlugin(id: string, force?: boolean): void; + getPluginDescription(id: string): string; + hasPlugin(id: string): boolean; + isPluginActivated(id: string): boolean; + listPlugins(): string[]; + registerPlugin(plugin: IPlugin): void; + registerPlugins(plugins: IPlugin[]): void; + resolveOptionalService(token: Token): Promise; + resolveRequiredService(token: Token): Promise; +} + +// @public +export namespace PluginRegistry { + export interface IOptions { + validatePlugin?: (plugin: IPlugin) => boolean; + } + export interface IStartOptions { + ignorePlugins?: string[]; + startPlugins?: string[]; + } +} + // @public export class PromiseDelegate { constructor(); diff --git a/yarn.lock b/yarn.lock index 6e7a79050..bc4be23eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -423,6 +423,7 @@ __metadata: version: 0.0.0-use.local resolution: "@lumino/coreutils@workspace:packages/coreutils" dependencies: + "@lumino/algorithm": ^2.0.1 "@lumino/buildutils": ^2.0.1 "@microsoft/api-extractor": ^7.36.0 "@rollup/plugin-commonjs": ^24.0.0 From d5fbc4096d810d8494265c86687b0297fdb8e15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Fri, 24 May 2024 16:02:49 +0200 Subject: [PATCH 10/11] Update packages/coreutils/src/plugins.ts Co-authored-by: Nicolas Brichet <32258950+brichet@users.noreply.github.com> --- packages/coreutils/src/plugins.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coreutils/src/plugins.ts b/packages/coreutils/src/plugins.ts index 6cd0e6074..518468f92 100644 --- a/packages/coreutils/src/plugins.ts +++ b/packages/coreutils/src/plugins.ts @@ -142,7 +142,7 @@ export class PluginRegistry { get application(): T { return this._application; } - set application(v: any) { + set application(v: T) { if (this._application !== null) { throw Error( 'PluginRegistry.application is already set. It cannot be overridden.' From 27115870ea880fbc75bd68b8c18d5097780cc9e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Fri, 24 May 2024 16:58:07 +0200 Subject: [PATCH 11/11] Update api --- review/api/coreutils.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/review/api/coreutils.api.md b/review/api/coreutils.api.md index 413e27c55..09a897b2e 100644 --- a/review/api/coreutils.api.md +++ b/review/api/coreutils.api.md @@ -84,7 +84,7 @@ export class PluginRegistry { activatePlugin(id: string): Promise; activatePlugins(kind: 'startUp' | 'defer', options?: PluginRegistry.IStartOptions): Promise; get application(): T; - set application(v: any); + set application(v: T); deactivatePlugin(id: string): Promise; get deferredPlugins(): string[]; deregisterPlugin(id: string, force?: boolean): void;