Skip to content

Commit

Permalink
Properly track where changes should be applied when a sequence of cha…
Browse files Browse the repository at this point in the history
…nges is broken up by an untracked change
  • Loading branch information
acurrieclark committed Mar 12, 2024
1 parent 280869e commit 7937322
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 13 deletions.
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,65 @@
# Automerge Repo Undo Redo
# Automerge Repo Undo Redo (very much WIP)

This is a simple wrapper around an Automerge Repo `DocHandle` which adds undo and redo functionality.

It allows you to make specific changes which you wish to be able to undo and redo, while any external changes (eg. changes from connected peers) will be untouched.

## Usage
```ts

const handle = repo.create(
age: 34;
name: "Jeremy"
})
const undoRedo = new AutomergeRepoUndoRedo(handle)

undoRedo.change((doc) => {
doc.age = 35;
}, "Update Age")

// You can also make changes directly to your handle at any time
// which will not be tracked and won't form part of the undo/redo tree.
handle.change(doc => {
next.updateText(doc, ['name'], "Jeremy Irons")
})

undoRedo.undo(); // doc => { age: 34, name: "Jeremy Irons" }

undoRedo.redo(); // doc.age => 35
```


## Concepts

Undo and redo patches are stored along with the heads they a based on. When an undo or redo is invoked, if there have been no untracked changes to the document, then the undo change is applied to the head of the document. If there is an untracked change, then the change is made at the heads at which the original change occurred. This helps to preserve the untracked change.

## Pitfalls
In the example below, some text is appended to a string by the tracked user. An untracked change is then made to that appended text, before the original change is undone. One might expect the text to revert to the original, but the untracked change remains, tacked unatractively on to the initial string.

```ts
const handle = repo.create({
text: "The jolly farmer enjoyed harvesting his ripe crop."
})
const undoRedo = new AutomergeRepoUndoRedo(handle);
undoRedo.change((doc) => {
next.updateText(
doc,
["text"],
"The jolly farmer enjoyed harvesting his ripe crop with his friends.",
);
});

handle.change((doc) => {
next.updateText(
doc,
["text"],
"The jolly farmer enjoyed harvesting his ripe crop with some friends.",
);
});

undoRedo.undo();
expect(handle.docSync().text).toBe(
"The jolly farmer enjoyed harvesting his ripe crop.",
); // fails with => "The jolly farmer enjoyed harvesting his ripe cropome.",

```
38 changes: 28 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ const equalArrays = (a: any[], b: any[]) =>
export class AutomergeRepoUndoRedo<T> {
#docHandle: DocHandle<T>;

#lastTrackedHeads: string[] = [];

get handle() {
return this.#docHandle;
}
Expand All @@ -48,15 +46,13 @@ export class AutomergeRepoUndoRedo<T> {
options.patchCallback(patches, patchInfo);
}

this.#lastTrackedHeads = next.getHeads(patchInfo.after);

this.#undos.push({
redo: {
heads: next.getHeads(patchInfo.after),
heads: next.getHeads(patchInfo.before),
patches,
},
undo: {
heads: this.#lastTrackedHeads,
heads: next.getHeads(patchInfo.after),
patches: unpatchAll(patchInfo.before, patches),
},
message,
Expand Down Expand Up @@ -86,15 +82,26 @@ export class AutomergeRepoUndoRedo<T> {
const change = this.#undos.pop();
if (change) {
const doc = this.#docHandle.docSync();
if (doc && equalArrays(next.getHeads(doc), this.#lastTrackedHeads)) {
let heads = next.getHeads(doc!);
if (doc && equalArrays(heads, change.undo.heads)) {
this.#docHandle.change((doc) => {
change.undo.patches.forEach((p) => {
patch<T>(doc, p);
});
});

const after = this.#docHandle.docSync();
this.#lastTrackedHeads = next.getHeads(after!);
const afterHeads = next.getHeads(after!);

if (this.#undos.length > 0) {
const nextUndo = this.#undos[this.#undos.length - 1];

if (equalArrays(nextUndo.undo.heads, change.redo.heads)) {
nextUndo.undo.heads = afterHeads;
}
}

change.redo.heads = afterHeads;
} else {
const heads = this.#docHandle.changeAt(change.undo.heads, (doc) => {
change.undo.patches.forEach((p) => {
Expand All @@ -115,15 +122,26 @@ export class AutomergeRepoUndoRedo<T> {
const change = this.#redos.pop();
if (change) {
const doc = this.#docHandle.docSync();
if (doc && equalArrays(next.getHeads(doc), this.#lastTrackedHeads)) {
let heads = next.getHeads(doc!);
if (doc && equalArrays(heads, change.redo.heads)) {
this.#docHandle.change((doc) => {
change.redo.patches.forEach((p) => {
patch<T>(doc, p);
});
});

const after = this.#docHandle.docSync();
this.#lastTrackedHeads = next.getHeads(after!);
const afterHeads = next.getHeads(after!);

if (this.#redos.length > 0) {
const nextRedo = this.#redos[this.#redos.length - 1];

if (equalArrays(nextRedo.redo.heads, change.undo.heads)) {
nextRedo.redo.heads = afterHeads;
}
}

change.undo.heads = afterHeads;
} else {
const heads = this.#docHandle.changeAt(change.redo.heads, (doc) => {
change.redo.patches.forEach((p) => {
Expand Down
46 changes: 44 additions & 2 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,55 @@ describe("basic tests", () => {
next.updateText(
doc,
["text"],
"The silly farmer enjoyed harvesting his ripe crop at the weekend.",
"The elated farmer enjoyed harvesting his ripe crop at the weekend.",
);
});

undoRedo.undo();
expect(next.getHeads(handle.docSync()).length).toBe(2);
expect(handle.docSync().text).toBe(
"The silly farmer enjoyed harvesting his ripe crop.",
"The elated farmer enjoyed harvesting his ripe crop.",
);
});

test("a tracked change can be undone at the head of the document even when another untracked change has been made", () => {
const undoRedo = new AutomergeRepoUndoRedo(handle);
undoRedo.change((doc) => {
next.updateText(
doc,
["text"],
"The jolly farmer enjoyed harvesting his ripe crop at the weekend.",
);
});

handle.change((doc) => {
next.updateText(
doc,
["text"],
"The elated farmer enjoyed harvesting his ripe crop at the weekend.",
);
});

undoRedo.change((doc) => {
next.updateText(
doc,
["text"],
"The elated farmer enjoyed reaping his ripe crop at the weekend.",
);
});

undoRedo.undo();
// this should have been applied to the head of the document, so we have one head
expect(next.getHeads(handle.docSync()).length).toBe(1);
expect(handle.docSync().text).toBe(
"The elated farmer enjoyed harvesting his ripe crop at the weekend.",
);

undoRedo.undo();
// there has been an untracked change here, so the history has been rewritten
expect(next.getHeads(handle.docSync()).length).toBe(2);
expect(handle.docSync().text).toBe(
"The elated farmer enjoyed harvesting his ripe crop.",
);
});

Expand Down

0 comments on commit 7937322

Please sign in to comment.