ArbitratorController.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. // Copyright (c) 2025 TerraByte Inc.
  2. //
  3. // This script acts as the Controller for the ArbitratorWindow. It manages all
  4. // state and business logic, separating it from the UI rendering code in the window.
  5. using System;
  6. using GitMerge;
  7. using System.Linq;
  8. using UnityEditor;
  9. using UnityEngine.Scripting;
  10. using Terra.Arbitrator.Settings;
  11. using Terra.Arbitrator.Services;
  12. using Terra.Arbitrator.Promises;
  13. using System.Collections.Generic;
  14. using UnityEditor.SceneManagement;
  15. namespace Terra.Arbitrator.GUI
  16. {
  17. public enum UserAction { Proceed, SaveAndProceed, Cancel }
  18. public enum SortColumn { Commit, Status, FilePath }
  19. [Preserve]
  20. public class ArbitratorController
  21. {
  22. private List<GitChange> _changes = new();
  23. public IReadOnlyList<GitChange> Changes => _changes;
  24. public string InfoMessage { get; private set; }
  25. public string ErrorMessage { get; private set; }
  26. public bool IsLoading { get; private set; }
  27. public string LoadingMessage { get; private set; } = "";
  28. public bool IsInConflictState { get; private set; }
  29. public bool HasStash { get; private set; }
  30. public int CommitsToPull { get; private set; }
  31. public SortColumn CurrentSortColumn { get; private set; } = SortColumn.FilePath;
  32. public float OperationProgress { get; private set; }
  33. public string OperationProgressMessage { get; private set; }
  34. public IReadOnlyList<string> RemoteBranchList { get; private set; } = new List<string>();
  35. public string CurrentBranchName { get; private set; } = "master";
  36. private readonly Action _requestRepaint;
  37. private readonly Func<string, string, string, string, bool> _displayDialog;
  38. private readonly Func<UserAction> _promptForUnsavedChanges;
  39. /// <summary>
  40. /// Initializes the controller.
  41. /// </summary>
  42. /// <param name="requestRepaint">A callback to the window's Repaint() method.</param>
  43. /// <param name="displayDialog">A callback to EditorUtility.DisplayDialog for user confirmations.</param>
  44. /// <param name="promptForUnsavedChanges">A callback to EditorUtility.DisplayDialog for user confirmations.</param>
  45. public ArbitratorController(Action requestRepaint, Func<string, string, string, string, bool> displayDialog, Func<UserAction> promptForUnsavedChanges)
  46. {
  47. _requestRepaint = requestRepaint;
  48. _displayDialog = displayDialog;
  49. _promptForUnsavedChanges = promptForUnsavedChanges;
  50. }
  51. public void OnEnable()
  52. {
  53. Refresh();
  54. }
  55. public void Refresh()
  56. {
  57. StartOperation("Refreshing status...");
  58. CommitsToPull = 0;
  59. UnstageStep()
  60. .Then(CompareStep)
  61. .Then(FetchUpstreamStep)
  62. .Then(CheckForStashStep)
  63. .Then(FetchBranchDataStep)
  64. .Then(FinalizeRefresh)
  65. .Catch(HandleOperationError)
  66. .Finally(FinishOperation);
  67. }
  68. public void Pull()
  69. {
  70. if (IsLoading) return;
  71. if (CancelOperationIfUnsavedScenes()) return;
  72. if (CommitsToPull > 0)
  73. {
  74. if (!_displayDialog("Confirm Pull", $"There are {CommitsToPull} incoming changes. Are you sure you want to pull?", "Yes, Pull", "Cancel"))
  75. {
  76. return;
  77. }
  78. }
  79. StartOperation("Analyzing for conflicts...");
  80. GitService.AnalyzePullConflicts()
  81. .Then(analysisResult =>
  82. {
  83. FinishOperation();
  84. if (analysisResult.HasConflicts)
  85. {
  86. var conflictingChanges = _changes.Where(c => analysisResult.ConflictingFiles.Contains(c.FilePath)).ToList();
  87. ConflictResolutionWindow.ShowWindow(this, conflictingChanges, new PullConflictSource());
  88. }
  89. else
  90. {
  91. PerformSafePullWithLock();
  92. }
  93. })
  94. .Catch(ex => {
  95. HandleOperationError(ex);
  96. FinishOperation();
  97. })
  98. .Finally(FinishOperation);
  99. }
  100. private void PerformSafePullWithLock()
  101. {
  102. StartOperation("Pulling changes...");
  103. EditorApplication.LockReloadAssemblies();
  104. GitService.PerformSafePull()
  105. .Then(successMessage =>
  106. {
  107. InfoMessage = successMessage;
  108. Refresh();
  109. })
  110. .Catch(ex => {
  111. HandleOperationError(ex);
  112. FinishOperation();
  113. })
  114. .Finally(EditorApplication.UnlockReloadAssemblies);
  115. }
  116. public void CommitAndPush(string commitMessage)
  117. {
  118. if (CancelOperationIfUnsavedScenes()) return;
  119. var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
  120. var username = BetterGitSettings.Username;
  121. var email = BetterGitSettings.Email;
  122. StartOperation("Staging, committing, and pushing files...");
  123. GitService.CommitAndPush(selectedFiles, commitMessage, username, email, OnProgressModified)
  124. .Then(successMessage => {
  125. InfoMessage = successMessage;
  126. Refresh();
  127. })
  128. .Catch(ex => {
  129. HandleOperationError(ex);
  130. FinishOperation();
  131. });
  132. return;
  133. void OnProgressModified(float progress, string message)
  134. {
  135. OperationProgress = progress;
  136. OperationProgressMessage = message;
  137. _requestRepaint?.Invoke();
  138. }
  139. }
  140. public void SetSortColumn(SortColumn newColumn)
  141. {
  142. // If it's already the active column, do nothing.
  143. if (CurrentSortColumn == newColumn) return;
  144. CurrentSortColumn = newColumn;
  145. ApplyGrouping();
  146. _requestRepaint?.Invoke();
  147. }
  148. private void ApplyGrouping()
  149. {
  150. if (_changes == null || !_changes.Any()) return;
  151. _changes = CurrentSortColumn switch
  152. {
  153. SortColumn.Commit => _changes.OrderByDescending(c => c.IsSelectedForCommit).ThenBy(c => c.FilePath).ToList(),
  154. SortColumn.Status => _changes.OrderBy(c => GetStatusSortPriority(c.Status)).ThenBy(c => c.FilePath).ToList(),
  155. SortColumn.FilePath => _changes.OrderBy(c => c.FilePath).ToList(),
  156. _ => _changes
  157. };
  158. }
  159. private static int GetStatusSortPriority(LibGit2Sharp.ChangeKind status)
  160. {
  161. return status switch
  162. {
  163. LibGit2Sharp.ChangeKind.Conflicted => -1, // Always show conflicts on top
  164. LibGit2Sharp.ChangeKind.Modified => 0,
  165. LibGit2Sharp.ChangeKind.Added => 1,
  166. LibGit2Sharp.ChangeKind.Deleted => 2,
  167. LibGit2Sharp.ChangeKind.Renamed => 3,
  168. _ => 99
  169. };
  170. }
  171. public void ResetFile(GitChange change)
  172. {
  173. if (IsLoading) return;
  174. var userConfirmed = _displayDialog("Confirm Reset", $"Are you sure you want to revert all local changes to '{change.FilePath}'? This action cannot be undone.", "Yes, Revert", "Cancel");
  175. if (!userConfirmed) return;
  176. StartOperation($"Resetting {change.FilePath}...");
  177. GitService.ResetFileChanges(change)
  178. .Then(successMessage => {
  179. InfoMessage = successMessage;
  180. Refresh();
  181. })
  182. .Catch(ex => {
  183. HandleOperationError(ex);
  184. FinishOperation();
  185. });
  186. }
  187. public void DiffFile(GitChange change)
  188. {
  189. if (IsLoading) return;
  190. StartOperation($"Launching diff for {change.FilePath}...");
  191. GitService.LaunchExternalDiff(change)
  192. .Catch(HandleOperationError)
  193. .Finally(Refresh);
  194. }
  195. public void ResolveConflict(GitChange change)
  196. {
  197. if (IsLoading) return;
  198. StartOperation($"Opening merge tool for {change.FilePath}...");
  199. var fileExtension = System.IO.Path.GetExtension(change.FilePath).ToLower();
  200. if (fileExtension is ".prefab" or ".unity")
  201. {
  202. try
  203. {
  204. GitMergeWindow.ResolveConflict(change.FilePath);
  205. }
  206. catch (Exception e)
  207. {
  208. ErrorMessage = e.Message;
  209. }
  210. Refresh();
  211. return;
  212. }
  213. GitService.LaunchMergeTool(change)
  214. .Then(successMessage => { InfoMessage = successMessage; })
  215. .Catch(HandleOperationError)
  216. .Finally(Refresh);
  217. }
  218. public void ResetSelected()
  219. {
  220. var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
  221. if (!selectedFiles.Any()) return;
  222. var fileList = string.Join("\n - ", selectedFiles.Select(f => f.FilePath));
  223. if (!_displayDialog("Confirm Reset Selected", $"Are you sure you want to revert changes for the following {selectedFiles.Count} file(s)?\n\n - {fileList}", "Yes, Revert Selected", "Cancel")) return;
  224. var pathsToReset = selectedFiles.Select(c => c.FilePath).ToList();
  225. ResetMultipleFiles(pathsToReset);
  226. }
  227. public void StashSelectedFiles()
  228. {
  229. var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
  230. if (!selectedFiles.Any())
  231. {
  232. InfoMessage = "No files selected to stash.";
  233. return;
  234. }
  235. EditorApplication.LockReloadAssemblies();
  236. StartOperation("Checking for existing stash...");
  237. GitService.HasStash()
  238. .Then(hasStash =>
  239. {
  240. FinishOperation();
  241. bool confirmed;
  242. if (hasStash)
  243. {
  244. confirmed = _displayDialog("Overwrite Stash?",
  245. "A 'Better Git Stash' already exists. Do you want to overwrite it with your currently selected files?",
  246. "Yes, Overwrite", "Cancel");
  247. }
  248. else
  249. {
  250. confirmed = _displayDialog("Create Stash?",
  251. $"Are you sure you want to stash the selected {selectedFiles.Count} file(s)?",
  252. "Yes, Stash", "Cancel");
  253. }
  254. if (!confirmed) return;
  255. StartOperation("Stashing selected files...");
  256. GitService.CreateOrOverwriteStash(selectedFiles)
  257. .Then(successMsg => { InfoMessage = successMsg; })
  258. .Catch(HandleOperationError)
  259. .Finally(() =>
  260. {
  261. Refresh();
  262. EditorApplication.delayCall += AssetDatabase.Refresh;
  263. });
  264. })
  265. .Catch(HandleOperationError)
  266. .Finally(() =>
  267. {
  268. FinishOperation();
  269. EditorApplication.delayCall += EditorApplication.UnlockReloadAssemblies;
  270. });
  271. }
  272. public void SetAllSelection(bool selected)
  273. {
  274. if (_changes == null) return;
  275. foreach (var change in _changes.Where(change => change.Status != LibGit2Sharp.ChangeKind.Conflicted))
  276. {
  277. change.IsSelectedForCommit = selected;
  278. }
  279. }
  280. public void SwitchToBranch(string targetBranch)
  281. {
  282. if (IsLoading || targetBranch == CurrentBranchName) return;
  283. if (Changes.Any())
  284. {
  285. if (!_displayDialog("Discard Local Changes?", $"You have local changes. To switch branches, these changes must be discarded.\n\nDiscard changes and switch to '{targetBranch}'?", "Yes, Discard and Switch", "Cancel"))
  286. {
  287. return;
  288. }
  289. StartOperation($"Discarding changes and switching to {targetBranch}...");
  290. GitService.ResetAndSwitchBranch(targetBranch)
  291. .Then(successMsg => { InfoMessage = successMsg; Refresh(); })
  292. .Catch(ex => { HandleOperationError(ex); FinishOperation(); })
  293. .Finally(() => { EditorApplication.delayCall += AssetDatabase.Refresh; });
  294. }
  295. else
  296. {
  297. if (!_displayDialog("Confirm Branch Switch", $"Are you sure you want to switch to branch '{targetBranch}'?", "Yes, Switch", "Cancel"))
  298. {
  299. return;
  300. }
  301. StartOperation($"Switching to {targetBranch}...");
  302. GitService.SwitchBranch(targetBranch)
  303. .Then(successMsg => { InfoMessage = successMsg; Refresh(); })
  304. .Catch(ex => { HandleOperationError(ex); FinishOperation(); })
  305. .Finally(() => { EditorApplication.delayCall += AssetDatabase.Refresh; });
  306. }
  307. }
  308. // --- Private Methods ---
  309. private static IPromise<bool> UnstageStep()
  310. {
  311. return GitService.UnstageAllFilesIfSafe();
  312. }
  313. private IPromise<List<GitChange>> CompareStep(bool wasUnstaged)
  314. {
  315. if (wasUnstaged)
  316. {
  317. InfoMessage = "Found and unstaged files for review.";
  318. }
  319. return GitService.CompareLocalToRemote();
  320. }
  321. private IPromise<int?> FetchUpstreamStep(List<GitChange> changes)
  322. {
  323. _changes = changes;
  324. IsInConflictState = _changes.Any(c => c.Status == LibGit2Sharp.ChangeKind.Conflicted);
  325. return IsInConflictState ? new Promise<int?>((resolve, _) => resolve(0)) : GitService.GetUpstreamAheadBy(OnProgressModified);
  326. void OnProgressModified(float progress, string message)
  327. {
  328. OperationProgress = progress;
  329. OperationProgressMessage = message;
  330. _requestRepaint?.Invoke();
  331. }
  332. }
  333. private IPromise<bool> CheckForStashStep(int? pullCount)
  334. {
  335. CommitsToPull = pullCount ?? 0;
  336. return GitService.HasStash();
  337. }
  338. private IPromise<BranchData> FetchBranchDataStep(bool hasStash)
  339. {
  340. HasStash = hasStash;
  341. OperationProgress = 0f;
  342. OperationProgressMessage = "";
  343. return GitService.GetBranchData();
  344. }
  345. private void FinalizeRefresh(BranchData branchData)
  346. {
  347. CurrentBranchName = branchData.CurrentBranch;
  348. RemoteBranchList = branchData.AllBranches;
  349. ApplyGrouping();
  350. }
  351. // --- Shared Helper Methods ---
  352. private void StartOperation(string loadingMessage)
  353. {
  354. IsLoading = true;
  355. LoadingMessage = loadingMessage;
  356. OperationProgress = 0f;
  357. OperationProgressMessage = "";
  358. ClearMessages();
  359. _changes = null;
  360. _requestRepaint?.Invoke();
  361. }
  362. private void HandleOperationError(Exception ex)
  363. {
  364. ErrorMessage = $"Operation Failed: {ex.Message}";
  365. }
  366. private void FinishOperation()
  367. {
  368. IsLoading = false;
  369. _requestRepaint?.Invoke();
  370. }
  371. private void ClearMessages()
  372. {
  373. ErrorMessage = null;
  374. InfoMessage = null;
  375. }
  376. private void ResetMultipleFiles(List<string> filePaths)
  377. {
  378. EditorApplication.LockReloadAssemblies();
  379. StartOperation($"Resetting {filePaths.Count} file(s)...");
  380. IPromise<string> promiseChain = new Promise<string>((resolve, _) => resolve(""));
  381. foreach (var path in filePaths)
  382. {
  383. promiseChain = promiseChain.Then(_ =>
  384. {
  385. var change = GitService.GetChangeForFile(path);
  386. return change != null ? GitService.ResetFileChanges(change) : new Promise<string>((res, _) => res(""));
  387. });
  388. }
  389. promiseChain
  390. .Then(_ =>
  391. {
  392. InfoMessage = $"Successfully reset {filePaths.Count} file(s).";
  393. Refresh();
  394. })
  395. .Catch(ex =>
  396. {
  397. HandleOperationError(ex);
  398. FinishOperation();
  399. })
  400. .Finally(() =>
  401. {
  402. EditorApplication.delayCall += () =>
  403. {
  404. EditorApplication.UnlockReloadAssemblies();
  405. AssetDatabase.Refresh();
  406. };
  407. });
  408. }
  409. public void ResolveConflicts(List<GitChange> resolutions, IConflictSource source)
  410. {
  411. StartOperation("Resolving conflicts...");
  412. EditorApplication.LockReloadAssemblies();
  413. source.Resolve(resolutions)
  414. .Then(successMessage =>
  415. {
  416. InfoMessage = successMessage;
  417. Refresh();
  418. })
  419. .Catch(HandleOperationError)
  420. .Finally(() =>
  421. {
  422. EditorApplication.delayCall += () =>
  423. {
  424. EditorApplication.UnlockReloadAssemblies();
  425. AssetDatabase.Refresh();
  426. };
  427. });
  428. }
  429. public void ShowStashedChangesWindow()
  430. {
  431. StartOperation("Loading stashed files...");
  432. GitService.GetStashedFiles()
  433. .Then(stashedFiles =>
  434. {
  435. if (stashedFiles.Any())
  436. {
  437. InfoMessage = "Fetching files from the stash.";
  438. StashedChangesWindow.ShowWindow(this, stashedFiles, Refresh);
  439. }
  440. else
  441. {
  442. InfoMessage = "No files found in the stash.";
  443. }
  444. })
  445. .Catch(HandleOperationError)
  446. .Finally(FinishOperation);
  447. }
  448. public void DiscardStash()
  449. {
  450. StartOperation("Discarding stash...");
  451. GitService.DropStash()
  452. .Then(successMessage =>
  453. {
  454. InfoMessage = successMessage;
  455. })
  456. .Catch(HandleOperationError)
  457. .Finally(Refresh);
  458. }
  459. public void DiffStashedFile(GitChange change)
  460. {
  461. GitService.DiffStashedFile(change)
  462. .Catch(ex =>
  463. {
  464. _ = _displayDialog("Stash Diff Error", $"Error diffing '{change.FilePath}': {ex.Message}", "Uh-oh", "Ok");
  465. });
  466. }
  467. public void ApplyStash()
  468. {
  469. StartOperation("Analyzing stash for conflicts...");
  470. GitService.AnalyzeStashConflicts()
  471. .Then(analysisResult =>
  472. {
  473. FinishOperation();
  474. if (analysisResult.HasConflicts)
  475. {
  476. var conflictSource = new StashConflictSource();
  477. GitService.GetStashedFiles().Then(stashedFiles =>
  478. {
  479. var conflictingChanges = stashedFiles
  480. .Where(sf => analysisResult.ConflictingFiles.Contains(sf.FilePath))
  481. .ToList();
  482. ConflictResolutionWindow.ShowWindow(this, conflictingChanges, conflictSource);
  483. });
  484. }
  485. else
  486. {
  487. StartOperation("Applying stash...");
  488. var resolutions = new List<GitChange>();
  489. var source = new StashConflictSource();
  490. source.Resolve(resolutions)
  491. .Then(successMsg => { InfoMessage = successMsg; })
  492. .Catch(HandleOperationError)
  493. .Finally(Refresh);
  494. }
  495. })
  496. .Catch(HandleOperationError)
  497. .Finally(() =>
  498. {
  499. FinishOperation();
  500. Refresh();
  501. EditorApplication.delayCall += AssetDatabase.Refresh;
  502. });
  503. }
  504. private bool CancelOperationIfUnsavedScenes()
  505. {
  506. var isAnySceneDirty = false;
  507. for (var i = 0; i < EditorSceneManager.sceneCount; i++)
  508. {
  509. var scene = EditorSceneManager.GetSceneAt(i);
  510. if (!scene.isDirty) continue;
  511. isAnySceneDirty = true;
  512. break;
  513. }
  514. if (!isAnySceneDirty)
  515. {
  516. return false;
  517. }
  518. var userChoice = _promptForUnsavedChanges();
  519. switch (userChoice)
  520. {
  521. case UserAction.SaveAndProceed:
  522. EditorSceneManager.SaveOpenScenes();
  523. return false;
  524. case UserAction.Proceed:
  525. return false;
  526. case UserAction.Cancel:
  527. default:
  528. return true;
  529. }
  530. }
  531. }
  532. }