krz/hutch

an ios client for sourcehut

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

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