krz/hutch

an ios client for sourcehut

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

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