浏览代码

Merge pull request #80260 from raulsntos/dotnet/msbuild-panel

C#: Redesign MSBuild panel
Rémi Verschelde 2 年之前
父节点
当前提交
c7a5a284d3

+ 1 - 0
editor/icons/FileTree.svg

@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m2 2v2h2v5h2v5h8v-2h-6v-3h6v-2h-8v-3h8v-2z" fill="#e0e0e0"/></svg>

+ 23 - 0
modules/mono/editor/GodotTools/GodotTools/Build/BuildDiagnostic.cs

@@ -0,0 +1,23 @@
+#nullable enable
+
+namespace GodotTools.Build
+{
+    public class BuildDiagnostic
+    {
+        public enum DiagnosticType
+        {
+            Hidden,
+            Info,
+            Warning,
+            Error,
+        }
+
+        public DiagnosticType Type { get; set; }
+        public string? File { get; set; }
+        public int Line { get; set; }
+        public int Column { get; set; }
+        public string? Code { get; set; }
+        public string Message { get; set; } = "";
+        public string? ProjectFile { get; set; }
+    }
+}

+ 0 - 3
modules/mono/editor/GodotTools/GodotTools/Build/BuildManager.cs

@@ -40,9 +40,6 @@ namespace GodotTools.Build
             plugin.MakeBottomPanelItemVisible(plugin.MSBuildPanel);
         }
 
-        public static void RestartBuild(BuildOutputView buildOutputView) => throw new NotImplementedException();
-        public static void StopBuild(BuildOutputView buildOutputView) => throw new NotImplementedException();
-
         private static string GetLogFilePath(BuildInfo buildInfo)
         {
             return Path.Combine(buildInfo.LogsDirPath, MsBuildLogFileName);

+ 97 - 372
modules/mono/editor/GodotTools/GodotTools/Build/BuildOutputView.cs

@@ -1,425 +1,150 @@
 using Godot;
-using System;
-using System.Diagnostics.CodeAnalysis;
-using GodotTools.Internals;
-using File = GodotTools.Utils.File;
-using Path = System.IO.Path;
+using static GodotTools.Internals.Globals;
+
+#nullable enable
 
 namespace GodotTools.Build
 {
-    public partial class BuildOutputView : VBoxContainer, ISerializationListener
+    public partial class BuildOutputView : HBoxContainer
     {
-        [Serializable]
-        private partial class BuildIssue : RefCounted // TODO Remove RefCounted once we have proper serialization
-        {
-            public bool Warning { get; set; }
-            public string File { get; set; }
-            public int Line { get; set; }
-            public int Column { get; set; }
-            public string Code { get; set; }
-            public string Message { get; set; }
-            public string ProjectFile { get; set; }
-        }
-
-        [Signal]
-        public delegate void BuildStateChangedEventHandler();
-
-        public bool HasBuildExited { get; private set; } = false;
+#nullable disable
+        private RichTextLabel _log;
 
-        public BuildResult? BuildResult { get; private set; } = null;
+        private Button _clearButton;
+        private Button _copyButton;
+#nullable enable
 
-        public int ErrorCount { get; private set; } = 0;
-
-        public int WarningCount { get; private set; } = 0;
-
-        public bool ErrorsVisible { get; set; } = true;
-        public bool WarningsVisible { get; set; } = true;
-
-        public Texture2D BuildStateIcon
+        public void Append(string text)
         {
-            get
-            {
-                if (!HasBuildExited)
-                    return GetThemeIcon("Stop", "EditorIcons");
-
-                if (BuildResult == Build.BuildResult.Error)
-                    return GetThemeIcon("Error", "EditorIcons");
-
-                if (WarningCount > 1)
-                    return GetThemeIcon("Warning", "EditorIcons");
-
-                return null;
-            }
+            _log.AddText(text);
         }
 
-        public bool LogVisible
+        public void Clear()
         {
-            set => _buildLog.Visible = value;
+            _log.Clear();
         }
 
-        // TODO Use List once we have proper serialization.
-        private Godot.Collections.Array<BuildIssue> _issues = new();
-        private ItemList _issuesList;
-        private PopupMenu _issuesListContextMenu;
-        private TextEdit _buildLog;
-        private BuildInfo _buildInfo;
-
-        private readonly object _pendingBuildLogTextLock = new object();
-        [NotNull] private string _pendingBuildLogText = string.Empty;
-
-        private void LoadIssuesFromFile(string csvFile)
+        private void CopyRequested()
         {
-            using var file = FileAccess.Open(csvFile, FileAccess.ModeFlags.Read);
-
-            if (file == null)
-                return;
+            string text = _log.GetSelectedText();
 
-            while (!file.EofReached())
-            {
-                string[] csvColumns = file.GetCsvLine();
+            if (string.IsNullOrEmpty(text))
+                text = _log.GetParsedText();
 
-                if (csvColumns.Length == 1 && string.IsNullOrEmpty(csvColumns[0]))
-                    return;
-
-                if (csvColumns.Length != 7)
-                {
-                    GD.PushError($"Expected 7 columns, got {csvColumns.Length}");
-                    continue;
-                }
-
-                var issue = new BuildIssue
-                {
-                    Warning = csvColumns[0] == "warning",
-                    File = csvColumns[1],
-                    Line = int.Parse(csvColumns[2]),
-                    Column = int.Parse(csvColumns[3]),
-                    Code = csvColumns[4],
-                    Message = csvColumns[5],
-                    ProjectFile = csvColumns[6]
-                };
-
-                if (issue.Warning)
-                    WarningCount += 1;
-                else
-                    ErrorCount += 1;
-
-                _issues.Add(issue);
-            }
+            if (!string.IsNullOrEmpty(text))
+                DisplayServer.ClipboardSet(text);
         }
 
-        private void IssueActivated(long idx)
+        public override void _Ready()
         {
-            if (idx < 0 || idx >= _issuesList.ItemCount)
-                throw new ArgumentOutOfRangeException(nameof(idx), "Item list index out of range.");
-
-            // Get correct issue idx from issue list
-            int issueIndex = (int)_issuesList.GetItemMetadata((int)idx);
-
-            if (issueIndex < 0 || issueIndex >= _issues.Count)
-                throw new InvalidOperationException("Issue index out of range.");
-
-            BuildIssue issue = _issues[issueIndex];
+            Name = "Output".TTR();
 
-            if (string.IsNullOrEmpty(issue.ProjectFile) && string.IsNullOrEmpty(issue.File))
-                return;
-
-            string projectDir = !string.IsNullOrEmpty(issue.ProjectFile) ?
-                issue.ProjectFile.GetBaseDir() :
-                _buildInfo.Solution.GetBaseDir();
-
-            string file = Path.Combine(projectDir.SimplifyGodotPath(), issue.File.SimplifyGodotPath());
-
-            if (!File.Exists(file))
-                return;
-
-            file = ProjectSettings.LocalizePath(file);
-
-            if (file.StartsWith("res://"))
+            var vbLeft = new VBoxContainer
             {
-                var script = (Script)ResourceLoader.Load(file, typeHint: Internal.CSharpLanguageType);
-
-                // Godot's ScriptEditor.Edit is 0-based but the issue lines are 1-based.
-                if (script != null && Internal.ScriptEditorEdit(script, issue.Line - 1, issue.Column - 1))
-                    Internal.EditorNodeShowScriptScreen();
-            }
-        }
-
-        public void UpdateIssuesList()
-        {
-            _issuesList.Clear();
+                CustomMinimumSize = new Vector2(0, 180 * EditorScale),
+                SizeFlagsVertical = SizeFlags.ExpandFill,
+                SizeFlagsHorizontal = SizeFlags.ExpandFill,
+            };
+            AddChild(vbLeft);
 
-            using (var warningIcon = GetThemeIcon("Warning", "EditorIcons"))
-            using (var errorIcon = GetThemeIcon("Error", "EditorIcons"))
+            // Log - Rich Text Label.
+            _log = new RichTextLabel
             {
-                for (int i = 0; i < _issues.Count; i++)
-                {
-                    BuildIssue issue = _issues[i];
-
-                    if (!(issue.Warning ? WarningsVisible : ErrorsVisible))
-                        continue;
-
-                    string tooltip = string.Empty;
-                    tooltip += $"Message: {issue.Message}";
-
-                    if (!string.IsNullOrEmpty(issue.Code))
-                        tooltip += $"\nCode: {issue.Code}";
-
-                    tooltip += $"\nType: {(issue.Warning ? "warning" : "error")}";
-
-                    string text = string.Empty;
-
-                    if (!string.IsNullOrEmpty(issue.File))
-                    {
-                        text += $"{issue.File}({issue.Line},{issue.Column}): ";
-
-                        tooltip += $"\nFile: {issue.File}";
-                        tooltip += $"\nLine: {issue.Line}";
-                        tooltip += $"\nColumn: {issue.Column}";
-                    }
-
-                    if (!string.IsNullOrEmpty(issue.ProjectFile))
-                        tooltip += $"\nProject: {issue.ProjectFile}";
-
-                    text += issue.Message;
-
-                    int lineBreakIdx = text.IndexOf("\n", StringComparison.Ordinal);
-                    string itemText = lineBreakIdx == -1 ? text : text.Substring(0, lineBreakIdx);
-                    _issuesList.AddItem(itemText, issue.Warning ? warningIcon : errorIcon);
-
-                    int index = _issuesList.ItemCount - 1;
-                    _issuesList.SetItemTooltip(index, tooltip);
-                    _issuesList.SetItemMetadata(index, i);
-                }
-            }
-        }
-
-        private void BuildLaunchFailed(BuildInfo buildInfo, string cause)
-        {
-            HasBuildExited = true;
-            BuildResult = Build.BuildResult.Error;
-
-            _issuesList.Clear();
-
-            var issue = new BuildIssue { Message = cause, Warning = false };
-
-            ErrorCount += 1;
-            _issues.Add(issue);
-
-            UpdateIssuesList();
-
-            EmitSignal(nameof(BuildStateChanged));
-        }
-
-        private void BuildStarted(BuildInfo buildInfo)
-        {
-            _buildInfo = buildInfo;
-            HasBuildExited = false;
-
-            _issues.Clear();
-            WarningCount = 0;
-            ErrorCount = 0;
-            _buildLog.Text = string.Empty;
-
-            UpdateIssuesList();
-
-            EmitSignal(nameof(BuildStateChanged));
-        }
-
-        private void BuildFinished(BuildResult result)
-        {
-            HasBuildExited = true;
-            BuildResult = result;
-
-            LoadIssuesFromFile(Path.Combine(_buildInfo.LogsDirPath, BuildManager.MsBuildIssuesFileName));
+                BbcodeEnabled = true,
+                ScrollFollowing = true,
+                SelectionEnabled = true,
+                ContextMenuEnabled = true,
+                FocusMode = FocusModeEnum.Click,
+                SizeFlagsVertical = SizeFlags.ExpandFill,
+                SizeFlagsHorizontal = SizeFlags.ExpandFill,
+                DeselectOnFocusLossEnabled = false,
 
-            UpdateIssuesList();
+            };
+            vbLeft.AddChild(_log);
 
-            EmitSignal(nameof(BuildStateChanged));
-        }
+            var vbRight = new VBoxContainer();
+            AddChild(vbRight);
 
-        private void UpdateBuildLogText()
-        {
-            lock (_pendingBuildLogTextLock)
+            // Tools grid
+            var hbTools = new HBoxContainer
             {
-                _buildLog.Text += _pendingBuildLogText;
-                _pendingBuildLogText = string.Empty;
-                ScrollToLastNonEmptyLogLine();
-            }
-        }
-
-        private void StdOutputReceived(string text)
-        {
-            lock (_pendingBuildLogTextLock)
-            {
-                if (_pendingBuildLogText.Length == 0)
-                    CallDeferred(nameof(UpdateBuildLogText));
-                _pendingBuildLogText += text + "\n";
-            }
-        }
+                SizeFlagsHorizontal = SizeFlags.ExpandFill,
+            };
+            vbRight.AddChild(hbTools);
 
