BuildOutputView.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. using Godot;
  2. using System;
  3. using System.Diagnostics.CodeAnalysis;
  4. using GodotTools.Internals;
  5. using File = GodotTools.Utils.File;
  6. using Path = System.IO.Path;
  7. namespace GodotTools.Build
  8. {
  9. public partial class BuildOutputView : VBoxContainer, ISerializationListener
  10. {
  11. [Serializable]
  12. private partial class BuildIssue : RefCounted // TODO Remove RefCounted once we have proper serialization
  13. {
  14. public bool Warning { get; set; }
  15. public string File { get; set; }
  16. public int Line { get; set; }
  17. public int Column { get; set; }
  18. public string Code { get; set; }
  19. public string Message { get; set; }
  20. public string ProjectFile { get; set; }
  21. }
  22. [Signal]
  23. public delegate void BuildStateChangedEventHandler();
  24. public bool HasBuildExited { get; private set; } = false;
  25. public BuildResult? BuildResult { get; private set; } = null;
  26. public int ErrorCount { get; private set; } = 0;
  27. public int WarningCount { get; private set; } = 0;
  28. public bool ErrorsVisible { get; set; } = true;
  29. public bool WarningsVisible { get; set; } = true;
  30. public Texture2D BuildStateIcon
  31. {
  32. get
  33. {
  34. if (!HasBuildExited)
  35. return GetThemeIcon("Stop", "EditorIcons");
  36. if (BuildResult == Build.BuildResult.Error)
  37. return GetThemeIcon("Error", "EditorIcons");
  38. if (WarningCount > 1)
  39. return GetThemeIcon("Warning", "EditorIcons");
  40. return null;
  41. }
  42. }
  43. public bool LogVisible
  44. {
  45. set => _buildLog.Visible = value;
  46. }
  47. // TODO Use List once we have proper serialization.
  48. private Godot.Collections.Array<BuildIssue> _issues = new();
  49. private ItemList _issuesList;
  50. private PopupMenu _issuesListContextMenu;
  51. private TextEdit _buildLog;
  52. private BuildInfo _buildInfo;
  53. private readonly object _pendingBuildLogTextLock = new object();
  54. [NotNull] private string _pendingBuildLogText = string.Empty;
  55. private void LoadIssuesFromFile(string csvFile)
  56. {
  57. using (var file = new Godot.File())
  58. {
  59. try
  60. {
  61. Error openError = file.Open(csvFile, Godot.File.ModeFlags.Read);
  62. if (openError != Error.Ok)
  63. return;
  64. while (!file.EofReached())
  65. {
  66. string[] csvColumns = file.GetCsvLine();
  67. if (csvColumns.Length == 1 && string.IsNullOrEmpty(csvColumns[0]))
  68. return;
  69. if (csvColumns.Length != 7)
  70. {
  71. GD.PushError($"Expected 7 columns, got {csvColumns.Length}");
  72. continue;
  73. }
  74. var issue = new BuildIssue
  75. {
  76. Warning = csvColumns[0] == "warning",
  77. File = csvColumns[1],
  78. Line = int.Parse(csvColumns[2]),
  79. Column = int.Parse(csvColumns[3]),
  80. Code = csvColumns[4],
  81. Message = csvColumns[5],
  82. ProjectFile = csvColumns[6]
  83. };
  84. if (issue.Warning)
  85. WarningCount += 1;
  86. else
  87. ErrorCount += 1;
  88. _issues.Add(issue);
  89. }
  90. }
  91. finally
  92. {
  93. file.Close(); // Disposing it is not enough. We need to call Close()
  94. }
  95. }
  96. }
  97. private void IssueActivated(int idx)
  98. {
  99. if (idx < 0 || idx >= _issuesList.ItemCount)
  100. throw new IndexOutOfRangeException("Item list index out of range");
  101. // Get correct issue idx from issue list
  102. int issueIndex = (int)_issuesList.GetItemMetadata(idx);
  103. if (issueIndex < 0 || issueIndex >= _issues.Count)
  104. throw new IndexOutOfRangeException("Issue index out of range");
  105. BuildIssue issue = _issues[issueIndex];
  106. if (string.IsNullOrEmpty(issue.ProjectFile) && string.IsNullOrEmpty(issue.File))
  107. return;
  108. string projectDir = !string.IsNullOrEmpty(issue.ProjectFile) ?
  109. issue.ProjectFile.GetBaseDir() :
  110. _buildInfo.Solution.GetBaseDir();
  111. string file = Path.Combine(projectDir.SimplifyGodotPath(), issue.File.SimplifyGodotPath());
  112. if (!File.Exists(file))
  113. return;
  114. file = ProjectSettings.LocalizePath(file);
  115. if (file.StartsWith("res://"))
  116. {
  117. var script = (Script)ResourceLoader.Load(file, typeHint: Internal.CSharpLanguageType);
  118. if (script != null && Internal.ScriptEditorEdit(script, issue.Line, issue.Column))
  119. Internal.EditorNodeShowScriptScreen();
  120. }
  121. }
  122. public void UpdateIssuesList()
  123. {
  124. _issuesList.Clear();
  125. using (var warningIcon = GetThemeIcon("Warning", "EditorIcons"))
  126. using (var errorIcon = GetThemeIcon("Error", "EditorIcons"))
  127. {
  128. for (int i = 0; i < _issues.Count; i++)
  129. {
  130. BuildIssue issue = _issues[i];
  131. if (!(issue.Warning ? WarningsVisible : ErrorsVisible))
  132. continue;
  133. string tooltip = string.Empty;
  134. tooltip += $"Message: {issue.Message}";
  135. if (!string.IsNullOrEmpty(issue.Code))
  136. tooltip += $"\nCode: {issue.Code}";
  137. tooltip += $"\nType: {(issue.Warning ? "warning" : "error")}";
  138. string text = string.Empty;
  139. if (!string.IsNullOrEmpty(issue.File))
  140. {
  141. text += $"{issue.File}({issue.Line},{issue.Column}): ";
  142. tooltip += $"\nFile: {issue.File}";
  143. tooltip += $"\nLine: {issue.Line}";
  144. tooltip += $"\nColumn: {issue.Column}";
  145. }
  146. if (!string.IsNullOrEmpty(issue.ProjectFile))
  147. tooltip += $"\nProject: {issue.ProjectFile}";
  148. text += issue.Message;
  149. int lineBreakIdx = text.IndexOf("\n", StringComparison.Ordinal);
  150. string itemText = lineBreakIdx == -1 ? text : text.Substring(0, lineBreakIdx);
  151. _issuesList.AddItem(itemText, issue.Warning ? warningIcon : errorIcon);
  152. int index = _issuesList.ItemCount - 1;
  153. _issuesList.SetItemTooltip(index, tooltip);
  154. _issuesList.SetItemMetadata(index, i);
  155. }
  156. }
  157. }
  158. private void BuildLaunchFailed(BuildInfo buildInfo, string cause)
  159. {
  160. HasBuildExited = true;
  161. BuildResult = Build.BuildResult.Error;
  162. _issuesList.Clear();
  163. var issue = new BuildIssue { Message = cause, Warning = false };
  164. ErrorCount += 1;
  165. _issues.Add(issue);
  166. UpdateIssuesList();
  167. EmitSignal(nameof(BuildStateChanged));
  168. }
  169. private void BuildStarted(BuildInfo buildInfo)
  170. {
  171. _buildInfo = buildInfo;
  172. HasBuildExited = false;
  173. _issues.Clear();
  174. WarningCount = 0;
  175. ErrorCount = 0;
  176. _buildLog.Text = string.Empty;
  177. UpdateIssuesList();
  178. EmitSignal(nameof(BuildStateChanged));
  179. }
  180. private void BuildFinished(BuildResult result)
  181. {
  182. HasBuildExited = true;
  183. BuildResult = result;
  184. LoadIssuesFromFile(Path.Combine(_buildInfo.LogsDirPath, BuildManager.MsBuildIssuesFileName));
  185. UpdateIssuesList();
  186. EmitSignal(nameof(BuildStateChanged));
  187. }
  188. private void UpdateBuildLogText()
  189. {
  190. lock (_pendingBuildLogTextLock)
  191. {
  192. _buildLog.Text += _pendingBuildLogText;
  193. _pendingBuildLogText = string.Empty;
  194. ScrollToLastNonEmptyLogLine();
  195. }
  196. }
  197. private void StdOutputReceived(string text)
  198. {
  199. lock (_pendingBuildLogTextLock)
  200. {
  201. if (_pendingBuildLogText.Length == 0)
  202. CallDeferred(nameof(UpdateBuildLogText));
  203. _pendingBuildLogText += text + "\n";
  204. }
  205. }
  206. private void StdErrorReceived(string text)
  207. {
  208. lock (_pendingBuildLogTextLock)
  209. {
  210. if (_pendingBuildLogText.Length == 0)
  211. CallDeferred(nameof(UpdateBuildLogText));
  212. _pendingBuildLogText += text + "\n";
  213. }
  214. }
  215. private void ScrollToLastNonEmptyLogLine()
  216. {
  217. int line;
  218. for (line = _buildLog.GetLineCount(); line > 0; line--)
  219. {
  220. string lineText = _buildLog.GetLine(line);
  221. if (!string.IsNullOrEmpty(lineText) || !string.IsNullOrEmpty(lineText?.Trim()))
  222. break;
  223. }
  224. _buildLog.SetCaretLine(line);
  225. }
  226. public void RestartBuild()
  227. {
  228. if (!HasBuildExited)
  229. throw new InvalidOperationException("Build already started");
  230. BuildManager.RestartBuild(this);
  231. }
  232. public void StopBuild()
  233. {
  234. if (!HasBuildExited)
  235. throw new InvalidOperationException("Build is not in progress");
  236. BuildManager.StopBuild(this);
  237. }
  238. private enum IssuesContextMenuOption
  239. {
  240. Copy
  241. }
  242. private void IssuesListContextOptionPressed(int id)
  243. {
  244. switch ((IssuesContextMenuOption)id)
  245. {
  246. case IssuesContextMenuOption.Copy:
  247. {
  248. // We don't allow multi-selection but just in case that changes later...
  249. string text = null;
  250. foreach (int issueIndex in _issuesList.GetSelectedItems())
  251. {
  252. if (text != null)
  253. text += "\n";
  254. text += _issuesList.GetItemText(issueIndex);
  255. }
  256. if (text != null)
  257. DisplayServer.ClipboardSet(text);
  258. break;
  259. }
  260. default:
  261. throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid issue context menu option");
  262. }
  263. }
  264. private void IssuesListClicked(int index, Vector2 atPosition, int mouseButtonIndex)
  265. {
  266. if (mouseButtonIndex != (int)MouseButton.Right)
  267. {
  268. return;
  269. }
  270. _ = index; // Unused
  271. _issuesListContextMenu.Clear();
  272. _issuesListContextMenu.Size = new Vector2i(1, 1);
  273. if (_issuesList.IsAnythingSelected())
  274. {
  275. // Add menu entries for the selected item
  276. _issuesListContextMenu.AddIconItem(GetThemeIcon("ActionCopy", "EditorIcons"),
  277. label: "Copy Error".TTR(), (int)IssuesContextMenuOption.Copy);
  278. }
  279. if (_issuesListContextMenu.ItemCount > 0)
  280. {
  281. _issuesListContextMenu.Position = (Vector2i)(_issuesList.GlobalPosition + atPosition);
  282. _issuesListContextMenu.Popup();
  283. }
  284. }
  285. public override void _Ready()
  286. {
  287. base._Ready();
  288. SizeFlagsVertical = (int)SizeFlags.ExpandFill;
  289. var hsc = new HSplitContainer
  290. {
  291. SizeFlagsHorizontal = (int)SizeFlags.ExpandFill,
  292. SizeFlagsVertical = (int)SizeFlags.ExpandFill
  293. };
  294. AddChild(hsc);
  295. _issuesList = new ItemList
  296. {
  297. SizeFlagsVertical = (int)SizeFlags.ExpandFill,
  298. SizeFlagsHorizontal = (int)SizeFlags.ExpandFill // Avoid being squashed by the build log
  299. };
  300. _issuesList.ItemActivated += IssueActivated;
  301. _issuesList.AllowRmbSelect = true;
  302. _issuesList.ItemClicked += IssuesListClicked;
  303. hsc.AddChild(_issuesList);
  304. _issuesListContextMenu = new PopupMenu();
  305. _issuesListContextMenu.IdPressed += IssuesListContextOptionPressed;
  306. _issuesList.AddChild(_issuesListContextMenu);
  307. _buildLog = new TextEdit
  308. {
  309. Editable = false,
  310. SizeFlagsVertical = (int)SizeFlags.ExpandFill,
  311. SizeFlagsHorizontal = (int)SizeFlags.ExpandFill // Avoid being squashed by the issues list
  312. };
  313. hsc.AddChild(_buildLog);
  314. AddBuildEventListeners();
  315. }
  316. private void AddBuildEventListeners()
  317. {
  318. BuildManager.BuildLaunchFailed += BuildLaunchFailed;
  319. BuildManager.BuildStarted += BuildStarted;
  320. BuildManager.BuildFinished += BuildFinished;
  321. // StdOutput/Error can be received from different threads, so we need to use CallDeferred
  322. BuildManager.StdOutputReceived += StdOutputReceived;
  323. BuildManager.StdErrorReceived += StdErrorReceived;
  324. }
  325. public void OnBeforeSerialize()
  326. {
  327. // In case it didn't update yet. We don't want to have to serialize any pending output.
  328. UpdateBuildLogText();
  329. // NOTE:
  330. // Currently, GodotTools is loaded in its own load context. This load context is not reloaded, but the script still are.
  331. // Until that changes, we need workarounds like this one because events keep strong references to disposed objects.
  332. BuildManager.BuildLaunchFailed -= BuildLaunchFailed;
  333. BuildManager.BuildStarted -= BuildStarted;
  334. BuildManager.BuildFinished -= BuildFinished;
  335. // StdOutput/Error can be received from different threads, so we need to use CallDeferred
  336. BuildManager.StdOutputReceived -= StdOutputReceived;
  337. BuildManager.StdErrorReceived -= StdErrorReceived;
  338. }
  339. public void OnAfterDeserialize()
  340. {
  341. AddBuildEventListeners(); // Re-add them
  342. }
  343. }
  344. }