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