-        private void StdErrorReceived(string text)
-        {
-            lock (_pendingBuildLogTextLock)
+            // Clear.
+            _clearButton = new Button
             {
-                if (_pendingBuildLogText.Length == 0)
-                    CallDeferred(nameof(UpdateBuildLogText));
-                _pendingBuildLogText += text + "\n";
-            }
-        }
+                ThemeTypeVariation = "FlatButton",
+                FocusMode = FocusModeEnum.None,
+                Shortcut = EditorDefShortcut("editor/clear_output", "Clear Output".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | (Key)KeyModifierMask.MaskShift | Key.K),
+            };
+            _clearButton.Pressed += Clear;
+            hbTools.AddChild(_clearButton);
 
-        private void ScrollToLastNonEmptyLogLine()
-        {
-            int line;
-            for (line = _buildLog.GetLineCount(); line > 0; line--)
+            // Copy.
+            _copyButton = new Button
             {
-                string lineText = _buildLog.GetLine(line);
-
-                if (!string.IsNullOrEmpty(lineText) || !string.IsNullOrEmpty(lineText?.Trim()))
-                    break;
-            }
-
-            _buildLog.SetCaretLine(line);
-        }
-
-        public void RestartBuild()
-        {
-            if (!HasBuildExited)
-                throw new InvalidOperationException("Build already started.");
-
-            BuildManager.RestartBuild(this);
-        }
-
-        public void StopBuild()
-        {
-            if (!HasBuildExited)
-                throw new InvalidOperationException("Build is not in progress.");
+                ThemeTypeVariation = "FlatButton",
+                FocusMode = FocusModeEnum.None,
+                Shortcut = EditorDefShortcut("editor/copy_output", "Copy Selection".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | Key.C),
+                ShortcutContext = this,
+            };
+            _copyButton.Pressed += CopyRequested;
+            hbTools.AddChild(_copyButton);
 
-            BuildManager.StopBuild(this);
+            UpdateTheme();
         }
 
-        private enum IssuesContextMenuOption
+        public override void _Notification(int what)
         {
-            Copy
-        }
+            base._Notification(what);
 
-        private void IssuesListContextOptionPressed(long id)
-        {
-            switch ((IssuesContextMenuOption)id)
+            if (what == NotificationThemeChanged)
             {
-                case IssuesContextMenuOption.Copy:
-                {
-                    // We don't allow multi-selection but just in case that changes later...
-                    string text = null;
-
-                    foreach (int issueIndex in _issuesList.GetSelectedItems())
-                    {
-                        if (text != null)
-                            text += "\n";
-                        text += _issuesList.GetItemText(issueIndex);
-                    }
-
-                    if (text != null)
-                        DisplayServer.ClipboardSet(text);
-                    break;
-                }
-                default:
-                    throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid issue context menu option");
+                UpdateTheme();
             }
         }
 
