@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
1,585 lines (1,411 loc) • 50.7 kB
text/typescript
// *****************************************************************************
// 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
// *****************************************************************************
import { Operation } from 'fast-json-patch';
import {
Can,
Command,
CommandAnalysis,
CommandResult,
CompoundCommandImpl,
CoreCommandStack,
CoreCommandStackSubscription,
DirtyStateChangedCallback,
EditingContext,
EditingContextChangedCallback,
MaybePromise,
RedoAnalysis,
SimpleCommandResult,
UndoAnalysis,
UndoRedoOp,
isCompoundCommand,
} from '../core';
import { DeferredCompoundCommand } from './deferred-compound-command-impl';
import { ExclusiveExecutor } from '../util';
import { getLogger } from '@eclipse-emfcloud/model-logger';
/** Any operation that can be performed on a command. */
export type CommandOp = 'execute' | UndoRedoOp;
const logger = getLogger('model-manager/core-command-stack-impl');
/** A savepoint record that points to a stack entry. */
interface StackEntrySavepoint<K = string> {
type: 'savepoint';
entry: StackEntry<K>;
}
/**
* A savepoint record that records the last known dirty
* models at time of flushing the context.
*/
interface FlushedSavepoint<K> {
type: 'flushed';
dirtyModelIds: K[];
}
/** A savepoint record. */
type Savepoint<K = string> = StackEntrySavepoint<K> | FlushedSavepoint<K>;
export interface WorkingCopyManager<K = string> {
/**
* Prepare a new edit session in which working copies will be required.
*/
open(modelId: K[]): void;
/**
* Whether the working copy manager has an open session.
*/
isOpen(modelId: K[]): boolean;
/**
* Get a working copy of the model identified by the given ID, if it
* exists. Until changes are committed or the working copies are
* reset by a call to {@link open}, the same working copy must be
* supplied for all requests for the same ID.
*/
getWorkingCopy(modelId: K): object | undefined;
/**
* Get the current public state of the model identified by the given ID, if it
* exists. **Note** that this **is not a working copy**.
*/
getModel(modelId: K): object | undefined;
/**
* Optional method 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.
*/
createFollowUpCommand?(
commandResult: Map<Command<K>, Operation[]>
): MaybePromise<Command<K> | undefined>;
/**
* Commit the current working copy state back to the model storage,
* with a summary of the changes that are being committed.
*
* @param result a map of commands to the operations that were executed
* @param modelIds the model IDs that were opened
*/
commit(result: Map<Command<K>, Operation[]>, modelIds: K[]): void;
/**
* Discard the current working copy state and close.
*/
cancel(modelIds: K[]): void;
}
export class CoreCommandStackImpl<K = string> implements CoreCommandStack<K> {
/** The most recent of all commands executed, in temporal order, in all editing contexts. */
private _top?: StackEntry<K>;
/**
* 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.
*/
private _undoEntries = new Map<string, StackEntry<K>>();
/**
* A pointer for each editing context to the top of its redo stack, keyed by editing context ID.
* @see {@link _undoEntries}
*/
private _redoEntries = new Map<string, StackEntry<K>>();
/**
* 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.
*/
private _savepoints = new Map<string, Savepoint<K>>();
/**
* Subscriptions by editing context, with the `null` key track subscriptions to all contexts.
*/
private _subscriptions = new Map<
EditingContext | null,
CoreCommandStackSubscription<K>[]
>();
/**
* Exclusively execute all public operations on the stack.
*/
private _exclusive = new ExclusiveExecutor<K>();
/**
* Operations to run after committing a working copy transaction.
*/
private postCommitOperations = <(() => unknown)[]>[];
/**
* 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(private workingCopyManager: WorkingCopyManager<K>) {}
execute(
command: Command<K>,
...contexts: EditingContext[]
): Promise<Map<Command<K>, Operation[]> | undefined> {
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`
*/
private perform(
operation: CommandOp,
command: Command<K>
): SimpleCommandResult | CommandResult<K> {
if (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: EditingContext,
command: Command<K>,
...contexts: EditingContext[]
): Promise<Map<Command<K>, Operation[]> | undefined> {
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: EditingContext,
withDependencies = true
): Promise<Map<Command<K>, Operation[]> | undefined> {
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}
*/
private async performUndoRedo(
context: EditingContext,
op: UndoRedoOp,
withDependencies: boolean
): Promise<Map<Command<K>, Operation[]> | undefined> {
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}
*/
private merge(
into: StackEntry<K>,
from: StackEntry<K>,
forOp: UndoRedoOp
): StackEntry<K> {
const result = into.merge(from, forOp);
const prev = from.pop();
if (this._top === from) {
this._top = prev;
}
return result;
}
redo(
context: EditingContext,
withDependencies = true
): Promise<Map<Command<K>, Operation[]> | undefined> {
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
*/
protected async withWorkingCopies<K>(
workingCopyManager: WorkingCopyManager<K>,
operation: () => Promise<Map<Command<K>, Operation[]> | undefined>,
modelIds: K[]
): Promise<Map<Command<K>, Operation[]> | undefined> {
let result: Map<Command<K>, Operation[]> | undefined;
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
*/
protected afterCommit(closer: () => unknown): void {
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
*/
protected async withDirtyNotification<T>(
contexts: EditingContext[],
operation: () => Promise<T>
): Promise<T> {
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<K, boolean>();
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
*/
private async postChange(
commandResult: Map<Command<K>, Operation[]> | undefined
): Promise<Map<Command<K>, Operation[]> | undefined> {
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<Command<K>, Operation[]>(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
*/
private async processFollowUp(
commandResult: Map<Command<K>, Operation[]>
): Promise<Map<Command<K>, Operation[]> | undefined> {
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: Command<K>, ...contexts: string[]): Promise<boolean> {
const validContexts = validateContexts(contexts);
if (validContexts !== true) {
return Promise.resolve(false);
}
return this.test('canExecute', command);
}
canUndo(context: EditingContext, withDependencies = true): Promise<boolean> {
const entry = this.getEntry(context, 'undo');
return !entry
? Promise.resolve(false)
: this.canUndoRedo(entry, 'undo', withDependencies);
}
canRedo(context: EditingContext, withDependencies = true): Promise<boolean> {
const entry = this.getEntry(context, 'redo');
return !entry
? Promise.resolve(false)
: this.canUndoRedo(entry, 'redo', withDependencies);
}
getUndoCommand(context: EditingContext): Command<K> | undefined {
return this.getEntry(context, 'undo')?.command;
}
getRedoCommand(context: EditingContext): Command<K> | undefined {
return this.getEntry(context, 'redo')?.command;
}
flush(context: EditingContext): Command<K>[] {
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: Command<K>[] = [];
let previous: StackEntry<K> | undefined;
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
*/
private executed(entry: StackEntry<K>): void {
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
*/
private appended(
appendedEntry: StackEntry<K>,
newEntry: StackEntry<K>
): void {
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
*/
private flushRedo(entry: StackEntry<K>): void {
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
*/
private flushRedoFrom(startingAt: StackEntry<K>): void {
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
*/
private push(entry: StackEntry<K>): StackEntry<K> {
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
*/
private pop(entry: StackEntry<K>): StackEntry<K> | undefined {
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
*/
private undone(entry: StackEntry<K>): void {
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
*/
private redone(entry: StackEntry<K>): void {
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
*/
private getEntry(
editingContext: EditingContext,
op: UndoRedoOp
): StackEntry<K> | undefined {
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
*/
private async checkExecute(
command: Command<K>,
...contexts: EditingContext[]
): Promise<void> {
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`
*/
private async test(
condition: Can<CommandOp>,
command: Command<K>
): Promise<boolean> {
if (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}
*/
private async checkUndoRedo(
entry: StackEntry<K>,
op: UndoRedoOp,
withDependencies: boolean
): Promise<void> {
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
*/
private async canUndoRedo(
entry: StackEntry<K>,
op: UndoRedoOp,
withDependencies: boolean
): Promise<boolean> {
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: string): Promise<UndoAnalysis> {
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
*/
private async analyzeUndoRedo<Op extends UndoRedoOp>(
context: string,
op: Op
): Promise<CommandAnalysis<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: {},
} as CommandAnalysis<Op>;
// Cast out just the dynamic [canOp] record for type-safe assignment
const success: Record<Can<Op>, boolean> = 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
*/
private getDependencyEntries(
entry: StackEntry<K>,
op: UndoRedoOp
): StackEntry<K>[] {
const result: StackEntry<K>[] = [];
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: string): Promise<RedoAnalysis> {
return this.analyzeUndoRedo(context, 'redo');
}
markSaved(editingContext: EditingContext): void {
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<K, boolean>();
oldDirtyModels.forEach((modelId) => dirtyState.set(modelId, false));
this.notifyDirtyStateChanged(editingContext, dirtyState);
}
}
isDirty(editingContext: string): boolean {
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: EditingContext): K[] {
let result: Set<K>;
const savepoint = this._savepoints.get(editingContext);
let savepointEntry: StackEntry<K> | undefined;
if (savepoint?.type === 'flushed') {
// Include these at least
result = new Set(savepoint.dirtyModelIds);
} else {
result = new Set<K>();
savepointEntry = savepoint?.entry;
}
const collectModelIds = (entry: StackEntry<K>) => {
if (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: StackEntry<K> | undefined = 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: StackEntry<K> | undefined = redoEntry;
entry;
entry = entry.nextIn(editingContext)
) {
collectModelIds(entry);
if (entry === savepointEntry) {
break;
}
}
}
return Array.from(result);
}
getEditingContexts(): EditingContext[] {
// This accounts for flushed but dirty contexts
const result = new Set<EditingContext>(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?: string | undefined
): CoreCommandStackSubscription<K> {
const key = editingContext ?? null;
const result: CoreCommandStackSubscription<K> = {
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
*/
protected notifyCommandStackChanged(
...args: Parameters<EditingContextChangedCallback<K>>
): void {
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
*/
private getSubscriptions(
editingContext: EditingContext
): CoreCommandStackSubscription<K>[] {
// 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
*/
protected notifyDirtyStateChanged(
...args: Parameters<DirtyStateChangedCallback<K>>
): void {
const editingContext = args[0];
for (const sub of this.getSubscriptions(editingContext)) {
if (sub.onDirtyStateChanged) {
safeCallback(sub.onDirtyStateChanged, ...args);
}
}
}
}
/**
* 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?: EditingContext[]): void => {
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?: EditingContext[]): string | true => {
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 <K = string>(
command: Command<K>,
commandResult: MaybePromise<
Operation[] | Map<Command<K>, Operation[]> | undefined
>
): Promise<Map<Command<K>, Operation[]> | undefined> => {
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.
*/
export class StackEntry<K = string> {
/** The editing contexts in which a command was executed. */
private readonly _editingContexts: Set<EditingContext>;
/** The command that was executed. */
private _command: Command<K>;
/** The next command in the list (stack). */
private _next?: StackEntry<K>;
/** The previous command in the list (stack). */
private _previous?: StackEntry<K>;
constructor(command: Command<K>, editingContexts: EditingContext[]) {
this._command = command;
this._editingContexts = new Set(editingContexts);
}
/** Get the command that was executed. */
get command(): Command<K> {
return this._command;
}
/** Get the editing contexts in which the command was executed. */
get editingContexts(): Set<EditingContext> {
return this._editingContexts;
}
/**
* Query whether the entry may be purged because all of its
* editing contexts have been flushed.
*/
get isPurgeable(): boolean {
return !this._editingContexts.size;
}
/**
* Obtain the next command in the stack in temporal order,
* regardless of editing contexts.
*/
get next(): StackEntry<K> | undefined {
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: EditingContext): StackEntry<K> | undefined {
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: StackEntry<K>): boolean {
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(): StackEntry<K> | undefined {
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: EditingContext): StackEntry<K> | undefined {
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: StackEntry<K>): boolean {
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: EditingContext[]): void {
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: EditingContext): boolean {
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: StackEntry<K>): StackEntry<K> {
for (
let check: StackEntry<K> | undefined = 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(): StackEntry<K> | undefined {
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: StackEntry<K>, forOp: UndoRedoOp = 'undo'): this {
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;
}
}
/**
* A specialized compound command that allows appending an already-executed command
* if the compound has already been executed but not undone.
*/
export class AppendableCompoundCommand<
K = string
> extends CompoundCommandImpl<K> {
/** Construct an appendable compound that is initially in the _executed_ state. */
constructor(label: string, ...commands: Command<K>[]);
/** Construct an appendable compound that is initially in the _executed_ state. */
constructor(
label: string,
initialState: CompoundCommandImpl['state'],
...commands: Command<K>[]
);
constructor(
label: string,
initialStateOrCommand: CompoundCommandImpl['state'] | Command<K>,
...commands: Command<K>[]
) {
super(
label,
...(typeof initialStateOrCommand === 'string'
? commands
: [initialStateOrCommand, ...commands])
);
this.state =
typeof initialStateOrCommand === 'string'
? initialStateOrCommand
: 'executed';
}
append(...commands: Command<K>[]): this {
if (this.wasUndone()) {
throw new Error('cannot append to a command on the redo stack');
}
this._commands.push(...commands);
return this;
}
}
/**
* 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`
*/
export const append = <K = string>(
base: Command<K>,
...commands: Command<K>[]
): Command<K> => appendForOp('undo', base, ...commands);
/**
* 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 = <K = string>(
op: UndoRedoOp,
base: Command<K>,
...commands: Command<K>[]
): Command<K> => {
if (!commands || !commands.length) {
return base;
}
let result: AppendableCompoundCommand<K>;
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 = <K>(
workingCopyManager: WorkingCopyManager<K>,
modelId: K
): object | undefined => {
if (workingCopyManager.isOpen([modelId])) {
return workingCopyManager.getWorkingCopy(modelId);
}
return workingCopyManager.getModel(modelId);
};
const bindGetModel = <K>(
workingCopyManager: WorkingCopyManager<K>,
modelIds: K[]
): ((modelId: K) => object | undefined) => {
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 = <F extends (...args: unknown[]) => void>(
callback: F,
...args: Parameters<F>
): void => {
try {
callback(...args);
} catch (error) {
logger.error('Uncaught exception in CoreCommandStack call-back.', error);
}
};
const can = <Op extends UndoRedoOp>(op: Op): Can<Op> => {
return (op === 'redo' ? 'canRe