krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.7.1: 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    var searchText = ""
 35
 36    private var cursor: String?
 37    private var hasMore = true
 38    private let client: SRHTClient
 39
 40    private static let cacheKey = "builds.jobs"
 41
 42    init(client: SRHTClient) {
 43        self.client = client
 44    }
 45
 46    var filteredJobs: [JobSummary] {
 47        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
 48        guard !q.isEmpty else { return jobs }
 49        return jobs.filter {
 50            String($0.id).contains(q) ||
 51            $0.tags.contains { $0.lowercased().contains(q) } ||
 52            ($0.note?.lowercased().contains(q) == true) ||
 53            ($0.image?.lowercased().contains(q) == true)
 54        }
 55    }
 56
 57    // MARK: - Query
 58
 59    private static let query = """
 60    query jobs($cursor: Cursor) {
 61        jobs(cursor: $cursor) {
 62            results {
 63                id
 64                created
 65                updated
 66                status
 67                note
 68                tags
 69                visibility
 70                image
 71                tasks { name status }
 72            }
 73            cursor
 74        }
 75    }
 76    """
 77
 78    private static let submitMutation = """
 79    mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
 80        submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
 81            id
 82        }
 83    }
 84    """
 85
 86    private static let cancelMutation = """
 87    mutation cancel($id: Int!) {
 88        cancel(jobId: $id) { id }
 89    }
 90    """
 91
 92    // MARK: - Public API
 93
 94    /// Fetch the first page of jobs. Shows cached data instantly if available,
 95    /// then refreshes from the network in the background.
 96    func loadJobs() async {
 97        // Show cached data immediately on first load
 98        if jobs.isEmpty {
 99            loadFromCache()
100        }
101
102        if jobs.isEmpty {
103            isLoading = true
104        } else {
105            isRefreshing = true
106        }
107        error = nil
108        cursor = nil
109        hasMore = true
110
111        do {
112            let page = try await fetchPage(cursor: nil, useCache: true)
113            jobs = page.results
114            cursor = page.cursor
115            hasMore = page.cursor != nil
116        } catch {
117            if jobs.isEmpty {
118                self.error = error.userFacingMessage
119            }
120        }
121
122        isLoading = false
123        isRefreshing = false
124    }
125
126    func loadMoreIfNeeded(currentItem: JobSummary) async {
127        guard let last = jobs.last,
128              last.id == currentItem.id,
129              hasMore,
130              !isLoadingMore else {
131            return
132        }
133
134        isLoadingMore = true
135
136        do {
137            let page = try await fetchPage(cursor: cursor, useCache: false)
138            jobs.append(contentsOf: page.results)
139            cursor = page.cursor
140            hasMore = page.cursor != nil
141        } catch {
142            self.error = error.userFacingMessage
143        }
144
145        isLoadingMore = false
146    }
147
148    func submitBuild(
149        manifest: String,
150        tags: [String],
151        note: String,
152        secrets: Bool,
153        execute: Bool,
154        visibility: Visibility
155    ) async -> Int? {
156        guard !isSubmitting else { return nil }
157
158        let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
159        guard !trimmedManifest.isEmpty else {
160            error = "Paste a build manifest."
161            return nil
162        }
163
164        isSubmitting = true
165        error = nil
166        defer { isSubmitting = false }
167
168        var variables: [String: any Sendable] = [
169            "manifest": trimmedManifest,
170            "secrets": secrets,
171            "execute": execute,
172            "visibility": visibility.rawValue
173        ]
174        if !tags.isEmpty {
175            variables["tags"] = tags
176        }
177        let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
178        if !trimmedNote.isEmpty {
179            variables["note"] = trimmedNote
180        }
181
182        do {
183            let result = try await client.execute(
184                service: .builds,
185                query: Self.submitMutation,
186                variables: variables,
187                responseType: SubmitJobResponse.self
188            )
189            await loadJobs()
190            return result.submit.id
191        } catch {
192            self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
193            return nil
194        }
195    }
196
197    func cancelJob(_ job: JobSummary) async {
198        guard job.status.isCancellable else { return }
199
200        do {
201            _ = try await client.execute(
202                service: .builds,
203                query: Self.cancelMutation,
204                variables: ["id": job.id],
205                responseType: CancelResponse.self
206            )
207            if let index = jobs.firstIndex(where: { $0.id == job.id }) {
208                let updated = JobSummary(
209                    id: job.id,
210                    created: job.created,
211                    updated: job.updated,
212                    status: .cancelled,
213                    note: job.note,
214                    tags: job.tags,
215                    visibility: job.visibility,
216                    image: job.image,
217                    tasks: job.tasks
218                )
219                jobs[index] = updated
220            }
221        } catch {
222            self.error = error.userFacingMessage
223        }
224    }
225
226    // MARK: - Private
227
228    private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
229        var variables: [String: any Sendable] = [:]
230        if let cursor {
231            variables["cursor"] = cursor
232        }
233
234        if useCache && cursor == nil {
235            let result = try await client.executeAndCache(
236                service: .builds,
237                query: Self.query,
238                variables: variables.isEmpty ? nil : variables,
239                responseType: JobsResponse.self,
240                cacheKey: Self.cacheKey
241            )
242            return result.jobs
243        } else {
244            let result = try await client.execute(
245                service: .builds,
246                query: Self.query,
247                variables: variables.isEmpty ? nil : variables,
248                responseType: JobsResponse.self
249            )
250            return result.jobs
251        }
252    }
253
254    private func loadFromCache() {
255        guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
256        let decoder = JSONDecoder()
257        decoder.dateDecodingStrategy = .srhtFlexible
258        if let response = try? decoder.decode(
259            GraphQLResponse<JobsResponse>.self,
260            from: data
261        ), let page = response.data?.jobs {
262            jobs = page.results
263            cursor = page.cursor
264            hasMore = page.cursor != nil
265        }
266    }
267
268    private struct CancelResponse: Decodable, Sendable {
269        struct CancelResult: Decodable, Sendable {
270            let id: Int
271        }
272
273        let cancel: CancelResult
274    }
275}