-        private void IssuesListClicked(long index, Vector2 atPosition, long mouseButtonIndex)
+        private void UpdateTheme()
         {
-            if (mouseButtonIndex != (long)MouseButton.Right)
-            {
+            // Nodes will be null until _Ready is called.
+            if (_log == null)
                 return;
-            }
-
-            _ = index; // Unused
-
-            _issuesListContextMenu.Clear();
-            _issuesListContextMenu.Size = new Vector2I(1, 1);
-
-            if (_issuesList.IsAnythingSelected())
-            {
-                // Add menu entries for the selected item
-                _issuesListContextMenu.AddIconItem(GetThemeIcon("ActionCopy", "EditorIcons"),
-                    label: "Copy Error".TTR(), (int)IssuesContextMenuOption.Copy);
-            }
-
-            if (_issuesListContextMenu.ItemCount > 0)
-            {
-                _issuesListContextMenu.Position = (Vector2I)(_issuesList.GlobalPosition + atPosition);
-                _issuesListContextMenu.Popup();
-            }
-        }
-
-        public override void _Ready()
-        {
-            base._Ready();
 
-            SizeFlagsVertical = SizeFlags.ExpandFill;
+            var normalFont = GetThemeFont("output_source", "EditorFonts");
+            if (normalFont != null)
+                _log.AddThemeFontOverride("normal_font", normalFont);
 
-            var hsc = new HSplitContainer
-            {
-                SizeFlagsHorizontal = SizeFlags.ExpandFill,
-                SizeFlagsVertical = SizeFlags.ExpandFill
-            };
-            AddChild(hsc);
+            var boldFont = GetThemeFont("output_source_bold", "EditorFonts");
+            if (boldFont != null)
+                _log.AddThemeFontOverride("bold_font", boldFont);
 
-            _issuesList = new ItemList
-            {
-                SizeFlagsVertical = SizeFlags.ExpandFill,
-                SizeFlagsHorizontal = SizeFlags.ExpandFill // Avoid being squashed by the build log
-            };
-            _issuesList.ItemActivated += IssueActivated;
-            _issuesList.AllowRmbSelect = true;
-            _issuesList.ItemClicked += IssuesListClicked;
-            hsc.AddChild(_issuesList);
+            var italicsFont = GetThemeFont("output_source_italic", "EditorFonts");
+            if (italicsFont != null)
+                _log.AddThemeFontOverride("italics_font", italicsFont);
 
-            _issuesListContextMenu = new PopupMenu();
-            _issuesListContextMenu.IdPressed += IssuesListContextOptionPressed;
-            _issuesList.AddChild(_issuesListContextMenu);
+            var boldItalicsFont = GetThemeFont("output_source_bold_italic", "EditorFonts");
+            if (boldItalicsFont != null)
+                _log.AddThemeFontOverride("bold_italics_font", boldItalicsFont);
 
-            _buildLog = new TextEdit
-            {
-                Editable = false,
-                SizeFlagsVertical = SizeFlags.ExpandFill,
-                SizeFlagsHorizontal = SizeFlags.ExpandFill // Avoid being squashed by the issues list
-            };
-            hsc.AddChild(_buildLog);
+            var monoFont = GetThemeFont("output_source_mono", "EditorFonts");
+            if (monoFont != null)
+                _log.AddThemeFontOverride("mono_font", monoFont);
 
-            AddBuildEventListeners();
-        }
-
-        private void AddBuildEventListeners()
-        {
-            BuildManager.BuildLaunchFailed += BuildLaunchFailed;
-            BuildManager.BuildStarted += BuildStarted;
-            BuildManager.BuildFinished += BuildFinished;
-            // StdOutput/Error can be received from different threads, so we need to use CallDeferred
-            BuildManager.StdOutputReceived += StdOutputReceived;
-            BuildManager.StdErrorReceived += StdErrorReceived;
-        }
+            // Disable padding for highlighted background/foreground to prevent highlights from overlapping on close lines.
+            // This also better matches terminal output, which does not use any form of padding.
+            _log.AddThemeConstantOverride("text_highlight_h_padding", 0);
+            _log.AddThemeConstantOverride("text_highlight_v_padding", 0);
 
-        public void OnBeforeSerialize()
-        {
-            // In case it didn't update yet. We don't want to have to serialize any pending output.
-            UpdateBuildLogText();
-
-            // NOTE:
-            // Currently, GodotTools is loaded in its own load context. This load context is not reloaded, but the script still are.
-            // Until that changes, we need workarounds like this one because events keep strong references to disposed objects.
-            BuildManager.BuildLaunchFailed -= BuildLaunchFailed;
-            BuildManager.BuildStarted -= BuildStarted;
-            BuildManager.BuildFinished -= BuildFinished;
-            // StdOutput/Error can be received from different threads, so we need to use CallDeferred
-            BuildManager.StdOutputReceived -= StdOutputReceived;
-            BuildManager.StdErrorReceived -= StdErrorReceived;
-        }
+            int font_size = GetThemeFontSize("output_source_size", "EditorFonts");
+            _log.AddThemeFontSizeOverride("normal_font_size", font_size);
+            _log.AddThemeFontSizeOverride("bold_font_size", font_size);
+            _log.AddThemeFontSizeOverride("italics_font_size", font_size);
+            _log.AddThemeFontSizeOverride("mono_font_size", font_size);
 
-        public void OnAfterDeserialize()
-        {
-            AddBuildEventListeners(); // Re-add them
+            _clearButton.Icon = GetThemeIcon("Clear", "EditorIcons");
+            _copyButton.Icon = GetThemeIcon("ActionCopy", "EditorIcons");
         }
     }
 }

+ 40 - 0
modules/mono/editor/GodotTools/GodotTools/Build/BuildProblemsFilter.cs

@@ -0,0 +1,40 @@
+using Godot;
+
+#nullable enable
+
+namespace GodotTools.Build
+{
+    public class BuildProblemsFilter
+    {
+        public BuildDiagnostic.DiagnosticType Type { get; }
+
+        public Button ToggleButton { get; }
+
+        private int _problemsCount;
+
+        public int ProblemsCount
+        {
+            get => _problemsCount;
+            set
+            {
+                _problemsCount = value;
+                ToggleButton.Text = _problemsCount.ToString();
+            }
+        }
+
+        public bool IsActive => ToggleButton.ButtonPressed;
+
+        public BuildProblemsFilter(BuildDiagnostic.DiagnosticType type)
+        {
+            Type = type;
+            ToggleButton = new Button
+            {
+                ToggleMode = true,
+                ButtonPressed = true,
+                Text = "0",
+                FocusMode = Control.FocusModeEnum.None,
+                ThemeTypeVariation = "EditorLogFilterButton",
+            };
+        }
+    }
+}

+ 694 - 0
modules/mono/editor/GodotTools/GodotTools/Build/BuildProblemsView.cs

