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