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