UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

1,152 lines 46 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.append = exports.AppendableCompoundCommand = exports.StackEntry = exports.CoreCommandStackImpl = void 0; exports.getModelIds = getModelIds; const core_1 = require("../core"); const deferred_compound_command_impl_1 = require("./deferred-compound-command-impl"); const util_1 = require("../util"); const model_logger_1 = require("@eclipse-emfcloud/model-logger"); const logger = (0, model_logger_1.getLogger)('model-manager/core-command-stack-impl'); class CoreCommandStackImpl { /** * Constructor of the core command stack. * * @param notify a call-back that receives the notification of changes * applied to one or more models, to broadcast to subscriptions or * to process otherwise * @param createFollowUpCommand Optional hook to follow up changes * performed on models with further changes to be executed in * isolation, not on the command history. * The purpose of such changes must only be to ensure integrity of * dependencies between properties of the models changed in this * `commandResult`. * The hook, if defined, is called following every execution, undo, * and redo of any command, and so must be prepared to logically * invert changes that it had provided previously to whatever extent * makes sense for its models. It is for this reason that the changes * provided by this hook are not recorded in the history, because * the hook is invoked also on undo/redo, not just on the original * execution of every command. */ constructor(workingCopyManager) { this.workingCopyManager = workingCopyManager; /** * A pointer for each editing context to the top of its undo stack, keyed by editing context ID. * Use key type `string`, not `EditingContext`, to be explicit in case of any future evolution * of the editing context as a complex type with a string identifier. */ this._undoEntries = new Map(); /** * A pointer for each editing context to the top of its redo stack, keyed by editing context ID. * @see {@link _undoEntries} */ this._redoEntries = new Map(); /** * A pointer to the stack entry that is the last command executed or redone in each context * at the time that context was last marked saved. * If a context does not have an entry then either it was never saved or it was saved when * no commands have been executed/redone, which makes no difference. * If the mapping is the flush token, then the context was dirty at the time it was flushed. */ this._savepoints = new Map(); /** * Subscriptions by editing context, with the `null` key track subscriptions to all contexts. */ this._subscriptions = new Map(); /** * Exclusively execute all public operations on the stack. */ this._exclusive = new util_1.ExclusiveExecutor(); /** * Operations to run after committing a working copy transaction. */ this.postCommitOperations = []; } execute(command, ...contexts) { return this._exclusive.run(async () => { return this.withWorkingCopies(this.workingCopyManager, async () => { await this.checkExecute(command, ...contexts); return this.withDirtyNotification(contexts, async () => { const commandResult = await mapCommandResult(command, this.perform('execute', command)); this.executed(new StackEntry(command, contexts)); this.afterCommit(() => contexts.forEach((context) => this.notifyCommandStackChanged(context, 'executed', command))); return this.postChange(commandResult); }); }, getModelIds(command)); }, contexts, getModelIds(command)); } /** * Perform an `operation` on a `command`. * * @param operation whether to execute, undo, or redo the `command` * @param command the command to execute, undo, or redo * @return a description of the model changes resulting from the execution, undo, or redo of the `command` */ perform(operation, command) { if ((0, core_1.isCompoundCommand)(command)) { return command[operation](this.workingCopyManager.getWorkingCopy.bind(this.workingCopyManager)); } const workingCopy = this.workingCopyManager.getWorkingCopy(command.modelId); if (!workingCopy) { throw new Error(`Model ${command.modelId} does not exist.`); } return command[operation](workingCopy); } executeAndAppend(appendedContext, command, ...contexts) { const modelIds = getModelIds(command); return this._exclusive.run(async () => { const appendedEntry = this.getEntry(appendedContext, 'undo'); if (!appendedEntry) { throw new Error('no command to append in the editing context'); } return this.withWorkingCopies(this.workingCopyManager, async () => { await this.checkExecute(command, appendedContext, ...contexts); const allContexts = contexts.includes(appendedContext) ? contexts : [appendedContext, ...contexts]; return this.withDirtyNotification(allContexts, async () => { const commandResult = await mapCommandResult(command, this.perform('execute', command)); this.appended(appendedEntry, new StackEntry(command, contexts)); this.afterCommit(() => allContexts.forEach((context) => this.notifyCommandStackChanged(context, 'executed', command))); return this.postChange(commandResult); }); }, modelIds); }, contexts, modelIds); } undo(context, withDependencies = true) { return this.performUndoRedo(context, 'undo', withDependencies); } /** * Revert the command on the top of the undo or redo stack of an editing `context` * according to the indicated `op`eration. * * @param context the editing context to undo or redo * @param op whether to revert the top of the undo or the redo stack of the `context` * @param withDependencies whether first to revert dependencies of the `context` as necessary * * @see {@link undo} * @see {@link redo} */ async performUndoRedo(context, op, withDependencies) { const maybeEntry = this.getEntry(context, op); if (!maybeEntry) { throw new Error(`nothing to ${op}`); } let entry = maybeEntry; if (withDependencies) { const dependencies = this.getDependencyEntries(entry, op); // Append all dependencies to the command being undone/redone for eventual atomic redo/undo entry = dependencies.reduce((prev, curr) => this.merge(prev, curr, op), entry); // And eliminate those dependencies as separate entries dependencies.forEach((dep) => this.pop(dep)); } const command = entry.command; const affectedContexts = Array.from(entry.editingContexts); const modelIds = getModelIds(entry.command); return this._exclusive.run(async () => { await this.checkUndoRedo(entry, op, withDependencies); return this.withWorkingCopies(this.workingCopyManager, async () => { return this.withDirtyNotification(affectedContexts, async () => { const commandResult = await mapCommandResult(command, this.perform(op, command)); const eventType = op === 'redo' ? 'redone' : 'undone'; this[eventType](entry); this.afterCommit(() => affectedContexts.forEach((ctx) => this.notifyCommandStackChanged(ctx, eventType, command))); return this.postChange(commandResult); }); }, modelIds); }, affectedContexts, modelIds); } /** * Merge two stack entries and remove the one that was merged into the other from * the history as now it is included in the other. * * @param into the stack entry into which to merge the other * @param from the other entry to merge `into` the first * @param forOp whether the merge is for undo or redo, which determines the order * in which the commands of the `from` entry are merged `into` the other's commands * @returns the result of the merge, which is just the `into` entry that now has been increased * * @see {@link StackEntry.merge} */ merge(into, from, forOp) { const result = into.merge(from, forOp); const prev = from.pop(); if (this._top === from) { this._top = prev; } return result; } redo(context, withDependencies = true) { return this.performUndoRedo(context, 'redo', withDependencies); } /** * Perform an `operation` on command(s) in my history that themselves modify working * copies of models supplied by the given working copy manager. * On successful conclusion of the `operation` the working copies that it used are committed * to the model manager; on failure they are just abandoned to retain the prior state of * all managed models. * In the event that the working copies are committed, all post-commit operations gathered * during the `operation` are executed; otherwise they too are abandoned. * * @param workingCopyManager the model manager's working-copy manager * @param operation the model operation to perform on working copies */ async withWorkingCopies(workingCopyManager, operation, modelIds) { let result; workingCopyManager.open(modelIds); try { result = await operation(); } finally { try { if (result) { workingCopyManager.commit(result, modelIds); this.postCommitOperations.forEach((op) => op()); } else { workingCopyManager.cancel(modelIds); } } finally { this.postCommitOperations.length = 0; } } return result; } /** * Gather an operation to run when model working copies are committed * to the model manager. * * @param closer an operation to run on working copy commit */ afterCommit(closer) { this.postCommitOperations.push(closer); } /** * Perform an `operation` on command(s) in my history with tracking and subsequent notification * of changes in the dirty state of models. Upon successful completion of the `operation` and * committing of its working copies to the model manager, dirty state change subscriptions * will be notified. * * @param contexts the editing contexts in which the `operation` is performed * @param operation the model operation to perform that will potentially change dirty states */ async withDirtyNotification(contexts, operation) { const wasDirty = contexts.map((context) => this.isDirty(context)); const oldDirtyModels = contexts.map((context, index) => new Set(wasDirty[index] ? this.getDirtyModelIds(context) : [])); const result = await operation(); this.afterCommit(() => { const isDirty = contexts.map((context) => this.isDirty(context)); const newDirtyModels = contexts.map((context, index) => new Set(isDirty[index] ? this.getDirtyModelIds(context) : [])); // Compute the symmetric difference oldDirtyModels.forEach((models, index) => Array.from(models).forEach((model) => { if (newDirtyModels[index].delete(model)) { models.delete(model); } })); for (let i = 0; i < contexts.length; i++) { if (newDirtyModels[i].size > 0 || oldDirtyModels[i].size > 0) { const modelDirtyState = new Map(); newDirtyModels[i].forEach((modelId) => modelDirtyState.set(modelId, true)); oldDirtyModels[i].forEach((modelId) => modelDirtyState.set(modelId, false)); this.notifyDirtyStateChanged(contexts[i], modelDirtyState); } } }); return result; } /** * Perform all post-change processing steps, including at least * * 1. Run the follow-up hook, if defined * 2. Run the notify hook * * if and only if the `commandResult` is defined. * * @param commandResult the result of a command execution, undo, or redo * @param the processed command result */ async postChange(commandResult) { if (!commandResult) { return commandResult; } const followUpResult = await this.processFollowUp(commandResult); let combinedResult = commandResult; if (followUpResult) { // Copy the original result and combine with the follow-up to // not modify the original `commandResult` map combinedResult = new Map(combinedResult); followUpResult.forEach((value, key) => combinedResult.set(key, value)); } return combinedResult; } /** * Call my follow-up hook, if defined, and return the results of executing the * command that it provides. * * @param commandResult the command result to inject into the follow-up hook * @returns the results from the follow-up hook's provided command, if any */ async processFollowUp(commandResult) { const followUp = await this.workingCopyManager.createFollowUpCommand?.(commandResult); if (followUp) { const canFollowUp = await this.test('canExecute', followUp); if (!canFollowUp) { logger.error('Follow-up command is not executable. Model integrity may be compromised.'); } else { return mapCommandResult(followUp, await this.perform('execute', followUp)); } } return undefined; } canExecute(command, ...contexts) { const validContexts = validateContexts(contexts); if (validContexts !== true) { return Promise.resolve(false); } return this.test('canExecute', command); } canUndo(context, withDependencies = true) { const entry = this.getEntry(context, 'undo'); return !entry ? Promise.resolve(false) : this.canUndoRedo(entry, 'undo', withDependencies); } canRedo(context, withDependencies = true) { const entry = this.getEntry(context, 'redo'); return !entry ? Promise.resolve(false) : this.canUndoRedo(entry, 'redo', withDependencies); } getUndoCommand(context) { return this.getEntry(context, 'undo')?.command; } getRedoCommand(context) { return this.getEntry(context, 'redo')?.command; } flush(context) { const dirtyModelIds = this.getDirtyModelIds(context); if (dirtyModelIds.length) { // We're flushing this context and so have to assume that // the model is changed in some unsupported fashion and // must be assumed to be dirty this._savepoints.set(context, { type: 'flushed', dirtyModelIds, }); } else { this._savepoints.delete(context); } this._undoEntries.delete(context); this._redoEntries.delete(context); const result = []; let previous; for (let entry = this._top; entry; entry = previous) { previous = entry.previous; entry.removeContext(context); if (entry.isPurgeable) { result.push(entry.command); this.pop(entry); } } this.notifyCommandStackChanged(context, 'flushed'); // Flush does not imply changes to dirty state return result; } /** * Add the stack entry for a command that has been executed in one or more contexts. * * @param entry the stack entry to add */ executed(entry) { this.flushRedo(entry); entry.editingContexts.forEach((ctx) => { this._undoEntries.set(ctx, entry); }); this.push(entry); } /** * Merge a command that has been executed into the stack-entry for the command that it appended. * * @param appendedEntry the entry for the command that was appended to * @param newEntry an entry for the command that was appended */ appended(appendedEntry, newEntry) { appendedEntry.merge(newEntry); this.flushRedo(appendedEntry); appendedEntry.editingContexts.forEach((ctx) => { this._undoEntries.set(ctx, appendedEntry); // Accounting for new contexts }); } /** * Flush all redo stacks for the editing contexts of an `entry`. * This is used after executing a command whose `entry` this is. * * @param entry an entry that has just been executed */ flushRedo(entry) { for (const ctx of entry.editingContexts) { const redoRoot = this.getEntry(ctx, 'redo'); if (redoRoot) { // Flush all of its contexts from this point this.flushRedoFrom(redoRoot); this.pop(redoRoot); } } } /** * Flush the redo stack starting from a given entry all the way down to the bottom. * All editing contexts of the starting entry are flushed from that point. * Thus some contexts may still have some older redo entries remaining. * * @param startingAt the top of the redo stack to flush */ flushRedoFrom(startingAt) { for (const ctx of startingAt.editingContexts) { // Are we flushing the entire redo history of this context? if (this.getEntry(ctx, 'redo') === startingAt) { this._redoEntries.delete(ctx); } const next = startingAt.nextIn(ctx); if (next) { // Flush recursively from this one this.flushRedoFrom(next); } } // This entry is now removed from the redo history of all its // contexts this.pop(startingAt); } /** * Push a new entry onto the stack for a command that was executed. * * @param entry an entry to push onto the stack * @returns the `entry` that was pushed */ push(entry) { if (this._top) { this._top = this._top.push(entry); } else { this._top = entry; } return entry; } /** * Remove an entry from wherever in the stack it occurs. So, no strictly speaking * a "pop" which is a LIFO operation, but it's the converse of our {@link push}. * * @param entry an entry to remove from the stack * @returns the stack entry that remains at the point where the `entry` was removed, * if the `entry` was not the very first in the temporal order */ pop(entry) { const result = entry.pop(); if (entry === this._top) { this._top = result; } return result; } /** * Update the undo pointers for all editing contexts of an `entry` whose * command has been undone. * * @param entry the entry for a command that was undone */ undone(entry) { entry.editingContexts.forEach((ctx) => { this._redoEntries.set(ctx, entry); const previous = entry.previousIn(ctx); if (previous) { this._undoEntries.set(ctx, previous); } else { this._undoEntries.delete(ctx); } }); } /** * Update the redo pointers for all editing contexts of an `entry` whose * command has been redone. * * @param entry the entry for a command that was redone */ redone(entry) { entry.editingContexts.forEach((ctx) => { this._undoEntries.set(ctx, entry); const next = entry.nextIn(ctx); if (next) { this._redoEntries.set(ctx, next); } else { this._redoEntries.delete(ctx); } }); } /** * Get the entry for the command that is at the top of an editing context's * undo stack or redo stack, that being the next command to undo or redo in * the context. * * @param editingContext the editing context * @param op whether to retrieve the undo or the redo entry * @returns the entry for the command at the top of the context's undo or redo stack */ getEntry(editingContext, op) { return (op === 'redo' ? this._redoEntries : this._undoEntries).get(editingContext); } /** * Assert that a command is executable. * Precondition for executing it on the stack. * * @param command a command to be executed * @param contexts the editing contexts in which it is to be executed * @throws if the `command` cannot be executed */ async checkExecute(command, ...contexts) { checkContexts(contexts); if (!(await this.test('canExecute', command))) { throw new Error('command is not executable'); } } /** * Test whether an operation on some `command` would be permitted in its current state. * * @param condition whether the `canExecute`, `canUndo`, or `canRedo` condition of the command is to be tested * @param command the command to be tested * @return the result of the test of the `command`'s current state under the `condition` */ async test(condition, command) { if ((0, core_1.isCompoundCommand)(command)) { return command[condition](bindGetModel(this.workingCopyManager, getModelIds(command))); } const model = getModel(this.workingCopyManager, command.modelId); return !!model && (await command[condition](model)); } /** * Assert that a command is revertible for undo or redo. * Precondition for undoing or redoing it on the stack. * * @param entry the entry for a command to be reverted * @param op the undo/redo operation to be validated * @param withDependencies whether to allow dependencies in the undo * @throws if the command of the `entry` cannot be reverted * @see {@link canUndoRedo} */ async checkUndoRedo(entry, op, withDependencies) { if (!entry || !(await this.canUndoRedo(entry, op, withDependencies))) { throw new Error(`command is not ${op}able`); } } /** * Query whether a command is revertible for undo or redo. * * A command may intrinsically be non-revertible or it may not be revertible because * one of its contexts has a command that would need to be reverted before it. * * @param entry the entry for a command to be revered * @param op whether the reversion is an undo or a redo * @param withDependencies whether to allow dependencies in the undo. Default `false` * @returns whether the command of the `entry` can be reverted */ async canUndoRedo(entry, op, withDependencies) { const toUndoRedo = new Set(this.getDependencyEntries(entry, op)); toUndoRedo.add(entry); if (toUndoRedo.size > 1 && !withDependencies) { return Promise.resolve(false); } const canOp = can(op); const results = Promise.all(Array.from(toUndoRedo).map((each) => this.test(canOp, each.command))); return results.then((all) => all.every((each) => each)); } analyzeUndo(context) { return this.analyzeUndoRedo(context, 'undo'); } /** * Compute the detailed analysis of the undoability or redoability of an editing `context`. * * @param context the editing context to analyze * @param op whether to analyze the feasibility of `undo` or `redo` of the `context` * @return the detailed analysis report */ async analyzeUndoRedo(context, op) { const canOp = can(op); // Initially assume the most trivial success const result = { [canOp]: true, hasDependencies: false, summary: `The ${op} command of context '${context}' is ${op}able.`, contexts: {}, }; // Cast out just the dynamic [canOp] record for type-safe assignment const success = result; const undoRedoEntry = this.getEntry(context, op); if (!undoRedoEntry) { success[canOp] = false; result.summary = `There is no command to ${op} in context '${context}'.`; result.contexts[context] = false; return result; } const allEntries = new Set(this.getDependencyEntries(undoRedoEntry, op)); allEntries.add(undoRedoEntry); result.hasDependencies = allEntries.size > 1; for (const entry of allEntries) { const canUndoRedo = await this.test(canOp, entry.command); success[canOp] &&= canUndoRedo; if (entry === undoRedoEntry) { // This entry is the one for the original context. We skip checking all additional associated editing contexts for it as // they are not necessarily dependencies. result.contexts[context] = canUndoRedo; } else { for (const ctx of entry.editingContexts) { result.contexts[ctx] = (result.contexts[ctx] ?? true) && canUndoRedo; } } } if (result[canOp]) { // Nothing further to compute. return result; } if (!result.hasDependencies) { result.summary = `The ${op} command of context '${context}' is not ${op}able.`; return result; } // Assemble a more elaborate summary. const failedDependencies = Object.keys(result.contexts) .filter((ctx) => ctx !== context && !result.contexts[ctx]) .map((ctx) => `'${ctx}'`); result.summary = `The ${op} command of context '${context}' is not ${op}able because`; if (!result.contexts[context]) { result.summary = `${result.summary} it is not itself ${op}able`; if (failedDependencies.length === 0) { result.summary = result.summary + '.'; } else { result.summary = result.summary + ' and'; } } if (failedDependencies.length === 1) { result.summary = `${result.summary} its dependency ${failedDependencies[0]} is not ${op}able.`; } else if (failedDependencies.length > 1) { result.summary = `${result.summary} its dependencies ${failedDependencies.join(', ')} are not ${op}able.`; } return result; } /** * Get the stack entries encoding dependencies, if any, of the given editing `context` * for undo or redo. * * @param context an editing context for which to get dependencies * @param op whether the analysis is for `undo` or for `redo` * @return the stack entries, or an empty array if none, that must be undone or * redone in other contexts before the given `context` can be undone or redone */ getDependencyEntries(entry, op) { const result = []; const chase = op === 'redo' ? 'nextIn' : 'previousIn'; for (const ctx of entry.editingContexts) { let other = this.getEntry(ctx, op); while (other && other !== entry) { result.push(other); other = other[chase](ctx); } } return result; } analyzeRedo(context) { return this.analyzeUndoRedo(context, 'redo'); } markSaved(editingContext) { const wasDirty = this.isDirty(editingContext); const oldDirtyModels = wasDirty ? this.getDirtyModelIds(editingContext) : []; const entry = this.getEntry(editingContext, 'undo'); if (entry) { this._savepoints.set(editingContext, { type: 'savepoint', entry }); } else { this._savepoints.delete(editingContext); } if (wasDirty) { // Save can only make models clean that were dirty before const dirtyState = new Map(); oldDirtyModels.forEach((modelId) => dirtyState.set(modelId, false)); this.notifyDirtyStateChanged(editingContext, dirtyState); } } isDirty(editingContext) { const savepoint = this._savepoints.get(editingContext); if (savepoint?.type === 'flushed') { // Easy case return true; } const savepointEntry = savepoint?.entry; // We are dirty if only one of undo entry and savepoint exists or // if both exist but are different const undo = this.getEntry(editingContext, 'undo'); return undo !== savepointEntry; } getDirtyModelIds(editingContext) { let result; const savepoint = this._savepoints.get(editingContext); let savepointEntry; if (savepoint?.type === 'flushed') { // Include these at least result = new Set(savepoint.dirtyModelIds); } else { result = new Set(); savepointEntry = savepoint?.entry; } const collectModelIds = (entry) => { if ((0, core_1.isCompoundCommand)(entry.command)) { entry.command.forEach((command) => result.add(command.modelId)); } else { result.add(entry.command.modelId); } }; const undoEntry = this.getEntry(editingContext, 'undo'); const redoEntry = this.getEntry(editingContext, 'redo'); if (undoEntry && (!savepointEntry || undoEntry.succeeds(savepointEntry))) { // Trace from the undo stack top down to but not including the savepoint to trace commands that // were executed or redone since last save for (let entry = undoEntry; entry && entry !== savepointEntry; entry = entry.previousIn(editingContext)) { collectModelIds(entry); } } // Redo stack is only interesting if a savepoint exists and is within it if (redoEntry && savepointEntry && (redoEntry === savepointEntry || redoEntry.precedes(savepointEntry))) { // Trace from the redo stack top down to AND including the savepoint to trace commands that // were undone since last save. Recall that the redo stack is upside-down within the overall history. for (let entry = redoEntry; entry; entry = entry.nextIn(editingContext)) { collectModelIds(entry); if (entry === savepointEntry) { break; } } } return Array.from(result); } getEditingContexts() { // This accounts for flushed but dirty contexts const result = new Set(this._savepoints.keys()); // And this accounts for all other contexts, regardless of dirty state for (let entry = this._top; entry; entry = entry.previous) { entry.editingContexts.forEach((editingContext) => result.add(editingContext)); } return Array.from(result); } subscribe(editingContext) { const key = editingContext ?? null; const result = { close: () => { const subs = this._subscriptions.get(key); if (subs) { const index = subs.indexOf(result); subs.splice(index, 1); if (subs.length === 0) { this._subscriptions.delete(key); } } }, }; let subs = this._subscriptions.get(key); if (!subs) { subs = []; this._subscriptions.set(key, subs); } subs.push(result); return result; } /** * Invoke the `onContextChanged` call-backs of subscriptions that have it. * * @param args the call-back arguments to pass along */ notifyCommandStackChanged(...args) { const editingContext = args[0]; for (const sub of this.getSubscriptions(editingContext)) { if (sub.onContextChanged) { safeCallback(sub.onContextChanged, ...args); } } } /** * Get all subscriptions pertaining to the given editing context. * * @param editingContext an editing context * @returns the subscriptions targeting the context specifically and all contexts generally */ getSubscriptions(editingContext) { // Make a copy in case a call-back adds or removes subscriptions return [ ...(this._subscriptions.get(editingContext) ?? []), ...(this._subscriptions.get(null) ?? []), ]; } /** * Invoke the `onDirtyStateChanged` call-backs of subscriptions that have it. * * @param args the call-back arguments to pass along */ notifyDirtyStateChanged(...args) { const editingContext = args[0]; for (const sub of this.getSubscriptions(editingContext)) { if (sub.onDirtyStateChanged) { safeCallback(sub.onDirtyStateChanged, ...args); } } } } exports.CoreCommandStackImpl = CoreCommandStackImpl; /** * Assert that a list of contexts for execution of a command is valid. * Precondition for executing a command on the stack. * * @param contexts contexts to be associated with a command's execution * @throws if the `contexts` array is `undefined` or empty */ const checkContexts = (contexts) => { const valid = validateContexts(contexts); if (valid !== true) { throw new Error(valid); } }; /** * Check whether a list of contexts for execution of a command is valid. * * @param contexts contexts to be associated with a command's execution * @returns an explanation if the `contexts` array is `undefined` or empty; `true`, otherwise */ const validateContexts = (contexts) => { if (!contexts || !contexts.length) { return 'an editing context is required'; } return true; }; /** * Coerce the result of execution, undo, or redo of a command to a mapping of leaf * (simple) commands to their results. * * @param command a command that has been executed, undone, or redone * @param commandResult its own result of execute, undo, or redo * @returns the `commandResult` if it is already a map from a compound command * result or else a new map of the `command` to the result */ const mapCommandResult = async (command, commandResult) => { const awaitedResult = await commandResult; if (!awaitedResult || awaitedResult instanceof Map) { return awaitedResult; } return new Map([[command, awaitedResult]]); }; /** * The command stack is a doubly-linked list, in temporal order of their original execution, of * commands and their associated editing contexts. * This class implements a node in that list. */ class StackEntry { constructor(command, editingContexts) { this._command = command; this._editingContexts = new Set(editingContexts); } /** Get the command that was executed. */ get command() { return this._command; } /** Get the editing contexts in which the command was executed. */ get editingContexts() { return this._editingContexts; } /** * Query whether the entry may be purged because all of its * editing contexts have been flushed. */ get isPurgeable() { return !this._editingContexts.size; } /** * Obtain the next command in the stack in temporal order, * regardless of editing contexts. */ get next() { return this._next; } /** * Obtain the next command in the stack that has the given * editing context. * * @param editingContext an editing context * @returns the next command, if any, in that context */ nextIn(editingContext) { for (let next = this.next; next; next = next.next) { if (next.hasContext(editingContext)) { return next; } } return undefined; } /** * Does this entry precede an`other` in the history? * An entry does not precede itself. * * @param other another stack entry * @returns whether the `other` is in my {@link next next chain} * * @see {@link next} */ precedes(other) { for (let next = this.next; next; next = next.next) { if (next === other) { return true; } } return false; } /** * Obtain the previous command in the stack in temporal order, * regardless of editing contexts. */ get previous() { return this._previous; } /** * Obtain the previous command in the stack that has the given * editing context. * * @param editingContext an editing context * @returns the previous command, if any, in that context */ previousIn(editingContext) { for (let previous = this.previous; previous; previous = previous.previous) { if (previous.hasContext(editingContext)) { return previous; } } return undefined; } /** * Does this entry succeed an`other` in the history? * An entry does not succeed itself. * * @param other another stack entry * @returns whether the `other` is in my {@link previous previous chain} * * @see {@link previous} */ succeeds(other) { for (let previous = this.previous; previous; previous = previous.previous) { if (previous === other) { return true; } } return false; } /** * Remove editing contexts (that were flushed) from a command. * * @param editingContexts the editing contexts to remove from the command */ removeContext(...editingContexts) { editingContexts.forEach((ctx) => this._editingContexts.delete(ctx)); } /** * Query whether my command is associated with the given editing context. * * @param editingContext an editing context * @returns whether the command has that context */ hasContext(editingContext) { return this._editingContexts.has(editingContext); } /** * Insert a new entry into the stack following me, making it my new next. * My former next becomes its next and I become its previous. * * @param next my new next entry * @returns the `next` entry that was pushed * @throws on attempt to push the entry, itself, to make a cycle */ push(next) { for (let check = next; check; check = check.next) { if (check === this) { throw new Error('push would create a cycle'); } } const oldNext = this._next; if (oldNext) { // There is no legitimate way to get here. Executing a command // is always the last (in temporal order) command operation and // so always pushes onto the top of the CoreCommandStack oldNext._previous = next; } next._next = oldNext; this._next = next; next._previous = this; return next; } /** * Remove me from the stack. * * @returns my former previous entry */ pop() { const oldPrevious = this._previous; const oldNext = this._next; if (oldPrevious) { oldPrevious._next = oldNext; } if (oldNext) { oldNext._previous = oldPrevious; } this._next = undefined; this._previous = undefined; return oldPrevious; } /** * Merge an `entry` into me, appending its command to mine * and its editing contexts to mine. * * @param entry an entry to merge into me * @param forOp the operation for which the compound is being prepared * @returns me */ merge(entry, forOp = 'undo') { this._command = // Reverse order for the redo op forOp === 'redo' ? appendForOp(forOp, entry.command, this._command) : appendForOp(forOp, this._command, entry.command); entry.editingContexts.forEach((ctx) => this._editingContexts.add(ctx)); return this; } } exports.StackEntry = StackEntry; /** * A specialized compound command that allows appending an already-executed command * if the compound has already been executed but not undone. */ class AppendableCompoundCommand extends core_1.CompoundCommandImpl { constructor(label, initialStateOrCommand, ...commands) { super(label, ...(typeof initialStateOrCommand === 'string' ? commands : [initialStateOrCommand, ...commands])); this.state = typeof initialStateOrCommand === 'string' ? initialStateOrCommand : 'executed'; } append(...commands) { if (this.wasUndone()) { throw new Error('cannot append to a command on the redo stack'); } this._commands.push(...commands); return this; } } exports.AppendableCompoundCommand = AppendableCompoundCommand; /** * Compose a `base` command with additional `commands` for either undo. * **Note** that compounding commands for their initial execution does not need * this specialized mechanism. * * @param base a command to append to * @param commands commands to append to it * @returns some command that includes the `base` and all of the additional `commands` */ const append = (base, ...commands) => appendForOp('undo', base, ...commands); exports.append = append; /** * Compose a `base` command with additional `commands` for either undo or redo. * **Note** that compounding commands for their initial execution does not need * this specialized mechanism. * * @param op the operation for which the compound is being prepared * @param base a command to append to * @param commands commands to append to it * @returns some command that includes the `base` and all of the additional `commands` */ const appendForOp = (op, base, ...commands) => { if (!commands || !commands.length) { return base; } let result; if (base instanceof AppendableCompoundCommand) { result = base; base.append(...commands); } else { const initialState = op === 'redo' ? 'undone' : 'executed'; result = new AppendableCompoundCommand(base.label, initialState, base, ...commands); } return result; }; const getModel = (workingCopyManager, modelId) => { if (workingCopyManager.isOpen([modelId])) { return workingCopyManager.getWorkingCopy(modelId); } return workingCopyManager.getModel(modelId); }; const bindGetModel = (workingCopyManager, modelIds) => { if (workingCopyManager.isOpen(modelIds)) { return workingCopyManager.getWorkingCopy.bind(workingCopyManager); } return workingCopyManager.getModel.bind(workingCopyManager); }; /** * Safely invoke a call-back, reporting any uncaught exception that it * may throw, to ensure that subsequent subscriptions don't miss out. */ const safeCallback = (callback, ...args) => { try { callback(...args); } catch (error) { logger.error('Uncaught exception in CoreCommandStack call-back.', error); } }; const can = (op) => { return (op === 'redo' ? 'canRedo' : 'canUndo'); }; function getModelIds(command) { if ((0, core_1.isCompoundCommand)(command)) { if (command instanceof deferred_compound_command_impl_1.DeferredCompoundCommand) { // A deferred compound command knows its own scope return command.modelScope; } const modelIdSet = new Set(); // Each command may be a CompoundCommand or DeferredCompoundCommand // so we need to recurse into each leaf command for (const leaf of command.getCommands()) { getModelIds(leaf).forEach((modelId) => modelIdSet.add(modelId)); } return Array.from(modelIdSet); } else { return [command.modelId]; } } //# sourceMappingURL=core-command-stack-impl.js.map