Hutch/Views/Repositories/RepositoryListViewModel.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/Views/Repositories/RepositoryListViewModel.swift history · blame · raw

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}