feat: cache views instead of reloading all items #12

This commit is contained in:
Stefan Hausotte 2026-03-11 20:27:38 +01:00
parent eede8a3467
commit a7fa09f6ea
16 changed files with 127 additions and 5 deletions

View file

@ -0,0 +1,7 @@
import Foundation
extension Notification.Name {
static let issuesDidChange = Notification.Name("issuesDidChange")
static let pullRequestsDidChange = Notification.Name("pullRequestsDidChange")
static let repositoriesDidChange = Notification.Name("repositoriesDidChange")
}

View file

@ -6,6 +6,7 @@ final class PaginationState<Item> {
var items: [Item] = []
private(set) var isLoading = false
private(set) var hasMore = true
private(set) var hasLoaded = false
var errorMessage: String?
var showError = false
@ -44,6 +45,7 @@ final class PaginationState<Item> {
items = fetched
hasMore = fetched.count >= pageSize
currentPage = 2
hasLoaded = true
} catch is CancellationError {
// Ignore cancellation
} catch let urlError as URLError where urlError.code == .cancelled {
@ -89,4 +91,8 @@ final class PaginationState<Item> {
loadTask = task
await task.value
}
func invalidate() {
hasLoaded = false
}
}

View file

@ -119,6 +119,7 @@ struct IssueCreateView: View {
milestone: selectedMilestoneID,
assignees: selectedAssigneeLogins.isEmpty ? nil : Array(selectedAssigneeLogins),
)
NotificationCenter.default.post(name: .issuesDidChange, object: nil)
onCreated()
dismiss()
} catch {

View file

@ -228,8 +228,7 @@ struct IssueDetailView: View {
}
private func toggleIssueState() async {
guard let issueService else { return }
guard let issue else { return }
guard let issueService, let issue else { return }
isTogglingState = true
do {
@ -243,6 +242,7 @@ struct IssueDetailView: View {
state: newState,
)
self.issue = updated
NotificationCenter.default.post(name: .issuesDidChange, object: nil)
} catch {
errorMessage = error.localizedDescription
showError = true

View file

@ -123,6 +123,7 @@ struct IssueEditView: View {
milestone: selectedMilestoneID ?? 0, // 0 clears the milestone per Forgejo API
assignees: Array(selectedAssigneeLogins),
)
NotificationCenter.default.post(name: .issuesDidChange, object: nil)
onSaved(updatedIssue)
dismiss()
} catch {

View file

@ -98,6 +98,12 @@ struct IssueListView: View {
}
}
.task {
if !pagination.hasLoaded {
reloadIssues()
}
}
.onReceive(NotificationCenter.default.publisher(for: .issuesDidChange)) { _ in
pagination.invalidate()
reloadIssues()
}
.onChange(of: stateFilter) {

View file

@ -198,11 +198,13 @@ struct PullRequestCreateView: View {
} catch {
errorMessage = "PR created, but requesting reviewers failed: \(error.localizedDescription)"
showError = true
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
onCreated()
isSubmitting = false
return
}
}
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
onCreated()
dismiss()
} catch {

View file

@ -545,6 +545,7 @@ struct PullRequestDetailView: View {
state: newState,
)
pullRequest = updated
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
} catch {
errorMessage = error.localizedDescription
showError = true

View file

@ -169,6 +169,7 @@ struct PullRequestEditView: View {
reviewers: Array(removedReviewers),
)
}
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
onSaved(updatedPR)
dismiss()
} catch {

View file

@ -98,6 +98,12 @@ struct PullRequestListView: View {
}
}
.task {
if !pagination.hasLoaded {
reloadPullRequests()
}
}
.onReceive(NotificationCenter.default.publisher(for: .pullRequestsDidChange)) { _ in
pagination.invalidate()
reloadPullRequests()
}
.onChange(of: stateFilter) {

View file

@ -92,6 +92,7 @@ struct PullRequestMergeView: View {
message: mergeMethod != "rebase" ? commitMessage : nil,
deleteBranch: deleteBranch,
)
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
onMerged()
dismiss()
} catch {

View file

@ -243,6 +243,7 @@ struct RepositoryCodeView: View {
@Binding var selectedBranch: String
@State private var branchContentTask: Task<Void, Never>?
@State private var contentLoadTask: Task<Void, Never>?
@State private var hasLoaded = false
private let repositoryService: RepositoryService?
var onFileNavigation: ((String, String) -> Void)?
@ -382,7 +383,9 @@ struct RepositoryCodeView: View {
}
}
.task {
await loadContents()
if !hasLoaded {
await loadContents()
}
}
.onChange(of: selectedBranch) {
pathComponents = []
@ -448,6 +451,7 @@ struct RepositoryCodeView: View {
readmeContent = nil
await loadReadme(owner: repository.owner, repo: repository.repoName)
hasLoaded = true
} catch is CancellationError {
// Ignore cancellation

View file

@ -14,6 +14,7 @@ struct RepositoryListView: View {
@State private var hasMore = true
@State private var currentPage = 1
@State private var starringInFlight: Set<Int> = []
@State private var hasLoaded = false
private let repositoryService: RepositoryService?
private let pageSize = 20
@ -104,7 +105,15 @@ struct RepositoryListView: View {
.navigationTitle("Repositories")
.searchable(text: $searchText, prompt: "Search repositories")
.task {
await loadRepositories()
if !hasLoaded {
await loadRepositories()
}
}
.onReceive(NotificationCenter.default.publisher(for: .repositoriesDidChange)) { _ in
hasLoaded = false
currentPage = 1
hasMore = true
Task { await loadRepositories() }
}
.onChange(of: selectedFilter) { _, _ in
Task {
@ -159,6 +168,7 @@ struct RepositoryListView: View {
starredRepoIds = try await allStarredIds
currentPage = 2
hasLoaded = true
} catch is CancellationError {
// Ignore cancellation
} catch {

View file

@ -203,6 +203,16 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
await reloadItems().value
}
.task {
if !pagination.hasLoaded {
reloadItems()
}
}
.onReceive(
NotificationCenter.default.publisher(
for: issueType == "pulls" ? .pullRequestsDidChange : .issuesDidChange,
),
) { _ in
pagination.invalidate()
reloadItems()
}
.onChange(of: stateFilter) {
@ -324,7 +334,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
static func deduplicateAndSort(_ batches: [[Issue]]) -> [Issue] {
var seen = Set<Int>()
var merged = [Issue]()
for issue in batches.flatMap({ $0 }) where seen.insert(issue.id).inserted {
for issue in batches.flatMap(\.self) where seen.insert(issue.id).inserted {
merged.append(issue)
}
return merged.sorted { $0.updatedAt > $1.updatedAt }

View file

@ -0,0 +1,12 @@
import Foundation
import Testing
@testable import Forji
struct CacheInvalidationTests {
@Test func notificationNamesAreDistinct() {
#expect(Notification.Name.issuesDidChange != Notification.Name.pullRequestsDidChange)
#expect(Notification.Name.issuesDidChange != Notification.Name.repositoriesDidChange)
#expect(Notification.Name.pullRequestsDidChange != Notification.Name.repositoriesDidChange)
}
}

View file

@ -305,6 +305,60 @@ struct PaginationStateConcurrentTests {
}
}
// MARK: - hasLoaded / invalidate
struct PaginationStateHasLoadedTests {
@Test @MainActor func hasLoadedIsFalseInitially() {
let pagination = PaginationState<String>(pageSize: 5)
#expect(!pagination.hasLoaded)
}
@Test @MainActor func hasLoadedTrueAfterSuccessfulReload() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in ["a"] }.value
#expect(pagination.hasLoaded)
}
@Test @MainActor func hasLoadedFalseAfterFailedReload() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value
#expect(!pagination.hasLoaded)
}
@Test @MainActor func invalidateResetsHasLoaded() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in ["a"] }.value
#expect(pagination.hasLoaded)
pagination.invalidate()
#expect(!pagination.hasLoaded)
}
@Test @MainActor func invalidateThenReloadCycle() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in ["a"] }.value
#expect(pagination.hasLoaded)
pagination.invalidate()
#expect(!pagination.hasLoaded)
await pagination.reload { _, _ in ["b"] }.value
#expect(pagination.hasLoaded)
#expect(pagination.items == ["b"])
}
@Test @MainActor func loadMoreDoesNotSetHasLoaded() async {
let pagination = PaginationState<String>(pageSize: 2)
// hasLoaded is false before any reload
#expect(!pagination.hasLoaded)
// loadMore should not set hasLoaded
await pagination.loadMore { _, _ in ["a", "b"] }
#expect(!pagination.hasLoaded)
}
}
// MARK: - loadMore
struct PaginationStateLoadMoreTests {