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