import Foundation import SwiftUI /// A PR row in the inbox. Deliberately lightweight: just the repo + PR, with /// NO per-PR CI/review enrichment. Enriching every PR with runs+reviews was /// the main cause of the slow landing screen (2 extra API calls per PR). /// CI/review status is shown in the PR detail screen instead. struct PRRow: Identifiable, Hashable, Codable { let repo: Repository let pr: PullRequest var id: String { "\(repo.fullName)#\(pr.number)" } var fullName: String { repo.fullName } } @MainActor final class InboxViewModel: ObservableObject { @Published var rows: [PRRow] = [] @Published var isLoading = false @Published var errorMessage: String? @Published var displayedRows: [PRRow] = [] private let cache = InboxCache() var isConfigured: Bool { SettingsStore.shared.isConfigured } /// Load the inbox. Shows the cached list instantly, then refreshes from /// the network in the background and updates when done. func load() async { guard isConfigured, let client = SettingsStore.shared.client() else { errorMessage = "Configure your Gitea instance in Settings." return } // 1. Render the cached list immediately (no network wait). if displayedRows.isEmpty { let cached = cache.load() if !cached.isEmpty { displayedRows = cached } } // 2. Refresh from the network in the background. isLoading = true errorMessage = nil defer { isLoading = false } do { let repos = try await client.repos() // If the user has configured monitored repos, only fetch PRs for // those — this cuts the number of API calls dramatically (the // inbox was fetching every accessible repo). let monitored = SettingsStore.shared.monitoredRepos let targets = monitored.isEmpty ? repos : repos.filter { monitored.contains($0.fullName) } LogStore.shared.log(.info, "Inbox: \(targets.count)/\(repos.count) repos") // Fetch open PRs across the target repos concurrently. A single // repo that fails (e.g. 404) must NOT wipe the whole inbox — catch // per-repo errors, log them, and keep whatever succeeded. var rows: [PRRow] = [] var failures: [String] = [] await withTaskGroup(of: Result<[PRRow], Error>.self) { group in for repo in targets { group.addTask { do { let prs = try await client.pullRequests(owner: repo.owner.login, repo: repo.name) return .success(prs.map { PRRow(repo: repo, pr: $0) }) } catch { return .failure(error) } } } for await result in group { switch result { case .success(let batch): rows.append(contentsOf: batch) case .failure(let error): failures.append(error.localizedDescription) } } } if !failures.isEmpty { LogStore.shared.log(.warn, "Inbox: \(failures.count) repo(s) failed: \(failures.joined(separator: "; "))") } // Sort: most recently updated first. rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) } displayedRows = rows cache.save(rows) if rows.isEmpty && !failures.isEmpty { errorMessage = "Couldn't load any PRs (\(failures.count) repo(s) failed). Check the Logs screen." } } catch { LogStore.shared.log(.error, "Inbox load failed: \(error.localizedDescription)") errorMessage = error.localizedDescription // Keep the cached list on a network failure rather than blanking it. if displayedRows.isEmpty { displayedRows = cache.load() } } } }