/* * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ import { type ChangeDesc, ChangeSet, type ChangeSpec, StateEffect, type Transaction, } from '@codemirror/state'; import type EditorStore from '../editor/EditorStore'; import PriorityMutex from '../utils/PriorityMutex'; const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; /** * State effect used to override the dirty changes after a transaction. * * If this state effect is _not_ present in a transaction, * the transaction will be appended to the current dirty changes. * * If this state effect is present, the current dirty changes will be replaced * by the value of this effect. */ const setDirtyChanges = StateEffect.define(); export interface Delta { deltaOffset: number; deltaReplaceLength: number; deltaText: string; } export default class UpdateStateTracker { private _xtextStateId: string | undefined; /** * The changes marked for synchronization to the server if a full or delta text update * is running, `undefined` otherwise. * * Must be `undefined` upon entering the critical section of `mutex`, * may only be changed in the critical section of `mutex`, * and will be set to `undefined` (marking any changes as dirty again) when leaving it. * * Methods named with an `Exclusive` suffix in this class assume that the mutex is held * and may mutate this field. */ private pendingChanges: ChangeSet | undefined; /** * Local changes not yet sychronized to the server and not part of the current update, if any. */ private dirtyChanges: ChangeSet; /** * Locked when we try to modify the state on the server. */ private readonly mutex = new PriorityMutex(WAIT_FOR_UPDATE_TIMEOUT_MS); constructor(private readonly store: EditorStore) { this.dirtyChanges = this.newEmptyChangeSet(); } get xtextStateId(): string | undefined { return this._xtextStateId; } private get hasDirtyChanges(): boolean { return !this.dirtyChanges.empty; } get needsUpdate(): boolean { return this.hasDirtyChanges || this.xtextStateId === undefined; } get lockedForUpdate(): boolean { return this.mutex.locked; } get hasPendingChanges(): boolean { return this.lockedForUpdate || this.needsUpdate; } hasChangesSince(xtextStateId: string): boolean { return this.xtextStateId !== xtextStateId || this.hasPendingChanges; } /** * Extends the current set of changes with `transaction`. * * Also determines if the transaction has made local changes * that will have to be synchronized to the server * * @param transaction the transaction that affected the editor * @returns `true` if the transaction requires and idle update, `false` otherwise */ onTransaction(transaction: Transaction): boolean { const setDirtyChangesEffect = transaction.effects.find((effect) => effect.is(setDirtyChanges), ) as StateEffect | undefined; if (setDirtyChangesEffect) { const { value } = setDirtyChangesEffect; if (this.pendingChanges !== undefined) { // Do not clear `pendingUpdate`, because that would indicate an update failure // to `withUpdateExclusive`. this.pendingChanges = ChangeSet.empty(value.length); } this.dirtyChanges = value; return false; } if (transaction.docChanged) { this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); return true; } return false; } invalidateStateId(): void { this._xtextStateId = undefined; } /** * Computes the summary of any changes happened since the last complete update. * * The result reflects any changes that happened since the `xtextStateId` * version was uploaded to the server. * * @returns the summary of changes since the last update */ computeChangesSinceLastUpdate(): ChangeDesc { return ( this.pendingChanges?.composeDesc(this.dirtyChanges.desc) ?? this.dirtyChanges.desc ); } prepareDeltaUpdateExclusive(): Delta | undefined { this.ensureLocked(); this.markDirtyChangesAsPendingExclusive(); if (this.pendingChanges === undefined || this.pendingChanges.empty) { return undefined; } let minFromA = Number.MAX_SAFE_INTEGER; let maxToA = 0; let minFromB = Number.MAX_SAFE_INTEGER; let maxToB = 0; this.pendingChanges.iterChangedRanges((fromA, toA, fromB, toB) => { minFromA = Math.min(minFromA, fromA); maxToA = Math.max(maxToA, toA); minFromB = Math.min(minFromB, fromB); maxToB = Math.max(maxToB, toB); }); return { deltaOffset: minFromA, deltaReplaceLength: maxToA - minFromA, deltaText: this.store.state.doc.sliceString(minFromB, maxToB), }; } prepareFullTextUpdateExclusive(): void { this.ensureLocked(); this.markDirtyChangesAsPendingExclusive(); } private markDirtyChangesAsPendingExclusive(): void { if (!this.lockedForUpdate) { throw new Error('Cannot update state without locking the mutex'); } if (this.hasDirtyChanges) { this.pendingChanges = this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges; this.dirtyChanges = this.newEmptyChangeSet(); } } private newEmptyChangeSet(): ChangeSet { return ChangeSet.of([], this.store.state.doc.length); } setStateIdExclusive( newStateId: string, remoteChanges?: ChangeSpec | undefined, ): void { this.ensureLocked(); if (remoteChanges !== undefined) { this.applyRemoteChangesExclusive(remoteChanges); } this._xtextStateId = newStateId; this.pendingChanges = undefined; } private applyRemoteChangesExclusive(changeSpec: ChangeSpec): void { const pendingChanges = this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges; const revertChanges = pendingChanges.invert(this.store.state.doc); const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); const redoChanges = pendingChanges.map(applyBefore.desc); const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); this.store.dispatch({ changes: changeSet, // Keep the current set of dirty changes (but update them according the re-formatting) // and to not add the formatting the dirty changes. effects: [setDirtyChanges.of(redoChanges)], }); } private ensureLocked(): void { if (!this.lockedForUpdate) { throw new Error('Cannot update state without locking the mutex'); } } runExclusive( callback: () => Promise, highPriority = false, ): Promise { return this.mutex.runExclusive(async () => { try { return await callback(); } finally { if (this.pendingChanges !== undefined) { this.dirtyChanges = this.pendingChanges.compose(this.dirtyChanges); this.pendingChanges = undefined; } } }, highPriority); } }