gitbay/Repos/PagedListModel.swift
95 lines · 3394 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 /// True until a page, or the empty state, is on screen. A list's
18 /// `.task` runs again when a pushed screen is popped; loading only
19 /// while this is true keeps the pages and scroll position the user
20 /// came back to. A failed first page has nothing to keep.
21 var needsLoad: Bool {
22 switch state {
23 case .loaded, .empty: false
24 case .loading, .failed: true
25 }
26 }
27
28 private let client: GitbayClient
29 private let pageSize: Int
30 /// The list command minus paging flags. Set by the owning view model
31 /// when a filter changes; a change only takes effect via `reload()`.
32 var argv: [String]
33 var emptyMessage: String
34
35 init(
36 client: GitbayClient,
37 argv: [String],
38 emptyMessage: String,
39 pageSize: Int = 50
40 ) {
41 self.client = client
42 self.argv = argv
43 self.emptyMessage = emptyMessage
44 self.pageSize = pageSize
45 }
46
47 // An explicit deinit: with the deployment target at iOS 18 the Swift
48 // 6.3 optimizer crashes inlining the synthesized one of this generic
49 // @Observable class (isCallerAndCalleeLayoutConstraintsCompatible),
50 // which only a Release build reaches.
51 deinit {}
52
53 func reload() async {
54 state = .loading
55 next = nil
56 await fetch(cursor: nil, appendingTo: [])
57 }
58
59 /// The first page again, keeping what is on screen until it arrives:
60 /// after a write on a row, the list should not blank.
61 func refresh() async {
62 next = nil
63 await fetch(cursor: nil, appendingTo: [])
64 }
65
66 /// Fetch the next page. Safe to call repeatedly from row-appear
67 /// triggers; it no-ops while a fetch is in flight or at the end.
68 func loadMore() async {
69 guard let cursor = next, !isLoadingMore, let loaded = state.value else { return }
70 isLoadingMore = true
71 defer { isLoadingMore = false }
72 await fetch(cursor: cursor, appendingTo: loaded)
73 }
74
75 private func fetch(cursor: String?, appendingTo existing: [Element]) async {
76 do {
77 let page = try await client.readPage(
78 argv, of: Element.self, limit: pageSize, cursor: cursor
79 )
80 next = page.next
81 let all = existing + page.items
82 state = all.isEmpty ? .empty(emptyMessage) : .loaded(all)
83 } catch {
84 // A failed first page is the screen's state; a failed later
85 // page keeps what is on screen and leaves the cursor for a
86 // retry from the same trigger. A cancelled fetch (superseded
87 // by a newer reload, e.g. a fast filter change) is neither —
88 // it must not paint a stale failure over whatever the
89 // replacement fetch already showed or is about to show.
90 if existing.isEmpty, !Task.isCancelled {
91 state = .from(error)
92 }
93 }
94 }
95}