gitbay/Views/Builds/BuildDetailView.swift
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}