Hutch/Views/Repositories/RepositoryListViewModel.swift
592 lines · 20564 bytes
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 isLoading = false
31 private(set) var isLoadingMore = false
32 private(set) var isRefreshing = false
33 var error: String?
34
35 var searchText = ""
36
37 private(set) var cursor: String?
38 private(set) var hasMore = false
39 private(set) var isSearching = false
40 private(set) var isCreatingRepository = false
41 private(set) var hasLoadedSearchIndex = false
42 private var searchIndex: [RepositorySummary] = []
43 private let client: SRHTClient
44
45 private static let gitCacheKey = "git.repositories"
46 private static let hgCacheKey = "hg.repositories"
47 private static let minimumRemoteSearchLength = 3
48
49 init(client: SRHTClient) {
50 self.client = client
51 }
52
53 // MARK: - Queries
54
55 private static let gitQuery = """
56 query repositories($cursor: Cursor, $filter: Filter) {
57 repositories(cursor: $cursor, filter: $filter) {
58 results {
59 id
60 rid
61 name
62 description
63 visibility
64 updated
65 owner { canonicalName }
66 HEAD { name }
67 }
68 cursor
69 }
70 }
71 """
72
73 private static let hgQuery = """
74 query repositories($cursor: Cursor) {
75 repositories(cursor: $cursor) {
76 results {
77 id
78 rid
79 name
80 description
81 visibility
82 updated
83 owner { canonicalName }
84 tip { branch }
85 }
86 cursor
87 }
88 }
89 """
90
91 private static let createRepositoryMutation = """
92 mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {
93 createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {
94 id
95 rid
96 name
97 description
98 visibility
99 updated
100 owner { canonicalName }
101 }
102 }
103 """
104
105 private static let createHgRepositoryMutation = """
106 mutation createRepository($name: String!, $visibility: Visibility!, $description: String) {
107 createRepository(name: $name, visibility: $visibility, description: $description) {
108 id
109 rid
110 name
111 description
112 visibility
113 updated
114 owner { canonicalName }
115 tip { branch }
116 }
117 }
118 """
119
120 // MARK: - Public API
121
122 /// Fetch the first page of repositories. Shows cached data instantly if available,
123 /// then refreshes from the network in the background.
124 /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
125 func loadRepositories(search: String? = nil) async {
126 let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
127 let isSearch = !query.isEmpty
128
129 // Only use cache for non-search, initial loads
130 if !isSearch, repositories.isEmpty {
131 loadFromCache()
132 }
133
134 // During search, never show the full-screen loading overlay (which
135 // would remove the List and dismiss the keyboard). Use "refreshing"
136 // instead so the list stays in the hierarchy.
137 if isSearch {
138 isRefreshing = true
139 isSearching = true
140 } else if repositories.isEmpty {
141 isLoading = true
142 isSearching = false
143 } else {
144 isRefreshing = true
145 isSearching = false
146 }
147 error = nil
148 cursor = nil
149 hasMore = false
150
151 do {
152 var filteredResults: [RepositorySummary]
153
154 if isSearch {
155 if hasLoadedSearchIndex || repositories.isEmpty == false {
156 filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query)
157 } else if Self.shouldRefreshSearchIndex(for: query) {
158 let repositories = try await fetchAllRepositories(useCache: true)
159 updateSearchIndex(with: repositories)
160 filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query)
161 } else {
162 filteredResults = []
163 }
164 } else {
165 let repositories = try await fetchAllRepositories(useCache: true)
166 updateSearchIndex(with: repositories)
167 filteredResults = repositories
168 }
169
170 repositories = filteredResults.sorted(by: repositorySortOrder)
171 } catch {
172 // Only show error if we have no cached data to fall back on
173 if repositories.isEmpty {
174 self.error = error.localizedDescription
175 }
176 }
177
178 isLoading = false
179 isRefreshing = false
180 }
181
182 /// Load the next page if available. Called when the user scrolls near the end.
183 /// Note: Pagination is disabled during search (client-side filtering).
184 func loadMoreIfNeeded(currentItem: RepositorySummary) async {
185 _ = currentItem
186 }
187
188 /// Remove a repository from the local list (e.g. after deletion).
189 func removeRepository(id: Int) {
190 repositories.removeAll { $0.id == id }
191 }
192
193 func createRepository(
194 service: RepositoryCreationService,
195 name: String,
196 description: String,
197 visibility: Visibility,
198 cloneURL: String
199 ) async -> RepositorySummary? {
200 guard !isCreatingRepository else { return nil }
201
202 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
203 guard !trimmedName.isEmpty else {
204 error = "Enter a repository name."
205 return nil
206 }
207
208 isCreatingRepository = true
209 error = nil
210 defer { isCreatingRepository = false }
211
212 var variables: [String: any Sendable] = [
213 "name": trimmedName,
214 "visibility": visibility.rawValue
215 ]
216 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
217 if !trimmedDescription.isEmpty {
218 variables["description"] = trimmedDescription
219 }
220 let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines)
221 if !trimmedCloneURL.isEmpty {
222 variables["cloneUrl"] = trimmedCloneURL
223 }
224
225 do {
226 let repository: RepositorySummary
227 switch service {
228 case .git:
229 let result = try await client.execute(
230 service: .git,
231 query: Self.createRepositoryMutation,
232 variables: variables,
233 responseType: CreateRepositoryResponse.self
234 )
235 repository = result.createRepository
236 case .hg:
237 variables.removeValue(forKey: "cloneUrl")
238 let result = try await client.execute(
239 service: .hg,
240 query: Self.createHgRepositoryMutation,
241 variables: variables,
242 responseType: CreateHGRepositoryResponse.self
243 )
244 repository = result.createRepository.repositorySummary(service: .hg)
245 }
246 repositories.insert(repository, at: 0)
247 insertIntoSearchIndex(repository)
248 return repository
249 } catch {
250 self.error = repositoryCreationErrorMessage(for: error)
251 return nil
252 }
253 }
254
255 private func repositoryCreationErrorMessage(for error: Error) -> String {
256 let message: String
257
258 if let srhtError = error as? SRHTError {
259 switch srhtError {
260 case .graphQLErrors(let errors):
261 message = errors.map(\.message).joined(separator: "\n")
262 default:
263 message = srhtError.localizedDescription
264 }
265 } else {
266 message = error.localizedDescription
267 }
268
269 return "Couldn’t create the repository. \(message)"
270 }
271
272 /// Fetch ALL repositories by paginating through all available pages.
273 /// Used for search functionality to ensure we search through the complete dataset.
274 private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
275 async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
276 async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
277 return try await gitRepositories + hgRepositories
278 }
279
280 /// Reset search state and reload all repositories
281 func resetSearch() {
282 repositories = []
283 cursor = nil
284 hasMore = false
285 isSearching = false
286 }
287
288 // MARK: - Private
289
290 /// Page shape matching the GraphQL response without generic constraints that
291 /// conflict with strict concurrency when used from a @MainActor context.
292 private struct Page: Decodable, Sendable {
293 let results: [RepositoryPayload]
294 let cursor: String?
295 }
296
297 private struct RepositoriesResponse: Decodable, Sendable {
298 let repositories: Page?
299 }
300
301 private struct CreateRepositoryResponse: Decodable, Sendable {
302 let createRepository: RepositorySummary
303 }
304
305 private struct CreateHGRepositoryResponse: Decodable, Sendable {
306 let createRepository: HGRepositoryPayload
307 }
308
309 private struct HGPage: Decodable, Sendable {
310 let results: [HGRepositoryPayload]
311 let cursor: String?
312 }
313
314 private struct HGRepositoriesResponse: Decodable, Sendable {
315 let repositories: HGPage?
316 }
317
318 private static let emptyPage = Page(results: [], cursor: nil)
319
320 private struct RepositoryPayload: Decodable, Sendable {
321 let id: Int
322 let rid: String
323 let name: String
324 let description: String?
325 let visibility: Visibility
326 let updated: Date
327 let owner: Entity
328 let head: Reference?
329
330 enum CodingKeys: String, CodingKey {
331 case id, rid, name, description, visibility, updated, owner
332 case head = "HEAD"
333 }
334
335 func repositorySummary(service: SRHTService) -> RepositorySummary {
336 RepositorySummary(
337 id: id,
338 rid: rid,
339 service: service,
340 name: name,
341 description: description,
342 visibility: visibility,
343 updated: updated,
344 owner: owner,
345 head: head
346 )
347 }
348 }
349
350 private struct HGRepositoryPayload: Decodable, Sendable {
351 let id: Int
352 let rid: String
353 let name: String
354 let description: String?
355 let visibility: Visibility
356 let updated: Date
357 let owner: Entity
358 let tip: HGTipReference?
359
360 func repositorySummary(service: SRHTService) -> RepositorySummary {
361 RepositorySummary(
362 id: id,
363 rid: rid,
364 service: service,
365 name: name,
366 description: description,
367 visibility: visibility,
368 updated: updated,
369 owner: owner,
370 head: tip.map { Reference(name: $0.branch, target: nil) }
371 )
372 }
373 }
374
375 private struct HGTipReference: Decodable, Sendable {
376 let branch: String
377 }
378
379 private var repositoriesForSearchIndex: [RepositorySummary] {
380 searchIndex
381 }
382
383 private func fetchPage(
384 service: SRHTService,
385 cursor: String?,
386 search: String? = nil,
387 useCache: Bool
388 ) async throws -> Page {
389 var variables: [String: any Sendable] = [:]
390 if let cursor {
391 variables["cursor"] = cursor
392 }
393 let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
394 if !trimmed.isEmpty {
395 variables["filter"] = ["search": trimmed] as [String: any Sendable]
396 }
397
398 if useCache && cursor == nil {
399 switch service {
400 case .git:
401 let result = try await client.executeAndCache(
402 service: service,
403 query: Self.gitQuery,
404 variables: variables.isEmpty ? nil : variables,
405 responseType: RepositoriesResponse.self,
406 cacheKey: cacheKey(for: service)
407 )
408 return result.repositories ?? Self.emptyPage
409 case .hg:
410 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
411 let result = try await client.executeAndCache(
412 service: service,
413 query: Self.hgQuery,
414 variables: hgVariables,
415 responseType: HGRepositoriesResponse.self,
416 cacheKey: cacheKey(for: service)
417 )
418 return Page(
419 results: result.repositories?.results.map {
420 RepositoryPayload(
421 id: $0.id,
422 rid: $0.rid,
423 name: $0.name,
424 description: $0.description,
425 visibility: $0.visibility,
426 updated: $0.updated,
427 owner: $0.owner,
428 head: $0.tip.map { Reference(name: $0.branch, target: nil) }
429 )
430 } ?? [],
431 cursor: result.repositories?.cursor
432 )
433 default:
434 let result = try await client.executeAndCache(
435 service: service,
436 query: Self.gitQuery,
437 variables: variables.isEmpty ? nil : variables,
438 responseType: RepositoriesResponse.self,
439 cacheKey: cacheKey(for: service)
440 )
441 return result.repositories ?? Self.emptyPage
442 }
443 } else {
444 switch service {
445 case .git:
446 let result = try await client.execute(
447 service: service,
448 query: Self.gitQuery,
449 variables: variables.isEmpty ? nil : variables,
450 responseType: RepositoriesResponse.self
451 )
452 return result.repositories ?? Self.emptyPage
453 case .hg:
454 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
455 let result = try await client.execute(
456 service: service,
457 query: Self.hgQuery,
458 variables: hgVariables,
459 responseType: HGRepositoriesResponse.self
460 )
461 return Page(
462 results: result.repositories?.results.map {
463 RepositoryPayload(
464 id: $0.id,
465 rid: $0.rid,
466 name: $0.name,
467 description: $0.description,
468 visibility: $0.visibility,
469 updated: $0.updated,
470 owner: $0.owner,
471 head: $0.tip.map { Reference(name: $0.branch, target: nil) }
472 )
473 } ?? [],
474 cursor: result.repositories?.cursor
475 )
476 default:
477 let result = try await client.execute(
478 service: service,
479 query: Self.gitQuery,
480 variables: variables.isEmpty ? nil : variables,
481 responseType: RepositoriesResponse.self
482 )
483 return result.repositories ?? Self.emptyPage
484 }
485 }
486 }
487
488 private func loadFromCache() {
489 let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
490 guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
491 let decoder = JSONDecoder()
492 decoder.dateDecodingStrategy = .srhtFlexible
493 switch service {
494 case .git:
495 if let response = try? decoder.decode(
496 GraphQLResponse<RepositoriesResponse>.self,
497 from: data
498 ), let repos = response.data?.repositories {
499 return repos.results.map { $0.repositorySummary(service: service) }
500 }
501 case .hg:
502 if let response = try? decoder.decode(
503 GraphQLResponse<HGRepositoriesResponse>.self,
504 from: data
505 ), let repos = response.data?.repositories {
506 return repos.results.map { $0.repositorySummary(service: service) }
507 }
508 default:
509 break
510 }
511 return []
512 }
513 if !cachedRepositories.isEmpty {
514 let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
515 repositories = sortedRepositories
516 updateSearchIndex(with: sortedRepositories)
517 }
518 }
519
520 private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
521 var allRepositories: [RepositorySummary] = []
522 var currentCursor: String? = nil
523
524 while true {
525 let page = try await fetchPage(
526 service: service,
527 cursor: currentCursor,
528 search: nil,
529 useCache: useCache && currentCursor == nil
530 )
531 allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
532 guard let nextCursor = page.cursor else { break }
533 currentCursor = nextCursor
534 }
535
536 return allRepositories
537 }
538
539 private func cacheKey(for service: SRHTService) -> String {
540 switch service {
541 case .git:
542 Self.gitCacheKey
543 case .hg:
544 Self.hgCacheKey
545 default:
546 "\(service.rawValue).repositories"
547 }
548 }
549
550 private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
551 if lhs.updated == rhs.updated {
552 if lhs.service == rhs.service {
553 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
554 }
555 return lhs.service.rawValue < rhs.service.rawValue
556 }
557 return lhs.updated > rhs.updated
558 }
559
560 private func updateSearchIndex(with repositories: [RepositorySummary]) {
561 searchIndex = repositories.sorted(by: repositorySortOrder)
562 hasLoadedSearchIndex = !searchIndex.isEmpty
563 }
564
565 private func insertIntoSearchIndex(_ repository: RepositorySummary) {
566 let updatedRepositories = (repositoriesForSearchIndex + [repository])
567 .uniqued(on: \.id)
568 .sorted(by: repositorySortOrder)
569 updateSearchIndex(with: updatedRepositories)
570 }
571
572 static func shouldRefreshSearchIndex(for query: String) -> Bool {
573 query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength
574 }
575
576 static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] {
577 let lowercasedQuery = query.lowercased()
578 return repositories.filter { repo in
579 repo.name.lowercased().contains(lowercasedQuery) ||
580 repo.description?.lowercased().contains(lowercasedQuery) ?? false
581 }
582 }
583}
584
585private extension Array {
586 func uniqued<ID: Hashable>(on keyPath: KeyPath<Element, ID>) -> [Element] {
587 var seenIDs: Set<ID> = []
588 return filter { element in
589 seenIDs.insert(element[keyPath: keyPath]).inserted
590 }
591 }
592}