| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696 |
- @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.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();
- }
- }
|