krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.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 @Environment(\.openURL) private var openURL
8 @State private var viewModel: BuildDetailViewModel?
9 @State private var rebuiltJobId: Int?
10 @State private var selectedTaskName: String?
11 @State private var showEditResubmitSheet = false
12 @State private var showCancelConfirmation = false
13 @State private var isOpeningRepository = false
14
15 private var isPresentingLogSheet: Bool {
16 selectedTaskName != nil
17 }
18
19 var body: some View {
20 Group {
21 if let viewModel {
22 detailContent(viewModel)
23 } else {
24 SRHTLoadingStateView(message: "Loading build…")
25 }
26 }
27 .navigationTitle("Job #\(jobId)")
28 .navigationBarTitleDisplayMode(.inline)
29 .toolbar {
30 ToolbarItemGroup(placement: .topBarTrailing) {
31 if let browserURL = viewModel?.job.flatMap({ SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }) {
32 Menu {
33 Button {
34 openURL(browserURL)
35 } label: {
36 Label("Open in Browser", systemImage: "safari")
37 }
38
39 Button {
40 appState.copyToPasteboard(browserURL.absoluteString, label: "build URL")
41 } label: {
42 Label("Copy URL", systemImage: "doc.on.doc")
43 }
44
45 if let job = viewModel?.job {
46 Button {
47 appState.copyToPasteboard(String(job.id), label: "job ID")
48 } label: {
49 Label("Copy Job ID", systemImage: "number")
50 }
51
52 if let note = job.note, !note.isEmpty {
53 Button {
54 appState.copyToPasteboard(note, label: "build note")
55 } label: {
56 Label("Copy Note", systemImage: "text.alignleft")
57 }
58 }
59 }
60 } label: {
61 Image(systemName: "ellipsis.circle")
62 }
63 .accessibilityLabel("Build actions")
64 }
65
66 SRHTShareButton(
67 url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
68 target: .build
69 ) {
70 Image(systemName: "square.and.arrow.up")
71 }
72 }
73 }
74 .navigationDestination(isPresented: Binding(
75 get: { rebuiltJobId != nil },
76 set: { isPresented in
77 if !isPresented {
78 rebuiltJobId = nil
79 }
80 }
81 )) {
82 if let rebuiltJobId {
83 BuildDetailView(jobId: rebuiltJobId)
84 }
85 }
86 .sheet(isPresented: $showEditResubmitSheet) {
87 if let viewModel, let job = viewModel.job {
88 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
89 showEditResubmitSheet = false
90 rebuiltJobId = jobId
91 }
92 }
93 }
94 .sheet(isPresented: Binding(
95 get: { selectedTaskName != nil },
96 set: { isPresented in
97 if !isPresented {
98 selectedTaskName = nil
99 }
100 }
101 )) {
102 if let selectedTaskName, let viewModel {
103 NavigationStack {
104 BuildTaskLogView(taskName: selectedTaskName, viewModel: viewModel)
105 .toolbar {
106 ToolbarItem(placement: .cancellationAction) {
107 Button("Done") {
108 self.selectedTaskName = nil
109 }
110 }
111 }
112 }
113 } else {
114 NavigationStack {
115 SRHTLoadingStateView(message: "Loading…")
116 .toolbar {
117 ToolbarItem(placement: .cancellationAction) {
118 Button("Done") {
119 self.selectedTaskName = nil
120 }
121 }
122 }
123 }
124 }
125 }
126 .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
127 Button("Keep Running", role: .cancel) {
128 // Alert dismissal is implicit; no additional action required.
129 }
130 Button("Cancel Build", role: .destructive) {
131 Task { await viewModel?.cancelJob() }
132 }
133 } message: {
134 Text("The build will stop as soon as possible.")
135 }
136 .task {
137 if viewModel == nil {
138 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
139 viewModel = vm
140 if appState.isDebugModeEnabled {
141 await vm.loadJobWithDebugCapture()
142 } else {
143 await vm.loadJob()
144 }
145 vm.startAutoRefresh()
146 }
147 }
148 .onAppear {
149 viewModel?.startAutoRefresh()
150 }
151 .onDisappear {
152 guard !isPresentingLogSheet else { return }
153 viewModel?.stopAutoRefresh()
154 }
155 }
156
157 @ViewBuilder
158 private func detailContent(_ viewModel: BuildDetailViewModel) -> some View {
159 if viewModel.isLoading, viewModel.job == nil {
160 SRHTLoadingStateView(message: "Loading build…")
161 } else if let error = viewModel.error, viewModel.job == nil {
162 SRHTErrorStateView(
163 title: "Couldn't Load Build",
164 message: error,
165 retryAction: { await reloadDetail(viewModel) }
166 )
167 } else if let job = viewModel.job {
168 List {
169 if let cacheMetadata = viewModel.cacheMetadata {
170 Section {
171 StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData)
172 }
173 }
174
175 Section("Details") {
176 HStack {
177 Text("Status")
178 Spacer()
179 HStack(spacing: 6) {
180 JobStatusIcon(status: job.status)
181 Text(job.status.rawValue)
182 .font(.subheadline.weight(.medium))
183 }
184 }
185 .themedRow()
186
187 if let note = job.note, !note.isEmpty {
188 LabeledContent("Note", value: note)
189 .themedRow()
190 }
191
192 if let image = job.image {
193 LabeledContent("Image", value: image)
194 .themedRow()
195 }
196
197 if !job.tags.isEmpty {
198 LabeledContent("Tags", value: job.tags.joined(separator: ", "))
199 .themedRow()
200 }
201
202 if let visibility = job.visibility {
203 LabeledContent("Visibility", value: visibility.rawValue.capitalized)
204 .themedRow()
205 }
206
207 LabeledContent("Owner", value: job.owner.canonicalName)
208 .themedRow()
209 LabeledContent("Created", value: job.created.relativeDescription)
210 .themedRow()
211 LabeledContent("Updated", value: job.updated.relativeDescription)
212 .themedRow()
213 }
214
215 if appState.isDebugModeEnabled {
216 Section("Debug") {
217 DebugTextBlock(
218 title: "Diagnostics",
219 content: """
220 jobId: \(job.id)
221 status: \(job.status.rawValue)
222 tasks: \(job.tasks.count)
223 artifacts: \(job.artifacts.count)
224 owner: \(job.owner.canonicalName)
225 url: \(SRHTWebURL.build(jobId: job.id, ownerCanonicalName: job.owner.canonicalName)?.absoluteString ?? "unavailable")
226 """
227 )
228 .themedRow()
229
230 if let rawJobResponse = viewModel.rawJobResponse {
231 DebugTextBlock(title: "Raw Response", content: rawJobResponse)
232 .themedRow()
233 }
234 }
235 }
236
237 if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) {
238 Section("Source") {
239 Button {
240 openRepository(ownerCanonicalName: repositoryReference.ownerCanonicalName, repositoryName: repositoryReference.name)
241 } label: {
242 HStack {
243 Label("\(repositoryReference.ownerCanonicalName)/\(repositoryReference.name)", systemImage: "book.closed")
244 Spacer()
245 if isOpeningRepository {
246 ProgressView()
247 .controlSize(.small)
248 } else {
249 Image(systemName: "arrow.up.right")
250 .font(.caption)
251 .foregroundStyle(.tertiary)
252 }
253 }
254 }
255 .disabled(isOpeningRepository)
256 .themedRow()
257 }
258 }
259
260 if !job.artifacts.isEmpty {
261 Section {
262 ForEach(job.artifacts) { artifact in
263 BuildArtifactRow(artifact: artifact) {
264 guard let url = artifact.url else { return }
265 openURL(url)
266 }
267 }
268 .themedRow()
269 } header: {
270 Text("Artifacts")
271 } footer: {
272 if job.artifacts.contains(where: { !$0.isDownloadable }) {
273 Text("Artifacts without a download URL are no longer available for download.")
274 }
275 }
276 }
277
278 // Per-task logs
279 if !job.tasks.isEmpty {
280 ForEach(job.tasks) { task in
281 Section {
282 Button {
283 selectedTaskName = task.name
284 } label: {
285 HStack {
286 Text(task.status.rawValue.capitalized)
287 .font(.subheadline)
288 .foregroundStyle(.secondary)
289 Spacer()
290 Image(systemName: "chevron.right")
291 .font(.caption)
292 .foregroundStyle(.tertiary)
293 }
294 }
295 .foregroundStyle(.primary)
296 .themedRow()
297 } header: {
298 HStack(spacing: 6) {
299 TaskStatusIcon(status: task.status)
300 Text(task.name)
301 }
302 }
303 }
304 }
305
306 // Cancel button
307 if job.status.isCancellable {
308 Section {
309 Button(role: .destructive) {
310 showCancelConfirmation = true
311 } label: {
312 HStack {
313 Text("Cancel Build")
314 if viewModel.isCancelling {
315 Spacer()
316 ProgressView()
317 }
318 }
319 }
320 .disabled(viewModel.isCancelling)
321 .themedRow()
322 }
323 }
324
325 if let manifest = job.manifest,
326 !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
327 Section {
328 Button {
329 Task {
330 rebuiltJobId = await viewModel.rebuildJob()
331 }
332 } label: {
333 HStack {
334 Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
335 if viewModel.isRebuilding {
336 Spacer()
337 ProgressView()
338 }
339 }
340 }
341 .disabled(viewModel.isRebuilding)
342 .themedRow()
343
344 Button {
345 showEditResubmitSheet = true
346 } label: {
347 Text("Edit & Resubmit")
348 }
349 .disabled(viewModel.isSubmittingEditedBuild)
350 .themedRow()
351 } footer: {
352 Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
353 }
354 }
355 }
356 .themedList()
357 .task(id: job.id) {
358 RecentActivityStore.recordBuild(
359 jobId: job.id,
360 title: recentActivityTitle(for: job),
361 defaults: appState.accountDefaults
362 )
363 }
364 .refreshable {
365 await reloadDetail(viewModel)
366 }
367 .srhtErrorBanner(error: Binding(
368 get: { viewModel.error },
369 set: { viewModel.error = $0 }
370 ))
371 .srhtErrorBanner(error: Binding(
372 get: { viewModel.actionError },
373 set: { _ in viewModel.dismissActionError() }
374 ))
375 }
376 }
377
378 private func openRepository(ownerCanonicalName: String, repositoryName: String) {
379 guard !isOpeningRepository else { return }
380 isOpeningRepository = true
381 Task {
382 defer { isOpeningRepository = false }
383 do {
384 let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName
385 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
386 appState.navigateToRepository(repository)
387 } catch {
388 appState.presentRepositoryDeepLinkError()
389 }
390 }
391 }
392
393 private func reloadDetail(_ viewModel: BuildDetailViewModel) async {
394 if appState.isDebugModeEnabled {
395 await viewModel.loadJobWithDebugCapture()
396 } else {
397 await viewModel.loadJob()
398 }
399 }
400
401 private func recentActivityTitle(for job: JobDetail) -> String {
402 if let note = job.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
403 return note
404 }
405 if !job.tags.isEmpty {
406 return job.tags.joined(separator: ", ")
407 }
408 return "Job #\(job.id)"
409 }
410}
411
412private struct BuildArtifactRow: View {
413 let artifact: BuildArtifact
414 let onDownload: () -> Void
415
416 var body: some View {
417 HStack(alignment: .top, spacing: 12) {
418 VStack(alignment: .leading, spacing: 4) {
419 Text(artifact.filename)
420 .font(.subheadline.weight(.medium))
421
422 if artifact.path != artifact.filename {
423 Text(artifact.path)
424 .font(.caption)
425 .foregroundStyle(.secondary)
426 .textSelection(.enabled)
427 }
428
429 Text(metadataText)
430 .font(.caption)
431 .foregroundStyle(.secondary)
432 }
433
434 Spacer(minLength: 12)
435
436 Button {
437 onDownload()
438 } label: {
439 Image(systemName: artifact.isDownloadable ? "arrow.down.circle" : "archivebox")
440 .imageScale(.large)
441 }
442 .disabled(!artifact.isDownloadable)
443 .accessibilityLabel(artifact.isDownloadable ? "Download \(artifact.filename)" : "\(artifact.filename) is unavailable")
444 }
445 }
446
447 private var metadataText: String {
448 var parts = [artifact.size.formattedByteCount]
449 parts.append("Created \(artifact.created.relativeDescription)")
450 if !artifact.isDownloadable {
451 parts.append("Unavailable")
452 }
453 return parts.joined(separator: " • ")
454 }
455}
456
457private struct EditResubmitBuildSheet: View {
458 let viewModel: BuildDetailViewModel
459 let job: JobDetail
460 let onSubmitted: (Int) -> Void
461
462 @Environment(\.dismiss) private var dismiss
463 @State private var manifest: String
464 @State private var tagsText: String
465 @State private var note: String
466 @State private var secrets = false
467 @State private var execute = true
468 @State private var visibility: Visibility
469
470 init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
471 self.viewModel = viewModel
472 self.job = job
473 self.onSubmitted = onSubmitted
474 _manifest = State(initialValue: job.manifest ?? "")
475 _tagsText = State(initialValue: job.tags.joined(separator: ", "))
476 _note = State(initialValue: job.note ?? "")
477 _visibility = State(initialValue: job.visibility ?? .publicVisibility)
478 }
479
480 var body: some View {
481 NavigationStack {
482 Form {
483 Section("Build Manifest") {
484 TextField("Build manifest", text: $manifest, axis: .vertical)
485 .font(.system(.body, design: .monospaced))
486 .lineLimit(12...24)
487 .textInputAutocapitalization(.never)
488 .autocorrectionDisabled()
489 .themedRow()
490 }
491
492 Section("Build Options") {
493 TextField("Note (optional)", text: $note)
494 .themedRow()
495 TextField("Tags (comma-separated, optional)", text: $tagsText)
496 .textInputAutocapitalization(.never)
497 .autocorrectionDisabled()
498 .themedRow()
499 Picker("Visibility", selection: $visibility) {
500 Text("Public").tag(Visibility.publicVisibility)
501 Text("Unlisted").tag(Visibility.unlisted)
502 Text("Private").tag(Visibility.privateVisibility)
503 }
504 .themedRow()
505 Toggle("Start build now", isOn: $execute)
506 .themedRow()
507 Toggle("Allow build secrets", isOn: $secrets)
508 .themedRow()
509 }
510
511 Section {
512 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.")
513 .font(.footnote)
514 .foregroundStyle(.secondary)
515 .themedRow()
516 }
517
518 if let actionError = viewModel.actionError {
519 Section {
520 Label {
521 Text(actionError)
522 } icon: {
523 Image(systemName: "exclamationmark.triangle.fill")
524 .foregroundStyle(.red)
525 }
526 .foregroundStyle(.red)
527 .themedRow()
528 }
529 }
530 }
531 .navigationTitle("Edit & Resubmit")
532 .navigationBarTitleDisplayMode(.inline)
533 .onDisappear {
534 viewModel.dismissActionError()
535 }
536 .toolbar {
537 ToolbarItem(placement: .cancellationAction) {
538 Button("Cancel") {
539 viewModel.dismissActionError()
540 dismiss()
541 }
542 }
543 ToolbarItem(placement: .confirmationAction) {
544 Button {
545 Task {
546 let tags = tagsText
547 .split(separator: ",")
548 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
549 .filter { !$0.isEmpty }
550 if let jobId = await viewModel.submitBuild(
551 manifest: manifest,
552 tags: tags,
553 note: note,
554 secrets: secrets,
555 execute: execute,
556 visibility: visibility
557 ) {
558 onSubmitted(jobId)
559 }
560 }
561 } label: {
562 if viewModel.isSubmittingEditedBuild {
563 ProgressView()
564 .controlSize(.small)
565 } else {
566 Text("Submit Build")
567 }
568 }
569 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
570 }
571 }
572 .themedList()
573 }
574 }
575}
576
577// MARK: - Task Status Icon
578
579private struct TaskStatusIcon: View {
580 let status: TaskStatus
581
582 var body: some View {
583 Image(systemName: iconName)
584 .foregroundStyle(color)
585 .frame(width: 20)
586 }
587
588 private var iconName: String {
589 switch status {
590 case .success: "checkmark.circle.fill"
591 case .failed: "xmark.circle.fill"
592 case .running: "arrow.trianglehead.2.clockwise.rotate.90"
593 case .pending: "circle.dashed"
594 case .skipped: "forward.circle.fill"
595 }
596 }
597
598 private var color: Color {
599 switch status {
600 case .success: .green
601 case .failed: .red
602 case .running: .yellow
603 case .pending: .gray
604 case .skipped: .secondary
605 }
606 }
607}