krz/hutch

an ios client for sourcehut

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

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