diff --git a/GiteaPR.xcodeproj/project.pbxproj b/GiteaPR.xcodeproj/project.pbxproj index df10e9b..fbf6752 100644 --- a/GiteaPR.xcodeproj/project.pbxproj +++ b/GiteaPR.xcodeproj/project.pbxproj @@ -187,7 +187,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; @@ -197,7 +197,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 2.1; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -211,7 +211,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; @@ -221,7 +221,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 2.1; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/GiteaPR/Inbox/InboxCache.swift b/GiteaPR/Inbox/InboxCache.swift new file mode 100644 index 0000000..f5bd104 --- /dev/null +++ b/GiteaPR/Inbox/InboxCache.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Persists the inbox PR list to disk so the landing screen can render +/// instantly from the last load, then refresh in the background. +struct InboxCache { + private let fileURL: URL + + init() { + let dir = FileManager.default.urls(for: .cachesDirectory, + in: .userDomainMask)[0] + fileURL = dir.appendingPathComponent("inbox.json") + } + + func load() -> [PRRow] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + return (try? Self.decoder.decode([PRRow].self, from: data)) ?? [] + } + + func save(_ rows: [PRRow]) { + guard let data = try? JSONEncoder().encode(rows) else { return } + try? data.write(to: fileURL) + } + + /// Same ISO8601-with-offset date strategy the API decoder uses, so the + /// cached `Date?` fields decode correctly. + private static let decoder: JSONDecoder = { + let d = JSONDecoder() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + d.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let str = try container.decode(String.self) + if let date = formatter.date(from: str) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + if let date = plain.date(from: str) { return date } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode date string \(str)") + } + return d + }() +} diff --git a/GiteaPR/Inbox/InboxViewModel.swift b/GiteaPR/Inbox/InboxViewModel.swift index d1a11be..d80cb10 100644 --- a/GiteaPR/Inbox/InboxViewModel.swift +++ b/GiteaPR/Inbox/InboxViewModel.swift @@ -1,21 +1,13 @@ import Foundation import SwiftUI -enum InboxFilter: String, CaseIterable, Identifiable { - case all = "All" - case assigned = "Assigned" - case created = "By me" - case review = "Review" - - var id: String { rawValue } -} - -/// A PR row enriched with its repo and aggregate status. -struct PRRow: Identifiable, Hashable { +/// 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 - let ciStatus: CIStatus - let reviewState: ReviewState var id: String { "\(repo.fullName)#\(pr.number)" } var fullName: String { repo.fullName } @@ -26,44 +18,29 @@ final class InboxViewModel: ObservableObject { @Published var rows: [PRRow] = [] @Published var isLoading = false @Published var errorMessage: String? - @Published var filter: InboxFilter = .all { - didSet { recomputeDisplayed() } - } - // Raw rows before filtering, kept so switching filters is instant. - private var allRows: [PRRow] = [] @Published var displayedRows: [PRRow] = [] + private let cache = InboxCache() + var isConfigured: Bool { SettingsStore.shared.isConfigured } - private func recomputeDisplayed() { - switch filter { - case .all: - displayedRows = allRows - case .assigned: - displayedRows = allRows.filter { - ($0.pr.assignees ?? []).contains { - $0.login == AppSession.currentLogin - } - } - case .created: - displayedRows = allRows.filter { - $0.pr.user?.login == AppSession.currentLogin - } - case .review: - displayedRows = allRows.filter { - switch $0.reviewState { - case .needsReview, .unknown: return true - default: return false - } - } - } - } - + /// 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 } @@ -82,7 +59,7 @@ final class InboxViewModel: ObservableObject { do { let prs = try await client.pullRequests(owner: repo.owner.login, repo: repo.name) - return .success(try await self.enrich(prs, in: repo, client: client)) + return .success(prs.map { PRRow(repo: repo, pr: $0) }) } catch { return .failure(error) } @@ -102,40 +79,18 @@ final class InboxViewModel: ObservableObject { } // Sort: most recently updated first. rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) } - allRows = rows - recomputeDisplayed() + 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 - allRows = [] - recomputeDisplayed() - } - } - - /// Enrich each PR with CI + review status. Runs sequentially per repo to - /// avoid hammering the API from one inbox refresh. - private func enrich(_ prs: [PullRequest], in repo: Repository, - client: GiteaClient) async throws -> [PRRow] { - var out: [PRRow] = [] - for pr in prs { - let sha = pr.head?.sha - var ci: CIStatus = .unknown - var review: ReviewState = .unknown - if let sha, !sha.isEmpty { - let runs = (try? await client.runs(owner: repo.owner.login, - repo: repo.name, sha: sha)) ?? [] - ci = CIStatus.from(runs: runs) + // Keep the cached list on a network failure rather than blanking it. + if displayedRows.isEmpty { + displayedRows = cache.load() } - let reviews = (try? await client.reviews(owner: repo.owner.login, - repo: repo.name, - number: pr.number)) ?? [] - review = ReviewState.from(reviews: reviews) - out.append(PRRow(repo: repo, pr: pr, - ciStatus: ci, reviewState: review)) } - return out } } diff --git a/GiteaPR/Views/InboxView.swift b/GiteaPR/Views/InboxView.swift index d4d3ea3..28765bf 100644 --- a/GiteaPR/Views/InboxView.swift +++ b/GiteaPR/Views/InboxView.swift @@ -41,7 +41,10 @@ struct InboxView: View { } .task { await vm.load() } .overlay { - if vm.isLoading { + // Only show a spinner when there's nothing cached to display + // yet; with a cache the list renders instantly and refreshes + // in the background. + if vm.isLoading && vm.displayedRows.isEmpty { ProgressView() } } @@ -74,10 +77,6 @@ struct PRRowView: View { Text("#\(row.pr.number)") .font(.subheadline.weight(.bold)) .foregroundColor(.secondary) - CICircle(status: row.ciStatus) - if row.reviewState != .unknown { - ReviewBadge(state: row.reviewState) - } if row.pr.draft == true { Text("Draft") .font(.caption2)