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