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}