gitbay/Views/Builds/BuildDetailView.swift

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

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}