-
Notifications
You must be signed in to change notification settings - Fork 4
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
Changes from all commits
56adc6a
c1f9eac
2ef5873
da932b4
a14a50c
46d8989
581e462
954ab98
88cef5d
079945a
1362c7b
b20f4d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,4 +17,4 @@ jobs: | |
env: | ||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
with: | ||
commitTitleMatch: "false" | ||
commitTitleMatch: false |
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, | ||
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) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is not part of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't seem to have been addressed There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
} | ||
} |
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) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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 therun
invocationThere was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 👍