native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300import ComposableArchitectureimport SwiftUI
@MainActor @Observablefinal class GithubSettingsViewModel { enum State: Equatable { case loading case unavailable case outdated case notAuthenticated case authenticated(GithubAuthStatusSnapshot) case rateLimited(retryAt: Date) case error(String) }
var state: State = .loading var rateLimitBlocks: [GithubRateLimitBlock] = []
// The scope of the `gh auth status` call that loads the account list. private static let accountListScope = GithubRateLimitRequest(arguments: ["auth", "status"]).scope
@ObservationIgnored @Dependency(GithubIntegrationClient.self) private var githubIntegration
@ObservationIgnored @Dependency(GithubCLIClient.self) private var githubCLI
// Follows the account's rate limit while the page is open, and reloads the account list once // GitHub accepts its requests again if the limit kept it from loading. func observeRateLimit() async { for await blocks in await githubCLI.rateLimitBlocks() { rateLimitBlocks = blocks if !Self.holdsBackAccountList(blocks), case .rateLimited = state { await load() } } }
func retryNow() async { await githubCLI.retryRateLimitedRequests() if case .rateLimited = state { await load() } }
private static func holdsBackAccountList(_ blocks: [GithubRateLimitBlock]) -> Bool { blocks.contains { $0.scope == accountListScope } }
func load() async { state = .loading let isAvailable = await githubIntegration.isAvailable() guard isAvailable else { state = .unavailable return }
do { let snapshot = try await githubCLI.authStatusSnapshot() if snapshot.allAccounts.contains(where: { $0.active }) { state = .authenticated(snapshot) } else { state = .notAuthenticated } } catch let error as GithubCLIError { switch error { case .outdated: state = .outdated case .unavailable: state = .unavailable case .commandFailed(let message), .graphQLError(_, let message): state = .error(message) case .rateLimited(let retryAt): // The gate may have reopened while this load was in flight, after the observer already // saw the recovery; its current state decides between reloading and waiting for it. var gateState = await githubCLI.rateLimitBlocks().makeAsyncIterator() if let blocks = await gateState.next(), !Self.holdsBackAccountList(blocks) { await load() return } state = .rateLimited(retryAt: retryAt) } } catch { state = .error(error.localizedDescription) } }}
struct GithubSettingsView: View { @Bindable var store: StoreOf<SettingsFeature> @State private var viewModel = GithubSettingsViewModel()
var body: some View { VStack(alignment: .leading, spacing: 0) { Form { Section("GitHub integration") { Toggle( "Enable GitHub integration", isOn: $store.githubIntegrationEnabled ) .help("Enable GitHub integration") } Section("GitHub CLI") { if let retryAt = viewModel.rateLimitBlocks.map(\.retryAt).min(), viewModel.state.isRateLimited == false { GithubRateLimitNotice(retryAt: retryAt, blocks: viewModel.rateLimitBlocks) { Task { await viewModel.retryNow() } } } switch viewModel.state { case .loading: HStack(spacing: 8) { ProgressView() .controlSize(.small) Text("Checking GitHub CLI...") .foregroundStyle(.secondary) }
case .unavailable: VStack(alignment: .leading, spacing: 8) { Label("GitHub integration unavailable", systemImage: "xmark.circle") .foregroundStyle(.red) Text("Enable GitHub integration and install gh CLI to use pull request checks.") .foregroundStyle(.secondary) .font(.callout) }
case .notAuthenticated: VStack(alignment: .leading, spacing: 8) { Label("Not authenticated", systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) Text("Run `gh auth login` in terminal to authenticate.") .foregroundStyle(.secondary) .font(.callout) }
case .outdated: VStack(alignment: .leading, spacing: 8) { Label("GitHub CLI outdated", systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) Text("Update GitHub CLI to the latest version to use GitHub integration.") .foregroundStyle(.secondary) .font(.callout) }
case .authenticated(let snapshot): ForEach(snapshot.sortedHosts, id: \.self) { host in let accounts = snapshot.accounts(on: host) VStack(alignment: .leading, spacing: 8) { LabeledContent("Host") { Text(host) .font(.body) } ForEach(accounts) { account in HStack { Text(account.login) if account.active { Text("Active") .foregroundStyle(.secondary) } Spacer() if let status = GithubAuthAccountStatusDisplay(account.state) { Text(status.title) .foregroundStyle(status.style) .help(status.help) } } .font(.body) } } }
case .rateLimited(let retryAt): GithubRateLimitNotice( retryAt: viewModel.rateLimitBlocks.map(\.retryAt).min() ?? retryAt, blocks: viewModel.rateLimitBlocks ) { Task { await viewModel.retryNow() } }
case .error(let message): VStack(alignment: .leading, spacing: 8) { Label("Error checking status", systemImage: "exclamationmark.triangle") .foregroundStyle(.red) Text(message) .foregroundStyle(.secondary) .font(.callout) } } } Section("Pull Requests") { Picker(selection: $store.pullRequestMergeStrategy) { ForEach(PullRequestMergeStrategy.allCases) { strategy in Text(strategy.title).tag(strategy) } } label: { Text("Merge strategy") Text("Default strategy when merging PRs from the command palette.") } } } .formStyle(.grouped)
switch viewModel.state { case .unavailable: HStack { Button("Get GitHub CLI") { NSWorkspace.shared.open(URL(string: "https://cli.github.com")!) } .help("Open GitHub CLI website") Spacer() } .padding(.top) case .outdated: HStack { Button("Update GitHub CLI") { NSWorkspace.shared.open(URL(string: "https://cli.github.com")!) } .help("Open GitHub CLI website") Spacer() } .padding(.top) default: EmptyView() } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { await viewModel.load() } .task { await viewModel.observeRateLimit() } .onChange(of: store.githubIntegrationEnabled) { _, _ in Task { await viewModel.load() } } }}
extension GithubSettingsViewModel.State { var isRateLimited: Bool { if case .rateLimited = self { return true } return false }}
private struct GithubRateLimitNotice: View { let retryAt: Date let blocks: [GithubRateLimitBlock] let onRetry: () -> Void
var body: some View { VStack(alignment: .leading, spacing: 8) { Label( "GitHub rate-limited, retrying at \(retryAt, format: .dateTime.hour().minute())", systemImage: "exclamationmark.triangle" ) .foregroundStyle(.orange) Text( """ GitHub refused requests for this account's rate limit. Prowl sends nothing more to these hosts \ and APIs until the retry time, so other tools on the account keep working. """ ) .foregroundStyle(.secondary) .font(.callout) GithubRateLimitBlockList(blocks: blocks) Button("Retry Now", action: onRetry) .help("Send the next request to GitHub now instead of at the retry time") } }}
private struct GithubAuthAccountStatusDisplay { let title: String let help: String let style: Color
init?(_ state: String?) { guard let state else { return nil } switch state.lowercased() { case "success": return nil case "timeout": title = String(localized: "Timed out") help = String(localized: "gh could not verify this account in time.") style = .orange case "error": title = String(localized: "Needs attention") help = String(localized: "gh could not verify this account.") style = .red default: title = String(localized: "Check failed") help = String(localized: "gh reported an authentication issue.") style = .orange } }}