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 @@
11import Foundation
22import Observation
33
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.
57@Observable
68@MainActor
79final class BuildListViewModel {
810
9 private(set) var state: LoadState<[Build]> = .loading
10 /// A change reloads. The sheet edits a copy and assigns once, so a
11 /// typed branch does not fire a request per keystroke.
11 let list: PagedListModel<Build>
12 /// A change reloads from the first page. The sheet edits a copy and
13 /// assigns once, so a typed branch does not fire a request per
14 /// keystroke.
1215 var filter = BuildFilter() {
1316 didSet {
1417 guard filter != oldValue else { return }
18 list.argv = ["build", "list", repoPath] + filter.flags()
19 list.emptyMessage = filter.emptyMessage
1520 reloadTask?.cancel()
16 reloadTask = Task { await load() }
21 reloadTask = Task { await list.reload() }
1722 }
1823 }
1924 private var reloadTask: Task<Void, Never>?
@@ -29,18 +34,19 @@ final class BuildListViewModel {
2934 init(client: GitbayClient, repoPath: String) {
3035 self.client = client
3136 self.repoPath = repoPath
37 list = PagedListModel(
38 client: client,
39 argv: ["build", "list", repoPath] + BuildFilter().flags(),
40 emptyMessage: BuildFilter().emptyMessage,
41 pageSize: 30
42 )
3243 }
3344
45 var state: LoadState<[Build]> { list.state }
46
3447 func load() async {
35 do {
36 let builds = try await client.readList(
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 }
48 reloadTask?.cancel()
49 await list.reload()
4450 }
4551
4652 /// `build jobs <owner/name>` — what the picker offers. A repo
gitbay/Views/Builds/BuildListView.swift +1
@@ -21,6 +21,7 @@ struct BuildListView: View {
2121 BuildRow(build: build)
2222 }
2323 }
24 PageFooter(list: model.list)
2425 }
2526 .overlay { LoadStateOverlay(state: model.state) }
2627 .navigationTitle("Builds")
gitbayTests/BuildListPagingTests.swift +66
@@ -46,3 +46,69 @@ struct BuildSubjectTests {
4646 #expect(build.title == "#12 db7503f06f")
4747 }
4848}
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 = """
3030 """
3131
3232private 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"},\
3436 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
3537 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\
36 "finished_at":"2026-08-20T10:05:00.000Z"},\
37 {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\
38 "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"}\
39 ],"exit_code":0}
38 "finished_at":"2026-08-20T10:05:00.000Z"}\
39 ]},"exit_code":0}
4040 """
4141
4242@MainActor
@@ -146,7 +146,7 @@ struct IssueDetailViewModelTests {
146146@MainActor
147147struct BuildListViewModelTests {
148148
149 @Test func listsNewestFirst() async throws {
149 @Test func decodesEachRow() async throws {
150150 let (client, stub) = try makeClient()
151151 stub.enqueue(.init(status: 200, json: buildListJSON))
152152 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
@@ -167,7 +167,7 @@ struct BuildListViewModelTests {
167167 await model.load()
168168
169169 stub.enqueue(.init(status: 200, json: """
170 {"protocol_version":1,"data":[],"exit_code":0}
170 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
171171 """))
172172 var filter = BuildFilter()
173173 filter.status = .failure
@@ -183,7 +183,7 @@ struct BuildListViewModelTests {
183183
184184 let second = try #require(stub.seen.dropFirst().first)
185185 #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")
187187 guard case .empty(let message) = model.state else {
188188 Issue.record("expected .empty after filtering, got \(model.state)")
189189 return
gitbayTests/RepoManagementTests.swift +2 −2
@@ -365,9 +365,9 @@ struct RepoCreateViewModelTests {
365365struct BuildTriggerTests {
366366
367367 private let buildListJSON = """
368 {"protocol_version":1,"data":[\
368 {"protocol_version":1,"data":{"items":[\
369369 {"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}
371371 """
372372
373373 @Test func triggerSendsTheJobAndReloads() async throws {