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 = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11; CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
@@ -197,7 +197,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0; MARKETING_VERSION = 2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -211,7 +211,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11; CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
@@ -221,7 +221,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0; MARKETING_VERSION = 2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; 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 Foundation
import SwiftUI import SwiftUI
enum InboxFilter: String, CaseIterable, Identifiable { /// A PR row in the inbox. Deliberately lightweight: just the repo + PR, with
case all = "All" /// NO per-PR CI/review enrichment. Enriching every PR with runs+reviews was
case assigned = "Assigned" /// the main cause of the slow landing screen (2 extra API calls per PR).
case created = "By me" /// CI/review status is shown in the PR detail screen instead.
case review = "Review" struct PRRow: Identifiable, Hashable, Codable {
var id: String { rawValue }
}
/// A PR row enriched with its repo and aggregate status.
struct PRRow: Identifiable, Hashable {
let repo: Repository let repo: Repository
let pr: PullRequest let pr: PullRequest
let ciStatus: CIStatus
let reviewState: ReviewState
var id: String { "\(repo.fullName)#\(pr.number)" } var id: String { "\(repo.fullName)#\(pr.number)" }
var fullName: String { repo.fullName } var fullName: String { repo.fullName }
@@ -26,44 +18,29 @@ final class InboxViewModel: ObservableObject {
@Published var rows: [PRRow] = [] @Published var rows: [PRRow] = []
@Published var isLoading = false @Published var isLoading = false
@Published var errorMessage: String? @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] = [] @Published var displayedRows: [PRRow] = []
private let cache = InboxCache()
var isConfigured: Bool { SettingsStore.shared.isConfigured } var isConfigured: Bool { SettingsStore.shared.isConfigured }
private func recomputeDisplayed() { /// Load the inbox. Shows the cached list instantly, then refreshes from
switch filter { /// the network in the background and updates when done.
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
}
}
}
}
func load() async { func load() async {
guard isConfigured, let client = SettingsStore.shared.client() else { guard isConfigured, let client = SettingsStore.shared.client() else {
errorMessage = "Configure your Gitea instance in Settings." errorMessage = "Configure your Gitea instance in Settings."
return 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 isLoading = true
errorMessage = nil errorMessage = nil
defer { isLoading = false } defer { isLoading = false }
@@ -82,7 +59,7 @@ final class InboxViewModel: ObservableObject {
do { do {
let prs = try await client.pullRequests(owner: repo.owner.login, let prs = try await client.pullRequests(owner: repo.owner.login,
repo: repo.name) repo: repo.name)
return .success(try await self.enrich(prs, in: repo, client: client)) return .success(prs.map { PRRow(repo: repo, pr: $0) })
} catch { } catch {
return .failure(error) return .failure(error)
} }
@@ -102,40 +79,18 @@ final class InboxViewModel: ObservableObject {
} }
// Sort: most recently updated first. // Sort: most recently updated first.
rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) } rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) }
allRows = rows displayedRows = rows
recomputeDisplayed() cache.save(rows)
if rows.isEmpty && !failures.isEmpty { if rows.isEmpty && !failures.isEmpty {
errorMessage = "Couldn't load any PRs (\(failures.count) repo(s) failed). Check the Logs screen." errorMessage = "Couldn't load any PRs (\(failures.count) repo(s) failed). Check the Logs screen."
} }
} catch { } catch {
LogStore.shared.log(.error, "Inbox load failed: \(error.localizedDescription)") LogStore.shared.log(.error, "Inbox load failed: \(error.localizedDescription)")
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
allRows = [] // Keep the cached list on a network failure rather than blanking it.
recomputeDisplayed() if displayedRows.isEmpty {
} displayedRows = cache.load()
}
/// 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)
} }
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() } .task { await vm.load() }
.overlay { .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() ProgressView()
} }
} }
@@ -74,10 +77,6 @@ struct PRRowView: View {
Text("#\(row.pr.number)") Text("#\(row.pr.number)")
.font(.subheadline.weight(.bold)) .font(.subheadline.weight(.bold))
.foregroundColor(.secondary) .foregroundColor(.secondary)
CICircle(status: row.ciStatus)
if row.reviewState != .unknown {
ReviewBadge(state: row.reviewState)
}
if row.pr.draft == true { if row.pr.draft == true {
Text("Draft") Text("Draft")
.font(.caption2) .font(.caption2)