Editor/Prism/Undo/PrismUndoStack.cs

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.

File Access
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() );
}