gitbay/Views/Builds/BuildDetailView.swift
288 lines · 11650 bytes
1import SwiftUI
2
3/// A build: what it was, then what it printed.
4struct BuildDetailView: View {
5
6 @State private var model: BuildDetailViewModel
7 @State private var confirmingCancel = false
8 @State private var query = ""
9 /// Steps the reader opened or closed, against `isOpenByDefault`.
10 @State private var toggled: Set<Int> = []
11 @State private var jump: Jump?
12
13 private enum Jump { case failure, end }
14
15 init(client: GitbayClient, repo: String, number: Int64) {
16 _model = State(initialValue: BuildDetailViewModel(
17 client: client, repoPath: repo, number: number
18 ))
19 }
20
21 var body: some View {
22 ZStack {
23 Color.clear
24 if let detail = model.state.value {
25 VStack(spacing: 0) {
26 if let error = model.actionError {
27 GBNotice(error, .gbWarn)
28 .padding(.horizontal, 16)
29 .padding(.vertical, 8)
30 }
31 header(detail.build)
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)
43 }
44 Divider()
45 log(detail)
46 }
47 }
48 }
49 .overlay { LoadStateOverlay(state: model.state) }
50 .navigationTitle("Build #\(model.number)")
51 .navigationBarTitleDisplayMode(.inline)
52 .searchable(text: $query, prompt: "Filter log lines")
53 .toolbar {
54 ToolbarItem(placement: .topBarTrailing) {
55 if model.isCancellable {
56 if model.working {
57 ProgressView()
58 } else {
59 Button("Cancel Build", role: .destructive) {
60 confirmingCancel = true
61 }
62 .accessibilityIdentifier("build-cancel-button")
63 }
64 }
65 }
66 }
67 .task {
68 await model.load()
69 await model.poll()
70 }
71 .refreshable { await model.load() }
72 .confirmationDialog(
73 "Cancel this build?",
74 isPresented: $confirmingCancel,
75 titleVisibility: .visible
76 ) {
77 Button("Cancel Build", role: .destructive) {
78 Task { await model.cancel() }
79 }
80 Button("Keep Running", role: .cancel) {}
81 } message: {
82 Text(
83 "A running step ends within a couple of seconds. If this is a " +
84 "duplicate of a commit that already passed, that earlier result " +
85 "is restored on the commit."
86 )
87 }
88 }
89
90 private func jumpBar(failed: Bool) -> some View {
91 HStack(spacing: 20) {
92 if failed {
93 Button("Jump to failure", systemImage: "exclamationmark.triangle") {
94 jump = .failure
95 }
96 .accessibilityIdentifier("build-jump-failure")
97 }
98 Button("Jump to end", systemImage: "arrow.down.to.line") {
99 jump = .end
100 }
101 .accessibilityIdentifier("build-jump-end")
102 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 }
114 }
115 .font(.gbSans(.caption))
116 .foregroundStyle(Color.gbAccent)
117 .buttonStyle(.borderless)
118 .padding(.horizontal, 16)
119 .padding(.bottom, 10)
120 }
121
122 private func header(_ build: Build) -> some View {
123 VStack(alignment: .leading, spacing: 6) {
124 HStack(spacing: 8) {
125 Text(build.job)
126 .font(.gbSans(.subheadline).weight(.medium))
127 GBChip(build.status, build.statusColor)
128 Spacer()
129 }
130 HStack(spacing: 6) {
131 Text(build.ref)
132 Text("·")
133 NavigationLink(value: RepoRoute.commit(repo: model.repoPath, sha: build.sha)) {
134 Text(build.shortSHA).font(.gbMono(.caption))
135 }
136 }
137 .font(.gbSans(.caption))
138 .foregroundStyle(.secondary)
139
140 Text(timing(build))
141 .font(.gbSans(.caption))
142 .foregroundStyle(.secondary)
143 }
144 .frame(maxWidth: .infinity, alignment: .leading)
145 .padding(.horizontal, 16)
146 .padding(.vertical, 12)
147 }
148
149 /// Queued always; finished only once it is.
150 private func timing(_ build: Build) -> String {
151 let queued = build.createdAt.formatted(date: .abbreviated, time: .shortened)
152 guard let finished = build.finishedAt else { return "queued \(queued)" }
153 let done = "queued \(queued) · finished \(finished.formatted(date: .omitted, time: .shortened))"
154 guard let ran = Build.ran(seconds: build.durationS) else { return done }
155 return "\(done) · ran \(ran)"
156 }
157
158 @ViewBuilder
159 private func log(_ detail: BuildDetail) -> some View {
160 let log = detail.lines
161 let sections = log.filtered(query)
162 if detail.log.isEmpty {
163 ContentUnavailableView {
164 Label("No log yet", systemImage: "doc.text")
165 }
166 } else if sections.isEmpty {
167 ContentUnavailableView.search(text: query)
168 } else {
169 // Wrapped lines in one lazy stack: a single Text of the whole
170 // log, or a horizontal scroll view per line, does not draw at
171 // a thousand lines and more.
172 ScrollViewReader { proxy in
173 ScrollView {
174 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
175 ForEach(sections) { section in
176 if log.stepped {
177 Section {
178 if !query.isEmpty || isOpen(section, in: detail) {
179 lines(section)
180 }
181 } header: {
182 stepHeader(section, in: detail)
183 }
184 } else {
185 lines(section)
186 }
187 }
188 }
189 .padding(.bottom, 12)
190 .textSelection(.enabled)
191 }
192 .onChange(of: jump) { _, target in
193 guard let target else { return }
194 jump = nil
195 scroll(to: target, in: detail, proxy: proxy)
196 }
197 }
198 }
199 }
200
201 private func lines(_ section: BuildLog.Section) -> some View {
202 ForEach(section.lines) { line in
203 Text(line.text.isEmpty ? " " : line.text)
204 .font(.gbMono(.caption2))
205 .frame(maxWidth: .infinity, alignment: .leading)
206 .padding(.horizontal, 12)
207 .id(line.id)
208 .accessibilityHidden(line.text.isEmpty)
209 }
210 }
211
212 private func stepHeader(_ section: BuildLog.Section, in detail: BuildDetail) -> some View {
213 let failed = detail.lines.failed.map { detail.lines.sections[$0].n } == section.n
214 let open = !query.isEmpty || isOpen(section, in: detail)
215 let label = section.n == 0 ? "setup" : "step \(section.n)/\(detail.build.steps?.count ?? 0)"
216 return Button {
217 toggled.formSymmetricDifference([section.n])
218 } label: {
219 HStack(spacing: 8) {
220 Image(systemName: open ? "chevron.down" : "chevron.right")
221 .font(.caption2)
222 .foregroundStyle(.secondary)
223 .frame(width: 12)
224 Text(label)
225 .font(.gbSans(.caption))
226 .foregroundStyle(.secondary)
227 if section.n > 0 {
228 Text(section.head)
229 .font(.gbMono(.caption))
230 .lineLimit(1)
231 .truncationMode(.tail)
232 }
233 Spacer(minLength: 0)
234 if failed {
235 GBChip(detail.build.failedReason.map { "failed: \($0)" } ?? "failed", .gbBad)
236 }
237 }
238 .padding(.horizontal, 12)
239 .padding(.vertical, 8)
240 .frame(maxWidth: .infinity, alignment: .leading)
241 .background(Color.gbFillSubtle)
242 .contentShape(Rectangle())
243 }
244 .buttonStyle(.plain)
245 .disabled(!query.isEmpty)
246 .id("step-\(section.n)")
247 .accessibilityElement(children: .ignore)
248 .accessibilityLabel([label, section.n > 0 ? section.head : nil, failed ? "failed" : nil]
249 .compactMap { $0 }.joined(separator: ", "))
250 .accessibilityValue(open ? "expanded" : "collapsed")
251 .accessibilityAddTraits(.isHeader)
252 }
253
254 /// A failed build opens at its failed step, a live one at its latest;
255 /// a passed build starts folded, as on the web.
256 private func isOpenByDefault(_ section: BuildLog.Section, in detail: BuildDetail) -> Bool {
257 let log = detail.lines
258 if let failed = log.failed { return log.sections[failed].n == section.n }
259 return BuildDetailViewModel.isLive(status: detail.build.status) && log.sections.last?.n == section.n
260 }
261
262 private func isOpen(_ section: BuildLog.Section, in detail: BuildDetail) -> Bool {
263 isOpenByDefault(section, in: detail) != toggled.contains(section.n)
264 }
265
266 /// To the top of the failed step, so its output reads from the start,
267 /// or to the last line of the log.
268 private func scroll(to target: Jump, in detail: BuildDetail, proxy: ScrollViewProxy) {
269 let log = detail.lines
270 let index = target == .failure ? log.failed : log.sections.indices.last
271 guard let index else { return }
272 let section = log.sections[index]
273 query = ""
274 if !isOpen(section, in: detail) { toggled.formSymmetricDifference([section.n]) }
275 // An unstepped log has no headers; its top is the first line.
276 let step: AnyHashable = log.stepped || section.lines.isEmpty
277 ? AnyHashable("step-\(section.n)") : AnyHashable(section.lines[0].id)
278 let last: AnyHashable = section.lines.last.map { AnyHashable($0.id) } ?? step
279 // Let the opened section lay out before scrolling into it.
280 Task { @MainActor in
281 if target == .failure {
282 proxy.scrollTo(step, anchor: .top)
283 } else {
284 proxy.scrollTo(last, anchor: .bottom)
285 }
286 }
287 }
288}