krz/hutch

an ios client for sourcehut

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

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