krz/hutch

an ios client for sourcehut

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

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