Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add progress step component #139

Merged
merged 12 commits into from
Jan 29, 2025
2 changes: 1 addition & 1 deletion .github/workflows/conventional-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
commitTitleMatch: "false"
commitTitleMatch: false
139 changes: 139 additions & 0 deletions Sources/Noora/Components/ProgressStep.swift
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +19 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd keep the init for dependency injection and pass the rest of the variables through the run invocation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can update this one and the others to follow that, but I don't see much value. The public interface is through an instance of Noora, which then instantiates components and invokes the run. From the testing perspective, there isn't a notable benefit that I can think of.
If you feel strongly about it, I can go ahead with the change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with the current approach 👍

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)
}
}
5 changes: 4 additions & 1 deletion Sources/Noora/Components/SingleChoicePrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ struct SingleChoicePrompt<T: CaseIterable & CustomStringConvertible & Equatable>
.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) {
Expand Down
6 changes: 5 additions & 1 deletion Sources/Noora/Components/YesOrNoChoicePrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
51 changes: 51 additions & 0 deletions Sources/Noora/Noora.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is not part of the Noorable protocol. The method is missing documentation (which should be in the Noorable definition)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to have been addressed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be addressed now.

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()
}
}
52 changes: 29 additions & 23 deletions Sources/Noora/Utilities/Spinner.swift
Original file line number Diff line number Diff line change
@@ -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 = [
"⠋",
"⠙",
Expand All @@ -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
}
}
34 changes: 34 additions & 0 deletions Sources/examples-cli/Commands/ProgressStepCommand.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
7 changes: 6 additions & 1 deletion Sources/examples-cli/ExamplesCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
)
}
Loading
Loading