krz/hutch

an ios client for sourcehut

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

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