gitbay/Views/Builds/BuildDetailView.swift

v1.14.1
gitbay-ios/gitbay/Views/Builds/BuildDetailView.swift history · blame · raw

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}