Commit e27fa2c83a

e27fa2c83abc1971751f9a1ae33f01c482045fa9

parent: c88e4da270

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 05:39 UTC

A build log's last lines only

Last 200 lines beside the jump buttons reads build log --tail and keeps
polling in that mode, so a long running build is not downloaded whole
every three seconds. The tail may start inside a step, so it is drawn
as one block; Whole log brings the steps back.

Closes #27

Layout: unified · split

gitbay/Builds/BuildDetailViewModel.swift +26 −4
@@ -6,10 +6,17 @@ nonisolated struct BuildDetail: Sendable {
66 let log: String
77 let lines: BuildLog
88
9 init(build: Build, log: String) {
9 /// `tail` is a log's last lines only, which may start inside a step
10 /// and cut its marker off, so it is drawn as one block.
11 init(build: Build, log: String, tail: Bool = false) {
1012 self.build = build
1113 self.log = log
12 self.lines = BuildLog(log, build: build)
14 var split = build
15 if tail {
16 split.steps = nil
17 split.failedStep = nil
18 }
19 self.lines = BuildLog(log, build: split)
1320 }
1421}
1522
@@ -21,6 +28,10 @@ nonisolated struct BuildDetail: Sendable {
2128final class BuildDetailViewModel {
2229
2330 private(set) var state: LoadState<BuildDetail> = .loading
31 /// Showing only the log's last `tailLines` lines (`build log --tail`):
32 /// on a phone a long log is a long download, every poll.
33 private(set) var tail = false
34 nonisolated static let tailLines = 200
2435 private(set) var actionError: String?
2536 private(set) var working = false
2637
@@ -48,8 +59,19 @@ final class BuildDetailViewModel {
4859 let ref = [repoPath, String(number)]
4960 async let build = client.read(["build", "show"] + ref, as: Build.self)
5061 // A queued build has no log yet; that is not a failure.
51 async let log = client.readText(["build", "log"] + ref)
52 return BuildDetail(build: try await build, log: (try? await log) ?? "")
62 let tail = self.tail
63 async let log = client.readText(["build", "log"] + ref + (tail ? ["--tail", String(Self.tailLines)] : []))
64 return BuildDetail(build: try await build, log: (try? await log) ?? "", tail: tail)
65 }
66
67 func showTail() async {
68 tail = true
69 await load()
70 }
71
72 func showWholeLog() async {
73 tail = false
74 await load()
5375 }
5476
5577 static func isLive(status: String) -> Bool {
gitbay/Views/Builds/BuildDetailView.swift +22 −2
@@ -29,8 +29,17 @@ struct BuildDetailView: View {
2929 .padding(.vertical, 8)
3030 }
3131 header(detail.build)
32 if !detail.log.isEmpty {
33 jumpBar(failed: detail.lines.failed != nil)
32 if !detail.log.isEmpty || model.tail {
33 // The tail is one block; its failure is the top of it.
34 jumpBar(failed: detail.lines.failed != nil && !model.tail)
35 }
36 if model.tail {
37 Text("The last \(BuildDetailViewModel.tailLines) lines, without the steps.")
38 .font(.gbSans(.caption))
39 .foregroundStyle(.secondary)
40 .frame(maxWidth: .infinity, alignment: .leading)
41 .padding(.horizontal, 16)
42 .padding(.bottom, 8)
3443 }
3544 Divider()
3645 log(detail)
@@ -91,6 +100,17 @@ struct BuildDetailView: View {
91100 }
92101 .accessibilityIdentifier("build-jump-end")
93102 Spacer()
103 if model.tail {
104 Button("Whole log") {
105 Task { await model.showWholeLog() }
106 }
107 .accessibilityIdentifier("build-log-whole")
108 } else {
109 Button("Last \(BuildDetailViewModel.tailLines) lines") {
110 Task { await model.showTail() }
111 }
112 .accessibilityIdentifier("build-log-tail")
113 }
94114 }
95115 .font(.gbSans(.caption))
96116 .foregroundStyle(Color.gbAccent)
gitbayTests/BuildLogTests.swift +57
@@ -192,3 +192,60 @@ struct BuildPollingTests {
192192 #expect(model.state.value?.log == "a")
193193 }
194194}
195
196private let buildShowJSON = """
197 {"protocol_version":1,"data":{"number":1,"job":"test","status":"failure","sha":"abc",\
198 "ref":"main","created_at":"2026-08-20T10:00:00.000Z","steps":["go build ./...","go test ./..."],\
199 "failed_step":2},"exit_code":0}
200 """
201
202private func logEnvelope(_ text: String) -> String {
203 let data = try! JSONSerialization.data(withJSONObject: ["protocol_version": 1, "output": text, "exit_code": 0])
204 return String(decoding: data, as: UTF8.self)
205}
206
207@MainActor
208struct BuildLogTailTests {
209
210 @Test func theTailReadsTheLastLinesAndDrawsThemUnsplit() async throws {
211 let (client, stub) = try makeClient()
212 stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
213 stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
214 stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
215 stub.enqueue(.init(status: 200, json: logEnvelope("--- FAIL: TestX\nstep 2/2 failed: exit 1\n"), match: "argv=log"))
216 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1)
217 await model.load()
218 #expect(model.state.value?.lines.stepped == true)
219
220 await model.showTail()
221
222 let log = try #require(stub.seen.last { $0.url.absoluteString.contains("argv=log") })
223 #expect(log.url.query()?.hasSuffix("argv=--tail&argv=\(BuildDetailViewModel.tailLines)") == true)
224 let detail = try #require(model.state.value)
225 #expect(model.tail)
226 // A step's marker may be cut off, so the tail is one plain block.
227 #expect(!detail.lines.stepped)
228 // A failed build's tail is where it failed.
229 #expect(detail.lines.failed == 0)
230 #expect(detail.lines.sections.first?.lines.map(\.text) == ["--- FAIL: TestX", "step 2/2 failed: exit 1"])
231 }
232
233 @Test func theWholeLogComesBackSplit() async throws {
234 let (client, stub) = try makeClient()
235 for _ in 0..<3 {
236 stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
237 }
238 stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
239 stub.enqueue(.init(status: 200, json: logEnvelope("step 2/2 failed: exit 1\n"), match: "argv=log"))
240 stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
241 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1)
242 await model.load()
243 await model.showTail()
244
245 await model.showWholeLog()
246
247 #expect(!model.tail)
248 #expect(stub.seen.last { $0.url.absoluteString.contains("argv=log") }?.url.query()?.contains("tail") == false)
249 #expect(model.state.value?.lines.stepped == true)
250 }
251}