gitbay/Views/Builds/BuildListView.swift
187 lines · 6602 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 Text(build.shortSHA)
85 .font(.gbMono(.caption))
86 }
87 .font(.gbSans(.caption))
88 .foregroundStyle(.secondary)
89 }
90 Spacer()
91 VStack(alignment: .trailing, spacing: 2) {
92 Text(build.status)
93 .font(.gbSans(.caption).weight(.medium))
94 .foregroundStyle(build.statusColor)
95 Text(build.createdAt, format: .relative(presentation: .named))
96 .font(.gbSans(.caption))
97 .foregroundStyle(.secondary)
98 }
99 }
100 .padding(.vertical, 2)
101 }
102
103}
104
105/// Branch, status and job, the three flags `build list` takes. The
106/// sheet edits a copy and hands it back on Done, so the list reloads
107/// once rather than on every keystroke of the branch field.
108private struct BuildFilterSheet: View {
109
110 let jobs: [CIJob]
111 let onApply: (BuildFilter) -> Void
112 @State private var draft: BuildFilter
113
114 @Environment(\.dismiss) private var dismiss
115
116 init(filter: BuildFilter, jobs: [CIJob], onApply: @escaping (BuildFilter) -> Void) {
117 _draft = State(initialValue: filter)
118 self.jobs = jobs
119 self.onApply = onApply
120 }
121
122 var body: some View {
123 NavigationStack {
124 Form {
125 Section("Branch") {
126 TextField("Branch", text: $draft.ref)
127 .autocorrectionDisabled()
128 .textInputAutocapitalization(.never)
129 .accessibilityIdentifier("build-filter-ref")
130 }
131 Section("Status") {
132 Menu {
133 Button("Any status") { draft.status = nil }
134 ForEach(BuildFilter.Status.allCases) { status in
135 Button(status.rawValue) { draft.status = status }
136 }
137 } label: {
138 menuLabel(draft.status?.rawValue ?? "Any status")
139 }
140 .accessibilityIdentifier("build-filter-status-menu")
141 }
142 // A repository without a CI config has no jobs to name.
143 if !jobs.isEmpty {
144 Section("Job") {
145 Menu {
146 Button("Any job") { draft.job = nil }
147 ForEach(jobs) { job in
148 Button(job.name) { draft.job = job.name }
149 }
150 } label: {
151 menuLabel(draft.job ?? "Any job")
152 }
153 .accessibilityIdentifier("build-filter-job-menu")
154 }
155 }
156 Section {
157 Button("Clear filters", role: .destructive) { draft = BuildFilter() }
158 }
159 }
160 .navigationTitle("Filter Builds")
161 .navigationBarTitleDisplayMode(.inline)
162 .toolbar {
163 ToolbarItem(placement: .confirmationAction) {
164 Button("Done") {
165 onApply(draft)
166 dismiss()
167 }
168 }
169 }
170 }
171 }
172
173 private func menuLabel(_ text: String) -> some View {
174 HStack {
175 Text(text)
176 Spacer()
177 Image(systemName: "chevron.up.chevron.down")
178 .font(.gbSans(.caption2))
179 .foregroundStyle(.secondary)
180 }
181 }
182}
183
184nonisolated enum BuildRoute: Hashable {
185 case list(repo: String)
186 case detail(repo: String, number: Int64)
187}