@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
262 lines (229 loc) • 7.93 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 { cloneDeep } from 'lodash';
import {
ChangeSubscription,
Command,
CoreCommandStack,
CoreModelManager,
groupByModelId,
} from '../core';
import {
CoreCommandStackImpl,
WorkingCopyManager,
} from './core-command-stack-impl';
export class CoreModelManagerImpl<K> implements CoreModelManager<K> {
/** Model storage. */
private readonly _modelStore: ModelStore<K>;
/** A pointer to the commandStack for editing the models. */
private readonly _commandStack: CoreCommandStack<K>;
private _subscriptions = new Map<K, ChangeSubscription<K>[]>();
private _allSubscriptions = new Array<ChangeSubscription<K>>();
constructor(
commandStackFactory: (
workingCopyManager: WorkingCopyManager<K>
) => CoreCommandStack<K> = (workingCopyManager) =>
new CoreCommandStackImpl(workingCopyManager)
) {
this._modelStore = new ModelStore<K>(this.changeOnCommandStack.bind(this));
this._commandStack = commandStackFactory(this._modelStore);
}
/**
* The callback provided to the CoreCommandStackImpl to be able to notify
* subscribers of any command executed (execute, executeAndAppend, undo, redo )
*
* @param deltas the command result sent by 'execute, executeAndAppend, undo or redo' done in CoreCommandStackImpl
*/
private changeOnCommandStack(deltas: Map<Command<K>, Operation[]>) {
groupByModelId(deltas).forEach((operations, modelId) => {
const model = this.getModel(modelId);
if (model === undefined) {
return;
}
for (const subscriber of this.subscriptions(modelId)) {
if (subscriber.onModelChanged) {
subscriber.onModelChanged(modelId, model, operations);
}
}
});
}
getModelId(model: object): K | undefined {
return this._modelStore.getModelId(model);
}
getModel<M extends object = object>(modelId: K): M | undefined {
return this._modelStore.getModel(modelId);
}
setModel(modelId: K, model: object): void {
this._modelStore.addModel(modelId, model);
}
getModelIds(): K[] {
return this._modelStore.getModelIds();
}
removeModel<M extends object = object>(modelId: K): M | undefined {
return this._modelStore.removeModel(modelId);
}
getCommandStack(): CoreCommandStack<K> {
return this._commandStack;
}
private addSubscription(modelId: K, subscription: ChangeSubscription<K>) {
const existingSubscriptions = this._subscriptions.get(modelId) || [];
existingSubscriptions.push(subscription);
this._subscriptions.set(modelId, existingSubscriptions);
}
private addAllSubscription(subscription: ChangeSubscription<K>) {
this._allSubscriptions.push(subscription);
}
private deleteSubscription(modelId: K, subscription: ChangeSubscription<K>) {
const existingSubscriptions = this._subscriptions.get(modelId) || [];
const index = existingSubscriptions.indexOf(subscription);
if (index > -1) {
existingSubscriptions.splice(index, 1);
if (existingSubscriptions.length > 0) {
this._subscriptions.set(modelId, existingSubscriptions);
} else {
this._subscriptions.delete(modelId);
}
}
}
private deleteAllSubscription(subscription: ChangeSubscription<K>) {
const index = this._allSubscriptions.indexOf(subscription);
if (index > -1) {
this._allSubscriptions.splice(index, 1);
}
}
subscribe<M extends object = object>(modelId?: K): ChangeSubscription<K, M> {
let subscription: ChangeSubscription<K, M>;
if (modelId) {
subscription = {
close: () => this.deleteSubscription(modelId, subscription),
};
this.addSubscription(modelId, subscription);
} else {
subscription = {
close: () => this.deleteAllSubscription(subscription),
};
this.addAllSubscription(subscription);
}
return subscription;
}
/**
* Obtain an iterable over all subscriptions pertaining to the given
* model ID.
*
* @param modelId a model ID for which to get subscriptions
* @returns an iterator over all subscriptions specific to the `modelId` (if any)
* followed by the general subscriptions
*/
protected *subscriptions<M extends object = object>(
modelId: K
): Iterable<ChangeSubscription<K, M>> {
const specific = this._subscriptions.get(modelId);
if (specific) {
for (const next of specific) {
yield next;
}
}
for (const next of this._allSubscriptions) {
yield next;
}
}
}
class ModelStore<K = string> implements WorkingCopyManager<K> {
/** Model storage. */
private readonly _models = new Map<K, object>();
/** Last known ID for each model. */
private readonly _modelIds = new WeakMap<object, K>();
private _workingCopies = new Map<K, object>();
private _open = new Map<K, boolean>();
constructor(
private readonly notify: (result: Map<Command<K>, Operation[]>) => void
) {}
getModel<M extends object = object>(modelId: K): M | undefined {
return this._models.get(modelId) as M | undefined;
}
getModelId(model: object): K | undefined {
return this._modelIds.get(model);
}
getModelIds(): K[] {
return Array.from(this._models.keys());
}
addModel(modelId: K, model: object): void {
if (this._models.get(modelId) !== undefined) {
throw new Error(`Model ${modelId} is already registered.`);
}
this._models.set(modelId, model);
this._modelIds.set(model, modelId);
}
removeModel<M extends object = object>(modelId: K): M | undefined {
const removedModel = this.getModel<M>(modelId);
if (removedModel !== undefined) {
this._models.delete(modelId);
// Let the generation info expire naturally with GC
}
return removedModel;
}
//
// WorkingCopyManager protocol
//
open(modelIds: K[]): void {
if (this.isOpen(modelIds)) {
throw new Error('Already open.');
}
modelIds.forEach((modelId) => {
this._open.set(modelId, true);
this._workingCopies.delete(modelId);
});
}
isOpen(modelIds: K[]): boolean {
return modelIds.some((modelId) => this._open.get(modelId));
}
getWorkingCopy(modelId: K): object | undefined {
if (!this.isOpen([modelId])) {
throw new Error('Not open.');
}
let result = this._workingCopies.get(modelId);
if (result === undefined) {
const model = this.getModel(modelId);
if (model !== undefined) {
result = cloneDeep(model);
this._workingCopies.set(modelId, result);
}
}
return result;
}
commit(result: Map<Command<K>, Operation[]>, modelIds: K[]): void {
try {
for (const modelId of modelIds) {
const workingCopy = this._workingCopies.get(modelId);
if (workingCopy !== undefined) {
this._models.set(modelId, workingCopy);
}
this._workingCopies.delete(modelId);
}
this.notify(result);
} finally {
for (const k of modelIds) {
this._open.delete(k);
}
}
}
cancel(modelIds: K[]): void {
modelIds.forEach((modelId) => {
this._workingCopies.delete(modelId);
this._open.delete(modelId);
});
}
}