krz/hutch

an ios client for sourcehut

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

v3.0.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
 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    private(set) var recentSearches: [ScopedSearchHistoryEntry]
 51    private(set) var isLoading = false
 52    private(set) var isLoadingMore = false
 53    private(set) var isRefreshing = false
 54    private(set) var isSubmitting = false
 55    var error: String?
 56    var filter: BuildListFilter = .attention
 57    var searchText = ""
 58    var repoFilter: String = "" {
 59        didSet { if repoFilter != oldValue { repoFilterDidChange() } }
 60    }
 61
 62    private var cursor: String?
 63    private var hasMore = true
 64    private let client: SRHTClient
 65    private let defaults: UserDefaults
 66    private var refreshTask: Task<Void, Never>?
 67    private var isAutoRefreshing = false
 68
 69    private static let cacheKey = "builds.jobs"
 70
 71    init(client: SRHTClient, defaults: UserDefaults = .standard) {
 72        self.client = client
 73        self.defaults = defaults
 74        self.recentSearches = ScopedSearchHistoryStore.load(
 75            scopeID: Self.searchHistoryScopeID,
 76            defaults: defaults
 77        )
 78    }
 79
 80    /// Unique tags across all loaded jobs, sorted alphabetically.
 81    var availableTags: [String] {
 82        let allTags = Set(jobs.flatMap(\.tags))
 83        return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
 84    }
 85
 86    var filteredJobs: [JobSummary] {
 87        var result = Self.filterJobs(jobs, filter: filter)
 88
 89        if !repoFilter.isEmpty {
 90            result = result.filter { $0.tags.contains(repoFilter) }
 91        }
 92
 93        return Self.searchJobs(result, matching: searchText)
 94    }
 95
 96    // MARK: - Auto-Refresh
 97
 98    func startAutoRefresh(interval: AutoRefreshInterval) {
 99        stopAutoRefresh()
100        guard interval != .off else { return }
101        let seconds = interval.rawValue
102        refreshTask = Task { [weak self] in
103            while !Task.isCancelled {
104                try? await Task.sleep(for: .seconds(seconds))
105                guard !Task.isCancelled, let self else { return }
106                guard !self.isAutoRefreshing, !self.isLoading, !self.isRefreshing else { continue }
107                self.isAutoRefreshing = true
108                await self.loadJobs()
109                self.isAutoRefreshing = false
110            }
111        }
112    }
113
114    func stopAutoRefresh() {
115        refreshTask?.cancel()
116        refreshTask = nil
117    }
118
119    private func repoFilterDidChange() {
120        // Reset to empty if the selected tag no longer exists
121        if !repoFilter.isEmpty, !availableTags.contains(repoFilter) {
122            repoFilter = ""
123        }
124    }
125
126    // MARK: - Query
127
128    private static let query = """
129    query jobs($cursor: Cursor) {
130        jobs(cursor: $cursor) {
131            results {
132                id
133                created
134                updated
135                status
136                note
137                tags
138                visibility
139                image
140                tasks { name status }
141            }
142            cursor
143        }
144    }
145    """
146
147    private static let submitMutation = """
148    mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
149        submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
150            id
151        }
152    }
153    """
154
155    private static let cancelMutation = """
156    mutation cancel($id: Int!) {
157        cancel(jobId: $id) { id }
158    }
159    """
160
161    // MARK: - Public API
162
163    /// Fetch the first page of jobs. Shows cached data instantly if available,
164    /// then refreshes from the network in the background.
165    func loadJobs() async {
166        // Show cached data immediately on first load
167        if jobs.isEmpty {
168            loadFromCache()
169        }
170
171        if jobs.isEmpty {
172            isLoading = true
173        } else {
174            isRefreshing = true
175        }
176        error = nil
177        cursor = nil
178        hasMore = true
179
180        do {
181            let page = try await fetchPage(cursor: nil, useCache: true)
182            jobs = page.results
183            cursor = page.cursor
184            hasMore = page.cursor != nil
185        } catch {
186            if jobs.isEmpty {
187                self.error = error.userFacingMessage
188            }
189        }
190
191        isLoading = false
192        isRefreshing = false
193    }
194
195    func loadMoreIfNeeded(currentItem: JobSummary) async {
196        guard let last = jobs.last,
197              last.id == currentItem.id,
198              hasMore,
199              !isLoadingMore else {
200            return
201        }
202
203        isLoadingMore = true
204
205        do {
206            let page = try await fetchPage(cursor: cursor, useCache: false)
207            jobs.append(contentsOf: page.results)
208            cursor = page.cursor
209            hasMore = page.cursor != nil
210        } catch {
211            self.error = error.userFacingMessage
212        }
213
214        isLoadingMore = false
215    }
216
217    func submitBuild(
218        manifest: String,
219        tags: [String],
220        note: String,
221        secrets: Bool,
222        execute: Bool,
223        visibility: Visibility
224    ) async -> Int? {
225        guard !isSubmitting else { return nil }
226
227        let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
228        guard !trimmedManifest.isEmpty else {
229            error = "Paste a build manifest."
230            return nil
231        }
232
233        isSubmitting = true
234        error = nil
235        defer { isSubmitting = false }
236
237        var variables: [String: any Sendable] = [
238            "manifest": trimmedManifest,
239            "secrets": secrets,
240            "execute": execute,
241            "visibility": visibility.rawValue
242        ]
243        if !tags.isEmpty {
244            variables["tags"] = tags
245        }
246        let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
247        if !trimmedNote.isEmpty {
248            variables["note"] = trimmedNote
249        }
250
251        do {
252            let result = try await client.execute(
253                service: .builds,
254                query: Self.submitMutation,
255                variables: variables,
256                responseType: SubmitJobResponse.self
257            )
258            await loadJobs()
259            return result.submit.id
260        } catch {
261            self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
262            return nil
263        }
264    }
265
266    func cancelJob(_ job: JobSummary) async {
267        guard job.status.isCancellable else { return }
268
269        do {
270            _ = try await client.execute(
271                service: .builds,
272                query: Self.cancelMutation,
273                variables: ["id": job.id],
274                responseType: CancelResponse.self
275            )
276            if let index = jobs.firstIndex(where: { $0.id == job.id }) {
277                let updated = JobSummary(
278                    id: job.id,
279                    created: job.created,
280                    updated: job.updated,
281                    status: .cancelled,
282                    note: job.note,
283                    tags: job.tags,
284                    visibility: job.visibility,
285                    image: job.image,
286                    tasks: job.tasks
287                )
288                jobs[index] = updated
289            }
290        } catch {
291            self.error = error.userFacingMessage
292        }
293    }
294
295    func recordRecentSearch(_ query: String) {
296        ScopedSearchHistoryStore.record(
297            query: query,
298            scopeID: Self.searchHistoryScopeID,
299            defaults: defaults
300        )
301        recentSearches = ScopedSearchHistoryStore.load(
302            scopeID: Self.searchHistoryScopeID,
303            defaults: defaults
304        )
305    }
306
307    func clearRecentSearches() {
308        ScopedSearchHistoryStore.clear(
309            scopeID: Self.searchHistoryScopeID,
310            defaults: defaults
311        )
312        recentSearches = []
313    }
314
315    // MARK: - Private
316
317    private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
318        var variables: [String: any Sendable] = [:]
319        if let cursor {
320            variables["cursor"] = cursor
321        }
322
323        if useCache && cursor == nil {
324            let result = try await client.executeAndCache(
325                service: .builds,
326                query: Self.query,
327                variables: variables.isEmpty ? nil : variables,
328                responseType: JobsResponse.self,
329                cacheKey: Self.cacheKey
330            )
331            return result.jobs
332        } else {
333            let result = try await client.execute(
334                service: .builds,
335                query: Self.query,
336                variables: variables.isEmpty ? nil : variables,
337                responseType: JobsResponse.self
338            )
339            return result.jobs
340        }
341    }
342
343    private func loadFromCache() {
344        guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
345        let decoder = JSONDecoder()
346        decoder.dateDecodingStrategy = .srhtFlexible
347        if let response = try? decoder.decode(
348            GraphQLResponse<JobsResponse>.self,
349            from: data
350        ), let page = response.data?.jobs {
351            jobs = page.results
352            cursor = page.cursor
353            hasMore = page.cursor != nil
354        }
355    }
356
357    private struct CancelResponse: Decodable, Sendable {
358        struct CancelResult: Decodable, Sendable {
359            let id: Int
360        }
361
362        let cancel: CancelResult
363    }
364
365    nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
366        jobs.filter { job in
367            switch filter {
368            case .attention:
369                switch job.status {
370                case .failed, .timeout, .running, .queued, .pending:
371                    return true
372                case .success, .cancelled:
373                    return false
374                }
375            case .active:
376                switch job.status {
377                case .running, .queued, .pending:
378                    return true
379                case .success, .failed, .cancelled, .timeout:
380                    return false
381                }
382            case .all:
383                return true
384            }
385        }
386    }
387
388    nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] {
389        let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
390        guard !normalizedQuery.isEmpty else { return jobs }
391
392        return jobs.filter {
393            String($0.id).contains(normalizedQuery) ||
394            $0.status.rawValue.lowercased().contains(normalizedQuery) ||
395            $0.tags.contains { $0.lowercased().contains(normalizedQuery) } ||
396            ($0.note?.lowercased().contains(normalizedQuery) == true) ||
397            ($0.image?.lowercased().contains(normalizedQuery) == true)
398        }
399    }
400}