/******************************************************************************** * Copyright (c) 2019-2026 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 { Action, ActionDispatcher, ActionHandler, ActionHandlerRegistry, Deferred, EMPTY_ROOT, GModelRoot, HandleActionResult, IActionDispatcher, MaybePromise, RejectAction, RequestAction, ResponseAction, SetModelAction, TYPES } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; import { GLSPActionHandlerRegistry } from './action-handler-registry'; import { IGModelRootListener } from './editor-context-service'; import { OptionalAction } from './model/glsp-model-source'; import { ModelInitializationConstraint } from './model/model-initialization-constraint'; @injectable() export class GLSPActionDispatcher extends ActionDispatcher implements IGModelRootListener, IActionDispatcher { protected readonly timeouts: Map = new Map(); protected initializedConstraint = false; @inject(ModelInitializationConstraint) protected initializationConstraint: ModelInitializationConstraint; @inject(ActionHandlerRegistry) protected override actionHandlerRegistry: ActionHandlerRegistry; /** @deprecated No longer in used. The {@link ActionHandlerRegistry} is now directly injected */ // eslint-disable-next-line @typescript-eslint/no-deprecated @inject(TYPES.ActionHandlerRegistryProvider) protected override actionHandlerRegistryProvider: () => Promise; protected postUpdateQueue: Action[] = []; protected initializeDeferred = new Deferred(); override initialize(): Promise { if (!this.initialized) { this.initialized = this.initializeDeferred.promise; this.doInitialize(); } return this.initialized; } protected async doInitialize(): Promise { try { if (this.actionHandlerRegistry instanceof GLSPActionHandlerRegistry) { this.actionHandlerRegistry.initialize(); } this.handleAction(SetModelAction.create(EMPTY_ROOT)).catch(() => { /* Logged in handleAction method */ }); this.startModelInitialization(); this.initializeDeferred.resolve(); } catch (error) { this.initializeDeferred.reject(error); } } protected startModelInitialization(): void { if (!this.initializedConstraint) { this.logger.log(this, 'Starting model initialization mode'); this.initializationConstraint.onInitialized(() => this.logger.log(this, 'Model initialization completed')); this.initializedConstraint = true; } } onceModelInitialized(): Promise { return this.initializationConstraint.onInitialized(); } hasHandler(action: Action): boolean { return this.actionHandlerRegistry.get(action.kind).length > 0; } /** * Processes all given actions, by dispatching them to the corresponding handlers, after the model initialization is completed. * * @param actions The actions that should be dispatched after the model initialization */ dispatchOnceModelInitialized(...actions: Action[]): void { this.initializationConstraint.onInitialized(() => this.dispatchAll(actions)); } /** * Processes all given actions, by dispatching them to the corresponding handlers, after the next model update. * The given actions are queued until the next model update cycle has been completed i.e. * the `EditorContextService.onModelRootChanged` event is triggered. * * @param actions The actions that should be dispatched after the next model update */ dispatchAfterNextUpdate(...actions: Action[]): void { this.postUpdateQueue.push(...actions); } modelRootChanged(_root: Readonly): void { if (this.postUpdateQueue.length === 0) { return; } const toDispatch = [...this.postUpdateQueue]; this.postUpdateQueue = []; this.dispatchAll(toDispatch); } override async dispatch(action: Action): Promise { const result = await super.dispatch(action); this.initializationConstraint.notifyDispatched(action); return result; } protected override handleAction(action: Action): Promise { return ResponseAction.hasValidResponseId(action) ? this.handleResponseAction(action) : this.doHandleAction(action); } protected async handleResponseAction(action: ResponseAction): Promise { const timeout = this.timeouts.get(action.responseId); if (timeout !== undefined) { clearTimeout(timeout); this.timeouts.delete(action.responseId); } const request = this.requests.get(action.responseId); if (!request) { // No pending request: re-dispatch as a normal action. this.logger.log(this, 'No matching request for response, dispatch normally', action); action.responseId = ''; return this.handleAction(action); } this.requests.delete(action.responseId); if (RejectAction.is(action)) { request.reject(new Error(action.message)); this.logger.warn(this, `Request with id ${action.responseId} failed.`, action.message, action.detail); } else { request.resolve(action); } } protected doHandleAction(action: Action): Promise { const handlers = this.actionHandlerRegistry.get(action.kind); return handlers.length === 0 ? this.handleActionWithoutHandler(action) : this.handleActionWithHandler(action, handlers); } protected async handleActionWithoutHandler(action: Action): Promise { if (OptionalAction.is(action) && !this.hasHandler(action)) { return; } this.logger.warn(this, 'Missing handler for action', action); const error = new Error(`Missing handler for action '${action.kind}'`); if (RequestAction.is(action)) { const request = this.requests.get(action.requestId); if (request !== undefined) { this.requests.delete(action.requestId); request.reject(error); } } throw error; } protected async handleActionWithHandler(action: Action, handlers: ActionHandler[]): Promise { this.logger.log(this, 'Handle', action); // No `await` inside the loop: invoke handlers in one burst and await their results collectively. const handlerResults: PromiseLike[] = []; for (const handler of handlers) { const maybeResult = handler.handle(action); if (MaybePromise.isPromise(maybeResult)) { handlerResults.push(maybeResult.then(result => this.processHandlerResult(result))); } else { const resultPromise = this.processHandlerResult(maybeResult); if (resultPromise) { handlerResults.push(resultPromise); } } } await Promise.all(handlerResults); } protected processHandlerResult(result: HandleActionResult): Promise | undefined { if (Action.is(result)) { return this.dispatch(result); } if (result !== undefined) { this.blockUntil = result.blockUntil; return this.commandStack.execute(result); } return undefined; } override request(action: RequestAction): Promise { if (!action.requestId || action.requestId === '') { action.requestId = RequestAction.generateRequestId(); } return super.request(action); } /** * Dispatch a request and waits for a response until the timeout given in `timeoutMs` has * been reached. The returned promise is resolved when a response with matching identifier * is dispatched or when the timeout has been reached. That response is _not_ passed to the * registered action handlers. Instead, it is the responsibility of the caller of this method * to handle the response properly. For example, it can be sent to the registered handlers by * passing it again to the `dispatch` method. * If `rejectOnTimeout` is set to false (default) the returned promise will be resolved with * no value, otherwise it will be rejected. */ requestUntil( action: RequestAction, timeoutMs: number = action.timeout ?? 2000, rejectOnTimeout = false ): Promise { if (!action.requestId || action.requestId === '') { action.requestId = RequestAction.generateRequestId(); } // Stamp the effective timeout onto the action so the receiving side // (handleServerRequest/handleClientRequest) can respect it. action.timeout = timeoutMs; const requestId = action.requestId; const timeout = setTimeout(() => { const deferred = this.requests.get(requestId); if (deferred !== undefined) { clearTimeout(timeout); this.requests.delete(requestId); const notification = 'Request ' + requestId + ' (' + action + ') time out after ' + timeoutMs + 'ms.'; if (rejectOnTimeout) { deferred.reject(notification); } else { this.logger.info(this, notification); deferred.resolve(); } } }, timeoutMs); this.timeouts.set(requestId, timeout); const result = super.request(action); // handleResponseAction only clears the timeout on the response path; clear it here on every // other settle path (timeout, rejection, missing handler) so the timer and map entry can't leak. const clearRequestTimeout = (): void => { const pending = this.timeouts.get(requestId); if (pending !== undefined) { clearTimeout(pending); this.timeouts.delete(requestId); } }; result.then(clearRequestTimeout, clearRequestTimeout); return result; } }