krz/hutch

an ios client for sourcehut

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

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