diff --git a/.github/workflows/conventional-pr.yml b/.github/workflows/conventional-pr.yml index ceceb4c..0012e9d 100644 --- a/.github/workflows/conventional-pr.yml +++ b/.github/workflows/conventional-pr.yml @@ -17,4 +17,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - commitTitleMatch: "false" + commitTitleMatch: false diff --git a/Sources/Noora/Components/ProgressStep.swift b/Sources/Noora/Components/ProgressStep.swift new file mode 100644 index 0000000..039b512 --- /dev/null +++ b/Sources/Noora/Components/ProgressStep.swift @@ -0,0 +1,139 @@ +import Foundation +import Rainbow + +struct ProgressStep { + // MARK: - Attributes + + let message: String + let successMessage: String? + let errorMessage: String? + let showSpinner: Bool + let action: (@escaping (String) -> Void) async throws -> Void + let theme: Theme + let terminal: Terminaling + let renderer: Rendering + let standardPipelines: StandardPipelines + let spinner: Spinning + + init( + message: String, + successMessage: String?, + errorMessage: String?, + showSpinner: Bool, + action: @escaping (@escaping (String) -> Void) async throws -> Void, + theme: Theme, + terminal: Terminaling, + renderer: Rendering, + standardPipelines: StandardPipelines, + spinner: Spinning = Spinner() + ) { + self.message = message + self.successMessage = successMessage + self.errorMessage = errorMessage + self.showSpinner = showSpinner + self.action = action + self.theme = theme + self.terminal = terminal + self.renderer = renderer + self.standardPipelines = standardPipelines + self.spinner = spinner + } + + func run() async throws { + if terminal.isInteractive { + try await runInteractive() + } else { + try await runNonInteractive() + } + } + + func runNonInteractive() async throws { + let start = DispatchTime.now() + + do { + standardPipelines.output.write(content: "\("ℹ︎".hexIfColoredTerminal(theme.primary, terminal)) \(message)\n") + + try await action { progressMessage in + standardPipelines.output + .write(content: " \(progressMessage.hexIfColoredTerminal(theme.muted, terminal))\n") + } + + let message = ProgressStep + .completionMessage( + successMessage ?? message, + timeString: timeString(start: start), + theme: theme, + terminal: terminal + ) + standardPipelines.output.write(content: " \(message)\n") + } catch { + standardPipelines.error + .write( + content: " \("⨯".hexIfColoredTerminal(theme.danger, terminal)) \((errorMessage ?? message).hexIfColoredTerminal(theme.muted, terminal)) \(timeString(start: start))\n" + ) + throw error + } + } + + func runInteractive() async throws { + let start = DispatchTime.now() + + defer { + if showSpinner { + spinner.stop() + } + } + + var spinnerIcon: String? + var lastMessage = message + + if showSpinner { + spinner.spin { icon in + spinnerIcon = icon + render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + } + } + + // swiftlint:disable:next identifier_name + do { + render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + try await action { progressMessage in + lastMessage = progressMessage + render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + } + renderer.render( + ProgressStep + .completionMessage( + successMessage ?? message, + timeString: timeString(start: start), + theme: theme, + terminal: terminal + ), + standardPipeline: standardPipelines.output + ) + } catch { + renderer.render( + "\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message) \(timeString(start: start))", + standardPipeline: standardPipelines.error + ) + + throw error + } + } + + static func completionMessage(_ message: String, timeString: String? = nil, theme: Theme, terminal: Terminaling) -> String { + "\("✔︎".hexIfColoredTerminal(theme.success, terminal)) \(message)\(" \(timeString ?? "")")" + } + + private func render(message: String, icon: String) { + renderer.render( + "\(icon.hexIfColoredTerminal(theme.primary, terminal)) \(message)", + standardPipeline: standardPipelines.output + ) + } + + private func timeString(start: DispatchTime) -> String { + let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + return "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) + } +} diff --git a/Sources/Noora/Components/SingleChoicePrompt.swift b/Sources/Noora/Components/SingleChoicePrompt.swift index 790f83f..c530f23 100644 --- a/Sources/Noora/Components/SingleChoicePrompt.swift +++ b/Sources/Noora/Components/SingleChoicePrompt.swift @@ -63,7 +63,10 @@ struct SingleChoicePrompt .boldIfColoredTerminal(terminal) } content += " \(selectedOption.description)" - renderer.render(content, standardPipeline: standardPipelines.output) + renderer.render( + ProgressStep.completionMessage(content, theme: theme, terminal: terminal), + standardPipeline: standardPipelines.output + ) } private func renderOptions(selectedOption: T) { diff --git a/Sources/Noora/Components/YesOrNoChoicePrompt.swift b/Sources/Noora/Components/YesOrNoChoicePrompt.swift index 3d7186b..b432d05 100644 --- a/Sources/Noora/Components/YesOrNoChoicePrompt.swift +++ b/Sources/Noora/Components/YesOrNoChoicePrompt.swift @@ -62,7 +62,11 @@ struct YesOrNoChoicePrompt { .boldIfColoredTerminal(terminal) } content += " \(answer ? "Yes" : "No")" - renderer.render(content, standardPipeline: standardPipelines.output) + + renderer.render( + ProgressStep.completionMessage(content, theme: theme, terminal: terminal), + standardPipeline: standardPipelines.output + ) } private func renderOptions(answer: Bool) { diff --git a/Sources/Noora/Noora.swift b/Sources/Noora/Noora.swift index 6e2c6e8..3611b1c 100644 --- a/Sources/Noora/Noora.swift +++ b/Sources/Noora/Noora.swift @@ -111,6 +111,32 @@ public protocol Noorable { /// - Parameters: /// - alerts: The warning messages. func warning(_ alerts: WarningAlert...) + + /// Shows a progress step. + /// - Parameters: + /// - message: The message that represents "what's being done" + /// - action: The asynchronous task to run. The caller can use the argument that the function takes to update the step + /// message. + func progressStep( + message: String, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws + + /// Shows a progress step. + /// - 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. + /// - showSpinner: True to show a spinner. + /// - action: The asynchronous task to run. The caller can use the argument that the function takes to update the step + /// message. + func progressStep( + message: String, + successMessage: String?, + errorMessage: String?, + showSpinner: Bool, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws } public struct Noora: Noorable { @@ -200,4 +226,29 @@ public struct Noora: Noorable { theme: theme ).run() } + + public func progressStep(message: String, action: @escaping ((String) -> Void) async throws -> Void) async throws { + try await progressStep(message: message, successMessage: nil, errorMessage: nil, showSpinner: true, action: action) + } + + public func progressStep( + message: String, + successMessage: String? = nil, + errorMessage: String? = nil, + showSpinner: Bool = true, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws { + let progressStep = ProgressStep( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + showSpinner: showSpinner, + action: action, + theme: theme, + terminal: terminal, + renderer: Renderer(), + standardPipelines: StandardPipelines() + ) + try await progressStep.run() + } } diff --git a/Sources/Noora/Utilities/Spinner.swift b/Sources/Noora/Utilities/Spinner.swift index 6f6c761..fdb05f9 100644 --- a/Sources/Noora/Utilities/Spinner.swift +++ b/Sources/Noora/Utilities/Spinner.swift @@ -1,16 +1,11 @@ import Foundation -enum Spinner { - typealias Cancellable = () -> Void - - actor Counter { - var count: Int = 0 - - func increase() { - count += 1 - } - } +protocol Spinning { + func spin(_ block: @escaping (String) -> Void) + func stop() +} +class Spinner: Spinning { private static let frames = [ "⠋", "⠙", @@ -23,23 +18,34 @@ enum Spinner { "⠇", "⠏", ] + private var isSpinning = true + private var timer: Timer? - static func spin(_ block: @escaping (String) async -> Void) async -> Cancellable { - let counter = Counter() - await block(Spinner.frames[0]) + func spin(_ block: @escaping (String) -> Void) { + isSpinning = true - let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global()) - timer.schedule(deadline: .now(), repeating: 0.1) - timer.setEventHandler { - Task { - await block(Spinner.frames[await counter.count % Spinner.frames.count]) - await counter.increase() + DispatchQueue.global(qos: .userInitiated).async { + let runLoop = RunLoop.current + var index = 0 + + // Schedule the timer in the current run loop + self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + if self.isSpinning { + block(Spinner.frames[index]) + index = (index + 1) % Spinner.frames.count + } else { + self.timer?.invalidate() + } } - } - timer.resume() - return { - timer.cancel() + // Start the run loop to allow the timer to fire + while self.isSpinning, runLoop.run(mode: .default, before: .distantFuture) {} } } + + func stop() { + isSpinning = false + timer?.invalidate() + timer = nil + } } diff --git a/Sources/examples-cli/Commands/ProgressStepCommand.swift b/Sources/examples-cli/Commands/ProgressStepCommand.swift new file mode 100644 index 0000000..3fa609b --- /dev/null +++ b/Sources/examples-cli/Commands/ProgressStepCommand.swift @@ -0,0 +1,34 @@ +import ArgumentParser +import Foundation +import Noora + +struct ProgressStepCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "progress-step", + abstract: "A component to shows a progress step" + ) + + func run() async throws { + try await Noora().progressStep( + message: "Loading manifests", + successMessage: "Manifests loaded", + errorMessage: "Failed to load manifests" + ) { _ in + try await Task.sleep(nanoseconds: 2_000_000_000) + } + try await Noora().progressStep( + message: "Processing the graph", + successMessage: "Project graph processed", + errorMessage: "Failed to process the project graph" + ) { _ in + try await Task.sleep(nanoseconds: 2_000_000_000) + } + try await Noora().progressStep( + message: "Generating Xcode projects and workspace", + successMessage: "Xcode projects and workspace generated", + errorMessage: "Failed to generate Xcode workspace and projects" + ) { _ in + try await Task.sleep(nanoseconds: 2_000_000_000) + } + } +} diff --git a/Sources/examples-cli/ExamplesCLI.swift b/Sources/examples-cli/ExamplesCLI.swift index 1227e64..d3d28c3 100644 --- a/Sources/examples-cli/ExamplesCLI.swift +++ b/Sources/examples-cli/ExamplesCLI.swift @@ -6,6 +6,11 @@ import Rainbow struct ExamplesCLI: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "A command line tool to test the different components available in Noora.", - subcommands: [SingleChoicePromptCommand.self, YesOrNoChoicePromptCommand.self, AlertCommand.self] + subcommands: [ + SingleChoicePromptCommand.self, + YesOrNoChoicePromptCommand.self, + AlertCommand.self, + ProgressStepCommand.self, + ] ) } diff --git a/Tests/NooraTests/Components/ProgressStepTests.swift b/Tests/NooraTests/Components/ProgressStepTests.swift new file mode 100644 index 0000000..04a977c --- /dev/null +++ b/Tests/NooraTests/Components/ProgressStepTests.swift @@ -0,0 +1,197 @@ +import Testing + +@testable import Noora + +struct ProgressStepTests { + enum TestError: Error, Equatable { + case loadError + } + + let renderer = MockRenderer() + let spinner = MockSpinner() + + @Test func renders_the_right_output_when_success_and_non_interactive_terminal() async throws { + // Given + let standardOutput = MockStandardPipeline() + let standardError = MockStandardPipeline() + let standardPipelines = StandardPipelines(output: standardOutput, error: standardError) + + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: true, + action: { reportProgress in + reportProgress("Loading project at path Project/") + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: false), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + try await subject.run() + + // Then + #expect(standardOutput.writtenContent.contains(""" + ℹ︎ Loading project graph + Loading project at path Project/ + ✔︎ Project graph loaded + """) == true) + } + + @Test func renders_the_right_output_when_failure_and_non_interactive_terminal() async throws { + // Given + let standardOutput = MockStandardPipeline() + let standardError = MockStandardPipeline() + let standardPipelines = StandardPipelines(output: standardOutput, error: standardError) + let error = TestError.loadError + + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: true, + action: { _ in + throw error + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: false), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + await #expect(throws: error, performing: subject.run) + + // Then + print(standardError.writtenContent) + #expect(standardOutput.writtenContent.contains(""" + ℹ︎ Loading project graph + """) == true) + #expect(standardError.writtenContent.contains(""" + ⨯ Failed to load the project graph + """) == true) + } + + @Test func renders_the_right_output_when_spinner_and_success_and_interactive_terminal() async throws { + // Given + let standardPipelines = StandardPipelines() + + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: true, + action: { reportProgress in + reportProgress("Loading project at path Project/") + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: true), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + try await subject.run() + + // Then + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == "⠋ Loading project graph") + #expect(renders.popLast() == "⠋ Loading project graph") + #expect(renders.popLast() == "⠋ Loading project at path Project/") + #expect(renders.popLast()?.range(of: "✔︎ Project graph loaded \\[.*s\\]", options: .regularExpression) != nil) + #expect(spinner.stoppedCalls == 1) + } + + @Test func renders_the_right_output_when_spinner_and_failure_and_interactive_terminal() async throws { + // Given + let standardPipelines = StandardPipelines() + let error = TestError.loadError + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: true, + action: { _ in + throw error + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: true), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + await #expect(throws: error, performing: subject.run) + + // Then + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == "⠋ Loading project graph") + #expect(renders.popLast() == "⠋ Loading project graph") + #expect(renders.popLast()?.range(of: "⨯ Failed to load the project graph \\[.*s\\]", options: .regularExpression) != nil) + #expect(spinner.stoppedCalls == 1) + } + + @Test func renders_the_right_output_when_no_spinner_and_success_and_interactive_terminal() async throws { + // Given + let standardPipelines = StandardPipelines() + + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: false, + action: { reportProgress in + reportProgress("Loading project at path Project/") + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: true), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + try await subject.run() + + // Then + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == "ℹ︎ Loading project graph") + #expect(renders.popLast() == "ℹ︎ Loading project at path Project/") + #expect(renders.popLast()?.range(of: "✔︎ Project graph loaded \\[.*s\\]", options: .regularExpression) != nil) + } + + @Test func renders_the_right_output_when_no_spinner_and_failure_and_interactive_terminal() async throws { + // Given + let standardPipelines = StandardPipelines() + let error = TestError.loadError + let subject = ProgressStep( + message: "Loading project graph", + successMessage: "Project graph loaded", + errorMessage: "Failed to load the project graph", + showSpinner: false, + action: { _ in + throw error + }, + theme: Theme.test(), + terminal: MockTerminal(isInteractive: true), + renderer: renderer, + standardPipelines: standardPipelines, + spinner: spinner + ) + + // When + await #expect(throws: error, performing: subject.run) + + // Then + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == "ℹ︎ Loading project graph") + #expect(renders.popLast()?.range(of: "⨯ Failed to load the project graph \\[.*s\\]", options: .regularExpression) != nil) + } +} diff --git a/Tests/NooraTests/Components/SingleChoicePromptTests.swift b/Tests/NooraTests/Components/SingleChoicePromptTests.swift index cd67628..5ce5c49 100644 --- a/Tests/NooraTests/Components/SingleChoicePromptTests.swift +++ b/Tests/NooraTests/Components/SingleChoicePromptTests.swift @@ -66,7 +66,7 @@ struct SingleChoicePromptTests { ↑/↓/k/j up/down • enter confirm """) #expect(renders.popLast() == """ - Integration: option1 + ✔︎ Integration: option1 """) } } diff --git a/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift b/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift index 398a369..0e0fec3 100644 --- a/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift +++ b/Tests/NooraTests/Components/YesOrNoChoicePromptTests.swift @@ -46,7 +46,7 @@ struct YesOrNoChoicePromptTests { ←/→/h/l left/right • enter confirm """) #expect(renders.popLast() == """ - Authentication: Yes + ✔︎ Authentication: Yes """) } } diff --git a/Tests/NooraTests/Mocks/MockSpinner.swift b/Tests/NooraTests/Mocks/MockSpinner.swift new file mode 100644 index 0000000..269be71 --- /dev/null +++ b/Tests/NooraTests/Mocks/MockSpinner.swift @@ -0,0 +1,12 @@ +@testable import Noora + +class MockSpinner: Spinning { + func spin(_ block: @escaping (String) -> Void) { + block("⠋") + } + + var stoppedCalls: UInt = 0 + func stop() { + stoppedCalls += 1 + } +} diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index ff34743..6550e8b 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -62,6 +62,16 @@ export default defineConfig({ }, ], }, + { + text: "Progress", + collapsed: true, + items: [ + { + text: "Step", + link: "/components/progress/step", + }, + ], + }, { text: "Alerts", collapsed: true, diff --git a/docs/content/components/progress/step.md b/docs/content/components/progress/step.md new file mode 100644 index 0000000..a80d053 --- /dev/null +++ b/docs/content/components/progress/step.md @@ -0,0 +1,54 @@ +--- +title: Step +titleTemplate: ":title · Progress · Noora · Tuist" +description: A component to represent a progress step +--- + +# Progress step + +This component represents a step in the execution of a command showing the time it took to complete. + +| Property | Value | +| --- | --- | +| Interactivity | Non-required. The component supports non-interactive mode. | + +## Demo + +### Interactive + +![It shows the execution of a command with an interactive progress step](/components/progress/step/interactive.gif) + +### Non-interactive + +![It shows the execution of a command with a non-interactive progress step](/components/progress/step/non-interactive.gif) + +## API + +### Example + +```swift +try await Noora().progressStep( + message: "Processing the graph", + successMessage: "Project graph processed", + errorMessage: "Failed to process the project graph" +) { updateMessage in + // An example of an asynchronous task. + let graph = try await loadGraph() + + // You can use updateMessage to update the progress step message. + updateMessage("Analyzing the graph") + + // Another asynchronous task. + try await analyzeGraph(graph) +} +``` + +### Options + +| Attribute | Description | Required | Default value | +| --- | --- | --- | --- | +| `message` | The message to show to the user | Yes | | +| `successMessage` | The message to show to the user when the step is successful | No | | +| `errorMessage` | The message to show to the user when the step fails | No | | +| `showSpinner` | Whether to show a spinner | No | `true` | +| `action` | The action to execute | Yes | | diff --git a/docs/content/public/components/progress/step/interactive.gif b/docs/content/public/components/progress/step/interactive.gif new file mode 100644 index 0000000..40d20bd Binary files /dev/null and b/docs/content/public/components/progress/step/interactive.gif differ diff --git a/docs/content/public/components/progress/step/non-interactive.gif b/docs/content/public/components/progress/step/non-interactive.gif new file mode 100644 index 0000000..3763dde Binary files /dev/null and b/docs/content/public/components/progress/step/non-interactive.gif differ