Editor/Prism/Ui/HistoryPanel.cs

An Editor UI panel showing the undo/redo history for a Prism document. It builds a list of HistoryRow entries from PrismUndoStack, paints rows with icons and timestamps, and allows clicking, context menu actions (jump, undo, redo, clear, copy dump). It binds to a PrismSession and listens for undo stack changes to rebuild the view.

File AccessNetworking
using Editor.Prism.Core;
using Editor.Prism.Undo;
using Margin = Sandbox.UI.Margin;

namespace Editor.Prism.Ui;

/// <summary>One row of the History panel: a level in the undo stack.</summary>
internal sealed class HistoryRow
{
	/// <summary>The stack level this row jumps to. Zero is the document as opened.</summary>
	public int Level { get; init; }

	/// <summary>The label of the edit.</summary>
	public string Name { get; init; }

	/// <summary>True when this is where the document currently sits.</summary>
	public bool IsCurrent { get; init; }

	/// <summary>True when this row is ahead of the current level, i.e. redoable.</summary>
	public bool IsFuture { get; init; }

	/// <summary>When the edit was committed.</summary>
	public DateTime Time { get; init; }

	/// <summary>How many characters the snapshot pair costs.</summary>
	public int Size { get; init; }

	/// <inheritdoc/>
	public override string ToString() => $"{Level}. {Name}";
}

/// <summary>
/// The History dock: the undo stack, as a list you can click.
/// <para>
/// Prism records snapshots rather than commands, so every level is a complete, valid document and
/// jumping to any of them is exactly as safe as jumping to the one next door. That makes a clickable
/// history honest rather than a trap — which is why it gets a panel instead of two toolbar arrows.
/// </para>
/// </summary>
public sealed class HistoryPanel : Widget
{
	/// <summary>The dock name this panel registers under. Frozen.</summary>
	public const string DockName = "History";

	readonly List<HistoryRow> _rows = new();

	PrismSession _session;
	PrismUndoStack _undo;
	ListView _list;
	PrismEmptyState _empty;
	Label _status;

	bool _rebuildQueued;

	/// <summary>Build the panel. A null session is legal and shows the empty state.</summary>
	public HistoryPanel( PrismSession session ) : base( null )
	{
		Name = "PrismHistory";
		WindowTitle = DockName;

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		_list = new ListView( this )
		{
			ItemSize = new Vector2( -1, PrismPanelChrome.RowHeight ),
			ItemPaint = PaintRow,
			ItemClicked = OnRowClicked,
			ItemActivated = OnRowClicked,
			ItemContextMenu = OnRowContextMenu,
			MultiSelect = false
		};

		Layout.Add( _list, 1 );

		_empty = new PrismEmptyState( this, "history", "Nothing to undo yet",
			"Every edit you make appears here. Click one to jump back to it." );

		Layout.Add( _empty, 1 );

		_status = new Label( string.Empty ) { Color = PrismTheme.TextMuted };
		_status.ContentMargins = new Margin( 8, 2, 8, 4 );
		Layout.Add( _status );

		Bind( session );
	}

	/// <summary>Material icon shown on the dock tab.</summary>
	public string DockIcon => "history";

	/// <summary>The session this panel is bound to. Null is legal.</summary>
	public PrismSession Session => _session;

	// ---------------------------------------------------------------- binding ----

	void Bind( PrismSession session )
	{
		Unbind();

		_session = session;

		if ( _session is not null )
		{
			_session.DocumentReplaced += OnDocumentReplaced;
			Attach( _session.Undo );
		}

		Rebuild();
	}

	void Attach( PrismUndoStack undo )
	{
		if ( ReferenceEquals( _undo, undo ) ) return;

		if ( _undo is not null )
		{
			_undo.Changed -= QueueRebuild;
			_undo.Restored -= QueueRebuild;
		}

		_undo = undo;

		if ( _undo is null ) return;

		_undo.Changed += QueueRebuild;
		_undo.Restored += QueueRebuild;
	}

	void Unbind()
	{
		Attach( null );

		if ( _session is null ) return;

		_session.DocumentReplaced -= OnDocumentReplaced;
		_session = null;
	}

	/// <inheritdoc/>
	public override void OnDestroyed()
	{
		Unbind();
		base.OnDestroyed();
	}

	void OnDocumentReplaced()
	{
		Attach( _session?.Undo );
		QueueRebuild();
	}

	void QueueRebuild()
	{
		if ( _rebuildQueued ) return;

		_rebuildQueued = true;

		MainThread.Queue( () =>
		{
			_rebuildQueued = false;

			if ( !this.IsValid() ) return;

			Rebuild();
		} );
	}

	// ---------------------------------------------------------------- model ----

	void Rebuild()
	{
		_rows.Clear();

		if ( _undo is null )
		{
			_empty.Set( "No document", "Open a graph to see its edit history." );
			_empty.Visible = true;
			_list.Visible = false;
			_status.Text = string.Empty;
			_list.SetItems( _rows );
			return;
		}

		var level = _undo.Level;

		foreach ( var item in _undo.History )
		{
			_rows.Add( new HistoryRow
			{
				Level = item.Level,
				Name = item.Name,
				IsCurrent = item.IsCurrent,
				IsFuture = item.Level > level,
				Time = item.Time,
				Size = item.Size
			} );
		}

		var meaningful = _undo.Count > 0;

		_empty.Visible = !meaningful;
		_list.Visible = meaningful;

		if ( !meaningful )
		{
			_empty.Set( "Nothing to undo yet", "Every edit you make appears here. Click one to jump back to it." );
		}

		_list.SetItems( _rows );

		var current = _rows.FirstOrDefault( x => x.IsCurrent );

		if ( current is not null )
		{
			_list.SelectItem( current, false, true );
			PrismLog.Guard( "History: scroll to current", () => _list.ScrollTo( current ) );
		}

		_status.Text = _undo.Describe();
	}

