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