@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
1,152 lines • 46 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.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