gitbay/Repos/PagedListModel.swift
77 lines · 2742 bytes
1import Foundation
2import Observation
3
4/// Accumulated pages of one list command (`repo list`, `issue list`,
5/// `mr list`). Cursors are opaque and server-minted; the model only
6/// hands back what `next` carried.
7@Observable
8@MainActor
9final class PagedListModel<Element: Decodable & Sendable> {
10
11 private(set) var state: LoadState<[Element]> = .loading
12 private(set) var isLoadingMore = false
13 private var next: String?
14
15 var hasMore: Bool { next != nil }
16
17 private let client: GitbayClient
18 private let pageSize: Int
19 /// The list command minus paging flags. Set by the owning view model
20 /// when a filter changes; a change only takes effect via `reload()`.
21 var argv: [String]
22 var emptyMessage: String
23
24 init(
25 client: GitbayClient,
26 argv: [String],
27 emptyMessage: String,
28 pageSize: Int = 50
29 ) {
30 self.client = client
31 self.argv = argv
32 self.emptyMessage = emptyMessage
33 self.pageSize = pageSize
34 }
35
36 // An explicit deinit: with the deployment target at iOS 18 the Swift
37 // 6.3 optimizer crashes inlining the synthesized one of this generic
38 // @Observable class (isCallerAndCalleeLayoutConstraintsCompatible),
39 // which only a Release build reaches.
40 deinit {}
41
42 func reload() async {
43 state = .loading
44 next = nil
45 await fetch(cursor: nil, appendingTo: [])
46 }
47
48 /// Fetch the next page. Safe to call repeatedly from row-appear
49 /// triggers; it no-ops while a fetch is in flight or at the end.
50 func loadMore() async {
51 guard let cursor = next, !isLoadingMore, let loaded = state.value else { return }
52 isLoadingMore = true
53 defer { isLoadingMore = false }
54 await fetch(cursor: cursor, appendingTo: loaded)
55 }
56
57 private func fetch(cursor: String?, appendingTo existing: [Element]) async {
58 do {
59 let page = try await client.readPage(
60 argv, of: Element.self, limit: pageSize, cursor: cursor
61 )
62 next = page.next
63 let all = existing + page.items
64 state = all.isEmpty ? .empty(emptyMessage) : .loaded(all)
65 } catch {
66 // A failed first page is the screen's state; a failed later
67 // page keeps what is on screen and leaves the cursor for a
68 // retry from the same trigger. A cancelled fetch (superseded
69 // by a newer reload, e.g. a fast filter change) is neither —
70 // it must not paint a stale failure over whatever the
71 // replacement fetch already showed or is about to show.
72 if existing.isEmpty, !Task.isCancelled {
73 state = .from(error)
74 }
75 }
76 }
77}