From f19694a8d6d681b668e0cc8667a40766fca97e05 Mon Sep 17 00:00:00 2001 From: Raul Santos Date: Sat, 24 Jun 2023 04:35:12 +0200 Subject: [PATCH] C#: Redesign MSBuild panel - Redesign panel to look closer to the look of other Godot panels such as Output and Debugger. - Moved list of problems and output log to separate tabs instead of using a HSplit. - Added Tree/List layouts to the problems tab. - Added search box to filter problems tab. - Added `FileTree` icon, made from `FileList`. Both are used for the button that toggles the Tree/List layouts. --- editor/icons/FileTree.svg | 1 + .../GodotTools/Build/BuildDiagnostic.cs | 23 + .../GodotTools/Build/BuildManager.cs | 3 - .../GodotTools/Build/BuildOutputView.cs | 493 +++---------- .../GodotTools/Build/BuildProblemsFilter.cs | 40 + .../GodotTools/Build/BuildProblemsView.cs | 694 ++++++++++++++++++ .../GodotTools/Build/MSBuildPanel.cs | 293 +++++--- .../GodotTools/GodotTools/GodotSharpEditor.cs | 15 +- 8 files changed, 1080 insertions(+), 482 deletions(-) create mode 100644 editor/icons/FileTree.svg create mode 100644 modules/mono/editor/GodotTools/GodotTools/Build/BuildDiagnostic.cs create mode 100644 modules/mono/editor/GodotTools/GodotTools/Build/BuildProblemsFilter.cs create mode 100644 modules/mono/editor/GodotTools/GodotTools/Build/BuildProblemsView.cs diff --git a/editor/icons/FileTree.svg b/editor/icons/FileTree.svg new file mode 100644 index 000000000000..995715c993b4 --- /dev/null +++ b/editor/icons/FileTree.svg @@ -0,0 +1 @@ + diff --git a/modules/mono/editor/GodotTools/GodotTools/Build/BuildDiagnostic.cs b/modules/mono/editor/GodotTools/GodotTools/Build/BuildDiagnostic.cs new file mode 100644 index 000000000000..6e0c63dd433e --- /dev/null +++ b/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; } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools/Build/BuildManager.cs b/modules/mono/editor/GodotTools/GodotTools/Build/BuildManager.cs index 312c65e36496..9bb4fd153b28 100644 --- a/modules/mono/editor/GodotTools/GodotTools/Build/BuildManager.cs +++ b/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); diff --git a/modules/mono/editor/GodotTools/GodotTools/Build/BuildOutputView.cs b/modules/mono/editor/GodotTools/GodotTools/Build/BuildOutputView.cs index 54f7ed02f511..f9e85c36e563 100644 --- a/modules/mono/editor/GodotTools/GodotTools/Build/BuildOutputView.cs +++ b/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 +#nullable disable + private RichTextLabel _log; + + private Button _clearButton; + private Button _copyButton; +#nullable enable + + public void Append(string text) { - 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; } + _log.AddText(text); } - [Signal] - public delegate void BuildStateChangedEventHandler(); - - public bool HasBuildExited { get; private set; } = false; - - public BuildResult? BuildResult { get; private set; } = null; - - 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 Clear() { - 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.Clear(); } - public bool LogVisible + private void CopyRequested() { - set => _buildLog.Visible = value; - } + string text = _log.GetSelectedText(); - // TODO Use List once we have proper serialization. - private Godot.Collections.Array _issues = new(); - private ItemList _issuesList; - private PopupMenu _issuesListContextMenu; - private TextEdit _buildLog; - private BuildInfo _buildInfo; + if (string.IsNullOrEmpty(text)) + text = _log.GetParsedText(); - private readonly object _pendingBuildLogTextLock = new object(); - [NotNull] private string _pendingBuildLogText = string.Empty; - - private void LoadIssuesFromFile(string csvFile) - { - using var file = FileAccess.Open(csvFile, FileAccess.ModeFlags.Read); - - if (file == null) - return; - - while (!file.EofReached()) - { - string[] csvColumns = file.GetCsvLine(); - - 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); - } - } - - private void IssueActivated(long idx) - { - 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]; - - 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 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(); - - using (var warningIcon = GetThemeIcon("Warning", "EditorIcons")) - using (var errorIcon = GetThemeIcon("Error", "EditorIcons")) - { - 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)); - - UpdateIssuesList(); - - EmitSignal(nameof(BuildStateChanged)); - } - - private void UpdateBuildLogText() - { - lock (_pendingBuildLogTextLock) - { - _buildLog.Text += _pendingBuildLogText; - _pendingBuildLogText = string.Empty; - ScrollToLastNonEmptyLogLine(); - } - } - - private void StdOutputReceived(string text) - { - 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"; - } - } - - private void ScrollToLastNonEmptyLogLine() - { - int line; - for (line = _buildLog.GetLineCount(); line > 0; line--) - { - 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."); - - BuildManager.StopBuild(this); - } - - private enum IssuesContextMenuOption - { - Copy - } - - private void IssuesListContextOptionPressed(long id) - { - switch ((IssuesContextMenuOption)id) - { - 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"); - } - } - - private void IssuesListClicked(long index, Vector2 atPosition, long mouseButtonIndex) - { - if (mouseButtonIndex != (long)MouseButton.Right) - { - 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(); - } + if (!string.IsNullOrEmpty(text)) + DisplayServer.ClipboardSet(text); } public override void _Ready() { - base._Ready(); + Name = "Output".TTR(); - SizeFlagsVertical = SizeFlags.ExpandFill; + var vbLeft = new VBoxContainer + { + CustomMinimumSize = new Vector2(0, 180 * EditorScale), + SizeFlagsVertical = SizeFlags.ExpandFill, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + AddChild(vbLeft); - var hsc = new HSplitContainer + // Log - Rich Text Label. + _log = new RichTextLabel + { + BbcodeEnabled = true, + ScrollFollowing = true, + SelectionEnabled = true, + ContextMenuEnabled = true, + FocusMode = FocusModeEnum.Click, + SizeFlagsVertical = SizeFlags.ExpandFill, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + DeselectOnFocusLossEnabled = false, + + }; + vbLeft.AddChild(_log); + + var vbRight = new VBoxContainer(); + AddChild(vbRight); + + // Tools grid + var hbTools = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill, - SizeFlagsVertical = SizeFlags.ExpandFill }; - AddChild(hsc); + vbRight.AddChild(hbTools); - _issuesList = new ItemList + // Clear. + _clearButton = new Button { - SizeFlagsVertical = SizeFlags.ExpandFill, - SizeFlagsHorizontal = SizeFlags.ExpandFill // Avoid being squashed by the build log + ThemeTypeVariation = "FlatButton", + FocusMode = FocusModeEnum.None, + Shortcut = EditorDefShortcut("editor/clear_output", "Clear Output".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | (Key)KeyModifierMask.MaskShift | Key.K), }; - _issuesList.ItemActivated += IssueActivated; - _issuesList.AllowRmbSelect = true; - _issuesList.ItemClicked += IssuesListClicked; - hsc.AddChild(_issuesList); + _clearButton.Pressed += Clear; + hbTools.AddChild(_clearButton); - _issuesListContextMenu = new PopupMenu(); - _issuesListContextMenu.IdPressed += IssuesListContextOptionPressed; - _issuesList.AddChild(_issuesListContextMenu); - - _buildLog = new TextEdit + // Copy. + _copyButton = new Button { - Editable = false, - SizeFlagsVertical = SizeFlags.ExpandFill, - SizeFlagsHorizontal = SizeFlags.ExpandFill // Avoid being squashed by the issues list + ThemeTypeVariation = "FlatButton", + FocusMode = FocusModeEnum.None, + Shortcut = EditorDefShortcut("editor/copy_output", "Copy Selection".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | Key.C), + ShortcutContext = this, }; - hsc.AddChild(_buildLog); + _copyButton.Pressed += CopyRequested; + hbTools.AddChild(_copyButton); - AddBuildEventListeners(); + UpdateTheme(); } - private void AddBuildEventListeners() + public override void _Notification(int what) { - 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; + base._Notification(what); + + if (what == NotificationThemeChanged) + { + UpdateTheme(); + } } - public void OnBeforeSerialize() + private void UpdateTheme() { - // In case it didn't update yet. We don't want to have to serialize any pending output. - UpdateBuildLogText(); + // Nodes will be null until _Ready is called. + if (_log == null) + return; - // 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; - } + var normalFont = GetThemeFont("output_source", "EditorFonts"); + if (normalFont != null) + _log.AddThemeFontOverride("normal_font", normalFont); - public void OnAfterDeserialize() - { - AddBuildEventListeners(); // Re-add them + var boldFont = GetThemeFont("output_source_bold", "EditorFonts"); + if (boldFont != null) + _log.AddThemeFontOverride("bold_font", boldFont); + + var italicsFont = GetThemeFont("output_source_italic", "EditorFonts"); + if (italicsFont != null) + _log.AddThemeFontOverride("italics_font", italicsFont); + + var boldItalicsFont = GetThemeFont("output_source_bold_italic", "EditorFonts"); + if (boldItalicsFont != null) + _log.AddThemeFontOverride("bold_italics_font", boldItalicsFont); + + var monoFont = GetThemeFont("output_source_mono", "EditorFonts"); + if (monoFont != null) + _log.AddThemeFontOverride("mono_font", monoFont); + + // 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); + + 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); + + _clearButton.Icon = GetThemeIcon("Clear", "EditorIcons"); + _copyButton.Icon = GetThemeIcon("ActionCopy", "EditorIcons"); } } } diff --git a/modules/mono/editor/GodotTools/GodotTools/Build/BuildProblemsFilter.cs b/modules/mono/editor/GodotTools/GodotTools/Build/BuildProblemsFilter.cs new file mode 100644 index 000000000000..9c165e57674c --- /dev/null +++ b/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", + }; + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools/Build/BuildProblemsView.cs b/modules/mono/editor/GodotTools/GodotTools/Build/BuildProblemsView.cs new file mode 100644 index 000000000000..b23b3f42ef7b --- /dev/null +++ b/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 _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 _diagnostics = new(); + + public int TotalDiagnosticCount => _diagnostics.Count; + + private readonly Dictionary _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 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 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(); + 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 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(); + + 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(); + _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"); + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools/Build/MSBuildPanel.cs b/modules/mono/editor/GodotTools/GodotTools/Build/MSBuildPanel.cs index cc11132a553a..bae87dd1ddf2 100644 --- a/modules/mono/editor/GodotTools/GodotTools/Build/MSBuildPanel.cs +++ b/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(); - private MenuButton _buildMenuBtn; - private Button _errorsBtn; - private Button _warningsBtn; - private Button _viewLogBtn; - private Button _openLogsFolderBtn; +#nullable disable + private MenuButton _buildMenuButton; + private Button _openLogsFolderButton; - private void WarningsToggled(bool pressed) + 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 readonly object _pendingBuildLogTextLock = new object(); + private string _pendingBuildLogText = string.Empty; + + 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 toolBarHBox = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; - AddChild(toolBarHBox); + var tabs = new TabContainer(); + AddChild(tabs); - _buildMenuBtn = new MenuButton { Text = "Build", Icon = GetThemeIcon("BuildCSharp", "EditorIcons") }; - toolBarHBox.AddChild(_buildMenuBtn); + var tabActions = new HBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + Alignment = BoxContainer.AlignmentMode.End, + }; + tabActions.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + tabs.GetTabBar().AddChild(tabActions); - var buildMenu = _buildMenuBtn.GetPopup(); + _buildMenuButton = new MenuButton + { + TooltipText = "Build".TTR(), + Flat = true, + }; + tabActions.AddChild(_buildMenuButton); + + 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); + _outputView = new BuildOutputView(); + tabs.AddChild(_outputView); - // Horizontal spacer, push everything to the right. - toolBarHBox.AddChild(new Control - { - SizeFlagsHorizontal = SizeFlags.ExpandFill, - }); + UpdateTheme(); - _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); - - 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. + } } } diff --git a/modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs b/modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs index e186c0302bb1..48e654c2866f 100644 --- a/modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs +++ b/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;