mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
feat: cache views instead of reloading all items #12
This commit is contained in:
parent
eede8a3467
commit
a7fa09f6ea
16 changed files with 127 additions and 5 deletions
7
Forji/Forji/Helpers/CacheInvalidation.swift
Normal file
7
Forji/Forji/Helpers/CacheInvalidation.swift
Normal 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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -545,6 +545,7 @@ struct PullRequestDetailView: View {
|
|||
state: newState,
|
||||
)
|
||||
pullRequest = updated
|
||||
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ struct PullRequestEditView: View {
|
|||
reviewers: Array(removedReviewers),
|
||||
)
|
||||
}
|
||||
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
|
||||
onSaved(updatedPR)
|
||||
dismiss()
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ struct PullRequestMergeView: View {
|
|||
message: mergeMethod != "rebase" ? commitMessage : nil,
|
||||
deleteBranch: deleteBranch,
|
||||
)
|
||||
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
|
||||
onMerged()
|
||||
dismiss()
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
12
Forji/ForjiTests/CacheInvalidationTests.swift
Normal file
12
Forji/ForjiTests/CacheInvalidationTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue