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}