krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.7: 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 Section("Details") {
170 HStack {
171 Text("Status")
172 Spacer()
173 HStack(spacing: 6) {
174 JobStatusIcon(status: job.status)
175 Text(job.status.rawValue)
176 .font(.subheadline.weight(.medium))
177 }
178 }
179 .themedRow()
180
181 if let note = job.note, !note.isEmpty {
182 LabeledContent("Note", value: note)
183 .themedRow()
184 }
185
186 if let image = job.image {
187 LabeledContent("Image", value: image)
188 .themedRow()
189 }
190
191 if !job.tags.isEmpty {
192 LabeledContent("Tags", value: job.tags.joined(separator: ", "))
193 .themedRow()
194 }
195
196 if let visibility = job.visibility {
197 LabeledContent("Visibility", value: visibility.rawValue.capitalized)
198 .themedRow()
199 }
200
201 LabeledContent("Owner", value: job.owner.canonicalName)
202 .themedRow()
203 LabeledContent("Created", value: job.created.relativeDescription)
204 .themedRow()
205 LabeledContent("Updated", value: job.updated.relativeDescription)
206 .themedRow()
207 }
208
209 if appState.isDebugModeEnabled {
210 Section("Debug") {
211 DebugTextBlock(
212 title: "Diagnostics",
213 content: """
214 jobId: \(job.id)
215 status: \(job.status.rawValue)
216 tasks: \(job.tasks.count)
217 artifacts: \(job.artifacts.count)
218 owner: \(job.owner.canonicalName)
219 url: \(SRHTWebURL.build(jobId: job.id, ownerCanonicalName: job.owner.canonicalName)?.absoluteString ?? "unavailable")
220 """
221 )
222 .themedRow()
223
224 if let rawJobResponse = viewModel.rawJobResponse {
225 DebugTextBlock(title: "Raw Response", content: rawJobResponse)
226 .themedRow()
227 }
228 }
229 }
230
231 if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) {
232 Section("Source") {
233 Button {
234 openRepository(ownerCanonicalName: repositoryReference.ownerCanonicalName, repositoryName: repositoryReference.name)
235 } label: {
236 HStack {
237 Label("\(repositoryReference.ownerCanonicalName)/\(repositoryReference.name)", systemImage: "book.closed")
238 Spacer()
239 if isOpeningRepository {
240 ProgressView()
241 .controlSize(.small)
242 } else {
243 Image(systemName: "arrow.up.right")
244 .font(.caption)
245 .foregroundStyle(.tertiary)
246 }
247 }
248 }
249 .disabled(isOpeningRepository)
250 .themedRow()
251 }
252 }
253
254 if !job.artifacts.isEmpty {
255 Section {
256 ForEach(job.artifacts) { artifact in
257 BuildArtifactRow(artifact: artifact) {
258 guard let url = artifact.url else { return }
259 openURL(url)
260 }
261 }
262 .themedRow()
263 } header: {
264 Text("Artifacts")
265 } footer: {
266 if job.artifacts.contains(where: { !$0.isDownloadable }) {
267 Text("Artifacts without a download URL are no longer available for download.")
268 }
269 }
270 }
271
272 // Per-task logs
273 if !job.tasks.isEmpty {
274 ForEach(job.tasks) { task in
275 Section {
276 Button {
277 selectedTaskName = task.name
278 } label: {
279 HStack {
280 Text(task.status.rawValue.capitalized)
281 .font(.subheadline)
282 .foregroundStyle(.secondary)
283 Spacer()
284 Image(systemName: "chevron.right")
285 .font(.caption)
286 .foregroundStyle(.tertiary)
287 }
288 }
289 .foregroundStyle(.primary)
290 .themedRow()
291 } header: {
292 HStack(spacing: 6) {
293 TaskStatusIcon(status: task.status)
294 Text(task.name)
295 }
296 }
297 }
298 }
299
300 // Cancel button
301 if job.status.isCancellable {
302 Section {
303 Button(role: .destructive) {
304 showCancelConfirmation = true
305 } label: {
306 HStack {
307 Text("Cancel Build")
308 if viewModel.isCancelling {
309 Spacer()
310 ProgressView()
311 }
312 }
313 }
314 .disabled(viewModel.isCancelling)
315 .themedRow()
316 }
317 }
318
319 if let manifest = job.manifest,
320 !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
321 Section {
322 Button {
323 Task {
324 rebuiltJobId = await viewModel.rebuildJob()
325 }
326 } label: {
327 HStack {
328 Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
329 if viewModel.isRebuilding {
330 Spacer()
331 ProgressView()
332 }
333 }
334 }
335 .disabled(viewModel.isRebuilding)
336 .themedRow()
337
338 Button {
339 showEditResubmitSheet = true
340 } label: {
341 Text("Edit & Resubmit")
342 }
343 .disabled(viewModel.isSubmittingEditedBuild)
344 .themedRow()
345 } footer: {
346 Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
347 }
348 }
349 }
350 .themedList()
351 .task(id: job.id) {
352 RecentActivityStore.recordBuild(
353 jobId: job.id,
354 title: recentActivityTitle(for: job),
355 defaults: appState.accountDefaults
356 )
357 }
358 .refreshable {
359 await reloadDetail(viewModel)
360 }
361 .srhtErrorBanner(error: Binding(
362 get: { viewModel.error },
363 set: { viewModel.error = $0 }
364 ))
365 .srhtErrorBanner(error: Binding(
366 get: { viewModel.actionError },
367 set: { _ in viewModel.dismissActionError() }
368 ))
369 }
370 }
371
372 private func openRepository(ownerCanonicalName: String, repositoryName: String) {
373 guard !isOpeningRepository else { return }
374 isOpeningRepository = true
375 Task {
376 defer { isOpeningRepository = false }
377 do {
378 let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName
379 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
380 appState.navigateToRepository(repository)
381 } catch {
382 appState.presentRepositoryDeepLinkError()
383 }
384 }
385 }
386
387 private func reloadDetail(_ viewModel: BuildDetailViewModel) async {
388 if appState.isDebugModeEnabled {
389 await viewModel.loadJobWithDebugCapture()
390 } else {
391 await viewModel.loadJob()
392 }
393 }
394
395 private func recentActivityTitle(for job: JobDetail) -> String {
396 if let note = job.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
397 return note
398 }
399 if !job.tags.isEmpty {
400 return job.tags.joined(separator: ", ")
401 }
402 return "Job #\(job.id)"
403 }
404}
405
406private struct BuildArtifactRow: View {
407 let artifact: BuildArtifact
408 let onDownload: () -> Void
409
410 var body: some View {
411 HStack(alignment: .top, spacing: 12) {
412 VStack(alignment: .leading, spacing: 4) {
413 Text(artifact.filename)
414 .font(.subheadline.weight(.medium))
415
416 if artifact.path != artifact.filename {
417 Text(artifact.path)
418 .font(.caption)
419 .foregroundStyle(.secondary)
420 .textSelection(.enabled)
421 }
422
423 Text(metadataText)
424 .font(.caption)
425 .foregroundStyle(.secondary)
426 }
427
428 Spacer(minLength: 12)
429
430 Button {
431 onDownload()
432 } label: {
433 Image(systemName: artifact.isDownloadable ? "arrow.down.circle" : "archivebox")
434 .imageScale(.large)
435 }
436 .disabled(!artifact.isDownloadable)
437 .accessibilityLabel(artifact.isDownloadable ? "Download \(artifact.filename)" : "\(artifact.filename) is unavailable")
438 }
439 }
440
441 private var metadataText: String {
442 var parts = [artifact.size.formattedByteCount]
443 parts.append("Created \(artifact.created.relativeDescription)")
444 if !artifact.isDownloadable {
445 parts.append("Unavailable")
446 }
447 return parts.joined(separator: " • ")
448 }
449}
450
451private struct EditResubmitBuildSheet: View {
452 let viewModel: BuildDetailViewModel
453 let job: JobDetail
454 let onSubmitted: (Int) -> Void
455
456 @Environment(\.dismiss) private var dismiss
457 @State private var manifest: String
458 @State private var tagsText: String
459 @State private var note: String
460 @State private var secrets = false
461 @State private var execute = true
462 @State private var visibility: Visibility
463
464 init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
465 self.viewModel = viewModel
466 self.job = job
467 self.onSubmitted = onSubmitted
468 _manifest = State(initialValue: job.manifest ?? "")
469 _tagsText = State(initialValue: job.tags.joined(separator: ", "))
470 _note = State(initialValue: job.note ?? "")
471 _visibility = State(initialValue: job.visibility ?? .publicVisibility)
472 }
473
474 var body: some View {
475 NavigationStack {
476 Form {
477 Section("Build Manifest") {
478 TextField("Build manifest", text: $manifest, axis: .vertical)
479 .font(.system(.body, design: .monospaced))
480 .lineLimit(12...24)
481 .textInputAutocapitalization(.never)
482 .autocorrectionDisabled()
483 .themedRow()
484 }
485
486 Section("Build Options") {
487 TextField("Note (optional)", text: $note)
488 .themedRow()
489 TextField("Tags (comma-separated, optional)", text: $tagsText)
490 .textInputAutocapitalization(.never)
491 .autocorrectionDisabled()
492 .themedRow()
493 Picker("Visibility", selection: $visibility) {
494 Text("Public").tag(Visibility.publicVisibility)
495 Text("Unlisted").tag(Visibility.unlisted)
496 Text("Private").tag(Visibility.privateVisibility)
497 }
498 .themedRow()
499 Toggle("Start build now", isOn: $execute)
500 .themedRow()
501 Toggle("Allow build secrets", isOn: $secrets)
502 .themedRow()
503 }
504
505 Section {
506 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.")
507 .font(.footnote)
508 .foregroundStyle(.secondary)
509 .themedRow()
510 }
511
512 if let actionError = viewModel.actionError {
513 Section {
514 Label {
515 Text(actionError)
516 } icon: {
517 Image(systemName: "exclamationmark.triangle.fill")
518 .foregroundStyle(.red)
519 }
520 .foregroundStyle(.red)
521 .themedRow()
522 }
523 }
524 }
525 .navigationTitle("Edit & Resubmit")
526 .navigationBarTitleDisplayMode(.inline)
527 .onDisappear {
528 viewModel.dismissActionError()
529 }
530 .toolbar {
531 ToolbarItem(placement: .cancellationAction) {
532 Button("Cancel") {
533 viewModel.dismissActionError()
534 dismiss()
535 }
536 }
537 ToolbarItem(placement: .confirmationAction) {
538 Button {
539 Task {
540 let tags = tagsText
541 .split(separator: ",")
542 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
543 .filter { !$0.isEmpty }
544 if let jobId = await viewModel.submitBuild(
545 manifest: manifest,
546 tags: tags,
547 note: note,
548 secrets: secrets,
549 execute: execute,
550 visibility: visibility
551 ) {
552 onSubmitted(jobId)
553 }
554 }
555 } label: {
556 if viewModel.isSubmittingEditedBuild {
557 ProgressView()
558 .controlSize(.small)
559 } else {
560 Text("Submit Build")
561 }
562 }
563 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
564 }
565 }
566 .themedList()
567 }
568 }
569}
570
571// MARK: - Task Status Icon
572
573private struct TaskStatusIcon: View {
574 let status: TaskStatus
575
576 var body: some View {
577 Image(systemName: iconName)
578 .foregroundStyle(color)
579 .frame(width: 20)
580 }
581
582 private var iconName: String {
583 switch status {
584 case .success: "checkmark.circle.fill"
585 case .failed: "xmark.circle.fill"
586 case .running: "arrow.trianglehead.2.clockwise.rotate.90"
587 case .pending: "circle.dashed"
588 case .skipped: "forward.circle.fill"
589 }
590 }
591
592 private var color: Color {
593 switch status {
594 case .success: .green
595 case .failed: .red
596 case .running: .yellow
597 case .pending: .gray
598 case .skipped: .secondary
599 }
600 }
601}