import SwiftUI /// A build: what it was, then what it printed. struct BuildDetailView: View { @State private var model: BuildDetailViewModel @State private var confirmingCancel = false @State private var query = "" /// Steps the reader opened or closed, against `isOpenByDefault`. @State private var toggled: Set = [] @State private var jump: Jump? private enum Jump { case failure, end } init(client: GitbayClient, repo: String, number: Int64) { _model = State(initialValue: BuildDetailViewModel( client: client, repoPath: repo, number: number )) } var body: some View { ZStack { Color.clear if let detail = model.state.value { VStack(spacing: 0) { if let error = model.actionError { GBNotice(error, .gbWarn) .padding(.horizontal, 16) .padding(.vertical, 8) } header(detail.build) if !detail.log.isEmpty || model.tail { // The tail is one block; its failure is the top of it. jumpBar(failed: detail.lines.failed != nil && !model.tail) } if model.tail { Text("The last \(BuildDetailViewModel.tailLines) lines, without the steps.") .font(.gbSans(.caption)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.bottom, 8) } Divider() log(detail) } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Build #\(model.number)") .navigationBarTitleDisplayMode(.inline) .searchable(text: $query, prompt: "Filter log lines") .toolbar { ToolbarItem(placement: .topBarTrailing) { if model.isCancellable { if model.working { ProgressView() } else { Button("Cancel Build", role: .destructive) { confirmingCancel = true } .accessibilityIdentifier("build-cancel-button") } } } } .task { await model.load() await model.poll() } .refreshable { await model.load() } .confirmationDialog( "Cancel this build?", isPresented: $confirmingCancel, titleVisibility: .visible ) { Button("Cancel Build", role: .destructive) { Task { await model.cancel() } } Button("Keep Running", role: .cancel) {} } message: { Text( "A running step ends within a couple of seconds. If this is a " + "duplicate of a commit that already passed, that earlier result " + "is restored on the commit." ) } } private func jumpBar(failed: Bool) -> some View { HStack(spacing: 20) { if failed { Button("Jump to failure", systemImage: "exclamationmark.triangle") { jump = .failure } .accessibilityIdentifier("build-jump-failure") } Button("Jump to end", systemImage: "arrow.down.to.line") { jump = .end } .accessibilityIdentifier("build-jump-end") Spacer() if model.tail { Button("Whole log") { Task { await model.showWholeLog() } } .accessibilityIdentifier("build-log-whole") } else { Button("Last \(BuildDetailViewModel.tailLines) lines") { Task { await model.showTail() } } .accessibilityIdentifier("build-log-tail") } } .font(.gbSans(.caption)) .foregroundStyle(Color.gbAccent) .buttonStyle(.borderless) .padding(.horizontal, 16) .padding(.bottom, 10) } private func header(_ build: Build) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Text(build.job) .font(.gbSans(.subheadline).weight(.medium)) GBChip(build.status, build.statusColor) Spacer() } HStack(spacing: 6) { Text(build.ref) Text("·") NavigationLink(value: RepoRoute.commit(repo: model.repoPath, sha: build.sha)) { Text(build.shortSHA).font(.gbMono(.caption)) } } .font(.gbSans(.caption)) .foregroundStyle(.secondary) Text(timing(build)) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.vertical, 12) } /// Queued always; finished only once it is. private func timing(_ build: Build) -> String { let queued = build.createdAt.formatted(date: .abbreviated, time: .shortened) guard let finished = build.finishedAt else { return "queued \(queued)" } let done = "queued \(queued) · finished \(finished.formatted(date: .omitted, time: .shortened))" guard let ran = Build.ran(seconds: build.durationS) else { return done } return "\(done) · ran \(ran)" } @ViewBuilder private func log(_ detail: BuildDetail) -> some View { let log = detail.lines let sections = log.filtered(query) if detail.log.isEmpty { ContentUnavailableView { Label("No log yet", systemImage: "doc.text") } } else if sections.isEmpty { ContentUnavailableView.search(text: query) } else { // Wrapped lines in one lazy stack: a single Text of the whole // log, or a horizontal scroll view per line, does not draw at // a thousand lines and more. ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { ForEach(sections) { section in if log.stepped { Section { if !query.isEmpty || isOpen(section, in: detail) { lines(section) } } header: { stepHeader(section, in: detail) } } else { lines(section) } } } .padding(.bottom, 12) .textSelection(.enabled) } .onChange(of: jump) { _, target in guard let target else { return } jump = nil scroll(to: target, in: detail, proxy: proxy) } } } } private func lines(_ section: BuildLog.Section) -> some View { ForEach(section.lines) { line in Text(line.text.isEmpty ? " " : line.text) .font(.gbMono(.caption2)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .id(line.id) .accessibilityHidden(line.text.isEmpty) } } private func stepHeader(_ section: BuildLog.Section, in detail: BuildDetail) -> some View { let failed = detail.lines.failed.map { detail.lines.sections[$0].n } == section.n let open = !query.isEmpty || isOpen(section, in: detail) let label = section.n == 0 ? "setup" : "step \(section.n)/\(detail.build.steps?.count ?? 0)" return Button { toggled.formSymmetricDifference([section.n]) } label: { HStack(spacing: 8) { Image(systemName: open ? "chevron.down" : "chevron.right") .font(.caption2) .foregroundStyle(.secondary) .frame(width: 12) Text(label) .font(.gbSans(.caption)) .foregroundStyle(.secondary) if section.n > 0 { Text(section.head) .font(.gbMono(.caption)) .lineLimit(1) .truncationMode(.tail) } Spacer(minLength: 0) if failed { GBChip(detail.build.failedReason.map { "failed: \($0)" } ?? "failed", .gbBad) } } .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.gbFillSubtle) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!query.isEmpty) .id("step-\(section.n)") .accessibilityElement(children: .ignore) .accessibilityLabel([label, section.n > 0 ? section.head : nil, failed ? "failed" : nil] .compactMap { $0 }.joined(separator: ", ")) .accessibilityValue(open ? "expanded" : "collapsed") .accessibilityAddTraits(.isHeader) } /// A failed build opens at its failed step, a live one at its latest; /// a passed build starts folded, as on the web. private func isOpenByDefault(_ section: BuildLog.Section, in detail: BuildDetail) -> Bool { let log = detail.lines if let failed = log.failed { return log.sections[failed].n == section.n } return BuildDetailViewModel.isLive(status: detail.build.status) && log.sections.last?.n == section.n } private func isOpen(_ section: BuildLog.Section, in detail: BuildDetail) -> Bool { isOpenByDefault(section, in: detail) != toggled.contains(section.n) } /// To the top of the failed step, so its output reads from the start, /// or to the last line of the log. private func scroll(to target: Jump, in detail: BuildDetail, proxy: ScrollViewProxy) { let log = detail.lines let index = target == .failure ? log.failed : log.sections.indices.last guard let index else { return } let section = log.sections[index] query = "" if !isOpen(section, in: detail) { toggled.formSymmetricDifference([section.n]) } // An unstepped log has no headers; its top is the first line. let step: AnyHashable = log.stepped || section.lines.isEmpty ? AnyHashable("step-\(section.n)") : AnyHashable(section.lines[0].id) let last: AnyHashable = section.lines.last.map { AnyHashable($0.id) } ?? step // Let the opened section lay out before scrolling into it. Task { @MainActor in if target == .failure { proxy.scrollTo(step, anchor: .top) } else { proxy.scrollTo(last, anchor: .bottom) } } } }