Просмотр исходного кода

feat: Add git status indicator and controls to web UI header

Add a minimal git integration to the web UI that shows the current
branch name, change status, and provides save/diff/discard/history
controls directly in the header.

- GitService wrapping git CLI for status, commit, diff, restore, log
- NullGitService for WASM Viewer where git is not available
- GitStatusIndicator component with auto-polling every 5s
- Branch name clickable to open commit history modal
- Save button with dropdown for diff view and discard
- Color-coded diff output and changed file listing
- Gracefully hidden when git is not installed or not a repo

Ref #131
mavnezz 1 месяц назад
Родитель
Сommit
b4fae4c371

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

@@ -0,0 +1,178 @@
+using System.Diagnostics;
+
+namespace RackPeek.Domain.Git;
+
+public sealed class GitService : IGitService
+{
+    private readonly string _workingDirectory;
+    private readonly bool _isAvailable;
+
+    public GitService(string configDirectory)
+    {
+        _workingDirectory = configDirectory;
+        _isAvailable = CheckGitAvailable();
+    }
+
+    public bool IsAvailable => _isAvailable;
+
+    public async Task<GitRepoStatus> GetStatusAsync()
+    {
+        if (!_isAvailable)
+            return GitRepoStatus.NotAvailable;
+
+        var (exitCode, output) = await RunGitAsync("status", "--porcelain");
+        if (exitCode != 0)
+            return GitRepoStatus.NotAvailable;
+
+        return string.IsNullOrWhiteSpace(output)
+            ? GitRepoStatus.Clean
+            : GitRepoStatus.Dirty;
+    }
+
+    public async Task<string?> CommitAllAsync(string message)
+    {
+        if (!_isAvailable)
+            return "Git is not available.";
+
+        var (addExit, addOutput) = await RunGitAsync("add", "-A");
+        if (addExit != 0)
+            return $"git add failed: {addOutput}";
+
+        var (commitExit, commitOutput) = await RunGitAsync("commit", "-m", message);
+        if (commitExit != 0)
+        {
+            if (commitOutput.Contains("nothing to commit"))
+                return null;
+            return $"git commit failed: {commitOutput}";
+        }
+
+        return null;
+    }
+
+    public async Task<string[]> GetChangedFilesAsync()
+    {
+        if (!_isAvailable)
+            return [];
+
+        var (exitCode, output) = await RunGitAsync("status", "--porcelain");
+        if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
+            return [];
+
+        return output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+    }
+
+    public async Task<string> GetDiffAsync()
+    {
+        if (!_isAvailable)
+            return string.Empty;
+
+        // Show both staged and unstaged changes, plus untracked files
+        var (_, trackedDiff) = await RunGitAsync("diff", "HEAD");
+        var (_, untrackedFiles) = await RunGitAsync("ls-files", "--others", "--exclude-standard");
+
+        var result = trackedDiff;
+        if (!string.IsNullOrWhiteSpace(untrackedFiles))
+        {
+            var files = untrackedFiles.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+            foreach (var file in files)
+            {
+                var (_, content) = await RunGitAsync("diff", "--no-index", "/dev/null", file);
+                if (!string.IsNullOrWhiteSpace(content))
+                    result = string.IsNullOrWhiteSpace(result) ? content : $"{result}\n{content}";
+            }
+        }
+
+        return result;
+    }
+
+    public async Task<string?> RestoreAllAsync()
+    {
+        if (!_isAvailable)
+            return "Git is not available.";
+
+        // Restore tracked files
+        var (restoreExit, restoreOutput) = await RunGitAsync("checkout", "--", ".");
+        if (restoreExit != 0)
+            return $"git restore failed: {restoreOutput}";
+
+        // Remove untracked files
+        var (cleanExit, cleanOutput) = await RunGitAsync("clean", "-fd");
+        if (cleanExit != 0)
+            return $"git clean failed: {cleanOutput}";
+
+        return null;
+    }
+
+    public async Task<string> GetCurrentBranchAsync()
+    {
+        if (!_isAvailable)
+            return string.Empty;
+
+        var (exitCode, output) = await RunGitAsync("branch", "--show-current");
+        return exitCode == 0 ? output : string.Empty;
+    }
+
+    public async Task<GitLogEntry[]> GetLogAsync(int count = 20)
+    {
+        if (!_isAvailable)
+            return [];
+
+        var (exitCode, output) = await RunGitAsync(
+            "log", $"-{count}", "--format=%h\t%s\t%an\t%ar");
+
+        if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
+            return [];
+
+        return output
+            .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+            .Select(line =>
+            {
+                var parts = line.Split('\t', 4);
+                return parts.Length >= 4
+                    ? new GitLogEntry(parts[0], parts[1], parts[2], parts[3])
+                    : new GitLogEntry(parts.ElementAtOrDefault(0) ?? "", line, "", "");
+            })
+            .ToArray();
+    }
+
+    private bool CheckGitAvailable()
+    {
+        try
+        {
+            var gitCheck = RunGitAsync("--version").GetAwaiter().GetResult();
+            if (gitCheck.ExitCode != 0) return false;
+
+            var repoCheck = RunGitAsync("rev-parse", "--is-inside-work-tree")
+                .GetAwaiter().GetResult();
+            return repoCheck.ExitCode == 0;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+
+    private async Task<(int ExitCode, string Output)> RunGitAsync(params string[] arguments)
+    {
+        var psi = new ProcessStartInfo
+        {
+            FileName = "git",
+            WorkingDirectory = _workingDirectory,
+            RedirectStandardOutput = true,
+            RedirectStandardError = true,
+            UseShellExecute = false,
+            CreateNoWindow = true
+        };
+
+        foreach (var arg in arguments)
+            psi.ArgumentList.Add(arg);
+
+        using var process = Process.Start(psi)!;
+        var stdout = await process.StandardOutput.ReadToEndAsync();
+        var stderr = await process.StandardError.ReadToEndAsync();
+        await process.WaitForExitAsync();
+
+        var output = string.IsNullOrWhiteSpace(stdout) ? stderr : stdout;
+        return (process.ExitCode, output.Trim());
+    }
+}

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

@@ -0,0 +1,22 @@
+namespace RackPeek.Domain.Git;
+
+public enum GitRepoStatus
+{
+    NotAvailable,
+    Clean,
+    Dirty
+}
+
+public interface IGitService
+{
+    bool IsAvailable { get; }
+    Task<GitRepoStatus> GetStatusAsync();
+    Task<string?> CommitAllAsync(string message);
+    Task<string[]> GetChangedFilesAsync();
+    Task<string> GetDiffAsync();
+    Task<string?> RestoreAllAsync();
+    Task<string> GetCurrentBranchAsync();
+    Task<GitLogEntry[]> GetLogAsync(int count = 20);
+}
+
+public record GitLogEntry(string Hash, string Message, string Author, string Date);

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

@@ -0,0 +1,13 @@
+namespace RackPeek.Domain.Git;
+
+public sealed class NullGitService : IGitService
+{
+    public bool IsAvailable => false;
+    public Task<GitRepoStatus> GetStatusAsync() => Task.FromResult(GitRepoStatus.NotAvailable);
+    public Task<string?> CommitAllAsync(string message) => Task.FromResult<string?>("Not available.");
+    public Task<string[]> GetChangedFilesAsync() => Task.FromResult(Array.Empty<string>());
+    public Task<string> GetDiffAsync() => Task.FromResult(string.Empty);
+    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>());
+}

+ 2 - 0
RackPeek.Web.Viewer/Program.cs

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Web;
 using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
 using RackPeek.Domain;
+using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl;
@@ -25,6 +26,7 @@ public class Program {
         });
 
 
