krz/hutch

an ios client for sourcehut

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

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