summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/RepositoryListViewModel.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Repositories/RepositoryListViewModel.swift')
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift548
1 files changed, 548 insertions, 0 deletions
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
new file mode 100644
index 0000000..bba0108
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -0,0 +1,548 @@
+import Foundation
+
+enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
+ case git
+ case hg
+
+ var id: String { rawValue }
+
+ var service: SRHTService {
+ switch self {
+ case .git: .git
+ case .hg: .hg
+ }
+ }
+
+ var displayName: String {
+ switch self {
+ case .git: "Git"
+ case .hg: "Mercurial"
+ }
+ }
+}
+
+/// View model for the repository list screen.
+@Observable
+@MainActor
+final class RepositoryListViewModel {
+
+ private(set) var repositories: [RepositorySummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ var error: String?
+
+ var searchText = ""
+
+ private(set) var cursor: String?
+ private(set) var hasMore = false
+ private(set) var isSearching = false
+ private(set) var isCreatingRepository = false
+ private let client: SRHTClient
+
+ private static let gitCacheKey = "git.repositories"
+ private static let hgCacheKey = "hg.repositories"
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let gitQuery = """
+ query repositories($cursor: Cursor, $filter: Filter) {
+ repositories(cursor: $cursor, filter: $filter) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ HEAD { name }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let hgQuery = """
+ query repositories($cursor: Cursor) {
+ repositories(cursor: $cursor) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ tip { branch }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let createRepositoryMutation = """
+ mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {
+ createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ }
+ """
+
+ private static let createHgRepositoryMutation = """
+ mutation createRepository($name: String!, $visibility: Visibility!, $description: String) {
+ createRepository(name: $name, visibility: $visibility, description: $description) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ tip { branch }
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ /// Fetch the first page of repositories. Shows cached data instantly if available,
+ /// then refreshes from the network in the background.
+ /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
+ func loadRepositories(search: String? = nil) async {
+ let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
+ let isSearch = !query.isEmpty
+
+ // Only use cache for non-search, initial loads
+ if !isSearch, repositories.isEmpty {
+ loadFromCache()
+ }
+
+ // During search, never show the full-screen loading overlay (which
+ // would remove the List and dismiss the keyboard). Use "refreshing"
+ // instead so the list stays in the hierarchy.
+ if isSearch {
+ isRefreshing = true
+ isSearching = true
+ } else if repositories.isEmpty {
+ isLoading = true
+ isSearching = false
+ } else {
+ isRefreshing = true
+ isSearching = false
+ }
+ error = nil
+ cursor = nil
+ hasMore = false
+
+ do {
+ var filteredResults: [RepositorySummary]
+
+ if isSearch {
+ // For search queries, fetch all repositories from both services.
+ filteredResults = try await fetchAllRepositories()
+
+ // Perform client-side filtering
+ let lowercasedQuery = query.lowercased()
+ filteredResults = filteredResults.filter { repo in
+ repo.name.lowercased().contains(lowercasedQuery) ||
+ repo.description?.lowercased().contains(lowercasedQuery) ?? false
+ }
+ } else {
+ let repositories = try await fetchAllRepositories(useCache: true)
+ filteredResults = repositories
+ }
+
+ repositories = filteredResults.sorted(by: repositorySortOrder)
+ } catch {
+ // Only show error if we have no cached data to fall back on
+ if repositories.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ /// Load the next page if available. Called when the user scrolls near the end.
+ /// Note: Pagination is disabled during search (client-side filtering).
+ func loadMoreIfNeeded(currentItem: RepositorySummary) async {
+ _ = currentItem
+ }
+
+ /// Remove a repository from the local list (e.g. after deletion).
+ func removeRepository(id: Int) {
+ repositories.removeAll { $0.id == id }
+ }
+
+ func createRepository(
+ service: RepositoryCreationService,
+ name: String,
+ description: String,
+ visibility: Visibility,
+ cloneURL: String
+ ) async -> RepositorySummary? {
+ guard !isCreatingRepository else { return nil }
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else {
+ error = "Enter a repository name."
+ return nil
+ }
+
+ isCreatingRepository = true
+ error = nil
+ defer { isCreatingRepository = false }
+
+ var variables: [String: any Sendable] = [
+ "name": trimmedName,
+ "visibility": visibility.rawValue
+ ]
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedDescription.isEmpty {
+ variables["description"] = trimmedDescription
+ }
+ let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedCloneURL.isEmpty {
+ variables["cloneUrl"] = trimmedCloneURL
+ }
+
+ do {
+ let repository: RepositorySummary
+ switch service {
+ case .git:
+ let result = try await client.execute(
+ service: .git,
+ query: Self.createRepositoryMutation,
+ variables: variables,
+ responseType: CreateRepositoryResponse.self
+ )
+ repository = result.createRepository
+ case .hg:
+ variables.removeValue(forKey: "cloneUrl")
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.createHgRepositoryMutation,
+ variables: variables,
+ responseType: CreateHGRepositoryResponse.self
+ )
+ repository = result.createRepository.repositorySummary(service: .hg)
+ }
+ repositories.insert(repository, at: 0)
+ return repository
+ } catch {
+ self.error = repositoryCreationErrorMessage(for: error)
+ return nil
+ }
+ }
+
+ private func repositoryCreationErrorMessage(for error: Error) -> String {
+ let message: String
+
+ if let srhtError = error as? SRHTError {
+ switch srhtError {
+ case .graphQLErrors(let errors):
+ message = errors.map(\.message).joined(separator: "\n")
+ default:
+ message = srhtError.localizedDescription
+ }
+ } else {
+ message = error.localizedDescription
+ }
+
+ return "Couldn’t create the repository. \(message)"
+ }
+
+ /// Fetch ALL repositories by paginating through all available pages.
+ /// Used for search functionality to ensure we search through the complete dataset.
+ private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
+ async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
+ async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
+ return try await gitRepositories + hgRepositories
+ }
+
+ /// Reset search state and reload all repositories
+ func resetSearch() {
+ repositories = []
+ cursor = nil
+ hasMore = false
+ isSearching = false
+ }
+
+ // MARK: - Private
+
+ /// Page shape matching the GraphQL response without generic constraints that
+ /// conflict with strict concurrency when used from a @MainActor context.
+ private struct Page: Decodable, Sendable {
+ let results: [RepositoryPayload]
+ let cursor: String?
+ }
+
+ private struct RepositoriesResponse: Decodable, Sendable {
+ let repositories: Page?
+ }
+
+ private struct CreateRepositoryResponse: Decodable, Sendable {
+ let createRepository: RepositorySummary
+ }
+
+ private struct CreateHGRepositoryResponse: Decodable, Sendable {
+ let createRepository: HGRepositoryPayload
+ }
+
+ private struct HGPage: Decodable, Sendable {
+ let results: [HGRepositoryPayload]
+ let cursor: String?
+ }
+
+ private struct HGRepositoriesResponse: Decodable, Sendable {
+ let repositories: HGPage?
+ }
+
+ private static let emptyPage = Page(results: [], cursor: nil)
+
+ private struct RepositoryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let head: Reference?
+
+ enum CodingKeys: String, CodingKey {
+ case id, rid, name, description, visibility, updated, owner
+ case head = "HEAD"
+ }
+
+ func repositorySummary(service: SRHTService) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ updated: updated,
+ owner: owner,
+ head: head
+ )
+ }
+ }
+
+ private struct HGRepositoryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let tip: HGTipReference?
+
+ func repositorySummary(service: SRHTService) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ updated: updated,
+ owner: owner,
+ head: tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ }
+ }
+
+ private struct HGTipReference: Decodable, Sendable {
+ let branch: String
+ }
+
+ private func fetchPage(
+ service: SRHTService,
+ cursor: String?,
+ search: String? = nil,
+ useCache: Bool
+ ) async throws -> Page {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ variables["filter"] = ["search": trimmed] as [String: any Sendable]
+ }
+
+ if useCache && cursor == nil {
+ switch service {
+ case .git:
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return result.repositories ?? Self.emptyPage
+ case .hg:
+ let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.hgQuery,
+ variables: hgVariables,
+ responseType: HGRepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return Page(
+ results: result.repositories?.results.map {
+ RepositoryPayload(
+ id: $0.id,
+ rid: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ updated: $0.updated,
+ owner: $0.owner,
+ head: $0.tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ } ?? [],
+ cursor: result.repositories?.cursor
+ )
+ default:
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return result.repositories ?? Self.emptyPage
+ }
+ } else {
+ switch service {
+ case .git:
+ let result = try await client.execute(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self
+ )
+ return result.repositories ?? Self.emptyPage
+ case .hg:
+ let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
+ let result = try await client.execute(
+ service: service,
+ query: Self.hgQuery,
+ variables: hgVariables,
+ responseType: HGRepositoriesResponse.self
+ )
+ return Page(
+ results: result.repositories?.results.map {
+ RepositoryPayload(
+ id: $0.id,
+ rid: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ updated: $0.updated,
+ owner: $0.owner,
+ head: $0.tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ } ?? [],
+ cursor: result.repositories?.cursor
+ )
+ default:
+ let result = try await client.execute(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self
+ )
+ return result.repositories ?? Self.emptyPage
+ }
+ }
+ }
+
+ private func loadFromCache() {
+ let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
+ guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ switch service {
+ case .git:
+ if let response = try? decoder.decode(
+ GraphQLResponse<RepositoriesResponse>.self,
+ from: data
+ ), let repos = response.data?.repositories {
+ return repos.results.map { $0.repositorySummary(service: service) }
+ }
+ case .hg:
+ if let response = try? decoder.decode(
+ GraphQLResponse<HGRepositoriesResponse>.self,
+ from: data
+ ), let repos = response.data?.repositories {
+ return repos.results.map { $0.repositorySummary(service: service) }
+ }
+ default:
+ break
+ }
+ return []
+ }
+ if !cachedRepositories.isEmpty {
+ repositories = cachedRepositories.sorted(by: repositorySortOrder)
+ }
+ }
+
+ private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
+ var allRepositories: [RepositorySummary] = []
+ var currentCursor: String? = nil
+
+ while true {
+ let page = try await fetchPage(
+ service: service,
+ cursor: currentCursor,
+ search: nil,
+ useCache: useCache && currentCursor == nil
+ )
+ allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
+ guard let nextCursor = page.cursor else { break }
+ currentCursor = nextCursor
+ }
+
+ return allRepositories
+ }
+
+ private func cacheKey(for service: SRHTService) -> String {
+ switch service {
+ case .git:
+ Self.gitCacheKey
+ case .hg:
+ Self.hgCacheKey
+ default:
+ "\(service.rawValue).repositories"
+ }
+ }
+
+ private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
+ if lhs.updated == rhs.updated {
+ if lhs.service == rhs.service {
+ return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+ }
+ return lhs.service.rawValue < rhs.service.rawValue
+ }
+ return lhs.updated > rhs.updated
+ }
+}