@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
386 lines (385 loc) • 13.9 kB
JavaScript
"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