krz/hutch

an ios client for sourcehut

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

v2: 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.localizedDescription
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        let message: String
276
277        if let srhtError = error as? SRHTError {
278            switch srhtError {
279            case .graphQLErrors(let errors):
280                message = errors.map(\.message).joined(separator: "\n")
281            default:
282                message = srhtError.localizedDescription
283            }
284        } else {
285            message = error.localizedDescription
286        }
287
288        return "Couldn’t create the repository. \(message)"
289    }
290
291    /// Fetch ALL repositories by paginating through all available pages.
292    /// Used for search functionality to ensure we search through the complete dataset.
293    private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
294        async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
295        async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
296        return try await gitRepositories + hgRepositories
297    }
298
299    /// Reset search state and reload all repositories
300    func resetSearch() {
301        repositories = []
302        cursor = nil
303        hasMore = false
304        isSearching = false
305    }
306
307    // MARK: - Private
308
309    /// Page shape matching the GraphQL response without generic constraints that
310    /// conflict with strict concurrency when used from a @MainActor context.
311    private struct Page: Decodable, Sendable {
312        let results: [RepositoryPayload]
313        let cursor: String?
314    }
315
316    private struct RepositoriesResponse: Decodable, Sendable {
317        let repositories: Page?
318    }
319
320    private struct CreateRepositoryResponse: Decodable, Sendable {
321        let createRepository: RepositorySummary
322    }
323
324    private struct CreateHGRepositoryResponse: Decodable, Sendable {
325        let createRepository: HGRepositoryPayload
326    }
327
328    private struct BuildJobsResponse: Decodable, Sendable {
329        let jobs: BuildJobsPage
330    }
331
332    private struct BuildJobsPage: Decodable, Sendable {
333        let results: [BuildStatusPayload]
334        let cursor: String?
335    }
336
337    private struct BuildStatusPayload: Decodable, Sendable {
338        let id: Int
339        let created: Date
340        let status: JobStatus
341        let manifest: String?
342    }
343
344    private struct HGPage: Decodable, Sendable {
345        let results: [HGRepositoryPayload]
346        let cursor: String?
347    }
348
349    private struct HGRepositoriesResponse: Decodable, Sendable {
350        let repositories: HGPage?
351    }
352
353    private static let emptyPage = Page(results: [], cursor: nil)
354
355    private struct RepositoryPayload: Decodable, Sendable {
356        let id: Int
357        let rid: String
358        let name: String
359        let description: String?
360        let visibility: Visibility
361        let updated: Date
362        let owner: Entity
363        let head: Reference?
364
365        enum CodingKeys: String, CodingKey {
366            case id, rid, name, description, visibility, updated, owner
367            case head = "HEAD"
368        }
369
370        func repositorySummary(service: SRHTService) -> RepositorySummary {
371            RepositorySummary(
372                id: id,
373                rid: rid,
374                service: service,
375                name: name,
376                description: description,
377                visibility: visibility,
378                updated: updated,
379                owner: owner,
380                head: head
381            )
382        }
383    }
384
385    private struct HGRepositoryPayload: Decodable, Sendable {
386        let id: Int
387        let rid: String
388        let name: String
389        let description: String?
390        let visibility: Visibility
391        let updated: Date
392        let owner: Entity
393        let tip: HGTipReference?
394
395        func repositorySummary(service: SRHTService) -> RepositorySummary {
396            RepositorySummary(
397                id: id,
398                rid: rid,
399                service: service,
400                name: name,
401                description: description,
402                visibility: visibility,
403                updated: updated,
404                owner: owner,
405                head: tip.map { Reference(name: $0.branch, target: nil) }
406            )
407        }
408    }
409
410    private struct HGTipReference: Decodable, Sendable {
411        let branch: String
412    }
413
414    private var repositoriesForSearchIndex: [RepositorySummary] {
415        searchIndex
416    }
417
418    func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus {
419        latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none
420    }
421
422    private func fetchPage(
423        service: SRHTService,
424        cursor: String?,
425        search: String? = nil,
426        useCache: Bool
427    ) async throws -> Page {
428        var variables: [String: any Sendable] = [:]
429        if let cursor {
430            variables["cursor"] = cursor
431        }
432        let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
433        if !trimmed.isEmpty {
434            variables["filter"] = ["search": trimmed] as [String: any Sendable]
435        }
436
437        if useCache && cursor == nil {
438            switch service {
439            case .git:
440                let result = try await client.executeAndCache(
441                    service: service,
442                    query: Self.gitQuery,
443                    variables: variables.isEmpty ? nil : variables,
444                    responseType: RepositoriesResponse.self,
445                    cacheKey: cacheKey(for: service)
446                )
447                return result.repositories ?? Self.emptyPage
448            case .hg:
449                let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
450                let result = try await client.executeAndCache(
451                    service: service,
452                    query: Self.hgQuery,
453                    variables: hgVariables,
454                    responseType: HGRepositoriesResponse.self,
455                    cacheKey: cacheKey(for: service)
456                )
457                return Page(
458                    results: result.repositories?.results.map {
459                        RepositoryPayload(
460                            id: $0.id,
461                            rid: $0.rid,
462                            name: $0.name,
463                            description: $0.description,
464                            visibility: $0.visibility,
465                            updated: $0.updated,
466                            owner: $0.owner,
467                            head: $0.tip.map { Reference(name: $0.branch, target: nil) }
468                        )
469                    } ?? [],
470                    cursor: result.repositories?.cursor
471                )
472            default:
473                let result = try await client.executeAndCache(
474                    service: service,
475                    query: Self.gitQuery,
476                    variables: variables.isEmpty ? nil : variables,
477                    responseType: RepositoriesResponse.self,
478                    cacheKey: cacheKey(for: service)
479                )
480                return result.repositories ?? Self.emptyPage
481            }
482        } else {
483            switch service {
484            case .git:
485                let result = try await client.execute(
486                    service: service,
487                    query: Self.gitQuery,
488                    variables: variables.isEmpty ? nil : variables,
489                    responseType: RepositoriesResponse.self
490                )
491                return result.repositories ?? Self.emptyPage
492            case .hg:
493                let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
494                let result = try await client.execute(
495                    service: service,
496                    query: Self.hgQuery,
497                    variables: hgVariables,
498                    responseType: HGRepositoriesResponse.self
499                )
500                return Page(
501                    results: result.repositories?.results.map {
502                        RepositoryPayload(
503                            id: $0.id,
504                            rid: $0.rid,
505                            name: $0.name,
506                            description: $0.description,
507                            visibility: $0.visibility,
508                            updated: $0.updated,
509                            owner: $0.owner,
510                            head: $0.tip.map { Reference(name: $0.branch, target: nil) }
511                        )
512                    } ?? [],
513                    cursor: result.repositories?.cursor
514                )
515            default:
516                let result = try await client.execute(
517                    service: service,
518                    query: Self.gitQuery,
519                    variables: variables.isEmpty ? nil : variables,
520                    responseType: RepositoriesResponse.self
521                )
522                return result.repositories ?? Self.emptyPage
523            }
524        }
525    }
526
527    private func loadFromCache() {
528        let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
529            guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
530            let decoder = JSONDecoder()
531            decoder.dateDecodingStrategy = .srhtFlexible
532            switch service {
533            case .git:
534                if let response = try? decoder.decode(
535                    GraphQLResponse<RepositoriesResponse>.self,
536                    from: data
537                ), let repos = response.data?.repositories {
538                    return repos.results.map { $0.repositorySummary(service: service) }
539                }
540            case .hg:
541                if let response = try? decoder.decode(
542                    GraphQLResponse<HGRepositoriesResponse>.self,
543                    from: data
544                ), let repos = response.data?.repositories {
545                    return repos.results.map { $0.repositorySummary(service: service) }
546                }
547            default:
548                break
549            }
550            return []
551        }
552        if !cachedRepositories.isEmpty {
553            let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
554            repositories = sortedRepositories
555            updateSearchIndex(with: sortedRepositories)
556            scheduleBuildStatusRefresh()
557        }
558    }
559
560    private func scheduleBuildStatusRefresh() {
561        let repositoriesSnapshot = repositories
562        buildStatusTask?.cancel()
563        buildStatusTask = Task { [weak self] in
564            guard let self else { return }
565            await self.loadLatestBuildStatuses(for: repositoriesSnapshot)
566        }
567    }
568
569    private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async {
570        let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:)))
571        guard !targetKeys.isEmpty else {
572            await MainActor.run {
573                latestBuildStatuses = [:]
574            }
575            return
576        }
577
578        var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:]
579        var cursor: String?
580        var shouldUseCache = true
581
582        do {
583            while !Task.isCancelled {
584                let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache)
585                shouldUseCache = false
586
587                for job in page.results {
588                    let jobStatus = Self.repositoryBuildStatus(for: job.status)
589                    guard let manifest = job.manifest else { continue }
590
591                    for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) {
592                        let existing = resolvedStatuses[key]
593                        if existing == nil || existing!.0 < job.created {
594                            resolvedStatuses[key] = (job.created, jobStatus)
595                        }
596                    }
597                }
598
599                if resolvedStatuses.count == targetKeys.count || page.cursor == nil {
600                    break
601                }
602                cursor = page.cursor
603            }
604
605            let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in
606                result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none
607            }
608
609            await MainActor.run {
610                guard repositories == self.repositories else { return }
611                latestBuildStatuses = finalStatuses
612            }
613        } catch {
614            // Build status is auxiliary data for the list. Leave the default gray state on failure.
615        }
616    }
617
618    private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage {
619        var variables: [String: any Sendable] = [:]
620        if let cursor {
621            variables["cursor"] = cursor
622        }
623
624        if useCache && cursor == nil {
625            let result = try await client.executeAndCache(
626                service: .builds,
627                query: Self.buildsQuery,
628                variables: variables.isEmpty ? nil : variables,
629                responseType: BuildJobsResponse.self,
630                cacheKey: Self.buildsCacheKey
631            )
632            return result.jobs
633        }
634
635        let result = try await client.execute(
636            service: .builds,
637            query: Self.buildsQuery,
638            variables: variables.isEmpty ? nil : variables,
639            responseType: BuildJobsResponse.self
640        )
641        return result.jobs
642    }
643
644    private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
645        var allRepositories: [RepositorySummary] = []
646        var currentCursor: String? = nil
647
648        while true {
649            let page = try await fetchPage(
650                service: service,
651                cursor: currentCursor,
652                search: nil,
653                useCache: useCache && currentCursor == nil
654            )
655            allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
656            guard let nextCursor = page.cursor else { break }
657            currentCursor = nextCursor
658        }
659
660        return allRepositories
661    }
662
663    private func cacheKey(for service: SRHTService) -> String {
664        switch service {
665        case .git:
666            Self.gitCacheKey
667        case .hg:
668            Self.hgCacheKey
669        default:
670            "\(service.rawValue).repositories"
671        }
672    }
673
674    private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
675        if lhs.updated == rhs.updated {
676            if lhs.service == rhs.service {
677                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
678            }
679            return lhs.service.rawValue < rhs.service.rawValue
680        }
681        return lhs.updated > rhs.updated
682    }
683
684    private func updateSearchIndex(with repositories: [RepositorySummary]) {
685        searchIndex = repositories.sorted(by: repositorySortOrder)
686        hasLoadedSearchIndex = !searchIndex.isEmpty
687    }
688
689    private func insertIntoSearchIndex(_ repository: RepositorySummary) {
690        let updatedRepositories = (repositoriesForSearchIndex + [repository])
691            .uniqued(on: \.id)
692            .sorted(by: repositorySortOrder)
693        updateSearchIndex(with: updatedRepositories)
694    }
695
696    static func shouldRefreshSearchIndex(for query: String) -> Bool {
697        query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength
698    }
699
700    static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] {
701        let lowercasedQuery = query.lowercased()
702        return repositories.filter { repo in
703            repo.name.lowercased().contains(lowercasedQuery) ||
704            repo.description?.lowercased().contains(lowercasedQuery) ?? false
705        }
706    }
707
708    nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String {
709        buildStatusCacheKey(
710            service: repository.service,
711            ownerCanonicalName: repository.owner.canonicalName,
712            repositoryName: repository.name
713        )
714    }
715
716    nonisolated static func buildStatusCacheKey(
717        service: SRHTService,
718        ownerCanonicalName: String,
719        repositoryName: String
720    ) -> String {
721        "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())"
722    }
723
724    nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus {
725        switch jobStatus {
726        case .success:
727            .success
728        case .pending, .queued, .running:
729            .running
730        case .failed, .cancelled, .timeout:
731            .failed
732        }
733    }
734
735    nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> {
736        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
737        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
738            return []
739        }
740
741        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
742        return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in
743            guard
744                let serviceRange = Range(match.range(at: 1), in: manifest),
745                let ownerRange = Range(match.range(at: 2), in: manifest),
746                let nameRange = Range(match.range(at: 3), in: manifest)
747            else {
748                return
749            }
750
751            let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git
752            let owner = String(manifest[ownerRange]).lowercased()
753            var name = String(manifest[nameRange]).lowercased()
754
755            if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
756                name.removeSubrange(suffixRange)
757            }
758            name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
759            if !name.isEmpty {
760                result.insert(buildStatusCacheKey(
761                    service: service,
762                    ownerCanonicalName: owner,
763                    repositoryName: name
764                ))
765            }
766        }
767    }
768}
769
770private extension Array {
771    func uniqued<ID: Hashable>(on keyPath: KeyPath<Element, ID>) -> [Element] {
772        var seenIDs: Set<ID> = []
773        return filter { element in
774            seenIDs.insert(element[keyPath: keyPath]).inserted
775        }
776    }
777}