GitStatusIndicator.razor 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  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. @* Branch name — clickable to open history *@
  47. @if (!string.IsNullOrEmpty(_branch))
  48. {
  49. <button class="text-zinc-400 text-xs hover:text-emerald-400 transition"
  50. data-testid="git-branch"
  51. @onclick="ToggleHistoryAsync">
  52. @_branch
  53. </button>
  54. }
  55. @if (_status == GitRepoStatus.Clean)
  56. {
  57. <span class="inline-block w-2 h-2 rounded-full bg-emerald-400"
  58. data-testid="git-status-dot-clean"
  59. title="All changes committed">
  60. </span>
  61. <span class="text-zinc-500 text-xs" data-testid="git-status-text">
  62. Saved
  63. </span>
  64. }
  65. else if (_status == GitRepoStatus.Dirty)
  66. {
  67. <span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse"
  68. data-testid="git-status-dot-dirty"
  69. title="Uncommitted changes">
  70. </span>
  71. @* Save button with dropdown toggle *@
  72. <div class="relative flex" data-testid="git-save-group">
  73. <button class="px-2 py-1 text-xs rounded-l bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
  74. disabled="@_isBusy"
  75. data-testid="git-save-button"
  76. @onclick="CommitAsync">
  77. @(_isCommitting ? "Saving..." : "Save")
  78. </button>
  79. <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"
  80. disabled="@_isBusy"
  81. data-testid="git-save-dropdown"
  82. @onclick="ToggleDropdown">
  83. &#9662;
  84. </button>
  85. @if (_showDropdown)
  86. {
  87. <div class="absolute top-full right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded shadow-lg z-50 min-w-[120px]"
  88. data-testid="git-dropdown-menu">
  89. <button class="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 hover:text-white transition"
  90. data-testid="git-diff-button"
  91. @onclick="OpenDiffAsync">
  92. Diff
  93. </button>
  94. @if (!_confirmDiscard)
  95. {
  96. <button class="w-full text-left px-3 py-2 text-xs text-zinc-400 hover:bg-zinc-700 hover:text-red-400 transition"
  97. data-testid="git-discard-button"
  98. @onclick="() => _confirmDiscard = true">
  99. Discard
  100. </button>
  101. }
  102. else
  103. {
  104. <div class="px-3 py-2 flex items-center gap-2">
  105. <span class="text-red-400 text-xs">Sure?</span>
  106. <button class="px-2 py-0.5 text-xs rounded bg-red-600 hover:bg-red-500 text-white transition"
  107. data-testid="git-discard-confirm"
  108. @onclick="DiscardAsync">
  109. Yes
  110. </button>
  111. <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
  112. data-testid="git-discard-cancel"
  113. @onclick="() => _confirmDiscard = false">
  114. No
  115. </button>
  116. </div>
  117. }
  118. </div>
  119. }
  120. </div>
  121. }
  122. @* Sync / Remote section *@
  123. @if (!_hasRemote)
  124. {
  125. <div class="relative flex items-center gap-1" data-testid="git-remote-group">
  126. @if (_showAddRemote)
  127. {
  128. <input type="text"
  129. 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"
  130. placeholder="https://github.com/user/repo.git"
  131. @bind="_remoteUrl"
  132. @bind:event="oninput"
  133. data-testid="git-remote-url" />
  134. <button class="px-2 py-0.5 text-xs rounded bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
  135. disabled="@(string.IsNullOrWhiteSpace(_remoteUrl))"
  136. data-testid="git-remote-save"
  137. @onclick="AddRemoteAsync">
  138. Add
  139. </button>
  140. <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
  141. data-testid="git-remote-cancel"
  142. @onclick="CancelAddRemote">
  143. &times;
  144. </button>
  145. }
  146. else
  147. {
  148. <button class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-emerald-400 hover:bg-zinc-800 transition"
  149. data-testid="git-add-remote-button"
  150. @onclick="() => _showAddRemote = true">
  151. Add Remote
  152. </button>
  153. }
  154. </div>
  155. }
  156. else if (_hasRemote)
  157. {
  158. <div class="relative flex" data-testid="git-sync-group">
  159. <button class="px-2 py-1 text-xs rounded text-zinc-400 hover:text-white hover:bg-zinc-700 transition disabled:opacity-50"
  160. disabled="@_isSyncing"
  161. data-testid="git-sync-button"
  162. @onclick="ToggleSyncAsync">
  163. @if (_isFetching)
  164. {
  165. <span>Checking...</span>
  166. }
  167. else if (_syncStatus.Ahead > 0 || _syncStatus.Behind > 0)
  168. {
  169. <span>
  170. Sync
  171. @if (_syncStatus.Ahead > 0) { <span class="text-emerald-400">↑@_syncStatus.Ahead</span> }
  172. @if (_syncStatus.Behind > 0) { <span class="text-blue-400">↓@_syncStatus.Behind</span> }
  173. </span>
  174. }
  175. else
  176. {
  177. <span>Sync</span>
  178. }
  179. </button>
  180. @if (_showSyncDropdown)
  181. {
  182. <div class="absolute top-full right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded shadow-lg z-50 min-w-[140px]"
  183. data-testid="git-sync-dropdown">
  184. @if (_syncStatus.Error is not null)
  185. {
  186. <div class="px-3 py-2 text-xs text-red-400 border-b border-zinc-700">
  187. Fetch failed
  188. </div>
  189. }
  190. else if (_syncStatus.Ahead > 0 || _syncStatus.Behind > 0)
  191. {
  192. <div class="px-3 py-2 text-xs text-zinc-500 border-b border-zinc-700">
  193. @if (_syncStatus.Ahead > 0) { <span class="text-emerald-400">↑@_syncStatus.Ahead ahead</span> }
  194. @if (_syncStatus.Ahead > 0 && _syncStatus.Behind > 0) { <span> · </span> }
  195. @if (_syncStatus.Behind > 0) { <span class="text-blue-400">↓@_syncStatus.Behind behind</span> }
  196. </div>
  197. }
  198. else
  199. {
  200. <div class="px-3 py-2 text-xs text-zinc-500 border-b border-zinc-700">
  201. Up to date
  202. </div>
  203. }
  204. <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"
  205. disabled="@(_isSyncing)"
  206. data-testid="git-push-button"
  207. @onclick="PushAsync">
  208. @(_isPushing ? "Pushing..." : "Push")
  209. </button>
  210. <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"
  211. disabled="@(_isSyncing || _syncStatus.Error is not null)"
  212. data-testid="git-pull-button"
  213. @onclick="PullAsync">
  214. @(_isPulling ? "Pulling..." : "Pull")
  215. </button>
  216. </div>
  217. }
  218. </div>
  219. }
  220. @if (_errorMessage is not null)
  221. {
  222. <span class="text-red-400 text-xs" data-testid="git-error">
  223. @_errorMessage
  224. </span>
  225. }
  226. </div>
  227. @* Dropdown backdrop — closes all dropdowns on outside click *@
  228. @if (_showDropdown || _showSyncDropdown)
  229. {
  230. <div class="fixed inset-0 z-40" @onclick="CloseAllDropdowns"></div>
  231. }
  232. @* Diff Modal *@
  233. @if (_showDiff)
  234. {
  235. <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
  236. data-testid="git-diff-overlay"
  237. @onclick="CloseDiff">
  238. <div class="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col"
  239. @onclick:stopPropagation="true">
  240. <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
  241. <div class="flex items-center gap-3">
  242. <span class="text-sm font-semibold text-zinc-200">Changes</span>
  243. <span class="text-xs text-zinc-500">@_changedFiles.Length file(s)</span>
  244. </div>
  245. <button class="text-zinc-400 hover:text-white transition text-lg"
  246. data-testid="git-diff-close"
  247. @onclick="CloseDiff">
  248. &times;
  249. </button>
  250. </div>
  251. @if (_changedFiles.Length > 0)
  252. {
  253. <div class="px-4 py-2 border-b border-zinc-800 flex flex-wrap gap-2">
  254. @foreach (var file in _changedFiles)
  255. {
  256. var status = file.Length >= 2 ? file[..2].Trim() : "?";
  257. var name = file.Length >= 3 ? file[3..] : file;
  258. var color = status switch
  259. {
  260. "M" => "text-amber-400",
  261. "A" or "?" => "text-emerald-400",
  262. "D" => "text-red-400",
  263. _ => "text-zinc-400"
  264. };
  265. <span class="text-xs">
  266. <span class="@color font-bold">@status</span>
  267. <span class="text-zinc-300">@name</span>
  268. </span>
  269. }
  270. </div>
  271. }
  272. <div class="overflow-auto flex-1 p-4">
  273. <pre class="text-xs leading-relaxed whitespace-pre-wrap">@((MarkupString)FormatDiff(_diffContent))</pre>
  274. </div>
  275. </div>
  276. </div>
  277. }
  278. @* History Modal *@
  279. @if (_showHistory)
  280. {
  281. <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
  282. data-testid="git-history-overlay"
  283. @onclick="CloseHistory">
  284. <div class="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl w-[90vw] max-w-3xl max-h-[80vh] flex flex-col"
  285. @onclick:stopPropagation="true">
  286. <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
  287. <div class="flex items-center gap-3">
  288. <span class="text-sm font-semibold text-zinc-200">History</span>
  289. <span class="text-xs text-zinc-500">@_branch</span>
  290. <span class="text-xs text-zinc-600">@_logEntries.Length commits</span>
  291. </div>
  292. <button class="text-zinc-400 hover:text-white transition text-lg"
  293. data-testid="git-history-close"
  294. @onclick="CloseHistory">
  295. &times;
  296. </button>
  297. </div>
  298. <div class="overflow-auto flex-1">
  299. @if (_logEntries.Length == 0)
  300. {
  301. <div class="p-4 text-zinc-500 text-sm">No commits yet.</div>
  302. }
  303. else
  304. {
  305. <div class="divide-y divide-zinc-800">
  306. @foreach (var entry in _logEntries)
  307. {
  308. <div class="px-4 py-3 hover:bg-zinc-800/50 transition" data-testid="git-log-entry">
  309. <div class="flex items-center gap-3">
  310. <span class="text-xs text-emerald-400 font-mono shrink-0">@entry.Hash</span>
  311. <span class="text-sm text-zinc-200 truncate">@entry.Message</span>
  312. </div>
  313. <div class="flex items-center gap-3 mt-1">
  314. <span class="text-xs text-zinc-500">@entry.Author</span>
  315. <span class="text-xs text-zinc-600">@entry.Date</span>
  316. </div>
  317. </div>
  318. }
  319. </div>
  320. }
  321. </div>
  322. </div>
  323. </div>
  324. }
  325. }
  326. @code {
  327. private GitRepoStatus _status = GitRepoStatus.NotAvailable;
  328. private string _branch = string.Empty;
  329. private bool _isCommitting;
  330. private bool _isRestoring;
  331. private bool _confirmDiscard;
  332. private bool _showDropdown;
  333. private string? _errorMessage;
  334. private PeriodicTimer? _timer;
  335. private CancellationTokenSource? _cts;
  336. private bool _showDiff;
  337. private string _diffContent = string.Empty;
  338. private string[] _changedFiles = [];
  339. private bool _showHistory;
  340. private GitLogEntry[] _logEntries = [];
  341. private bool _hasRemote;
  342. private GitSyncStatus _syncStatus = new(0, 0, false);
  343. private bool _showSyncDropdown;
  344. private bool _isFetching;
  345. private bool _isPushing;
  346. private bool _isPulling;
  347. private bool _isInitializing;
  348. private bool _confirmInit;
  349. private bool _showAddRemote;
  350. private string _remoteUrl = string.Empty;
  351. private bool _isBusy => _isCommitting || _isRestoring || _isSyncing;
  352. private bool _isSyncing => _isPushing || _isPulling || _isFetching;
  353. protected override async Task OnInitializedAsync()
  354. {
  355. _status = GitRepo.GetStatus();
  356. if (_status == GitRepoStatus.NotAvailable)
  357. return;
  358. _branch = GitRepo.GetCurrentBranch();
  359. _hasRemote = GitRepo.HasRemote();
  360. _cts?.Cancel();
  361. _timer?.Dispose();
  362. _cts = new CancellationTokenSource();
  363. _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
  364. _ = PollStatusAsync(_cts.Token);
  365. await Task.CompletedTask;
  366. }
  367. private async Task PollStatusAsync(CancellationToken ct)
  368. {
  369. try
  370. {
  371. while (_timer != null && await _timer.WaitForNextTickAsync(ct))
  372. {
  373. if (_isBusy)
  374. continue;
  375. var newStatus = GitRepo.GetStatus();
  376. if (newStatus != _status)
  377. {
  378. _status = newStatus;
  379. _confirmDiscard = false;
  380. _showDropdown = false;
  381. await InvokeAsync(StateHasChanged);
  382. }
  383. }
  384. }
  385. catch (OperationCanceledException)
  386. {
  387. }
  388. }
  389. private async Task InitRepoAsync()
  390. {
  391. _errorMessage = null;
  392. _isInitializing = true;
  393. try
  394. {
  395. var error = await InitRepo.ExecuteAsync();
  396. if (error is not null)
  397. {
  398. _errorMessage = error;
  399. return;
  400. }
  401. _status = GitRepo.GetStatus();
  402. _branch = GitRepo.GetCurrentBranch();
  403. _hasRemote = GitRepo.HasRemote();
  404. _cts?.Cancel();
  405. _timer?.Dispose();
  406. _cts = new CancellationTokenSource();
  407. _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
  408. _ = PollStatusAsync(_cts.Token);
  409. }
  410. catch (Exception ex)
  411. {
  412. _errorMessage = $"Init error: {ex.Message}";
  413. }
  414. finally
  415. {
  416. _isInitializing = false;
  417. }
  418. }
  419. private void CancelAddRemote()
  420. {
  421. _showAddRemote = false;
  422. _remoteUrl = string.Empty;
  423. }
  424. private async Task AddRemoteAsync()
  425. {
  426. _errorMessage = null;
  427. try
  428. {
  429. var error = await AddRemoteUseCase.ExecuteAsync(_remoteUrl);
  430. if (error is not null)
  431. {
  432. _errorMessage = error;
  433. return;
  434. }
  435. _hasRemote = true;
  436. _showAddRemote = false;
  437. _remoteUrl = string.Empty;
  438. }
  439. catch (Exception ex)
  440. {
  441. _errorMessage = $"Remote error: {ex.Message}";
  442. }
  443. }
  444. private async Task CommitAsync()
  445. {
  446. _errorMessage = null;
  447. _isCommitting = true;
  448. _confirmDiscard = false;
  449. _showDropdown = false;
  450. try
  451. {
  452. var error = await CommitAll.ExecuteAsync(
  453. $"rackpeek: save config {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
  454. if (error is not null)
  455. _errorMessage = error;
  456. _status = GitRepo.GetStatus();
  457. }
  458. catch (Exception ex)
  459. {
  460. _errorMessage = $"Unexpected error: {ex.Message}";
  461. }
  462. finally
  463. {
  464. _isCommitting = false;
  465. }
  466. }
  467. private void ToggleDropdown()
  468. {
  469. _showDropdown = !_showDropdown;
  470. _showSyncDropdown = false;
  471. _confirmDiscard = false;
  472. }
  473. private void ToggleSyncAsync()
  474. {
  475. if (_showSyncDropdown)
  476. {
  477. _showSyncDropdown = false;
  478. return;
  479. }
  480. _showDropdown = false;
  481. _errorMessage = null;
  482. _isFetching = true;
  483. _showSyncDropdown = true;
  484. try
  485. {
  486. _syncStatus = GitRepo.FetchAndGetSyncStatus();
  487. if (_syncStatus.Error is not null)
  488. _errorMessage = _syncStatus.Error;
  489. }
  490. catch (Exception ex)
  491. {
  492. _errorMessage = $"Fetch error: {ex.Message}";
  493. }
  494. finally
  495. {
  496. _isFetching = false;
  497. }
  498. }
  499. private void CloseAllDropdowns()
  500. {
  501. _showDropdown = false;
  502. _showSyncDropdown = false;
  503. _confirmDiscard = false;
  504. }
  505. private void OpenDiffAsync()
  506. {
  507. _showDropdown = false;
  508. _changedFiles = GitRepo.GetChangedFiles();
  509. _diffContent = GitRepo.GetDiff();
  510. _showDiff = true;
  511. }
  512. private void CloseDiff()
  513. {
  514. _showDiff = false;
  515. _diffContent = string.Empty;
  516. _changedFiles = [];
  517. }
  518. private void ToggleHistoryAsync()
  519. {
  520. if (_showHistory)
  521. {
  522. CloseHistory();
  523. return;
  524. }
  525. _showDropdown = false;
  526. _showSyncDropdown = false;
  527. _logEntries = GitRepo.GetLog(20);
  528. _showHistory = true;
  529. }
  530. private void CloseHistory()
  531. {
  532. _showHistory = false;
  533. _logEntries = [];
  534. }
  535. private async Task DiscardAsync()
  536. {
  537. _errorMessage = null;
  538. _isRestoring = true;
  539. _confirmDiscard = false;
  540. _showDropdown = false;
  541. try
  542. {
  543. var error = await RestoreAll.ExecuteAsync();
  544. if (error is not null)
  545. _errorMessage = error;
  546. _status = GitRepo.GetStatus();
  547. }
  548. catch (Exception ex)
  549. {
  550. _errorMessage = $"Unexpected error: {ex.Message}";
  551. }
  552. finally
  553. {
  554. _isRestoring = false;
  555. }
  556. }
  557. private async Task PushAsync()
  558. {
  559. _errorMessage = null;
  560. _isPushing = true;
  561. try
  562. {
  563. var error = await PushUseCase.ExecuteAsync();
  564. if (error is not null)
  565. _errorMessage = error;
  566. _syncStatus = GitRepo.FetchAndGetSyncStatus();
  567. }
  568. catch (Exception ex)
  569. {
  570. _errorMessage = $"Push error: {ex.Message}";
  571. }
  572. finally
  573. {
  574. _isPushing = false;
  575. }
  576. }
  577. private async Task PullAsync()
  578. {
  579. _errorMessage = null;
  580. _isPulling = true;
  581. try
  582. {
  583. var error = await PullUseCase.ExecuteAsync();
  584. if (error is not null)
  585. _errorMessage = error;
  586. _syncStatus = GitRepo.FetchAndGetSyncStatus();
  587. _status = GitRepo.GetStatus();
  588. }
  589. catch (Exception ex)
  590. {
  591. _errorMessage = $"Pull error: {ex.Message}";
  592. }
  593. finally
  594. {
  595. _isPulling = false;
  596. }
  597. }
  598. private static string FormatDiff(string diff)
  599. {
  600. if (string.IsNullOrWhiteSpace(diff))
  601. return "<span class=\"text-zinc-500\">No diff available</span>";
  602. var lines = diff.Split('\n');
  603. var sb = new System.Text.StringBuilder();
  604. foreach (var line in lines)
  605. {
  606. var escaped = System.Net.WebUtility.HtmlEncode(line);
  607. if (line.StartsWith('+') && !line.StartsWith("+++"))
  608. sb.AppendLine($"<span class=\"text-emerald-400\">{escaped}</span>");
  609. else if (line.StartsWith('-') && !line.StartsWith("---"))
  610. sb.AppendLine($"<span class=\"text-red-400\">{escaped}</span>");
  611. else if (line.StartsWith("@@"))
  612. sb.AppendLine($"<span class=\"text-blue-400\">{escaped}</span>");
  613. else if (line.StartsWith("diff "))
  614. sb.AppendLine($"<span class=\"text-amber-300 font-bold\">{escaped}</span>");
  615. else
  616. sb.AppendLine($"<span class=\"text-zinc-400\">{escaped}</span>");
  617. }
  618. return sb.ToString();
  619. }
  620. public void Dispose()
  621. {
  622. _cts?.Cancel();
  623. _cts?.Dispose();
  624. _timer?.Dispose();
  625. }
  626. }