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; + } +}