krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.4.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 viewModel = vm
128 await vm.loadJobs()
129 }
130 // Restart auto-refresh every time the view (re)appears, since
131 // onDisappear stops it when navigating away.
132 viewModel?.startAutoRefresh(interval: autoRefreshInterval)
133 }
134 .onChange(of: lookbackDays) { _, newValue in
135 viewModel?.lookbackDays = newValue
136 }
137 .onDisappear {
138 viewModel?.stopAutoRefresh()
139 }
140 }
141
142 @ViewBuilder
143 private func listContent(_ viewModel: BuildListViewModel) -> some View {
144 @Bindable var vm = viewModel
145
146 List {
147 Section {
148 Picker("Filter", selection: $vm.filter) {
149 ForEach(BuildListFilter.allCases, id: \.self) { filter in
150 Text(filter.rawValue).tag(filter)
151 }
152 }
153 .pickerStyle(.segmented)
154 .padding(.horizontal, 16)
155 .padding(.top, 6)
156 .padding(.bottom, 10)
157 .listRowInsets(EdgeInsets())
158 .listRowBackground(isAMOLED ? Color.black : Color.clear)
159 .listRowSeparator(.hidden)
160
161 ForEach(viewModel.filteredJobs) { job in
162 NavigationLink(value: job) {
163 BuildRowView(job: job)
164 .equatable()
165 }
166 .contextMenu {
167 Button {
168 appState.copyToPasteboard(String(job.id), label: "job ID")
169 } label: {
170 Label("Copy Job ID", systemImage: "doc.on.doc")
171 }
172
173 if let note = job.note, !note.isEmpty {
174 Button {
175 appState.copyToPasteboard(note, label: "build note")
176 } label: {
177 Label("Copy Note", systemImage: "text.alignleft")
178 }
179 }
180
181 if !job.tags.isEmpty {
182 Button {
183 appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags")
184 } label: {
185 Label("Copy Tags", systemImage: "tag")
186 }
187 }
188 }
189 .swipeActions(edge: .leading, allowsFullSwipe: true) {
190 if swipeActionsEnabled, job.status.isCancellable {
191 Button {
192 Task {
193 await viewModel.cancelJob(job)
194 }
195 } label: {
196 Label("Cancel", systemImage: "xmark.circle")
197 }
198 .tint(.red)
199 }
200 }
201 .task {
202 await viewModel.loadMoreIfNeeded(currentItem: job)
203 }
204 }
205 .themedRow()
206
207 if viewModel.isLoadingMore {
208 HStack {
209 Spacer()
210 ProgressView()
211 Spacer()
212 }
213 .listRowSeparator(.hidden)
214 .themedRow()
215 }
216 }
217 }
218 .themedList()
219 .listStyle(.plain)
220 .listSectionSpacing(.compact)
221 .searchable(
222 text: $vm.searchText,
223 placement: .navigationBarDrawer(displayMode: .always),
224 prompt: "Search builds by job ID, tag, note, or status"
225 )
226 .searchSuggestions {
227 if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
228 RecentSearchSuggestions(
229 title: "Recent Build Searches",
230 entries: viewModel.recentSearches
231 ) { query in
232 vm.searchText = query
233 } onClear: {
234 viewModel.clearRecentSearches()
235 }
236 }
237 }
238 .onSubmit(of: .search) {
239 let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
240 guard !query.isEmpty else { return }
241 viewModel.recordRecentSearch(query)
242 }
243 .overlay {
244 if viewModel.isLoading, viewModel.jobs.isEmpty {
245 SRHTLoadingStateView(message: "Loading builds…")
246 } else if let error = viewModel.error, viewModel.jobs.isEmpty {
247 SRHTErrorStateView(
248 title: "Couldn't Load Builds",
249 message: error,
250 retryAction: { await viewModel.loadJobs() }
251 )
252 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
253 viewModel.filteredJobs.isEmpty {
254 ContentUnavailableView(
255 "No Build Matches",
256 systemImage: "magnifyingglass",
257 description: Text("No builds matched “\(viewModel.searchText)”.")
258 )
259 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
260 ContentUnavailableView(
261 "No Builds In Timeframe",
262 systemImage: "calendar.badge.clock",
263 description: Text("No builds were updated \(HomeViewModel.failedBuildLookbackLabel(days: lookbackDays)).")
264 )
265 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
266 ContentUnavailableView(
267 "No Builds",
268 systemImage: "hammer",
269 description: Text("Your build jobs will appear here.")
270 )
271 }
272 }
273 .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
274 await viewModel.loadJobs()
275 }
276 .srhtErrorBanner(error: $vm.error)
277 .refreshable {
278 await viewModel.loadJobs()
279 }
280 }
281}
282
283private struct SubmitBuildSheet: View {
284 let viewModel: BuildListViewModel
285 let onSubmitted: (Int) -> Void
286
287 @Environment(\.dismiss) private var dismiss
288 @Bindable var viewModelBindable: BuildListViewModel
289 @State private var manifest = ""
290 @State private var tagsText = ""
291 @State private var note = ""
292 @State private var secrets = false
293 @State private var execute = true
294 @State private var visibility: Visibility = .publicVisibility
295
296 init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
297 self.viewModel = viewModel
298 self._viewModelBindable = Bindable(viewModel)
299 self.onSubmitted = onSubmitted
300 }
301
302 var body: some View {
303 NavigationStack {
304 Form {
305 Section("Build Manifest") {
306 TextField("Paste a build manifest", text: $manifest, axis: .vertical)
307 .font(.system(.body, design: .monospaced))
308 .lineLimit(12...24)
309 .textInputAutocapitalization(.never)
310 .autocorrectionDisabled()
311 .themedRow()
312 }
313
314 Section("Build Options") {
315 TextField("Note (optional)", text: $note)
316 .themedRow()
317 TextField("Tags (comma-separated, optional)", text: $tagsText)
318 .textInputAutocapitalization(.never)
319 .autocorrectionDisabled()
320 .themedRow()
321 Picker("Visibility", selection: $visibility) {
322 Text("Public").tag(Visibility.publicVisibility)
323 Text("Unlisted").tag(Visibility.unlisted)
324 Text("Private").tag(Visibility.privateVisibility)
325 }
326 .themedRow()
327 Toggle("Start build now", isOn: $execute)
328 .themedRow()
329 Toggle("Allow build secrets", isOn: $secrets)
330 .themedRow()
331 }
332
333 Section {
334 Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
335 .font(.footnote)
336 .foregroundStyle(.secondary)
337 .themedRow()
338 }
339
340 if let error = viewModel.error {
341 Section {
342 Label {
343 Text(error)
344 } icon: {
345 Image(systemName: "exclamationmark.triangle.fill")
346 .foregroundStyle(.red)
347 }
348 .foregroundStyle(.red)
349 .themedRow()
350 }
351 }
352 }
353 .themedList()
354 .navigationTitle("Submit Build")
355 .navigationBarTitleDisplayMode(.inline)
356 .onDisappear {
357 viewModelBindable.error = nil
358 }
359 .toolbar {
360 ToolbarItem(placement: .cancellationAction) {
361 Button("Cancel") {
362 viewModelBindable.error = nil
363 dismiss()
364 }
365 }
366 ToolbarItem(placement: .confirmationAction) {
367 Button {
368 Task {
369 let tags = tagsText
370 .split(separator: ",")
371 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
372 .filter { !$0.isEmpty }
373 if let jobId = await viewModel.submitBuild(
374 manifest: manifest,
375 tags: tags,
376 note: note,
377 secrets: secrets,
378 execute: execute,
379 visibility: visibility
380 ) {
381 onSubmitted(jobId)
382 }
383 }
384 } label: {
385 if viewModel.isSubmitting {
386 ProgressView()
387 .controlSize(.small)
388 } else {
389 Text("Submit Build")
390 }
391 }
392 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
393 }
394 }
395 }
396 }
397}