krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.3: 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
186 if jobs.isEmpty {
187 loadFromCache()
188 }
189
190 if jobs.isEmpty {
191 isLoading = true
192 } else {
193 isRefreshing = true
194 }
195 error = nil
196 cursor = nil
197 hasMore = true
198
199 do {
200 let page = try await fetchPage(cursor: nil, useCache: true)
201 jobs = page.results
202 cursor = page.cursor
203 hasMore = page.cursor != nil
204 } catch {
205 if jobs.isEmpty {
206 self.error = error.userFacingMessage
207 }
208 }
209
210 isLoading = false
211 isRefreshing = false
212 }
213
214 func loadMoreIfNeeded(currentItem: JobSummary) async {
215 guard let last = jobs.last,
216 last.id == currentItem.id,
217 hasMore,
218 !isLoadingMore else {
219 return
220 }
221
222 isLoadingMore = true
223
224 do {
225 let page = try await fetchPage(cursor: cursor, useCache: false)
226 jobs.append(contentsOf: page.results)
227 cursor = page.cursor
228 hasMore = page.cursor != nil
229 } catch {
230 self.error = error.userFacingMessage
231 }
232
233 isLoadingMore = false
234 }
235
236 func submitBuild(
237 manifest: String,
238 tags: [String],
239 note: String,
240 secrets: Bool,
241 execute: Bool,
242 visibility: Visibility
243 ) async -> Int? {
244 guard !isSubmitting else { return nil }
245
246 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
247 guard !trimmedManifest.isEmpty else {
248 error = "Paste a build manifest."
249 return nil
250 }
251
252 isSubmitting = true
253 error = nil
254 defer { isSubmitting = false }
255
256 var variables: [String: any Sendable] = [
257 "manifest": trimmedManifest,
258 "secrets": secrets,
259 "execute": execute,
260 "visibility": visibility.rawValue
261 ]
262 if !tags.isEmpty {
263 variables["tags"] = tags
264 }
265 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
266 if !trimmedNote.isEmpty {
267 variables["note"] = trimmedNote
268 }
269
270 do {
271 let result = try await client.execute(
272 service: .builds,
273 query: Self.submitMutation,
274 variables: variables,
275 responseType: SubmitJobResponse.self
276 )
277 await loadJobs()
278 return result.submit.id
279 } catch {
280 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
281 return nil
282 }
283 }
284
285 func cancelJob(_ job: JobSummary) async {
286 guard job.status.isCancellable else { return }
287
288 do {
289 _ = try await client.execute(
290 service: .builds,
291 query: Self.cancelMutation,
292 variables: ["id": job.id],
293 responseType: CancelResponse.self
294 )
295 if let index = jobs.firstIndex(where: { $0.id == job.id }) {
296 let updated = JobSummary(
297 id: job.id,
298 created: job.created,
299 updated: job.updated,
300 status: .cancelled,
301 note: job.note,
302 tags: job.tags,
303 visibility: job.visibility,
304 image: job.image,
305 tasks: job.tasks
306 )
307 jobs[index] = updated
308 }
309 } catch {
310 self.error = error.userFacingMessage
311 }
312 }
313
314 func recordRecentSearch(_ query: String) {
315 ScopedSearchHistoryStore.record(
316 query: query,
317 scopeID: Self.searchHistoryScopeID,
318 defaults: defaults
319 )
320 recentSearches = ScopedSearchHistoryStore.load(
321 scopeID: Self.searchHistoryScopeID,
322 defaults: defaults
323 )
324 }
325
326 func clearRecentSearches() {
327 ScopedSearchHistoryStore.clear(
328 scopeID: Self.searchHistoryScopeID,
329 defaults: defaults
330 )
331 recentSearches = []
332 }
333
334 // MARK: - Private
335
336 private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
337 var variables: [String: any Sendable] = [:]
338 if let cursor {
339 variables["cursor"] = cursor
340 }
341
342 if useCache && cursor == nil {
343 let result = try await client.executeAndCache(
344 service: .builds,
345 query: Self.query,
346 variables: variables.isEmpty ? nil : variables,
347 responseType: JobsResponse.self,
348 cacheKey: Self.cacheKey
349 )
350 return result.jobs
351 } else {
352 let result = try await client.execute(
353 service: .builds,
354 query: Self.query,
355 variables: variables.isEmpty ? nil : variables,
356 responseType: JobsResponse.self
357 )
358 return result.jobs
359 }
360 }
361
362 private func loadFromCache() {
363 guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
364 let decoder = JSONDecoder()
365 decoder.dateDecodingStrategy = .srhtFlexible
366 if let response = try? decoder.decode(
367 GraphQLResponse<JobsResponse>.self,
368 from: data
369 ), let page = response.data?.jobs {
370 jobs = page.results
371 cursor = page.cursor
372 hasMore = page.cursor != nil
373 }
374 }
375
376 private struct CancelResponse: Decodable, Sendable {
377 struct CancelResult: Decodable, Sendable {
378 let id: Int
379 }
380
381 let cancel: CancelResult
382 }
383
384 nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
385 jobs.filter { job in
386 switch filter {
387 case .attention:
388 switch job.status {
389 case .failed, .timeout, .running, .queued, .pending:
390 return true
391 case .success, .cancelled:
392 return false
393 }
394 case .active:
395 switch job.status {
396 case .running, .queued, .pending:
397 return true
398 case .success, .failed, .cancelled, .timeout:
399 return false
400 }
401 case .all:
402 return true
403 }
404 }
405 }
406
407 nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] {
408 let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
409 guard !normalizedQuery.isEmpty else { return jobs }
410
411 return jobs.filter {
412 String($0.id).contains(normalizedQuery) ||
413 $0.status.rawValue.lowercased().contains(normalizedQuery) ||
414 $0.tags.contains { $0.lowercased().contains(normalizedQuery) } ||
415 ($0.note?.lowercased().contains(normalizedQuery) == true) ||
416 ($0.image?.lowercased().contains(normalizedQuery) == true)
417 }
418 }
419}