import SwiftUI struct BuildListView: View { @State private var model: BuildListViewModel @State private var filtering = false init(client: GitbayClient, repo: String) { _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) } var body: some View { List { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } ForEach(model.state.value ?? []) { build in NavigationLink(value: BuildRoute.detail(repo: model.repoPath, number: build.number)) { BuildRow(build: build) } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Builds") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { filtering = true } label: { Image(systemName: model.filter.isNarrowed ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } .accessibilityIdentifier("build-filter-button") } ToolbarItem(placement: .topBarTrailing) { // A menu of jobs, like the web's picker — not a name to // type. Nothing to trigger without a job file. Menu { ForEach(model.jobs) { job in Button("\(job.name) — \(job.trigger)") { Task { await model.trigger(job: job.name) } } } } label: { Image(systemName: "play.circle") } .disabled(model.working || model.jobs.isEmpty) .accessibilityIdentifier("build-trigger-button") } } .sheet(isPresented: $filtering) { BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 } } .task { await model.load() await model.loadJobs() } .refreshable { await model.load() await model.loadJobs() } } } private struct BuildRow: View { let build: Build var body: some View { HStack(spacing: 10) { Image(systemName: build.statusIcon) .foregroundStyle(build.statusColor) VStack(alignment: .leading, spacing: 2) { Text("#\(build.number) \(build.job)") .font(.gbSans(.subheadline).weight(.medium)) HStack(spacing: 6) { Text(build.ref) Text(build.shortSHA) .font(.gbMono(.caption)) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(build.status) .font(.gbSans(.caption).weight(.medium)) .foregroundStyle(build.statusColor) Text(build.createdAt, format: .relative(presentation: .named)) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } } .padding(.vertical, 2) } } /// Branch, status and job, the three flags `build list` takes. The /// sheet edits a copy and hands it back on Done, so the list reloads /// once rather than on every keystroke of the branch field. private struct BuildFilterSheet: View { let jobs: [CIJob] let onApply: (BuildFilter) -> Void @State private var draft: BuildFilter @Environment(\.dismiss) private var dismiss init(filter: BuildFilter, jobs: [CIJob], onApply: @escaping (BuildFilter) -> Void) { _draft = State(initialValue: filter) self.jobs = jobs self.onApply = onApply } var body: some View { NavigationStack { Form { Section("Branch") { TextField("Branch", text: $draft.ref) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("build-filter-ref") } Section("Status") { Menu { Button("Any status") { draft.status = nil } ForEach(BuildFilter.Status.allCases) { status in Button(status.rawValue) { draft.status = status } } } label: { menuLabel(draft.status?.rawValue ?? "Any status") } .accessibilityIdentifier("build-filter-status-menu") } // A repository without a CI config has no jobs to name. if !jobs.isEmpty { Section("Job") { Menu { Button("Any job") { draft.job = nil } ForEach(jobs) { job in Button(job.name) { draft.job = job.name } } } label: { menuLabel(draft.job ?? "Any job") } .accessibilityIdentifier("build-filter-job-menu") } } Section { Button("Clear filters", role: .destructive) { draft = BuildFilter() } } } .navigationTitle("Filter Builds") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { onApply(draft) dismiss() } } } } } private func menuLabel(_ text: String) -> some View { HStack { Text(text) Spacer() Image(systemName: "chevron.up.chevron.down") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } } nonisolated enum BuildRoute: Hashable { case list(repo: String) case detail(repo: String, number: Int64) }