Editor/Prism/Ui/DiagnosticsPanel.cs

An editor UI panel that lists compiler and graph diagnostics, groups them by severity, filters and mutes severities, allows selecting/framing nodes, copying messages, and jumping to generated code lines via the session compile artifacts and source map.

NetworkingFile Access
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Text;
using Margin = Sandbox.UI.Margin;
using System.Text;

namespace Editor.Prism.Ui;

/// <summary>One row of the diagnostics list: a severity group header or a single diagnostic.</summary>
internal sealed class DiagnosticEntry
{
	/// <summary>The severity this row belongs to.</summary>
	public DiagnosticSeverity Severity { get; init; }

	/// <summary>Header text when this row is a group header.</summary>
	public string Header { get; init; }

	/// <summary>The diagnostic this row presents.</summary>
	public Diagnostic Diagnostic { get; init; }

	/// <summary>Number of diagnostics under a header.</summary>
	public int Count { get; init; }

	/// <summary>Display name of the node this diagnostic points at, when it points at one.</summary>
	public string NodeName { get; init; }

	/// <summary>True when the row is a group header.</summary>
	public bool IsHeader => Diagnostic is null;

	/// <inheritdoc/>
	public override string ToString() => Header ?? Diagnostic?.Message ?? "(row)";
}

/// <summary>
/// The Diagnostics dock: everything wrong with the graph, grouped by severity and clickable.
/// <para>
/// A single click selects and frames the node — and the port — a diagnostic names. A double click
/// jumps to the generated line that produced it, resolved through the compile's
/// <see cref="SourceMap"/>. That two-hop mapping from a compiler line back to a node is the whole
/// reason this editor exists, so it gets the primary gesture rather than a context-menu item.
/// </para>
/// </summary>
public sealed class DiagnosticsPanel : Widget
{
	/// <summary>The dock name this panel registers under. Frozen.</summary>
	public const string DockName = "Diagnostics";

	readonly List<DiagnosticEntry> _entries = new();
	readonly HashSet<DiagnosticSeverity> _muted = new();

	PrismSession _session;
	LineEdit _filter;
	ListView _list;
	PrismEmptyState _empty;
	Button _errorToggle;
	Button _warningToggle;
	Button _infoToggle;
	Label _status;

	bool _rebuildQueued;

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

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

		BuildToolbar();

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

		Layout.Add( _list, 1 );

		_empty = new PrismEmptyState( this, "check_circle", "No problems",
			"The graph compiles cleanly." );

		Layout.Add( _empty, 1 );

