gitbay/Views/Builds/BuildDetailView.swift

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

118 lines · 4131 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
  9    init(client: GitbayClient, repo: String, number: Int64) {
 10        _model = State(initialValue: BuildDetailViewModel(
 11            client: client, repoPath: repo, number: number
 12        ))
 13    }
 14
 15    var body: some View {
 16        ZStack {
 17            Color.clear
 18            if let detail = model.state.value {
 19                VStack(spacing: 0) {
 20                    if let error = model.actionError {
 21                        GBNotice(error, .gbWarn)
 22                            .padding(.horizontal, 16)
 23                            .padding(.vertical, 8)
 24                    }
 25                    header(detail.build)
 26                    Divider()
 27                    log(detail.log)
 28                }
 29            }
 30        }
 31        .overlay { LoadStateOverlay(state: model.state) }
 32        .navigationTitle("Build #\(model.number)")
 33        .navigationBarTitleDisplayMode(.inline)
 34        .toolbar {
 35            ToolbarItem(placement: .topBarTrailing) {
 36                if model.isCancellable {
 37                    if model.working {
 38                        ProgressView()
 39                    } else {
 40                        Button("Cancel Build", role: .destructive) {
 41                            confirmingCancel = true
 42                        }
 43                        .accessibilityIdentifier("build-cancel-button")
 44                    }
 45                }
 46            }
 47        }
 48        .task { await model.load() }
 49        .refreshable { await model.load() }
 50        .confirmationDialog(
 51            "Cancel this build?",
 52            isPresented: $confirmingCancel,
 53            titleVisibility: .visible
 54        ) {
 55            Button("Cancel Build", role: .destructive) {
 56                Task { await model.cancel() }
 57            }
 58            Button("Keep Running", role: .cancel) {}
 59        } message: {
 60            Text(
 61                "A running step ends within a couple of seconds. If this is a " +
 62                "duplicate of a commit that already passed, that earlier result " +
 63                "is restored on the commit."
 64            )
 65        }
 66    }
 67
 68    private func header(_ build: Build) -> some View {
 69        VStack(alignment: .leading, spacing: 6) {
 70            HStack(spacing: 8) {
 71                Text(build.job)
 72                    .font(.gbSans(.subheadline).weight(.medium))
 73                GBChip(build.status, build.statusColor)
 74                Spacer()
 75            }
 76            HStack(spacing: 6) {
 77                Text(build.ref)
 78                Text("·")
 79                NavigationLink(value: RepoRoute.commit(repo: model.repoPath, sha: build.sha)) {
 80                    Text(build.shortSHA).font(.gbMono(.caption))
 81                }
 82            }
 83            .font(.gbSans(.caption))
 84            .foregroundStyle(.secondary)
 85
 86            Text(timing(build))
 87                .font(.gbSans(.caption))
 88                .foregroundStyle(.secondary)
 89        }
 90        .frame(maxWidth: .infinity, alignment: .leading)
 91        .padding(.horizontal, 16)
 92        .padding(.vertical, 12)
 93    }
 94
 95    /// Queued always; finished only once it is.
 96    private func timing(_ build: Build) -> String {
 97        let queued = build.createdAt.formatted(date: .abbreviated, time: .shortened)
 98        guard let finished = build.finishedAt else { return "queued \(queued)" }
 99        return "queued \(queued) · finished \(finished.formatted(date: .omitted, time: .shortened))"
100    }
101
102    @ViewBuilder
103    private func log(_ text: String) -> some View {
104        if text.isEmpty {
105            ContentUnavailableView {
106                Label("No log yet", systemImage: "doc.text")
107            }
108        } else {
109            ScrollView([.horizontal, .vertical]) {
110                Text(text)
111                    .font(.gbMono(.caption2))
112                    .frame(maxWidth: .infinity, alignment: .leading)
113                    .padding(12)
114                    .textSelection(.enabled)
115            }
116        }
117    }
118}