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