krz/hutch

an ios client for sourcehut

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

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