gitbay/Views/Builds/BuildListView.swift
184 lines · 6503 bytes
1import SwiftUI
2
3struct BuildListView: View {
4
5 @State private var model: BuildListViewModel
6 @State private var filtering = false
7
8 init(client: GitbayClient, repo: String) {
9 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
10 }
11
12 var body: some View {
13 List {
14 if let error = model.actionError {
15 Section {
16 GBNotice(error, .gbWarn)
17 }
18 }
19 ForEach(model.state.value ?? []) { build in
20 NavigationLink(value: BuildRoute.detail(repo: model.repoPath, number: build.number)) {
21 BuildRow(build: build)
22 }
23 }
24 }
25 .overlay { LoadStateOverlay(state: model.state) }
26 .navigationTitle("Builds")
27 .navigationBarTitleDisplayMode(.inline)
28 .toolbar {
29 ToolbarItem(placement: .topBarTrailing) {
30 Button {
31 filtering = true
32 } label: {
33 Image(systemName: model.filter.isNarrowed
34 ? "line.3.horizontal.decrease.circle.fill"
35 : "line.3.horizontal.decrease.circle")
36 }
37 .accessibilityIdentifier("build-filter-button")
38 }
39 ToolbarItem(placement: .topBarTrailing) {
40 // A menu of jobs, like the web's picker — not a name to
41 // type. Nothing to trigger without a job file.
42 Menu {
43 ForEach(model.jobs) { job in
44 Button("\(job.name) — \(job.trigger)") {
45 Task { await model.trigger(job: job.name) }
46 }
47 }
48 } label: {
49 Image(systemName: "play.circle")
50 }
51 .disabled(model.working || model.jobs.isEmpty)
52 .accessibilityIdentifier("build-trigger-button")
53 }
54 }
55 .sheet(isPresented: $filtering) {
56 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
57 }
58 .task {
59 await model.load()
60 await model.loadJobs()
61 }
62 .refreshable {
63 await model.load()
64 await model.loadJobs()
65 }
66 }
67}
68
69private struct BuildRow: View {
70 let build: Build
71
72 var body: some View {
73 HStack(spacing: 10) {
74 Image(systemName: build.statusIcon)
75 .foregroundStyle(build.statusColor)
76 VStack(alignment: .leading, spacing: 2) {
77 Text("#\(build.number) \(build.job)")
78 .font(.gbSans(.subheadline).weight(.medium))
79 HStack(spacing: 6) {
80 Text(build.ref)
81 Text(build.shortSHA)
82 .font(.gbMono(.caption))
83 }
84 .font(.gbSans(.caption))
85 .foregroundStyle(.secondary)
86 }
87 Spacer()
88 VStack(alignment: .trailing, spacing: 2) {
89 Text(build.status)
90 .font(.gbSans(.caption).weight(.medium))
91 .foregroundStyle(build.statusColor)
92 Text(build.createdAt, format: .relative(presentation: .named))
93 .font(.gbSans(.caption))
94 .foregroundStyle(.secondary)
95 }
96 }
97 .padding(.vertical, 2)
98 }
99
100}
101
102/// Branch, status and job, the three flags `build list` takes. The
103/// sheet edits a copy and hands it back on Done, so the list reloads
104/// once rather than on every keystroke of the branch field.
105private struct BuildFilterSheet: View {
106
107 let jobs: [CIJob]
108 let onApply: (BuildFilter) -> Void
109 @State private var draft: BuildFilter
110
111 @Environment(\.dismiss) private var dismiss
112
113 init(filter: BuildFilter, jobs: [CIJob], onApply: @escaping (BuildFilter) -> Void) {
114 _draft = State(initialValue: filter)
115 self.jobs = jobs
116 self.onApply = onApply
117 }
118
119 var body: some View {
120 NavigationStack {
121 Form {
122 Section("Branch") {
123 TextField("Branch", text: $draft.ref)
124 .autocorrectionDisabled()
125 .textInputAutocapitalization(.never)
126 .accessibilityIdentifier("build-filter-ref")
127 }
128 Section("Status") {
129 Menu {
130 Button("Any status") { draft.status = nil }
131 ForEach(BuildFilter.Status.allCases) { status in
132 Button(status.rawValue) { draft.status = status }
133 }
134 } label: {
135 menuLabel(draft.status?.rawValue ?? "Any status")
136 }
137 .accessibilityIdentifier("build-filter-status-menu")
138 }
139 // A repository without a CI config has no jobs to name.
140 if !jobs.isEmpty {
141 Section("Job") {
142 Menu {
143 Button("Any job") { draft.job = nil }
144 ForEach(jobs) { job in
145 Button(job.name) { draft.job = job.name }
146 }
147 } label: {
148 menuLabel(draft.job ?? "Any job")
149 }
150 .accessibilityIdentifier("build-filter-job-menu")
151 }
152 }
153 Section {
154 Button("Clear filters", role: .destructive) { draft = BuildFilter() }
155 }
156 }
157 .navigationTitle("Filter Builds")
158 .navigationBarTitleDisplayMode(.inline)
159 .toolbar {
160 ToolbarItem(placement: .confirmationAction) {
161 Button("Done") {
162 onApply(draft)
163 dismiss()
164 }
165 }
166 }
167 }
168 }
169
170 private func menuLabel(_ text: String) -> some View {
171 HStack {
172 Text(text)
173 Spacer()
174 Image(systemName: "chevron.up.chevron.down")
175 .font(.gbSans(.caption2))
176 .foregroundStyle(.secondary)
177 }
178 }
179}
180
181nonisolated enum BuildRoute: Hashable {
182 case list(repo: String)
183 case detail(repo: String, number: Int64)
184}