+        builder.Services.AddSingleton<IGitService>(new NullGitService());
         builder.Services.AddScoped<ITextFileStore, WasmTextFileStore>();
 
         var resources = new ResourceCollection();

+ 3 - 0
RackPeek.Web/Program.cs

@@ -2,6 +2,7 @@ using System.Text.Json.Serialization;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Hosting.StaticWebAssets;
 using RackPeek.Domain;
+using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Web.Api;
@@ -27,6 +28,8 @@ public class Program {
 
         Directory.CreateDirectory(yamlPath);
 
+        builder.Services.AddSingleton<IGitService>(new GitService(yamlPath));
+
         var yamlFilePath = Path.Combine(yamlPath, yamlFileName);
 
         if (!File.Exists(yamlFilePath)) {

+ 389 - 0
Shared.Rcl/Layout/GitStatusIndicator.razor

@@ -0,0 +1,389 @@
+@using RackPeek.Domain.Git
+@inject IGitService GitService
+@implements IDisposable
+
+@if (_status != GitRepoStatus.NotAvailable)
+{
+    <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">
+                    &#9662;
+                </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>
+        }
+
+        @if (_errorMessage is not null)
+        {
+            <span class="text-red-400 text-xs" data-testid="git-error">
+                @_errorMessage
+            </span>
+        }
+    </div>
+
+    @* Dropdown backdrop — closes dropdown on outside click *@
+    @if (_showDropdown)
+    {
+        <div class="fixed inset-0 z-40" @onclick="CloseDropdown"></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">
+                        &times;
+                    </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">
+                        &times;
+                    </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 _isBusy => _isCommitting || _isRestoring;
+
+    protected override async Task OnInitializedAsync()
+    {
+        _status = await GitService.GetStatusAsync();
+
+        if (_status == GitRepoStatus.NotAvailable)
+            return;
+
+        _branch = await GitService.GetCurrentBranchAsync();
+
+        _cts = new CancellationTokenSource();
+        _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
+        _ = PollStatusAsync(_cts.Token);
+    }
+
+    private async Task PollStatusAsync(CancellationToken ct)
+    {
+        try
+        {
+            while (await _timer!.WaitForNextTickAsync(ct))
+            {
+                if (_isBusy) continue;
+
+                var newStatus = await GitService.GetStatusAsync();
+                if (newStatus != _status)
+                {
+                    _status = newStatus;
+                    _confirmDiscard = false;
+                    _showDropdown = false;
+                    await InvokeAsync(StateHasChanged);
+                }
+            }
+        }
+        catch (OperationCanceledException) { }
+    }
+
+    private async Task CommitAsync()
+    {
+        _errorMessage = null;
+        _isCommitting = true;
+        _confirmDiscard = false;
+        _showDropdown = false;
+
+        try
+        {
+            var error = await GitService.CommitAllAsync(
+                $"rackpeek: save config {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
+
+            if (error is not null)
+                _errorMessage = error;
+
+            _status = await GitService.GetStatusAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Unexpected error: {ex.Message}";
+        }
+        finally
+        {
+            _isCommitting = false;
+        }
+    }
+
+    private void ToggleDropdown()
+    {
+        _showDropdown = !_showDropdown;
+        _confirmDiscard = false;
+    }
+
+    private void CloseDropdown()
+    {
+        _showDropdown = false;
+        _confirmDiscard = false;
+    }
+
+    private async Task OpenDiffAsync()
+    {
+        _showDropdown = false;
+        _changedFiles = await GitService.GetChangedFilesAsync();
+        _diffContent = await GitService.GetDiffAsync();
+        _showDiff = true;
+    }
+
+    private void CloseDiff()
+    {
+        _showDiff = false;
+        _diffContent = string.Empty;
+        _changedFiles = [];
+    }
+
+    private async Task ToggleHistoryAsync()
+    {
+        if (_showHistory)
+        {
+            CloseHistory();
+            return;
+        }
+
+        _showDropdown = false;
+        _logEntries = await GitService.GetLogAsync();
+        _showHistory = true;
+    }
+
+    private void CloseHistory()
+    {
+        _showHistory = false;
+        _logEntries = [];
+    }
+
+    private async Task DiscardAsync()
+    {
+        _errorMessage = null;
+        _isRestoring = true;
+        _confirmDiscard = false;
+        _showDropdown = false;
+
+        try
+        {
+            var error = await GitService.RestoreAllAsync();
+            if (error is not null)
+                _errorMessage = error;
+
+            _status = await GitService.GetStatusAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Unexpected error: {ex.Message}";
+        }
+        finally
+        {
+            _isRestoring = 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();
+    }
+}

+ 7 - 2
Shared.Rcl/Layout/MainLayout.razor

@@ -26,7 +26,10 @@
             </div>
         </NavLink>
 
-        <nav class="space-x-6 text-sm" data-testid="main-nav">
+        <div class="flex items-center gap-6">
+            <GitStatusIndicator />
+
+            <nav class="space-x-6 text-sm" data-testid="main-nav">
 
             <NavLink href=""
                      Match="NavLinkMatch.All"
@@ -79,7 +82,9 @@
             </NavLink>
 
 
-        </nav>
+
+            </nav>
+        </div>
     </header>
 
     <main class="p-6" data-testid="page-content">