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 {
49 await list.reload() 49 await list.reload()
50 } 50 }
51 51
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
52 /// `build jobs <owner/name>` — what the picker offers. A repo 58 /// `build jobs <owner/name>` — what the picker offers. A repo
53 /// without a CI config answers "not found"; that means no jobs, not 59 /// without a CI config answers "not found"; that means no jobs, not
54 /// a failure worth showing. 60 /// a failure worth showing.
gitbay/Issues/IssueListViewModel.swift +6
@@ -64,6 +64,12 @@ final class IssueListViewModel {
64 colors = LabelColors(labels) 64 colors = LabelColors(labels)
65 } 65 }
66 66
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
67 func loadMilestones() async { 73 func loadMilestones() async {
68 guard milestones.isEmpty else { return } 74 guard milestones.isEmpty else { return }
69 milestones = (try? await client.readList( 75 milestones = (try? await client.readList(
gitbay/MRs/MRListViewModel.swift +6
@@ -50,6 +50,12 @@ final class MRListViewModel {
50 await list.reload() 50 await list.reload()
51 } 51 }
52 52
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
53 func loadMilestones() async { 59 func loadMilestones() async {
54 guard milestones.isEmpty else { return } 60 guard milestones.isEmpty else { return }
55 milestones = (try? await client.readList( 61 milestones = (try? await client.readList(
gitbay/Repos/PagedListModel.swift +11
@@ -14,6 +14,17 @@ final class PagedListModel<Element: Decodable & Sendable> {
14 14
15 var hasMore: Bool { next != nil } 15 var hasMore: Bool { next != nil }
16 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
17 private let client: GitbayClient 28 private let client: GitbayClient
18 private let pageSize: Int 29 private let pageSize: Int
19 /// The list command minus paging flags. Set by the owning view model 30 /// 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 {
57 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 } 57 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
58 } 58 }
59 .task { 59 .task {
60 await model.load() 60 await model.loadIfNeeded()
61 await model.loadJobs() 61 await model.loadJobs()
62 } 62 }
63 .refreshable { 63 .refreshable {
gitbay/Views/Issues/IssueListView.swift +1 −1
@@ -100,7 +100,7 @@ struct IssueListView: View {
100 } 100 }
101 } 101 }
102 } 102 }
103 .task { await model.load() } 103 .task { await model.loadIfNeeded() }
104 .task { await createModel.loadTemplates() } 104 .task { await createModel.loadTemplates() }
105 .refreshable { await model.load() } 105 .refreshable { await model.load() }
106 } 106 }
gitbay/Views/MRs/MRListView.swift +1 −1
@@ -82,7 +82,7 @@ struct MRListView: View {
82 Task { await model.load() } 82 Task { await model.load() }
83 } 83 }
84 } 84 }
85 .task { await model.load() } 85 .task { await model.loadIfNeeded() }
86 .refreshable { await model.load() } 86 .refreshable { await model.load() }
87 } 87 }
88} 88}
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 {
131 } 131 }
132 } 132 }
133 133
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
134 @Test func reloadDropsTheOldCursorAndItems() async throws { 162 @Test func reloadDropsTheOldCursorAndItems() async throws {
135 let (list, stub) = try makeList() 163 let (list, stub) = try makeList()
136 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) 164 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))