@@ -0,0 +1,694 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using Godot;
+using GodotTools.Internals;
+using static GodotTools.Internals.Globals;
+using FileAccess = Godot.FileAccess;
+
+#nullable enable
+
+namespace GodotTools.Build
+{
+    public partial class BuildProblemsView : HBoxContainer
+    {
+#nullable disable
+        private Button _clearButton;
+        private Button _copyButton;
+
+        private Button _toggleLayoutButton;
+
+        private Button _showSearchButton;
+        private LineEdit _searchBox;
+#nullable enable
+
+        private readonly Dictionary<BuildDiagnostic.DiagnosticType, BuildProblemsFilter> _filtersByType = new();
+
+#nullable disable
+        private Tree _problemsTree;
+        private PopupMenu _problemsContextMenu;
+#nullable enable
+
+        public enum ProblemsLayout { List, Tree }
+        private ProblemsLayout _layout = ProblemsLayout.Tree;
+
+        private readonly List<BuildDiagnostic> _diagnostics = new();
+
+        public int TotalDiagnosticCount => _diagnostics.Count;
+
+        private readonly Dictionary<BuildDiagnostic.DiagnosticType, int> _problemCountByType = new();
+
+        public int WarningCount =>
+            GetProblemCountForType(BuildDiagnostic.DiagnosticType.Warning);
+
+        public int ErrorCount =>
+            GetProblemCountForType(BuildDiagnostic.DiagnosticType.Error);
+
+        private int GetProblemCountForType(BuildDiagnostic.DiagnosticType type)
+        {
+            if (!_problemCountByType.TryGetValue(type, out int count))
+            {
+                count = _diagnostics.Count(d => d.Type == type);
+                _problemCountByType[type] = count;
+            }
+
+            return count;
+        }
+
+        private static IEnumerable<BuildDiagnostic> ReadDiagnosticsFromFile(string csvFile)
+        {
+            using var file = FileAccess.Open(csvFile, FileAccess.ModeFlags.Read);
+
+            if (file == null)
+                yield break;
+
+            while (!file.EofReached())
+            {
+                string[] csvColumns = file.GetCsvLine();
+
+                if (csvColumns.Length == 1 && string.IsNullOrEmpty(csvColumns[0]))
+                    yield break;
+
+                if (csvColumns.Length != 7)
+                {
+                    GD.PushError($"Expected 7 columns, got {csvColumns.Length}");
+                    continue;
+                }
+
+                var diagnostic = new BuildDiagnostic
+                {
+                    Type = csvColumns[0] switch
+                    {
+                        "warning" => BuildDiagnostic.DiagnosticType.Warning,
+                        "error" or _ => BuildDiagnostic.DiagnosticType.Error,
+                    },
+                    File = csvColumns[1],
+                    Line = int.Parse(csvColumns[2]),
+                    Column = int.Parse(csvColumns[3]),
+                    Code = csvColumns[4],
+                    Message = csvColumns[5],
+                    ProjectFile = csvColumns[6],
+                };
+
+                // If there's no ProjectFile but the File is a csproj, then use that.
+                if (string.IsNullOrEmpty(diagnostic.ProjectFile) &&
+                    !string.IsNullOrEmpty(diagnostic.File) &&
+                    diagnostic.File.EndsWith(".csproj"))
+                {
+                    diagnostic.ProjectFile = diagnostic.File;
+                }
+
+                yield return diagnostic;
+            }
+        }
+
+        public void SetDiagnosticsFromFile(string csvFile)
+        {
+            var diagnostics = ReadDiagnosticsFromFile(csvFile);
+            SetDiagnostics(diagnostics);
+        }
+
+        public void SetDiagnostics(IEnumerable<BuildDiagnostic> diagnostics)
+        {
+            _diagnostics.Clear();
+            _problemCountByType.Clear();
+
+            _diagnostics.AddRange(diagnostics);
+            UpdateProblemsView();
+        }
+
+        public void Clear()
+        {
+            _problemsTree.Clear();
+            _diagnostics.Clear();
+            _problemCountByType.Clear();
+
+            UpdateProblemsView();
+        }
+
+        private void CopySelectedProblems()
+        {
+            var selectedItem = _problemsTree.GetNextSelected(null);
+            if (selectedItem == null)
+                return;
+
+            var selectedIdxs = new List<int>();
+            while (selectedItem != null)
+            {
+                int selectedIdx = (int)selectedItem.GetMetadata(0);
+                selectedIdxs.Add(selectedIdx);
+
+                selectedItem = _problemsTree.GetNextSelected(selectedItem);
+            }
+
+            if (selectedIdxs.Count == 0)
+                return;
+
+            var selectedDiagnostics = selectedIdxs.Select(i => _diagnostics[i]);
+
+            var sb = new StringBuilder();
+
+            foreach (var diagnostic in selectedDiagnostics)
+            {
+                if (!string.IsNullOrEmpty(diagnostic.Code))
+                    sb.Append($"{diagnostic.Code}: ");
+
+                sb.AppendLine($"{diagnostic.Message} {diagnostic.File}({diagnostic.Line},{diagnostic.Column})");
+            }
+
+            string text = sb.ToString();
+
+            if (!string.IsNullOrEmpty(text))
+                DisplayServer.ClipboardSet(text);
+        }
+
+        private void ToggleLayout(bool pressed)
+        {
+            _layout = pressed ? ProblemsLayout.List : ProblemsLayout.Tree;
+
+            var editorSettings = EditorInterface.Singleton.GetEditorSettings();
+            editorSettings.SetSetting(GodotSharpEditor.Settings.ProblemsLayout, Variant.From(_layout));
+
+            _toggleLayoutButton.Icon = GetToggleLayoutIcon();
+            _toggleLayoutButton.TooltipText = GetToggleLayoutTooltipText();
+
+            UpdateProblemsView();
+        }
+
+        private bool GetToggleLayoutPressedState()
+        {
+            // If pressed: List layout.
+            // If not pressed: Tree layout.
+            return _layout == ProblemsLayout.List;
+        }
+
+        private Texture2D? GetToggleLayoutIcon()
+        {
+            return _layout switch
+            {
+                ProblemsLayout.List => GetThemeIcon("FileList", "EditorIcons"),
+                ProblemsLayout.Tree or _ => GetThemeIcon("FileTree", "EditorIcons"),
+            };
+        }
+
+        private string GetToggleLayoutTooltipText()
+        {
+            return _layout switch
+            {
+                ProblemsLayout.List => "View as a Tree".TTR(),
+                ProblemsLayout.Tree or _ => "View as a List".TTR(),
+            };
+        }
+
+        private void ToggleSearchBoxVisibility(bool pressed)
+        {
+            _searchBox.Visible = pressed;
+            if (pressed)
+            {
+                _searchBox.GrabFocus();
+            }
+        }
+
+        private void SearchTextChanged(string text)
+        {
+            UpdateProblemsView();
+        }
+
+        private void ToggleFilter(bool pressed)
+        {
+            UpdateProblemsView();
+        }
+
+        private void GoToSelectedProblem()
+        {
+            var selectedItem = _problemsTree.GetSelected();
+            if (selectedItem == null)
+                throw new InvalidOperationException("Item tree has no selected items.");
+
+            // Get correct diagnostic index from problems tree.
+            int diagnosticIndex = (int)selectedItem.GetMetadata(0);
+
+            if (diagnosticIndex < 0 || diagnosticIndex >= _diagnostics.Count)
+                throw new InvalidOperationException("Diagnostic index out of range.");
+
+            var diagnostic = _diagnostics[diagnosticIndex];
+
+            if (string.IsNullOrEmpty(diagnostic.ProjectFile) && string.IsNullOrEmpty(diagnostic.File))
+                return;
+
+            string? projectDir = !string.IsNullOrEmpty(diagnostic.ProjectFile) ?
+                diagnostic.ProjectFile.GetBaseDir() :
+                GodotSharpEditor.Instance.MSBuildPanel.LastBuildInfo?.Solution.GetBaseDir();
+            if (string.IsNullOrEmpty(projectDir))
+                return;
+
+            string file = Path.Combine(projectDir.SimplifyGodotPath(), diagnostic.File.SimplifyGodotPath());
+
+            if (!File.Exists(file))
+                return;
+
+            file = ProjectSettings.LocalizePath(file);
+
+            if (file.StartsWith("res://"))
+            {
+                var script = (Script)ResourceLoader.Load(file, typeHint: Internal.CSharpLanguageType);
+
+                // Godot's ScriptEditor.Edit is 0-based but the diagnostic lines are 1-based.
+                if (script != null && Internal.ScriptEditorEdit(script, diagnostic.Line - 1, diagnostic.Column - 1))
+                    Internal.EditorNodeShowScriptScreen();
+            }
+        }
+
+        private void ShowProblemContextMenu(Vector2 position, long mouseButtonIndex)
+        {
+            if (mouseButtonIndex != (long)MouseButton.Right)
+                return;
+
+            _problemsContextMenu.Clear();
+            _problemsContextMenu.Size = new Vector2I(1, 1);
+
+            var selectedItem = _problemsTree.GetSelected();
+            if (selectedItem != null)
+            {
+                // Add menu entries for the selected item.
+                _problemsContextMenu.AddIconItem(GetThemeIcon("ActionCopy", "EditorIcons"),
+                    label: "Copy Error".TTR(), (int)ProblemContextMenuOption.Copy);
+            }
+
+            if (_problemsContextMenu.ItemCount > 0)
+            {
+                _problemsContextMenu.Position = (Vector2I)(_problemsTree.GlobalPosition + position);
+                _problemsContextMenu.Popup();
+            }
+        }
+
+        private enum ProblemContextMenuOption
+        {
+            Copy,
+        }
+
+        private void ProblemContextOptionPressed(long id)
+        {
+            switch ((ProblemContextMenuOption)id)
+            {
+                case ProblemContextMenuOption.Copy:
+                    CopySelectedProblems();
+                    break;
+
+                default:
+                    throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid problem context menu option.");
+            }
+        }
+
+        private bool ShouldDisplayDiagnostic(BuildDiagnostic diagnostic)
+        {
+            if (!_filtersByType[diagnostic.Type].IsActive)
+                return false;
+
+            string searchText = _searchBox.Text;
+            if (!string.IsNullOrEmpty(searchText) &&
+                (!diagnostic.Message.Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
+                !(diagnostic.File?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false)))
+            {
+                return false;
+            }
+
+            return true;
+        }
+
+        private Color? GetProblemItemColor(BuildDiagnostic diagnostic)
+        {
+            return diagnostic.Type switch
+            {
+                BuildDiagnostic.DiagnosticType.Warning => GetThemeColor("warning_color", "Editor"),
+                BuildDiagnostic.DiagnosticType.Error => GetThemeColor("error_color", "Editor"),
+                _ => null,
+            };
+        }
+
+        public void UpdateProblemsView()
+        {
+            switch (_layout)
+            {
+                case ProblemsLayout.List:
+                    UpdateProblemsList();
+                    break;
+
+                case ProblemsLayout.Tree:
+                default:
+                    UpdateProblemsTree();
+                    break;
+            }
+
+            foreach (var (type, filter) in _filtersByType)
+            {
+                int count = _diagnostics.Count(d => d.Type == type);
+                filter.ProblemsCount = count;
+            }
+
+            if (_diagnostics.Count == 0)
+                Name = "Problems".TTR();
+            else
+                Name = $"{"Problems".TTR()} ({_diagnostics.Count})";
+        }
+
+        private void UpdateProblemsList()
+        {
+            _problemsTree.Clear();
+
+            var root = _problemsTree.CreateItem();
+
+            for (int i = 0; i < _diagnostics.Count; i++)
+            {
+                var diagnostic = _diagnostics[i];
+
+                if (!ShouldDisplayDiagnostic(diagnostic))
+                    continue;
+
+                var item = CreateProblemItem(diagnostic, includeFileInText: true);
+
+                var problemItem = _problemsTree.CreateItem(root);
+                problemItem.SetIcon(0, item.Icon);
+                problemItem.SetText(0, item.Text);
+                problemItem.SetTooltipText(0, item.TooltipText);
+                problemItem.SetMetadata(0, i);
+
+                var color = GetProblemItemColor(diagnostic);
+                if (color.HasValue)
+                    problemItem.SetCustomColor(0, color.Value);
+            }
+        }
+
+        private void UpdateProblemsTree()
+        {
+            _problemsTree.Clear();
+
+            var root = _problemsTree.CreateItem();
+
+            var groupedDiagnostics = _diagnostics.Select((d, i) => (Diagnostic: d, Index: i))
+                .Where(x => ShouldDisplayDiagnostic(x.Diagnostic))
+                .GroupBy(x => x.Diagnostic.ProjectFile)
+                .Select(g => (ProjectFile: g.Key, Diagnostics: g.GroupBy(x => x.Diagnostic.File)
+                    .Select(x => (File: x.Key, Diagnostics: x.ToArray()))))
+                .ToArray();
+
+            if (groupedDiagnostics.Length == 0)
+                return;
+
+            foreach (var (projectFile, projectDiagnostics) in groupedDiagnostics)
+            {
+                TreeItem projectItem;
+
+                if (groupedDiagnostics.Length == 1)
+                {
+                    // Don't create a project item if there's only one project.
+                    projectItem = root;
+                }
+                else
+                {
+                    string projectFilePath = !string.IsNullOrEmpty(projectFile)
+                        ? projectFile
+                        : "Unknown project".TTR();
+                    projectItem = _problemsTree.CreateItem(root);
+                    projectItem.SetText(0, projectFilePath);
+                    projectItem.SetSelectable(0, false);
+                }
+
+                foreach (var (file, fileDiagnostics) in projectDiagnostics)
+                {
+                    if (fileDiagnostics.Length == 0)
+                        continue;
+
+                    string? projectDir = Path.GetDirectoryName(projectFile);
+                    string relativeFilePath = !string.IsNullOrEmpty(file) && !string.IsNullOrEmpty(projectDir)
+                        ? Path.GetRelativePath(projectDir, file)
+                        : "Unknown file".TTR();
+
+                    string fileItemText = string.Format("{0} ({1} issues)".TTR(), relativeFilePath, fileDiagnostics.Length);
+
+                    var fileItem = _problemsTree.CreateItem(projectItem);
+                    fileItem.SetText(0, fileItemText);
+                    fileItem.SetSelectable(0, false);
+
+                    foreach (var (diagnostic, index) in fileDiagnostics)
+                    {
+                        var item = CreateProblemItem(diagnostic);
+
+                        var problemItem = _problemsTree.CreateItem(fileItem);
+                        problemItem.SetIcon(0, item.Icon);
+                        problemItem.SetText(0, item.Text);
+                        problemItem.SetTooltipText(0, item.TooltipText);
+                        problemItem.SetMetadata(0, index);
+
+                        var color = GetProblemItemColor(diagnostic);
+                        if (color.HasValue)
+                            problemItem.SetCustomColor(0, color.Value);
+                    }
+                }
+            }
+        }
+
+        private class ProblemItem
+        {
+            public string? Text { get; set; }
+            public string? TooltipText { get; set; }
+            public Texture2D? Icon { get; set; }
+        }
+
+        private ProblemItem CreateProblemItem(BuildDiagnostic diagnostic, bool includeFileInText = false)
+        {
+            var text = new StringBuilder();
+            var tooltip = new StringBuilder();
+
+            ReadOnlySpan<char> shortMessage = diagnostic.Message.AsSpan();
+            int lineBreakIdx = shortMessage.IndexOf('\n');
+            if (lineBreakIdx != -1)
+                shortMessage = shortMessage[..lineBreakIdx];
+            text.Append(shortMessage);
+
+            tooltip.Append($"Message: {diagnostic.Message}");
+
+            if (!string.IsNullOrEmpty(diagnostic.Code))
+                tooltip.Append($"\nCode: {diagnostic.Code}");
+
+            string type = diagnostic.Type switch
+            {
+                BuildDiagnostic.DiagnosticType.Hidden => "hidden",
+                BuildDiagnostic.DiagnosticType.Info => "info",
+                BuildDiagnostic.DiagnosticType.Warning => "warning",
+                BuildDiagnostic.DiagnosticType.Error => "error",
+                _ => "unknown",
+            };
+            tooltip.Append($"\nType: {type}");
+
+            if (!string.IsNullOrEmpty(diagnostic.File))
+            {
+                text.Append(' ');
+                if (includeFileInText)
+                {
+                    text.Append(diagnostic.File);
+                }
+
+                text.Append($"({diagnostic.Line},{diagnostic.Column})");
+
+                tooltip.Append($"\nFile: {diagnostic.File}");
+                tooltip.Append($"\nLine: {diagnostic.Line}");
+                tooltip.Append($"\nColumn: {diagnostic.Column}");
+            }
+
+            if (!string.IsNullOrEmpty(diagnostic.ProjectFile))
+                tooltip.Append($"\nProject: {diagnostic.ProjectFile}");
+
+            return new ProblemItem()
+            {
+                Text = text.ToString(),
+                TooltipText = tooltip.ToString(),
+                Icon = diagnostic.Type switch
+                {
+                    BuildDiagnostic.DiagnosticType.Warning => GetThemeIcon("Warning", "EditorIcons"),
+                    BuildDiagnostic.DiagnosticType.Error => GetThemeIcon("Error", "EditorIcons"),
+                    _ => null,
+                },
+            };
+        }
+
+        public override void _Ready()
+        {
+            var editorSettings = GodotSharpEditor.Instance.GetEditorInterface().GetEditorSettings();
+            _layout = editorSettings.GetSetting(GodotSharpEditor.Settings.ProblemsLayout).As<ProblemsLayout>();
+
+            Name = "Problems".TTR();
+
+            var vbLeft = new VBoxContainer
+            {
+                CustomMinimumSize = new Vector2(0, 180 * EditorScale),
+                SizeFlagsVertical = SizeFlags.ExpandFill,
+                SizeFlagsHorizontal = SizeFlags.ExpandFill,
+            };
+            AddChild(vbLeft);
+
+            // Problem Tree.
+            _problemsTree = new Tree
+            {
+                SizeFlagsVertical = SizeFlags.ExpandFill,
+                SizeFlagsHorizontal = SizeFlags.ExpandFill,
+                AllowRmbSelect = true,
+                HideRoot = true,
+            };
+            _problemsTree.ItemActivated += GoToSelectedProblem;
+            _problemsTree.ItemMouseSelected += ShowProblemContextMenu;
+            vbLeft.AddChild(_problemsTree);
+
+            // Problem context menu.
+            _problemsContextMenu = new PopupMenu();
+            _problemsContextMenu.IdPressed += ProblemContextOptionPressed;
+            _problemsTree.AddChild(_problemsContextMenu);
+
+            // Search box.
+            _searchBox = new LineEdit
+            {
+                SizeFlagsHorizontal = SizeFlags.ExpandFill,
+                PlaceholderText = "Filter Problems".TTR(),
+                ClearButtonEnabled = true,
+            };
+            _searchBox.TextChanged += SearchTextChanged;
+            vbLeft.AddChild(_searchBox);
+
+            var vbRight = new VBoxContainer();
+            AddChild(vbRight);
+
+            // Tools grid.
+            var hbTools = new HBoxContainer
+            {
+                SizeFlagsHorizontal = SizeFlags.ExpandFill,
+            };
+            vbRight.AddChild(hbTools);
+
+            // Clear.
+            _clearButton = new Button
+            {
+                ThemeTypeVariation = "FlatButton",
+                FocusMode = FocusModeEnum.None,
+                Shortcut = EditorDefShortcut("editor/clear_output", "Clear Output".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | (Key)KeyModifierMask.MaskShift | Key.K),
+                ShortcutContext = this,
+            };
+            _clearButton.Pressed += Clear;
+            hbTools.AddChild(_clearButton);
+
+            // Copy.
+            _copyButton = new Button
+            {
+                ThemeTypeVariation = "FlatButton",
+                FocusMode = FocusModeEnum.None,
+                Shortcut = EditorDefShortcut("editor/copy_output", "Copy Selection".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | Key.C),
+                ShortcutContext = this,
+            };
+            _copyButton.Pressed += CopySelectedProblems;
+            hbTools.AddChild(_copyButton);
+
+            // A second hbox to make a 2x2 grid of buttons.
+            var hbTools2 = new HBoxContainer
+            {
+                SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
+            };
+            vbRight.AddChild(hbTools2);
+
+            // Toggle List/Tree.
+            _toggleLayoutButton = new Button
+            {
+                Flat = true,
+                FocusMode = FocusModeEnum.None,
+                TooltipText = GetToggleLayoutTooltipText(),
+                ToggleMode = true,
+                ButtonPressed = GetToggleLayoutPressedState(),
+            };
+            // Don't tint the icon even when in "pressed" state.
+            _toggleLayoutButton.AddThemeColorOverride("icon_pressed_color", Colors.White);
+            _toggleLayoutButton.Toggled += ToggleLayout;
+            hbTools2.AddChild(_toggleLayoutButton);
+
+            // Show Search.
+            _showSearchButton = new Button
+            {
+                ThemeTypeVariation = "FlatButton",
+                FocusMode = FocusModeEnum.None,
+                ToggleMode = true,
+                ButtonPressed = true,
+                Shortcut = EditorDefShortcut("editor/open_search", "Focus Search/Filter Bar".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | Key.F),
+                ShortcutContext = this,
+            };
+            _showSearchButton.Toggled += ToggleSearchBoxVisibility;
+            hbTools2.AddChild(_showSearchButton);
+
+            // Diagnostic Type Filters.
+            vbRight.AddChild(new HSeparator());
+
+            var infoFilter = new BuildProblemsFilter(BuildDiagnostic.DiagnosticType.Info);
+            infoFilter.ToggleButton.TooltipText = "Toggle visibility of info diagnostics.".TTR();
+            infoFilter.ToggleButton.Toggled += ToggleFilter;
+            vbRight.AddChild(infoFilter.ToggleButton);
+            _filtersByType[BuildDiagnostic.DiagnosticType.Info] = infoFilter;
+
+            var errorFilter = new BuildProblemsFilter(BuildDiagnostic.DiagnosticType.Error);
+            errorFilter.ToggleButton.TooltipText = "Toggle visibility of errors.".TTR();
+            errorFilter.ToggleButton.Toggled += ToggleFilter;
+            vbRight.AddChild(errorFilter.ToggleButton);
+            _filtersByType[BuildDiagnostic.DiagnosticType.Error] = errorFilter;
+
+            var warningFilter = new BuildProblemsFilter(BuildDiagnostic.DiagnosticType.Warning);
+            warningFilter.ToggleButton.TooltipText = "Toggle visibility of warnings.".TTR();
+            warningFilter.ToggleButton.Toggled += ToggleFilter;
+            vbRight.AddChild(warningFilter.ToggleButton);
+            _filtersByType[BuildDiagnostic.DiagnosticType.Warning] = warningFilter;
+
+            UpdateTheme();
+
+            UpdateProblemsView();
+        }
+
+        public override void _Notification(int what)
+        {
+            base._Notification(what);
+
+            switch ((long)what)
+            {
+                case EditorSettings.NotificationEditorSettingsChanged:
+                    var editorSettings = GodotSharpEditor.Instance.GetEditorInterface().GetEditorSettings();
+                    _layout = editorSettings.GetSetting(GodotSharpEditor.Settings.ProblemsLayout).As<ProblemsLayout>();
+                    _toggleLayoutButton.ButtonPressed = GetToggleLayoutPressedState();
+                    UpdateProblemsView();
+                    break;
+
+                case NotificationThemeChanged:
+                    UpdateTheme();
+                    break;
+            }
+        }
+
+        private void UpdateTheme()
+        {
+            // Nodes will be null until _Ready is called.
+            if (_clearButton == null)
+                return;
+
+            foreach (var (type, filter) in _filtersByType)
+            {
+                filter.ToggleButton.Icon = type switch
+                {
+                    BuildDiagnostic.DiagnosticType.Info => GetThemeIcon("Popup", "EditorIcons"),
+                    BuildDiagnostic.DiagnosticType.Warning => GetThemeIcon("StatusWarning", "EditorIcons"),
+                    BuildDiagnostic.DiagnosticType.Error => GetThemeIcon("StatusError", "EditorIcons"),
+                    _ => null,
+                };
+            }
+
+            _clearButton.Icon = GetThemeIcon("Clear", "EditorIcons");
+            _copyButton.Icon = GetThemeIcon("ActionCopy", "EditorIcons");
+            _toggleLayoutButton.Icon = GetToggleLayoutIcon();
+            _showSearchButton.Icon = GetThemeIcon("Search", "EditorIcons");
+            _searchBox.RightIcon = GetThemeIcon("Search", "EditorIcons");
+        }
+    }
+}

