GitStatusIndicator.razor 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. @using RackPeek.Domain.Git
  2. @using RackPeek.Domain.Git.UseCases
  3. @inject InitRepoUseCase InitRepo
  4. @inject CommitAllUseCase CommitAll
  5. @inject RestoreAllUseCase RestoreAll
  6. @inject PushUseCase PushUseCase
  7. @inject PullUseCase PullUseCase
  8. @inject AddRemoteUseCase AddRemoteUseCase
  9. @inject IGitRepository GitRepo
  10. @implements IDisposable
  11. @if (_status == GitRepoStatus.NotAvailable)
  12. {
  13. <div class="flex items-center gap-2 text-sm" data-testid="git-init-indicator">
  14. @if (_confirmInit)
  15. {
  16. <span class="text-zinc-400 text-xs">Enable git tracking?</span>
  17. <button class="px-2 py-0.5 text-xs rounded bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
  18. disabled="@_isInitializing"
  19. data-testid="git-init-confirm"
  20. @onclick="InitRepoAsync">
  21. @(_isInitializing ? "..." : "Yes")
  22. </button>
  23. <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
  24. data-testid="git-init-cancel"
  25. @onclick="() => _confirmInit = false">
  26. No
  27. </button>
  28. }
  29. else
  30. {
  31. <button class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-emerald-400 hover:bg-zinc-800 transition"
  32. data-testid="git-init-button"
  33. @onclick="() => _confirmInit = true">
  34. Enable Git
  35. </button>
  36. }
  37. @if (_errorMessage is not null)
  38. {
  39. <span class="text-red-400 text-xs" data-testid="git-error">@_errorMessage</span>
  40. }
  41. </div>
  42. }
  43. else
  44. {
  45. <div class="relative flex items-center gap-2 text-sm" data-testid="git-status-indicator">
  46. @if (!string.IsNullOrEmpty(_branch) && _hasRemote)
  47. {
  48. <button class="text-zinc-400 text-xs hover:text-emerald-400 transition"
  49. data-testid="git-branch"
  50. @onclick="ToggleHistoryAsync">
  51. @_branch
  52. </button>
  53. }
  54. @if (_status == GitRepoStatus.Clean && _hasRemote)
  55. {
  56. <span class="inline-block w-2 h-2 rounded-full bg-emerald-400"
  57. data-testid="git-status-dot-clean"
  58. title="All changes committed"></span>
  59. <span class="text-zinc-500 text-xs" data-testid="git-status-text">
  60. Saved
  61. </span>
  62. }
  63. else if (_status == GitRepoStatus.Dirty && _hasRemote)
  64. {
  65. <span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse"
  66. data-testid="git-status-dot-dirty"
  67. title="Uncommitted changes"></span>
  68. <div class="relative flex" data-testid="git-save-group">
  69. <button class="px-2 py-1 text-xs rounded-l bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
  70. disabled="@(_isBusy || !_hasRemote)"
  71. data-testid="git-save-button"
  72. @onclick="CommitAsync">
  73. @(_isCommitting ? "Saving..." : "Save")
  74. </button>
  75. <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"
  76. disabled="@(_isBusy || !_hasRemote)"
  77. data-testid="git-save-dropdown"
  78. @onclick="ToggleDropdown">
  79. &#9662;
  80. </button>
  81. @if (_showDropdown)
  82. {
  83. <div class="absolute top-full right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded shadow-lg z-50 min-w-[120px]"
  84. data-testid="git-dropdown-menu">
  85. <button class="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 hover:text-white transition"
  86. data-testid="git-diff-button"
  87. @onclick="OpenDiffAsync">
  88. Diff
  89. </button>
  90. @if (!_confirmDiscard)
  91. {
  92. <button class="w-full text-left px-3 py-2 text-xs text-zinc-400 hover:bg-zinc-700 hover:text-red-400 transition"
  93. data-testid="git-discard-button"
  94. @onclick="() => _confirmDiscard = true">
  95. Discard
  96. </button>
  97. }
  98. else
  99. {
  100. <div class="px-3 py-2 flex items-center gap-2">
  101. <span class="text-red-400 text-xs">Sure?</span>
  102. <button class="px-2 py-0.5 text-xs rounded bg-red-600 hover:bg-red-500 text-white transition"
  103. data-testid="git-discard-confirm"
  104. @onclick="DiscardAsync">
  105. Yes
  106. </button>
  107. <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
  108. data-testid="git-discard-cancel"
  109. @onclick="() => _confirmDiscard = false">
  110. No
  111. </button>
  112. </div>
  113. }
  114. </div>
  115. }
  116. </div>
  117. }
  118. @if (!_hasRemote)
  119. {
  120. <div class="relative flex items-center gap-1" data-testid="git-remote-group">
  121. @if (_showAddRemote)
  122. {
  123. <input type="text"
  124. 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"
  125. placeholder="https://github.com/user/repo.git"
  126. @bind="_remoteUrl"
  127. @bind:event="oninput"
  128. data-testid="git-remote-url" />
  129. <button class="px-2 py-0.5 text-xs rounded bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
  130. disabled="@(string.IsNullOrWhiteSpace(_remoteUrl))"
  131. data-testid="git-remote-save"
  132. @onclick="AddRemoteAsync">
  133. Add
  134. </button>
  135. <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
  136. data-testid="git-remote-cancel"
  137. @onclick="CancelAddRemote">
  138. &times;
  139. </button>
  140. }
  141. else
  142. {
  143. <button class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-emerald-400 hover:bg-zinc-800 transition"
  144. data-testid="git-add-remote-button"
  145. @onclick="() => _showAddRemote = true">
  146. Add Remote
  147. </button>
  148. }
  149. <span class="text-xs text-zinc-500">
  150. Connect a remote repository to enable saving and sync.
  151. </span>
  152. </div>
  153. }
  154. else
  155. {
  156. <div class="relative flex" data-testid="git-sync-group">
  157. <button class="px-2 py-1 text-xs rounded text-zinc-400 hover:text-white hover:bg-zinc-700 transition disabled:opacity-50"
  158. disabled="@_isSyncing"
  159. data-testid="git-sync-button"
  160. @onclick="ToggleSyncAsync">
  161. @if (_isFetching)
  162. {
  163. <span>Checking...</span>
  164. }
  165. else if (_syncStatus.Ahead > 0 || _syncStatus.Behind > 0)
  166. {
  167. <span>
  168. Sync
  169. @if (_syncStatus.Ahead > 0) { <span class="text-emerald-400">↑@_syncStatus.Ahead</span> }
  170. @if (_syncStatus.Behind > 0) { <span class="text-blue-400">↓@_syncStatus.Behind</span> }
  171. </span>
  172. }
  173. else
  174. {
  175. <span>Sync</span>
  176. }
  177. </button>
  178. @if (_showSyncDropdown)
  179. {
  180. <div class="absolute top-full right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded shadow-lg z-50 min-w-[140px]"
  181. data-testid="git-sync-dropdown">
  182. <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"
  183. disabled="@_isSyncing"
  184. data-testid="git-push-button"
  185. @onclick="PushAsync">
  186. @(_isPushing ? "Pushing..." : "Push")
  187. </button>
  188. <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"
  189. disabled="@(_isSyncing || _syncStatus.Error is not null)"
  190. data-testid="git-pull-button"
  191. @onclick="PullAsync">
  192. @(_isPulling ? "Pulling..." : "Pull")
  193. </button>
  194. </div>
  195. }
  196. </div>
  197. }
  198. @if (_errorMessage is not null)
  199. {
  200. <span class="text-red-400 text-xs">@_errorMessage</span>
  201. }
  202. </div>
  203. }
  204. @code {
  205. private GitRepoStatus _status = GitRepoStatus.NotAvailable;
  206. private string _branch = "";
  207. private bool _isCommitting;
  208. private bool _isRestoring;
  209. private bool _confirmDiscard;
  210. private bool _showDropdown;
  211. private bool _showAddRemote;
  212. private bool _showSyncDropdown;
  213. private bool _showHistory;
  214. private bool _hasRemote;
  215. private bool _isFetching;
  216. private bool _isPushing;
  217. private bool _isPulling;
  218. private bool _isInitializing;
  219. private bool _confirmInit;
  220. private string? _errorMessage;
  221. private string _remoteUrl = "";
  222. private string _diffContent = "";
  223. private string[] _changedFiles = [];
  224. private GitLogEntry[] _logEntries = [];
  225. private PeriodicTimer? _timer;
  226. private CancellationTokenSource? _cts;
  227. private GitSyncStatus _syncStatus = new(0,0,false);
  228. private bool _isBusy => _isCommitting || _isRestoring || _isSyncing;
  229. private bool _isSyncing => _isPushing || _isPulling || _isFetching;
  230. protected override async Task OnInitializedAsync()
  231. {
  232. _status = GitRepo.GetStatus();
  233. if (_status == GitRepoStatus.NotAvailable)
  234. return;
  235. _branch = GitRepo.GetCurrentBranch();
  236. _hasRemote = GitRepo.HasRemote();
  237. _cts = new CancellationTokenSource();
  238. _timer = new PeriodicTimer(TimeSpan.FromSeconds(15));
  239. _ = PollStatusAsync(_cts.Token);
  240. await Task.CompletedTask;
  241. }
  242. private async Task PollStatusAsync(CancellationToken ct)
  243. {
  244. try
  245. {
  246. while (_timer != null && await _timer.WaitForNextTickAsync(ct))
  247. {
  248. if (_isBusy)
  249. continue;
  250. var newStatus = GitRepo.GetStatus();
  251. if (newStatus != _status)
  252. {
  253. _status = newStatus;
  254. await InvokeAsync(StateHasChanged);
  255. }
  256. }
  257. }
  258. catch (OperationCanceledException) {}
  259. }
  260. private async Task InitRepoAsync()
  261. {
  262. _errorMessage = null;
  263. _isInitializing = true;
  264. try
  265. {
  266. var error = await InitRepo.ExecuteAsync();
  267. if (error != null)
  268. {
  269. _errorMessage = error;
  270. return;
  271. }
  272. _status = GitRepo.GetStatus();
  273. _branch = GitRepo.GetCurrentBranch();
  274. _hasRemote = GitRepo.HasRemote();
  275. }
  276. catch (Exception ex)
  277. {
  278. _errorMessage = $"Init error: {ex.Message}";
  279. }
  280. finally
  281. {
  282. _isInitializing = false;
  283. }
  284. }
  285. private void CancelAddRemote()
  286. {
  287. _showAddRemote = false;
  288. _remoteUrl = "";
  289. }
  290. private async Task AddRemoteAsync()
  291. {
  292. _errorMessage = null;
  293. try
  294. {
  295. var error = await AddRemoteUseCase.ExecuteAsync(_remoteUrl);
  296. if (error != null)
  297. {
  298. _errorMessage = error;
  299. return;
  300. }
  301. _hasRemote = true;
  302. _showAddRemote = false;
  303. _remoteUrl = "";
  304. _syncStatus = GitRepo.FetchAndGetSyncStatus();
  305. if (_syncStatus.Behind > 0)
  306. await PullAsync();
  307. }
  308. catch (Exception ex)
  309. {
  310. _errorMessage = $"Remote error: {ex.Message}";
  311. }
  312. }
  313. private async Task CommitAsync()
  314. {
  315. if (!_hasRemote)
  316. {
  317. _errorMessage = "Add a remote repository before saving.";
  318. return;
  319. }
  320. _errorMessage = null;
  321. _isCommitting = true;
  322. try
  323. {
  324. var error = await CommitAll.ExecuteAsync(
  325. $"rackpeek: save config {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
  326. if (error != null)
  327. _errorMessage = error;
  328. _status = GitRepo.GetStatus();
  329. }
  330. catch (Exception ex)
  331. {
  332. _errorMessage = $"Commit error: {ex.Message}";
  333. }
  334. finally
  335. {
  336. _isCommitting = false;
  337. }
  338. }
  339. private void ToggleDropdown()
  340. {
  341. _showDropdown = !_showDropdown;
  342. _showSyncDropdown = false;
  343. }
  344. private void ToggleSyncAsync()
  345. {
  346. _showSyncDropdown = !_showSyncDropdown;
  347. _showDropdown = false;
  348. if (_showSyncDropdown)
  349. {
  350. _isFetching = true;
  351. try
  352. {
  353. _syncStatus = GitRepo.FetchAndGetSyncStatus();
  354. }
  355. finally
  356. {
  357. _isFetching = false;
  358. }
  359. }
  360. }
  361. private async Task PushAsync()
  362. {
  363. if (!_hasRemote)
  364. {
  365. _errorMessage = "Add a remote first.";
  366. return;
  367. }
  368. _errorMessage = null;
  369. _isPushing = true;
  370. try
  371. {
  372. var error = await PushUseCase.ExecuteAsync();
  373. if (error != null)
  374. _errorMessage = error;
  375. _syncStatus = GitRepo.FetchAndGetSyncStatus();
  376. }
  377. catch (Exception ex)
  378. {
  379. _errorMessage = $"Push error: {ex.Message}";
  380. }
  381. finally
  382. {
  383. _isPushing = false;
  384. }
  385. }
  386. private async Task PullAsync()
  387. {
  388. _errorMessage = null;
  389. _isPulling = true;
  390. try
  391. {
  392. var error = await PullUseCase.ExecuteAsync();
  393. if (error != null)
  394. _errorMessage = error;
  395. _syncStatus = GitRepo.FetchAndGetSyncStatus();
  396. _status = GitRepo.GetStatus();
  397. }
  398. catch (Exception ex)
  399. {
  400. _errorMessage = $"Pull error: {ex.Message}";
  401. }
  402. finally
  403. {
  404. _isPulling = false;
  405. }
  406. }
  407. public void Dispose()
  408. {
  409. _cts?.Cancel();
  410. _cts?.Dispose();
  411. _timer?.Dispose();
  412. }
  413. private void OpenDiffAsync()
  414. {
  415. _showDropdown = false;
  416. try
  417. {
  418. _changedFiles = GitRepo.GetChangedFiles();
  419. _diffContent = GitRepo.GetDiff();
  420. }
  421. catch (Exception ex)
  422. {
  423. _errorMessage = $"Diff error: {ex.Message}";
  424. }
  425. }
  426. private async Task DiscardAsync()
  427. {
  428. _errorMessage = null;
  429. _isRestoring = true;
  430. _confirmDiscard = false;
  431. _showDropdown = false;
  432. try
  433. {
  434. var error = await RestoreAll.ExecuteAsync();
  435. if (error != null)
  436. _errorMessage = error;
  437. _status = GitRepo.GetStatus();
  438. }
  439. catch (Exception ex)
  440. {
  441. _errorMessage = $"Discard error: {ex.Message}";
  442. }
  443. finally
  444. {
  445. _isRestoring = false;
  446. }
  447. }
  448. private void ToggleHistoryAsync()
  449. {
  450. if (_showHistory)
  451. {
  452. _showHistory = false;
  453. return;
  454. }
  455. try
  456. {
  457. _logEntries = GitRepo.GetLog(20);
  458. _showHistory = true;
  459. }
  460. catch (Exception ex)
  461. {
  462. _errorMessage = $"History error: {ex.Message}";
  463. }
  464. }
  465. }