diff --git a/cli/Sources/Noora/Components/ProgressBarStep.swift b/cli/Sources/Noora/Components/ProgressBarStep.swift index 2072e87e..3f957328 100644 --- a/cli/Sources/Noora/Components/ProgressBarStep.swift +++ b/cli/Sources/Noora/Components/ProgressBarStep.swift @@ -2,13 +2,23 @@ import Foundation import Logging import Rainbow +public struct ProgressBarUpdate: Sendable, Equatable { + public let progress: Double + public let detail: String? + + public init(progress: Double, detail: String? = nil) { + self.progress = progress + self.detail = detail + } +} + struct ProgressBarStep { // MARK: - Attributes let message: String let successMessage: String? let errorMessage: String? - let task: (@escaping (Double) -> Void) async throws -> V + let task: (@escaping (ProgressBarUpdate) -> Void) async throws -> V let theme: Theme let terminal: Terminaling let renderer: Rendering @@ -24,7 +34,7 @@ struct ProgressBarStep { message: String, successMessage: String?, errorMessage: String?, - task: @escaping (@escaping (Double) -> Void) async throws -> V, + task: @escaping (@escaping (ProgressBarUpdate) -> Void) async throws -> V, theme: Theme, terminal: Terminaling, renderer: Rendering, @@ -61,15 +71,17 @@ struct ProgressBarStep { var spinnerIcon: String? var lastProgress = 0.0 + var lastDetail: String? spinner.spin { icon in spinnerIcon = icon - render(progress: lastProgress, icon: spinnerIcon ?? "ℹ︎") + render(progress: lastProgress, icon: spinnerIcon ?? "ℹ︎", detail: lastDetail) } do { - let result = try await task { progress in - lastProgress = progress + let result = try await task { update in + lastProgress = update.progress + lastDetail = update.detail } renderer.render( .progressCompletionMessage( @@ -101,11 +113,11 @@ struct ProgressBarStep { let start = DispatchTime.now() do { - render(progress: 0, icon: "ℹ︎") + render(progress: 0, icon: "ℹ︎", detail: nil) // The updated progress is ignored in non-interactive environments - let result = try await task { progress in - render(progress: progress, icon: "ℹ︎") + let result = try await task { update in + render(progress: update.progress, icon: "ℹ︎", detail: update.detail) } let message: String = .progressCompletionMessage( @@ -132,7 +144,7 @@ struct ProgressBarStep { return "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) } - private func render(progress: Double, icon: String) { + private func render(progress: Double, icon: String, detail: String?) { let width = 30 let completed: Int if progress == 0.0 { @@ -143,8 +155,14 @@ struct ProgressBarStep { let completedBar = String(repeating: "█", count: completed) let incompleteBar = String(repeating: "▒", count: width - completed) let bar = completedBar + incompleteBar + let detailSuffix: String + if let detail, !detail.isEmpty { + detailSuffix = " (\(detail))" + } else { + detailSuffix = "" + } let output = - "\(icon.hexIfColoredTerminal(theme.primary, terminal)) \(message) \(bar.hexIfColoredTerminal(theme.primary, terminal)) \(Int(floor(progress * 100)))%" + "\(icon.hexIfColoredTerminal(theme.primary, terminal)) \(message) \(bar.hexIfColoredTerminal(theme.primary, terminal)) \(Int(floor(progress * 100)))%\(detailSuffix)" if terminal.isInteractive { renderer.render( output, diff --git a/cli/Sources/Noora/Noora.swift b/cli/Sources/Noora/Noora.swift index eda1d4ed..23dddc17 100644 --- a/cli/Sources/Noora/Noora.swift +++ b/cli/Sources/Noora/Noora.swift @@ -300,6 +300,23 @@ public protocol Noorable: Sendable { task: @escaping (@escaping (Double) -> Void) async throws -> V ) async throws -> V + /// Shows a progress bar step with optional detail text. + /// - Parameters: + /// - message: The message that represents "what's being done" + /// - successMessage: The message that the step gets updated to when the action completes. + /// - errorMessage: The message that the step gets updated to when the action errors. + /// - renderer: A rendering interface that holds the UI state. + /// - task: The asynchronous task to run. The caller can use the argument that the function takes to update the progress. + /// The value should be between 0 and 1. The detail text is appended after the percentage when present. + /// message. + func progressBarStep( + message: String, + successMessage: String?, + errorMessage: String?, + renderer: Rendering, + task: @escaping (@escaping (ProgressBarUpdate) -> Void) async throws -> V + ) async throws -> V + /// Displays a static table /// - Parameters: /// - headers: Column headers @@ -766,6 +783,25 @@ public final class Noora: Noorable { errorMessage: String?, renderer: Rendering, task: @escaping (@escaping (Double) -> Void) async throws -> V + ) async throws -> V { + try await progressBarStep( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + renderer: renderer + ) { update in + try await task { progress in + update(ProgressBarUpdate(progress: progress)) + } + } + } + + public func progressBarStep( + message: String, + successMessage: String?, + errorMessage: String?, + renderer: Rendering, + task: @escaping (@escaping (ProgressBarUpdate) -> Void) async throws -> V ) async throws -> V { try await ProgressBarStep( message: message, @@ -1303,7 +1339,6 @@ extension Noorable { message: message, successMessage: nil, errorMessage: nil, - renderer: Renderer(), task: task ) } @@ -1323,6 +1358,34 @@ extension Noorable { ) } + public func progressBarStep( + message: String, + task: @escaping (@escaping (ProgressBarUpdate) -> Void) async throws -> V + ) async throws -> V { + try await progressBarStep( + message: message, + successMessage: nil, + errorMessage: nil, + renderer: Renderer(), + task: task + ) + } + + public func progressBarStep( + message: String, + successMessage: String?, + errorMessage: String?, + task: @escaping (@escaping (ProgressBarUpdate) -> Void) async throws -> V + ) async throws -> V { + try await progressBarStep( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + renderer: Renderer(), + task: task + ) + } + public func table( headers: [String], rows: [[String]], diff --git a/cli/Sources/Noora/NooraMock.swift b/cli/Sources/Noora/NooraMock.swift index e24578de..2cca7862 100644 --- a/cli/Sources/Noora/NooraMock.swift +++ b/cli/Sources/Noora/NooraMock.swift @@ -259,6 +259,22 @@ ) } + public func progressBarStep( + message: String, + successMessage: String?, + errorMessage: String?, + renderer: Rendering, + task: @escaping (@escaping (ProgressBarUpdate) -> Void) async throws -> V + ) async throws -> V { + try await noora.progressBarStep( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + renderer: renderer, + task: task + ) + } + public func format(_ terminalText: TerminalText) -> String { noora.format(terminalText) } diff --git a/cli/Sources/examples-cli/Commands/ProgressBarStepCommand.swift b/cli/Sources/examples-cli/Commands/ProgressBarStepCommand.swift index 994671e2..54405e11 100644 --- a/cli/Sources/examples-cli/Commands/ProgressBarStepCommand.swift +++ b/cli/Sources/examples-cli/Commands/ProgressBarStepCommand.swift @@ -15,10 +15,13 @@ struct ProgressBarStepCommand: AsyncParsableCommand { ) { progress in let totalSteps = 100 let stepInterval: UInt64 = 4_000_000_000 / UInt64(totalSteps) // 4 seconds divided by steps + let totalSize = 2.33 for step in 0 ... totalSteps { let progressValue = Double(step) / Double(totalSteps) - progress(progressValue) + let downloaded = totalSize * progressValue + let detail = String(format: "%.2f GB/%.2f GB", downloaded, totalSize) + progress(ProgressBarUpdate(progress: progressValue, detail: detail)) if step < totalSteps { try await Task.sleep(nanoseconds: stepInterval) diff --git a/cli/Tests/NooraTests/Components/ProgressBarStepTests.swift b/cli/Tests/NooraTests/Components/ProgressBarStepTests.swift index ff31e301..67d330a9 100644 --- a/cli/Tests/NooraTests/Components/ProgressBarStepTests.swift +++ b/cli/Tests/NooraTests/Components/ProgressBarStepTests.swift @@ -49,9 +49,9 @@ struct ProgressBarStepTests { successMessage: "Project graph loaded", errorMessage: "Failed to load the project graph", task: { updateProgress in - updateProgress(0.1) - updateProgress(0.5) - updateProgress(0.9) + updateProgress(ProgressBarUpdate(progress: 0.1)) + updateProgress(ProgressBarUpdate(progress: 0.5)) + updateProgress(ProgressBarUpdate(progress: 0.9)) }, theme: Theme.test(), terminal: MockTerminal(isInteractive: false), @@ -87,9 +87,9 @@ struct ProgressBarStepTests { successMessage: nil, errorMessage: nil, task: { updateProgress in - updateProgress(0.1) - updateProgress(0.5) - updateProgress(0.9) + updateProgress(ProgressBarUpdate(progress: 0.1)) + updateProgress(ProgressBarUpdate(progress: 0.5)) + updateProgress(ProgressBarUpdate(progress: 0.9)) }, theme: Theme.test(), terminal: MockTerminal(isInteractive: false), @@ -114,6 +114,34 @@ struct ProgressBarStepTests { ) } + @Test func renders_detail_text_when_provided_in_non_interactive_terminal() async throws { + // Given + let standardOutput = MockStandardPipeline() + let standardError = MockStandardPipeline() + let standardPipelines = StandardPipelines(output: standardOutput, error: standardError) + + let subject = ProgressBarStep( + message: "Downloading artifacts", + successMessage: nil, + errorMessage: nil, + task: { updateProgress in + updateProgress(ProgressBarUpdate(progress: 0.33, detail: "123 MB/2.33 GB")) + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: false), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner, + logger: nil + ) + + // When + try await subject.run() + + // Then + #expect(standardOutput.writtenContent.value.range(of: "33% (123 MB/2.33 GB)") != nil) + } + @Test func renders_the_right_output_when_failure_and_non_interactive_terminal() async throws { // Given let standardOutput = MockStandardPipeline() @@ -159,11 +187,11 @@ struct ProgressBarStepTests { successMessage: "Project graph loaded", errorMessage: "Failed to load the project graph", task: { updateProgress in - updateProgress(0.1) + updateProgress(ProgressBarUpdate(progress: 0.1)) spinner.lastBlock?("⠋") - updateProgress(0.5) + updateProgress(ProgressBarUpdate(progress: 0.5)) spinner.lastBlock?("⠋") - updateProgress(0.9) + updateProgress(ProgressBarUpdate(progress: 0.9)) spinner.lastBlock?("⠋") }, theme: Theme.test(), diff --git a/docs/content/components/step/progress-bar.md b/docs/content/components/step/progress-bar.md index 8475184f..a5674a8b 100644 --- a/docs/content/components/step/progress-bar.md +++ b/docs/content/components/step/progress-bar.md @@ -27,7 +27,7 @@ This component represents a long-running step in the execution of a command show ### Example ```swift -try await Noora().progressStep( +try await Noora().progressBarStep( message: "Processing the graph", successMessage: "Project graph processed", errorMessage: "Failed to process the project graph" @@ -35,7 +35,9 @@ try await Noora().progressStep( for step in steps { try await runStep() // Use updateProgress to update the progress. The value should be between 0 and 1. - updateProgress(step / steps) + let progress = Double(step) / Double(steps) + let detail = "\(step) / \(steps) nodes" + updateProgress(ProgressBarUpdate(progress: progress, detail: detail)) } } ```