krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: 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
22// MARK: - View Model
23
24@Observable
25@MainActor
26final class BuildListViewModel {
27
28 private(set) var jobs: [JobSummary] = []
29 private(set) var isLoading = false
30 private(set) var isLoadingMore = false
31 private(set) var isRefreshing = false
32 private(set) var isSubmitting = false
33 var error: String?
34
35 private var cursor: String?
36 private var hasMore = true
37 private let client: SRHTClient
38
39 private static let cacheKey = "builds.jobs"
40
41 init(client: SRHTClient) {
42 self.client = client
43 }
44
45 // MARK: - Query
46
47 private static let query = """
48 query jobs($cursor: Cursor) {
49 jobs(cursor: $cursor) {
50 results {
51 id
52 created
53 updated
54 status
55 note
56 tags
57 visibility
58 image
59 tasks { name status }
60 }
61 cursor
62 }
63 }
64 """
65
66 private static let submitMutation = """
67 mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
68 submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
69 id
70 }
71 }
72 """
73
74 // MARK: - Public API
75
76 /// Fetch the first page of jobs. Shows cached data instantly if available,
77 /// then refreshes from the network in the background.
78 func loadJobs() async {
79 // Show cached data immediately on first load
80 if jobs.isEmpty {
81 loadFromCache()
82 }
83
84 if jobs.isEmpty {
85 isLoading = true
86 } else {
87 isRefreshing = true
88 }
89 error = nil
90 cursor = nil
91 hasMore = true
92
93 do {
94 let page = try await fetchPage(cursor: nil, useCache: true)
95 jobs = page.results
96 cursor = page.cursor
97 hasMore = page.cursor != nil
98 } catch {
99 if jobs.isEmpty {
100 self.error = error.localizedDescription
101 }
102 }
103
104 isLoading = false
105 isRefreshing = false
106 }
107
108 func loadMoreIfNeeded(currentItem: JobSummary) async {
109 guard let last = jobs.last,
110 last.id == currentItem.id,
111 hasMore,
112 !isLoadingMore else {
113 return
114 }
115
116 isLoadingMore = true
117
118 do {
119 let page = try await fetchPage(cursor: cursor, useCache: false)
120 jobs.append(contentsOf: page.results)
121 cursor = page.cursor
122 hasMore = page.cursor != nil
123 } catch {
124 self.error = error.localizedDescription
125 }
126
127 isLoadingMore = false
128 }
129
130 func submitBuild(
131 manifest: String,
132 tags: [String],
133 note: String,
134 secrets: Bool,
135 execute: Bool,
136 visibility: Visibility
137 ) async -> Int? {
138 guard !isSubmitting else { return nil }
139
140 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
141 guard !trimmedManifest.isEmpty else {
142 error = "Paste a build manifest."
143 return nil
144 }
145
146 isSubmitting = true
147 error = nil
148 defer { isSubmitting = false }
149
150 var variables: [String: any Sendable] = [
151 "manifest": trimmedManifest,
152 "secrets": secrets,
153 "execute": execute,
154 "visibility": visibility.rawValue
155 ]
156 if !tags.isEmpty {
157 variables["tags"] = tags
158 }
159 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
160 if !trimmedNote.isEmpty {
161 variables["note"] = trimmedNote
162 }
163
164 do {
165 let result = try await client.execute(
166 service: .builds,
167 query: Self.submitMutation,
168 variables: variables,
169 responseType: SubmitJobResponse.self
170 )
171 await loadJobs()
172 return result.submit.id
173 } catch {
174 self.error = "Couldn’t submit the build. \(error.localizedDescription)"
175 return nil
176 }
177 }
178
179 // MARK: - Private
180
181 private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
182 var variables: [String: any Sendable] = [:]
183 if let cursor {
184 variables["cursor"] = cursor
185 }
186
187 if useCache && cursor == nil {
188 let result = try await client.executeAndCache(
189 service: .builds,
190 query: Self.query,
191 variables: variables.isEmpty ? nil : variables,
192 responseType: JobsResponse.self,
193 cacheKey: Self.cacheKey
194 )
195 return result.jobs
196 } else {
197 let result = try await client.execute(
198 service: .builds,
199 query: Self.query,
200 variables: variables.isEmpty ? nil : variables,
201 responseType: JobsResponse.self
202 )
203 return result.jobs
204 }
205 }
206
207 private func loadFromCache() {
208 guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
209 let decoder = JSONDecoder()
210 decoder.dateDecodingStrategy = .srhtFlexible
211 if let response = try? decoder.decode(
212 GraphQLResponse<JobsResponse>.self,
213 from: data
214 ), let page = response.data?.jobs {
215 jobs = page.results
216 cursor = page.cursor
217 hasMore = page.cursor != nil
218 }
219 }
220}