godot/modules/mono/editor/GodotTools/GodotTools/Build/BuildOutputView.cs
Ignacio Roldán Etcheverry 50b603c7dc C#: Begin move to .NET Core
We're targeting .NET 5 for now to make development easier while
.NET 6 is not yet released.

TEMPORARY REGRESSIONS
---------------------

Assembly unloading is not implemented yet. As such, many Godot
resources are leaked at exit. This will be re-implemented later
together with assembly hot-reloading.
2021-09-22 08:27:12 +02:00

418 lines
14 KiB
C#

using Godot;
using System;
using System.Diagnostics.CodeAnalysis;
using Godot.Collections;
using GodotTools.Internals;
using File = GodotTools.Utils.File;
using Path = System.IO.Path;
namespace GodotTools.Build
{
public class BuildOutputView : VBoxContainer, ISerializationListener
{
[Serializable]
private class BuildIssue : RefCounted // TODO Remove RefCounted once we have proper serialization
{
public bool Warning { get; set; }
public string File { get; set; }
public int Line { get; set; }
public int Column { get; set; }
public string Code { get; set; }
public string Message { get; set; }
public string ProjectFile { get; set; }
}
private readonly Array<BuildIssue> issues = new Array<BuildIssue>(); // TODO Use List once we have proper serialization
private ItemList issuesList;
private TextEdit buildLog;
private PopupMenu issuesListContextMenu;
private readonly object pendingBuildLogTextLock = new object();
[NotNull] private string pendingBuildLogText = string.Empty;
[Signal] public event Action BuildStateChanged;
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
{
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;
}
}
private BuildInfo BuildInfo { get; set; }
public bool LogVisible
{
set => buildLog.Visible = value;
}
private void LoadIssuesFromFile(string csvFile)
{
using (var file = new Godot.File())
{
try
{
Error openError = file.Open(csvFile, Godot.File.ModeFlags.Read);
if (openError != Error.Ok)
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);
}
}
finally
{
file.Close(); // Disposing it is not enough. We need to call Close()
}
}
}
private void IssueActivated(int idx)
{
if (idx < 0 || idx >= issuesList.GetItemCount())
throw new IndexOutOfRangeException("Item list index out of range");
// Get correct issue idx from issue list
int issueIndex = (int)(long)issuesList.GetItemMetadata(idx);
if (issueIndex < 0 || issueIndex >= issues.Count)
throw new IndexOutOfRangeException("Issue index out of range");
BuildIssue issue = issues[issueIndex];
if (string.IsNullOrEmpty(issue.ProjectFile) && string.IsNullOrEmpty(issue.File))
return;
string projectDir = issue.ProjectFile.Length > 0 ? 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);
if (script != null && Internal.ScriptEditorEdit(script, issue.Line, issue.Column))
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.GetItemCount() - 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(int 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 IssuesListRmbSelected(int index, Vector2 atPosition)
{
_ = 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.GetItemCount() > 0)
{
issuesListContextMenu.Position = (Vector2i)(issuesList.RectGlobalPosition + atPosition);
issuesListContextMenu.Popup();
}
}
public override void _Ready()
{
base._Ready();
SizeFlagsVertical = (int)SizeFlags.ExpandFill;
var hsc = new HSplitContainer
{
SizeFlagsHorizontal = (int)SizeFlags.ExpandFill,
SizeFlagsVertical = (int)SizeFlags.ExpandFill
};
AddChild(hsc);
issuesList = new ItemList
{
SizeFlagsVertical = (int)SizeFlags.ExpandFill,
SizeFlagsHorizontal = (int)SizeFlags.ExpandFill // Avoid being squashed by the build log
};
issuesList.ItemActivated += IssueActivated;
issuesList.AllowRmbSelect = true;
issuesList.ItemRmbSelected += IssuesListRmbSelected;
hsc.AddChild(issuesList);
issuesListContextMenu = new PopupMenu();
issuesListContextMenu.IdPressed += IssuesListContextOptionPressed;
issuesList.AddChild(issuesListContextMenu);
buildLog = new TextEdit
{
Editable = false,
SizeFlagsVertical = (int)SizeFlags.ExpandFill,
SizeFlagsHorizontal = (int)SizeFlags.ExpandFill // Avoid being squashed by the issues list
};
hsc.AddChild(buildLog);
AddBuildEventListeners();
}
private void AddBuildEventListeners()
{
BuildManager.BuildLaunchFailed += BuildLaunchFailed;
BuildManager.BuildStarted += BuildStarted;
BuildManager.BuildFinished += BuildFinished;
// StdOutput/Error can be received from different threads, so we need to use CallDeferred
BuildManager.StdOutputReceived += StdOutputReceived;
BuildManager.StdErrorReceived += StdErrorReceived;
}
public void OnBeforeSerialize()
{
// In case it didn't update yet. We don't want to have to serialize any pending output.
UpdateBuildLogText();
}
public void OnAfterDeserialize()
{
AddBuildEventListeners(); // Re-add them
}
}
}