Add Clone to SharedTree Revertible (#23044)

#### Description


[13864](https://dev.azure.com/fluidframework/internal/_workitems/edit/13864/)

This PR adds forkable revertible feature to the `Revertible` object of
SharedTree.

- Removed `DisposableRevertible` and replaced by `RevertibleAlpha`.
- Added `clone()` method to the new interface.
- Uses `TreeBranch` (which is subset of `TreeCheckout`) to access data
necessary for revert operation.

---------

Co-authored-by: Noah Encke <78610362+noencke@users.noreply.github.com>
Co-authored-by: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com>
Co-authored-by: Jenn <jennle@microsoft.com>
This commit is contained in:
Ji Kim 2024-11-20 15:45:41 -08:00 коммит произвёл GitHub
Родитель d3bf90ca08
Коммит 5abfa015af
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
11 изменённых файлов: 398 добавлений и 68 удалений

Просмотреть файл

@ -0,0 +1,11 @@
---
"fluid-framework": minor
"@fluidframework/tree": minor
---
---
"section": tree
---
Enables Revertible objects to be cloned using `RevertibleAlpha.clone()`.
Replaced `DisposableRevertible` with `RevertibleAlpha`. The new `RevertibleAlpha` interface extends `Revertible` and includes a `clone(branch: TreeBranch)` method to facilitate cloning a Revertible to a specified target branch. The source branch where the `RevertibleAlpha` was created must share revision logs with the target branch where the `RevertibleAlpha` is being cloned. If this condition is not met, the operation will throw an error.

Просмотреть файл

@ -539,6 +539,14 @@ export interface Revertible {
readonly status: RevertibleStatus; readonly status: RevertibleStatus;
} }
// @alpha @sealed
export interface RevertibleAlpha extends Revertible {
clone: (branch: TreeBranch) => RevertibleAlpha;
}
// @alpha @sealed
export type RevertibleAlphaFactory = (onRevertibleDisposed?: (revertible: RevertibleAlpha) => void) => RevertibleAlpha;
// @public @sealed // @public @sealed
export type RevertibleFactory = (onRevertibleDisposed?: (revertible: Revertible) => void) => Revertible; export type RevertibleFactory = (onRevertibleDisposed?: (revertible: Revertible) => void) => Revertible;
@ -723,8 +731,8 @@ export interface TreeBranch extends IDisposable {
// @alpha @sealed // @alpha @sealed
export interface TreeBranchEvents { export interface TreeBranchEvents {
changed(data: CommitMetadata, getRevertible?: RevertibleFactory): void; changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
commitApplied(data: CommitMetadata, getRevertible?: RevertibleFactory): void; commitApplied(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
schemaChanged(): void; schemaChanged(): void;
} }

Просмотреть файл

@ -206,4 +206,10 @@ export {
AllowedUpdateType, AllowedUpdateType,
} from "./schema-view/index.js"; } from "./schema-view/index.js";
export { type Revertible, RevertibleStatus, type RevertibleFactory } from "./revertible.js"; export {
type Revertible,
RevertibleStatus,
type RevertibleFactory,
type RevertibleAlphaFactory,
type RevertibleAlpha,
} from "./revertible.js";

Просмотреть файл

@ -3,6 +3,8 @@
* Licensed under the MIT License. * Licensed under the MIT License.
*/ */
import type { TreeBranch } from "../simple-tree/index.js";
/** /**
* Allows reversion of a change made to SharedTree. * Allows reversion of a change made to SharedTree.
* *
@ -36,6 +38,23 @@ export interface Revertible {
dispose(): void; dispose(): void;
} }
/**
* A {@link Revertible} with features that are not yet stable.
*
* @sealed @alpha
*/
export interface RevertibleAlpha extends Revertible {
/**
* Clones the {@link Revertible} to a target branch.
*
* @param branch - A target branch to apply the revertible to.
* The target branch must contain the same commit that this revertible is meant to revert, otherwise will throw an error.
* @returns A cloned revertible is independent of the original, meaning disposing of one will not affect the other,
* provided they do not belong to the same branch. Both revertibles can be reverted independently.
*/
clone: (branch: TreeBranch) => RevertibleAlpha;
}
/** /**
* The status of a {@link Revertible}. * The status of a {@link Revertible}.
* *
@ -50,7 +69,7 @@ export enum RevertibleStatus {
/** /**
* Factory for creating a {@link Revertible}. * Factory for creating a {@link Revertible}.
* Will error if invoked outside the scope of the `changed` event that provides it, or if invoked multiple times. * @throws error if invoked outside the scope of the `changed` event that provides it, or if invoked multiple times.
* *
* @param onRevertibleDisposed - A callback that will be invoked when the `Revertible` generated by this factory is disposed. * @param onRevertibleDisposed - A callback that will be invoked when the `Revertible` generated by this factory is disposed.
* This happens when the `Revertible` is disposed manually, or when the `TreeView` that the `Revertible` belongs to is disposed, * This happens when the `Revertible` is disposed manually, or when the `TreeView` that the `Revertible` belongs to is disposed,
@ -62,3 +81,18 @@ export enum RevertibleStatus {
export type RevertibleFactory = ( export type RevertibleFactory = (
onRevertibleDisposed?: (revertible: Revertible) => void, onRevertibleDisposed?: (revertible: Revertible) => void,
) => Revertible; ) => Revertible;
/**
* Factory for creating a {@link RevertibleAlpha}.
* @throws error if invoked outside the scope of the `changed` event that provides it, or if invoked multiple times.
*
* @param onRevertibleDisposed - A callback that will be invoked when the {@link RevertibleAlpha | Revertible} generated by this factory is disposed.
* This happens when the `Revertible` is disposed manually, or when the `TreeView` that the `Revertible` belongs to is disposed,
* whichever happens first.
* This is typically used to clean up any resources associated with the `Revertible` in the host application.
*
* @sealed @alpha
*/
export type RevertibleAlphaFactory = (
onRevertibleDisposed?: (revertible: RevertibleAlpha) => void,
) => RevertibleAlpha;

Просмотреть файл

@ -29,6 +29,8 @@ export {
MapNodeStoredSchema, MapNodeStoredSchema,
LeafNodeStoredSchema, LeafNodeStoredSchema,
type RevertibleFactory, type RevertibleFactory,
type RevertibleAlphaFactory,
type RevertibleAlpha,
} from "./core/index.js"; } from "./core/index.js";
export { type Brand } from "./util/index.js"; export { type Brand } from "./util/index.js";

Просмотреть файл

@ -409,7 +409,7 @@ export class SchematizingSimpleTreeView<
* @remarks Currently, all contexts are also {@link SchematizingSimpleTreeView}s. * @remarks Currently, all contexts are also {@link SchematizingSimpleTreeView}s.
* Other checkout implementations (e.g. not associated with a view) may be supported in the future. * Other checkout implementations (e.g. not associated with a view) may be supported in the future.
*/ */
function getCheckout(context: TreeBranch): TreeCheckout { export function getCheckout(context: TreeBranch): TreeCheckout {
if (context instanceof SchematizingSimpleTreeView) { if (context instanceof SchematizingSimpleTreeView) {
return context.checkout; return context.checkout;
} }

Просмотреть файл

@ -24,7 +24,6 @@ import {
type IEditableForest, type IEditableForest,
type IForestSubscription, type IForestSubscription,
type JsonableTree, type JsonableTree,
type Revertible,
RevertibleStatus, RevertibleStatus,
type RevisionTag, type RevisionTag,
type RevisionTagCodec, type RevisionTagCodec,
@ -37,7 +36,8 @@ import {
rootFieldKey, rootFieldKey,
tagChange, tagChange,
visitDelta, visitDelta,
type RevertibleFactory, type RevertibleAlphaFactory,
type RevertibleAlpha,
} from "../core/index.js"; } from "../core/index.js";
import { import {
type HasListeners, type HasListeners,
@ -80,8 +80,9 @@ import type {
TreeViewConfiguration, TreeViewConfiguration,
UnsafeUnknownSchema, UnsafeUnknownSchema,
ViewableTree, ViewableTree,
TreeBranch,
} from "../simple-tree/index.js"; } from "../simple-tree/index.js";
import { SchematizingSimpleTreeView } from "./schematizingTreeView.js"; import { getCheckout, SchematizingSimpleTreeView } from "./schematizingTreeView.js";
/** /**
* Events for {@link ITreeCheckout}. * Events for {@link ITreeCheckout}.
@ -104,7 +105,7 @@ export interface CheckoutEvents {
* @param getRevertible - a function provided that allows users to get a revertible for the change. If not provided, * @param getRevertible - a function provided that allows users to get a revertible for the change. If not provided,
* this change is not revertible. * this change is not revertible.
*/ */
changed(data: CommitMetadata, getRevertible?: RevertibleFactory): void; changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
} }
/** /**
@ -409,7 +410,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
/** /**
* Set of revertibles maintained for automatic disposal * Set of revertibles maintained for automatic disposal
*/ */
private readonly revertibles = new Set<DisposableRevertible>(); private readonly revertibles = new Set<RevertibleAlpha>();
/** /**
* Each branch's head commit corresponds to a revertible commit. * Each branch's head commit corresponds to a revertible commit.
@ -542,7 +543,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
const getRevertible = hasSchemaChange(change) const getRevertible = hasSchemaChange(change)
? undefined ? undefined
: (onRevertibleDisposed?: (revertible: Revertible) => void) => { : (onRevertibleDisposed?: (revertible: RevertibleAlpha) => void) => {
if (!withinEventContext) { if (!withinEventContext) {
throw new UsageError( throw new UsageError(
"Cannot get a revertible outside of the context of a changed event.", "Cannot get a revertible outside of the context of a changed event.",
@ -553,42 +554,12 @@ export class TreeCheckout implements ITreeCheckoutFork {
"Cannot generate the same revertible more than once. Note that this can happen when multiple changed event listeners are registered.", "Cannot generate the same revertible more than once. Note that this can happen when multiple changed event listeners are registered.",
); );
} }
const revertibleCommits = this.revertibleCommitBranches; const revertible = this.createRevertible(
const revertible: DisposableRevertible = { revision,
get status(): RevertibleStatus { kind,
const revertibleCommit = revertibleCommits.get(revision); this,
return revertibleCommit === undefined onRevertibleDisposed,
? RevertibleStatus.Disposed );
: RevertibleStatus.Valid;
},
revert: (release: boolean = true) => {
if (revertible.status === RevertibleStatus.Disposed) {
throw new UsageError(
"Unable to revert a revertible that has been disposed.",
);
}
const revertMetrics = this.revertRevertible(revision, kind);
this.logger?.sendTelemetryEvent({
eventName: TreeCheckout.revertTelemetryEventName,
...revertMetrics,
});
if (release) {
revertible.dispose();
}
},
dispose: () => {
if (revertible.status === RevertibleStatus.Disposed) {
throw new UsageError(
"Unable to dispose a revertible that has already been disposed.",
);
}
this.disposeRevertible(revertible, revision);
onRevertibleDisposed?.(revertible);
},
};
this.revertibleCommitBranches.set(revision, _branch.fork(commit)); this.revertibleCommitBranches.set(revision, _branch.fork(commit));
this.revertibles.add(revertible); this.revertibles.add(revertible);
return revertible; return revertible;
@ -643,6 +614,76 @@ export class TreeCheckout implements ITreeCheckoutFork {
} }
} }
/**
* Creates a {@link RevertibleAlpha} object that can undo a specific change in the tree's history.
* Revision must exist in the given {@link TreeCheckout}'s branch.
*
* @param revision - The revision tag identifying the change to be made revertible.
* @param kind - The {@link CommitKind} that produced this revertible (e.g., Default, Undo, Redo).
* @param checkout - The {@link TreeCheckout} instance this revertible belongs to.
* @param onRevertibleDisposed - Callback function that will be called when the revertible is disposed.
* @returns - {@link RevertibleAlpha}
*/
private createRevertible(
revision: RevisionTag,
kind: CommitKind,
checkout: TreeCheckout,
onRevertibleDisposed: ((revertible: RevertibleAlpha) => void) | undefined,
): RevertibleAlpha {
const commitBranches = checkout.revertibleCommitBranches;
const revertible: RevertibleAlpha = {
get status(): RevertibleStatus {
const revertibleCommit = commitBranches.get(revision);
return revertibleCommit === undefined
? RevertibleStatus.Disposed
: RevertibleStatus.Valid;
},
revert: (release: boolean = true) => {
if (revertible.status === RevertibleStatus.Disposed) {
throw new UsageError("Unable to revert a revertible that has been disposed.");
}
const revertMetrics = checkout.revertRevertible(revision, kind);
checkout.logger?.sendTelemetryEvent({
eventName: TreeCheckout.revertTelemetryEventName,
...revertMetrics,
});
if (release) {
revertible.dispose();
}
},
clone: (forkedBranch: TreeBranch) => {
if (forkedBranch === undefined) {
return this.createRevertible(revision, kind, checkout, onRevertibleDisposed);
}
// TODO:#23442: When a revertible is cloned for a forked branch, optimize to create a fork of a revertible branch once per revision NOT once per revision per checkout.
const forkedCheckout = getCheckout(forkedBranch);
const revertibleBranch = this.revertibleCommitBranches.get(revision);
assert(
revertibleBranch !== undefined,
"change to revert does not exist on the given forked branch",
);
forkedCheckout.revertibleCommitBranches.set(revision, revertibleBranch.fork());
return this.createRevertible(revision, kind, forkedCheckout, onRevertibleDisposed);
},
dispose: () => {
if (revertible.status === RevertibleStatus.Disposed) {
throw new UsageError(
"Unable to dispose a revertible that has already been disposed.",
);
}
checkout.disposeRevertible(revertible, revision);
onRevertibleDisposed?.(revertible);
},
};
return revertible;
}
// For the new TreeViewAlpha API // For the new TreeViewAlpha API
public viewWith<TRoot extends ImplicitFieldSchema | UnsafeUnknownSchema>( public viewWith<TRoot extends ImplicitFieldSchema | UnsafeUnknownSchema>(
config: TreeViewConfiguration<ReadSchema<TRoot>>, config: TreeViewConfiguration<ReadSchema<TRoot>>,
@ -809,7 +850,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
} }
} }
private disposeRevertible(revertible: DisposableRevertible, revision: RevisionTag): void { private disposeRevertible(revertible: RevertibleAlpha, revision: RevisionTag): void {
this.revertibleCommitBranches.get(revision)?.dispose(); this.revertibleCommitBranches.get(revision)?.dispose();
this.revertibleCommitBranches.delete(revision); this.revertibleCommitBranches.delete(revision);
this.revertibles.delete(revertible); this.revertibles.delete(revertible);
@ -922,7 +963,3 @@ export function runSynchronous(
? view.transaction.abort() ? view.transaction.abort()
: view.transaction.commit(); : view.transaction.commit();
} }
interface DisposableRevertible extends Revertible {
dispose: () => void;
}

Просмотреть файл

@ -6,7 +6,11 @@
import type { IFluidLoadable, IDisposable } from "@fluidframework/core-interfaces"; import type { IFluidLoadable, IDisposable } from "@fluidframework/core-interfaces";
import { UsageError } from "@fluidframework/telemetry-utils/internal"; import { UsageError } from "@fluidframework/telemetry-utils/internal";
import type { CommitMetadata, RevertibleFactory } from "../../core/index.js"; import type {
CommitMetadata,
RevertibleAlphaFactory,
RevertibleFactory,
} from "../../core/index.js";
import type { Listenable } from "../../events/index.js"; import type { Listenable } from "../../events/index.js";
import { import {
@ -629,7 +633,7 @@ export interface TreeBranchEvents {
* @param getRevertible - a function that allows users to get a revertible for the change. If not provided, * @param getRevertible - a function that allows users to get a revertible for the change. If not provided,
* this change is not revertible. * this change is not revertible.
*/ */
changed(data: CommitMetadata, getRevertible?: RevertibleFactory): void; changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
/** /**
* Fired when: * Fired when:
@ -644,7 +648,7 @@ export interface TreeBranchEvents {
* @param getRevertible - a function provided that allows users to get a revertible for the commit that was applied. If not provided, * @param getRevertible - a function provided that allows users to get a revertible for the commit that was applied. If not provided,
* this commit is not revertible. * this commit is not revertible.
*/ */
commitApplied(data: CommitMetadata, getRevertible?: RevertibleFactory): void; commitApplied(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
} }
/** /**

Просмотреть файл

@ -6,13 +6,19 @@
import { import {
type FieldUpPath, type FieldUpPath,
type Revertible, type Revertible,
RevertibleStatus,
type UpPath, type UpPath,
rootFieldKey, rootFieldKey,
} from "../../core/index.js"; } from "../../core/index.js";
import { singleJsonCursor } from "../json/index.js"; import { singleJsonCursor } from "../json/index.js";
import { SharedTreeFactory, type ITreeCheckout } from "../../shared-tree/index.js"; import { SharedTreeFactory, type ITreeCheckout } from "../../shared-tree/index.js";
import { type JsonCompatible, brand } from "../../util/index.js"; import { type JsonCompatible, brand } from "../../util/index.js";
import { createTestUndoRedoStacks, expectJsonTree, moveWithin } from "../utils.js"; import {
createTestUndoRedoStacks,
expectJsonTree,
moveWithin,
TestTreeProviderLite,
} from "../utils.js";
import { insert, jsonSequenceRootSchema, remove } from "../sequenceRootUtils.js"; import { insert, jsonSequenceRootSchema, remove } from "../sequenceRootUtils.js";
import { createIdCompressor } from "@fluidframework/id-compressor/internal"; import { createIdCompressor } from "@fluidframework/id-compressor/internal";
import { import {
@ -21,7 +27,11 @@ import {
MockStorage, MockStorage,
} from "@fluidframework/test-runtime-utils/internal"; } from "@fluidframework/test-runtime-utils/internal";
import assert from "node:assert"; import assert from "node:assert";
import { SchemaFactory, TreeViewConfiguration } from "../../simple-tree/index.js"; import {
asTreeViewAlpha,
SchemaFactory,
TreeViewConfiguration,
} from "../../simple-tree/index.js";
// eslint-disable-next-line import/no-internal-modules // eslint-disable-next-line import/no-internal-modules
import { initialize } from "../../shared-tree/schematizeTree.js"; import { initialize } from "../../shared-tree/schematizeTree.js";
@ -158,6 +168,44 @@ const testCases: {
}, },
]; ];
/**
* Schema definitions for forkable revertible test suites.
* TODO: Should be removed once #24414 is implemented.
*/
function createInitializedView() {
const factory = new SchemaFactory("shared-tree-test");
class ChildNodeSchema extends factory.object("child-item", {
propertyOne: factory.optional(factory.number),
propertyTwo: factory.object("propertyTwo-item", {
itemOne: factory.string,
}),
}) {}
class RootNodeSchema extends factory.object("root-item", {
child: factory.optional(ChildNodeSchema),
}) {}
const provider = new TestTreeProviderLite();
const view = asTreeViewAlpha(
provider.trees[0].viewWith(
new TreeViewConfiguration({
schema: RootNodeSchema,
}),
),
);
view.initialize(
new RootNodeSchema({
child: {
propertyOne: 128,
propertyTwo: {
itemOne: "",
},
},
}),
);
return view;
}
describe("Undo and redo", () => { describe("Undo and redo", () => {
for (const attached of [true, false]) { for (const attached of [true, false]) {
const attachStr = attached ? "attached" : "detached"; const attachStr = attached ? "attached" : "detached";
@ -453,6 +501,178 @@ describe("Undo and redo", () => {
revertible.revert(); revertible.revert();
assert.equal(view.root.foo, 1); assert.equal(view.root.foo, 1);
}); });
// TODO:#24414: Enable forkable revertibles tests to run on attached/detached mode.
it("reverts original & forked revertibles after making change to the original view", () => {
const originalView = createInitializedView();
const { undoStack } = createTestUndoRedoStacks(originalView.events);
assert(originalView.root.child !== undefined);
originalView.root.child.propertyOne = 256; // 128 -> 256
const forkedView = originalView.fork();
const propertyOneUndo = undoStack.pop();
const clonedPropertyOneUndo = propertyOneUndo?.clone(forkedView);
propertyOneUndo?.revert();
assert.equal(originalView.root.child?.propertyOne, 128);
assert.equal(forkedView.root.child?.propertyOne, 256);
assert.equal(propertyOneUndo?.status, RevertibleStatus.Disposed);
assert.equal(clonedPropertyOneUndo?.status, RevertibleStatus.Valid);
clonedPropertyOneUndo?.revert();
assert.equal(forkedView.root.child?.propertyOne, 128);
assert.equal(clonedPropertyOneUndo?.status, RevertibleStatus.Disposed);
});
// TODO:#24414: Enable forkable revertibles tests to run on attached/detached mode.
it("reverts original & forked revertibles after making separate changes to the original & forked view", () => {
const originalView = createInitializedView();
const { undoStack: undoStack1 } = createTestUndoRedoStacks(originalView.events);
assert(originalView.root.child !== undefined);
originalView.root.child.propertyOne = 256; // 128 -> 256
originalView.root.child.propertyTwo.itemOne = "newItem";
const forkedView = originalView.fork();
const { undoStack: undoStack2 } = createTestUndoRedoStacks(forkedView.events);
assert(forkedView.root.child !== undefined);
forkedView.root.child.propertyOne = 512; // 256 -> 512
undoStack2.pop()?.revert();
assert.equal(forkedView.root.child?.propertyOne, 256);
const undoOriginalPropertyTwo = undoStack1.pop();
const clonedUndoOriginalPropertyTwo = undoOriginalPropertyTwo?.clone(forkedView);
const undoOriginalPropertyOne = undoStack1.pop();
const clonedUndoOriginalPropertyOne = undoOriginalPropertyOne?.clone(forkedView);
undoOriginalPropertyOne?.revert();
undoOriginalPropertyTwo?.revert();
assert.equal(originalView.root.child?.propertyOne, 128);
assert.equal(originalView.root.child?.propertyTwo.itemOne, "");
assert.equal(forkedView.root.child?.propertyOne, 256);
assert.equal(forkedView.root.child?.propertyTwo.itemOne, "newItem");
clonedUndoOriginalPropertyOne?.revert();
clonedUndoOriginalPropertyTwo?.revert();
assert.equal(forkedView.root.child?.propertyOne, 128);
assert.equal(forkedView.root.child?.propertyTwo.itemOne, "");
assert.equal(undoOriginalPropertyOne?.status, RevertibleStatus.Disposed);
assert.equal(undoOriginalPropertyTwo?.status, RevertibleStatus.Disposed);
assert.equal(clonedUndoOriginalPropertyOne?.status, RevertibleStatus.Disposed);
assert.equal(clonedUndoOriginalPropertyTwo?.status, RevertibleStatus.Disposed);
});
// TODO:#24414: Enable forkable revertibles tests to run on attached/detached mode.
it("reverts cloned revertible on original view", () => {
const view = createInitializedView();
const { undoStack } = createTestUndoRedoStacks(view.events);
assert(view.root.child !== undefined);
view.root.child.propertyOne = 256; // 128 -> 256
view.root.child.propertyTwo.itemOne = "newItem";
const undoOriginalPropertyTwo = undoStack.pop();
const undoOriginalPropertyOne = undoStack.pop();
const clonedUndoOriginalPropertyTwo = undoOriginalPropertyTwo?.clone(view);
const clonedUndoOriginalPropertyOne = undoOriginalPropertyOne?.clone(view);
clonedUndoOriginalPropertyTwo?.revert();
clonedUndoOriginalPropertyOne?.revert();
assert.equal(view.root.child?.propertyOne, 128);
assert.equal(view.root.child?.propertyTwo.itemOne, "");
assert.equal(undoOriginalPropertyOne?.status, RevertibleStatus.Disposed);
assert.equal(undoOriginalPropertyTwo?.status, RevertibleStatus.Disposed);
assert.equal(clonedUndoOriginalPropertyOne?.status, RevertibleStatus.Disposed);
assert.equal(clonedUndoOriginalPropertyTwo?.status, RevertibleStatus.Disposed);
});
// TODO:#24414: Enable forkable revertibles tests to run on attached/detached mode.
it("reverts cloned revertible prior to original revertible", () => {
const originalView = createInitializedView();
const { undoStack } = createTestUndoRedoStacks(originalView.events);
assert(originalView.root.child !== undefined);
originalView.root.child.propertyOne = 256; // 128 -> 256
originalView.root.child.propertyTwo.itemOne = "newItem";
const forkedView = originalView.fork();
const undoOriginalPropertyTwo = undoStack.pop();
const undoOriginalPropertyOne = undoStack.pop();
const clonedUndoOriginalPropertyTwo = undoOriginalPropertyTwo?.clone(forkedView);
const clonedUndoOriginalPropertyOne = undoOriginalPropertyOne?.clone(forkedView);
clonedUndoOriginalPropertyTwo?.revert();
clonedUndoOriginalPropertyOne?.revert();
assert.equal(originalView.root.child?.propertyOne, 256);
assert.equal(originalView.root.child?.propertyTwo.itemOne, "newItem");
assert.equal(forkedView.root.child?.propertyOne, 128);
assert.equal(forkedView.root.child?.propertyTwo.itemOne, "");
assert.equal(undoOriginalPropertyOne?.status, RevertibleStatus.Valid);
assert.equal(undoOriginalPropertyTwo?.status, RevertibleStatus.Valid);
assert.equal(clonedUndoOriginalPropertyOne?.status, RevertibleStatus.Disposed);
assert.equal(clonedUndoOriginalPropertyTwo?.status, RevertibleStatus.Disposed);
undoOriginalPropertyTwo?.revert();
undoOriginalPropertyOne?.revert();
assert.equal(originalView.root.child?.propertyOne, 128);
assert.equal(originalView.root.child?.propertyTwo.itemOne, "");
assert.equal(undoOriginalPropertyOne?.status, RevertibleStatus.Disposed);
assert.equal(undoOriginalPropertyTwo?.status, RevertibleStatus.Disposed);
});
// TODO:#24414: Enable forkable revertibles tests to run on attached/detached mode.
it("clone revertible fails if trees are different", () => {
const viewA = createInitializedView();
const viewB = createInitializedView();
const { undoStack } = createTestUndoRedoStacks(viewA.events);
assert(viewA.root.child !== undefined);
viewA.root.child.propertyOne = 256; // 128 -> 256
const undoOriginalPropertyOne = undoStack.pop();
assert.throws(() => undoOriginalPropertyOne?.clone(viewB).revert(), "Error: 0x576");
});
// TODO:#24414: Enable forkable revertibles tests to run on attached/detached mode.
it("cloned revertible fails if already applied", () => {
const view = createInitializedView();
const { undoStack } = createTestUndoRedoStacks(view.events);
assert(view.root.child !== undefined);
view.root.child.propertyOne = 256; // 128 -> 256
const undoOriginalPropertyOne = undoStack.pop();
const clonedUndoOriginalPropertyOne = undoOriginalPropertyOne?.clone(view);
undoOriginalPropertyOne?.revert();
assert.equal(view.root.child?.propertyOne, 128);
assert.equal(undoOriginalPropertyOne?.status, RevertibleStatus.Disposed);
assert.equal(clonedUndoOriginalPropertyOne?.status, RevertibleStatus.Disposed);
assert.throws(
() => clonedUndoOriginalPropertyOne?.revert(),
"Error: Unable to revert a revertible that has been disposed.",
);
});
}); });
/** /**

Просмотреть файл

@ -62,7 +62,6 @@ import {
type IEditableForest, type IEditableForest,
type IForestSubscription, type IForestSubscription,
type JsonableTree, type JsonableTree,
type Revertible,
type RevisionInfo, type RevisionInfo,
type RevisionMetadataSource, type RevisionMetadataSource,
type RevisionTag, type RevisionTag,
@ -86,7 +85,8 @@ import {
type TreeStoredSchemaSubscription, type TreeStoredSchemaSubscription,
type ITreeCursorSynchronous, type ITreeCursorSynchronous,
CursorLocationType, CursorLocationType,
type RevertibleFactory, type RevertibleAlpha,
type RevertibleAlphaFactory,
} from "../core/index.js"; } from "../core/index.js";
import type { HasListeners, IEmitter, Listenable } from "../events/index.js"; import type { HasListeners, IEmitter, Listenable } from "../events/index.js";
import { typeboxValidator } from "../external-utilities/index.js"; import { typeboxValidator } from "../external-utilities/index.js";
@ -450,7 +450,7 @@ export function spyOnMethod(
} }
/** /**
* @returns `true` iff the given delta has a visible impact on the document tree. * Determines whether or not the given delta has a visible impact on the document tree.
*/ */
export function isDeltaVisible(delta: DeltaFieldChanges): boolean { export function isDeltaVisible(delta: DeltaFieldChanges): boolean {
for (const mark of delta.local ?? []) { for (const mark of delta.local ?? []) {
@ -1049,14 +1049,14 @@ export function rootFromDeltaFieldMap(
export function createTestUndoRedoStacks( export function createTestUndoRedoStacks(
events: Listenable<TreeBranchEvents | CheckoutEvents>, events: Listenable<TreeBranchEvents | CheckoutEvents>,
): { ): {
undoStack: Revertible[]; undoStack: RevertibleAlpha[];
redoStack: Revertible[]; redoStack: RevertibleAlpha[];
unsubscribe: () => void; unsubscribe: () => void;
} { } {
const undoStack: Revertible[] = []; const undoStack: RevertibleAlpha[] = [];
const redoStack: Revertible[] = []; const redoStack: RevertibleAlpha[] = [];
function onDispose(disposed: Revertible): void { function onDispose(disposed: RevertibleAlpha): void {
const redoIndex = redoStack.indexOf(disposed); const redoIndex = redoStack.indexOf(disposed);
if (redoIndex !== -1) { if (redoIndex !== -1) {
redoStack.splice(redoIndex, 1); redoStack.splice(redoIndex, 1);
@ -1068,7 +1068,7 @@ export function createTestUndoRedoStacks(
} }
} }
function onNewCommit(commit: CommitMetadata, getRevertible?: RevertibleFactory): void { function onNewCommit(commit: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void {
if (getRevertible !== undefined) { if (getRevertible !== undefined) {
const revertible = getRevertible(onDispose); const revertible = getRevertible(onDispose);
if (commit.kind === CommitKind.Undo) { if (commit.kind === CommitKind.Undo) {

Просмотреть файл

@ -892,6 +892,14 @@ export interface Revertible {
readonly status: RevertibleStatus; readonly status: RevertibleStatus;
} }
// @alpha @sealed
export interface RevertibleAlpha extends Revertible {
clone: (branch: TreeBranch) => RevertibleAlpha;
}
// @alpha @sealed
export type RevertibleAlphaFactory = (onRevertibleDisposed?: (revertible: RevertibleAlpha) => void) => RevertibleAlpha;
// @public @sealed // @public @sealed
export type RevertibleFactory = (onRevertibleDisposed?: (revertible: Revertible) => void) => Revertible; export type RevertibleFactory = (onRevertibleDisposed?: (revertible: Revertible) => void) => Revertible;
@ -1098,8 +1106,8 @@ export interface TreeBranch extends IDisposable {
// @alpha @sealed // @alpha @sealed
export interface TreeBranchEvents { export interface TreeBranchEvents {
changed(data: CommitMetadata, getRevertible?: RevertibleFactory): void; changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
commitApplied(data: CommitMetadata, getRevertible?: RevertibleFactory): void; commitApplied(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
schemaChanged(): void; schemaChanged(): void;
} }