krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.7.1: 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 ForEach(viewModel.filteredJobs) { job in
69 NavigationLink(value: job) {
70 BuildRowView(job: job)
71 }
72 .swipeActions(edge: .leading, allowsFullSwipe: true) {
73 if swipeActionsEnabled, job.status.isCancellable {
74 Button {
75 Task {
76 await viewModel.cancelJob(job)
77 }
78 } label: {
79 Label("Cancel", systemImage: "xmark.circle")
80 }
81 .tint(.red)
82 }
83 }
84 .task {
85 await viewModel.loadMoreIfNeeded(currentItem: job)
86 }
87 }
88
89 if viewModel.isLoadingMore {
90 HStack {
91 Spacer()
92 ProgressView()
93 Spacer()
94 }
95 .listRowSeparator(.hidden)
96 }
97 }
98 .listStyle(.plain)
99 .searchable(
100 text: $vm.searchText,
101 placement: .navigationBarDrawer(displayMode: .always),
102 prompt: "Search builds"
103 )
104 .overlay {
105 if viewModel.isLoading, viewModel.jobs.isEmpty {
106 SRHTLoadingStateView(message: "Loading builds…")
107 } else if let error = viewModel.error, viewModel.jobs.isEmpty {
108 SRHTErrorStateView(
109 title: "Couldn't Load Builds",
110 message: error,
111 retryAction: { await viewModel.loadJobs() }
112 )
113 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
114 ContentUnavailableView.search(text: viewModel.searchText)
115 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
116 ContentUnavailableView(
117 "No Builds",
118 systemImage: "hammer",
119 description: Text("Your build jobs will appear here.")
120 )
121 }
122 }
123 .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
124 await viewModel.loadJobs()
125 }
126 .srhtErrorBanner(error: $vm.error)
127 .refreshable {
128 await viewModel.loadJobs()
129 }
130 }
131}
132
133private struct SubmitBuildSheet: View {
134 let viewModel: BuildListViewModel
135 let onSubmitted: (Int) -> Void
136
137 @Environment(\.dismiss) private var dismiss
138 @Bindable var viewModelBindable: BuildListViewModel
139 @State private var manifest = ""
140 @State private var tagsText = ""
141 @State private var note = ""
142 @State private var secrets = false
143 @State private var execute = true
144 @State private var visibility: Visibility = .public
145
146 init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
147 self.viewModel = viewModel
148 self._viewModelBindable = Bindable(viewModel)
149 self.onSubmitted = onSubmitted
150 }
151
152 var body: some View {
153 NavigationStack {
154 Form {
155 Section("Build Manifest") {
156 TextField("Paste a build manifest", text: $manifest, axis: .vertical)
157 .font(.system(.body, design: .monospaced))
158 .lineLimit(12...24)
159 .textInputAutocapitalization(.never)
160 .autocorrectionDisabled()
161 }
162
163 Section("Build Options") {
164 TextField("Note (optional)", text: $note)
165 TextField("Tags (comma-separated, optional)", text: $tagsText)
166 .textInputAutocapitalization(.never)
167 .autocorrectionDisabled()
168 Picker("Visibility", selection: $visibility) {
169 Text("Public").tag(Visibility.public)
170 Text("Unlisted").tag(Visibility.unlisted)
171 Text("Private").tag(Visibility.private)
172 }
173 Toggle("Start build now", isOn: $execute)
174 Toggle("Allow build secrets", isOn: $secrets)
175 }
176
177 Section {
178 Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
179 .font(.footnote)
180 .foregroundStyle(.secondary)
181 }
182
183 if let error = viewModel.error {
184 Section {
185 Label {
186 Text(error)
187 } icon: {
188 Image(systemName: "exclamationmark.triangle.fill")
189 .foregroundStyle(.red)
190 }
191 .foregroundStyle(.red)
192 }
193 }
194 }
195 .navigationTitle("Submit Build")
196 .navigationBarTitleDisplayMode(.inline)
197 .onDisappear {
198 viewModelBindable.error = nil
199 }
200 .toolbar {
201 ToolbarItem(placement: .cancellationAction) {
202 Button("Cancel") {
203 viewModelBindable.error = nil
204 dismiss()
205 }
206 }
207 ToolbarItem(placement: .confirmationAction) {
208 Button {
209 Task {
210 let tags = tagsText
211 .split(separator: ",")
212 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
213 .filter { !$0.isEmpty }
214 if let jobId = await viewModel.submitBuild(
215 manifest: manifest,
216 tags: tags,
217 note: note,
218 secrets: secrets,
219 execute: execute,
220 visibility: visibility
221 ) {
222 onSubmitted(jobId)
223 }
224 }
225 } label: {
226 if viewModel.isSubmitting {
227 ProgressView()
228 .controlSize(.small)
229 } else {
230 Text("Submit Build")
231 }
232 }
233 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
234 }
235 }
236 }
237 }
238}