diff --git a/OpenNest.Data/UndoHistory.cs b/OpenNest.Data/UndoHistory.cs
new file mode 100644
index 00000000..0b1b98a0
--- /dev/null
+++ b/OpenNest.Data/UndoHistory.cs
@@ -0,0 +1,170 @@
+namespace OpenNest.Data;
+
+/// A saved document state the history can hold; its size counts against the byte budget.
+public interface IUndoState
+{
+ long SizeInBytes { get; }
+}
+
+///
+/// A document's undo history as a list of whole states and a cursor at the live one.
+/// Committing records the state after an operation and drops anything that could be
+/// redone; Undo and Redo move the cursor and return the state to restore. The oldest
+/// states are evicted past undo steps or ,
+/// never the current one.
+///
+public sealed class UndoHistory where TState : class, IUndoState
+{
+ public const int DefaultMaxDepth = 50;
+ public const long DefaultMaxBytes = 128L * 1024 * 1024;
+ public static readonly TimeSpan DefaultCoalesceWindow = TimeSpan.FromSeconds(1);
+
+ private readonly List entries = new();
+ private readonly TimeProvider clock;
+ private int cursor;
+ private int savePoint = -1;
+ private long totalBytes;
+ // Only an entry committed last, with nothing undone, redone or saved since, can absorb the next.
+ private bool canCoalesce;
+
+ public UndoHistory(int maxDepth = DefaultMaxDepth, long maxBytes = DefaultMaxBytes,
+ TimeSpan? coalesceWindow = null, TimeProvider? clock = null)
+ {
+ if (maxDepth < 1) throw new ArgumentOutOfRangeException(nameof(maxDepth));
+ if (maxBytes < 1) throw new ArgumentOutOfRangeException(nameof(maxBytes));
+ MaxDepth = maxDepth;
+ MaxBytes = maxBytes;
+ CoalesceWindow = coalesceWindow ?? DefaultCoalesceWindow;
+ this.clock = clock ?? TimeProvider.System;
+ }
+
+ /// Raised after every change to the states, the cursor or the save point.
+ public event EventHandler? Changed;
+
+ public int MaxDepth { get; }
+ public long MaxBytes { get; }
+ public TimeSpan CoalesceWindow { get; }
+
+ /// The state the live document should match; null before .
+ public TState? Current => entries.Count == 0 ? null : entries[cursor].State;
+
+ public bool CanUndo => cursor > 0;
+ public bool CanRedo => cursor + 1 < entries.Count;
+ public int UndoCount => cursor;
+ public int RedoCount => entries.Count == 0 ? 0 : entries.Count - cursor - 1;
+
+ /// The label of the operation Undo would reverse, or null.
+ public string? UndoLabel => CanUndo ? entries[cursor].Label : null;
+
+ /// The label of the operation Redo would repeat, or null.
+ public string? RedoLabel => CanRedo ? entries[cursor + 1].Label : null;
+
+ public long TotalBytes => totalBytes;
+
+ /// True while the current state is the one last marked saved.
+ public bool IsAtSavePoint => entries.Count > 0 && savePoint == cursor;
+
+ /// Starts over from , which is treated as saved.
+ public void Reset(TState initial)
+ {
+ ArgumentNullException.ThrowIfNull(initial);
+ entries.Clear();
+ entries.Add(new Entry(null, initial, null, clock.GetUtcNow()));
+ totalBytes = initial.SizeInBytes;
+ cursor = 0;
+ savePoint = 0;
+ canCoalesce = false;
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Records as the result of . With a
+ /// matching the last commit's within
+ /// , replaces that entry instead, so repeated small edits
+ /// (nudges of one selection) undo as one.
+ ///
+ public void Commit(string label, TState state, object? coalesceKey = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(label);
+ ArgumentNullException.ThrowIfNull(state);
+ if (entries.Count == 0)
+ throw new InvalidOperationException("Reset the history before committing.");
+
+ DropRedo();
+ var now = clock.GetUtcNow();
+ var top = entries[cursor];
+ if (canCoalesce && coalesceKey != null && cursor > 0 && savePoint != cursor
+ && Equals(top.CoalesceKey, coalesceKey) && now - top.CommittedAt <= CoalesceWindow)
+ {
+ totalBytes += state.SizeInBytes - top.State.SizeInBytes;
+ entries[cursor] = top with { State = state, CommittedAt = now };
+ }
+ else
+ {
+ entries.Add(new Entry(label, state, coalesceKey, now));
+ totalBytes += state.SizeInBytes;
+ cursor++;
+ }
+
+ canCoalesce = true;
+ Evict();
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// Steps back one operation and returns the state to restore.
+ public TState Undo()
+ {
+ if (!CanUndo) throw new InvalidOperationException("Nothing to undo.");
+ cursor--;
+ canCoalesce = false;
+ Changed?.Invoke(this, EventArgs.Empty);
+ return entries[cursor].State;
+ }
+
+ /// Repeats the next undone operation and returns the state to restore.
+ public TState Redo()
+ {
+ if (!CanRedo) throw new InvalidOperationException("Nothing to redo.");
+ cursor++;
+ canCoalesce = false;
+ Changed?.Invoke(this, EventArgs.Empty);
+ return entries[cursor].State;
+ }
+
+ /// Marks the current state as the one on disk or on the server.
+ public void MarkSaved()
+ {
+ if (entries.Count == 0)
+ throw new InvalidOperationException("Reset the history before marking it saved.");
+ savePoint = cursor;
+ canCoalesce = false;
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void DropRedo()
+ {
+ var first = cursor + 1;
+ if (first >= entries.Count)
+ return;
+ for (var i = first; i < entries.Count; i++)
+ totalBytes -= entries[i].State.SizeInBytes;
+ entries.RemoveRange(first, entries.Count - first);
+ // A saved state that was undone past is no longer reachable.
+ if (savePoint >= first)
+ savePoint = -1;
+ }
+
+ private void Evict()
+ {
+ while (cursor > 0 && (cursor > MaxDepth || totalBytes > MaxBytes))
+ {
+ totalBytes -= entries[0].State.SizeInBytes;
+ entries.RemoveAt(0);
+ cursor--;
+ if (savePoint >= 0)
+ savePoint--;
+ }
+ }
+
+ private sealed record Entry(string? Label, TState State, object? CoalesceKey, DateTimeOffset CommittedAt);
+}
diff --git a/OpenNest.Tests/Data/UndoHistoryTests.cs b/OpenNest.Tests/Data/UndoHistoryTests.cs
new file mode 100644
index 00000000..bdb198a7
--- /dev/null
+++ b/OpenNest.Tests/Data/UndoHistoryTests.cs
@@ -0,0 +1,214 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class UndoHistoryTests
+{
+ [Fact]
+ public void UndoAndRedo_WalkTheCommittedStates_WithTheirLabels()
+ {
+ var history = Started(out var s0);
+ var s1 = State("1");
+ var s2 = State("2");
+ history.Commit("Move", s1);
+ history.Commit("Rotate", s2);
+
+ Assert.Equal("Rotate", history.UndoLabel);
+ Assert.Null(history.RedoLabel);
+ Assert.Same(s1, history.Undo());
+ Assert.Equal("Move", history.UndoLabel);
+ Assert.Equal("Rotate", history.RedoLabel);
+ Assert.Same(s0, history.Undo());
+ Assert.False(history.CanUndo);
+ Assert.Null(history.UndoLabel);
+ Assert.Throws(() => history.Undo());
+
+ Assert.Same(s1, history.Redo());
+ Assert.Same(s2, history.Redo());
+ Assert.False(history.CanRedo);
+ Assert.Throws(() => history.Redo());
+ Assert.Same(s2, history.Current);
+ }
+
+ [Fact]
+ public void Commit_AfterUndo_DropsTheRedoTail()
+ {
+ var history = Started(out _);
+ history.Commit("A", State("a", 10));
+ history.Commit("B", State("b", 20));
+ history.Undo();
+ var c = State("c", 5);
+
+ history.Commit("C", c);
+
+ Assert.False(history.CanRedo);
+ Assert.Equal(2, history.UndoCount);
+ Assert.Same(c, history.Current);
+ Assert.Equal(1 + 10 + 5, history.TotalBytes);
+ }
+
+ [Fact]
+ public void Commit_RequiresReset()
+ {
+ var history = new UndoHistory();
+
+ Assert.Null(history.Current);
+ Assert.False(history.CanUndo);
+ Assert.Throws(() => history.Commit("A", State("a")));
+ }
+
+ [Fact]
+ public void Depth_EvictsTheOldestStates()
+ {
+ var history = new UndoHistory(maxDepth: 3);
+ history.Reset(State("0"));
+ for (var i = 1; i <= 5; i++)
+ history.Commit($"Step {i}", State(i.ToString()));
+
+ Assert.Equal(3, history.UndoCount);
+ var undone = new List();
+ while (history.CanUndo)
+ undone.Add(history.Undo().Name);
+ Assert.Equal(new[] { "4", "3", "2" }, undone);
+ }
+
+ [Fact]
+ public void Bytes_EvictTheOldestStates_ButNeverTheCurrentOne()
+ {
+ var history = new UndoHistory(maxBytes: 100);
+ history.Reset(State("0", 40));
+ history.Commit("A", State("a", 40));
+ history.Commit("B", State("b", 40));
+
+ Assert.Equal(1, history.UndoCount);
+ Assert.Equal(80, history.TotalBytes);
+
+ var huge = State("huge", 500);
+ history.Commit("Huge", huge);
+
+ Assert.False(history.CanUndo);
+ Assert.Same(huge, history.Current);
+ Assert.Equal(500, history.TotalBytes);
+ }
+
+ [Fact]
+ public void SavePoint_FollowsTheCursor_AndIsLostWhenDroppedOrEvicted()
+ {
+ var history = Started(out _);
+ Assert.True(history.IsAtSavePoint);
+ history.Commit("A", State("a"));
+ Assert.False(history.IsAtSavePoint);
+ history.MarkSaved();
+ Assert.True(history.IsAtSavePoint);
+ history.Undo();
+ Assert.False(history.IsAtSavePoint);
+ history.Redo();
+ Assert.True(history.IsAtSavePoint);
+
+ history.Undo();
+ history.Commit("B", State("b")); // Drops the saved "A": no reachable state is saved.
+ Assert.False(history.IsAtSavePoint);
+ history.Undo();
+ Assert.False(history.IsAtSavePoint);
+ history.Redo();
+ Assert.False(history.IsAtSavePoint);
+
+ var shallow = new UndoHistory(maxDepth: 1);
+ shallow.Reset(State("0"));
+ shallow.Commit("A", State("a"));
+ shallow.Commit("B", State("b"));
+ shallow.Undo();
+ Assert.False(shallow.IsAtSavePoint); // The saved reset state was evicted.
+ }
+
+ [Fact]
+ public void Coalescing_MergesMatchingCommitsWithinTheWindow()
+ {
+ var clock = new ManualClock();
+ var history = new UndoHistory(clock: clock);
+ var s0 = State("0");
+ history.Reset(s0);
+ history.Commit("Nudge", State("n1", 3), coalesceKey: "selection");
+ clock.Advance(TimeSpan.FromMilliseconds(600));
+ history.Commit("Nudge", State("n2", 3), coalesceKey: "selection");
+ clock.Advance(TimeSpan.FromMilliseconds(600)); // Sliding: within a second of the last nudge.
+ var n3 = State("n3", 3);
+ history.Commit("Nudge", n3, coalesceKey: "selection");
+
+ Assert.Equal(1, history.UndoCount);
+ Assert.Same(n3, history.Current);
+ Assert.Equal(1 + 3, history.TotalBytes);
+
+ clock.Advance(TimeSpan.FromSeconds(2));
+ history.Commit("Nudge", State("late"), coalesceKey: "selection");
+ history.Commit("Nudge", State("other"), coalesceKey: "other selection");
+ history.Commit("Delete", State("plain"));
+ history.Commit("Delete", State("plain again"));
+
+ Assert.Equal(5, history.UndoCount);
+ history.Undo();
+ history.Undo();
+ history.Undo();
+ history.Undo();
+ Assert.Same(n3, history.Current);
+ Assert.Same(s0, history.Undo());
+ }
+
+ [Fact]
+ public void Coalescing_StopsAtUndoRedoAndSave()
+ {
+ var history = new UndoHistory(clock: new ManualClock());
+ history.Reset(State("0"));
+ history.Commit("Nudge", State("1"), "k");
+ history.MarkSaved();
+ history.Commit("Nudge", State("2"), "k");
+ Assert.Equal(2, history.UndoCount); // The saved state is kept.
+
+ history.Undo();
+ history.Redo();
+ history.Commit("Nudge", State("3"), "k");
+ Assert.Equal(3, history.UndoCount);
+
+ history.Undo();
+ history.Commit("Nudge", State("4"), "k");
+ Assert.Equal(3, history.UndoCount);
+ Assert.Equal("4", history.Current!.Name);
+ }
+
+ [Fact]
+ public void Changed_IsRaisedForEveryChange()
+ {
+ var history = new UndoHistory();
+ var raised = 0;
+ history.Changed += (_, _) => raised++;
+
+ history.Reset(State("0"));
+ history.Commit("A", State("a"));
+ history.Undo();
+ history.Redo();
+ history.MarkSaved();
+
+ Assert.Equal(5, raised);
+ }
+
+ private static UndoHistory Started(out TestState initial)
+ {
+ var history = new UndoHistory();
+ initial = State("0");
+ history.Reset(initial);
+ return history;
+ }
+
+ private static TestState State(string name, long size = 1) => new(name, size);
+
+ private sealed record TestState(string Name, long SizeInBytes) : IUndoState;
+
+ private sealed class ManualClock : TimeProvider
+ {
+ private DateTimeOffset now = new(2026, 10, 26, 8, 0, 0, TimeSpan.Zero);
+
+ public override DateTimeOffset GetUtcNow() => now;
+
+ public void Advance(TimeSpan by) => now += by;
+ }
+}