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