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