Merge pull request #80260 from raulsntos/dotnet/msbuild-panel
C#: Redesign MSBuild panel
This commit is contained in:
commit
c7a5a284d3
8 changed files with 1080 additions and 482 deletions
1
editor/icons/FileTree.svg
Normal file
1
editor/icons/FileTree.svg
Normal file
|
@ -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>
|
After Width: | Height: | Size: 159 B |
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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<BuildIssue> _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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in a new issue