+ 201 - 92
modules/mono/editor/GodotTools/GodotTools/Build/MSBuildPanel.cs

@@ -5,28 +5,73 @@ using GodotTools.Internals;
 using static GodotTools.Internals.Globals;
 using File = GodotTools.Utils.File;
 
+#nullable enable
+
 namespace GodotTools.Build
 {
-    public partial class MSBuildPanel : VBoxContainer
+    public partial class MSBuildPanel : MarginContainer, ISerializationListener
     {
-        public BuildOutputView BuildOutputView { get; private set; }
+        [Signal]
+        public delegate void BuildStateChangedEventHandler();
+
+#nullable disable
+        private MenuButton _buildMenuButton;
+        private Button _openLogsFolderButton;
+
+        private BuildProblemsView _problemsView;
+        private BuildOutputView _outputView;
+#nullable enable
+
+        public BuildInfo? LastBuildInfo { get; private set; }
+        public bool IsBuildingOngoing { get; private set; }
+        public BuildResult? BuildResult { get; private set; }
 
-        private MenuButton _buildMenuBtn;
-        private Button _errorsBtn;
-        private Button _warningsBtn;
-        private Button _viewLogBtn;
-        private Button _openLogsFolderBtn;
+        private readonly object _pendingBuildLogTextLock = new object();
+        private string _pendingBuildLogText = string.Empty;
 
-        private void WarningsToggled(bool pressed)
+        public Texture2D? GetBuildStateIcon()
         {
-            BuildOutputView.WarningsVisible = pressed;
-            BuildOutputView.UpdateIssuesList();
+            if (IsBuildingOngoing)
+                return GetThemeIcon("Stop", "EditorIcons");
+
+            if (_problemsView.WarningCount > 0 && _problemsView.ErrorCount > 0)
+                return GetThemeIcon("ErrorWarning", "EditorIcons");
+
+            if (_problemsView.WarningCount > 0)
+                return GetThemeIcon("Warning", "EditorIcons");
+
+            if (_problemsView.ErrorCount > 0)
+                return GetThemeIcon("Error", "EditorIcons");
+
+            return null;
         }
 
-        private void ErrorsToggled(bool pressed)
+        private enum BuildMenuOptions
         {
-            BuildOutputView.ErrorsVisible = pressed;
-            BuildOutputView.UpdateIssuesList();
+            BuildProject,
+            RebuildProject,
+            CleanProject,
+        }
+
+        private void BuildMenuOptionPressed(long id)
+        {
+            switch ((BuildMenuOptions)id)
+            {
+                case BuildMenuOptions.BuildProject:
+                    BuildProject();
+                    break;
+
+                case BuildMenuOptions.RebuildProject:
+                    RebuildProject();
+                    break;
+
+                case BuildMenuOptions.CleanProject:
+                    CleanProject();
+                    break;
+
+                default:
+                    throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid build menu option");
+            }
         }
 
         public void BuildProject()
@@ -73,108 +118,136 @@ namespace GodotTools.Build
             _ = BuildManager.CleanProjectBlocking("Debug");
         }
 
-        private void ViewLogToggled(bool pressed) => BuildOutputView.LogVisible = pressed;
-
-        private void OpenLogsFolderPressed() => OS.ShellOpen(
+        private void OpenLogsFolder() => OS.ShellOpen(
             $"file://{GodotSharpDirs.LogsDirPathFor("Debug")}"
         );
 
-        private void BuildMenuOptionPressed(long id)
+        private void BuildLaunchFailed(BuildInfo buildInfo, string cause)
         {
-            switch ((BuildMenuOptions)id)
+            IsBuildingOngoing = false;
+            BuildResult = Build.BuildResult.Error;
+
+            _problemsView.Clear();
+            _outputView.Clear();
+
+            var diagnostic = new BuildDiagnostic
             {
-                case BuildMenuOptions.BuildProject:
-                    BuildProject();
-                    break;
-                case BuildMenuOptions.RebuildProject:
-                    RebuildProject();
-                    break;
-                case BuildMenuOptions.CleanProject:
-                    CleanProject();
-                    break;
-                default:
-                    throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid build menu option");
+                Type = BuildDiagnostic.DiagnosticType.Error,
+                Message = cause,
+            };
+
+            _problemsView.SetDiagnostics(new[] { diagnostic });
+
+            EmitSignal(SignalName.BuildStateChanged);
+        }
+
+        private void BuildStarted(BuildInfo buildInfo)
+        {
+            LastBuildInfo = buildInfo;
+            IsBuildingOngoing = true;
+            BuildResult = null;
+
+            _problemsView.Clear();
+            _outputView.Clear();
+
+            _problemsView.UpdateProblemsView();
+
+            EmitSignal(SignalName.BuildStateChanged);
+        }
+
+        private void BuildFinished(BuildResult result)
+        {
+            IsBuildingOngoing = false;
+            BuildResult = result;
+
+            string csvFile = Path.Combine(LastBuildInfo!.LogsDirPath, BuildManager.MsBuildIssuesFileName);
+            _problemsView.SetDiagnosticsFromFile(csvFile);
+
+            _problemsView.UpdateProblemsView();
+
+            EmitSignal(SignalName.BuildStateChanged);
+        }
+
+        private void UpdateBuildLogText()
+        {
+            lock (_pendingBuildLogTextLock)
+            {
+                _outputView.Append(_pendingBuildLogText);
+                _pendingBuildLogText = string.Empty;
             }
         }
 
-        private enum BuildMenuOptions
+        private void StdOutputReceived(string text)
         {
-            BuildProject,
-            RebuildProject,
-            CleanProject
+            lock (_pendingBuildLogTextLock)
+            {
+                if (_pendingBuildLogText.Length == 0)
+                    CallDeferred(nameof(UpdateBuildLogText));
+                _pendingBuildLogText += text + "\n";
+            }
+        }
+
+        private void StdErrorReceived(string text)
+        {
+            lock (_pendingBuildLogTextLock)
+            {
+                if (_pendingBuildLogText.Length == 0)
+                    CallDeferred(nameof(UpdateBuildLogText));
+                _pendingBuildLogText += text + "\n";
+            }
         }
 
         public override void _Ready()
         {
             base._Ready();
 
-            CustomMinimumSize = new Vector2(0, 228 * EditorScale);
-            SizeFlagsVertical = SizeFlags.ExpandFill;
+            var bottomPanelStylebox = EditorInterface.Singleton.GetBaseControl().GetThemeStylebox("BottomPanel", "EditorStyles");
+            AddThemeConstantOverride("margin_top", -(int)bottomPanelStylebox.ContentMarginTop);
+            AddThemeConstantOverride("margin_left", -(int)bottomPanelStylebox.ContentMarginLeft);
+            AddThemeConstantOverride("margin_right", -(int)bottomPanelStylebox.ContentMarginRight);
+
+            var tabs = new TabContainer();
+            AddChild(tabs);
 
-            var toolBarHBox = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
-            AddChild(toolBarHBox);
+            var tabActions = new HBoxContainer
+            {
+                SizeFlagsVertical = SizeFlags.ExpandFill,
+                SizeFlagsHorizontal = SizeFlags.ExpandFill,
+                Alignment = BoxContainer.AlignmentMode.End,
+            };
+            tabActions.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+            tabs.GetTabBar().AddChild(tabActions);
 
-            _buildMenuBtn = new MenuButton { Text = "Build", Icon = GetThemeIcon("BuildCSharp", "EditorIcons") };
-            toolBarHBox.AddChild(_buildMenuBtn);
+            _buildMenuButton = new MenuButton
+            {
+                TooltipText = "Build".TTR(),
+                Flat = true,
+            };
+            tabActions.AddChild(_buildMenuButton);
 
-            var buildMenu = _buildMenuBtn.GetPopup();
+            var buildMenu = _buildMenuButton.GetPopup();
             buildMenu.AddItem("Build Project".TTR(), (int)BuildMenuOptions.BuildProject);
             buildMenu.AddItem("Rebuild Project".TTR(), (int)BuildMenuOptions.RebuildProject);
             buildMenu.AddItem("Clean Project".TTR(), (int)BuildMenuOptions.CleanProject);
             buildMenu.IdPressed += BuildMenuOptionPressed;
 
-            _errorsBtn = new Button
+            _openLogsFolderButton = new Button
             {
-                TooltipText = "Show Errors".TTR(),
-                Icon = GetThemeIcon("StatusError", "EditorIcons"),
-                ExpandIcon = false,
-                ToggleMode = true,
-                ButtonPressed = true,
-                FocusMode = FocusModeEnum.None
+                TooltipText = "Show Logs in File Manager".TTR(),
+                Flat = true,
             };
-            _errorsBtn.Toggled += ErrorsToggled;
-            toolBarHBox.AddChild(_errorsBtn);
+            _openLogsFolderButton.Pressed += OpenLogsFolder;
+            tabActions.AddChild(_openLogsFolderButton);
 
-            _warningsBtn = new Button
-            {
-                TooltipText = "Show Warnings".TTR(),
-                Icon = GetThemeIcon("NodeWarning", "EditorIcons"),
-                ExpandIcon = false,
-                ToggleMode = true,
-                ButtonPressed = true,
-                FocusMode = FocusModeEnum.None
-            };
-            _warningsBtn.Toggled += WarningsToggled;
-            toolBarHBox.AddChild(_warningsBtn);
+            _problemsView = new BuildProblemsView();
+            tabs.AddChild(_problemsView);
 
-            _viewLogBtn = new Button
-            {
-                Text = "Show Output".TTR(),
-                ToggleMode = true,
-                ButtonPressed = true,
-                FocusMode = FocusModeEnum.None
-            };
-            _viewLogBtn.Toggled += ViewLogToggled;
-            toolBarHBox.AddChild(_viewLogBtn);
-
-            // Horizontal spacer, push everything to the right.
-            toolBarHBox.AddChild(new Control
-            {
-                SizeFlagsHorizontal = SizeFlags.ExpandFill,
-            });
+            _outputView = new BuildOutputView();
+            tabs.AddChild(_outputView);
 
-            _openLogsFolderBtn = new Button
-            {
-                Text = "Show Logs in File Manager".TTR(),
-                Icon = GetThemeIcon("Filesystem", "EditorIcons"),
-                ExpandIcon = false,
-                FocusMode = FocusModeEnum.None,
-            };
-            _openLogsFolderBtn.Pressed += OpenLogsFolderPressed;
-            toolBarHBox.AddChild(_openLogsFolderBtn);
+            UpdateTheme();
 
-            BuildOutputView = new BuildOutputView();
-            AddChild(BuildOutputView);
+            AddBuildEventListeners();
         }
 
         public override void _Notification(int what)
@@ -183,13 +256,49 @@ namespace GodotTools.Build
 
             if (what == NotificationThemeChanged)
             {
-                if (_buildMenuBtn != null)
-                    _buildMenuBtn.Icon = GetThemeIcon("BuildCSharp", "EditorIcons");
-                if (_errorsBtn != null)
-                    _errorsBtn.Icon = GetThemeIcon("StatusError", "EditorIcons");
-                if (_warningsBtn != null)
-                    _warningsBtn.Icon = GetThemeIcon("NodeWarning", "EditorIcons");
+                UpdateTheme();
             }
         }
+
+        private void UpdateTheme()
+        {
+            // Nodes will be null until _Ready is called.
+            if (_buildMenuButton == null)
+                return;
+
+            _buildMenuButton.Icon = GetThemeIcon("BuildCSharp", "EditorIcons");
+            _openLogsFolderButton.Icon = GetThemeIcon("Filesystem", "EditorIcons");
+        }
+
+        private void AddBuildEventListeners()
+        {
+            BuildManager.BuildLaunchFailed += BuildLaunchFailed;
+            BuildManager.BuildStarted += BuildStarted;
+            BuildManager.BuildFinished += BuildFinished;
+            // StdOutput/Error can be received from different threads, so we need to use CallDeferred.
+            BuildManager.StdOutputReceived += StdOutputReceived;
+            BuildManager.StdErrorReceived += StdErrorReceived;
+        }
+
+        public void OnBeforeSerialize()
+        {
+            // In case it didn't update yet. We don't want to have to serialize any pending output.
+            UpdateBuildLogText();
+
+            // NOTE:
+            // Currently, GodotTools is loaded in its own load context. This load context is not reloaded, but the script still are.
+            // Until that changes, we need workarounds like this one because events keep strong references to disposed objects.
+            BuildManager.BuildLaunchFailed -= BuildLaunchFailed;
+            BuildManager.BuildStarted -= BuildStarted;
+            BuildManager.BuildFinished -= BuildFinished;
+            // StdOutput/Error can be received from different threads, so we need to use CallDeferred
+            BuildManager.StdOutputReceived -= StdOutputReceived;
+            BuildManager.StdErrorReceived -= StdErrorReceived;
+        }
+
+        public void OnAfterDeserialize()
+        {
+            AddBuildEventListeners(); // Re-add them.
+        }
     }
 }

