50b603c7dc
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.
418 lines
14 KiB
C#
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
|
|
}
|
|
}
|
|
}
|