Commit 2c596e6aef

2c596e6aefd1fbdc375a549271e0119484fa07d1

parent: c81fb98b83

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 21:50 UTC

lists: keep loaded pages when returning from a detail screen

A list's .task runs again when a pushed screen is popped, and load()
goes through PagedListModel.reload(), which blanked the issue, merge
request and build lists behind a spinner and dropped the pages and
scroll position. The .task now calls loadIfNeeded(), which reads only
while nothing is shown yet. Pull-to-refresh, filter changes and the
reload after creating an item still call load().

Layout: unified · split

gitbay/Builds/BuildListViewModel.swift +6
@@ -49,6 +49,12 @@ final class BuildListViewModel {
4949 await list.reload()
5050 }
5151
52 /// The list's `.task`: reads only while nothing is shown yet.
53 func loadIfNeeded() async {
54 guard list.needsLoad else { return }
55 await load()
56 }
57
5258 /// `build jobs <owner/name>` — what the picker offers. A repo
5359 /// without a CI config answers "not found"; that means no jobs, not
5460 /// a failure worth showing.
gitbay/Issues/IssueListViewModel.swift +6
@@ -64,6 +64,12 @@ final class IssueListViewModel {
6464 colors = LabelColors(labels)
6565 }
6666
67 /// The list's `.task`: reads only while nothing is shown yet.
68 func loadIfNeeded() async {
69 guard list.needsLoad else { return }
70 await load()
71 }
72
6773 func loadMilestones() async {
6874 guard milestones.isEmpty else { return }
6975 milestones = (try? await client.readList(
gitbay/MRs/MRListViewModel.swift +6
@@ -50,6 +50,12 @@ final class MRListViewModel {
5050 await list.reload()
5151 }
5252
53 /// The list's `.task`: reads only while nothing is shown yet.
54 func loadIfNeeded() async {
55 guard list.needsLoad else { return }
56 await load()
57 }
58
5359 func loadMilestones() async {
5460 guard milestones.isEmpty else { return }
5561 milestones = (try? await client.readList(
gitbay/Repos/PagedListModel.swift +11
@@ -14,6 +14,17 @@ final class PagedListModel<Element: Decodable & Sendable> {
1414
1515 var hasMore: Bool { next != nil }
1616
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
1728 private let client: GitbayClient
1829 private let pageSize: Int
1930 /// The list command minus paging flags. Set by the owning view model
gitbay/Views/Builds/BuildListView.swift +1 −1
@@ -57,7 +57,7 @@ struct BuildListView: View {
5757 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
5858 }
5959 .task {
60 await model.load()
60 await model.loadIfNeeded()
6161 await model.loadJobs()
6262 }
6363 .refreshable {
gitbay/Views/Issues/IssueListView.swift +1 −1
@@ -100,7 +100,7 @@ struct IssueListView: View {
100100 }
101101 }
102102 }
103 .task { await model.load() }
103 .task { await model.loadIfNeeded() }
104104 .task { await createModel.loadTemplates() }
105105 .refreshable { await model.load() }
106106 }
gitbay/Views/MRs/MRListView.swift +1 −1
@@ -82,7 +82,7 @@ struct MRListView: View {
8282 Task { await model.load() }
8383 }
8484 }
85 .task { await model.load() }
85 .task { await model.loadIfNeeded() }
8686 .refreshable { await model.load() }
8787 }
8888}
gitbayTests/ListReturnTests.swift added +91
@@ -0,0 +1,91 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private let emptyPage = #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#
16/// A refusal, not a 5xx: the client retries a 5xx once, which would
17/// take the next stub and hide the failure.
18private let refusal = #"{"protocol_version":1,"error":"denied","exit_code":4}"#
19
20private func reads(_ stub: StubProtocol.Box, _ command: String) -> Int {
21 stub.seen.count { $0.url.query()?.contains(command) == true }
22}
23
24/// Returning to a list from a pushed detail screen runs its `.task`
25/// again. `loadIfNeeded()` reads the list only when nothing is shown yet,
26/// so the pages and scroll position the user left are still there.
27@MainActor
28struct ListReturnTests {
29
30 @Test func theIssueListLoadsOnce() async throws {
31 let (client, stub) = try makeClient()
32 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue&argv=list"))
33 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
34
35 await model.loadIfNeeded()
36 await model.loadIfNeeded()
37
38 #expect(reads(stub, "argv=issue&argv=list") == 1)
39 }
40
41 @Test func theMRListLoadsOnce() async throws {
42 let (client, stub) = try makeClient()
43 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=mr&argv=list"))
44 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
45
46 await model.loadIfNeeded()
47 await model.loadIfNeeded()
48
49 #expect(reads(stub, "argv=mr&argv=list") == 1)
50 }
51
52 @Test func theBuildListLoadsOnce() async throws {
53 let (client, stub) = try makeClient()
54 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=build&argv=list"))
55 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
56
57 await model.loadIfNeeded()
58 await model.loadIfNeeded()
59
60 #expect(reads(stub, "argv=build&argv=list") == 1)
61 }
62
63 @Test func aFailedLoadIsTriedAgainOnReturn() async throws {
64 let (client, stub) = try makeClient()
65 stub.enqueue(.init(status: 403, json: refusal, match: "argv=mr&argv=list"))
66 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=mr&argv=list"))
67 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
68
69 await model.loadIfNeeded()
70 await model.loadIfNeeded()
71
72 #expect(reads(stub, "argv=mr&argv=list") == 2)
73 guard case .empty = model.state else {
74 Issue.record("expected .empty after the retry, got \(model.state)")
75 return
76 }
77 }
78
79 /// Pull-to-refresh still calls `load()`, which always reads.
80 @Test func loadStillReloads() async throws {
81 let (client, stub) = try makeClient()
82 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue&argv=list"))
83 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue&argv=list"))
84 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
85
86 await model.loadIfNeeded()
87 await model.load()
88
89 #expect(reads(stub, "argv=issue&argv=list") == 2)
90 }
91}
gitbayTests/PagedListTests.swift +28
@@ -131,6 +131,34 @@ struct PagedListModelTests {
131131 }
132132 }
133133
134 /// A list's `.task` runs again when a pushed screen is popped. Only a
135 /// list with nothing to show yet loads there; one showing pages keeps
136 /// them.
137 @Test func needsLoadUntilAPageOrTheEmptyStateIsShown() async throws {
138 let (list, stub) = try makeList()
139 #expect(list.needsLoad)
140
141 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
142 await list.reload()
143 #expect(!list.needsLoad)
144
145 let (empty, emptyStub) = try makeList()
146 emptyStub.enqueue(.init(status: 200, json: page([])))
147 await empty.reload()
148 #expect(!empty.needsLoad)
149 }
150
151 /// A failed first page has nothing on screen to keep, so returning to
152 /// the list tries again.
153 @Test func aFailedFirstPageStillNeedsLoad() async throws {
154 let (list, stub) = try makeList()
155 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"denied","exit_code":4}"#))
156
157 await list.reload()
158
159 #expect(list.needsLoad)
160 }
161
134162 @Test func reloadDropsTheOldCursorAndItems() async throws {
135163 let (list, stub) = try makeList()
136164 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))