krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.9.0: Hutch/Views/Builds/BuildListView.swift · raw
1import SwiftUI
2
3struct BuildListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
5 @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0
6 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = ""
7 @AppStorage(AppStorageKeys.buildsLookbackDays, store: .standard)
8 private var lookbackDays = BuildListViewModel.defaultLookbackDays
9 @Environment(AppState.self) private var appState
10 @Environment(\.isAMOLEDTheme) private var isAMOLED
11 @State private var viewModel: BuildListViewModel?
12 @State private var showSubmitSheet = false
13 @State private var submittedJobId: Int?
14
15 private var autoRefreshInterval: AutoRefreshInterval {
16 AutoRefreshInterval(rawValue: autoRefreshRawValue) ?? .off
17 }
18
19 var body: some View {
20 Group {
21 if let viewModel {
22 listContent(viewModel)
23 } else {
24 SRHTLoadingStateView(message: "Loading builds…")
25 }
26 }
27 .navigationTitle("Builds")
28 .toolbar {
29 if let viewModel {
30 ToolbarItem(placement: .topBarLeading) {
31 Menu {
32 Section("Auto-Refresh") {
33 ForEach(AutoRefreshInterval.allCases, id: \.self) { interval in
34 Button {
35 autoRefreshRawValue = interval.rawValue
36 viewModel.startAutoRefresh(interval: interval)
37 } label: {
38 if interval.rawValue == autoRefreshRawValue {
39 Label(interval.label, systemImage: "checkmark")
40 } else {
41 Text(interval.label)
42 }
43 }
44 }
45 }
46 Section("Filter by Tag") {
47 Button {
48 savedRepoFilter = ""
49 viewModel.repoFilter = ""
50 } label: {
51 if savedRepoFilter.isEmpty {
52 Label("All", systemImage: "checkmark")
53 } else {
54 Text("All")
55 }
56 }
57 ForEach(viewModel.availableTags, id: \.self) { tag in
58 Button {
59 savedRepoFilter = tag
60 viewModel.repoFilter = tag
61 } label: {
62 if savedRepoFilter == tag {
63 Label(tag, systemImage: "checkmark")
64 } else {
65 Text(tag)
66 }
67 }
68 }
69 }
70 Section("Timeframe") {
71 ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in
72 Button {
73 lookbackDays = days
74 viewModel.lookbackDays = days
75 } label: {
76 if lookbackDays == days {
77 Label(HomeViewModel.failedBuildLookbackLabel(days: days), systemImage: "checkmark")
78 } else {
79 Text(HomeViewModel.failedBuildLookbackLabel(days: days))
80 }
81 }
82 }
83 }
84 } label: {
85 Image(systemName: "line.3.horizontal.decrease.circle")
86 }
87 .accessibilityLabel("Build filters")
88 }
89 ToolbarItem(placement: .topBarTrailing) {
90 Button {
91 showSubmitSheet = true
92 } label: {
93 Image(systemName: "plus")
94 }
95 .accessibilityLabel("Submit build")
96 }
97 }
98 }
99 .sheet(isPresented: $showSubmitSheet) {
100 if let viewModel {
101 SubmitBuildSheet(viewModel: viewModel) { jobId in
102 showSubmitSheet = false
103 submittedJobId = jobId
104 }
105 }
106 }
107 .navigationDestination(for: JobSummary.self) { job in
108 BuildDetailView(jobId: job.id)
109 }
110 .navigationDestination(isPresented: Binding(
111 get: { submittedJobId != nil },
112 set: { isPresented in
113 if !isPresented {
114 submittedJobId = nil
115 }
116 }
117 )) {
118 if let submittedJobId {
119 BuildDetailView(jobId: submittedJobId)
120 }
121 }
122 .task {
123 if viewModel == nil {
124 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
125 vm.repoFilter = savedRepoFilter
126 vm.lookbackDays = lookbackDays
127 if let pendingFilter = appState.pendingBuildListFilter {
128 vm.filter = pendingFilter
129 appState.pendingBuildListFilter = nil
130 }
131 viewModel = vm
132 await vm.loadJobs()
133 }
134 // Restart auto-refresh every time the view (re)appears, since
135 // onDisappear stops it when navigating away.
136 viewModel?.startAutoRefresh(interval: autoRefreshInterval)
137 }
138 .onChange(of: lookbackDays) { _, newValue in
139 viewModel?.lookbackDays = newValue
140 }
141 .onChange(of: appState.pendingBuildListFilter) { _, newValue in
142 guard let newValue else { return }
143 viewModel?.filter = newValue
144 appState.pendingBuildListFilter = nil
145 }
146 .onDisappear {
147 viewModel?.stopAutoRefresh()
148 }
149 }
150
151 @ViewBuilder
152 private func listContent(_ viewModel: BuildListViewModel) -> some View {
153 @Bindable var vm = viewModel
154
155 List {
156 Section {
157 Picker("Filter", selection: $vm.filter) {
158 ForEach(BuildListFilter.allCases, id: \.self) { filter in
159 Text(filter.rawValue).tag(filter)
160 }
161 }
162 .pickerStyle(.segmented)
163 .padding(.horizontal, 16)
164 .padding(.top, 6)
165 .padding(.bottom, 10)
166 .listRowInsets(EdgeInsets())
167 .listRowBackground(isAMOLED ? Color.black : Color.clear)
168 .listRowSeparator(.hidden)
169
170 ForEach(viewModel.filteredJobs) { job in
171 NavigationLink(value: job) {
172 BuildRowView(job: job)
173 .equatable()
174 }
175 .contextMenu {
176 Button {
177 appState.copyToPasteboard(String(job.id), label: "job ID")
178 } label: {
179 Label("Copy Job ID", systemImage: "doc.on.doc")
180 }
181
182 if let note = job.note, !note.isEmpty {
183 Button {
184 appState.copyToPasteboard(note, label: "build note")
185 } label: {
186 Label("Copy Note", systemImage: "text.alignleft")
187 }
188 }
189
190 if !job.tags.isEmpty {
191 Button {
192 appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags")
193 } label: {
194 Label("Copy Tags", systemImage: "tag")
195 }
196 }
197 }
198 .swipeActions(edge: .leading, allowsFullSwipe: true) {
199 if swipeActionsEnabled, job.status.isCancellable {
200 Button {
201 Task {
202 await viewModel.cancelJob(job)
203 }
204 } label: {
205 Label("Cancel", systemImage: "xmark.circle")
206 }
207 .tint(.red)
208 }
209 }
210 .task {
211 await viewModel.loadMoreIfNeeded(currentItem: job)
212 }
213 }
214 .themedRow()
215
216 if viewModel.isLoadingMore {
217 HStack {
218 Spacer()
219 ProgressView()
220 Spacer()
221 }
222 .listRowSeparator(.hidden)
223 .themedRow()
224 }
225 }
226 }
227 .themedList()
228 .listStyle(.plain)
229 .listSectionSpacing(.compact)
230 .searchable(
231 text: $vm.searchText,
232 placement: .navigationBarDrawer(displayMode: .always),
233 prompt: "Search builds by job ID, tag, note, or status"
234 )
235 .searchSuggestions {
236 if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
237 RecentSearchSuggestions(
238 title: "Recent Build Searches",
239 entries: viewModel.recentSearches
240 ) { query in
241 vm.searchText = query
242 } onClear: {
243 viewModel.clearRecentSearches()
244 }
245 }
246 }
247 .onSubmit(of: .search) {
248 let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
249 guard !query.isEmpty else { return }
250 viewModel.recordRecentSearch(query)
251 }
252 .overlay {
253 if viewModel.isLoading, viewModel.jobs.isEmpty {
254 SRHTLoadingStateView(message: "Loading builds…")
255 } else if let error = viewModel.error, viewModel.jobs.isEmpty {
256 SRHTErrorStateView(
257 title: "Couldn't Load Builds",
258 message: error,
259 retryAction: { await viewModel.loadJobs() }
260 )
261 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
262 viewModel.filteredJobs.isEmpty {
263 ContentUnavailableView(
264 "No Build Matches",
265 systemImage: "magnifyingglass",
266 description: Text("No builds matched “\(viewModel.searchText)”.")
267 )
268 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
269 ContentUnavailableView(
270 "No Builds In Timeframe",
271 systemImage: "calendar.badge.clock",
272 description: Text("No builds were updated \(HomeViewModel.failedBuildLookbackLabel(days: lookbackDays)).")
273 )
274 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
275 ContentUnavailableView(
276 "No Builds",
277 systemImage: "hammer",
278 description: Text("Your build jobs will appear here.")
279 )
280 }
281 }
282 .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
283 await viewModel.loadJobs()
284 }
285 .srhtErrorBanner(error: $vm.error)
286 .refreshable {
287 await viewModel.loadJobs()
288 }
289 }
290}
291
292private struct SubmitBuildSheet: View {
293 let viewModel: BuildListViewModel
294 let onSubmitted: (Int) -> Void
295
296 @Environment(\.dismiss) private var dismiss
297 @Bindable var viewModelBindable: BuildListViewModel
298 @State private var manifest = ""
299 @State private var tagsText = ""
300 @State private var note = ""
301 @State private var secrets = false
302 @State private var execute = true
303 @State private var visibility: Visibility = .publicVisibility
304
305 init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
306 self.viewModel = viewModel
307 self._viewModelBindable = Bindable(viewModel)
308 self.onSubmitted = onSubmitted
309 }
310
311 var body: some View {
312 NavigationStack {
313 Form {
314 Section("Build Manifest") {
315 TextField("Paste a build manifest", text: $manifest, axis: .vertical)
316 .font(.system(.body, design: .monospaced))
317 .lineLimit(12...24)
318 .textInputAutocapitalization(.never)
319 .autocorrectionDisabled()
320 .themedRow()
321 }
322
323 Section("Build Options") {
324 TextField("Note (optional)", text: $note)
325 .themedRow()
326 TextField("Tags (comma-separated, optional)", text: $tagsText)
327 .textInputAutocapitalization(.never)
328 .autocorrectionDisabled()
329 .themedRow()
330 Picker("Visibility", selection: $visibility) {
331 Text("Public").tag(Visibility.publicVisibility)
332 Text("Unlisted").tag(Visibility.unlisted)
333 Text("Private").tag(Visibility.privateVisibility)
334 }
335 .themedRow()
336 Toggle("Start build now", isOn: $execute)
337 .themedRow()
338 Toggle("Allow build secrets", isOn: $secrets)
339 .themedRow()
340 }
341
342 Section {
343 Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
344 .font(.footnote)
345 .foregroundStyle(.secondary)
346 .themedRow()
347 }
348
349 if let error = viewModel.error {
350 Section {
351 Label {
352 Text(error)
353 } icon: {
354 Image(systemName: "exclamationmark.triangle.fill")
355 .foregroundStyle(.red)
356 }
357 .foregroundStyle(.red)
358 .themedRow()
359 }
360 }
361 }
362 .themedList()
363 .navigationTitle("Submit Build")
364 .navigationBarTitleDisplayMode(.inline)
365 .onDisappear {
366 viewModelBindable.error = nil
367 }
368 .toolbar {
369 ToolbarItem(placement: .cancellationAction) {
370 Button("Cancel") {
371 viewModelBindable.error = nil
372 dismiss()
373 }
374 }
375 ToolbarItem(placement: .confirmationAction) {
376 Button {
377 Task {
378 let tags = tagsText
379 .split(separator: ",")
380 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
381 .filter { !$0.isEmpty }
382 if let jobId = await viewModel.submitBuild(
383 manifest: manifest,
384 tags: tags,
385 note: note,
386 secrets: secrets,
387 execute: execute,
388 visibility: visibility
389 ) {
390 onSubmitted(jobId)
391 }
392 }
393 } label: {
394 if viewModel.isSubmitting {
395 ProgressView()
396 .controlSize(.small)
397 } else {
398 Text("Submit Build")
399 }
400 }
401 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
402 }
403 }
404 }
405 }
406}