krz/hutch

an ios client for sourcehut

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

v3.1.6: 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 (may populate `jobs` from cache).
186        if jobs.isEmpty {
187            loadFromCache()
188        }
189
190        let treatAsInitialLoad = jobs.isEmpty
191        if treatAsInitialLoad {
192            isLoading = true
193        } else {
194            isRefreshing = true
195        }
196        error = nil
197        cursor = nil
198        hasMore = true
199
200        do {
201            let page = try await fetchPage(cursor: nil, useCache: true)
202            jobs = page.results
203            cursor = page.cursor
204            hasMore = page.cursor != nil
205        } catch {
206            if jobs.isEmpty {
207                self.error = error.userFacingMessage
208            }
209        }
210
211        isLoading = false
212        isRefreshing = false
213    }
214
215    func loadMoreIfNeeded(currentItem: JobSummary) async {
216        guard let last = jobs.last,
217              last.id == currentItem.id,
218              hasMore,
219              !isLoadingMore else {
220            return
221        }
222
223        isLoadingMore = true
224
225        do {
226            let page = try await fetchPage(cursor: cursor, useCache: false)
227            jobs.append(contentsOf: page.results)
228            cursor = page.cursor
229            hasMore = page.cursor != nil
230        } catch {
231            self.error = error.userFacingMessage
232        }
233
234        isLoadingMore = false
235    }
236
237    func submitBuild(
238        manifest: String,
239        tags: [String],
240        note: String,
241        secrets: Bool,
242        execute: Bool,
243        visibility: Visibility
244    ) async -> Int? {
245        guard !isSubmitting else { return nil }
246
247        let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
248        guard !trimmedManifest.isEmpty else {
249            error = "Paste a build manifest."
250            return nil
251        }
252
253        isSubmitting = true
254        error = nil
255        defer { isSubmitting = false }
256
257        var variables: [String: any Sendable] = [
258            "manifest": trimmedManifest,
259            "secrets": secrets,
260            "execute": execute,
261            "visibility": visibility.rawValue
262        ]
263        if !tags.isEmpty {
264            variables["tags"] = tags
265        }
266        let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
267        if !trimmedNote.isEmpty {
268            variables["note"] = trimmedNote
269        }
270
271        do {
272            let result = try await client.execute(
273                service: .builds,
274                query: Self.submitMutation,
275                variables: variables,
276                responseType: SubmitJobResponse.self
277            )
278            await loadJobs()
279            return result.submit.id
280        } catch {
281            self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
282            return nil
283        }
284    }
285
286    func cancelJob(_ job: JobSummary) async {
287        guard job.status.isCancellable else { return }
288
289        do {
290            _ = try await client.execute(
291                service: .builds,
292                query: Self.cancelMutation,
293                variables: ["id": job.id],
294                responseType: CancelResponse.self
295            )
296            if let index = jobs.firstIndex(where: { $0.id == job.id }) {
297                let updated = JobSummary(
298                    id: job.id,
299                    created: job.created,
300                    updated: job.updated,
301                    status: .cancelled,
302                    note: job.note,
303                    tags: job.tags,
304                    visibility: job.visibility,
305                    image: job.image,
306                    tasks: job.tasks
307                )
308                jobs[index] = updated
309            }
310        } catch {
311            self.error = error.userFacingMessage
312        }
313    }
314
315    func recordRecentSearch(_ query: String) {
316        ScopedSearchHistoryStore.record(
317            query: query,
318            scopeID: Self.searchHistoryScopeID,
319            defaults: defaults
320        )
321        recentSearches = ScopedSearchHistoryStore.load(
322            scopeID: Self.searchHistoryScopeID,
323            defaults: defaults
324        )
325    }
326
327    func clearRecentSearches() {
328        ScopedSearchHistoryStore.clear(
329            scopeID: Self.searchHistoryScopeID,
330            defaults: defaults
331        )
332        recentSearches = []
333    }
334
335    // MARK: - Private
336
337    private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
338        var variables: [String: any Sendable] = [:]
339        if let cursor {
340            variables["cursor"] = cursor
341        }
342
343        if useCache && cursor == nil {
344            let result = try await client.executeAndCache(
345                service: .builds,
346                query: Self.query,
347                variables: variables.isEmpty ? nil : variables,
348                responseType: JobsResponse.self,
349                cacheKey: Self.cacheKey
350            )
351            return result.jobs
352        } else {
353            let result = try await client.execute(
354                service: .builds,
355                query: Self.query,
356                variables: variables.isEmpty ? nil : variables,
357                responseType: JobsResponse.self
358            )
359            return result.jobs
360        }
361    }
362
363    private func loadFromCache() {
364        guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
365        let decoder = JSONDecoder()
366        decoder.dateDecodingStrategy = .srhtFlexible
367        if let response = try? decoder.decode(
368            GraphQLResponse<JobsResponse>.self,
369            from: data
370        ), let page = response.data?.jobs {
371            jobs = page.results
372            cursor = page.cursor
373            hasMore = page.cursor != nil
374        }
375    }
376
377    private struct CancelResponse: Decodable, Sendable {
378        struct CancelResult: Decodable, Sendable {
379            let id: Int
380        }
381
382        let cancel: CancelResult
383    }
384
385    nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
386        jobs.filter { job in
387            switch filter {
388            case .attention:
389                switch job.status {
390                case .failed, .timeout, .running, .queued, .pending:
391                    return true
392                case .success, .cancelled:
393                    return false
394                }
395            case .active:
396                switch job.status {
397                case .running, .queued, .pending:
398                    return true
399                case .success, .failed, .cancelled, .timeout:
400                    return false
401                }
402            case .all:
403                return true
404            }
405        }
406    }
407
408    nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] {
409        let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
410        guard !normalizedQuery.isEmpty else { return jobs }
411
412        return jobs.filter {
413            String($0.id).contains(normalizedQuery) ||
414            $0.status.rawValue.lowercased().contains(normalizedQuery) ||
415            $0.tags.contains { $0.lowercased().contains(normalizedQuery) } ||
416            ($0.note?.lowercased().contains(normalizedQuery) == true) ||
417            ($0.image?.lowercased().contains(normalizedQuery) == true)
418        }
419    }
420}