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