krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.15.0: Hutch/Views/Builds/BuildListView.swift · raw
1import SwiftUI
2
3struct BuildListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
5 @Environment(AppState.self) private var appState
6 @State private var viewModel: BuildListViewModel?
7 @State private var showSubmitSheet = false
8 @State private var submittedJobId: Int?
9
10 var body: some View {
11 Group {
12 if let viewModel {
13 listContent(viewModel)
14 } else {
15 SRHTLoadingStateView(message: "Loading builds…")
16 }
17 }
18 .navigationTitle("Builds")
19 .toolbar {
20 if viewModel != nil {
21 ToolbarItem(placement: .topBarTrailing) {
22 Button {
23 showSubmitSheet = true
24 } label: {
25 Image(systemName: "plus")
26 }
27 .accessibilityLabel("Submit build")
28 }
29 }
30 }
31 .sheet(isPresented: $showSubmitSheet) {
32 if let viewModel {
33 SubmitBuildSheet(viewModel: viewModel) { jobId in
34 showSubmitSheet = false
35 submittedJobId = jobId
36 }
37 }
38 }
39 .navigationDestination(for: JobSummary.self) { job in
40 BuildDetailView(jobId: job.id)
41 }
42 .navigationDestination(isPresented: Binding(
43 get: { submittedJobId != nil },
44 set: { isPresented in
45 if !isPresented {
46 submittedJobId = nil
47 }
48 }
49 )) {
50 if let submittedJobId {
51 BuildDetailView(jobId: submittedJobId)
52 }
53 }
54 .task {
55 if viewModel == nil {
56 let vm = BuildListViewModel(client: appState.client)
57 viewModel = vm
58 await vm.loadJobs()
59 }
60 }
61 }
62
63 @ViewBuilder
64 private func listContent(_ viewModel: BuildListViewModel) -> some View {
65 @Bindable var vm = viewModel
66
67 List {
68 Section {
69 Picker("Filter", selection: $vm.filter) {
70 ForEach(BuildListFilter.allCases, id: \.self) { filter in
71 Text(filter.rawValue).tag(filter)
72 }
73 }
74 .pickerStyle(.segmented)
75 .listRowBackground(Color.clear)
76 .listRowInsets(EdgeInsets())
77 }
78
79 ForEach(viewModel.filteredJobs) { job in
80 NavigationLink(value: job) {
81 BuildRowView(job: job)
82 }
83 .swipeActions(edge: .leading, allowsFullSwipe: true) {
84 if swipeActionsEnabled, job.status.isCancellable {
85 Button {
86 Task {
87 await viewModel.cancelJob(job)
88 }
89 } label: {
90 Label("Cancel", systemImage: "xmark.circle")
91 }
92 .tint(.red)
93 }
94 }
95 .task {
96 await viewModel.loadMoreIfNeeded(currentItem: job)
97 }
98 }
99
100 if viewModel.isLoadingMore {
101 HStack {
102 Spacer()
103 ProgressView()
104 Spacer()
105 }
106 .listRowSeparator(.hidden)
107 }
108 }
109 .listStyle(.plain)
110 .searchable(
111 text: $vm.searchText,
112 placement: .navigationBarDrawer(displayMode: .always),
113 prompt: "Search builds"
114 )
115 .overlay {
116 if viewModel.isLoading, viewModel.jobs.isEmpty {
117 SRHTLoadingStateView(message: "Loading builds…")
118 } else if let error = viewModel.error, viewModel.jobs.isEmpty {
119 SRHTErrorStateView(
120 title: "Couldn't Load Builds",
121 message: error,
122 retryAction: { await viewModel.loadJobs() }
123 )
124 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
125 ContentUnavailableView.search(text: viewModel.searchText)
126 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
127 ContentUnavailableView(
128 "No Builds",
129 systemImage: "hammer",
130 description: Text("Your build jobs will appear here.")
131 )
132 }
133 }
134 .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
135 await viewModel.loadJobs()
136 }
137 .srhtErrorBanner(error: $vm.error)
138 .refreshable {
139 await viewModel.loadJobs()
140 }
141 }
142}
143
144private struct SubmitBuildSheet: View {
145 let viewModel: BuildListViewModel
146 let onSubmitted: (Int) -> Void
147
148 @Environment(\.dismiss) private var dismiss
149 @Bindable var viewModelBindable: BuildListViewModel
150 @State private var manifest = ""
151 @State private var tagsText = ""
152 @State private var note = ""
153 @State private var secrets = false
154 @State private var execute = true
155 @State private var visibility: Visibility = .public
156
157 init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
158 self.viewModel = viewModel
159 self._viewModelBindable = Bindable(viewModel)
160 self.onSubmitted = onSubmitted
161 }
162
163 var body: some View {
164 NavigationStack {
165 Form {
166 Section("Build Manifest") {
167 TextField("Paste a build manifest", text: $manifest, axis: .vertical)
168 .font(.system(.body, design: .monospaced))
169 .lineLimit(12...24)
170 .textInputAutocapitalization(.never)
171 .autocorrectionDisabled()
172 }
173
174 Section("Build Options") {
175 TextField("Note (optional)", text: $note)
176 TextField("Tags (comma-separated, optional)", text: $tagsText)
177 .textInputAutocapitalization(.never)
178 .autocorrectionDisabled()
179 Picker("Visibility", selection: $visibility) {
180 Text("Public").tag(Visibility.public)
181 Text("Unlisted").tag(Visibility.unlisted)
182 Text("Private").tag(Visibility.private)
183 }
184 Toggle("Start build now", isOn: $execute)
185 Toggle("Allow build secrets", isOn: $secrets)
186 }
187
188 Section {
189 Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
190 .font(.footnote)
191 .foregroundStyle(.secondary)
192 }
193
194 if let error = viewModel.error {
195 Section {
196 Label {
197 Text(error)
198 } icon: {
199 Image(systemName: "exclamationmark.triangle.fill")
200 .foregroundStyle(.red)
201 }
202 .foregroundStyle(.red)
203 }
204 }
205 }
206 .navigationTitle("Submit Build")
207 .navigationBarTitleDisplayMode(.inline)
208 .onDisappear {
209 viewModelBindable.error = nil
210 }
211 .toolbar {
212 ToolbarItem(placement: .cancellationAction) {
213 Button("Cancel") {
214 viewModelBindable.error = nil
215 dismiss()
216 }
217 }
218 ToolbarItem(placement: .confirmationAction) {
219 Button {
220 Task {
221 let tags = tagsText
222 .split(separator: ",")
223 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
224 .filter { !$0.isEmpty }
225 if let jobId = await viewModel.submitBuild(
226 manifest: manifest,
227 tags: tags,
228 note: note,
229 secrets: secrets,
230 execute: execute,
231 visibility: visibility
232 ) {
233 onSubmitted(jobId)
234 }
235 }
236 } label: {
237 if viewModel.isSubmitting {
238 ProgressView()
239 .controlSize(.small)
240 } else {
241 Text("Submit Build")
242 }
243 }
244 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
245 }
246 }
247 }
248 }
249}