BuildOutputView.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. using Godot;
  2. using System;
  3. using Godot.Collections;
  4. using GodotTools.Internals;
  5. using JetBrains.Annotations;
  6. using File = GodotTools.Utils.File;
  7. using Path = System.IO.Path;
  8. namespace GodotTools.Build
  9. {
  10. public class BuildOutputView : VBoxContainer, ISerializationListener
  11. {
  12. [Serializable]
  13. private class BuildIssue : RefCounted // TODO Remove RefCounted once we have proper serialization
  14. {
  15. public bool Warning { get; set; }
  16. public string File { get; set; }
  17. public int Line { get; set; }
  18. public int Column { get; set; }
  19. public string Code { get; set; }
  20. public string Message { get; set; }
  21. public string ProjectFile { get; set; }
  22. }
  23. [Signal] public event Action BuildStateChanged;
  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 readonly Array<BuildIssue> _issues = new Array<BuildIssue>();
  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)(long)_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 = issue.ProjectFile.Length > 0 ? issue.ProjectFile.GetBaseDir() : _buildInfo.Solution.GetBaseDir();
  109. string file = Path.Combine(projectDir.SimplifyGodotPath(), issue.File.SimplifyGodotPath());
  110. if (!File.Exists(file))
  111. return;
  112. file = ProjectSettings.LocalizePath(file);
  113. if (file.StartsWith("res://"))
  114. {
  115. var script = (Script)ResourceLoader.Load(file, typeHint: Internal.CSharpLanguageType);
  116. if (script != null && Internal.ScriptEditorEdit(script, issue.Line, issue.Column))
  117. Internal.EditorNodeShowScriptScreen();
  118. }
  119. }
  120. public void UpdateIssuesList()
  121. {
  122. _issuesList.Clear();
  123. using (var warningIcon = GetThemeIcon("Warning", "EditorIcons"))
  124. using (var errorIcon = GetThemeIcon("Error", "EditorIcons"))
  125. {
  126. for (int i = 0; i < _issues.Count; i++)
  127. {
  128. BuildIssue issue = _issues[i];
  129. if (!(issue.Warning ? WarningsVisible : ErrorsVisible))
  130. continue;
  131. string tooltip = string.Empty;
  132. tooltip += $"Message: {issue.Message}";
  133. if (!string.IsNullOrEmpty(issue.Code))
  134. tooltip += $"\nCode: {issue.Code}";
  135. tooltip += $"\nType: {(issue.Warning ? "warning" : "error")}";
  136. string text = string.Empty;
  137. if (!string.IsNullOrEmpty(issue.File))
  138. {
  139. text += $"{issue.File}({issue.Line},{issue.Column}): ";
  140. tooltip += $"\nFile: {issue.File}";
  141. tooltip += $"\nLine: {issue.Line}";
  142. tooltip += $"\nColumn: {issue.Column}";
  143. }
  144. if (!string.IsNullOrEmpty(issue.ProjectFile))
  145. tooltip += $"\nProject: {issue.ProjectFile}";
  146. text += issue.Message;
  147. int lineBreakIdx = text.IndexOf("\n", StringComparison.Ordinal);
  148. string itemText = lineBreakIdx == -1 ? text : text.Substring(0, lineBreakIdx);
  149. _issuesList.AddItem(itemText, issue.Warning ? warningIcon : errorIcon);
  150. int index = _issuesList.ItemCount - 1;
  151. _issuesList.SetItemTooltip(index, tooltip);
  152. _issuesList.SetItemMetadata(index, i);
  153. }
  154. }
  155. }
  156. private void BuildLaunchFailed(BuildInfo buildInfo, string cause)
  157. {
  158. HasBuildExited = true;
  159. BuildResult = Build.BuildResult.Error;
  160. _issuesList.Clear();
  161. var issue = new BuildIssue { Message = cause, Warning = false };
  162. ErrorCount += 1;
  163. _issues.Add(issue);
  164. UpdateIssuesList();
  165. EmitSignal(nameof(BuildStateChanged));
  166. }
  167. private void BuildStarted(BuildInfo buildInfo)
  168. {
  169. _buildInfo = buildInfo;
  170. HasBuildExited = false;
  171. _issues.Clear();
  172. WarningCount = 0;
  173. ErrorCount = 0;
  174. _buildLog.Text = string.Empty;
  175. UpdateIssuesList();
  176. EmitSignal(nameof(BuildStateChanged));
  177. }
  178. private void BuildFinished(BuildResult result)
  179. {
  180. HasBuildExited = true;
  181. BuildResult = result;
  182. LoadIssuesFromFile(Path.Combine(_buildInfo.LogsDirPath, BuildManager.MsBuildIssuesFileName));
  183. UpdateIssuesList();
  184. EmitSignal(nameof(BuildStateChanged));
  185. }
  186. private void UpdateBuildLogText()
  187. {
  188. lock (_pendingBuildLogTextLock)
  189. {
  190. _buildLog.Text += _pendingBuildLogText;
  191. _pendingBuildLogText = string.Empty;
  192. ScrollToLastNonEmptyLogLine();
  193. }
  194. }
  195. private void StdOutputReceived(string text)
  196. {
  197. lock (_pendingBuildLogTextLock)
  198. {
  199. if (_pendingBuildLogText.Length == 0)
  200. CallDeferred(nameof(UpdateBuildLogText));
  201. _pendingBuildLogText += text + "\n";
  202. }
  203. }
  204. private void StdErrorReceived(string text)
  205. {
  206. lock (_pendingBuildLogTextLock)
  207. {
  208. if (_pendingBuildLogText.Length == 0)
  209. CallDeferred(nameof(UpdateBuildLogText));
  210. _pendingBuildLogText += text + "\n";
  211. }
  212. }
  213. private void ScrollToLastNonEmptyLogLine()
  214. {
  215. int line;
  216. for (line = _buildLog.GetLineCount(); line > 0; line--)
  217. {
  218. string lineText = _buildLog.GetLine(line);
  219. if (!string.IsNullOrEmpty(lineText) || !string.IsNullOrEmpty(lineText?.Trim()))
  220. break;
  221. }
  222. _buildLog.SetCaretLine(line);
  223. }
  224. public void RestartBuild()
  225. {
  226. if (!HasBuildExited)
  227. throw new InvalidOperationException("Build already started");
  228. BuildManager.RestartBuild(this);
  229. }
  230. public void StopBuild()
  231. {
  232. if (!HasBuildExited)
  233. throw new InvalidOperationException("Build is not in progress");
  234. BuildManager.StopBuild(this);
  235. }
  236. private enum IssuesContextMenuOption
  237. {
  238. Copy
  239. }
  240. private void IssuesListContextOptionPressed(int id)
  241. {
  242. switch ((IssuesContextMenuOption)id)
  243. {
  244. case IssuesContextMenuOption.Copy:
  245. {
  246. // We don't allow multi-selection but just in case that changes later...
  247. string text = null;
  248. foreach (int issueIndex in _issuesList.GetSelectedItems())
  249. {
  250. if (text != null)
  251. text += "\n";
  252. text += _issuesList.GetItemText(issueIndex);
  253. }
  254. if (text != null)
  255. DisplayServer.ClipboardSet(text);
  256. break;
  257. }
  258. default:
  259. throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid issue context menu option");
  260. }
  261. }
  262. private void IssuesListRmbSelected(int index, Vector2 atPosition)
  263. {
  264. _ = index; // Unused
  265. _issuesListContextMenu.Clear();
  266. _issuesListContextMenu.Size = new Vector2i(1, 1);
  267. if (_issuesList.IsAnythingSelected())
  268. {
  269. // Add menu entries for the selected item
  270. _issuesListContextMenu.AddIconItem(GetThemeIcon("ActionCopy", "EditorIcons"),
  271. label: "Copy Error".TTR(), (int)IssuesContextMenuOption.Copy);
  272. }
  273. if (_issuesListContextMenu.ItemCount > 0)
  274. {
  275. _issuesListContextMenu.Position = (Vector2i)(_issuesList.GlobalPosition + atPosition);
  276. _issuesListContextMenu.Popup();
  277. }
  278. }
  279. public override void _Ready()
  280. {
  281. base._Ready();
  282. SizeFlagsVertical = (int)SizeFlags.ExpandFill;
  283. var hsc = new HSplitContainer
  284. {
  285. SizeFlagsHorizontal = (int)SizeFlags.ExpandFill,
  286. SizeFlagsVertical = (int)SizeFlags.ExpandFill
  287. };
  288. AddChild(hsc);
  289. _issuesList = new ItemList
  290. {
  291. SizeFlagsVertical = (int)SizeFlags.ExpandFill,
  292. SizeFlagsHorizontal = (int)SizeFlags.ExpandFill // Avoid being squashed by the build log
  293. };
  294. _issuesList.ItemActivated += IssueActivated;
  295. _issuesList.AllowRmbSelect = true;
  296. _issuesList.ItemRmbSelected += IssuesListRmbSelected;
  297. hsc.AddChild(_issuesList);
  298. _issuesListContextMenu = new PopupMenu();
  299. _issuesListContextMenu.IdPressed += IssuesListContextOptionPressed;
  300. _issuesList.AddChild(_issuesListContextMenu);
  301. _buildLog = new TextEdit
  302. {
  303. Editable = false,
  304. SizeFlagsVertical = (int)SizeFlags.ExpandFill,
  305. SizeFlagsHorizontal = (int)SizeFlags.ExpandFill // Avoid being squashed by the issues list
  306. };
  307. hsc.AddChild(_buildLog);
  308. AddBuildEventListeners();
  309. }
  310. private void AddBuildEventListeners()
  311. {
  312. BuildManager.BuildLaunchFailed += BuildLaunchFailed;
  313. BuildManager.BuildStarted += BuildStarted;
  314. BuildManager.BuildFinished += BuildFinished;
  315. // StdOutput/Error can be received from different threads, so we need to use CallDeferred
  316. BuildManager.StdOutputReceived += StdOutputReceived;
  317. BuildManager.StdErrorReceived += StdErrorReceived;
  318. }
  319. public void OnBeforeSerialize()
  320. {
  321. // In case it didn't update yet. We don't want to have to serialize any pending output.
  322. UpdateBuildLogText();
  323. }
  324. public void OnAfterDeserialize()
  325. {
  326. AddBuildEventListeners(); // Re-add them
  327. }
  328. }
  329. }