Commit 54b9cc95db

54b9cc95db55dc0652651dd417b4719abf8b355b

parent: 8c9ab4e1a9

Verified · cmc

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

builds: page the list thirty at a time

build list takes --limit and --cursor since gitbay v1.33.0
(krz/gitbay#244). The list moves onto PagedListModel and keeps server
order.

Layout: unified · split

gitbay/Builds/BuildListViewModel.swift +20 −14
@@ -1,19 +1,24 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `build list <repo>`, narrowed by `filter`. 4/// `build list <repo>`, narrowed by `filter`, thirty to a page as the web
5/// pages it. Rows stay in server order: a client sort would reorder them
6/// across a page boundary.
5@Observable 7@Observable
6@MainActor 8@MainActor
7final class BuildListViewModel { 9final class BuildListViewModel {
8 10
9 private(set) var state: LoadState<[Build]> = .loading 11 let list: PagedListModel<Build>
10 /// A change reloads. The sheet edits a copy and assigns once, so a 12 /// A change reloads from the first page. The sheet edits a copy and
11 /// typed branch does not fire a request per keystroke. 13 /// assigns once, so a typed branch does not fire a request per
14 /// keystroke.
12 var filter = BuildFilter() { 15 var filter = BuildFilter() {
13 didSet { 16 didSet {
14 guard filter != oldValue else { return } 17 guard filter != oldValue else { return }
18 list.argv = ["build", "list", repoPath] + filter.flags()
19 list.emptyMessage = filter.emptyMessage
15 reloadTask?.cancel() 20 reloadTask?.cancel()
16 reloadTask = Task { await load() } 21 reloadTask = Task { await list.reload() }
17 } 22 }
18 } 23 }
19 private var reloadTask: Task<Void, Never>? 24 private var reloadTask: Task<Void, Never>?
@@ -29,18 +34,19 @@ final class BuildListViewModel {
29 init(client: GitbayClient, repoPath: String) { 34 init(client: GitbayClient, repoPath: String) {
30 self.client = client 35 self.client = client
31 self.repoPath = repoPath 36 self.repoPath = repoPath
37 list = PagedListModel(
38 client: client,
39 argv: ["build", "list", repoPath] + BuildFilter().flags(),
40 emptyMessage: BuildFilter().emptyMessage,
41 pageSize: 30
42 )
32 } 43 }
33 44
45 var state: LoadState<[Build]> { list.state }
46
34 func load() async { 47 func load() async {
35 do { 48 reloadTask?.cancel()
36 let builds = try await client.readList( 49 await list.reload()
37 ["build", "list", repoPath] + filter.flags(), of: Build.self)
38 state = builds.isEmpty
39 ? .empty(filter.emptyMessage)
40 : .loaded(builds.sorted { $0.number > $1.number })
41 } catch {
42 state = .from(error)
43 }
44 } 50 }
45 51
46 /// `build jobs <owner/name>` — what the picker offers. A repo 52 /// `build jobs <owner/name>` — what the picker offers. A repo
gitbay/Views/Builds/BuildListView.swift +1
@@ -21,6 +21,7 @@ struct BuildListView: View {
21 BuildRow(build: build) 21 BuildRow(build: build)
22 } 22 }
23 } 23 }
24 PageFooter(list: model.list)
24 } 25 }
25 .overlay { LoadStateOverlay(state: model.state) } 26 .overlay { LoadStateOverlay(state: model.state) }
26 .navigationTitle("Builds") 27 .navigationTitle("Builds")
gitbayTests/BuildListPagingTests.swift +66
@@ -46,3 +46,69 @@ struct BuildSubjectTests {
46 #expect(build.title == "#12 db7503f06f") 46 #expect(build.title == "#12 db7503f06f")
47 } 47 }
48} 48}
49
50private func page(_ rows: [String], next: String? = nil) -> String {
51 let nextField = next.map { #","next":"\#($0)""# } ?? ""
52 return #"{"protocol_version":1,"data":{"items":["# + rows.joined(separator: ",")
53 + "]" + nextField + #"},"exit_code":0}"#
54}
55
56@MainActor
57struct BuildListPagingTests {
58
59 /// The web asks for thirty; so does the app.
60 @Test func asksForThirtyAndFollowsTheCursor() async throws {
61 let (client, stub) = try makeClient()
62 stub.enqueue(.init(status: 200, json: page([buildRow(5), buildRow(4)], next: "YnVpbGQ6NA"),
63 match: "argv=build&argv=list"))
64 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
65
66 await model.load()
67
68 #expect(try #require(model.state.value).map(\.number) == [5, 4])
69 #expect(model.list.hasMore)
70 let first = try #require(stub.seen.first)
71 #expect(first.url.query() == "argv=build&argv=list&argv=krz/gitbay&argv=--limit&argv=30")
72
73 stub.enqueue(.init(status: 200, json: page([buildRow(3)]), match: "argv=build&argv=list"))
74 await model.list.loadMore()
75
76 #expect(try #require(model.state.value).map(\.number) == [5, 4, 3])
77 #expect(model.list.hasMore == false)
78 let second = try #require(stub.seen.last)
79 #expect(second.url.query()?.hasSuffix("argv=--limit&argv=30&argv=--cursor&argv=YnVpbGQ6NA") == true)
80 }
81
82 /// Rows are shown in the order the server sent them. A client sort
83 /// would reorder rows across a page boundary.
84 @Test func keepsTheServersOrder() async throws {
85 let (client, stub) = try makeClient()
86 stub.enqueue(.init(status: 200, json: page([buildRow(3), buildRow(4)])))
87 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
88
89 await model.load()
90
91 #expect(try #require(model.state.value).map(\.number) == [3, 4])
92 }
93
94 @Test func aFilterChangeStartsOverFromTheFirstPage() async throws {
95 let (client, stub) = try makeClient()
96 stub.enqueue(.init(status: 200, json: page([buildRow(5)], next: "YnVpbGQ6NQ")))
97 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
98 await model.load()
99
100 stub.enqueue(.init(status: 200, json: page([])))
101 var filter = BuildFilter()
102 filter.status = .failure
103 model.filter = filter
104 await until {
105 if case .empty = model.state { return true }
106 return false
107 }
108
109 let second = try #require(stub.seen.last)
110 #expect(second.url.query()
111 == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--limit&argv=30")
112 #expect(model.list.hasMore == false)
113 }
114}
gitbayTests/IssueBuildViewModelTests.swift +8 −8
@@ -30,13 +30,13 @@ private let issueShowJSON = """
30 """ 30 """
31 31
32private let buildListJSON = """ 32private let buildListJSON = """
33 {"protocol_version":1,"data":[\ 33 {"protocol_version":1,"data":{"items":[\
34 {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\
35 "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"},\
34 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ 36 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
35 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\ 37 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\
36 "finished_at":"2026-08-20T10:05:00.000Z"},\ 38 "finished_at":"2026-08-20T10:05:00.000Z"}\
37 {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\ 39 ]},"exit_code":0}
38 "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"}\
39 ],"exit_code":0}
40 """ 40 """
41 41
42@MainActor 42@MainActor
@@ -146,7 +146,7 @@ struct IssueDetailViewModelTests {
146@MainActor 146@MainActor
147struct BuildListViewModelTests { 147struct BuildListViewModelTests {
148 148
149 @Test func listsNewestFirst() async throws { 149 @Test func decodesEachRow() async throws {
150 let (client, stub) = try makeClient() 150 let (client, stub) = try makeClient()
151 stub.enqueue(.init(status: 200, json: buildListJSON)) 151 stub.enqueue(.init(status: 200, json: buildListJSON))
152 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") 152 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
@@ -167,7 +167,7 @@ struct BuildListViewModelTests {
167 await model.load() 167 await model.load()
168 168
169 stub.enqueue(.init(status: 200, json: """ 169 stub.enqueue(.init(status: 200, json: """
170 {"protocol_version":1,"data":[],"exit_code":0} 170 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
171 """)) 171 """))
172 var filter = BuildFilter() 172 var filter = BuildFilter()
173 filter.status = .failure 173 filter.status = .failure
@@ -183,7 +183,7 @@ struct BuildListViewModelTests {
183 183
184 let second = try #require(stub.seen.dropFirst().first) 184 let second = try #require(stub.seen.dropFirst().first)
185 #expect(second.url.query() 185 #expect(second.url.query()
186 == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--job&argv=test") 186 == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--job&argv=test&argv=--limit&argv=30")
187 guard case .empty(let message) = model.state else { 187 guard case .empty(let message) = model.state else {
188 Issue.record("expected .empty after filtering, got \(model.state)") 188 Issue.record("expected .empty after filtering, got \(model.state)")
189 return 189 return
gitbayTests/RepoManagementTests.swift +2 −2
@@ -365,9 +365,9 @@ struct RepoCreateViewModelTests {
365struct BuildTriggerTests { 365struct BuildTriggerTests {
366 366
367 private let buildListJSON = """ 367 private let buildListJSON = """
368 {"protocol_version":1,"data":[\ 368 {"protocol_version":1,"data":{"items":[\
369 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ 369 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
370 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z"}],"exit_code":0} 370 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z"}]},"exit_code":0}
371 """ 371 """
372 372
373 @Test func triggerSendsTheJobAndReloads() async throws { 373 @Test func triggerSendsTheJobAndReloads() async throws {