krz/hutch

an ios client for sourcehut

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

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