Hutch/Views/Builds/BuildListView.swift

9e6ec710861994439ab9eb56949da4f7ffa512ff
hutch/Hutch/Views/Builds/BuildListView.swift history · blame · raw

353 lines · 14090 bytes

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