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}