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