UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

386 lines (385 loc) 13.9 kB
"use strict"; // ***************************************************************************** // Copyright (C) 2023-2024 STMicroelectronics. // // 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: MIT License which is // available at https://opensource.org/licenses/MIT. // // SPDX-License-Identifier: EPL-2.0 OR MIT // ***************************************************************************** Object.defineProperty(exports, "__esModule", { value: true }); exports.groupByModelId = exports.append = exports.isCompoundCommand = exports.CompoundCommandImpl = exports.unwrapReturnResult = exports.isSimpleCommandWithResult = void 0; const model_logger_1 = require("@eclipse-emfcloud/model-logger"); /** * Query whether a command is a simple command publishing an abstract execution result. */ const isSimpleCommandWithResult = (command) => { return (!(0, exports.isCompoundCommand)(command) && 'result' in command && !!command.result && typeof command.result === 'object' && 'status' in command.result && typeof command.result.status === 'string'); }; exports.isSimpleCommandWithResult = isSimpleCommandWithResult; /** * Unwrap the return result of a {@link SimpleCommandWithResult}. * * @returns the return-result `value` if it is `ready`, otherwise `undefined` if it is * still `pending` * @throws the return-result `error` if it is `failed` */ const unwrapReturnResult = (command) => { const result = command.result; switch (result.status) { case 'ready': return result.value; case 'failed': if (result.error) { throw result.error; } throw new Error('Command failed.'); default: // 'pending' return undefined; } }; exports.unwrapReturnResult = unwrapReturnResult; const logger = (0, model_logger_1.getLogger)('model-manager/command'); /** * A basic implementation of the `CompoundCommand` interface suitable for most uses. * * @class CompoundCommandImpl */ class CompoundCommandImpl { constructor(label, ...commands) { this.state = 'ready'; this._label = label; this._commands = [...commands]; } get label() { return this._label; } /** Query whether I am in the "ready" (not yet executed, undone, or redone) state. */ isReady() { return this.inState('ready'); } canExecute(getModel) { return !this.isReady() ? Promise.resolve(false) : this.everyCommand((c) => { if ((0, exports.isCompoundCommand)(c)) { return c.canExecute(getModel); } const model = getModel(c.modelId); return !!model && c.canExecute(model); }); } /** Query whether I am in the "executed" (or redone) state. */ wasExecuted() { return this.inState('executed'); } canUndo(getModel) { return !this.wasExecuted() ? Promise.resolve(false) : this.everyCommand((c) => { if ((0, exports.isCompoundCommand)(c)) { return c.canUndo(getModel); } const model = getModel(c.modelId); return !!model && c.canUndo(model); }); } /** Query whether I am in the "undone" state. */ wasUndone() { return this.inState('undone'); } canRedo(getModel) { return !this.wasUndone() ? Promise.resolve(false) : this.everyCommand((c) => { if ((0, exports.isCompoundCommand)(c)) { return c.canRedo(getModel); } const model = getModel(c.modelId); return !!model && c.canRedo(model); }); } async execute(getModel) { await this.checkState('execute', 'ready', this.canExecute(getModel)); const result = await this.iterateCommands('execute', getModel); this.state = 'executed'; return result; } async undo(getModel) { await this.checkState('undo', 'executed', this.canUndo(getModel)); const result = await this.iterateCommands('undo', getModel); this.state = 'undone'; return result; } async redo(getModel) { await this.checkState('redo', 'undone', this.canRedo(getModel)); const result = await this.iterateCommands('redo', getModel); this.state = 'executed'; return result; } append(...commands) { if (!this.inState('ready')) { throw new Error('Cannot append to executed compound.'); } this._commands.push(...commands); return this; } getCommands() { return [...this._commands]; } /** * Compute the conjunction of a possibly asynchronous `predicate` over all of my constituent commands. * * @param predicate a test to apply to each command in turn * @returns the conjunction of the `predicate` results for every command, or `false` if I have no commands */ async everyCommand(predicate) { if (!this._commands.length) { logger.debug(`No constituent command for ${this.label}.`); } for (const command of this._commands) { const current = await predicate(command); if (!current) { return current; } } return true; } /** * Query whether I am in some `state`. * * @param state a state to query * @returns whether I am in the given `state` */ inState(state) { return this.state === state; } /** * Guard the invocation of some operation by preconditions of `state` and other * arbitrary conditions. * * @param op the name of the operation being guarded * @param state the state in which I must be for valid invocation of the operation * @param canDo whether other preconditions for the operation are met * * @throws if either I am not in the given `state` or `canDo` is `false` */ async checkState(op, state, canDo) { if (!this.inState(state)) { throw new Error(`Cannot ${op}: not in the correct state`); } if (!(await canDo)) { throw new Error(`Cannot ${op}`); } } [Symbol.iterator]() { return new CompoundCommandIterator(this); } forEach(processor) { let index = 0; for (const next of this) { processor(next, index++); } } map(transform) { const result = []; this.forEach((next, index) => result.push(transform(next, index))); return result; } /** * Iterate over my commands, applying an execute/undo/redo operation on each, and collecting the results. * * The iteration order depends on my current state: if I have been executed or redone, then iteration is backwards * from my last subcommand to my first, because the operation that I can perform is an undo. * Otherwise, the order is forwards from my first subcommand to my last. * * The result is `undefined` if all of my subcommands returned an `undefined` result. * Otherwise, it is a mapping of subcommands to their results, for those that returned some result. * * @param operation the operation to invoke on my commands * @param getModel a function providing the models on which I shall execute/undo/redo my sub-commands * @returns the aggregate results of the `operation` over my commands, if available */ async iterateCommands(operation, getModel) { // The direction to iterate the commands depends on whether we are undoing them or now const commands = operation === 'undo' ? [...this._commands].reverse() : this._commands; const result = new Map(); // Rewind changes already done in case of failure const rewind = async (from) => { // Rewind in reverse order, not including the one that failed const recover = commands.slice(0, from).reverse(); const revert = operation === 'undo' ? 'redo' : 'undo'; // Best-effort all the way for (const command of recover) { try { if ((0, exports.isCompoundCommand)(command)) { await command[revert](getModel); } else { const model = getModel(command.modelId); if (!model) { throw new Error(`No model on which to ${revert} command.`); } await command[revert](model); } } catch (error) { logger.error(`Error in recovery of failed ${operation}. Continuing best-effort rewind.`, error); } } }; for (let i = 0; i < commands.length; i++) { const command = commands[i]; try { let delta; if ((0, exports.isCompoundCommand)(command)) { delta = await command[operation](getModel); } else { const model = getModel(command.modelId); if (!model) { throw new Error(`No model on which to ${operation} command.`); } delta = await command[operation](model); } if (delta instanceof Map) { for (const [subCommand, subDelta] of delta.entries()) { result.set(subCommand, subDelta); } } else if (delta) { result.set(command, delta); } } catch (error) { await rewind(i); throw error; } } return result.size ? result : undefined; } } exports.CompoundCommandImpl = CompoundCommandImpl; /** * Type guard determining whether a `command` is a `CompoundCommand`. * * @function * @param command a command * @returns whether the `command` is a `CompoundCommand` */ const isCompoundCommand = (command) => { return 'append' in command && typeof command.append === 'function'; }; exports.isCompoundCommand = isCompoundCommand; /** * Append zero or more commands to a `base` command. * * If the `commands` to append are none, then the `base` is returned as is. * * If the `base` command is already a compound, it is appended in place and returned. * Otherwise, a new `CompoundCommand` is created from all of the given commands and * returned. * * In all cases, the label of the result is the label of the `base` command. * * @template K the type of model ID used by the Model Manager * @param base the command on which to append one or more additional commands * @param commands commands to append * @returns a compound of the `base` and, `commands`, in that order */ const append = (base, ...commands) => { if (!commands || commands.length === 0) { return base; } let result; if ((0, exports.isCompoundCommand)(base)) { result = base; base.append(...commands); } else { result = new CompoundCommandImpl(base.label, base, ...commands); } return result; }; exports.append = append; /** * Convert a map of (Command, Operation[]) to a map of (Model, Operation[]). * @param commands The map to convert (typically the result of an execute/undo/redo call) * @returns A map grouping result Operation[] by affected model */ const groupByModelId = (commands) => { const result = new Map(); for (const command of commands.keys()) { if (!(0, exports.isCompoundCommand)(command)) { const operations = commands.get(command); if (operations !== undefined) { const existingOps = result.get(command.modelId); if (existingOps === undefined) { result.set(command.modelId, operations); } else { existingOps.push(...operations); } } } } return result; }; exports.groupByModelId = groupByModelId; class SimpleCommandIterator { constructor(source) { this._nextValue = source; } next() { const value = this._nextValue; this._nextValue = undefined; return value ? { done: false, value, } : { done: true, value, }; } } class CompoundCommandIterator { constructor(source) { this._cursor = 0; this._iterators = source .getCommands() .map((command) => (0, exports.isCompoundCommand)(command) ? new CompoundCommandIterator(command) : new SimpleCommandIterator(command)); } next() { if (this._cursor >= this._iterators.length) { this._iterators.length = 0; this._cursor = 0; return { done: true, value: undefined, }; } const nextResult = this._iterators[this._cursor].next(); if (!nextResult.done) { return nextResult; } // Advance to the next iterator this._cursor++; return this.next(); } } //# sourceMappingURL=command.js.map