|
|
@@ -0,0 +1,696 @@
|
|
|
+@using RackPeek.Domain.Git
|
|
|
+@using RackPeek.Domain.Git.UseCases
|
|
|
+@inject InitRepoUseCase InitRepo
|
|
|
+@inject CommitAllUseCase CommitAll
|
|
|
+@inject RestoreAllUseCase RestoreAll
|
|
|
+@inject PushUseCase PushUseCase
|
|
|
+@inject PullUseCase PullUseCase
|
|
|
+@inject AddRemoteUseCase AddRemoteUseCase
|
|
|
+@inject IGitRepository GitRepo
|
|
|
+@implements IDisposable
|
|
|
+
|
|
|
+@if (_status == GitRepoStatus.NotAvailable)
|
|
|
+{
|
|
|
+ <div class="flex items-center gap-2 text-sm" data-testid="git-init-indicator">
|
|
|
+ @if (_confirmInit)
|
|
|
+ {
|
|
|
+ <span class="text-zinc-400 text-xs">Enable git tracking?</span>
|
|
|
+ <button class="px-2 py-0.5 text-xs rounded bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
|
|
|
+ disabled="@_isInitializing"
|
|
|
+ data-testid="git-init-confirm"
|
|
|
+ @onclick="InitRepoAsync">
|
|
|
+ @(_isInitializing ? "..." : "Yes")
|
|
|
+ </button>
|
|
|
+ <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
|
|
|
+ data-testid="git-init-cancel"
|
|
|
+ @onclick="() => _confirmInit = false">
|
|
|
+ No
|
|
|
+ </button>
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ <button class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-emerald-400 hover:bg-zinc-800 transition"
|
|
|
+ data-testid="git-init-button"
|
|
|
+ @onclick="() => _confirmInit = true">
|
|
|
+ Enable Git
|
|
|
+ </button>
|
|
|
+ }
|
|
|
+ @if (_errorMessage is not null)
|
|
|
+ {
|
|
|
+ <span class="text-red-400 text-xs" data-testid="git-error">@_errorMessage</span>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+}
|
|
|
+else
|
|
|
+{
|
|
|
+ <div class="relative flex items-center gap-2 text-sm" data-testid="git-status-indicator">
|
|
|
+
|
|
|
+ @* Branch name — clickable to open history *@
|
|
|
+ @if (!string.IsNullOrEmpty(_branch))
|
|
|
+ {
|
|
|
+ <button class="text-zinc-400 text-xs hover:text-emerald-400 transition"
|
|
|
+ data-testid="git-branch"
|
|
|
+ @onclick="ToggleHistoryAsync">
|
|
|
+ @_branch
|
|
|
+ </button>
|
|
|
+ }
|
|
|
+
|
|
|
+ @if (_status == GitRepoStatus.Clean)
|
|
|
+ {
|
|
|
+ <span class="inline-block w-2 h-2 rounded-full bg-emerald-400"
|
|
|
+ data-testid="git-status-dot-clean"
|
|
|
+ title="All changes committed">
|
|
|
+ </span>
|
|
|
+ <span class="text-zinc-500 text-xs" data-testid="git-status-text">
|
|
|
+ Saved
|
|
|
+ </span>
|
|
|
+ }
|
|
|
+ else if (_status == GitRepoStatus.Dirty)
|
|
|
+ {
|
|
|
+ <span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse"
|
|
|
+ data-testid="git-status-dot-dirty"
|
|
|
+ title="Uncommitted changes">
|
|
|
+ </span>
|
|
|
+
|
|
|
+ @* Save button with dropdown toggle *@
|
|
|
+ <div class="relative flex" data-testid="git-save-group">
|
|
|
+ <button class="px-2 py-1 text-xs rounded-l bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
|
|
|
+ disabled="@_isBusy"
|
|
|
+ data-testid="git-save-button"
|
|
|
+ @onclick="CommitAsync">
|
|
|
+ @(_isCommitting ? "Saving..." : "Save")
|
|
|
+ </button>
|
|
|
+ <button class="px-1.5 py-1 text-xs rounded-r bg-emerald-700 hover:bg-emerald-600 text-white transition border-l border-emerald-800 disabled:opacity-50"
|
|
|
+ disabled="@_isBusy"
|
|
|
+ data-testid="git-save-dropdown"
|
|
|
+ @onclick="ToggleDropdown">
|
|
|
+ ▾
|
|
|
+ </button>
|
|
|
+
|
|
|
+ @if (_showDropdown)
|
|
|
+ {
|
|
|
+ <div class="absolute top-full right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded shadow-lg z-50 min-w-[120px]"
|
|
|
+ data-testid="git-dropdown-menu">
|
|
|
+ <button class="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 hover:text-white transition"
|
|
|
+ data-testid="git-diff-button"
|
|
|
+ @onclick="OpenDiffAsync">
|
|
|
+ Diff
|
|
|
+ </button>
|
|
|
+ @if (!_confirmDiscard)
|
|
|
+ {
|
|
|
+ <button class="w-full text-left px-3 py-2 text-xs text-zinc-400 hover:bg-zinc-700 hover:text-red-400 transition"
|
|
|
+ data-testid="git-discard-button"
|
|
|
+ @onclick="() => _confirmDiscard = true">
|
|
|
+ Discard
|
|
|
+ </button>
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ <div class="px-3 py-2 flex items-center gap-2">
|
|
|
+ <span class="text-red-400 text-xs">Sure?</span>
|
|
|
+ <button class="px-2 py-0.5 text-xs rounded bg-red-600 hover:bg-red-500 text-white transition"
|
|
|
+ data-testid="git-discard-confirm"
|
|
|
+ @onclick="DiscardAsync">
|
|
|
+ Yes
|
|
|
+ </button>
|
|
|
+ <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
|
|
|
+ data-testid="git-discard-cancel"
|
|
|
+ @onclick="() => _confirmDiscard = false">
|
|
|
+ No
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+
|
|
|
+ @* Sync / Remote section *@
|
|
|
+ @if (!_hasRemote)
|
|
|
+ {
|
|
|
+ <div class="relative flex items-center gap-1" data-testid="git-remote-group">
|
|
|
+ @if (_showAddRemote)
|
|
|
+ {
|
|
|
+ <input type="text"
|
|
|
+ class="px-2 py-1 text-xs rounded bg-zinc-800 border border-zinc-700 text-zinc-200 placeholder-zinc-500 w-56 focus:outline-none focus:border-emerald-500"
|
|
|
+ placeholder="https://github.com/user/repo.git"
|
|
|
+ @bind="_remoteUrl"
|
|
|
+ @bind:event="oninput"
|
|
|
+ data-testid="git-remote-url" />
|
|
|
+ <button class="px-2 py-0.5 text-xs rounded bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
|
|
|
+ disabled="@(string.IsNullOrWhiteSpace(_remoteUrl))"
|
|
|
+ data-testid="git-remote-save"
|
|
|
+ @onclick="AddRemoteAsync">
|
|
|
+ Add
|
|
|
+ </button>
|
|
|
+ <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
|
|
|
+ data-testid="git-remote-cancel"
|
|
|
+ @onclick="CancelAddRemote">
|
|
|
+ ×
|
|
|
+ </button>
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ <button class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-emerald-400 hover:bg-zinc-800 transition"
|
|
|
+ data-testid="git-add-remote-button"
|
|
|
+ @onclick="() => _showAddRemote = true">
|
|
|
+ Add Remote
|
|
|
+ </button>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ else 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.Error is not null)
|
|
|
+ {
|
|
|
+ <div class="px-3 py-2 text-xs text-red-400 border-b border-zinc-700">
|
|
|
+ Fetch failed
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ else 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)"
|
|
|
+ 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 || _syncStatus.Error is not null)"
|
|
|
+ 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">
|
|
|
+ @_errorMessage
|
|
|
+ </span>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+
|
|
|
+ @* Dropdown backdrop — closes all dropdowns on outside click *@
|
|
|
+ @if (_showDropdown || _showSyncDropdown)
|
|
|
+ {
|
|
|
+ <div class="fixed inset-0 z-40" @onclick="CloseAllDropdowns"></div>
|
|
|
+ }
|
|
|
+
|
|
|
+ @* Diff Modal *@
|
|
|
+ @if (_showDiff)
|
|
|
+ {
|
|
|
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
|
|
+ data-testid="git-diff-overlay"
|
|
|
+ @onclick="CloseDiff">
|
|
|
+ <div class="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col"
|
|
|
+ @onclick:stopPropagation="true">
|
|
|
+
|
|
|
+ <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <span class="text-sm font-semibold text-zinc-200">Changes</span>
|
|
|
+ <span class="text-xs text-zinc-500">@_changedFiles.Length file(s)</span>
|
|
|
+ </div>
|
|
|
+ <button class="text-zinc-400 hover:text-white transition text-lg"
|
|
|
+ data-testid="git-diff-close"
|
|
|
+ @onclick="CloseDiff">
|
|
|
+ ×
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ @if (_changedFiles.Length > 0)
|
|
|
+ {
|
|
|
+ <div class="px-4 py-2 border-b border-zinc-800 flex flex-wrap gap-2">
|
|
|
+ @foreach (var file in _changedFiles)
|
|
|
+ {
|
|
|
+ var status = file.Length >= 2 ? file[..2].Trim() : "?";
|
|
|
+ var name = file.Length >= 3 ? file[3..] : file;
|
|
|
+ var color = status switch
|
|
|
+ {
|
|
|
+ "M" => "text-amber-400",
|
|
|
+ "A" or "?" => "text-emerald-400",
|
|
|
+ "D" => "text-red-400",
|
|
|
+ _ => "text-zinc-400"
|
|
|
+ };
|
|
|
+ <span class="text-xs">
|
|
|
+ <span class="@color font-bold">@status</span>
|
|
|
+ <span class="text-zinc-300">@name</span>
|
|
|
+ </span>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+
|
|
|
+ <div class="overflow-auto flex-1 p-4">
|
|
|
+ <pre class="text-xs leading-relaxed whitespace-pre-wrap">@((MarkupString)FormatDiff(_diffContent))</pre>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+
|
|
|
+ @* History Modal *@
|
|
|
+ @if (_showHistory)
|
|
|
+ {
|
|
|
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
|
|
+ data-testid="git-history-overlay"
|
|
|
+ @onclick="CloseHistory">
|
|
|
+ <div class="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl w-[90vw] max-w-3xl max-h-[80vh] flex flex-col"
|
|
|
+ @onclick:stopPropagation="true">
|
|
|
+
|
|
|
+ <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <span class="text-sm font-semibold text-zinc-200">History</span>
|
|
|
+ <span class="text-xs text-zinc-500">@_branch</span>
|
|
|
+ <span class="text-xs text-zinc-600">@_logEntries.Length commits</span>
|
|
|
+ </div>
|
|
|
+ <button class="text-zinc-400 hover:text-white transition text-lg"
|
|
|
+ data-testid="git-history-close"
|
|
|
+ @onclick="CloseHistory">
|
|
|
+ ×
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="overflow-auto flex-1">
|
|
|
+ @if (_logEntries.Length == 0)
|
|
|
+ {
|
|
|
+ <div class="p-4 text-zinc-500 text-sm">No commits yet.</div>
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ <div class="divide-y divide-zinc-800">
|
|
|
+ @foreach (var entry in _logEntries)
|
|
|
+ {
|
|
|
+ <div class="px-4 py-3 hover:bg-zinc-800/50 transition" data-testid="git-log-entry">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <span class="text-xs text-emerald-400 font-mono shrink-0">@entry.Hash</span>
|
|
|
+ <span class="text-sm text-zinc-200 truncate">@entry.Message</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-3 mt-1">
|
|
|
+ <span class="text-xs text-zinc-500">@entry.Author</span>
|
|
|
+ <span class="text-xs text-zinc-600">@entry.Date</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@code {
|
|
|
+ private GitRepoStatus _status = GitRepoStatus.NotAvailable;
|
|
|
+ private string _branch = string.Empty;
|
|
|
+ private bool _isCommitting;
|
|
|
+ private bool _isRestoring;
|
|
|
+ private bool _confirmDiscard;
|
|
|
+ private bool _showDropdown;
|
|
|
+ private string? _errorMessage;
|
|
|
+ private PeriodicTimer? _timer;
|
|
|
+ private CancellationTokenSource? _cts;
|
|
|
+
|
|
|
+ private bool _showDiff;
|
|
|
+ private string _diffContent = string.Empty;
|
|
|
+ private string[] _changedFiles = [];
|
|
|
+
|
|
|
+ private bool _showHistory;
|
|
|
+ private GitLogEntry[] _logEntries = [];
|
|
|
+
|
|
|
+ private bool _hasRemote;
|
|
|
+ private GitSyncStatus _syncStatus = new(0, 0, false);
|
|
|
+ private bool _showSyncDropdown;
|
|
|
+ private bool _isFetching;
|
|
|
+ private bool _isPushing;
|
|
|
+ private bool _isPulling;
|
|
|
+ private bool _isInitializing;
|
|
|
+ private bool _confirmInit;
|
|
|
+ private bool _showAddRemote;
|
|
|
+ private string _remoteUrl = string.Empty;
|
|
|
+
|
|
|
+ private bool _isBusy => _isCommitting || _isRestoring || _isSyncing;
|
|
|
+ private bool _isSyncing => _isPushing || _isPulling || _isFetching;
|
|
|
+
|
|
|
+ protected override async Task OnInitializedAsync()
|
|
|
+ {
|
|
|
+ _status = GitRepo.GetStatus();
|
|
|
+
|
|
|
+ if (_status == GitRepoStatus.NotAvailable)
|
|
|
+ return;
|
|
|
+
|
|
|
+ _branch = GitRepo.GetCurrentBranch();
|
|
|
+ _hasRemote = GitRepo.HasRemote();
|
|
|
+
|
|
|
+ _cts?.Cancel();
|
|
|
+ _timer?.Dispose();
|
|
|
+
|
|
|
+ _cts = new CancellationTokenSource();
|
|
|
+ _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
|
|
+ _ = PollStatusAsync(_cts.Token);
|
|
|
+
|
|
|
+ await Task.CompletedTask;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task PollStatusAsync(CancellationToken ct)
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ while (_timer != null && await _timer.WaitForNextTickAsync(ct))
|
|
|
+ {
|
|
|
+ if (_isBusy)
|
|
|
+ continue;
|
|
|
+
|
|
|
+ var newStatus = GitRepo.GetStatus();
|
|
|
+
|
|
|
+ if (newStatus != _status)
|
|
|
+ {
|
|
|
+ _status = newStatus;
|
|
|
+ _confirmDiscard = false;
|
|
|
+ _showDropdown = false;
|
|
|
+
|
|
|
+ await InvokeAsync(StateHasChanged);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ catch (OperationCanceledException)
|
|
|
+ {
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task InitRepoAsync()
|
|
|
+ {
|
|
|
+ _errorMessage = null;
|
|
|
+ _isInitializing = true;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var error = await InitRepo.ExecuteAsync();
|
|
|
+
|
|
|
+ if (error is not null)
|
|
|
+ {
|
|
|
+ _errorMessage = error;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ _status = GitRepo.GetStatus();
|
|
|
+ _branch = GitRepo.GetCurrentBranch();
|
|
|
+ _hasRemote = GitRepo.HasRemote();
|
|
|
+
|
|
|
+ _cts?.Cancel();
|
|
|
+ _timer?.Dispose();
|
|
|
+
|
|
|
+ _cts = new CancellationTokenSource();
|
|
|
+ _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
|
|
+
|
|
|
+ _ = PollStatusAsync(_cts.Token);
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _errorMessage = $"Init error: {ex.Message}";
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ _isInitializing = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void CancelAddRemote()
|
|
|
+ {
|
|
|
+ _showAddRemote = false;
|
|
|
+ _remoteUrl = string.Empty;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task AddRemoteAsync()
|
|
|
+ {
|
|
|
+ _errorMessage = null;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var error = await AddRemoteUseCase.ExecuteAsync(_remoteUrl);
|
|
|
+ if (error is not null)
|
|
|
+ {
|
|
|
+ _errorMessage = error;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ _hasRemote = true;
|
|
|
+ _showAddRemote = false;
|
|
|
+ _remoteUrl = string.Empty;
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _errorMessage = $"Remote error: {ex.Message}";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task CommitAsync()
|
|
|
+ {
|
|
|
+ _errorMessage = null;
|
|
|
+ _isCommitting = true;
|
|
|
+ _confirmDiscard = false;
|
|
|
+ _showDropdown = false;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var error = await CommitAll.ExecuteAsync(
|
|
|
+ $"rackpeek: save config {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
|
|
|
+
|
|
|
+ if (error is not null)
|
|
|
+ _errorMessage = error;
|
|
|
+
|
|
|
+ _status = GitRepo.GetStatus();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _errorMessage = $"Unexpected error: {ex.Message}";
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ _isCommitting = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ToggleDropdown()
|
|
|
+ {
|
|
|
+ _showDropdown = !_showDropdown;
|
|
|
+ _showSyncDropdown = false;
|
|
|
+ _confirmDiscard = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ToggleSyncAsync()
|
|
|
+ {
|
|
|
+ if (_showSyncDropdown)
|
|
|
+ {
|
|
|
+ _showSyncDropdown = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ _showDropdown = false;
|
|
|
+ _errorMessage = null;
|
|
|
+ _isFetching = true;
|
|
|
+ _showSyncDropdown = true;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ _syncStatus = GitRepo.FetchAndGetSyncStatus();
|
|
|
+ if (_syncStatus.Error is not null)
|
|
|
+ _errorMessage = _syncStatus.Error;
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _errorMessage = $"Fetch error: {ex.Message}";
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ _isFetching = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void CloseAllDropdowns()
|
|
|
+ {
|
|
|
+ _showDropdown = false;
|
|
|
+ _showSyncDropdown = false;
|
|
|
+ _confirmDiscard = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void OpenDiffAsync()
|
|
|
+ {
|
|
|
+ _showDropdown = false;
|
|
|
+ _changedFiles = GitRepo.GetChangedFiles();
|
|
|
+ _diffContent = GitRepo.GetDiff();
|
|
|
+ _showDiff = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void CloseDiff()
|
|
|
+ {
|
|
|
+ _showDiff = false;
|
|
|
+ _diffContent = string.Empty;
|
|
|
+ _changedFiles = [];
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ToggleHistoryAsync()
|
|
|
+ {
|
|
|
+ if (_showHistory)
|
|
|
+ {
|
|
|
+ CloseHistory();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ _showDropdown = false;
|
|
|
+ _showSyncDropdown = false;
|
|
|
+ _logEntries = GitRepo.GetLog(20);
|
|
|
+ _showHistory = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void CloseHistory()
|
|
|
+ {
|
|
|
+ _showHistory = false;
|
|
|
+ _logEntries = [];
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task DiscardAsync()
|
|
|
+ {
|
|
|
+ _errorMessage = null;
|
|
|
+ _isRestoring = true;
|
|
|
+ _confirmDiscard = false;
|
|
|
+ _showDropdown = false;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var error = await RestoreAll.ExecuteAsync();
|
|
|
+ if (error is not null)
|
|
|
+ _errorMessage = error;
|
|
|
+
|
|
|
+ _status = GitRepo.GetStatus();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _errorMessage = $"Unexpected error: {ex.Message}";
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ _isRestoring = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task PushAsync()
|
|
|
+ {
|
|
|
+ _errorMessage = null;
|
|
|
+ _isPushing = true;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var error = await PushUseCase.ExecuteAsync();
|
|
|
+ if (error is not null)
|
|
|
+ _errorMessage = error;
|
|
|
+
|
|
|
+ _syncStatus = GitRepo.FetchAndGetSyncStatus();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _errorMessage = $"Push error: {ex.Message}";
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ _isPushing = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task PullAsync()
|
|
|
+ {
|
|
|
+ _errorMessage = null;
|
|
|
+ _isPulling = true;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var error = await PullUseCase.ExecuteAsync();
|
|
|
+ if (error is not null)
|
|
|
+ _errorMessage = error;
|
|
|
+
|
|
|
+ _syncStatus = GitRepo.FetchAndGetSyncStatus();
|
|
|
+ _status = GitRepo.GetStatus();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _errorMessage = $"Pull error: {ex.Message}";
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ _isPulling = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string FormatDiff(string diff)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(diff))
|
|
|
+ return "<span class=\"text-zinc-500\">No diff available</span>";
|
|
|
+
|
|
|
+ var lines = diff.Split('\n');
|
|
|
+ var sb = new System.Text.StringBuilder();
|
|
|
+
|
|
|
+ foreach (var line in lines)
|
|
|
+ {
|
|
|
+ var escaped = System.Net.WebUtility.HtmlEncode(line);
|
|
|
+ if (line.StartsWith('+') && !line.StartsWith("+++"))
|
|
|
+ sb.AppendLine($"<span class=\"text-emerald-400\">{escaped}</span>");
|
|
|
+ else if (line.StartsWith('-') && !line.StartsWith("---"))
|
|
|
+ sb.AppendLine($"<span class=\"text-red-400\">{escaped}</span>");
|
|
|
+ else if (line.StartsWith("@@"))
|
|
|
+ sb.AppendLine($"<span class=\"text-blue-400\">{escaped}</span>");
|
|
|
+ else if (line.StartsWith("diff "))
|
|
|
+ sb.AppendLine($"<span class=\"text-amber-300 font-bold\">{escaped}</span>");
|
|
|
+ else
|
|
|
+ sb.AppendLine($"<span class=\"text-zinc-400\">{escaped}</span>");
|
|
|
+ }
|
|
|
+
|
|
|
+ return sb.ToString();
|
|
|
+ }
|
|
|
+
|
|
|
+ public void Dispose()
|
|
|
+ {
|
|
|
+ _cts?.Cancel();
|
|
|
+ _cts?.Dispose();
|
|
|
+ _timer?.Dispose();
|
|
|
+ }
|
|
|
+}
|