+ 12 - 3
modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs

@@ -30,6 +30,7 @@ namespace GodotTools
             public const string VerbosityLevel = "dotnet/build/verbosity_level";
             public const string NoConsoleLogging = "dotnet/build/no_console_logging";
             public const string CreateBinaryLog = "dotnet/build/create_binary_log";
+            public const string ProblemsLayout = "dotnet/build/problems_layout";
         }
 
         private EditorSettings _editorSettings;
@@ -437,7 +438,7 @@ namespace GodotTools
         private void BuildStateChanged()
         {
             if (_bottomPanelBtn != null)
-                _bottomPanelBtn.Icon = MSBuildPanel.BuildOutputView.BuildStateIcon;
+                _bottomPanelBtn.Icon = MSBuildPanel.GetBuildStateIcon();
         }
 
         public override void _EnablePlugin()
@@ -489,8 +490,7 @@ namespace GodotTools
             editorBaseControl.AddChild(_confirmCreateSlnDialog);
 
             MSBuildPanel = new MSBuildPanel();
-            MSBuildPanel.Ready += () =>
-                MSBuildPanel.BuildOutputView.BuildStateChanged += BuildStateChanged;
+            MSBuildPanel.BuildStateChanged += BuildStateChanged;
             _bottomPanelBtn = AddControlToBottomPanel(MSBuildPanel, "MSBuild".TTR());
 
             AddChild(new HotReloadAssemblyWatcher { Name = "HotReloadAssemblyWatcher" });
@@ -535,6 +535,7 @@ namespace GodotTools
             EditorDef(Settings.VerbosityLevel, Variant.From(VerbosityLevelId.Normal));
             EditorDef(Settings.NoConsoleLogging, false);
             EditorDef(Settings.CreateBinaryLog, false);
+            EditorDef(Settings.ProblemsLayout, Variant.From(BuildProblemsView.ProblemsLayout.Tree));
 
             string settingsHintStr = "Disabled";
 
@@ -593,6 +594,14 @@ namespace GodotTools
                 ["hint_string"] = string.Join(",", verbosityLevels),
             });
 
+            _editorSettings.AddPropertyInfo(new Godot.Collections.Dictionary
+            {
+                ["type"] = (int)Variant.Type.Int,
+                ["name"] = Settings.ProblemsLayout,
+                ["hint"] = (int)PropertyHint.Enum,
+                ["hint_string"] = "View as List,View as Tree",
+            });
+
             OnSettingsChanged();
             _editorSettings.SettingsChanged += OnSettingsChanged;