diff --git a/Forji/Forji/Helpers/CacheInvalidation.swift b/Forji/Forji/Helpers/CacheInvalidation.swift new file mode 100644 index 0000000..6c2715a --- /dev/null +++ b/Forji/Forji/Helpers/CacheInvalidation.swift @@ -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") +} diff --git a/Forji/Forji/Helpers/PaginationState.swift b/Forji/Forji/Helpers/PaginationState.swift index 4832abb..0435226 100644 --- a/Forji/Forji/Helpers/PaginationState.swift +++ b/Forji/Forji/Helpers/PaginationState.swift @@ -6,6 +6,7 @@ final class PaginationState { 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 { 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 { loadTask = task await task.value } + + func invalidate() { + hasLoaded = false + } } diff --git a/Forji/Forji/Views/IssueCreateView.swift b/Forji/Forji/Views/IssueCreateView.swift index 0fd564d..4d1f20e 100644 --- a/Forji/Forji/Views/IssueCreateView.swift +++ b/Forji/Forji/Views/IssueCreateView.swift @@ -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 { diff --git a/Forji/Forji/Views/IssueDetailView.swift b/Forji/Forji/Views/IssueDetailView.swift index b20949c..2a68524 100644 --- a/Forji/Forji/Views/IssueDetailView.swift +++ b/Forji/Forji/Views/IssueDetailView.swift @@ -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 diff --git a/Forji/Forji/Views/IssueEditView.swift b/Forji/Forji/Views/IssueEditView.swift index 36f14ec..6840f73 100644 --- a/Forji/Forji/Views/IssueEditView.swift +++ b/Forji/Forji/Views/IssueEditView.swift @@ -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 { diff --git a/Forji/Forji/Views/IssueListView.swift b/Forji/Forji/Views/IssueListView.swift index 35267e2..fb93a63 100644 --- a/Forji/Forji/Views/IssueListView.swift +++ b/Forji/Forji/Views/IssueListView.swift @@ -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) { diff --git a/Forji/Forji/Views/PullRequestCreateView.swift b/Forji/Forji/Views/PullRequestCreateView.swift index 8beea21..38f2b33 100644 --- a/Forji/Forji/Views/PullRequestCreateView.swift +++ b/Forji/Forji/Views/PullRequestCreateView.swift @@ -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 { diff --git a/Forji/Forji/Views/PullRequestDetailView.swift b/Forji/Forji/Views/PullRequestDetailView.swift index 5dda57d..cd2397e 100644 --- a/Forji/Forji/Views/PullRequestDetailView.swift +++ b/Forji/Forji/Views/PullRequestDetailView.swift @@ -545,6 +545,7 @@ struct PullRequestDetailView: View { state: newState, ) pullRequest = updated + NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil) } catch { errorMessage = error.localizedDescription showError = true diff --git a/Forji/Forji/Views/PullRequestEditView.swift b/Forji/Forji/Views/PullRequestEditView.swift index 37271d7..fc82fb1 100644 --- a/Forji/Forji/Views/PullRequestEditView.swift +++ b/Forji/Forji/Views/PullRequestEditView.swift @@ -169,6 +169,7 @@ struct PullRequestEditView: View { reviewers: Array(removedReviewers), ) } + NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil) onSaved(updatedPR) dismiss() } catch { diff --git a/Forji/Forji/Views/PullRequestListView.swift b/Forji/Forji/Views/PullRequestListView.swift index 8cf313e..af679f7 100644 --- a/Forji/Forji/Views/PullRequestListView.swift +++ b/Forji/Forji/Views/PullRequestListView.swift @@ -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) { diff --git a/Forji/Forji/Views/PullRequestMergeView.swift b/Forji/Forji/Views/PullRequestMergeView.swift index 994bbe4..2f092c1 100644 --- a/Forji/Forji/Views/PullRequestMergeView.swift +++ b/Forji/Forji/Views/PullRequestMergeView.swift @@ -92,6 +92,7 @@ struct PullRequestMergeView: View { message: mergeMethod != "rebase" ? commitMessage : nil, deleteBranch: deleteBranch, ) + NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil) onMerged() dismiss() } catch { diff --git a/Forji/Forji/Views/RepositoryDetailView.swift b/Forji/Forji/Views/RepositoryDetailView.swift index ec46795..cbb10ad 100644 --- a/Forji/Forji/Views/RepositoryDetailView.swift +++ b/Forji/Forji/Views/RepositoryDetailView.swift @@ -243,6 +243,7 @@ struct RepositoryCodeView: View { @Binding var selectedBranch: String @State private var branchContentTask: Task? @State private var contentLoadTask: Task? + @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 diff --git a/Forji/Forji/Views/RepositoryListView.swift b/Forji/Forji/Views/RepositoryListView.swift index 7ff9f07..d149a9c 100644 --- a/Forji/Forji/Views/RepositoryListView.swift +++ b/Forji/Forji/Views/RepositoryListView.swift @@ -14,6 +14,7 @@ struct RepositoryListView: View { @State private var hasMore = true @State private var currentPage = 1 @State private var starringInFlight: Set = [] + @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 { diff --git a/Forji/Forji/Views/SearchableOverviewView.swift b/Forji/Forji/Views/SearchableOverviewView.swift index 3af9f45..a1da5d0 100644 --- a/Forji/Forji/Views/SearchableOverviewView.swift +++ b/Forji/Forji/Views/SearchableOverviewView.swift @@ -203,6 +203,16 @@ struct SearchableOverviewView: 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: View { static func deduplicateAndSort(_ batches: [[Issue]]) -> [Issue] { var seen = Set() 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 } diff --git a/Forji/ForjiTests/CacheInvalidationTests.swift b/Forji/ForjiTests/CacheInvalidationTests.swift new file mode 100644 index 0000000..6078ea5 --- /dev/null +++ b/Forji/ForjiTests/CacheInvalidationTests.swift @@ -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) + } +} diff --git a/Forji/ForjiTests/PaginationStateTests.swift b/Forji/ForjiTests/PaginationStateTests.swift index edaa0b0..32cd25f 100644 --- a/Forji/ForjiTests/PaginationStateTests.swift +++ b/Forji/ForjiTests/PaginationStateTests.swift @@ -305,6 +305,60 @@ struct PaginationStateConcurrentTests { } } +// MARK: - hasLoaded / invalidate + +struct PaginationStateHasLoadedTests { + + @Test @MainActor func hasLoadedIsFalseInitially() { + let pagination = PaginationState(pageSize: 5) + #expect(!pagination.hasLoaded) + } + + @Test @MainActor func hasLoadedTrueAfterSuccessfulReload() async { + let pagination = PaginationState(pageSize: 5) + await pagination.reload { _, _ in ["a"] }.value + #expect(pagination.hasLoaded) + } + + @Test @MainActor func hasLoadedFalseAfterFailedReload() async { + let pagination = PaginationState(pageSize: 5) + await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value + #expect(!pagination.hasLoaded) + } + + @Test @MainActor func invalidateResetsHasLoaded() async { + let pagination = PaginationState(pageSize: 5) + await pagination.reload { _, _ in ["a"] }.value + #expect(pagination.hasLoaded) + + pagination.invalidate() + #expect(!pagination.hasLoaded) + } + + @Test @MainActor func invalidateThenReloadCycle() async { + let pagination = PaginationState(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(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 {