krz/hutch

an ios client for sourcehut

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

v2.13.1: Hutch/Views/Repositories/RepositoryListViewModel.swift · raw

  1import Foundation
  2
  3enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
  4    case git
  5    case hg
  6
  7    var id: String { rawValue }
  8
  9    var service: SRHTService {
 10        switch self {
 11        case .git: .git
 12        case .hg: .hg
 13        }
 14    }
 15
 16    var displayName: String {
 17        switch self {
 18        case .git: "Git"
 19        case .hg: "Mercurial"
 20        }
 21    }
 22}
 23
 24/// View model for the repository list screen.
 25@Observable
 26@MainActor
 27final class RepositoryListViewModel {
 28
 29    private(set) var repositories: [RepositorySummary] = []
 30    private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
 31    private(set) var isLoading = false
 32    private(set) var isLoadingMore = false
 33    private(set) var isRefreshing = false
 34    var error: String?
 35
 36    var searchText = ""
 37
 38    private(set) var cursor: String?
 39    private(set) var hasMore = false
 40    private(set) var isSearching = false
 41    private(set) var isCreatingRepository = false
 42    private(set) var hasLoadedSearchIndex = false
 43    private var searchIndex: [RepositorySummary] = []
 44    private let client: SRHTClient
 45    private var buildStatusTask: Task<Void, Never>?
 46
 47    private static let gitCacheKey = "git.repositories"
 48    private static let hgCacheKey = "hg.repositories"
 49    private static let buildsCacheKey = "builds.repository-status"
 50    private static let minimumRemoteSearchLength = 3
 51
 52    init(client: SRHTClient) {
 53        self.client = client
 54    }
 55
 56    // MARK: - Queries
 57
 58    private static let gitQuery = """
 59    query repositories($cursor: Cursor, $filter: Filter) {
 60        repositories(cursor: $cursor, filter: $filter) {
 61            results {
 62                id
 63                rid
 64                name
 65                description
 66                visibility
 67                updated
 68                owner { canonicalName }
 69                HEAD { name }
 70            }
 71            cursor
 72        }
 73    }
 74    """
 75
 76    private static let hgQuery = """
 77    query repositories($cursor: Cursor) {
 78        repositories(cursor: $cursor) {
 79            results {
 80                id
 81                rid
 82                name
 83                description
 84                visibility
 85                updated
 86                owner { canonicalName }
 87                tip { branch }
 88            }
 89            cursor
 90        }
 91    }
 92    """
 93
 94    private static let createRepositoryMutation = """
 95    mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {
 96        createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {
 97            id
 98            rid
 99            name
100            description
101            visibility
102            updated
103            owner { canonicalName }
104        }
105    }
106    """
107
108    private static let createHgRepositoryMutation = """
109    mutation createRepository($name: String!, $visibility: Visibility!, $description: String) {
110        createRepository(name: $name, visibility: $visibility, description: $description) {
111            id
112            rid
113            name
114            description
115            visibility
116            updated
117            owner { canonicalName }
118            tip { branch }
119        }
120    }
121    """
122
123    private static let buildsQuery = """
124    query jobs($cursor: Cursor) {
125        jobs(cursor: $cursor) {
126            results {
127                id
128                created
129                status
130                manifest
131            }
132            cursor
133        }
134    }
135    """
136
137    // MARK: - Public API
138
139    /// Fetch the first page of repositories. Shows cached data instantly if available,
140    /// then refreshes from the network in the background.
141    /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
142    func loadRepositories(search: String? = nil) async {
143        let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
144        let isSearch = !query.isEmpty
145
146        // Only use cache for non-search, initial loads
147        if !isSearch, repositories.isEmpty {
148            loadFromCache()
149        }
150
151        // During search, never show the full-screen loading overlay (which
152        // would remove the List and dismiss the keyboard). Use "refreshing"
153        // instead so the list stays in the hierarchy.
154        if isSearch {
155            isRefreshing = true
156            isSearching = true
157        } else if repositories.isEmpty {
158            isLoading = true
159            isSearching = false
160        } else {
161            isRefreshing = true
162            isSearching = false
163        }
164        error = nil
165        cursor = nil
166        hasMore = false
167
168        do {
169            var filteredResults: [RepositorySummary]
170
171            if isSearch {
172                if hasLoadedSearchIndex || repositories.isEmpty == false {
173                    filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query)
174                } else if Self.shouldRefreshSearchIndex(for: query) {
175                    let repositories = try await fetchAllRepositories(useCache: true)
176                    updateSearchIndex(with: repositories)
177                    filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query)
178                } else {
179                    filteredResults = []
180                }
181            } else {
182                let repositories = try await fetchAllRepositories(useCache: true)
183                updateSearchIndex(with: repositories)
184                filteredResults = repositories
185            }
186
187            repositories = filteredResults.sorted(by: repositorySortOrder)
188            scheduleBuildStatusRefresh()
189        } catch {
190            // Only show error if we have no cached data to fall back on
191            if repositories.isEmpty {
192                self.error = error.userFacingMessage
193            }
194        }
195
196        isLoading = false
197        isRefreshing = false
198    }
199
200    /// Load the next page if available. Called when the user scrolls near the end.
201    /// Note: Pagination is disabled during search (client-side filtering).
202    func loadMoreIfNeeded(currentItem: RepositorySummary) async {
203        _ = currentItem
204    }
205
206    /// Remove a repository from the local list (e.g. after deletion).
207    func removeRepository(id: Int) {
208        repositories.removeAll { $0.id == id }
209    }
210
211    func createRepository(
212        service: RepositoryCreationService,
213        name: String,
214        description: String,
215        visibility: Visibility,
216        cloneURL: String
217    ) async -> RepositorySummary? {
218        guard !isCreatingRepository else { return nil }
219
220        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
221        guard !trimmedName.isEmpty else {
222            error = "Enter a repository name."
223            return nil
224        }
225
226        isCreatingRepository = true
227        error = nil
228        defer { isCreatingRepository = false }
229
230        var variables: [String: any Sendable] = [
231            "name": trimmedName,
232            "visibility": visibility.rawValue
233        ]
234        let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
235        if !trimmedDescription.isEmpty {
236            variables["description"] = trimmedDescription
237        }
238        let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines)
239        if !trimmedCloneURL.isEmpty {
240            variables["cloneUrl"] = trimmedCloneURL
241        }
242
243        do {
244            let repository: RepositorySummary
245            switch service {
246            case .git:
247                let result = try await client.execute(
248                    service: .git,
249                    query: Self.createRepositoryMutation,
250                    variables: variables,
251                    responseType: CreateRepositoryResponse.self
252                )
253                repository = result.createRepository
254            case .hg:
255                variables.removeValue(forKey: "cloneUrl")
256                let result = try await client.execute(
257                    service: .hg,
258                    query: Self.createHgRepositoryMutation,
259                    variables: variables,
260                    responseType: CreateHGRepositoryResponse.self
261                )
262                repository = result.createRepository.repositorySummary(service: .hg)
263            }
264            repositories.insert(repository, at: 0)
265            insertIntoSearchIndex(repository)
266            scheduleBuildStatusRefresh()
267            return repository
268        } catch {
269            self.error = repositoryCreationErrorMessage(for: error)
270            return nil
271        }
272    }
273
274    private func repositoryCreationErrorMessage(for error: Error) -> String {
275        "Couldn’t create the repository. \(error.userFacingMessage)"
276    }
277
278    /// Fetch ALL repositories by paginating through all available pages.
279    /// Used for search functionality to ensure we search through the complete dataset.
280    private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
281        async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
282        async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
283        return try await gitRepositories + hgRepositories
284    }
285
286    /// Reset search state and reload all repositories
287    func resetSearch() {
288        repositories = []
289        cursor = nil
290        hasMore = false
291        isSearching = false
292    }
293
294    // MARK: - Private
295
296    /// Page shape matching the GraphQL response without generic constraints that
297    /// conflict with strict concurrency when used from a @MainActor context.
298    private struct Page: Decodable, Sendable {
299        let results: [RepositoryPayload]
300        let cursor: String?
301    }
302
303    private struct RepositoriesResponse: Decodable, Sendable {
304        let repositories: Page?
305    }
306
307    private struct CreateRepositoryResponse: Decodable, Sendable {
308        let createRepository: RepositorySummary
309    }
310
311    private struct CreateHGRepositoryResponse: Decodable, Sendable {
312        let createRepository: HGRepositoryPayload
313    }
314
315    private struct BuildJobsResponse: Decodable, Sendable {
316        let jobs: BuildJobsPage
317    }
318
319    private struct BuildJobsPage: Decodable, Sendable {
320        let results: [BuildStatusPayload]
321        let cursor: String?
322    }
323
324    private struct BuildStatusPayload: Decodable, Sendable {
325        let id: Int
326        let created: Date
327        let status: JobStatus
328        let manifest: String?
329    }
330
331    private struct HGPage: Decodable, Sendable {
332        let results: [HGRepositoryPayload]
333        let cursor: String?
334    }
335
336    private struct HGRepositoriesResponse: Decodable, Sendable {
337        let repositories: HGPage?
338    }
339
340    private static let emptyPage = Page(results: [], cursor: nil)
341
342    private struct RepositoryPayload: Decodable, Sendable {
343        let id: Int
344        let rid: String
345        let name: String
346        let description: String?
347        let visibility: Visibility
348        let updated: Date
349        let owner: Entity
350        let head: Reference?
351
352        enum CodingKeys: String, CodingKey {
353            case id, rid, name, description, visibility, updated, owner
354            case head = "HEAD"
355        }
356
357        func repositorySummary(service: SRHTService) -> RepositorySummary {
358            RepositorySummary(
359                id: id,
360                rid: rid,
361                service: service,
362                name: name,
363                description: description,
364                visibility: visibility,
365                updated: updated,
366                owner: owner,
367                head: head
368            )
369        }
370    }
371
372    private struct HGRepositoryPayload: Decodable, Sendable {
373        let id: Int
374        let rid: String
375        let name: String
376        let description: String?
377        let visibility: Visibility
378        let updated: Date
379        let owner: Entity
380        let tip: HGTipReference?
381
382        func repositorySummary(service: SRHTService) -> RepositorySummary {
383            RepositorySummary(
384                id: id,
385                rid: rid,
386                service: service,
387                name: name,
388                description: description,
389                visibility: visibility,
390                updated: updated,
391                owner: owner,
392                head: tip.map { Reference(name: $0.branch, target: nil) }
393            )
394        }
395    }
396
397    private struct HGTipReference: Decodable, Sendable {
398        let branch: String
399    }
400
401    private var repositoriesForSearchIndex: [RepositorySummary] {
402        searchIndex
403    }
404
405    func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus {
406        latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none
407    }
408
409    private func fetchPage(
410        service: SRHTService,
411        cursor: String?,
412        search: String? = nil,
413        useCache: Bool
414    ) async throws -> Page {
415        var variables: [String: any Sendable] = [:]
416        if let cursor {
417            variables["cursor"] = cursor
418        }
419        let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
420        if !trimmed.isEmpty {
421            variables["filter"] = ["search": trimmed] as [String: any Sendable]
422        }
423
424        if useCache && cursor == nil {
425            switch service {
426            case .git:
427                let result = try await client.executeAndCache(
428                    service: service,
429                    query: Self.gitQuery,
430                    variables: variables.isEmpty ? nil : variables,
431                    responseType: RepositoriesResponse.self,
432                    cacheKey: cacheKey(for: service)
433                )
434                return result.repositories ?? Self.emptyPage
435            case .hg:
436                let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
437                let result = try await client.executeAndCache(
438                    service: service,
439                    query: Self.hgQuery,
440                    variables: hgVariables,
441                    responseType: HGRepositoriesResponse.self,
442                    cacheKey: cacheKey(for: service)
443                )
444                return Page(
445                    results: result.repositories?.results.map {
446                        RepositoryPayload(
447                            id: $0.id,
448                            rid: $0.rid,
449                            name: $0.name,
450                            description: $0.description,
451                            visibility: $0.visibility,
452                            updated: $0.updated,
453                            owner: $0.owner,
454                            head: $0.tip.map { Reference(name: $0.branch, target: nil) }
455                        )
456                    } ?? [],
457                    cursor: result.repositories?.cursor
458                )
459            default:
460                let result = try await client.executeAndCache(
461                    service: service,
462                    query: Self.gitQuery,
463                    variables: variables.isEmpty ? nil : variables,
464                    responseType: RepositoriesResponse.self,
465                    cacheKey: cacheKey(for: service)
466                )
467                return result.repositories ?? Self.emptyPage
468            }
469        } else {
470            switch service {
471            case .git:
472                let result = try await client.execute(
473                    service: service,
474                    query: Self.gitQuery,
475                    variables: variables.isEmpty ? nil : variables,
476                    responseType: RepositoriesResponse.self
477                )
478                return result.repositories ?? Self.emptyPage
479            case .hg:
480                let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
481                let result = try await client.execute(
482                    service: service,
483                    query: Self.hgQuery,
484                    variables: hgVariables,
485                    responseType: HGRepositoriesResponse.self
486                )
487                return Page(
488                    results: result.repositories?.results.map {
489                        RepositoryPayload(
490                            id: $0.id,
491                            rid: $0.rid,
492                            name: $0.name,
493                            description: $0.description,
494                            visibility: $0.visibility,
495                            updated: $0.updated,
496                            owner: $0.owner,
497                            head: $0.tip.map { Reference(name: $0.branch, target: nil) }
498                        )
499                    } ?? [],
500                    cursor: result.repositories?.cursor
501                )
502            default:
503                let result = try await client.execute(
504                    service: service,
505                    query: Self.gitQuery,
506                    variables: variables.isEmpty ? nil : variables,
507                    responseType: RepositoriesResponse.self
508                )
509                return result.repositories ?? Self.emptyPage
510            }
511        }
512    }
513
514    private func loadFromCache() {
515        let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
516            guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
517            let decoder = JSONDecoder()
518            decoder.dateDecodingStrategy = .srhtFlexible
519            switch service {
520            case .git:
521                if let response = try? decoder.decode(
522                    GraphQLResponse<RepositoriesResponse>.self,
523                    from: data
524                ), let repos = response.data?.repositories {
525                    return repos.results.map { $0.repositorySummary(service: service) }
526                }
527            case .hg:
528                if let response = try? decoder.decode(
529                    GraphQLResponse<HGRepositoriesResponse>.self,
530                    from: data
531                ), let repos = response.data?.repositories {
532                    return repos.results.map { $0.repositorySummary(service: service) }
533                }
534            default:
535                break
536            }
537            return []
538        }
539        if !cachedRepositories.isEmpty {
540            let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
541            repositories = sortedRepositories
542            updateSearchIndex(with: sortedRepositories)
543            scheduleBuildStatusRefresh()
544        }
545    }
546
547    private func scheduleBuildStatusRefresh() {
548        let repositoriesSnapshot = repositories
549        buildStatusTask?.cancel()
550        buildStatusTask = Task { [weak self] in
551            guard let self else { return }
552            await self.loadLatestBuildStatuses(for: repositoriesSnapshot)
553        }
554    }
555
556    private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async {
557        let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:)))
558        guard !targetKeys.isEmpty else {
559            await MainActor.run {
560                latestBuildStatuses = [:]
561            }
562            return
563        }
564
565        var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:]
566        var cursor: String?
567        var shouldUseCache = true
568
569        do {
570            while !Task.isCancelled {
571                let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache)
572                shouldUseCache = false
573
574                for job in page.results {
575                    let jobStatus = Self.repositoryBuildStatus(for: job.status)
576                    guard let manifest = job.manifest else { continue }
577
578                    for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) {
579                        let existing = resolvedStatuses[key]
580                        if existing == nil || existing!.0 < job.created {
581                            resolvedStatuses[key] = (job.created, jobStatus)
582                        }
583                    }
584                }
585
586                if resolvedStatuses.count == targetKeys.count || page.cursor == nil {
587                    break
588                }
589                cursor = page.cursor
590            }
591
592            let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in
593                result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none
594            }
595
596            await MainActor.run {
597                guard repositories == self.repositories else { return }
598                latestBuildStatuses = finalStatuses
599            }
600        } catch {
601            // Build status is auxiliary data for the list. Leave the default gray state on failure.
602        }
603    }
604
605    private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage {
606        var variables: [String: any Sendable] = [:]
607        if let cursor {
608            variables["cursor"] = cursor
609        }
610
611        if useCache && cursor == nil {
612            let result = try await client.executeAndCache(
613                service: .builds,
614                query: Self.buildsQuery,
615                variables: variables.isEmpty ? nil : variables,
616                responseType: BuildJobsResponse.self,
617                cacheKey: Self.buildsCacheKey
618            )
619            return result.jobs
620        }
621
622        let result = try await client.execute(
623            service: .builds,
624            query: Self.buildsQuery,
625            variables: variables.isEmpty ? nil : variables,
626            responseType: BuildJobsResponse.self
627        )
628        return result.jobs
629    }
630
631    private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
632        var allRepositories: [RepositorySummary] = []
633        var currentCursor: String? = nil
634
635        while true {
636            let page = try await fetchPage(
637                service: service,
638                cursor: currentCursor,
639                search: nil,
640                useCache: useCache && currentCursor == nil
641            )
642            allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
643            guard let nextCursor = page.cursor else { break }
644            currentCursor = nextCursor
645        }
646
647        return allRepositories
648    }
649
650    private func cacheKey(for service: SRHTService) -> String {
651        switch service {
652        case .git:
653            Self.gitCacheKey
654        case .hg:
655            Self.hgCacheKey
656        default:
657            "\(service.rawValue).repositories"
658        }
659    }
660
661    private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
662        if lhs.updated == rhs.updated {
663            if lhs.service == rhs.service {
664                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
665            }
666            return lhs.service.rawValue < rhs.service.rawValue
667        }
668        return lhs.updated > rhs.updated
669    }
670
671    private func updateSearchIndex(with repositories: [RepositorySummary]) {
672        searchIndex = repositories.sorted(by: repositorySortOrder)
673        hasLoadedSearchIndex = !searchIndex.isEmpty
674    }
675
676    private func insertIntoSearchIndex(_ repository: RepositorySummary) {
677        let updatedRepositories = (repositoriesForSearchIndex + [repository])
678            .uniqued(on: \.id)
679            .sorted(by: repositorySortOrder)
680        updateSearchIndex(with: updatedRepositories)
681    }
682
683    static func shouldRefreshSearchIndex(for query: String) -> Bool {
684        query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength
685    }
686
687    static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] {
688        let lowercasedQuery = query.lowercased()
689        return repositories.filter { repo in
690            repo.name.lowercased().contains(lowercasedQuery) ||
691            repo.description?.lowercased().contains(lowercasedQuery) ?? false
692        }
693    }
694
695    nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String {
696        buildStatusCacheKey(
697            service: repository.service,
698            ownerCanonicalName: repository.owner.canonicalName,
699            repositoryName: repository.name
700        )
701    }
702
703    nonisolated static func buildStatusCacheKey(
704        service: SRHTService,
705        ownerCanonicalName: String,
706        repositoryName: String
707    ) -> String {
708        "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())"
709    }
710
711    nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus {
712        switch jobStatus {
713        case .success:
714            .success
715        case .pending, .queued, .running:
716            .running
717        case .failed, .cancelled, .timeout:
718            .failed
719        }
720    }
721
722    nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> {
723        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
724        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
725            return []
726        }
727
728        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
729        return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in
730            guard
731                let serviceRange = Range(match.range(at: 1), in: manifest),
732                let ownerRange = Range(match.range(at: 2), in: manifest),
733                let nameRange = Range(match.range(at: 3), in: manifest)
734            else {
735                return
736            }
737
738            let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git
739            let owner = String(manifest[ownerRange]).lowercased()
740            var name = String(manifest[nameRange]).lowercased()
741
742            if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
743                name.removeSubrange(suffixRange)
744            }
745            name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
746            if !name.isEmpty {
747                result.insert(buildStatusCacheKey(
748                    service: service,
749                    ownerCanonicalName: owner,
750                    repositoryName: name
751                ))
752            }
753        }
754    }
755}
756
757private extension Array {
758    func uniqued<ID: Hashable>(on keyPath: KeyPath<Element, ID>) -> [Element] {
759        var seenIDs: Set<ID> = []
760        return filter { element in
761            seenIDs.insert(element[keyPath: keyPath]).inserted
762        }
763    }
764}