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