krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

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