Sfoglia il codice sorgente

feat: Add remote sync, push/pull, diff, discard and history to git UI

- Add push/pull support with automatic upstream tracking on first push
- Add separate Sync button for manual remote fetch (no auto-polling)
- Add diff viewer modal with color-coded output
- Add discard changes with confirmation prompt
- Add commit history modal via branch name click
- Split save dropdown (diff/discard) from sync dropdown (push/pull)
mavnezz 1 mese fa
parent
commit
1c62bb2016

+ 56 - 0
RackPeek.Domain/Git/GitService.cs

@@ -135,6 +135,62 @@ public sealed class GitService : IGitService
             .ToArray();
     }
 
+    public async Task<bool> HasRemoteAsync()
+    {
+        if (!_isAvailable) return false;
+        var (exitCode, output) = await RunGitAsync("remote");
+        return exitCode == 0 && !string.IsNullOrWhiteSpace(output);
+    }
+
+    public async Task<GitSyncStatus> GetSyncStatusAsync()
+    {
+        if (!_isAvailable || !await HasRemoteAsync())
+            return new GitSyncStatus(0, 0, false);
+
+        // Fetch latest remote state (silent, no merge)
+        await RunGitAsync("fetch", "--quiet");
+
+        // Check if upstream tracking is configured
+        var (upstreamExit, _) = await RunGitAsync("rev-parse", "--abbrev-ref", "@{upstream}");
+        if (upstreamExit != 0)
+        {
+            // No upstream configured — count local commits as ahead
+            var (logExit, logOutput) = await RunGitAsync("rev-list", "--count", "HEAD");
+            var localCommits = logExit == 0 && int.TryParse(logOutput, out var c) ? c : 0;
+            return new GitSyncStatus(localCommits, 0, true);
+        }
+
+        var (aheadExit, aheadOutput) = await RunGitAsync("rev-list", "--count", "@{upstream}..HEAD");
+        var (behindExit, behindOutput) = await RunGitAsync("rev-list", "--count", "HEAD..@{upstream}");
+
+        var ahead = aheadExit == 0 && int.TryParse(aheadOutput, out var a) ? a : 0;
+        var behind = behindExit == 0 && int.TryParse(behindOutput, out var b) ? b : 0;
+
+        return new GitSyncStatus(ahead, behind, true);
+    }
+
+    public async Task<string?> PushAsync()
+    {
+        if (!_isAvailable) return "Git is not available.";
+        if (!await HasRemoteAsync()) return "No remote configured.";
+
+        // Use -u on first push to set upstream tracking
+        var (upstreamExit, _) = await RunGitAsync("rev-parse", "--abbrev-ref", "@{upstream}");
+        var (exitCode, output) = upstreamExit != 0
+            ? await RunGitAsync("push", "-u", "origin", "HEAD")
+            : await RunGitAsync("push");
+        return exitCode != 0 ? $"git push failed: {output}" : null;
+    }
+
+    public async Task<string?> PullAsync()
+    {
+        if (!_isAvailable) return "Git is not available.";
+        if (!await HasRemoteAsync()) return "No remote configured.";
+
+        var (exitCode, output) = await RunGitAsync("pull");
+        return exitCode != 0 ? $"git pull failed: {output}" : null;
+    }
+
     private bool CheckGitAvailable()
     {
         try

+ 5 - 0
RackPeek.Domain/Git/IGitService.cs

@@ -17,6 +17,11 @@ public interface IGitService
     Task<string?> RestoreAllAsync();
     Task<string> GetCurrentBranchAsync();
     Task<GitLogEntry[]> GetLogAsync(int count = 20);
+    Task<bool> HasRemoteAsync();
+    Task<GitSyncStatus> GetSyncStatusAsync();
+    Task<string?> PushAsync();
+    Task<string?> PullAsync();
 }
 
 public record GitLogEntry(string Hash, string Message, string Author, string Date);
+public record GitSyncStatus(int Ahead, int Behind, bool HasRemote);

+ 4 - 0
RackPeek.Domain/Git/NullGitService.cs

@@ -10,4 +10,8 @@ public sealed class NullGitService : IGitService
     public Task<string?> RestoreAllAsync() => Task.FromResult<string?>("Not available.");
     public Task<string> GetCurrentBranchAsync() => Task.FromResult(string.Empty);
     public Task<GitLogEntry[]> GetLogAsync(int count = 20) => Task.FromResult(Array.Empty<GitLogEntry>());
+    public Task<bool> HasRemoteAsync() => Task.FromResult(false);
+    public Task<GitSyncStatus> GetSyncStatusAsync() => Task.FromResult(new GitSyncStatus(0, 0, false));
+    public Task<string?> PushAsync() => Task.FromResult<string?>("Not available.");
+    public Task<string?> PullAsync() => Task.FromResult<string?>("Not available.");
 }

+ 152 - 5
Shared.Rcl/Layout/GitStatusIndicator.razor

@@ -86,6 +86,67 @@
             </div>
         }
 
