An undo stack for the Prism graph editor. It records full serialized snapshots (before and after) per edit, supports bracketed scopes, coalescing rapid edits, undo/redo navigation, trimming to capacity, and applying snapshots via the PrismSerializer restore path.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using System.Text;
namespace Editor.Prism.Undo;
/// <summary>
/// The document's undo history.
/// <para>
/// <b>Snapshots, not commands.</b> Every entry holds the complete serialized document before and
/// after the edit. A command stack is smaller and, in a graph editor, wrong: every command needs an
/// exact inverse, and the moment one of them is subtly asymmetric — a node whose ports rebuilt, a
/// broken edge that got repaired, a parameter rename that touched three nodes — the document drifts
/// away from what the user sees, silently, and only surfaces after a save. A snapshot cannot drift.
/// The document is already required to serialize deterministically and to round-trip losslessly, so a
/// snapshot is exactly as correct as a save, and it is validated by the same code path.
/// </para>
/// <para>
/// The cost is memory and serialization time. Both are bounded: <see cref="Capacity"/> caps the
/// number of retained entries, identical snapshots are discarded rather than recorded, and rapid
/// edits with the same label inside <see cref="CoalesceMs"/> extend the previous entry instead of
/// creating a new one — so dragging a slider produces one step, not four hundred.
/// </para>
/// <para>
/// Restoring goes through <see cref="PrismSerializer.Restore"/>, which reconciles the live document
/// against the snapshot rather than rebuilding it. Nodes whose ids survive keep their object
/// identity, so the editor can diff instead of tearing down and re-creating every card.
/// </para>
/// </summary>
public sealed class PrismUndoStack
{
readonly List<UndoEntry> _entries = new();
string _pendingBefore;
string _pendingName;
bool _coalesceBlocked;
int _depth;
int _level;
/// <summary>Build an undo stack over a document.</summary>
public PrismUndoStack( PrismGraph graph )
{
Graph = graph;
}
/// <summary>The document this stack records.</summary>
public PrismGraph Graph { get; }
/// <summary>How many entries to retain. Older entries are dropped from the bottom.</summary>
public int Capacity { get; set; } = 200;
/// <summary>
/// How long after an edit an identically-labelled edit still merges into it. Zero disables
/// coalescing entirely.
/// </summary>
public int CoalesceMs { get; set; } = PrismConstants.UndoCoalesceMs;
/// <summary>Set false to stop recording, e.g. while a document is being loaded.</summary>
public bool Enabled { get; set; } = true;
/// <summary>True while a snapshot is being applied. Mutations during a restore are never recorded.</summary>
public bool IsRestoring { get; private set; }
/// <summary>True while an edit is bracketed but not yet committed.</summary>
public bool IsCapturing => _depth > 0;
/// <summary>Current position in the stack. Zero is the state the document was opened in.</summary>
public int Level => _level;
/// <summary>How many entries the stack holds.</summary>
public int Count => _entries.Count;
/// <summary>Total characters retained, so the memory cost is inspectable from the status bar.</summary>
public int SizeInChars => _entries.Sum( x => x.Size );
/// <summary>True when there is something to undo.</summary>
public bool CanUndo => _level > 0 && !IsCapturing && !IsRestoring;
/// <summary>True when there is something to redo.</summary>
public bool CanRedo => _level < _entries.Count && !IsCapturing && !IsRestoring;
/// <summary>The label of the edit undo would reverse, or null.</summary>
public string UndoName => CanUndo ? _entries[_level - 1].Name : null;
/// <summary>The label of the edit redo would reapply, or null.</summary>
public string RedoName => CanRedo ? _entries[_level].Name : null;
/// <summary>Every recorded entry, oldest first.</summary>
public IReadOnlyList<UndoEntry> Entries => _entries;
/// <summary>Raised whenever the stack or the current level changed.</summary>
public event Action Changed;
/// <summary>Raised after a snapshot has been applied to the document.</summary>
public event Action Restored;
/// <summary>
/// The History panel's view of the stack: a baseline row for the document as opened, then one row
/// per edit, with the current level marked.
/// </summary>
public IReadOnlyList<UndoHistoryItem> History
{
get
{
var items = new List<UndoHistoryItem>( _entries.Count + 1 )
{
new( 0, "Open", _level == 0, _entries.Count > 0 ? _entries[0].Time : DateTime.UtcNow, 0 )
};
for ( int i = 0; i < _entries.Count; i++ )
{
var entry = _entries[i];
items.Add( new UndoHistoryItem( i + 1, entry.Name, _level == i + 1, entry.Time, entry.Size ) );
}
return items;
}
}
// ---------------------------------------------------------------- recording ----
/// <summary>
/// Open a bracketed edit. Dispose the returned scope to commit it. Nested scopes merge into the
/// outermost one, so a compound operation is a single step.
/// </summary>
public UndoScope Scope( string name )
{
var depth = _depth;
Push( name );
return new UndoScope( this, name, depth );
}
/// <summary>
/// Begin an edit without a scope object. Provided for the node-graph framework, whose
/// <c>PushUndo</c> / <c>PushRedo</c> pair is strictly ordered and cannot hand us a disposable.
/// Always pair it with <see cref="Commit"/>.
/// </summary>
public void Push( string name )
{
if ( _depth == 0 )
{
_pendingName = string.IsNullOrWhiteSpace( name ) ? "Edit" : name;
// The depth counter is maintained even when recording is off, so a Push/Commit pair stays
// balanced through a load or a restore and never poisons the next real edit.
_pendingBefore = !Enabled || IsRestoring || Graph is null ? null : Snapshot();
}
_depth++;
}
/// <summary>Close an edit opened with <see cref="Push"/>.</summary>
public void Commit()
{
if ( _depth == 0 )
{
// The balance assert. The node-graph framework's own stack asserts here too; ours reports
// and carries on, because losing a step is better than taking the editor down.
PrismLog.Warn( "PrismUndoStack: Commit without a matching Push. The edit was not recorded." );
return;
}
_depth--;
if ( _depth > 0 ) return;
var before = _pendingBefore;
var name = _pendingName;
_pendingBefore = null;
_pendingName = null;
if ( before is null ) return;
var after = Snapshot();
if ( after is null || string.Equals( before, after, StringComparison.Ordinal ) )
{
// Nothing actually changed. Recording it would put a no-op step in the user's history.
return;
}
Record( name, before, after );
}
/// <summary>Discard a bracketed edit without recording it. The document is left as it is.</summary>
public void CancelPending()
{
if ( _depth == 0 ) return;
_depth--;
if ( _depth > 0 ) return;
_pendingBefore = null;
_pendingName = null;
}
/// <summary>
/// Record an edit that was already performed, given the document text from before it. Use this
/// when the "before" state was captured outside a scope.
/// </summary>
public void Record( string name, string before, string after = null )
{
if ( !Enabled || Graph is null || before is null ) return;
after ??= Snapshot();
if ( after is null || string.Equals( before, after, StringComparison.Ordinal ) ) return;
name = string.IsNullOrWhiteSpace( name ) ? "Edit" : name;
// Anything ahead of the current level is a redo branch the user just abandoned.
if ( _level < _entries.Count )
{
_entries.RemoveRange( _level, _entries.Count - _level );
_coalesceBlocked = true;
}
var now = DateTime.UtcNow;
if ( ShouldCoalesce( name, now ) )
{
var last = _entries[^1];
last.After = after;
last.Time = now;
Raise();
return;
}
_entries.Add( new UndoEntry { Name = name, Before = before, After = after, Time = now } );
_level = _entries.Count;
_coalesceBlocked = false;
Trim();
Raise();
}
// ---------------------------------------------------------------- navigation ----
/// <summary>Step back one entry.</summary>
public bool Undo() => CanUndo && JumpTo( _level - 1 );
/// <summary>Step forward one entry.</summary>
public bool Redo() => CanRedo && JumpTo( _level + 1 );
/// <summary>
/// Jump straight to a level, as the History panel does. Level zero is the document as opened;
/// level N is the document after the Nth edit.
/// </summary>
public bool JumpTo( int level )
{
if ( Graph is null || IsCapturing || IsRestoring ) return false;
if ( _entries.Count == 0 ) return false;
level = Math.Clamp( level, 0, _entries.Count );
if ( level == _level ) return false;
var text = level == 0 ? _entries[0].Before : _entries[level - 1].After;
if ( text is null ) return false;
if ( !Apply( text ) ) return false;
_level = level;
_coalesceBlocked = true;
Raise();
return true;
}
/// <summary>Drop every entry and re-baseline on the document as it is now.</summary>
public void Clear()
{
_entries.Clear();
_level = 0;
_depth = 0;
_pendingBefore = null;
_pendingName = null;
Raise();
}
/// <summary>Alias for <see cref="Clear"/>, for callers that read better as "start over".</summary>
public void Reset() => Clear();
/// <summary>A one-line summary for the status bar.</summary>
public string Describe() =>
$"{_level}/{_entries.Count} steps, {SizeInChars / 1024} KB" +
( IsCapturing ? $", capturing '{_pendingName}'" : string.Empty );
/// <summary>A multi-line dump of the stack, for bug reports.</summary>
public string Dump()
{
var sb = new StringBuilder();
sb.AppendLine( $"Undo stack: {Describe()}" );
foreach ( var item in History )
{
sb.AppendLine( $" {item} — {item.Time.ToLocalTime():HH:mm:ss} ({item.Size} chars)" );
}
return sb.ToString();
}
// ---------------------------------------------------------------- internals ----
internal void EndScope( UndoScope scope )
{
if ( scope is null ) return;
if ( scope.Depth != _depth - 1 )
{
PrismLog.Warn( $"PrismUndoStack: '{scope.Name}' closed at depth {_depth} but was opened at " +
$"{scope.Depth}. The stack was repaired; an undo scope somewhere is unbalanced." );
_depth = scope.Depth + 1;
}
Commit();
}
string Snapshot() => PrismLog.Guard<string>( "Undo snapshot", () => PrismSerializer.Write( Graph ) );
bool Apply( string text )
{
IsRestoring = true;
try
{
var sink = new DiagnosticSink();
var ok = PrismSerializer.Restore( Graph, text, sink );
foreach ( var diagnostic in sink.All )
{
if ( diagnostic.Severity == DiagnosticSeverity.Error ) PrismLog.Error( diagnostic.ToString() );
else PrismLog.Trace( diagnostic.ToString() );
}
if ( ok ) Graph.IsDirty = true;
return ok;
}
catch ( Exception e )
{
PrismLog.Error( e, "Undo restore failed" );
return false;
}
finally
{
IsRestoring = false;
PrismLog.Guard( "Undo restored", () => Restored?.Invoke() );
}
}
bool ShouldCoalesce( string name, DateTime now )
{
if ( CoalesceMs <= 0 ) return false;
if ( _coalesceBlocked ) return false;
if ( _entries.Count == 0 ) return false;
if ( _level != _entries.Count ) return false;
var last = _entries[^1];
if ( !string.Equals( last.Name, name, StringComparison.Ordinal ) ) return false;
return ( now - last.Time ).TotalMilliseconds <= CoalesceMs;
}
void Trim()
{
if ( Capacity <= 0 ) return;
while ( _entries.Count > Capacity )
{
_entries.RemoveAt( 0 );
if ( _level > 0 ) _level--;
}
}
void Raise() => PrismLog.Guard( "Undo stack changed", () => Changed?.Invoke() );
}