krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.15.0: Hutch/Views/Builds/BuildListViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct JobsResponse: Decodable, Sendable {
6 let jobs: JobsPage
7}
8
9private struct JobsPage: Decodable, Sendable {
10 let results: [JobSummary]
11 let cursor: String?
12}
13
14private struct SubmitJobResponse: Decodable, Sendable {
15 let submit: SubmittedJob
16}
17
18private struct SubmittedJob: Decodable, Sendable {
19 let id: Int
20}
21
22enum BuildListFilter: String, CaseIterable, Sendable {
23 case attention = "Attention"
24 case active = "Active"
25 case all = "All"
26}
27
28// MARK: - View Model
29
30@Observable
31@MainActor
32final class BuildListViewModel {
33
34 private(set) var jobs: [JobSummary] = []
35 private(set) var isLoading = false
36 private(set) var isLoadingMore = false
37 private(set) var isRefreshing = false
38 private(set) var isSubmitting = false
39 var error: String?
40 var filter: BuildListFilter = .attention
41 var searchText = ""
42
43 private var cursor: String?
44 private var hasMore = true
45 private let client: SRHTClient
46
47 private static let cacheKey = "builds.jobs"
48
49 init(client: SRHTClient) {
50 self.client = client
51 }
52
53 var filteredJobs: [JobSummary] {
54 let statusFiltered = Self.filterJobs(jobs, filter: filter)
55 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
56 guard !q.isEmpty else { return statusFiltered }
57 return statusFiltered.filter {
58 String($0.id).contains(q) ||
59 $0.tags.contains { $0.lowercased().contains(q) } ||
60 ($0.note?.lowercased().contains(q) == true) ||
61 ($0.image?.lowercased().contains(q) == true)
62 }
63 }
64
65 // MARK: - Query
66
67 private static let query = """
68 query jobs($cursor: Cursor) {
69 jobs(cursor: $cursor) {
70 results {
71 id
72 created
73 updated
74 status
75 note
76 tags
77 visibility
78 image
79 tasks { name status }
80 }
81 cursor
82 }
83 }
84 """
85
86 private static let submitMutation = """
87 mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
88 submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
89 id
90 }
91 }
92 """
93
94 private static let cancelMutation = """
95 mutation cancel($id: Int!) {
96 cancel(jobId: $id) { id }
97 }
98 """
99
100 // MARK: - Public API
101
102 /// Fetch the first page of jobs. Shows cached data instantly if available,
103 /// then refreshes from the network in the background.
104 func loadJobs() async {
105 // Show cached data immediately on first load
106 if jobs.isEmpty {
107 loadFromCache()
108 }
109
110 if jobs.isEmpty {
111 isLoading = true
112 } else {
113 isRefreshing = true
114 }
115 error = nil
116 cursor = nil
117 hasMore = true
118
119 do {
120 let page = try await fetchPage(cursor: nil, useCache: true)
121 jobs = page.results
122 cursor = page.cursor
123 hasMore = page.cursor != nil
124 } catch {
125 if jobs.isEmpty {
126 self.error = error.userFacingMessage
127 }
128 }
129
130 isLoading = false
131 isRefreshing = false
132 }
133
134 func loadMoreIfNeeded(currentItem: JobSummary) async {
135 guard let last = jobs.last,
136 last.id == currentItem.id,
137 hasMore,
138 !isLoadingMore else {
139 return
140 }
141
142 isLoadingMore = true
143
144 do {
145 let page = try await fetchPage(cursor: cursor, useCache: false)
146 jobs.append(contentsOf: page.results)
147 cursor = page.cursor
148 hasMore = page.cursor != nil
149 } catch {
150 self.error = error.userFacingMessage
151 }
152
153 isLoadingMore = false
154 }
155
156 func submitBuild(
157 manifest: String,
158 tags: [String],
159 note: String,
160 secrets: Bool,
161 execute: Bool,
162 visibility: Visibility
163 ) async -> Int? {
164 guard !isSubmitting else { return nil }
165
166 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
167 guard !trimmedManifest.isEmpty else {
168 error = "Paste a build manifest."
169 return nil
170 }
171
172 isSubmitting = true
173 error = nil
174 defer { isSubmitting = false }
175
176 var variables: [String: any Sendable] = [
177 "manifest": trimmedManifest,
178 "secrets": secrets,
179 "execute": execute,
180 "visibility": visibility.rawValue
181 ]
182 if !tags.isEmpty {
183 variables["tags"] = tags
184 }
185 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
186 if !trimmedNote.isEmpty {
187 variables["note"] = trimmedNote
188 }
189
190 do {
191 let result = try await client.execute(
192 service: .builds,
193 query: Self.submitMutation,
194 variables: variables,
195 responseType: SubmitJobResponse.self
196 )
197 await loadJobs()
198 return result.submit.id
199 } catch {
200 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
201 return nil
202 }
203 }
204
205 func cancelJob(_ job: JobSummary) async {
206 guard job.status.isCancellable else { return }
207
208 do {
209 _ = try await client.execute(
210 service: .builds,
211 query: Self.cancelMutation,
212 variables: ["id": job.id],
213 responseType: CancelResponse.self
214 )
215 if let index = jobs.firstIndex(where: { $0.id == job.id }) {
216 let updated = JobSummary(
217 id: job.id,
218 created: job.created,
219 updated: job.updated,
220 status: .cancelled,
221 note: job.note,
222 tags: job.tags,
223 visibility: job.visibility,
224 image: job.image,
225 tasks: job.tasks
226 )
227 jobs[index] = updated
228 }
229 } catch {
230 self.error = error.userFacingMessage
231 }
232 }
233
234 // MARK: - Private
235
236 private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
237 var variables: [String: any Sendable] = [:]
238 if let cursor {
239 variables["cursor"] = cursor
240 }
241
242 if useCache && cursor == nil {
243 let result = try await client.executeAndCache(
244 service: .builds,
245 query: Self.query,
246 variables: variables.isEmpty ? nil : variables,
247 responseType: JobsResponse.self,
248 cacheKey: Self.cacheKey
249 )
250 return result.jobs
251 } else {
252 let result = try await client.execute(
253 service: .builds,
254 query: Self.query,
255 variables: variables.isEmpty ? nil : variables,
256 responseType: JobsResponse.self
257 )
258 return result.jobs
259 }
260 }
261
262 private func loadFromCache() {
263 guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
264 let decoder = JSONDecoder()
265 decoder.dateDecodingStrategy = .srhtFlexible
266 if let response = try? decoder.decode(
267 GraphQLResponse<JobsResponse>.self,
268 from: data
269 ), let page = response.data?.jobs {
270 jobs = page.results
271 cursor = page.cursor
272 hasMore = page.cursor != nil
273 }
274 }
275
276 private struct CancelResponse: Decodable, Sendable {
277 struct CancelResult: Decodable, Sendable {
278 let id: Int
279 }
280
281 let cancel: CancelResult
282 }
283
284 nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
285 jobs.filter { job in
286 switch filter {
287 case .attention:
288 switch job.status {
289 case .failed, .timeout, .running, .queued, .pending:
290 return true
291 case .success, .cancelled:
292 return false
293 }
294 case .active:
295 switch job.status {
296 case .running, .queued, .pending:
297 return true
298 case .success, .failed, .cancelled, .timeout:
299 return false
300 }
301 case .all:
302 return true
303 }
304 }
305 }
306}