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