krz/hutch

an ios client for sourcehut

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

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