krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.2.1: 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}