mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-30 21:11:06 -04:00
feat(data): add a state-based undo history
The desktop app has no undo. Phase 0 showed whole-nest snapshots are cheap enough to record after every edit, so the history keeps whole states with a cursor instead of per-command inverses that each need hand-written reversal code. It drops the redo tail on commit, evicts the oldest states past 50 steps or 128 MB without ever evicting the current one, tracks a save point for a later dirty indicator, and merges matching commits within a second so repeated nudges undo as one. The clock is a TimeProvider so tests control the coalescing window. It is generic and free of IO and WinForms so it is unit tested on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11b04cc272
commit
81ec6cc738
2 files changed
+384
No files matched your search
@@ -0,0 +1,170 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>A saved document state the history can hold; its size counts against the byte budget.</summary>
|
||||
public interface IUndoState
|
||||
{
|
||||
long SizeInBytes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="MaxDepth"/> undo steps or <see cref="MaxBytes"/>,
|
||||
/// never the current one.
|
||||
/// </summary>
|
||||
public sealed class UndoHistory<TState> 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<Entry> 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;
|
||||
}
|
||||
|
||||
/// <summary>Raised after every change to the states, the cursor or the save point.</summary>
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public int MaxDepth { get; }
|
||||
public long MaxBytes { get; }
|
||||
public TimeSpan CoalesceWindow { get; }
|
||||
|
||||
/// <summary>The state the live document should match; null before <see cref="Reset"/>.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>The label of the operation Undo would reverse, or null.</summary>
|
||||
public string? UndoLabel => CanUndo ? entries[cursor].Label : null;
|
||||
|
||||
/// <summary>The label of the operation Redo would repeat, or null.</summary>
|
||||
public string? RedoLabel => CanRedo ? entries[cursor + 1].Label : null;
|
||||
|
||||
public long TotalBytes => totalBytes;
|
||||
|
||||
/// <summary>True while the current state is the one last marked saved.</summary>
|
||||
public bool IsAtSavePoint => entries.Count > 0 && savePoint == cursor;
|
||||
|
||||
/// <summary>Starts over from <paramref name="initial"/>, which is treated as saved.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records <paramref name="state"/> as the result of <paramref name="label"/>. With a
|
||||
/// <paramref name="coalesceKey"/> matching the last commit's within
|
||||
/// <see cref="CoalesceWindow"/>, replaces that entry instead, so repeated small edits
|
||||
/// (nudges of one selection) undo as one.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Steps back one operation and returns the state to restore.</summary>
|
||||
public TState Undo()
|
||||
{
|
||||
if (!CanUndo) throw new InvalidOperationException("Nothing to undo.");
|
||||
cursor--;
|
||||
canCoalesce = false;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return entries[cursor].State;
|
||||
}
|
||||
|
||||
/// <summary>Repeats the next undone operation and returns the state to restore.</summary>
|
||||
public TState Redo()
|
||||
{
|
||||
if (!CanRedo) throw new InvalidOperationException("Nothing to redo.");
|
||||
cursor++;
|
||||
canCoalesce = false;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return entries[cursor].State;
|
||||
}
|
||||
|
||||
/// <summary>Marks the current state as the one on disk or on the server.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() => history.Undo());
|
||||
|
||||
Assert.Same(s1, history.Redo());
|
||||
Assert.Same(s2, history.Redo());
|
||||
Assert.False(history.CanRedo);
|
||||
Assert.Throws<InvalidOperationException>(() => 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<TestState>();
|
||||
|
||||
Assert.Null(history.Current);
|
||||
Assert.False(history.CanUndo);
|
||||
Assert.Throws<InvalidOperationException>(() => history.Commit("A", State("a")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Depth_EvictsTheOldestStates()
|
||||
{
|
||||
var history = new UndoHistory<TestState>(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<string>();
|
||||
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<TestState>(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<TestState>(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<TestState>(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<TestState>(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<TestState>();
|
||||
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<TestState> Started(out TestState initial)
|
||||
{
|
||||
var history = new UndoHistory<TestState>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user