123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607 |
- // Copyright (c) 2025 TerraByte Inc.
- //
- // This script acts as the Controller for the ArbitratorWindow. It manages all
- // state and business logic, separating it from the UI rendering code in the window.
- using System;
- using GitMerge;
- using System.Linq;
- using UnityEditor;
- using UnityEngine.Scripting;
- using Terra.Arbitrator.Settings;
- using Terra.Arbitrator.Services;
- using Terra.Arbitrator.Promises;
- using System.Collections.Generic;
- using UnityEditor.SceneManagement;
- namespace Terra.Arbitrator.GUI
- {
- public enum UserAction { Proceed, SaveAndProceed, Cancel }
- public enum SortColumn { Commit, Status, FilePath }
-
- [Preserve]
- public class ArbitratorController
- {
- private List<GitChange> _changes = new();
- public IReadOnlyList<GitChange> Changes => _changes;
- public string InfoMessage { get; private set; }
- public string ErrorMessage { get; private set; }
- public bool IsLoading { get; private set; }
- public string LoadingMessage { get; private set; } = "";
- public bool IsInConflictState { get; private set; }
- public bool HasStash { get; private set; }
- public int CommitsToPull { get; private set; }
- public SortColumn CurrentSortColumn { get; private set; } = SortColumn.FilePath;
- public float OperationProgress { get; private set; }
- public string OperationProgressMessage { get; private set; }
- public IReadOnlyList<string> RemoteBranchList { get; private set; } = new List<string>();
- public string CurrentBranchName { get; private set; } = "master";
- private readonly Action _requestRepaint;
- private readonly Func<string, string, string, string, bool> _displayDialog;
- private readonly Func<UserAction> _promptForUnsavedChanges;
- /// <summary>
- /// Initializes the controller.
- /// </summary>
- /// <param name="requestRepaint">A callback to the window's Repaint() method.</param>
- /// <param name="displayDialog">A callback to EditorUtility.DisplayDialog for user confirmations.</param>
- /// <param name="promptForUnsavedChanges">A callback to EditorUtility.DisplayDialog for user confirmations.</param>
- public ArbitratorController(Action requestRepaint, Func<string, string, string, string, bool> displayDialog, Func<UserAction> promptForUnsavedChanges)
- {
- _requestRepaint = requestRepaint;
- _displayDialog = displayDialog;
- _promptForUnsavedChanges = promptForUnsavedChanges;
- }
-
- public void OnEnable()
- {
- Refresh();
- }
- public void Refresh()
- {
- StartOperation("Refreshing status...");
- CommitsToPull = 0;
-
- UnstageStep()
- .Then(CompareStep)
- .Then(FetchUpstreamStep)
- .Then(CheckForStashStep)
- .Then(FetchBranchDataStep)
- .Then(FinalizeRefresh)
- .Catch(HandleOperationError)
- .Finally(FinishOperation);
- }
-
- public void Pull()
- {
- if (IsLoading) return;
- if (CancelOperationIfUnsavedScenes()) return;
- if (CommitsToPull > 0)
- {
- if (!_displayDialog("Confirm Pull", $"There are {CommitsToPull} incoming changes. Are you sure you want to pull?", "Yes, Pull", "Cancel"))
- {
- return;
- }
- }
- StartOperation("Analyzing for conflicts...");
- GitService.AnalyzePullConflicts()
- .Then(analysisResult =>
- {
- FinishOperation();
- if (analysisResult.HasConflicts)
- {
- var conflictingChanges = _changes.Where(c => analysisResult.ConflictingFiles.Contains(c.FilePath)).ToList();
- ConflictResolutionWindow.ShowWindow(this, conflictingChanges, new PullConflictSource());
- }
- else
- {
- PerformSafePullWithLock();
- }
- })
- .Catch(ex => {
- HandleOperationError(ex);
- FinishOperation();
- })
- .Finally(FinishOperation);
- }
- private void PerformSafePullWithLock()
- {
- StartOperation("Pulling changes...");
- EditorApplication.LockReloadAssemblies();
- GitService.PerformSafePull()
- .Then(successMessage =>
- {
- InfoMessage = successMessage;
- Refresh();
- })
- .Catch(ex => {
- HandleOperationError(ex);
- FinishOperation();
- })
- .Finally(EditorApplication.UnlockReloadAssemblies);
- }
- public void CommitAndPush(string commitMessage)
- {
- if (CancelOperationIfUnsavedScenes()) return;
-
- var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
-
- var username = BetterGitSettings.Username;
- var email = BetterGitSettings.Email;
-
- StartOperation("Staging, committing, and pushing files...");
- GitService.CommitAndPush(selectedFiles, commitMessage, username, email, OnProgressModified)
- .Then(successMessage => {
- InfoMessage = successMessage;
- Refresh();
- })
- .Catch(ex => {
- HandleOperationError(ex);
- FinishOperation();
- });
- return;
- void OnProgressModified(float progress, string message)
- {
- OperationProgress = progress;
- OperationProgressMessage = message;
- _requestRepaint?.Invoke();
- }
- }
-
- public void SetSortColumn(SortColumn newColumn)
- {
- // If it's already the active column, do nothing.
- if (CurrentSortColumn == newColumn) return;
- CurrentSortColumn = newColumn;
- ApplyGrouping();
- _requestRepaint?.Invoke();
- }
- private void ApplyGrouping()
- {
- if (_changes == null || !_changes.Any()) return;
- _changes = CurrentSortColumn switch
- {
- SortColumn.Commit => _changes.OrderByDescending(c => c.IsSelectedForCommit).ThenBy(c => c.FilePath).ToList(),
- SortColumn.Status => _changes.OrderBy(c => GetStatusSortPriority(c.Status)).ThenBy(c => c.FilePath).ToList(),
- SortColumn.FilePath => _changes.OrderBy(c => c.FilePath).ToList(),
- _ => _changes
- };
- }
-
- private static int GetStatusSortPriority(LibGit2Sharp.ChangeKind status)
- {
- return status switch
- {
- LibGit2Sharp.ChangeKind.Conflicted => -1, // Always show conflicts on top
- LibGit2Sharp.ChangeKind.Modified => 0,
- LibGit2Sharp.ChangeKind.Added => 1,
- LibGit2Sharp.ChangeKind.Deleted => 2,
- LibGit2Sharp.ChangeKind.Renamed => 3,
- _ => 99
- };
- }
- public void ResetFile(GitChange change)
- {
- if (IsLoading) return;
- 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");
- if (!userConfirmed) return;
-
- StartOperation($"Resetting {change.FilePath}...");
-
- GitService.ResetFileChanges(change)
- .Then(successMessage => {
- InfoMessage = successMessage;
- Refresh();
- })
- .Catch(ex => {
- HandleOperationError(ex);
- FinishOperation();
- });
- }
-
- public void DiffFile(GitChange change)
- {
- if (IsLoading) return;
- StartOperation($"Launching diff for {change.FilePath}...");
-
- GitService.LaunchExternalDiff(change)
- .Catch(HandleOperationError)
- .Finally(Refresh);
- }
-
- public void ResolveConflict(GitChange change)
- {
- if (IsLoading) return;
-
- StartOperation($"Opening merge tool for {change.FilePath}...");
-
- var fileExtension = System.IO.Path.GetExtension(change.FilePath).ToLower();
- if (fileExtension is ".prefab" or ".unity")
- {
- try
- {
- GitMergeWindow.ResolveConflict(change.FilePath);
- }
- catch (Exception e)
- {
- ErrorMessage = e.Message;
- }
-
- Refresh();
- return;
- }
-
- GitService.LaunchMergeTool(change)
- .Then(successMessage => { InfoMessage = successMessage; })
- .Catch(HandleOperationError)
- .Finally(Refresh);
- }
-
- public void ResetSelected()
- {
- var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
- if (!selectedFiles.Any()) return;
- var fileList = string.Join("\n - ", selectedFiles.Select(f => f.FilePath));
- 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;
-
- var pathsToReset = selectedFiles.Select(c => c.FilePath).ToList();
- ResetMultipleFiles(pathsToReset);
- }
-
- public void StashSelectedFiles()
- {
- var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
- if (!selectedFiles.Any())
- {
- InfoMessage = "No files selected to stash.";
- return;
- }
-
- EditorApplication.LockReloadAssemblies();
-
- StartOperation("Checking for existing stash...");
- GitService.HasStash()
- .Then(hasStash =>
- {
- FinishOperation();
- bool confirmed;
- if (hasStash)
- {
- confirmed = _displayDialog("Overwrite Stash?",
- "A 'Better Git Stash' already exists. Do you want to overwrite it with your currently selected files?",
- "Yes, Overwrite", "Cancel");
- }
- else
- {
- confirmed = _displayDialog("Create Stash?",
- $"Are you sure you want to stash the selected {selectedFiles.Count} file(s)?",
- "Yes, Stash", "Cancel");
- }
- if (!confirmed) return;
-
- StartOperation("Stashing selected files...");
- GitService.CreateOrOverwriteStash(selectedFiles)
- .Then(successMsg => { InfoMessage = successMsg; })
- .Catch(HandleOperationError)
- .Finally(() =>
- {
- Refresh();
- EditorApplication.delayCall += AssetDatabase.Refresh;
- });
- })
- .Catch(HandleOperationError)
- .Finally(() =>
- {
- FinishOperation();
- EditorApplication.delayCall += EditorApplication.UnlockReloadAssemblies;
- });
- }
- public void SetAllSelection(bool selected)
- {
- if (_changes == null) return;
- foreach (var change in _changes.Where(change => change.Status != LibGit2Sharp.ChangeKind.Conflicted))
- {
- change.IsSelectedForCommit = selected;
- }
- }
-
- public void SwitchToBranch(string targetBranch)
- {
- if (IsLoading || targetBranch == CurrentBranchName) return;
- if (Changes.Any())
- {
- 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"))
- {
- return;
- }
-
- StartOperation($"Discarding changes and switching to {targetBranch}...");
- GitService.ResetAndSwitchBranch(targetBranch)
- .Then(successMsg => { InfoMessage = successMsg; Refresh(); })
- .Catch(ex => { HandleOperationError(ex); FinishOperation(); })
- .Finally(() => { EditorApplication.delayCall += AssetDatabase.Refresh; });
- }
- else
- {
- if (!_displayDialog("Confirm Branch Switch", $"Are you sure you want to switch to branch '{targetBranch}'?", "Yes, Switch", "Cancel"))
- {
- return;
- }
- StartOperation($"Switching to {targetBranch}...");
- GitService.SwitchBranch(targetBranch)
- .Then(successMsg => { InfoMessage = successMsg; Refresh(); })
- .Catch(ex => { HandleOperationError(ex); FinishOperation(); })
- .Finally(() => { EditorApplication.delayCall += AssetDatabase.Refresh; });
- }
- }
- // --- Private Methods ---
-
- private static IPromise<bool> UnstageStep()
- {
- return GitService.UnstageAllFilesIfSafe();
- }
- private IPromise<List<GitChange>> CompareStep(bool wasUnstaged)
- {
- if (wasUnstaged)
- {
- InfoMessage = "Found and unstaged files for review.";
- }
- return GitService.CompareLocalToRemote();
- }
-
- private IPromise<int?> FetchUpstreamStep(List<GitChange> changes)
- {
- _changes = changes;
- IsInConflictState = _changes.Any(c => c.Status == LibGit2Sharp.ChangeKind.Conflicted);
- return IsInConflictState ? new Promise<int?>((resolve, _) => resolve(0)) : GitService.GetUpstreamAheadBy(OnProgressModified);
- void OnProgressModified(float progress, string message)
- {
- OperationProgress = progress;
- OperationProgressMessage = message;
- _requestRepaint?.Invoke();
- }
- }
-
- private IPromise<bool> CheckForStashStep(int? pullCount)
- {
- CommitsToPull = pullCount ?? 0;
- return GitService.HasStash();
- }
- private IPromise<BranchData> FetchBranchDataStep(bool hasStash)
- {
- HasStash = hasStash;
- OperationProgress = 0f;
- OperationProgressMessage = "";
- return GitService.GetBranchData();
- }
- private void FinalizeRefresh(BranchData branchData)
- {
- CurrentBranchName = branchData.CurrentBranch;
- RemoteBranchList = branchData.AllBranches;
- ApplyGrouping();
- }
-
- // --- Shared Helper Methods ---
- private void StartOperation(string loadingMessage)
- {
- IsLoading = true;
- LoadingMessage = loadingMessage;
- OperationProgress = 0f;
- OperationProgressMessage = "";
- ClearMessages();
- _changes = null;
- _requestRepaint?.Invoke();
- }
- private void HandleOperationError(Exception ex)
- {
- ErrorMessage = $"Operation Failed: {ex.Message}";
- }
-
- private void FinishOperation()
- {
- IsLoading = false;
- _requestRepaint?.Invoke();
- }
-
- private void ClearMessages()
- {
- ErrorMessage = null;
- InfoMessage = null;
- }
- private void ResetMultipleFiles(List<string> filePaths)
- {
- EditorApplication.LockReloadAssemblies();
- StartOperation($"Resetting {filePaths.Count} file(s)...");
- IPromise<string> promiseChain = new Promise<string>((resolve, _) => resolve(""));
- foreach (var path in filePaths)
- {
- promiseChain = promiseChain.Then(_ =>
- {
- var change = GitService.GetChangeForFile(path);
- return change != null ? GitService.ResetFileChanges(change) : new Promise<string>((res, _) => res(""));
- });
- }
- promiseChain
- .Then(_ =>
- {
- InfoMessage = $"Successfully reset {filePaths.Count} file(s).";
- Refresh();
- })
- .Catch(ex =>
- {
- HandleOperationError(ex);
- FinishOperation();
- })
- .Finally(() =>
- {
- EditorApplication.delayCall += () =>
- {
- EditorApplication.UnlockReloadAssemblies();
- AssetDatabase.Refresh();
- };
- });
- }
-
- public void ResolveConflicts(List<GitChange> resolutions, IConflictSource source)
- {
- StartOperation("Resolving conflicts...");
- EditorApplication.LockReloadAssemblies();
- source.Resolve(resolutions)
- .Then(successMessage =>
- {
- InfoMessage = successMessage;
- Refresh();
- })
- .Catch(HandleOperationError)
- .Finally(() =>
- {
- EditorApplication.delayCall += () =>
- {
- EditorApplication.UnlockReloadAssemblies();
- AssetDatabase.Refresh();
- };
- });
- }
-
- public void ShowStashedChangesWindow()
- {
- StartOperation("Loading stashed files...");
- GitService.GetStashedFiles()
- .Then(stashedFiles =>
- {
- if (stashedFiles.Any())
- {
- InfoMessage = "Fetching files from the stash.";
- StashedChangesWindow.ShowWindow(this, stashedFiles, Refresh);
- }
- else
- {
- InfoMessage = "No files found in the stash.";
- }
- })
- .Catch(HandleOperationError)
- .Finally(FinishOperation);
- }
- public void DiscardStash()
- {
- StartOperation("Discarding stash...");
- GitService.DropStash()
- .Then(successMessage =>
- {
- InfoMessage = successMessage;
- })
- .Catch(HandleOperationError)
- .Finally(Refresh);
- }
-
- public void DiffStashedFile(GitChange change)
- {
- GitService.DiffStashedFile(change)
- .Catch(ex =>
- {
- _ = _displayDialog("Stash Diff Error", $"Error diffing '{change.FilePath}': {ex.Message}", "Uh-oh", "Ok");
- });
- }
-
- public void ApplyStash()
- {
- StartOperation("Analyzing stash for conflicts...");
- GitService.AnalyzeStashConflicts()
- .Then(analysisResult =>
- {
- FinishOperation();
- if (analysisResult.HasConflicts)
- {
-
- var conflictSource = new StashConflictSource();
- GitService.GetStashedFiles().Then(stashedFiles =>
- {
- var conflictingChanges = stashedFiles
- .Where(sf => analysisResult.ConflictingFiles.Contains(sf.FilePath))
- .ToList();
- ConflictResolutionWindow.ShowWindow(this, conflictingChanges, conflictSource);
- });
- }
- else
- {
- StartOperation("Applying stash...");
- var resolutions = new List<GitChange>();
- var source = new StashConflictSource();
- source.Resolve(resolutions)
- .Then(successMsg => { InfoMessage = successMsg; })
- .Catch(HandleOperationError)
- .Finally(Refresh);
- }
- })
- .Catch(HandleOperationError)
- .Finally(() =>
- {
- FinishOperation();
- Refresh();
- EditorApplication.delayCall += AssetDatabase.Refresh;
- });
- }
-
- private bool CancelOperationIfUnsavedScenes()
- {
- var isAnySceneDirty = false;
- for (var i = 0; i < EditorSceneManager.sceneCount; i++)
- {
- var scene = EditorSceneManager.GetSceneAt(i);
- if (!scene.isDirty) continue;
- isAnySceneDirty = true;
- break;
- }
- if (!isAnySceneDirty)
- {
- return false;
- }
-
- var userChoice = _promptForUnsavedChanges();
- switch (userChoice)
- {
- case UserAction.SaveAndProceed:
- EditorSceneManager.SaveOpenScenes();
- return false;
- case UserAction.Proceed:
- return false;
- case UserAction.Cancel:
- default:
- return true;
- }
- }
- }
- }
|