krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.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
28enum AutoRefreshInterval: Int, CaseIterable, Sendable {
29 case off = 0
30 case fiveSeconds = 5
31 case tenSeconds = 10
32
33 var label: String {
34 switch self {
35 case .off: "Off"
36 case .fiveSeconds: "5s"
37 case .tenSeconds: "10s"
38 }
39 }
40}
41
42// MARK: - View Model
43
44@Observable
45@MainActor
46final class BuildListViewModel {
47 private static let searchHistoryScopeID = "builds"
48
49 private(set) var jobs: [JobSummary] = []
50 private(set) var recentSearches: [ScopedSearchHistoryEntry]
51 private(set) var isLoading = false
52 private(set) var isLoadingMore = false
53 private(set) var isRefreshing = false
54 private(set) var isSubmitting = false
55 var error: String?
56 var filter: BuildListFilter = .attention
57 var searchText = ""
58 var repoFilter: String = "" {
59 didSet { if repoFilter != oldValue { repoFilterDidChange() } }
60 }
61
62 private var cursor: String?
63 private var hasMore = true
64 private let client: SRHTClient
65 private let defaults: UserDefaults
66 private var refreshTask: Task<Void, Never>?
67 private var isAutoRefreshing = false
68
69 private static let cacheKey = "builds.jobs"
70
71 init(client: SRHTClient, defaults: UserDefaults = .standard) {
72 self.client = client
73 self.defaults = defaults
74 self.recentSearches = ScopedSearchHistoryStore.load(
75 scopeID: Self.searchHistoryScopeID,
76 defaults: defaults
77 )
78 }
79
80 /// Unique tags across all loaded jobs, sorted alphabetically.
81 var availableTags: [String] {
82 let allTags = Set(jobs.flatMap(\.tags))
83 return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
84 }
85
86 var filteredJobs: [JobSummary] {
87 var result = Self.filterJobs(jobs, filter: filter)
88
89 if !repoFilter.isEmpty {
90 result = result.filter { $0.tags.contains(repoFilter) }
91 }
92
93 return Self.searchJobs(result, matching: searchText)
94 }
95
96 // MARK: - Auto-Refresh
97
98 func startAutoRefresh(interval: AutoRefreshInterval) {
99 stopAutoRefresh()
100 guard interval != .off else { return }
101 let seconds = interval.rawValue
102 refreshTask = Task { [weak self] in
103 while !Task.isCancelled {
104 try? await Task.sleep(for: .seconds(seconds))
105 guard !Task.isCancelled, let self else { return }
106 guard !self.isAutoRefreshing, !self.isLoading, !self.isRefreshing else { continue }
107 self.isAutoRefreshing = true
108 await self.loadJobs()
109 self.isAutoRefreshing = false
110 }
111 }
112 }
113
114 func stopAutoRefresh() {
115 refreshTask?.cancel()
116 refreshTask = nil
117 }
118
119 private func repoFilterDidChange() {
120 // Reset to empty if the selected tag no longer exists
121 if !repoFilter.isEmpty, !availableTags.contains(repoFilter) {
122 repoFilter = ""
123 }
124 }
125
126 // MARK: - Query
127
128 private static let query = """
129 query jobs($cursor: Cursor) {
130 jobs(cursor: $cursor) {
131 results {
132 id
133 created
134 updated
135 status
136 note
137 tags
138 visibility
139 image
140 tasks { name status }
141 }
142 cursor
143 }
144 }
145 """
146
147 private static let submitMutation = """
148 mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
149 submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
150 id
151 }
152 }
153 """
154
155 private static let cancelMutation = """
156 mutation cancel($id: Int!) {
157 cancel(jobId: $id) { id }
158 }
159 """
160
161 // MARK: - Public API
162
163 /// Fetch the first page of jobs. Shows cached data instantly if available,
164 /// then refreshes from the network in the background.
165 func loadJobs() async {
166 // Show cached data immediately on first load
167 if jobs.isEmpty {
168 loadFromCache()
169 }
170
171 if jobs.isEmpty {
172 isLoading = true
173 } else {
174 isRefreshing = true
175 }
176 error = nil
177 cursor = nil
178 hasMore = true
179
180 do {
181 let page = try await fetchPage(cursor: nil, useCache: true)
182 jobs = page.results
183 cursor = page.cursor
184 hasMore = page.cursor != nil
185 } catch {
186 if jobs.isEmpty {
187 self.error = error.userFacingMessage
188 }
189 }
190
191 isLoading = false
192 isRefreshing = false
193 }
194
195 func loadMoreIfNeeded(currentItem: JobSummary) async {
196 guard let last = jobs.last,
197 last.id == currentItem.id,
198 hasMore,
199 !isLoadingMore else {
200 return
201 }
202
203 isLoadingMore = true
204
205 do {
206 let page = try await fetchPage(cursor: cursor, useCache: false)
207 jobs.append(contentsOf: page.results)
208 cursor = page.cursor
209 hasMore = page.cursor != nil
210 } catch {
211 self.error = error.userFacingMessage
212 }
213
214 isLoadingMore = false
215 }
216
217 func submitBuild(
218 manifest: String,
219 tags: [String],
220 note: String,
221 secrets: Bool,
222 execute: Bool,
223 visibility: Visibility
224 ) async -> Int? {
225 guard !isSubmitting else { return nil }
226
227 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
228 guard !trimmedManifest.isEmpty else {
229 error = "Paste a build manifest."
230 return nil
231 }
232
233 isSubmitting = true
234 error = nil
235 defer { isSubmitting = false }
236
237 var variables: [String: any Sendable] = [
238 "manifest": trimmedManifest,
239 "secrets": secrets,
240 "execute": execute,
241 "visibility": visibility.rawValue
242 ]
243 if !tags.isEmpty {
244 variables["tags"] = tags
245 }
246 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
247 if !trimmedNote.isEmpty {
248 variables["note"] = trimmedNote
249 }
250
251 do {
252 let result = try await client.execute(
253 service: .builds,
254 query: Self.submitMutation,
255 variables: variables,
256 responseType: SubmitJobResponse.self
257 )
258 await loadJobs()
259 return result.submit.id
260 } catch {
261 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
262 return nil
263 }
264 }
265
266 func cancelJob(_ job: JobSummary) async {
267 guard job.status.isCancellable else { return }
268
269 do {
270 _ = try await client.execute(
271 service: .builds,
272 query: Self.cancelMutation,
273 variables: ["id": job.id],
274 responseType: CancelResponse.self
275 )
276 if let index = jobs.firstIndex(where: { $0.id == job.id }) {
277 let updated = JobSummary(
278 id: job.id,
279 created: job.created,
280 updated: job.updated,
281 status: .cancelled,
282 note: job.note,
283 tags: job.tags,
284 visibility: job.visibility,
285 image: job.image,
286 tasks: job.tasks
287 )
288 jobs[index] = updated
289 }
290 } catch {
291 self.error = error.userFacingMessage
292 }
293 }
294
295 func recordRecentSearch(_ query: String) {
296 ScopedSearchHistoryStore.record(
297 query: query,
298 scopeID: Self.searchHistoryScopeID,
299 defaults: defaults
300 )
301 recentSearches = ScopedSearchHistoryStore.load(
302 scopeID: Self.searchHistoryScopeID,
303 defaults: defaults
304 )
305 }
306
307 func clearRecentSearches() {
308 ScopedSearchHistoryStore.clear(
309 scopeID: Self.searchHistoryScopeID,
310 defaults: defaults
311 )
312 recentSearches = []
313 }
314
315 // MARK: - Private
316
317 private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
318 var variables: [String: any Sendable] = [:]
319 if let cursor {
320 variables["cursor"] = cursor
321 }
322
323 if useCache && cursor == nil {
324 let result = try await client.executeAndCache(
325 service: .builds,
326 query: Self.query,
327 variables: variables.isEmpty ? nil : variables,
328 responseType: JobsResponse.self,
329 cacheKey: Self.cacheKey
330 )
331 return result.jobs
332 } else {
333 let result = try await client.execute(
334 service: .builds,
335 query: Self.query,
336 variables: variables.isEmpty ? nil : variables,
337 responseType: JobsResponse.self
338 )
339 return result.jobs
340 }
341 }
342
343 private func loadFromCache() {
344 guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
345 let decoder = JSONDecoder()
346 decoder.dateDecodingStrategy = .srhtFlexible
347 if let response = try? decoder.decode(
348 GraphQLResponse<JobsResponse>.self,
349 from: data
350 ), let page = response.data?.jobs {
351 jobs = page.results
352 cursor = page.cursor
353 hasMore = page.cursor != nil
354 }
355 }
356
357 private struct CancelResponse: Decodable, Sendable {
358 struct CancelResult: Decodable, Sendable {
359 let id: Int
360 }
361
362 let cancel: CancelResult
363 }
364
365 nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
366 jobs.filter { job in
367 switch filter {
368 case .attention:
369 switch job.status {
370 case .failed, .timeout, .running, .queued, .pending:
371 return true
372 case .success, .cancelled:
373 return false
374 }
375 case .active:
376 switch job.status {
377 case .running, .queued, .pending:
378 return true
379 case .success, .failed, .cancelled, .timeout:
380 return false
381 }
382 case .all:
383 return true
384 }
385 }
386 }
387
388 nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] {
389 let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
390 guard !normalizedQuery.isEmpty else { return jobs }
391
392 return jobs.filter {
393 String($0.id).contains(normalizedQuery) ||
394 $0.status.rawValue.lowercased().contains(normalizedQuery) ||
395 $0.tags.contains { $0.lowercased().contains(normalizedQuery) } ||
396 ($0.note?.lowercased().contains(normalizedQuery) == true) ||
397 ($0.image?.lowercased().contains(normalizedQuery) == true)
398 }
399 }
400}