Speed up landing screen: drop per-PR enrichment + add inbox cache; bump to 2.1
Build iOS App / Build .ipa and publish source (push) Successful in 23s

The inbox was slow because it enriched every PR with CI runs + reviews (2
extra API calls per PR, sequential). Drop that from the list — CI/review
status now shows only in the PR detail screen. Add a disk cache so the landing
screen renders instantly from the last load, then refreshes in the background
and keeps the cached list on network failure. Bump to 2.1.
This commit is contained in:
Hermes committed 2026-08-13 06:27:32 +00:00
1 parent 53b3f69d0f
commit 2b929e65e9
4 files changed
+76 -79

No files matched your search

+4 -4
View File
@@ -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;
+43
View File
@@ -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
}()
}
+25 -70
View File
@@ -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
}
}
+4 -5
View File
@@ -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)