krz/hutch

an ios client for sourcehut

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

v2.4.0: 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    @State private var viewModel: BuildDetailViewModel?
  8    @State private var rebuiltJobId: Int?
  9    @State private var selectedTaskName: String?
 10    @State private var showEditResubmitSheet = false
 11    @State private var showCancelConfirmation = false
 12
 13    private var isPresentingLogSheet: Bool {
 14        selectedTaskName != nil
 15    }
 16
 17    var body: some View {
 18        Group {
 19            if let viewModel {
 20                detailContent(viewModel)
 21            } else {
 22                SRHTLoadingStateView(message: "Loading build…")
 23            }
 24        }
 25        .navigationTitle("Job #\(jobId)")
 26        .navigationBarTitleDisplayMode(.inline)
 27        .toolbar {
 28            ToolbarItem(placement: .topBarTrailing) {
 29                SRHTShareButton(
 30                    url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
 31                    target: .build
 32                ) {
 33                    Image(systemName: "square.and.arrow.up")
 34                }
 35            }
 36        }
 37        .navigationDestination(isPresented: Binding(
 38            get: { rebuiltJobId != nil },
 39            set: { isPresented in
 40                if !isPresented {
 41                    rebuiltJobId = nil
 42                }
 43            }
 44        )) {
 45            if let rebuiltJobId {
 46                BuildDetailView(jobId: rebuiltJobId)
 47            }
 48        }
 49        .sheet(isPresented: $showEditResubmitSheet) {
 50            if let viewModel, let job = viewModel.job {
 51                EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
 52                    showEditResubmitSheet = false
 53                    rebuiltJobId = jobId
 54                }
 55            }
 56        }
 57        .sheet(isPresented: Binding(
 58            get: { selectedTaskName != nil },
 59            set: { isPresented in
 60                if !isPresented {
 61                    selectedTaskName = nil
 62                }
 63            }
 64        )) {
 65            if let selectedTaskName, let viewModel {
 66                NavigationStack {
 67                    BuildTaskLogView(taskName: selectedTaskName, viewModel: viewModel)
 68                        .toolbar {
 69                            ToolbarItem(placement: .cancellationAction) {
 70                                Button("Done") {
 71                                    self.selectedTaskName = nil
 72                                }
 73                            }
 74                        }
 75                }
 76            } else {
 77                NavigationStack {
 78                    SRHTLoadingStateView(message: "Loading…")
 79                        .toolbar {
 80                            ToolbarItem(placement: .cancellationAction) {
 81                                Button("Done") {
 82                                    self.selectedTaskName = nil
 83                                }
 84                            }
 85                        }
 86                }
 87            }
 88        }
 89        .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
 90            Button("Keep Running", role: .cancel) {
 91                // Alert dismissal is implicit; no additional action required.
 92            }
 93            Button("Cancel Build", role: .destructive) {
 94                Task { await viewModel?.cancelJob() }
 95            }
 96        } message: {
 97            Text("The build will stop as soon as possible.")
 98        }
 99        .task {
100            if viewModel == nil {
101                let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
102                viewModel = vm
103                await vm.loadJob()
104                vm.startAutoRefresh()
105            }
106        }
107        .onAppear {
108            viewModel?.startAutoRefresh()
109        }
110        .onDisappear {
111            guard !isPresentingLogSheet else { return }
112            viewModel?.stopAutoRefresh()
113        }
114    }
115
116    @ViewBuilder
117    private func detailContent(_ viewModel: BuildDetailViewModel) -> some View {
118        if viewModel.isLoading, viewModel.job == nil {
119            SRHTLoadingStateView(message: "Loading build…")
120        } else if let error = viewModel.error, viewModel.job == nil {
121            SRHTErrorStateView(
122                title: "Couldn't Load Build",
123                message: error,
124                retryAction: { await viewModel.loadJob() }
125            )
126        } else if let job = viewModel.job {
127            List {
128                // Status & metadata
129                Section("Details") {
130                    HStack {
131                        Text("Status")
132                        Spacer()
133                        HStack(spacing: 6) {
134                            JobStatusIcon(status: job.status)
135                            Text(job.status.rawValue)
136                                .font(.subheadline.weight(.medium))
137                        }
138                    }
139
140                    if let note = job.note, !note.isEmpty {
141                        LabeledContent("Note", value: note)
142                    }
143
144                    if let image = job.image {
145                        LabeledContent("Image", value: image)
146                    }
147
148                    if !job.tags.isEmpty {
149                        LabeledContent("Tags", value: job.tags.joined(separator: ", "))
150                    }
151
152                    if let visibility = job.visibility {
153                        LabeledContent("Visibility", value: visibility.rawValue.capitalized)
154                    }
155
156                    LabeledContent("Owner", value: job.owner.canonicalName)
157                    LabeledContent("Created", value: job.created.relativeDescription)
158                    LabeledContent("Updated", value: job.updated.relativeDescription)
159                }
160
161                // Per-task logs
162                if !job.tasks.isEmpty {
163                    ForEach(job.tasks) { task in
164                        Section {
165                            Button {
166                                selectedTaskName = task.name
167                            } label: {
168                                HStack {
169                                    Text(task.status.rawValue.capitalized)
170                                        .font(.subheadline)
171                                        .foregroundStyle(.secondary)
172                                    Spacer()
173                                    Image(systemName: "chevron.right")
174                                        .font(.caption)
175                                        .foregroundStyle(.tertiary)
176                                }
177                            }
178                            .foregroundStyle(.primary)
179                        } header: {
180                            HStack(spacing: 6) {
181                                TaskStatusIcon(status: task.status)
182                                Text(task.name)
183                            }
184                        }
185                    }
186                }
187
188                // Cancel button
189                if job.status.isCancellable {
190                    Section {
191                        Button(role: .destructive) {
192                            showCancelConfirmation = true
193                        } label: {
194                            HStack {
195                                Text("Cancel Build")
196                                if viewModel.isCancelling {
197                                    Spacer()
198                                    ProgressView()
199                                }
200                            }
201                        }
202                        .disabled(viewModel.isCancelling)
203                    }
204                }
205
206                if let manifest = job.manifest,
207                   !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
208                    Section {
209                        Button {
210                            Task {
211                                rebuiltJobId = await viewModel.rebuildJob()
212                            }
213                        } label: {
214                            HStack {
215                                Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
216                                if viewModel.isRebuilding {
217                                    Spacer()
218                                    ProgressView()
219                                }
220                            }
221                        }
222                        .disabled(viewModel.isRebuilding)
223
224                        Button {
225                            showEditResubmitSheet = true
226                        } label: {
227                            Text("Edit & Resubmit")
228                        }
229                        .disabled(viewModel.isSubmittingEditedBuild)
230                    } footer: {
231                        Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
232                    }
233                }
234            }
235            .refreshable {
236                await viewModel.loadJob()
237            }
238            .srhtErrorBanner(error: Binding(
239                get: { viewModel.error },
240                set: { viewModel.error = $0 }
241            ))
242        }
243    }
244}
245
246private struct EditResubmitBuildSheet: View {
247    let viewModel: BuildDetailViewModel
248    let job: JobDetail
249    let onSubmitted: (Int) -> Void
250
251    @Environment(\.dismiss) private var dismiss
252    @Bindable var viewModelBindable: BuildDetailViewModel
253    @State private var manifest: String
254    @State private var tagsText: String
255    @State private var note: String
256    @State private var secrets = false
257    @State private var execute = true
258    @State private var visibility: Visibility
259
260    init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
261        self.viewModel = viewModel
262        self._viewModelBindable = Bindable(viewModel)
263        self.job = job
264        self.onSubmitted = onSubmitted
265        _manifest = State(initialValue: job.manifest ?? "")
266        _tagsText = State(initialValue: job.tags.joined(separator: ", "))
267        _note = State(initialValue: job.note ?? "")
268        _visibility = State(initialValue: job.visibility ?? .public)
269    }
270
271    var body: some View {
272        NavigationStack {
273            Form {
274                Section("Build Manifest") {
275                    TextField("Build manifest", text: $manifest, axis: .vertical)
276                        .font(.system(.body, design: .monospaced))
277                        .lineLimit(12...24)
278                        .textInputAutocapitalization(.never)
279                        .autocorrectionDisabled()
280                }
281
282                Section("Build Options") {
283                    TextField("Note (optional)", text: $note)
284                    TextField("Tags (comma-separated, optional)", text: $tagsText)
285                        .textInputAutocapitalization(.never)
286                        .autocorrectionDisabled()
287                    Picker("Visibility", selection: $visibility) {
288                        Text("Public").tag(Visibility.public)
289                        Text("Unlisted").tag(Visibility.unlisted)
290                        Text("Private").tag(Visibility.private)
291                    }
292                    Toggle("Start build now", isOn: $execute)
293                    Toggle("Allow build secrets", isOn: $secrets)
294                }
295
296                Section {
297                    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.")
298                        .font(.footnote)
299                        .foregroundStyle(.secondary)
300                }
301
302                if let error = viewModel.error {
303                    Section {
304                        Label {
305                            Text(error)
306                        } icon: {
307                            Image(systemName: "exclamationmark.triangle.fill")
308                                .foregroundStyle(.red)
309                        }
310                        .foregroundStyle(.red)
311                    }
312                }
313            }
314            .navigationTitle("Edit & Resubmit")
315            .navigationBarTitleDisplayMode(.inline)
316            .onDisappear {
317                viewModelBindable.error = nil
318            }
319            .toolbar {
320                ToolbarItem(placement: .cancellationAction) {
321                    Button("Cancel") {
322                        viewModelBindable.error = nil
323                        dismiss()
324                    }
325                }
326                ToolbarItem(placement: .confirmationAction) {
327                    Button {
328                        Task {
329                            let tags = tagsText
330                                .split(separator: ",")
331                                .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
332                                .filter { !$0.isEmpty }
333                            if let jobId = await viewModel.submitBuild(
334                                manifest: manifest,
335                                tags: tags,
336                                note: note,
337                                secrets: secrets,
338                                execute: execute,
339                                visibility: visibility
340                            ) {
341                                onSubmitted(jobId)
342                            }
343                        }
344                    } label: {
345                        if viewModel.isSubmittingEditedBuild {
346                            ProgressView()
347                                .controlSize(.small)
348                        } else {
349                            Text("Submit Build")
350                        }
351                    }
352                    .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
353                }
354            }
355        }
356    }
357}
358
359// MARK: - Task Status Icon
360
361private struct TaskStatusIcon: View {
362    let status: TaskStatus
363
364    var body: some View {
365        Image(systemName: iconName)
366            .foregroundStyle(color)
367            .frame(width: 20)
368    }
369
370    private var iconName: String {
371        switch status {
372        case .success: "checkmark.circle.fill"
373        case .failed:  "xmark.circle.fill"
374        case .running: "arrow.trianglehead.2.clockwise.rotate.90"
375        case .pending: "circle.dashed"
376        case .skipped: "forward.circle.fill"
377        }
378    }
379
380    private var color: Color {
381        switch status {
382        case .success: .green
383        case .failed:  .red
384        case .running: .yellow
385        case .pending: .gray
386        case .skipped: .secondary
387        }
388    }
389}