+        @* Sync button — separate from save, only when remote exists *@
+        @if (_hasRemote)
+        {
+            <div class="relative flex" data-testid="git-sync-group">
+                <button class="px-2 py-1 text-xs rounded text-zinc-400 hover:text-white hover:bg-zinc-700 transition disabled:opacity-50"
+                        disabled="@_isSyncing"
+                        data-testid="git-sync-button"
+                        @onclick="ToggleSyncAsync">
+                    @if (_isFetching)
+                    {
+                        <span>Checking...</span>
+                    }
+                    else if (_syncStatus.Ahead > 0 || _syncStatus.Behind > 0)
+                    {
+                        <span>
+                            Sync
+                            @if (_syncStatus.Ahead > 0) { <span class="text-emerald-400">↑@_syncStatus.Ahead</span> }
+                            @if (_syncStatus.Behind > 0) { <span class="text-blue-400">↓@_syncStatus.Behind</span> }
+                        </span>
+                    }
+                    else
+                    {
+                        <span>Sync</span>
+                    }
+                </button>
+
+                @if (_showSyncDropdown)
+                {
+                    <div class="absolute top-full right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded shadow-lg z-50 min-w-[140px]"
+                         data-testid="git-sync-dropdown">
+                        @if (_syncStatus.Ahead > 0 || _syncStatus.Behind > 0)
+                        {
+                            <div class="px-3 py-2 text-xs text-zinc-500 border-b border-zinc-700">
+                                @if (_syncStatus.Ahead > 0) { <span class="text-emerald-400">↑@_syncStatus.Ahead ahead</span> }
+                                @if (_syncStatus.Ahead > 0 && _syncStatus.Behind > 0) { <span> · </span> }
+                                @if (_syncStatus.Behind > 0) { <span class="text-blue-400">↓@_syncStatus.Behind behind</span> }
+                            </div>
+                        }
+                        else
+                        {
+                            <div class="px-3 py-2 text-xs text-zinc-500 border-b border-zinc-700">
+                                Up to date
+                            </div>
+                        }
+                        <button class="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 hover:text-white transition disabled:opacity-50"
+                                disabled="@(_isSyncing || _syncStatus.Ahead == 0)"
+                                data-testid="git-push-button"
+                                @onclick="PushAsync">
+                            @(_isPushing ? "Pushing..." : "Push")
+                        </button>
+                        <button class="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 hover:text-white transition disabled:opacity-50"
+                                disabled="@(_isSyncing || _syncStatus.Behind == 0)"
+                                data-testid="git-pull-button"
+                                @onclick="PullAsync">
+                            @(_isPulling ? "Pulling..." : "Pull")
+                        </button>
+                    </div>
+                }
+            </div>
+        }
+
         @if (_errorMessage is not null)
         {
             <span class="text-red-400 text-xs" data-testid="git-error">
@@ -94,10 +155,10 @@
         }
     </div>
 
-    @* Dropdown backdrop — closes dropdown on outside click *@
-    @if (_showDropdown)
+    @* Dropdown backdrop — closes all dropdowns on outside click *@
+    @if (_showDropdown || _showSyncDropdown)
     {
-        <div class="fixed inset-0 z-40" @onclick="CloseDropdown"></div>
+        <div class="fixed inset-0 z-40" @onclick="CloseAllDropdowns"></div>
     }
 
     @* Diff Modal *@
@@ -219,7 +280,15 @@
     private bool _showHistory;
     private GitLogEntry[] _logEntries = [];
 
-    private bool _isBusy => _isCommitting || _isRestoring;
+    private bool _hasRemote;
+    private GitSyncStatus _syncStatus = new(0, 0, false);
+    private bool _showSyncDropdown;
+    private bool _isFetching;
+    private bool _isPushing;
+    private bool _isPulling;
+
+    private bool _isBusy => _isCommitting || _isRestoring || _isSyncing;
+    private bool _isSyncing => _isPushing || _isPulling || _isFetching;
 
     protected override async Task OnInitializedAsync()
     {
@@ -229,6 +298,7 @@
             return;
 
         _branch = await GitService.GetCurrentBranchAsync();
+        _hasRemote = await GitService.HasRemoteAsync();
 
         _cts = new CancellationTokenSource();
         _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
@@ -286,12 +356,41 @@
     private void ToggleDropdown()
     {
         _showDropdown = !_showDropdown;
+        _showSyncDropdown = false;
         _confirmDiscard = false;
     }
 
-    private void CloseDropdown()
+    private async Task ToggleSyncAsync()
+    {
+        if (_showSyncDropdown)
+        {
+            _showSyncDropdown = false;
+            return;
+        }
+
+        _showDropdown = false;
+        _errorMessage = null;
+        _isFetching = true;
+        _showSyncDropdown = true;
+
+        try
+        {
+            _syncStatus = await GitService.GetSyncStatusAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Fetch error: {ex.Message}";
+        }
+        finally
+        {
+            _isFetching = false;
+        }
+    }
+
+    private void CloseAllDropdowns()
     {
         _showDropdown = false;
+        _showSyncDropdown = false;
         _confirmDiscard = false;
     }
 
@@ -319,6 +418,7 @@
         }
 
         _showDropdown = false;
+        _showSyncDropdown = false;
         _logEntries = await GitService.GetLogAsync();
         _showHistory = true;
     }
@@ -354,6 +454,53 @@
         }
     }
 
+    private async Task PushAsync()
+    {
+        _errorMessage = null;
+        _isPushing = true;
+
+        try
+        {
+            var error = await GitService.PushAsync();
+            if (error is not null)
+                _errorMessage = error;
+
+            _syncStatus = await GitService.GetSyncStatusAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Push error: {ex.Message}";
+        }
+        finally
+        {
+            _isPushing = false;
+        }
+    }
+
+    private async Task PullAsync()
+    {
+        _errorMessage = null;
+        _isPulling = true;
+
+        try
+        {
+            var error = await GitService.PullAsync();
+            if (error is not null)
+                _errorMessage = error;
+
+            _syncStatus = await GitService.GetSyncStatusAsync();
+            _status = await GitService.GetStatusAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Pull error: {ex.Message}";
+        }
+        finally
+        {
+            _isPulling = false;
+        }
+    }
+
     private static string FormatDiff(string diff)
     {
         if (string.IsNullOrWhiteSpace(diff))