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}