diff --git a/examples/workflow-glsp/src/workflow-diagram-module.ts b/examples/workflow-glsp/src/workflow-diagram-module.ts index 2f37df09..9efa511d 100644 --- a/examples/workflow-glsp/src/workflow-diagram-module.ts +++ b/examples/workflow-glsp/src/workflow-diagram-module.ts @@ -23,6 +23,7 @@ import { GLSPGraph, GLSPProjectionView, GridSnapper, + IDiagramOptions, LogLevel, RectangularNodeView, RevealNamedElementActionProvider, @@ -38,9 +39,9 @@ import { bindOrRebind, configureDefaultModelElements, configureModelElement, + createDiagramOptionsModule, editLabelFeature, - initializeDiagramContainer, - overrideViewerOptions + initializeDiagramContainer } from '@eclipse-glsp/client'; import 'balloon-css/balloon.min.css'; import { Container, ContainerModule } from 'inversify'; @@ -78,19 +79,21 @@ export const workflowDiagramModule = new ContainerModule((bind, unbind, isBound, configureModelElement(context, 'struct', SCompartment, StructureCompartmentView); }); -export function createWorkflowDiagramContainer(widgetId: string, ...containerConfiguration: ContainerConfiguration): Container { - return initializeWorkflowDiagramContainer(new Container(), widgetId, ...containerConfiguration); +export function createWorkflowDiagramContainer(options: IDiagramOptions, ...containerConfiguration: ContainerConfiguration): Container { + return initializeWorkflowDiagramContainer(new Container(), options, ...containerConfiguration); } export function initializeWorkflowDiagramContainer( container: Container, - widgetId: string, + options: IDiagramOptions, ...containerConfiguration: ContainerConfiguration ): Container { - initializeDiagramContainer(container, workflowDiagramModule, directTaskEditor, accessibilityModule, ...containerConfiguration); - overrideViewerOptions(container, { - baseDiv: widgetId, - hiddenDiv: widgetId + '_hidden' - }); - return container; + return initializeDiagramContainer( + container, + createDiagramOptionsModule(options), + workflowDiagramModule, + directTaskEditor, + accessibilityModule, + ...containerConfiguration + ); } diff --git a/examples/workflow-standalone/src/app.ts b/examples/workflow-standalone/src/app.ts index 3fb067b7..d6c5792a 100644 --- a/examples/workflow-standalone/src/app.ts +++ b/examples/workflow-standalone/src/app.ts @@ -16,21 +16,15 @@ import 'reflect-metadata'; import { - ApplicationIdProvider, BaseJsonrpcGLSPClient, - EnableToolPaletteAction, + DiagramLoader, GLSPActionDispatcher, GLSPClient, - GLSPModelSource, GLSPWebSocketProvider, - RequestModelAction, - RequestTypeHintsAction, ServerMessageAction, - ServerStatusAction, - SetUIExtensionVisibilityAction, - StatusOverlay, - configureServerActions + ServerStatusAction } from '@eclipse-glsp/client'; +import { Container } from 'inversify'; import { join, resolve } from 'path'; import { MessageConnection } from 'vscode-jsonrpc'; import createContainer from './di.config'; @@ -41,45 +35,21 @@ const diagramType = 'workflow-diagram'; const loc = window.location.pathname; const currentDir = loc.substring(0, loc.lastIndexOf('/')); const examplePath = resolve(join(currentDir, '../app/example1.wf')); -const clientId = ApplicationIdProvider.get() + '_' + examplePath; +const clientId = 'sprotty'; const webSocketUrl = `ws://localhost:${port}/${id}`; -let container = createContainer(); -let diagramServer = container.get(GLSPModelSource); - +let glspClient: GLSPClient; +let container: Container; const wsProvider = new GLSPWebSocketProvider(webSocketUrl); wsProvider.listen({ onConnection: initialize, onReconnect: reconnect, logger: console }); async function initialize(connectionProvider: MessageConnection, isReconnecting = false): Promise { - const actionDispatcher: GLSPActionDispatcher = container.get(GLSPActionDispatcher); - - await actionDispatcher.dispatch(SetUIExtensionVisibilityAction.create({ extensionId: StatusOverlay.ID, visible: true })); - await actionDispatcher.dispatch(ServerStatusAction.create('Initializing...', { severity: 'INFO' })); - const client = new BaseJsonrpcGLSPClient({ id, connectionProvider }); - - await diagramServer.connect(client, clientId); - const result = await client.initializeServer({ - applicationId: ApplicationIdProvider.get(), - protocolVersion: GLSPClient.protocolVersion - }); - actionDispatcher.dispatch(ServerStatusAction.create('', { severity: 'NONE' })); - await configureServerActions(result, diagramType, container); - - await client.initializeClientSession({ clientSessionId: diagramServer.clientId, diagramType }); - - actionDispatcher.dispatch(SetUIExtensionVisibilityAction.create({ extensionId: StatusOverlay.ID, visible: true })); - actionDispatcher.dispatch(EnableToolPaletteAction.create()); - actionDispatcher.dispatch( - RequestModelAction.create({ - options: { - sourceUri: `file://${examplePath}`, - diagramType, - isReconnecting - } - }) - ); - actionDispatcher.dispatch(RequestTypeHintsAction.create()); + glspClient = new BaseJsonrpcGLSPClient({ id, connectionProvider }); + container = createContainer({ clientId, diagramType, glspClient, sourceUri: examplePath }); + const actionDispatcher = container.get(GLSPActionDispatcher); + const diagramLoader = container.get(DiagramLoader); + await diagramLoader.load({ isReconnecting }); if (isReconnecting) { const message = `Connection to the ${id} glsp server got closed. Connection was successfully re-established.`; @@ -94,9 +64,6 @@ async function initialize(connectionProvider: MessageConnection, isReconnecting } async function reconnect(connectionProvider: MessageConnection): Promise { - container = createContainer(); - diagramServer = container.get(GLSPModelSource); - diagramServer.clientId = clientId; - + glspClient.stop(); initialize(connectionProvider, true /* isReconnecting */); } diff --git a/examples/workflow-standalone/src/di.config.ts b/examples/workflow-standalone/src/di.config.ts index 9a721610..d0dcf9f0 100644 --- a/examples/workflow-standalone/src/di.config.ts +++ b/examples/workflow-standalone/src/di.config.ts @@ -14,11 +14,11 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { createWorkflowDiagramContainer } from '@eclipse-glsp-examples/workflow-glsp'; -import { bindOrRebind, ConsoleLogger, LogLevel, STANDALONE_MODULE_CONFIG, TYPES } from '@eclipse-glsp/client'; +import { bindOrRebind, ConsoleLogger, IDiagramOptions, LogLevel, STANDALONE_MODULE_CONFIG, TYPES } from '@eclipse-glsp/client'; import { Container } from 'inversify'; import '../css/diagram.css'; -export default function createContainer(): Container { - const container = createWorkflowDiagramContainer('sprotty', STANDALONE_MODULE_CONFIG); +export default function createContainer(options: IDiagramOptions): Container { + const container = createWorkflowDiagramContainer(options, STANDALONE_MODULE_CONFIG); bindOrRebind(container, TYPES.ILogger).to(ConsoleLogger).inSingletonScope(); bindOrRebind(container, TYPES.LogLevel).toConstantValue(LogLevel.warn); container.bind(TYPES.IMarqueeBehavior).toConstantValue({ entireEdge: true, entireElement: true }); diff --git a/packages/client/src/base/default.module.ts b/packages/client/src/base/default.module.ts index cdff23b9..e9bf7a21 100644 --- a/packages/client/src/base/default.module.ts +++ b/packages/client/src/base/default.module.ts @@ -14,14 +14,10 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import '@vscode/codicons/dist/codicon.css'; -import { Container } from 'inversify'; import { - ActionHandlerRegistry, FeatureModule, - InitializeResult, KeyTool, LocationPostprocessor, - ModelSource, MouseTool, MoveCommand, SetDirtyStateAction, @@ -43,6 +39,7 @@ import { FeedbackAwareUpdateModelCommand } from './feedback/update-model-command import { FocusStateChangedAction } from './focus/focus-state-change-action'; import { FocusTracker } from './focus/focus-tracker'; import { DefaultModelInitializationConstraint, ModelInitializationConstraint } from './model-initialization-constraint'; +import { DiagramLoader } from './model/diagram-loader'; import { GLSPModelSource } from './model/glsp-model-source'; import { GLSPModelRegistry } from './model/model-registry'; import { SelectionClearingMouseListener } from './selection-clearing-mouse-listener'; @@ -97,6 +94,7 @@ export const defaultModule = new FeatureModule((bind, unbind, isBound, rebind, . bindOrRebind(context, TYPES.IActionDispatcher).toService(GLSPActionDispatcher); bindAsService(context, TYPES.ModelSource, GLSPModelSource); + bind(DiagramLoader).toSelf().inSingletonScope(); bind(ModelInitializationConstraint).to(DefaultModelInitializationConstraint).inSingletonScope(); // support re-registration of model elements and views @@ -115,20 +113,3 @@ export const defaultModule = new FeatureModule((bind, unbind, isBound, rebind, . bindAsService(context, TYPES.IVNodePostprocessor, LocationPostprocessor); bind(TYPES.HiddenVNodePostprocessor).toService(LocationPostprocessor); }); - -/** - * Utility function to configure the {@link ModelSource}, i.e. the `DiagramServer`, as action handler for all server actions for the given - * diagramType. - * @param result A promise that resolves after all server actions have been registered. - * @param diagramType The diagram type. - * @param container The di container. - */ -export async function configureServerActions(result: InitializeResult, diagramType: string, container: Container): Promise { - const modelSource = container.get(TYPES.ModelSource); - const actionHandlerRegistry = container.get(ActionHandlerRegistry); - const serverActions = result.serverActions[diagramType]; - if (serverActions.length === 0) { - throw new Error(`No server-handled actions could be derived from the initialize result for diagramType: ${diagramType}!`); - } - serverActions.forEach(actionKind => actionHandlerRegistry.register(actionKind, modelSource)); -} diff --git a/packages/client/src/base/model/diagram-loader.ts b/packages/client/src/base/model/diagram-loader.ts new file mode 100644 index 00000000..60cc7ac7 --- /dev/null +++ b/packages/client/src/base/model/diagram-loader.ts @@ -0,0 +1,160 @@ +/******************************************************************************** + * Copyright (c) 2023 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable, multiInject, optional, postConstruct } from 'inversify'; +import { + ApplicationIdProvider, + Args, + EMPTY_ROOT, + EndProgressAction, + GLSPClient, + MaybePromise, + RequestModelAction, + ServerStatusAction, + SetModelAction, + StartProgressAction, + TYPES +} from '~glsp-sprotty'; +import { GLSPActionDispatcher } from '../action-dispatcher'; +import { Ranked } from '../ranked'; + +/** + * Instance specific configuration options for a GLSP diagram + */ +export interface IDiagramOptions { + /** + * Unique id associated with this diagram. Used on the server side to identify the + * corresponding client session. + */ + clientId: string; + /** + * The diagram type i.e. diagram language this diagram is associated with. + */ + diagramType: string; + /** + * The GLSP client used by this diagram to communicate with the server. + */ + glspClient: GLSPClient; + /** + * The file source URI associated with this diagram. + */ + sourceUri?: string; +} + +/** + * Services that implement startup hooks which are invoked during the {@link DiagramLoader.load} process. + * Typically used to dispatch additional initial actions and/or activate UI extensions on startup. + * Execution order is derived by the `rank` property of the service. If not present, the {@link Ranked.DEFAULT_RANK} will be assumed. + * + */ +export interface IDiagramStartup extends Partial { + /** + * Hook for services that should be executed before the underlying GLSP client is configured and the server is initialized. + */ + preInitialize?(): MaybePromise; + /** + * Hook for services that should be executed before the initial model loading request (i.e. `RequestModelAction`) but + * after the underlying GLSP client has been configured and the server is initialized. + */ + preModelLoading?(): MaybePromise; + /** + * Hook for services that should be executed after the initial model loading request (i.e. `RequestModelAction`). + * Note that this hook is invoked directly after the `RequestModelAction` has been dispatched. It does not necessarily wait + * until the client-server update roundtrip is completed. If you need to wait until the diagram is fully initialized use the + * {@link GLSPActionDispatcher.onceModelInitialized} constraint. + */ + postModelLoading?(): MaybePromise; +} + +export namespace IDiagramStartup { + export function is(object: unknown): object is IDiagramStartup { + return Ranked.is(object) && ('preInitialize' in object || 'preModelLoading' in object || 'postModelLoading' in object); + } +} + +/** + * The central component responsible for initializing the diagram and loading the graphical model + * from the GLSP server. + * Invoking the {@link DiagramLoader.load} method is typically the first operation that is executed after + * a diagram DI container has been created + */ +@injectable() +export class DiagramLoader { + @inject(TYPES.IDiagramOptions) + protected options: IDiagramOptions; + + @inject(GLSPActionDispatcher) + protected actionDispatcher: GLSPActionDispatcher; + + @multiInject(TYPES.IDiagramStartup) + @optional() + protected diagramStartups: IDiagramStartup[] = []; + + protected enableLoadingNotifications = true; + + @postConstruct() + protected postConstruct(): void { + this.diagramStartups.sort((a, b) => Ranked.getRank(a) - Ranked.getRank(b)); + } + + async load(requestModelOptions: Args = {}): Promise { + // Set placeholder model until real model from server is available + await this.actionDispatcher.dispatch(SetModelAction.create(EMPTY_ROOT)); + await this.invokeStartupHook('preInitialize'); + await this.configureGLSPClient(); + await this.invokeStartupHook('preModelLoading'); + await this.requestModel(requestModelOptions); + await this.invokeStartupHook('postModelLoading'); + } + + protected async invokeStartupHook(hook: keyof Omit): Promise { + for (const startup of this.diagramStartups) { + await startup[hook]?.(); + } + } + + protected requestModel(requestModelOptions: Args = {}): Promise { + const options = { sourceUri: this.options.sourceUri, diagramType: this.options.diagramType, ...requestModelOptions } as Args; + return this.actionDispatcher.dispatch(RequestModelAction.create({ options })); + } + + protected async configureGLSPClient(): Promise { + const glspClient = this.options.glspClient; + + if (this.enableLoadingNotifications) { + this.actionDispatcher.dispatchAll([ + ServerStatusAction.create('Initializing...', { severity: 'INFO' }), + StartProgressAction.create({ progressId: 'initializeClient', title: 'Initializing' }) + ]); + } + + await glspClient.start(); + + if (!glspClient.initializeResult) { + await glspClient.initializeServer({ + applicationId: ApplicationIdProvider.get(), + protocolVersion: GLSPClient.protocolVersion + }); + } + + if (this.enableLoadingNotifications) { + this.actionDispatcher.dispatchAll([ + ServerStatusAction.create('', { severity: 'NONE' }), + EndProgressAction.create('initializeClient') + ]); + } + } +} diff --git a/packages/client/src/base/model/glsp-model-source.ts b/packages/client/src/base/model/glsp-model-source.ts index 97f1197a..0fd74bbd 100644 --- a/packages/client/src/base/model/glsp-model-source.ts +++ b/packages/client/src/base/model/glsp-model-source.ts @@ -23,11 +23,12 @@ import { DisposableCollection, GLSPClient, ILogger, + InitializeResult, ModelSource, - RequestModelAction, SModelRootSchema, TYPES } from '~glsp-sprotty'; +import { IDiagramOptions } from './diagram-loader'; /** * A helper interface that allows the client to mark actions that have been received from the server. */ @@ -64,28 +65,30 @@ export class GLSPModelSource extends ModelSource implements Disposable { @inject(TYPES.ILogger) protected logger: ILogger; - protected _sourceUri?: string; - protected _glspClient?: GLSPClient; protected toDispose = new DisposableCollection(); - + protected registry: ActionHandlerRegistry; clientId: string; - - get glspClient(): GLSPClient | undefined { - return this._glspClient; + readonly glspClient: GLSPClient; + readonly sourceUri?: string; + readonly diagramType: string; + + constructor(@inject(TYPES.IDiagramOptions) options: IDiagramOptions) { + super(); + this.glspClient = options.glspClient; + this.clientId = options.clientId ?? this.viewerOptions.baseDiv; + this.diagramType = options.diagramType; + this.sourceUri = options.sourceUri; + this.glspClient.onServerInitialized(result => this.configure(result)); } - get sourceUri(): string | undefined { - return this._sourceUri; - } - - async connect(client: GLSPClient, clientId?: string): Promise { - if (clientId) { - this.clientId = clientId; + configure(initializeResult: InitializeResult): Promise { + const serverActions = initializeResult.serverActions[this.diagramType]; + if (!serverActions || serverActions.length === 0) { + throw new Error(`No server-handled actions could be derived from the initialize result for diagramType: ${this.diagramType}!`); } - await client.start(); - this.toDispose.push(client.onActionMessage(message => this.messageReceived(message), this.clientId)); - this._glspClient = client; - return this._glspClient; + serverActions.forEach(action => this.registry.register(action, this)); + this.toDispose.push(this.glspClient.onActionMessage(message => this.messageReceived(message), this.clientId)); + return this.glspClient.initializeClientSession({ clientSessionId: this.clientId, diagramType: this.diagramType }); } protected messageReceived(message: ActionMessage): void { @@ -104,14 +107,12 @@ export class GLSPModelSource extends ModelSource implements Disposable { if (!this.clientId) { this.clientId = this.viewerOptions.baseDiv; } + this.registry = registry; } handle(action: Action): void { // Handling additional actions here is discouraged and it's recommended // to implemented dedicated action handlers. - if (RequestModelAction.is(action)) { - this._sourceUri = action.options?.sourceUri.toString(); - } if (this.shouldForwardToServer(action)) { this.forwardToServer(action); } diff --git a/packages/client/src/default-modules.ts b/packages/client/src/default-modules.ts index 87d3a933..8880e859 100644 --- a/packages/client/src/default-modules.ts +++ b/packages/client/src/default-modules.ts @@ -16,9 +16,13 @@ import { Container, ContainerModule } from 'inversify'; import { + BindingContext, ContainerConfiguration, FeatureModule, + TYPES, + ViewerOptions, buttonModule, + configureViewerOptions, edgeIntersectionModule, edgeLayoutModule, expandModule, @@ -29,6 +33,7 @@ import { zorderModule } from '~glsp-sprotty'; import { defaultModule } from './base/default.module'; +import { IDiagramOptions } from './base/model/diagram-loader'; import { boundsModule } from './features/bounds/bounds-module'; import { commandPaletteModule } from './features/command-palette/command-palette-module'; import { contextMenuModule } from './features/context-menu/context-menu-module'; @@ -95,6 +100,34 @@ export const DEFAULT_MODULES = [ statusModule ] as const; +/** + * Wraps the {@link configureDiagramOptions} utility function in a module. Adopters can either include this + * module into the container {@link ModuleConfiguration} or configure the container after its creation + * (e.g. using the {@link configureDiagramOptions} utility function) + * @param options The diagram instance specific configuration options + * @returns The corresponding {@link FeatureModule} + */ +export function createDiagramOptionsModule(options: IDiagramOptions): FeatureModule { + return new FeatureModule((bind, unbind, isBound, rebind) => configureDiagramOptions({ bind, unbind, isBound, rebind }, options)); +} + +/** + * Utility function to bind the diagram instance specific configuration options. + * In addition to binding the {@link IDiagramOptions} this function also overrides the + * {@link ViewerOptions} to match the given client id. + * @param context The binding context + * @param options The {@link IDiagramOptions} that should be bound + */ +export function configureDiagramOptions(context: BindingContext, options: IDiagramOptions): void { + const viewerOptions: Partial = { + baseDiv: options.clientId, + hiddenDiv: options.clientId + '_hidden' + }; + configureViewerOptions(context, viewerOptions); + + context.bind(TYPES.IDiagramOptions).toConstantValue(options); +} + /** * Initializes a GLSP Diagram container with the GLSP default modules and the specified custom `modules`. * Additional modules can be passed as direct arguments or as part of a {@link ModuleConfiguration}. diff --git a/packages/client/src/features/hints/type-hints-module.ts b/packages/client/src/features/hints/type-hints-module.ts index f91d5a76..0ba11f9d 100644 --- a/packages/client/src/features/hints/type-hints-module.ts +++ b/packages/client/src/features/hints/type-hints-module.ts @@ -13,12 +13,13 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { FeatureModule, SetTypeHintsAction, TYPES, configureActionHandler, configureCommand } from '~glsp-sprotty'; +import { FeatureModule, SetTypeHintsAction, TYPES, bindAsService, configureActionHandler, configureCommand } from '~glsp-sprotty'; import { ApplyTypeHintsCommand, TypeHintProvider } from './type-hints'; -export const typeHintsModule = new FeatureModule((bind, _unbind, isBound) => { - bind(TypeHintProvider).toSelf().inSingletonScope(); - bind(TYPES.ITypeHintProvider).toService(TypeHintProvider); - configureActionHandler({ bind, isBound }, SetTypeHintsAction.KIND, TypeHintProvider); - configureCommand({ bind, isBound }, ApplyTypeHintsCommand); +export const typeHintsModule = new FeatureModule((bind, unbind, isBound) => { + const context = { bind, unbind, isBound }; + bindAsService(context, TYPES.ITypeHintProvider, TypeHintProvider); + bind(TYPES.IDiagramStartup).toService(TypeHintProvider); + configureActionHandler(context, SetTypeHintsAction.KIND, TypeHintProvider); + configureCommand(context, ApplyTypeHintsCommand); }); diff --git a/packages/client/src/features/hints/type-hints.ts b/packages/client/src/features/hints/type-hints.ts index 292caa13..d4edc9c6 100644 --- a/packages/client/src/features/hints/type-hints.ts +++ b/packages/client/src/features/hints/type-hints.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { MaybePromise } from '@eclipse-glsp/protocol'; import { inject, injectable } from 'inversify'; import { Action, @@ -23,6 +24,7 @@ import { FeatureSet, IActionHandler, ICommand, + RequestTypeHintsAction, SEdge, SModelElement, SModelRoot, @@ -36,8 +38,10 @@ import { editFeature, moveFeature } from '~glsp-sprotty'; +import { GLSPActionDispatcher } from '../../base/action-dispatcher'; import { IFeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher'; import { FeedbackCommand } from '../../base/feedback/feedback-command'; +import { IDiagramStartup } from '../../base/model/diagram-loader'; import { getElementTypeId, hasCompatibleType } from '../../utils/smodel-util'; import { resizeFeature } from '../change-bounds/model'; import { reconnectFeature } from '../reconnect/model'; @@ -149,10 +153,13 @@ export interface ITypeHintProvider { } @injectable() -export class TypeHintProvider implements IActionHandler, ITypeHintProvider { +export class TypeHintProvider implements IActionHandler, ITypeHintProvider, IDiagramStartup { @inject(TYPES.IFeedbackActionDispatcher) protected feedbackActionDispatcher: IFeedbackActionDispatcher; + @inject(GLSPActionDispatcher) + protected actionDispatcher: GLSPActionDispatcher; + protected shapeHints: Map = new Map(); protected edgeHints: Map = new Map(); @@ -192,6 +199,10 @@ export class TypeHintProvider implements IActionHandler, ITypeHintProvider { getEdgeTypeHint(input: SModelElement | SModelElement | string): EdgeTypeHint | undefined { return getTypeHint(input, this.edgeHints); } + + preModelLoading(): MaybePromise { + this.actionDispatcher.dispatch(RequestTypeHintsAction.create()); + } } function getTypeHint(input: SModelElement | SModelElement | string, hints: Map): T | undefined { diff --git a/packages/client/src/features/status/status-module.ts b/packages/client/src/features/status/status-module.ts index 80b47d45..53520d47 100644 --- a/packages/client/src/features/status/status-module.ts +++ b/packages/client/src/features/status/status-module.ts @@ -21,5 +21,6 @@ import { StatusOverlay } from './status-overlay'; export const statusModule = new FeatureModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; bindAsService(context, TYPES.IUIExtension, StatusOverlay); + bind(TYPES.IDiagramStartup).toService(StatusOverlay); configureActionHandler(context, ServerStatusAction.KIND, StatusOverlay); }); diff --git a/packages/client/src/features/status/status-overlay.ts b/packages/client/src/features/status/status-overlay.ts index 4be6baad..14f8bcc8 100644 --- a/packages/client/src/features/status/status-overlay.ts +++ b/packages/client/src/features/status/status-overlay.ts @@ -13,16 +13,25 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import { AbstractUIExtension, IActionHandler, ServerStatusAction, codiconCSSClasses } from '~glsp-sprotty'; +import { GLSPActionDispatcher } from '../../base/action-dispatcher'; +import { EditorContextService } from '../../base/editor-context-service'; +import { IDiagramStartup } from '../../base/model/diagram-loader'; /** * A reusable status overlay for rendering (icon + message) and handling of {@link ServerStatusAction}'s. */ @injectable() -export class StatusOverlay extends AbstractUIExtension implements IActionHandler { +export class StatusOverlay extends AbstractUIExtension implements IActionHandler, IDiagramStartup { static readonly ID = 'glsp.server.status.overlay'; + @inject(GLSPActionDispatcher) + protected actionDispatcher: GLSPActionDispatcher; + + @inject(EditorContextService) + protected editorContext: EditorContextService; + protected statusIconDiv?: HTMLDivElement; protected statusMessageDiv?: HTMLDivElement; protected pendingTimeout?: number; @@ -105,4 +114,8 @@ export class StatusOverlay extends AbstractUIExtension implements IActionHandler this.pendingTimeout = window.setTimeout(() => this.clearStatus(), statusTimeout); } } + + preInitialize(): void { + this.show(this.editorContext.modelRoot); + } } diff --git a/packages/client/src/features/tool-palette/tool-palette-module.ts b/packages/client/src/features/tool-palette/tool-palette-module.ts index dd1dd567..dd581319 100644 --- a/packages/client/src/features/tool-palette/tool-palette-module.ts +++ b/packages/client/src/features/tool-palette/tool-palette-module.ts @@ -15,10 +15,10 @@ ********************************************************************************/ import { bindAsService, configureActionHandler, EnableDefaultToolsAction, FeatureModule, TYPES } from '~glsp-sprotty'; import '../../../css/tool-palette.css'; -import { EnableToolPaletteAction, ToolPalette } from './tool-palette'; +import { ToolPalette } from './tool-palette'; export const toolPaletteModule = new FeatureModule((bind, _unbind, isBound, _rebind) => { bindAsService(bind, TYPES.IUIExtension, ToolPalette); - configureActionHandler({ bind, isBound }, EnableToolPaletteAction.KIND, ToolPalette); + bind(TYPES.IDiagramStartup).toService(ToolPalette); configureActionHandler({ bind, isBound }, EnableDefaultToolsAction.KIND, ToolPalette); }); diff --git a/packages/client/src/features/tool-palette/tool-palette.ts b/packages/client/src/features/tool-palette/tool-palette.ts index 8b93a1b3..619e7b51 100644 --- a/packages/client/src/features/tool-palette/tool-palette.ts +++ b/packages/client/src/features/tool-palette/tool-palette.ts @@ -36,6 +36,7 @@ import { import { GLSPActionDispatcher } from '../../base/action-dispatcher'; import { EditorContextService, IEditModeListener } from '../../base/editor-context-service'; import { FocusTracker } from '../../base/focus/focus-tracker'; +import { IDiagramStartup } from '../../base/model/diagram-loader'; import { MouseDeleteTool } from '../tools/deletion/delete-tool'; import { MarqueeMouseTool } from '../tools/marquee-selection/marquee-mouse-tool'; @@ -61,7 +62,7 @@ export namespace EnableToolPaletteAction { } } @injectable() -export class ToolPalette extends AbstractUIExtension implements IActionHandler, IEditModeListener { +export class ToolPalette extends AbstractUIExtension implements IActionHandler, IEditModeListener, IDiagramStartup { static readonly ID = 'tool-palette'; @inject(TYPES.IActionDispatcher) protected readonly actionDispatcher: GLSPActionDispatcher; @@ -319,28 +320,11 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, } } - handle(action: Action): ICommand | Action | void { - if (action.kind === EnableToolPaletteAction.KIND) { - const requestAction = RequestContextActions.create({ - contextId: ToolPalette.ID, - editorContext: { - selectedElementIds: [] - } - }); - this.actionDispatcher.requestUntil(requestAction).then(response => { - if (SetContextActions.is(response)) { - this.paletteItems = response.actions.map(e => e as PaletteItem); - this.actionDispatcher.dispatch( - SetUIExtensionVisibilityAction.create({ extensionId: ToolPalette.ID, visible: !this.editorContext.isReadonly }) - ); - } - }); - } else if (action.kind === EnableDefaultToolsAction.KIND) { - this.changeActiveButton(); - if (this.focusTracker.hasFocus) { - // if focus was deliberately taken do not restore focus to the palette - this.restoreFocus(); - } + handle(action: EnableDefaultToolsAction): ICommand | Action | void { + this.changeActiveButton(); + if (this.focusTracker.hasFocus) { + // if focus was deliberately taken do not restore focus to the palette + this.restoreFocus(); } } @@ -396,6 +380,20 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, this.paletteItems = filteredPaletteItems; this.createBody(); } + + async preModelLoading(): Promise { + const requestAction = RequestContextActions.create({ + contextId: ToolPalette.ID, + editorContext: { + selectedElementIds: [] + } + }); + const response = await this.actionDispatcher.request(requestAction); + this.paletteItems = response.actions.map(e => e as PaletteItem); + if (!this.editorContext.isReadonly) { + this.show(this.editorContext.modelRoot); + } + } } export function compare(a: PaletteItem, b: PaletteItem): number { diff --git a/packages/client/src/glsp-sprotty/types.ts b/packages/client/src/glsp-sprotty/types.ts index 4de45f47..fd2ce13b 100644 --- a/packages/client/src/glsp-sprotty/types.ts +++ b/packages/client/src/glsp-sprotty/types.ts @@ -34,7 +34,9 @@ export const TYPES = { ITool: Symbol('ITool'), IDefaultTool: Symbol('IDefaultTool'), IEditModeListener: Symbol('IEditModeListener'), - IMarqueeBehavior: Symbol('IMarqueeBehavior') + IMarqueeBehavior: Symbol('IMarqueeBehavior'), + IDiagramOptions: Symbol('IDiagramOptions'), + IDiagramStartup: Symbol('IDiagramStartup') }; /** diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index da2d380a..d70a4aff 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -29,6 +29,7 @@ export * from './base/feedback/update-model-command'; export * from './base/focus/focus-state-change-action'; export * from './base/focus/focus-tracker'; export * from './base/model-initialization-constraint'; +export * from './base/model/diagram-loader'; export * from './base/model/glsp-model-source'; export * from './base/model/model-registry'; export * from './base/ranked'; diff --git a/packages/protocol/src/client-server-protocol/base-glsp-client.spec.ts b/packages/protocol/src/client-server-protocol/base-glsp-client.spec.ts index 3cc601c5..eadf3056 100644 --- a/packages/protocol/src/client-server-protocol/base-glsp-client.spec.ts +++ b/packages/protocol/src/client-server-protocol/base-glsp-client.spec.ts @@ -131,6 +131,19 @@ describe('Node GLSP Client', () => { expect(result).to.be.deep.equal(client.initializeResult); expect(server.initialize.called).to.be.false; }); + it('should fire event on first invocation', async () => { + await resetClient(); + const expectedResult = { protocolVersion: '1.0.0', serverActions: {} }; + const params = { applicationId: 'id', protocolVersion: '1.0.0' }; + server.initialize.returns(Promise.resolve(expectedResult)); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const eventHandler = (result: InitializeResult): void => {}; + const eventHandlerSpy = sinon.spy(eventHandler); + client.onServerInitialized(eventHandlerSpy); + await client.initializeServer(params); + await client.initializeServer(params); + expect(eventHandlerSpy.calledOnceWith(expectedResult)).to.be.true; + }); }); describe('initializeClientSession', () => { diff --git a/packages/protocol/src/client-server-protocol/base-glsp-client.ts b/packages/protocol/src/client-server-protocol/base-glsp-client.ts index 2c8eecc7..edc0a2d1 100644 --- a/packages/protocol/src/client-server-protocol/base-glsp-client.ts +++ b/packages/protocol/src/client-server-protocol/base-glsp-client.ts @@ -18,6 +18,7 @@ import { Deferred } from 'sprotty-protocol'; import { Disposable } from 'vscode-jsonrpc'; import { Action, ActionMessage } from '../action-protocol'; import { distinctAdd, remove } from '../utils/array-util'; +import { Emitter, Event } from '../utils/event'; import { ActionMessageHandler, ClientState, GLSPClient } from './glsp-client'; import { GLSPClientProxy, GLSPServer } from './glsp-server'; import { DisposeClientSessionParameters, InitializeClientSessionParameters, InitializeParameters, InitializeResult } from './types'; @@ -39,6 +40,11 @@ export class BaseGLSPClient implements GLSPClient { protected actionMessageHandlers: Map = new Map([[GLOBAL_HANDLER_ID, []]]); protected _initializeResult: InitializeResult; + protected onServerInitializedEmitter = new Emitter(); + get onServerInitialized(): Event { + return this.onServerInitializedEmitter.event; + } + constructor(protected options: GLSPClient.Options) { this.state = ClientState.Initial; this.proxy = this.createProxy(); @@ -92,6 +98,7 @@ export class BaseGLSPClient implements GLSPClient { async initializeServer(params: InitializeParameters): Promise { if (!this._initializeResult) { this._initializeResult = await this.checkedServer.initialize(params); + this.onServerInitializedEmitter.fire(this._initializeResult); } return this._initializeResult; } diff --git a/packages/protocol/src/client-server-protocol/glsp-client.ts b/packages/protocol/src/client-server-protocol/glsp-client.ts index 3996472e..aba78ff3 100644 --- a/packages/protocol/src/client-server-protocol/glsp-client.ts +++ b/packages/protocol/src/client-server-protocol/glsp-client.ts @@ -17,6 +17,7 @@ import * as uuid from 'uuid'; import { ActionMessage } from '../action-protocol'; import { Disposable } from '../utils/disposable'; +import { Event } from '../utils/event'; import { DisposeClientSessionParameters, InitializeClientSessionParameters, InitializeParameters, InitializeResult } from './types'; export class ApplicationIdProvider { @@ -100,6 +101,11 @@ export interface GLSPClient { */ readonly initializeResult: InitializeResult | undefined; + /** + * Event that is fired once the first invocation of {@link GLSPClient.initializeServer} has been completed. + */ + readonly onServerInitialized: Event; + /** * Send an `initializeClientSession` request to the server. One client application may open several session. * Each individual diagram on the client side counts as one session and has to provide diff --git a/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.spec.ts b/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.spec.ts index 60c067c8..64a32b41 100644 --- a/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.spec.ts +++ b/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.spec.ts @@ -21,6 +21,7 @@ import { ActionMessage } from '../../action-protocol/base-protocol'; import { remove } from '../../utils/array-util'; import { expectToThrowAsync } from '../../utils/test-util'; import { ClientState } from '../glsp-client'; +import { InitializeResult } from '../types'; import { BaseJsonrpcGLSPClient } from './base-jsonrpc-glsp-client'; import { JsonrpcGLSPClient } from './glsp-jsonrpc-client'; @@ -139,6 +140,19 @@ describe('Base JSON-RPC GLSP Client', () => { expect(result).to.be.deep.equal(client.initializeResult); expect(initializeMock.called).to.be.false; }); + it('should fire event on first invocation', async () => { + await resetClient(); + const expectedResult = { protocolVersion: '1.0.0', serverActions: {} }; + const params = { applicationId: 'id', protocolVersion: '1.0.0' }; + const initializeMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.InitializeRequest, params); + initializeMock.returns(expectedResult); + const eventHandler = (result: InitializeResult): void => {}; + const eventHandlerSpy = sinon.spy(eventHandler); + client.onServerInitialized(eventHandlerSpy); + await client.initializeServer(params); + await client.initializeServer(params); + expect(eventHandlerSpy.calledOnceWith(expectedResult)).to.be.true; + }); }); describe('initializeClientSession', () => { diff --git a/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.ts b/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.ts index 9d146c48..5e392dd3 100644 --- a/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.ts +++ b/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.ts @@ -16,6 +16,7 @@ import { injectable } from 'inversify'; import { Disposable, Message, MessageConnection } from 'vscode-jsonrpc'; import { ActionMessage } from '../../action-protocol'; +import { Emitter, Event } from '../../utils/event'; import { ActionMessageHandler, ClientState, GLSPClient } from '../glsp-client'; import { GLSPClientProxy } from '../glsp-server'; import { DisposeClientSessionParameters, InitializeClientSessionParameters, InitializeParameters, InitializeResult } from '../types'; @@ -30,6 +31,11 @@ export class BaseJsonrpcGLSPClient implements GLSPClient { protected onStop?: Promise; protected _initializeResult: InitializeResult | undefined; + protected onServerInitializedEmitter = new Emitter(); + get onServerInitialized(): Event { + return this.onServerInitializedEmitter.event; + } + constructor(options: JsonrpcGLSPClient.Options) { Object.assign(this, options); this.state = ClientState.Initial; @@ -42,6 +48,7 @@ export class BaseJsonrpcGLSPClient implements GLSPClient { async initializeServer(params: InitializeParameters): Promise { if (!this._initializeResult) { this._initializeResult = await this.checkedConnection.sendRequest(JsonrpcGLSPClient.InitializeRequest, params); + this.onServerInitializedEmitter.fire(this._initializeResult); } return this._initializeResult; }