krz/hutch

an ios client for sourcehut

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

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