krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.3.0: Hutch/Views/Builds/BuildDetailView.swift · raw
1import SwiftUI
2
3struct BuildDetailView: View {
4 let jobId: Int
5
6 @Environment(AppState.self) private var appState
7 @State private var viewModel: BuildDetailViewModel?
8 @State private var rebuiltJobId: Int?
9 @State private var selectedTaskName: String?
10 @State private var showEditResubmitSheet = false
11 @State private var showCancelConfirmation = false
12
13 private var isPresentingLogSheet: Bool {
14 selectedTaskName != nil
15 }
16
17 var body: some View {
18 Group {
19 if let viewModel {
20 detailContent(viewModel)
21 } else {
22 SRHTLoadingStateView(message: "Loading build…")
23 }
24 }
25 .navigationTitle("Job #\(jobId)")
26 .navigationBarTitleDisplayMode(.inline)
27 .toolbar {
28 ToolbarItem(placement: .topBarTrailing) {
29 SRHTShareButton(
30 url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
31 target: .build
32 ) {
33 Image(systemName: "square.and.arrow.up")
34 }
35 }
36 }
37 .navigationDestination(isPresented: Binding(
38 get: { rebuiltJobId != nil },
39 set: { isPresented in
40 if !isPresented {
41 rebuiltJobId = nil
42 }
43 }
44 )) {
45 if let rebuiltJobId {
46 BuildDetailView(jobId: rebuiltJobId)
47 }
48 }
49 .sheet(isPresented: $showEditResubmitSheet) {
50 if let viewModel, let job = viewModel.job {
51 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
52 showEditResubmitSheet = false
53 rebuiltJobId = jobId
54 }
55 }
56 }
57 .sheet(isPresented: Binding(
58 get: { selectedTaskName != nil },
59 set: { isPresented in
60 if !isPresented {
61 selectedTaskName = nil
62 }
63 }
64 )) {
65 if let selectedTaskName, let viewModel {
66 NavigationStack {
67 BuildTaskLogView(taskName: selectedTaskName, viewModel: viewModel)
68 .toolbar {
69 ToolbarItem(placement: .cancellationAction) {
70 Button("Done") {
71 self.selectedTaskName = nil
72 }
73 }
74 }
75 }
76 } else {
77 NavigationStack {
78 SRHTLoadingStateView(message: "Loading…")
79 .toolbar {
80 ToolbarItem(placement: .cancellationAction) {
81 Button("Done") {
82 self.selectedTaskName = nil
83 }
84 }
85 }
86 }
87 }
88 }
89 .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
90 Button("Keep Running", role: .cancel) {}
91 Button("Cancel Build", role: .destructive) {
92 Task { await viewModel?.cancelJob() }
93 }
94 } message: {
95 Text("The build will stop as soon as possible.")
96 }
97 .task {
98 if viewModel == nil {
99 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
100 viewModel = vm
101 await vm.loadJob()
102 vm.startAutoRefresh()
103 }
104 }
105 .onAppear {
106 viewModel?.startAutoRefresh()
107 }
108 .onDisappear {
109 guard !isPresentingLogSheet else { return }
110 viewModel?.stopAutoRefresh()
111 }
112 }
113
114 @ViewBuilder
115 private func detailContent(_ viewModel: BuildDetailViewModel) -> some View {
116 if viewModel.isLoading, viewModel.job == nil {
117 SRHTLoadingStateView(message: "Loading build…")
118 } else if let error = viewModel.error, viewModel.job == nil {
119 SRHTErrorStateView(
120 title: "Couldn't Load Build",
121 message: error,
122 retryAction: { await viewModel.loadJob() }
123 )
124 } else if let job = viewModel.job {
125 List {
126 // Status & metadata
127 Section("Details") {
128 HStack {
129 Text("Status")
130 Spacer()
131 HStack(spacing: 6) {
132 JobStatusIcon(status: job.status)
133 Text(job.status.rawValue)
134 .font(.subheadline.weight(.medium))
135 }
136 }
137
138 if let note = job.note, !note.isEmpty {
139 LabeledContent("Note", value: note)
140 }
141
142 if let image = job.image {
143 LabeledContent("Image", value: image)
144 }
145
146 if !job.tags.isEmpty {
147 LabeledContent("Tags", value: job.tags.joined(separator: ", "))
148 }
149
150 if let visibility = job.visibility {
151 LabeledContent("Visibility", value: visibility.rawValue.capitalized)
152 }
153
154 LabeledContent("Owner", value: job.owner.canonicalName)
155 LabeledContent("Created", value: job.created.relativeDescription)
156 LabeledContent("Updated", value: job.updated.relativeDescription)
157 }
158
159 // Per-task logs
160 if !job.tasks.isEmpty {
161 ForEach(job.tasks) { task in
162 Section {
163 Button {
164 selectedTaskName = task.name
165 } label: {
166 HStack {
167 Text(task.status.rawValue.capitalized)
168 .font(.subheadline)
169 .foregroundStyle(.secondary)
170 Spacer()
171 Image(systemName: "chevron.right")
172 .font(.caption)
173 .foregroundStyle(.tertiary)
174 }
175 }
176 .foregroundStyle(.primary)
177 } header: {
178 HStack(spacing: 6) {
179 TaskStatusIcon(status: task.status)
180 Text(task.name)
181 }
182 }
183 }
184 }
185
186 // Cancel button
187 if job.status.isCancellable {
188 Section {
189 Button(role: .destructive) {
190 showCancelConfirmation = true
191 } label: {
192 HStack {
193 Text("Cancel Build")
194 if viewModel.isCancelling {
195 Spacer()
196 ProgressView()
197 }
198 }
199 }
200 .disabled(viewModel.isCancelling)
201 }
202 }
203
204 if let manifest = job.manifest,
205 !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
206 Section {
207 Button {
208 Task {
209 rebuiltJobId = await viewModel.rebuildJob()
210 }
211 } label: {
212 HStack {
213 Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
214 if viewModel.isRebuilding {
215 Spacer()
216 ProgressView()
217 }
218 }
219 }
220 .disabled(viewModel.isRebuilding)
221
222 Button {
223 showEditResubmitSheet = true
224 } label: {
225 Text("Edit & Resubmit")
226 }
227 .disabled(viewModel.isSubmittingEditedBuild)
228 } footer: {
229 Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
230 }
231 }
232 }
233 .refreshable {
234 await viewModel.loadJob()
235 }
236 .srhtErrorBanner(error: Binding(
237 get: { viewModel.error },
238 set: { viewModel.error = $0 }
239 ))
240 }
241 }
242}
243
244private struct EditResubmitBuildSheet: View {
245 let viewModel: BuildDetailViewModel
246 let job: JobDetail
247 let onSubmitted: (Int) -> Void
248
249 @Environment(\.dismiss) private var dismiss
250 @Bindable var viewModelBindable: BuildDetailViewModel
251 @State private var manifest: String
252 @State private var tagsText: String
253 @State private var note: String
254 @State private var secrets = false
255 @State private var execute = true
256 @State private var visibility: Visibility
257
258 init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
259 self.viewModel = viewModel
260 self._viewModelBindable = Bindable(viewModel)
261 self.job = job
262 self.onSubmitted = onSubmitted
263 _manifest = State(initialValue: job.manifest ?? "")
264 _tagsText = State(initialValue: job.tags.joined(separator: ", "))
265 _note = State(initialValue: job.note ?? "")
266 _visibility = State(initialValue: job.visibility ?? .public)
267 }
268
269 var body: some View {
270 NavigationStack {
271 Form {
272 Section("Build Manifest") {
273 TextField("Build manifest", text: $manifest, axis: .vertical)
274 .font(.system(.body, design: .monospaced))
275 .lineLimit(12...24)
276 .textInputAutocapitalization(.never)
277 .autocorrectionDisabled()
278 }
279
280 Section("Build Options") {
281 TextField("Note (optional)", text: $note)
282 TextField("Tags (comma-separated, optional)", text: $tagsText)
283 .textInputAutocapitalization(.never)
284 .autocorrectionDisabled()
285 Picker("Visibility", selection: $visibility) {
286 Text("Public").tag(Visibility.public)
287 Text("Unlisted").tag(Visibility.unlisted)
288 Text("Private").tag(Visibility.private)
289 }
290 Toggle("Start build now", isOn: $execute)
291 Toggle("Allow build secrets", isOn: $secrets)
292 }
293
294 Section {
295 Text("This submits a new build. “Start build now” and “Allow build secrets” use local defaults because the current job does not include those original values.")
296 .font(.footnote)
297 .foregroundStyle(.secondary)
298 }
299
300 if let error = viewModel.error {
301 Section {
302 Label {
303 Text(error)
304 } icon: {
305 Image(systemName: "exclamationmark.triangle.fill")
306 .foregroundStyle(.red)
307 }
308 .foregroundStyle(.red)
309 }
310 }
311 }
312 .navigationTitle("Edit & Resubmit")
313 .navigationBarTitleDisplayMode(.inline)
314 .onDisappear {
315 viewModelBindable.error = nil
316 }
317 .toolbar {
318 ToolbarItem(placement: .cancellationAction) {
319 Button("Cancel") {
320 viewModelBindable.error = nil
321 dismiss()
322 }
323 }
324 ToolbarItem(placement: .confirmationAction) {
325 Button {
326 Task {
327 let tags = tagsText
328 .split(separator: ",")
329 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
330 .filter { !$0.isEmpty }
331 if let jobId = await viewModel.submitBuild(
332 manifest: manifest,
333 tags: tags,
334 note: note,
335 secrets: secrets,
336 execute: execute,
337 visibility: visibility
338 ) {
339 onSubmitted(jobId)
340 }
341 }
342 } label: {
343 if viewModel.isSubmittingEditedBuild {
344 ProgressView()
345 .controlSize(.small)
346 } else {
347 Text("Submit Build")
348 }
349 }
350 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
351 }
352 }
353 }
354 }
355}
356
357// MARK: - Task Status Icon
358
359private struct TaskStatusIcon: View {
360 let status: TaskStatus
361
362 var body: some View {
363 Image(systemName: iconName)
364 .foregroundStyle(color)
365 .frame(width: 20)
366 }
367
368 private var iconName: String {
369 switch status {
370 case .success: "checkmark.circle.fill"
371 case .failed: "xmark.circle.fill"
372 case .running: "arrow.trianglehead.2.clockwise.rotate.90"
373 case .pending: "circle.dashed"
374 case .skipped: "forward.circle.fill"
375 }
376 }
377
378 private var color: Color {
379 switch status {
380 case .success: .green
381 case .failed: .red
382 case .running: .yellow
383 case .pending: .gray
384 case .skipped: .secondary
385 }
386 }
387}