		Bind( session );
	}

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

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

	/// <summary>
	/// Where a "go to the generated line" request is sent. The window points this at the Code panel;
	/// when nothing sets it, the panel opens a standalone code window instead so the gesture always
	/// does something.
	/// </summary>
	public Action<Diagnostic> CodeNavigationRequested { get; set; }

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

	void Bind( PrismSession session )
	{
		Unbind();

		_session = session;

		if ( _session is not null )
		{
			_session.Compiled += OnCompiled;
			_session.CompileStarted += OnCompileStarted;
			_session.DocumentReplaced += QueueRebuild;
		}

		Rebuild();
	}

	void Unbind()
	{
		if ( _session is null ) return;

		_session.Compiled -= OnCompiled;
		_session.CompileStarted -= OnCompileStarted;
		_session.DocumentReplaced -= QueueRebuild;
		_session = null;
	}

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

	void OnCompiled( CompileResult result ) => QueueRebuild();

	void OnCompileStarted()
	{
		if ( _status is null ) return;

		_status.Text = "Compiling…";
		_status.Color = PrismTheme.TextMuted;
	}

	void QueueRebuild()
	{
		if ( _rebuildQueued ) return;

		_rebuildQueued = true;

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

			if ( !this.IsValid() ) return;

			Rebuild();
		} );
	}

	// ---------------------------------------------------------------- toolbar ----

	void BuildToolbar()
	{
		var bar = new Widget( this );
		bar.Layout = Layout.Row();
		bar.Layout.Margin = new Margin( 6, 6, 6, 4 );
		bar.Layout.Spacing = 4;

		_errorToggle = CreateToggle( bar, PrismIcons.Error, PrismTheme.Error, DiagnosticSeverity.Error );
		_warningToggle = CreateToggle( bar, PrismIcons.Warning, PrismTheme.Warning, DiagnosticSeverity.Warning );
		_infoToggle = CreateToggle( bar, PrismIcons.Info, PrismTheme.Info, DiagnosticSeverity.Info );

		_filter = PrismPanelChrome.CreateSearchField( bar, "Filter problems" );
		_filter.TextEdited += _ => Rebuild();
		bar.Layout.Add( _filter, 1 );

		var copy = new IconButton( PrismIcons.Copy, CopyAll, bar )
		{
			ToolTip = "Copy every problem to the clipboard",
			FixedWidth = 24f,
			FixedHeight = 24f
		};

		bar.Layout.Add( copy );

		Layout.Add( bar );

		_status = new Label( "Idle" ) { Color = PrismTheme.TextMuted };
		_status.ContentMargins = new Margin( 8, 0, 8, 4 );

		Layout.Add( _status );
	}

	Button CreateToggle( Widget parent, string icon, Color tint, DiagnosticSeverity severity )
	{
		var button = new Button( "0", icon, parent )
		{
			IsToggle = true,
			IsChecked = true,
			Tint = tint.WithAlpha( 0.28f ),
			FixedHeight = 24f,
			ToolTip = $"Show {severity.ToString().ToLowerInvariant()}s"
		};

		button.Toggled = () =>
		{
			if ( button.IsChecked ) _muted.Remove( severity );
			else _muted.Add( severity );

			Rebuild();
		};

		parent.Layout.Add( button );

		return button;
	}

	/// <summary>Move keyboard focus to the filter field.</summary>
	[Shortcut( "prism.diagnostics.search", "CTRL+F" )]
	public void FocusSearch()
	{
		_filter?.Focus();
		_filter?.SelectAll();
	}

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

	void Rebuild()
	{
		_entries.Clear();

		var all = _session?.Diagnostics ?? Array.Empty<Diagnostic>();
		var graph = _session?.Graph;
		var filter = _filter?.Text?.Trim() ?? string.Empty;

		var errors = all.Count( x => x.Severity == DiagnosticSeverity.Error );
		var warnings = all.Count( x => x.Severity == DiagnosticSeverity.Warning );
		var infos = all.Count( x => x.Severity == DiagnosticSeverity.Info );

		if ( _errorToggle is not null ) _errorToggle.Text = errors.ToString();
		if ( _warningToggle is not null ) _warningToggle.Text = warnings.ToString();
		if ( _infoToggle is not null ) _infoToggle.Text = infos.ToString();

		foreach ( var severity in s_order )
		{
			if ( _muted.Contains( severity ) ) continue;

			var matching = all
				.Where( x => x.Severity == severity )
				.Where( x => Matches( x, filter ) )
				.ToList();

			if ( matching.Count == 0 ) continue;

			_entries.Add( new DiagnosticEntry
			{
				Severity = severity,
				Header = Plural( severity, matching.Count ),
				Count = matching.Count
			} );

			foreach ( var diagnostic in matching )
			{
				_entries.Add( new DiagnosticEntry
				{
					Severity = severity,
					Diagnostic = diagnostic,
					NodeName = NameOf( graph, diagnostic )
				} );
			}
		}

		_empty.Visible = _entries.Count == 0;
		_list.Visible = _entries.Count > 0;

		if ( _entries.Count == 0 ) SetEmptyState( all.Count );

		_list.SetItems( _entries );

		UpdateStatus( errors, warnings, infos );
	}

	/// <summary>
	/// Say the right thing when the list is empty, and offer the one action that resolves it.
	/// <para>
	/// "No problems" is a lie before the first compile — the graph has not been checked, it has merely
	/// not been checked <em>yet</em> — and "nothing shown" without a way to unhide is a dead end. The
	/// three states are distinguished so the panel is never quietly reassuring about something it does
	/// not know.
	/// </para>
	/// </summary>
	void SetEmptyState( int total )
	{
		if ( _empty is null ) return;

		if ( _session is null )
		{
			_empty.Set( "No document", "Open a graph to see its problems." );
			SetEmptyAction( null, null );

			return;
		}

		if ( total > 0 )
		{
			_empty.Set( "Nothing shown", "Every problem is filtered out by the toggles above." );
			SetEmptyAction( "Show everything", ShowAllSeverities );

			return;
		}

		if ( _session.LastCompile is null )
		{
			_empty.Set( "Not compiled yet", "Compile the graph to hear what the shader compiler says." );
			SetEmptyAction( "Compile now", () => _session?.RequestCompile( CompileMode.Preview ) );

			return;
		}

		_empty.Set( "No problems", "The graph compiles cleanly." );
		SetEmptyAction( null, null );
	}

	void SetEmptyAction( string text, Action action )
	{
		_empty.ActionText = text;
		_empty.Action = action;
		_empty.Update();
	}

	/// <summary>Un-mute every severity and put the toolbar toggles back in step.</summary>
	public void ShowAllSeverities()
	{
		if ( _muted.Count == 0 ) return;

		_muted.Clear();

		if ( _errorToggle is not null ) _errorToggle.IsChecked = true;
		if ( _warningToggle is not null ) _warningToggle.IsChecked = true;
		if ( _infoToggle is not null ) _infoToggle.IsChecked = true;

		Rebuild();
	}

	void UpdateStatus( int errors, int warnings, int infos )
	{
		if ( _status is null ) return;

		var result = _session?.LastCompile;

		if ( result is null )
		{
			_status.Text = _session is null ? "No document" : "Not compiled yet";
			_status.Color = PrismTheme.TextMuted;
			return;
		}

		var parts = new List<string>();

		if ( errors > 0 ) parts.Add( errors == 1 ? "1 error" : $"{errors} errors" );
		if ( warnings > 0 ) parts.Add( warnings == 1 ? "1 warning" : $"{warnings} warnings" );
		if ( infos > 0 ) parts.Add( infos == 1 ? "1 note" : $"{infos} notes" );

		if ( parts.Count == 0 ) parts.Add( "no problems" );

		var stats = result.Stats;

		_status.Text = stats is null
			? string.Join( " · ", parts )
			: $"{string.Join( " · ", parts )} · {stats.NodeCount} nodes · {stats.StatementCount} statements · {stats.TotalMs:0} ms";

		_status.Color = errors > 0 ? PrismTheme.Error : warnings > 0 ? PrismTheme.Warning : PrismTheme.Success;
	}

	static string Plural( DiagnosticSeverity severity, int count ) => severity switch
	{
		DiagnosticSeverity.Error => count == 1 ? "Error" : "Errors",
		DiagnosticSeverity.Warning => count == 1 ? "Warning" : "Warnings",
		_ => count == 1 ? "Note" : "Notes"
	};

	static bool Matches( Diagnostic diagnostic, string filter )
	{
		if ( string.IsNullOrEmpty( filter ) ) return true;

		return ( diagnostic.Message?.Contains( filter, StringComparison.OrdinalIgnoreCase ) ?? false )
			|| ( diagnostic.Code?.Contains( filter, StringComparison.OrdinalIgnoreCase ) ?? false )
			|| ( diagnostic.Detail?.Contains( filter, StringComparison.OrdinalIgnoreCase ) ?? false );
	}

	static string NameOf( PrismGraph graph, Diagnostic diagnostic )
	{
		if ( graph is null || diagnostic.Graph is not { } reference || !reference.Node.IsValid ) return null;

		var node = graph.FindNode( reference.Node );

		if ( node is null ) return null;

		var title = node.Descriptor?.Title ?? node.GetType().Name;

		return reference.Port is { } port ? $"{title}.{port.Value}" : title;
	}

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

	void PaintRow( VirtualWidget item )
	{
		if ( item.Object is not DiagnosticEntry entry ) return;

		var rect = item.Rect;

		if ( entry.IsHeader )
		{
			PrismPanelChrome.PaintSectionHeader( rect, entry.Header, entry.Count.ToString() );
			return;
		}

		var index = _entries.IndexOf( entry );

		PrismPanelChrome.PaintRow( rect, index, item.Hovered, item.Selected );

		var color = PrismTheme.ForSeverity( entry.Severity );
		var inner = rect.Shrink( PrismPanelChrome.Pad, 0f, PrismPanelChrome.Pad, 0f );

		Paint.SetPen( color );
		Paint.DrawIcon( new Rect( inner.Left, inner.Top, 16f, inner.Height ),
			IconFor( entry.Severity ), 13f, TextFlag.Center );

		var left = inner.Left + 20f;

		if ( !string.IsNullOrEmpty( entry.Diagnostic.Code ) && inner.Width > 260f )
		{
			Paint.SetFont( PrismTheme.FontFamily, 10, 500, false, true );
			Paint.SetPen( PrismTheme.TextDisabled );
			Paint.DrawText( new Rect( left, inner.Top, 44f, inner.Height ), entry.Diagnostic.Code,
				TextFlag.LeftCenter | TextFlag.SingleLine );

			left += 48f;
		}

		var right = inner.Right;
		var location = LocationOf( entry );

		if ( !string.IsNullOrEmpty( location ) && inner.Width > 220f )
		{
			Paint.SetFont( PrismTheme.FontFamily, 10, 400, false, true );

			var width = MathF.Min( 170f, Paint.MeasureText( location ).x + 4f );

			Paint.SetPen( PrismTheme.TextMuted );
			Paint.DrawText( new Rect( right - width, inner.Top, width, inner.Height ),
				Paint.GetElidedText( location, width, ElideMode.Left, TextFlag.RightCenter ),
				TextFlag.RightCenter | TextFlag.SingleLine );

			right -= width + 8f;
		}

		PrismPaint.Text( new Rect( left, inner.Top, MathF.Max( 20f, right - left ), inner.Height ),
			entry.Diagnostic.Message, item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary,
			PrismTheme.BodySize, 400 );
	}

	static string LocationOf( DiagnosticEntry entry )
	{
		if ( !string.IsNullOrEmpty( entry.NodeName ) ) return entry.NodeName;

		var span = entry.Diagnostic.Span;

		return span is { IsValid: true } ? $"line {span.Value.Line}" : null;
	}

	static string IconFor( DiagnosticSeverity severity ) => severity switch
	{
		DiagnosticSeverity.Error => PrismIcons.Error,
		DiagnosticSeverity.Warning => PrismIcons.Warning,
		_ => PrismIcons.Info
	};

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

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

	void OnRowClicked( object item )
	{
		if ( item is not DiagnosticEntry entry || entry.IsHeader ) return;

		Focus( entry.Diagnostic );
	}

	void OnRowActivated( object item )
	{
		if ( item is not DiagnosticEntry entry || entry.IsHeader ) return;

		Focus( entry.Diagnostic );
		JumpToCode( entry.Diagnostic );
	}

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

		if ( item is DiagnosticEntry entry && !entry.IsHeader )
		{
			menu.AddOption( "Select Node", PrismIcons.Frame, () => Focus( entry.Diagnostic ) );
			menu.AddOption( "Go To Generated Line", PrismIcons.Code, () => JumpToCode( entry.Diagnostic ) );
			menu.AddOption( "Copy Message", PrismIcons.Copy, () => EditorUtility.Clipboard.Copy( Format( entry.Diagnostic ) ) );
			menu.AddSeparator();
		}

		menu.AddOption( "Copy All", PrismIcons.Copy, CopyAll );
		menu.AddOption( "Recompile", "refresh", () => _session?.RequestCompile( CompileMode.Preview ) );

		menu.OpenAtCursor( false );
	}

	void Focus( Diagnostic diagnostic )
	{
		if ( _session is null || diagnostic.Graph is not { } reference || !reference.Node.IsValid ) return;

		_session.SelectNode( reference.Node );
		_session.RequestFocus( reference.Node, reference.Port ?? default );
	}

	/// <summary>
	/// Jump to the generated line a diagnostic came from. A compiler diagnostic already carries the
	/// line; a graph diagnostic is resolved through the compile's source map, which is what turns
	/// "this node is wrong" into "this is the line it produced".
	/// </summary>
	void JumpToCode( Diagnostic diagnostic )
	{
		if ( CodeNavigationRequested is not null )
		{
			PrismLog.Guard( "Diagnostics: go to code", () => CodeNavigationRequested( diagnostic ) );
			return;
		}

		var result = _session?.LastCompile;
		var artifact = result?.Artifact( PrismConstants.BackendHlsl );

		if ( artifact is null || string.IsNullOrEmpty( artifact.Text ) ) return;

		var line = diagnostic.Span is { IsValid: true } span ? span.Line : 0;

		if ( line <= 0 && diagnostic.Graph is { } reference && artifact.SourceMap is not null &&
			artifact.SourceMap.TryGetLines( reference.Node, out var range ) )
		{
			line = range.Start;
		}

		PrismLog.Guard( "Diagnostics: open generated code", () =>
		{
			var window = CodeWindow.Open();

			if ( window is null ) return;

			var tab = window.OpenText( "Generated", artifact.Text, "vfx", true );

			if ( tab?.Editor is not null && line > 0 ) tab.Editor.GoToLine( line );
		} );
	}

	void CopyAll()
	{
		var all = _session?.Diagnostics;

		if ( all is null || all.Count == 0 ) return;

		var sb = new StringBuilder();

		foreach ( var diagnostic in all )
		{
			sb.AppendLine( Format( diagnostic ) );
		}

		EditorUtility.Clipboard.Copy( sb.ToString() );
	}

	static string Format( Diagnostic diagnostic )
	{
		var sb = new StringBuilder();

		sb.Append( diagnostic.Severity.ToString().ToLowerInvariant() ).Append( ' ' )
			.Append( diagnostic.Code ).Append( ": " ).Append( diagnostic.Message );

		if ( diagnostic.Span is { IsValid: true } span ) sb.Append( "  (" ).Append( span ).Append( ')' );
		if ( diagnostic.Graph is { IsValid: true } reference ) sb.Append( "  [" ).Append( reference ).Append( ']' );
		if ( !string.IsNullOrWhiteSpace( diagnostic.Detail ) ) sb.Append( "\n\t" ).Append( diagnostic.Detail );

		return sb.ToString();
	}

	static readonly DiagnosticSeverity[] s_order =
	{
		DiagnosticSeverity.Error, DiagnosticSeverity.Warning, DiagnosticSeverity.Info
	};
}