	// ---------------------------------------------------------------- painting ----

	void PaintRow( VirtualWidget item )
	{
		if ( item.Object is not HistoryRow row ) return;

		var rect = item.Rect;
		var index = _rows.IndexOf( row );

		PrismPanelChrome.PaintRow( rect, index, item.Hovered, row.IsCurrent );

		var alpha = row.IsFuture ? 0.42f : 1f;
		var inner = rect.Shrink( PrismPanelChrome.Pad, 0f, PrismPanelChrome.Pad, 0f );

		var icon = IconFor( row );
		var color = row.IsCurrent ? PrismTheme.Accent : PrismTheme.TextMuted;

		Paint.SetPen( color.WithAlpha( alpha ) );
		Paint.DrawIcon( new Rect( inner.Left, inner.Top, 16f, inner.Height ), icon, 13f, TextFlag.Center );

		var right = inner.Right;

		if ( inner.Width > 170f )
		{
			var time = row.Time == default ? string.Empty : row.Time.ToLocalTime().ToString( "HH:mm:ss" );

			if ( !string.IsNullOrEmpty( time ) )
			{
				Paint.SetFont( PrismTheme.FontFamily, 10, 400, false, true );
				Paint.SetPen( PrismTheme.TextDisabled.WithAlpha( alpha ) );
				Paint.DrawText( new Rect( right - 52f, inner.Top, 52f, inner.Height ), time,
					TextFlag.RightCenter | TextFlag.SingleLine );

				right -= 58f;
			}
		}

		if ( row.IsCurrent && inner.Width > 220f )
		{
			Paint.SetFont( PrismTheme.FontFamily, 9, 600, false, true );
			Paint.SetPen( PrismTheme.Accent );
			Paint.DrawText( new Rect( right - 44f, inner.Top, 44f, inner.Height ), "CURRENT",
				TextFlag.RightCenter | TextFlag.SingleLine );

			right -= 50f;
		}

		var nameRect = new Rect( inner.Left + 20f, inner.Top,
			MathF.Max( 20f, right - inner.Left - 20f ), inner.Height );

		PrismPaint.Text( nameRect, row.Name,
			( row.IsCurrent ? PrismTheme.TextPrimary : PrismTheme.TextSecondary ).WithAlpha( alpha ),
			PrismTheme.BodySize, row.IsCurrent ? 500 : 400 );
	}

	/// <summary>
	/// The glyph for an edit, matched on its label. Undo entries are labelled by the mutation API from
	/// a small, stable vocabulary, so a prefix match is reliable and one unknown label degrades to a
	/// generic pencil rather than a blank row.
	/// </summary>
	static string IconFor( HistoryRow row )
	{
		if ( row.Level == 0 ) return PrismIcons.Open;

		var name = row.Name ?? string.Empty;

		if ( name.StartsWith( "Add", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Add;
		if ( name.StartsWith( "Delete", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Delete;
		if ( name.StartsWith( "Move", StringComparison.OrdinalIgnoreCase ) ) return "open_with";
		if ( name.StartsWith( "Resize", StringComparison.OrdinalIgnoreCase ) ) return "aspect_ratio";
		if ( name.StartsWith( "Create Connection", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Connect;
		if ( name.StartsWith( "Disconnect", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Disconnect;
		if ( name.StartsWith( "Reroute", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Reroute;
		if ( name.StartsWith( "Route", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Reroute;
		if ( name.StartsWith( "Paste", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Paste;
		if ( name.StartsWith( "Cut", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Cut;
		if ( name.StartsWith( "Duplicate", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Duplicate;
		if ( name.StartsWith( "Rename", StringComparison.OrdinalIgnoreCase ) ) return "edit";
		if ( name.StartsWith( "Reorder", StringComparison.OrdinalIgnoreCase ) ) return "swap_vert";
		if ( name.StartsWith( "Change Settings", StringComparison.OrdinalIgnoreCase ) ) return "settings";
		if ( name.StartsWith( "Change Preview", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Preview;
		if ( name.StartsWith( "Set", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Parameter;
		if ( name.StartsWith( "Edit", StringComparison.OrdinalIgnoreCase ) ) return "edit";

		return "edit_note";
	}

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.Panel );
		Paint.DrawRect( LocalRect );
	}

	// ---------------------------------------------------------------- interaction ----

	void OnRowClicked( object item )
	{
		if ( item is not HistoryRow row || _undo is null ) return;
		if ( row.IsCurrent ) return;

		if ( !_undo.JumpTo( row.Level ) ) return;

		_session?.MarkDirty();
		_session?.Touch();
	}

	void OnRowContextMenu( object item )
	{
		var menu = new Menu( this );

		if ( item is HistoryRow row && !row.IsCurrent )
		{
			menu.AddOption( $"Jump To “{row.Name}”", "history", () => OnRowClicked( row ) );
			menu.AddSeparator();
		}

		menu.AddOption( "Undo", PrismIcons.Undo, () => { _undo?.Undo(); _session?.Touch(); } )
			.Enabled = _undo is { CanUndo: true };

		menu.AddOption( "Redo", PrismIcons.Redo, () => { _undo?.Redo(); _session?.Touch(); } )
			.Enabled = _undo is { CanRedo: true };

		menu.AddSeparator();
		menu.AddOption( "Clear History", PrismIcons.Delete, () => { _undo?.Clear(); Rebuild(); } );
		menu.AddOption( "Copy Stack Dump", PrismIcons.Copy,
			() => EditorUtility.Clipboard.Copy( _undo?.Dump() ?? string.Empty ) );

		menu.OpenAtCursor( false );
	}
}