From 3888276cf316a1125e346322026fa4d59e20a893 Mon Sep 17 00:00:00 2001 From: Arash Shabbeh Date: Sat, 22 Aug 2026 01:36:47 +0300 Subject: [PATCH] feat(cli): add progress checklist --- VERSION | 2 +- apps.Tests/LiveProgressRendererTests.cs | 103 ++++ apps/AnsiStyle.cs | 3 + apps/CheckOrchestrator.cs | 13 +- apps/Components/Chrome/ChromeExtScanner.cs | 6 + apps/Components/Docker/DockerImageScanner.cs | 6 + apps/Components/Dotnet/DotnetScanner.cs | 3 + apps/Components/Go/GoScanner.cs | 6 + .../JetBrains/JetBrainsPluginScanner.cs | 6 + .../MacOs/MacApplicationsScanner.cs | 6 + apps/Components/Node/NodeScanner.cs | 3 + apps/Components/VsCode/VsCodeExtScanner.cs | 6 + .../Windows/WindowsApplicationsScanner.cs | 6 + apps/IScanner.cs | 6 + apps/LiveProgressRenderer.cs | 459 +++++++++++++----- apps/Orchestrator.cs | 1 + apps/ScanOrchestrator.cs | 12 +- 17 files changed, 524 insertions(+), 123 deletions(-) create mode 100644 apps.Tests/LiveProgressRendererTests.cs diff --git a/VERSION b/VERSION index 4bf7ee7..f795f57 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.14-beta +0.0.15-beta diff --git a/apps.Tests/LiveProgressRendererTests.cs b/apps.Tests/LiveProgressRendererTests.cs new file mode 100644 index 0000000..88a7b19 --- /dev/null +++ b/apps.Tests/LiveProgressRendererTests.cs @@ -0,0 +1,103 @@ +using apps.Tests.Fakes; + +namespace apps.Tests; + +/// Covers checklist state transitions and per-scanner progress counts. +public sealed class LiveProgressRendererTests +{ + [Test] + public async Task Checklist_TransitionsThroughScanAndCheckStates() + { + var scanner = new FakeScanner { Name = "JetBrains", Kind = AppKind.Extension }; + var renderer = new LiveProgressRenderer([scanner]); + + renderer.StartScan([scanner]); + await AssertState(renderer, scanner.Name, ChecklistProgressState.Waiting); + + renderer.RenderScannerActive(scanner.Name); + renderer.RenderScannerProgress(scanner.Name, 2); + await AssertState(renderer, scanner.Name, ChecklistProgressState.Scanning); + + renderer.RenderScannerDone(scanner.Name); + await AssertState(renderer, scanner.Name, ChecklistProgressState.Waiting); + + renderer.StartCheck([(scanner, 2)]); + renderer.RenderCheckActive(scanner.Name); + await AssertState(renderer, scanner.Name, ChecklistProgressState.Checking); + + renderer.RenderCheckProgress(scanner.Name, updateAvailable: false, failed: false); + var checking = renderer.GetChecklistSnapshot(scanner.Name); + await Assert.That(checking.Checked).IsEqualTo(1); + await Assert.That(checking.State).IsEqualTo(ChecklistProgressState.Checking); + + renderer.RenderCheckProgress(scanner.Name, updateAvailable: true, failed: false); + renderer.RenderCheckComplete(); + + var completed = renderer.GetChecklistSnapshot(scanner.Name); + await Assert.That(completed.State).IsEqualTo(ChecklistProgressState.Completed); + await Assert.That(completed.Discovered).IsEqualTo(2); + await Assert.That(completed.CheckTotal).IsEqualTo(2); + await Assert.That(completed.Checked).IsEqualTo(2); + await Assert.That(completed.Updates).IsEqualTo(1); + await Assert.That(completed.Failures).IsEqualTo(0); + } + + [Test] + public async Task Checklist_ScanFailureRemainsFailedAfterCheckStage() + { + var scanner = new FakeScanner { Name = "Broken" }; + var renderer = new LiveProgressRenderer([scanner]); + + renderer.StartScan([scanner]); + renderer.RenderScannerActive(scanner.Name); + renderer.RenderScannerFailed(scanner.Name); + renderer.StartCheck(Array.Empty<(IScanner Scanner, int Total)>()); + renderer.RenderCheckComplete(); + + await AssertState(renderer, scanner.Name, ChecklistProgressState.Failed); + } + + [Test] + public async Task Checklist_CheckErrorCompletesAsFailed() + { + var scanner = new FakeScanner { Name = "Broken" }; + var renderer = new LiveProgressRenderer([scanner]); + + renderer.StartScan([scanner]); + renderer.RenderScannerProgress(scanner.Name, 1); + renderer.RenderScannerDone(scanner.Name); + renderer.StartCheck([(scanner, 1)]); + renderer.RenderCheckActive(scanner.Name); + renderer.RenderCheckProgress(scanner.Name, updateAvailable: false, failed: true); + renderer.RenderCheckComplete(); + + var failed = renderer.GetChecklistSnapshot(scanner.Name); + await Assert.That(failed.State).IsEqualTo(ChecklistProgressState.Failed); + await Assert.That(failed.Failures).IsEqualTo(1); + } + + [Test] + public async Task Checklist_DryRunCompletesSuccessfulScanners() + { + var scanner = new FakeScanner { Name = "ScanOnly" }; + var renderer = new LiveProgressRenderer([scanner]); + + renderer.StartScan([scanner]); + renderer.RenderScannerProgress(scanner.Name, 3); + renderer.RenderScannerDone(scanner.Name); + renderer.RenderDryRunComplete(); + + var completed = renderer.GetChecklistSnapshot(scanner.Name); + await Assert.That(completed.State).IsEqualTo(ChecklistProgressState.Completed); + await Assert.That(completed.Discovered).IsEqualTo(3); + } + + private static async Task AssertState( + LiveProgressRenderer renderer, + string scannerName, + ChecklistProgressState expected) + { + var snapshot = renderer.GetChecklistSnapshot(scannerName); + await Assert.That(snapshot.State).IsEqualTo(expected); + } +} diff --git a/apps/AnsiStyle.cs b/apps/AnsiStyle.cs index b12184d..8cdb567 100644 --- a/apps/AnsiStyle.cs +++ b/apps/AnsiStyle.cs @@ -17,6 +17,9 @@ internal static class AnsiStyle /// Applies red foreground. internal static string Red(string s) => IsAnsi ? $"\e[31m{s}\e[0m" : s; + /// Applies white foreground. + internal static string White(string s) => IsAnsi ? $"\e[37m{s}\e[0m" : s; + /// Applies cyan foreground. internal static string Cyan(string s) => IsAnsi ? $"\e[36m{s}\e[0m" : s; diff --git a/apps/CheckOrchestrator.cs b/apps/CheckOrchestrator.cs index b9bd04f..8070b23 100644 --- a/apps/CheckOrchestrator.cs +++ b/apps/CheckOrchestrator.cs @@ -40,10 +40,9 @@ public sealed class CheckOrchestrator(IEnumerable scanners, LiveProgre appGroups.Add((scanner, groupedByScanner)); } - var totalToCheck = appGroups.Sum(g => g.Apps.Length); - renderer.SetCheckTotal(totalToCheck); + renderer.StartCheck(appGroups.Select(g => (g.Scanner, g.Apps.Length)).ToArray()); - // Periodic timer to refresh the check progress line with updated elapsed time + // Periodic timer to animate the check checklist and refresh elapsed time. using var timerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var timerTask = renderer.RunCheckTimerAsync(timerCts.Token); @@ -66,7 +65,7 @@ public sealed class CheckOrchestrator(IEnumerable scanners, LiveProgre app.CheckFailed = app.CheckFailed || error; checkedApps.Add(app); - renderer.RenderCheckActive(total); + renderer.RenderCheckProgress(app.App.Source.Name, app.HasUpdate, app.CheckFailed); } await timerCts.CancelAsync().ConfigureAwait(false); @@ -80,7 +79,7 @@ public sealed class CheckOrchestrator(IEnumerable scanners, LiveProgre } var updates = checkedApps.Count(c => c.HasUpdate); - renderer.RenderCheckComplete(total, updates, errors); + renderer.RenderCheckComplete(); logger.LogInformation( "Check complete: {Total} checked, {Updates} updates, {Errors} errors", total, updates, errors); @@ -88,11 +87,13 @@ public sealed class CheckOrchestrator(IEnumerable scanners, LiveProgre return (total, updates, errors); } - private static async Task RunCheckGroupAsync( + private async Task RunCheckGroupAsync( (IScanner Scanner, AppRecord[] Apps) group, ChannelWriter<(AppRecord App, bool Error)> writer, CancellationToken cancellationToken) { + renderer.RenderCheckActive(group.Scanner.Name); + await foreach (var result in group.Scanner.CheckAsync(group.Apps, cancellationToken).ConfigureAwait(false)) { await writer.WriteAsync(result, cancellationToken).ConfigureAwait(false); diff --git a/apps/Components/Chrome/ChromeExtScanner.cs b/apps/Components/Chrome/ChromeExtScanner.cs index edd2259..46c23f6 100644 --- a/apps/Components/Chrome/ChromeExtScanner.cs +++ b/apps/Components/Chrome/ChromeExtScanner.cs @@ -26,6 +26,12 @@ public sealed class ChromeExtScanner(IHttpClientFactory httpClientFactory, ILogg /// public string DisplayName => "Chrome"; + /// + public string ProgressLabel => "Chrome Extensions"; + + /// + public string ProgressItemNoun => "extension"; + public OS SupportedOS => OS.MacOS | OS.Windows; public AppKind Kind => AppKind.Extension; diff --git a/apps/Components/Docker/DockerImageScanner.cs b/apps/Components/Docker/DockerImageScanner.cs index e21b4df..2e09215 100644 --- a/apps/Components/Docker/DockerImageScanner.cs +++ b/apps/Components/Docker/DockerImageScanner.cs @@ -27,6 +27,12 @@ public sealed class DockerImageScanner(IProcessRunner runner, IHttpClientFactory /// public string DisplayName => "Docker"; + /// + public string ProgressLabel => "Docker Images"; + + /// + public string ProgressItemNoun => "image"; + public OS SupportedOS => OS.MacOS | OS.Windows; public AppKind Kind => AppKind.DevTool; diff --git a/apps/Components/Dotnet/DotnetScanner.cs b/apps/Components/Dotnet/DotnetScanner.cs index 4542f3b..5efabd4 100644 --- a/apps/Components/Dotnet/DotnetScanner.cs +++ b/apps/Components/Dotnet/DotnetScanner.cs @@ -29,6 +29,9 @@ internal sealed record DotnetComponent(string Name, string Version, string Path) /// public string DisplayName => ".NET"; + /// + public string ProgressLabel => ".NET"; + public OS SupportedOS => OS.MacOS | OS.Windows; public AppKind Kind => AppKind.DevTool; diff --git a/apps/Components/Go/GoScanner.cs b/apps/Components/Go/GoScanner.cs index 7e64921..c717253 100644 --- a/apps/Components/Go/GoScanner.cs +++ b/apps/Components/Go/GoScanner.cs @@ -20,6 +20,12 @@ public sealed class GoScanner(IProcessRunner runner, IHttpClientFactory httpClie /// public string DisplayName => "Go"; + /// + public string ProgressLabel => "Go Tools"; + + /// + public string ProgressItemNoun => "tool"; + public OS SupportedOS => OS.MacOS | OS.Windows; public AppKind Kind => AppKind.DevTool; diff --git a/apps/Components/JetBrains/JetBrainsPluginScanner.cs b/apps/Components/JetBrains/JetBrainsPluginScanner.cs index 8d2c3aa..f75103e 100644 --- a/apps/Components/JetBrains/JetBrainsPluginScanner.cs +++ b/apps/Components/JetBrains/JetBrainsPluginScanner.cs @@ -27,6 +27,12 @@ public sealed class JetBrainsPluginScanner(IHttpClientFactory httpClientFactory, /// public string DisplayName => "JetBrains"; + /// + public string ProgressLabel => "JetBrains Plugins"; + + /// + public string ProgressItemNoun => "plugin"; + public OS SupportedOS => OS.MacOS | OS.Windows; public AppKind Kind => AppKind.Extension; diff --git a/apps/Components/MacOs/MacApplicationsScanner.cs b/apps/Components/MacOs/MacApplicationsScanner.cs index 1d0e02e..f45c1b5 100644 --- a/apps/Components/MacOs/MacApplicationsScanner.cs +++ b/apps/Components/MacOs/MacApplicationsScanner.cs @@ -38,6 +38,12 @@ public sealed partial class MacApplicationsScanner( /// public string DisplayName => "Application"; + /// + public string ProgressLabel => "Applications"; + + /// + public string ProgressItemNoun => "app"; + public OS SupportedOS => OS.MacOS; public AppKind Kind => AppKind.App | AppKind.Extension | AppKind.Package; diff --git a/apps/Components/Node/NodeScanner.cs b/apps/Components/Node/NodeScanner.cs index d478058..5bb7706 100644 --- a/apps/Components/Node/NodeScanner.cs +++ b/apps/Components/Node/NodeScanner.cs @@ -32,6 +32,9 @@ public sealed class NodeScanner(IProcessRunner runner, IHttpClientFactory httpCl /// public string DisplayName => "Node"; + /// + public string ProgressLabel => "Node"; + public OS SupportedOS => OS.MacOS | OS.Windows; public AppKind Kind => AppKind.DevTool | AppKind.Package; diff --git a/apps/Components/VsCode/VsCodeExtScanner.cs b/apps/Components/VsCode/VsCodeExtScanner.cs index c2520f2..e9c1fc1 100644 --- a/apps/Components/VsCode/VsCodeExtScanner.cs +++ b/apps/Components/VsCode/VsCodeExtScanner.cs @@ -22,6 +22,12 @@ public sealed class VsCodeExtScanner(IProcessRunner runner, IHttpClientFactory h /// public string DisplayName => "VS Code"; + /// + public string ProgressLabel => "VS Code Extensions"; + + /// + public string ProgressItemNoun => "extension"; + public OS SupportedOS => OS.MacOS | OS.Windows; public AppKind Kind => AppKind.Extension; diff --git a/apps/Components/Windows/WindowsApplicationsScanner.cs b/apps/Components/Windows/WindowsApplicationsScanner.cs index b6c8d1b..7c0ecd5 100644 --- a/apps/Components/Windows/WindowsApplicationsScanner.cs +++ b/apps/Components/Windows/WindowsApplicationsScanner.cs @@ -25,6 +25,12 @@ public sealed class WindowsApplicationsScanner(IProcessRunner runner, ILogger public string DisplayName => "Application"; + /// + public string ProgressLabel => "Applications"; + + /// + public string ProgressItemNoun => "app"; + public OS SupportedOS => OS.Windows; public AppKind Kind => AppKind.App; diff --git a/apps/IScanner.cs b/apps/IScanner.cs index 2f6ca8c..bfdbd06 100644 --- a/apps/IScanner.cs +++ b/apps/IScanner.cs @@ -12,6 +12,12 @@ public interface IScanner /// Human-readable label shown in the Source output column (e.g. "App Store", ".NET"). string DisplayName { get; } + /// Human-readable category label shown in the progress checklist. + string ProgressLabel => DisplayName; + + /// Singular name for an item reported by this scanner. + string ProgressItemNoun => "item"; + OS SupportedOS { get; } AppKind Kind { get; } diff --git a/apps/LiveProgressRenderer.cs b/apps/LiveProgressRenderer.cs index e342760..966fd10 100644 --- a/apps/LiveProgressRenderer.cs +++ b/apps/LiveProgressRenderer.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using apps.Components.Audit; @@ -18,18 +19,23 @@ public sealed class LiveProgressRenderer(IEnumerable scanners) .DistinctBy(s => s.Name, StringComparer.Ordinal) .ToDictionary(s => s.Name, StringComparer.Ordinal); + private readonly Dictionary _checklistRows = new(StringComparer.Ordinal); + private readonly List _checklistOrder = []; + + private static readonly string[] SpinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + private const string Dash = "—"; // Text of the current single-line status (scan or check progress). // Empty means no active line is displayed. private string _currentStatusLine = ""; - private int _totalScanners; - private int _completedScanners; - private int _totalToCheck; - private readonly Stopwatch _phaseStopwatch = new(); - private int _checkDone; + private readonly Stopwatch _checklistStopwatch = new(); + private string _checklistHeading = ""; + private int _renderedChecklistLines; + private int _spinnerFrame; + private bool _checklistVisible; private int _auditDone; private int _auditTotal; @@ -46,127 +52,186 @@ public static void RenderClear() } } - /// Sets the total number of active scanners so the progress bar knows its denominator. - public void SetScannerCount(int total) + /// Starts a checklist for the active scanners. + public void StartScan(IReadOnlyList activeScanners) { lock (_lock) { - _totalScanners = total; - _completedScanners = 0; + _checklistRows.Clear(); + _checklistOrder.Clear(); + + foreach (var scanner in activeScanners.DistinctBy(s => s.Name, StringComparer.Ordinal)) + { + _checklistRows.Add(scanner.Name, new ChecklistRow(scanner)); + _checklistOrder.Add(scanner.Name); + } + + _checklistHeading = "Scanning"; + _spinnerFrame = 0; + _checklistVisible = true; + _checklistStopwatch.Restart(); _phaseStopwatch.Restart(); + RenderChecklist(); } } - /// Sets the total number of apps that will be checked so the check progress bar is accurate. - public void SetCheckTotal(int total) + /// Starts the check stage for each scanner with discovered items. + public void StartCheck(IReadOnlyList<(IScanner Scanner, int Total)> groups) { lock (_lock) { - _totalToCheck = total; + var totals = groups.ToDictionary(g => g.Scanner.Name, g => g.Total, StringComparer.Ordinal); + foreach (var row in _checklistRows.Values) + { + row.CheckStarted = true; + row.CheckTotal = totals.GetValueOrDefault(row.Scanner.Name); + row.State = row.ScanFailed + ? ChecklistProgressState.Failed + : row.CheckTotal == 0 + ? ChecklistProgressState.Completed + : ChecklistProgressState.Waiting; + } + + _checklistHeading = "Checking"; _phaseStopwatch.Restart(); + RenderChecklist(); } } - /// - /// Updates the single scan-progress line in-place while scanners are active. - /// Shows a progress bar indicating how many scanners have completed. - /// On a real TTY the line is overwritten via ANSI; on redirected output a new line is - /// printed only for the first scanner to avoid polluting piped output. - /// + /// Marks a scanner as active. public void RenderScannerActive(string scannerName) { lock (_lock) { - var bar = AnsiStyle.ProgressBar(_completedScanners, _totalScanners); - var label = AnsiStyle.Cyan("Scanning"); - var elapsed = FormatElapsed(_phaseStopwatch.Elapsed.TotalSeconds); - var line = $"{bar} {label} {AnsiStyle.Bold(scannerName)}… {elapsed}"; - var firstLine = _currentStatusLine.Length == 0; - _currentStatusLine = line; - - if (AnsiStyle.IsAnsi) + if (_checklistRows.TryGetValue(scannerName, out var row)) { - Console.Error.Write($"\r\e[2K{line}"); + row.State = ChecklistProgressState.Scanning; + RenderChecklist(); } - else if (firstLine) + } + } + + /// Updates the number of items found by a scanner. + public void RenderScannerProgress(string scannerName, int discovered) + { + lock (_lock) + { + if (_checklistRows.TryGetValue(scannerName, out var row)) { - Console.Error.WriteLine($"● Scanning {scannerName}…"); + row.Discovered = discovered; + RenderChecklist(); } } } - /// Marks a scanner as completed and refreshes the progress bar. + /// Marks a scanner as waiting for the check stage. public void RenderScannerDone(string scannerName) { lock (_lock) { - _completedScanners++; + if (_checklistRows.TryGetValue(scannerName, out var row)) + { + row.State = ChecklistProgressState.Waiting; + RenderChecklist(); + } + } + } - var bar = AnsiStyle.ProgressBar(_completedScanners, _totalScanners); - var label = AnsiStyle.Cyan("Scanning"); - var elapsed = FormatElapsed(_phaseStopwatch.Elapsed.TotalSeconds); - var line = $"{bar} {label} {AnsiStyle.Dim(scannerName)} ✓ {elapsed}"; - _currentStatusLine = line; + /// Marks a scanner as failed. + public void RenderScannerFailed(string scannerName) + { + lock (_lock) + { + if (_checklistRows.TryGetValue(scannerName, out var row)) + { + row.ScanFailed = true; + row.State = ChecklistProgressState.Failed; + RenderChecklist(); + } + } + } - if (AnsiStyle.IsAnsi) + /// Marks the scan stage as complete while the checklist waits for checks. + public void RenderScanComplete() + { + lock (_lock) + { + _checklistHeading = "Scan complete"; + RenderChecklist(); + } + } + + /// Completes the checklist after a scan-only run. + public void RenderDryRunComplete() + { + lock (_lock) + { + foreach (var row in _checklistRows.Values.Where(r => !r.ScanFailed)) { - Console.Error.Write($"\r\e[2K{line}"); + row.State = ChecklistProgressState.Completed; } + + _checklistHeading = "Scan complete"; + FinalizeChecklist(); } } - /// Clears the scan-progress line and prints the total discovered count. - public void RenderScanComplete(int total) + /// Marks a scanner as actively checking its discovered items. + public void RenderCheckActive(string scannerName) { lock (_lock) { - ClearStatusLine(); - var bar = AnsiStyle.ProgressBar(_totalScanners, _totalScanners); - var elapsed = FormatElapsed(_phaseStopwatch.Elapsed.TotalSeconds); - Console.Error.WriteLine($"{bar} {AnsiStyle.Green("✓")} Discovered {AnsiStyle.Bold(total.ToString())} apps {elapsed}"); - _currentStatusLine = ""; + if (_checklistRows.TryGetValue(scannerName, out var row)) + { + row.State = ChecklistProgressState.Checking; + RenderChecklist(); + } } } - /// - /// Updates the single check-progress line in-place as each result arrives. - /// Shows a progress bar indicating how many checks have completed out of the total. - /// - public void RenderCheckActive(int done) + /// Updates one scanner's completed check, update, and failure counts. + public void RenderCheckProgress(string scannerName, bool updateAvailable, bool failed) { lock (_lock) { - _checkDone = done; - var bar = AnsiStyle.ProgressBar(done, _totalToCheck); - var label = AnsiStyle.Magenta("Checking"); - var elapsed = FormatElapsed(_phaseStopwatch.Elapsed.TotalSeconds); - var line = $"{bar} {label} {AnsiStyle.Bold(done.ToString())}/{_totalToCheck} apps… {elapsed}"; - _currentStatusLine = line; + if (!_checklistRows.TryGetValue(scannerName, out var row)) + { + return; + } - if (AnsiStyle.IsAnsi) + row.Checked++; + row.Updates += updateAvailable ? 1 : 0; + row.Failures += failed ? 1 : 0; + if (row.Checked >= row.CheckTotal) { - Console.Error.Write($"\r\e[2K{line}"); + row.State = row.ScanFailed || row.Failures > 0 + ? ChecklistProgressState.Failed + : ChecklistProgressState.Completed; } + + RenderChecklist(); } } - /// - /// Clears the check-progress line and prints a summary of the check phase. - /// - public void RenderCheckComplete(int total, int updates, int errors) + /// Completes the checklist after all update checks finish. + public void RenderCheckComplete() { lock (_lock) { - ClearStatusLine(); - _currentStatusLine = ""; + foreach (var row in _checklistRows.Values) + { + if (row.CheckTotal > row.Checked) + { + row.Failures += row.CheckTotal - row.Checked; + } - var bar = AnsiStyle.ProgressBar(total, total); - var elapsed = FormatElapsed(_phaseStopwatch.Elapsed.TotalSeconds); - var updateStr = updates > 0 - ? AnsiStyle.Yellow($"{updates} update{(updates == 1 ? "" : "s")} available") - : AnsiStyle.Green("up to date"); - var errorPart = errors > 0 ? " " + AnsiStyle.Red($"{errors} error{(errors == 1 ? "" : "s")}") : ""; - Console.Error.WriteLine($"{bar} {AnsiStyle.Green("✓")} Checked {AnsiStyle.Bold(total.ToString())} apps — {updateStr}{errorPart} {elapsed}"); + row.State = row.ScanFailed || row.Failures > 0 + ? ChecklistProgressState.Failed + : ChecklistProgressState.Completed; + } + + _checklistHeading = "Completed"; + FinalizeChecklist(); } } @@ -176,13 +241,6 @@ public void RenderCheckComplete(int total, int updates, int errors) /// public void RenderTable(IReadOnlyList apps) { - // On a real terminal, wipe the transient progress/status lines so results start on a clean screen. - // Skipped when output is redirected (no in-place progress was drawn, and clearing would throw). - if (AnsiStyle.IsAnsi) - { - RenderClear(); - } - PrintTableFmt(apps); } @@ -191,6 +249,15 @@ public void RenderError(string message) { lock (_lock) { + if (_checklistVisible) + { + ClearChecklist(); + Console.Error.WriteLine(AnsiStyle.Red($"✗ {message}")); + _renderedChecklistLines = 0; + RenderChecklist(); + return; + } + ClearStatusLine(); Console.Error.WriteLine(AnsiStyle.Red($"✗ {message}")); RestoreStatusLine(); @@ -361,12 +428,30 @@ private void RestoreStatusLine() } private static string FormatElapsed(double seconds) - => AnsiStyle.DarkGray($"[{seconds:F1}s]"); + => AnsiStyle.DarkGray($"[{seconds.ToString("F1", CultureInfo.InvariantCulture)}s]"); - /// - /// Background task that refreshes the scan progress line every 100ms with updated elapsed time. - /// - public async Task RunScanTimerAsync(CancellationToken cancellationToken) + /// Refreshes the checklist spinner and elapsed time during scanning. + public Task RunScanTimerAsync(CancellationToken cancellationToken) => RunChecklistTimerAsync(cancellationToken); + + /// Refreshes the checklist spinner and elapsed time during update checks. + public Task RunCheckTimerAsync(CancellationToken cancellationToken) => RunChecklistTimerAsync(cancellationToken); + + internal ChecklistProgressSnapshot GetChecklistSnapshot(string scannerName) + { + lock (_lock) + { + var row = _checklistRows[scannerName]; + return new ChecklistProgressSnapshot( + row.State, + row.Discovered, + row.CheckTotal, + row.Checked, + row.Updates, + row.Failures); + } + } + + private async Task RunChecklistTimerAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { @@ -381,51 +466,169 @@ public async Task RunScanTimerAsync(CancellationToken cancellationToken) lock (_lock) { - if (!AnsiStyle.IsAnsi || _currentStatusLine.Length == 0) + if (!AnsiStyle.IsAnsi || !_checklistVisible) { continue; } - var bar = AnsiStyle.ProgressBar(_completedScanners, _totalScanners); - var label = AnsiStyle.Cyan("Scanning"); - var elapsed = FormatElapsed(_phaseStopwatch.Elapsed.TotalSeconds); - var line = $"{bar} {label} {elapsed}"; - _currentStatusLine = line; - Console.Error.Write($"\r\e[2K{line}"); + _spinnerFrame = (_spinnerFrame + 1) % SpinnerFrames.Length; + RenderChecklist(); } } } - /// - /// Background task that refreshes the check progress line every 100ms with updated elapsed time. - /// - public async Task RunCheckTimerAsync(CancellationToken cancellationToken) + private void FinalizeChecklist() { - while (!cancellationToken.IsCancellationRequested) + _checklistStopwatch.Stop(); + if (AnsiStyle.IsAnsi) { - try + RenderChecklist(); + } + else + { + foreach (var line in BuildChecklistLines(int.MaxValue)) { - await Task.Delay(100, cancellationToken).ConfigureAwait(false); + Console.Error.WriteLine(line); } - catch (OperationCanceledException) + } + + Console.Error.WriteLine(); + _checklistVisible = false; + _renderedChecklistLines = 0; + } + + private void RenderChecklist() + { + if (!AnsiStyle.IsAnsi || !_checklistVisible) + { + return; + } + + var lines = BuildChecklistLines(GetConsoleWidth()); + ClearChecklist(); + foreach (var line in lines) + { + Console.Error.Write("\e[2K"); + Console.Error.WriteLine(line); + } + + _renderedChecklistLines = lines.Length; + } + + private void ClearChecklist() + { + if (!AnsiStyle.IsAnsi || _renderedChecklistLines == 0) + { + return; + } + + Console.Error.Write($"\e[{_renderedChecklistLines}F"); + for (var i = 0; i < _renderedChecklistLines; i++) + { + Console.Error.Write("\e[2K"); + if (i < _renderedChecklistLines - 1) { - break; + Console.Error.Write("\e[1E"); } + } - lock (_lock) - { - if (!AnsiStyle.IsAnsi || _currentStatusLine.Length == 0) - { - continue; - } + if (_renderedChecklistLines > 1) + { + Console.Error.Write($"\e[{_renderedChecklistLines - 1}F"); + } + } - var bar = AnsiStyle.ProgressBar(_checkDone, _totalToCheck); - var label = AnsiStyle.Magenta("Checking"); - var elapsed = FormatElapsed(_phaseStopwatch.Elapsed.TotalSeconds); - var line = $"{bar} {label} {AnsiStyle.Bold(_checkDone.ToString())}/{_totalToCheck} apps… {elapsed}"; - _currentStatusLine = line; - Console.Error.Write($"\r\e[2K{line}"); - } + private string[] BuildChecklistLines(int width) + { + var elapsed = _checklistStopwatch.Elapsed.TotalSeconds.ToString("F1", CultureInfo.InvariantCulture); + var heading = $"{_checklistHeading} [{elapsed}s]"; + var lines = new string[_checklistOrder.Count + 2]; + lines[0] = TruncateChecklistText(heading, width); + lines[1] = ""; + + for (var i = 0; i < _checklistOrder.Count; i++) + { + lines[i + 2] = FormatChecklistRow(_checklistRows[_checklistOrder[i]], width); + } + + return lines; + } + + private string FormatChecklistRow(ChecklistRow row, int width) + { + var indicator = row.State switch + { + ChecklistProgressState.Waiting => AnsiStyle.DarkGray("[ ]"), + ChecklistProgressState.Scanning => AnsiStyle.White($"[{SpinnerFrames[_spinnerFrame]}]"), + ChecklistProgressState.Checking => AnsiStyle.Yellow($"[{SpinnerFrames[_spinnerFrame]}]"), + ChecklistProgressState.Completed => AnsiStyle.Green("[✓]"), + ChecklistProgressState.Failed => AnsiStyle.Red("[!]"), + _ => throw new InvalidOperationException($"Unknown checklist state: {row.State}") + }; + var text = row.Scanner.ProgressLabel + FormatChecklistDetails(row); + return indicator + " " + TruncateChecklistText(text, Math.Max(1, width - 4)); + } + + private static string FormatChecklistDetails(ChecklistRow row) + { + if (row.Discovered == 0 && row.State is ChecklistProgressState.Waiting or ChecklistProgressState.Scanning) + { + return ""; + } + + var count = FormatCount(row.Discovered, row.Scanner.ProgressItemNoun); + if (!row.CheckStarted) + { + return $": {count}"; + } + + if (row.State == ChecklistProgressState.Checking) + { + return $": {count} · checking {row.Checked}/{row.CheckTotal}"; + } + + if (row.ScanFailed) + { + return $": {count} · scan failed"; + } + + if (row.Failures > 0) + { + var updates = row.Updates > 0 ? $" · {FormatCount(row.Updates, "update")}" : ""; + return $": {count}{updates} · {FormatCount(row.Failures, "failure")}"; + } + + if (row.CheckTotal == 0) + { + return $": {count} · nothing to check"; + } + + return row.Updates > 0 + ? $": {count} · {FormatCount(row.Updates, "update")}" + : $": {count} · up to date"; + } + + private static string FormatCount(int count, string noun) => $"{count} {noun}{(count == 1 ? "" : "s")}"; + + private static string TruncateChecklistText(string text, int width) + { + if (width <= 1) + { + return text[..Math.Min(text.Length, width)]; + } + + return text.Length <= width ? text : text[..(width - 1)] + "…"; + } + + private static int GetConsoleWidth() + { + try + { + return Math.Max(20, Console.WindowWidth - 1); + } + catch (IOException) + { + return 120; } } @@ -886,4 +1089,34 @@ private static string ExtractCaskToken(string detail) var colonIdx = afterPrefix.IndexOf(':'); return colonIdx > 0 ? afterPrefix[..colonIdx].ToString() : afterPrefix.ToString(); } -} \ No newline at end of file + + private sealed class ChecklistRow(IScanner scanner) + { + public IScanner Scanner { get; } = scanner; + public ChecklistProgressState State { get; set; } = ChecklistProgressState.Waiting; + public int Discovered { get; set; } + public int CheckTotal { get; set; } + public int Checked { get; set; } + public int Updates { get; set; } + public int Failures { get; set; } + public bool CheckStarted { get; set; } + public bool ScanFailed { get; set; } + } +} + +internal enum ChecklistProgressState +{ + Waiting, + Scanning, + Checking, + Completed, + Failed +} + +internal readonly record struct ChecklistProgressSnapshot( + ChecklistProgressState State, + int Discovered, + int CheckTotal, + int Checked, + int Updates, + int Failures); diff --git a/apps/Orchestrator.cs b/apps/Orchestrator.cs index c04d7de..fa1dcf7 100644 --- a/apps/Orchestrator.cs +++ b/apps/Orchestrator.cs @@ -54,6 +54,7 @@ public async Task InvokeAsync(PipelineOptions options, CancellationToken ca //.Select(PickBestRecord) .OrderBy(a => KindOrder(a.App.Kind)).ThenBy(a => a.App.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); + renderer.RenderDryRunComplete(); renderer.RenderTable(scanned); return 0; } diff --git a/apps/ScanOrchestrator.cs b/apps/ScanOrchestrator.cs index 7b2503c..d176483 100644 --- a/apps/ScanOrchestrator.cs +++ b/apps/ScanOrchestrator.cs @@ -32,12 +32,12 @@ public async Task> ScanAsync(AppKind? kind, Ca return []; } - renderer.SetScannerCount(activeScanners.Length); + renderer.StartScan(activeScanners); // Pre-establish HTTP connections to registry hosts while scanners run. var warmupTask = warmup.WarmAsync(cancellationToken); - // Periodic timer to refresh the scan progress line with updated elapsed time + // Periodic timer to animate the scan checklist and refresh elapsed time. using var timerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var timerTask = renderer.RunScanTimerAsync(timerCts.Token); @@ -80,7 +80,7 @@ public async Task> ScanAsync(AppKind? kind, Ca // Count sub-apps too (e.g. a Homebrew cask channel of a scanned bundle): they are // discovered here and checked later, so both totals cover the same set. var discoveredCount = results.Values.Sum(a => 1 + (a.SubApps?.Count ?? 0)); - renderer.RenderScanComplete(discoveredCount); + renderer.RenderScanComplete(); logger.LogInformation("Scan complete: {Total} apps discovered", discoveredCount); return results; } @@ -89,6 +89,7 @@ public async Task> ScanAsync(AppKind? kind, Ca { // TODO: needs optimization var discovered = await ScanAsync(kind: null, cancellationToken: cancellationToken).ConfigureAwait(false); + renderer.RenderDryRunComplete(); var match = discovered.FirstOrDefault(a => string.Equals(a.Value.Name, packageName, StringComparison.OrdinalIgnoreCase)); return match.Value; } @@ -131,6 +132,7 @@ private IScanner[] GetActiveScanners(AppKind? kind) private async Task RunScannerAsync(IScanner scanner, ChannelWriter writer, CancellationToken cancellationToken) { renderer.RenderScannerActive(scanner.Name); + var discovered = 0; try { @@ -142,6 +144,8 @@ private async Task RunScannerAsync(IScanner scanner, ChannelWriter