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