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 static let searchHistoryScopeID = "repositories" private(set) var repositories: [RepositorySummary] = [] private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:] private(set) var recentSearches: [ScopedSearchHistoryEntry] 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(set) var hasLoadedSearchIndex = false private var searchIndex: [RepositorySummary] = [] private let client: SRHTClient private let defaults: UserDefaults private var buildStatusTask: Task? private var lastBuildStatusRefresh: Date? private static let gitCacheKey = "git.repositories" private static let hgCacheKey = "hg.repositories" private static let buildsCacheKey = "builds.repository-status" private static let minimumRemoteSearchLength = 3 init(client: SRHTClient, defaults: UserDefaults = .standard) { self.client = client self.defaults = defaults self.recentSearches = ScopedSearchHistoryStore.load( scopeID: Self.searchHistoryScopeID, defaults: defaults ) } // 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 } } } """ private static let buildsQuery = """ query jobs($cursor: Cursor) { jobs(cursor: $cursor) { results { id created status manifest } cursor } } """ // 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`. /// - Parameter forceRefresh: When true, bypass the build-status TTL (e.g. pull-to-refresh). func loadRepositories(search: String? = nil, forceRefresh: Bool = false) async { let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines) let isSearch = !query.isEmpty // Only use cache for non-search, initial loads if !isSearch, repositories.isEmpty { await 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 { if hasLoadedSearchIndex || repositories.isEmpty == false { filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query) } else if Self.shouldRefreshSearchIndex(for: query) { let repositories = try await fetchAllRepositories(useCache: true) updateSearchIndex(with: repositories) filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query) } else { filteredResults = [] } } else { // forceRefresh used to reach only the build statuses, so a pull to // refresh re-served the cached list and a deleted repository stayed // on screen. let repositories = try await fetchAllRepositories(useCache: !forceRefresh) updateSearchIndex(with: repositories) filteredResults = repositories } repositories = filteredResults.sorted(by: repositorySortOrder) scheduleBuildStatusRefresh(force: forceRefresh) } catch { // Only show error if we have no cached data to fall back on if repositories.isEmpty { self.error = error.userFacingMessage } } 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 updateRepository(_ repository: RepositorySummary) { if let index = repositories.firstIndex(where: { $0.id == repository.id }) { repositories[index] = repository repositories.sort(by: repositorySortOrder) } let updatedRepositories = repositoriesForSearchIndex .filter { $0.id != repository.id } + [repository] updateSearchIndex(with: updatedRepositories) } 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) } await client.invalidateCache(prefix: APICacheKeys.prefix(repository.service.rawValue, "repositories")) await client.invalidateCache(prefix: APICacheKeys.prefix("home")) repositories.insert(repository, at: 0) insertIntoSearchIndex(repository) scheduleBuildStatusRefresh() return repository } catch { self.error = repositoryCreationErrorMessage(for: error) return nil } } private func repositoryCreationErrorMessage(for error: Error) -> String { "Couldn’t create the repository. \(error.userFacingMessage)" } /// 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 } func recordRecentSearch(_ query: String) { ScopedSearchHistoryStore.record( query: query, scopeID: Self.searchHistoryScopeID, defaults: defaults ) recentSearches = ScopedSearchHistoryStore.load( scopeID: Self.searchHistoryScopeID, defaults: defaults ) } func clearRecentSearches() { ScopedSearchHistoryStore.clear( scopeID: Self.searchHistoryScopeID, defaults: defaults ) recentSearches = [] } // 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 BuildJobsResponse: Decodable, Sendable { let jobs: BuildJobsPage } private struct BuildJobsPage: Decodable, Sendable { let results: [BuildStatusPayload] let cursor: String? } private struct BuildStatusPayload: Decodable, Sendable { let id: Int let created: Date let status: JobStatus let manifest: String? } 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( fields: .init( 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( fields: .init( 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 var repositoriesForSearchIndex: [RepositorySummary] { searchIndex } func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus { latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none } 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 { if service == .hg { let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } let cached = try await client.executeCached( service: service, query: Self.hgQuery, variables: hgVariables, responseType: HGRepositoriesResponse.self, cacheKey: cacheKey(for: service), resourceType: .repositoryList, ttl: APICacheTTLs.repositoryList, policy: .cacheFirstThenRefresh ) let result = cached.value 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 ) } let cached = try await client.executeCached( service: service, query: Self.gitQuery, variables: variables.isEmpty ? nil : variables, responseType: RepositoriesResponse.self, cacheKey: cacheKey(for: service), resourceType: .repositoryList, ttl: APICacheTTLs.repositoryList, policy: .cacheFirstThenRefresh ) return cached.value.repositories ?? Self.emptyPage } else { if service == .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 ) } 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() async { var persistedRepositories: [RepositorySummary] = [] for service in [SRHTService.git, .hg] { if let data = await client.cachedPayload(forKey: cacheKey(for: service)) { persistedRepositories.append(contentsOf: Self.decodeCachedRepositories(data, service: service)) } } let cachedRepositories = persistedRepositories.isEmpty ? legacyCachedRepositories() : persistedRepositories if !cachedRepositories.isEmpty { let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) repositories = sortedRepositories updateSearchIndex(with: sortedRepositories) scheduleBuildStatusRefresh() } } private func legacyCachedRepositories() -> [RepositorySummary] { [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] } return Self.decodeCachedRepositories(data, service: service) } } private static func decodeCachedRepositories(_ data: Data, service: SRHTService) -> [RepositorySummary] { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .srhtFlexible switch service { case .git: if let response = try? decoder.decode( GraphQLResponse.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.self, from: data ), let repos = response.data?.repositories { return repos.results.map { $0.repositorySummary(service: service) } } default: break } return [] } private func scheduleBuildStatusRefresh(force: Bool = false) { // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh // passes force: true to bypass this check. if !force, let last = lastBuildStatusRefresh, Date().timeIntervalSince(last) < 120 { return } let repositoriesSnapshot = repositories buildStatusTask?.cancel() buildStatusTask = Task { [weak self] in guard let self else { return } await self.loadLatestBuildStatuses(for: repositoriesSnapshot) } } private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async { let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:))) guard !targetKeys.isEmpty else { await MainActor.run { latestBuildStatuses = [:] } return } var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:] var cursor: String? var shouldUseCache = true do { while !Task.isCancelled { let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache) shouldUseCache = false for job in page.results { let jobStatus = Self.repositoryBuildStatus(for: job.status) guard let manifest = job.manifest else { continue } for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) { let existing = resolvedStatuses[key] if existing == nil || existing!.0 < job.created { resolvedStatuses[key] = (job.created, jobStatus) } } } if resolvedStatuses.count == targetKeys.count || page.cursor == nil { break } cursor = page.cursor } let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none } await MainActor.run { guard repositories == self.repositories else { return } latestBuildStatuses = finalStatuses lastBuildStatusRefresh = Date() } } catch { // Build status is auxiliary data for the list. Leave the default gray state on failure. } } private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage { var variables: [String: any Sendable] = [:] if let cursor { variables["cursor"] = cursor } if useCache && cursor == nil { let cached = try await client.executeCached( service: .builds, query: Self.buildsQuery, variables: variables.isEmpty ? nil : variables, responseType: BuildJobsResponse.self, cacheKey: APICacheKeys.builds(cursor: cursor, filter: "repository-status"), resourceType: .buildList, ttl: APICacheTTLs.activeBuild, policy: .cacheFirstThenRefresh ) return cached.value.jobs } let result = try await client.execute( service: .builds, query: Self.buildsQuery, variables: variables.isEmpty ? nil : variables, responseType: BuildJobsResponse.self ) return result.jobs } 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: APICacheKeys.repositories(service: .git) case .hg: APICacheKeys.repositories(service: .hg) default: APICacheKeys.repositories(service: service) } } 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 } private func updateSearchIndex(with repositories: [RepositorySummary]) { searchIndex = repositories.sorted(by: repositorySortOrder) hasLoadedSearchIndex = !searchIndex.isEmpty } private func insertIntoSearchIndex(_ repository: RepositorySummary) { let updatedRepositories = (repositoriesForSearchIndex + [repository]) .uniqued(on: \.id) .sorted(by: repositorySortOrder) updateSearchIndex(with: updatedRepositories) } static func shouldRefreshSearchIndex(for query: String) -> Bool { query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength } static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] { let lowercasedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !lowercasedQuery.isEmpty else { return repositories } return repositories.filter { repo in repo.name.lowercased().contains(lowercasedQuery) || repo.owner.canonicalName.lowercased().contains(lowercasedQuery) || repo.defaultBranchName?.lowercased().contains(lowercasedQuery) ?? false || repo.description?.lowercased().contains(lowercasedQuery) ?? false } } nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String { buildStatusCacheKey( service: repository.service, ownerCanonicalName: repository.owner.canonicalName, repositoryName: repository.name ) } nonisolated static func buildStatusCacheKey( service: SRHTService, ownerCanonicalName: String, repositoryName: String ) -> String { "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())" } nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus { switch jobStatus { case .success: .success case .pending, .queued, .running: .running case .failed, .cancelled, .timeout: .failed } } nonisolated static func buildStatusKeys(in manifest: String) -> Set { let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] } let nsRange = NSRange(manifest.startIndex..()) { result, match in guard let serviceRange = Range(match.range(at: 1), in: manifest), let ownerRange = Range(match.range(at: 2), in: manifest), let nameRange = Range(match.range(at: 3), in: manifest) else { return } let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git let owner = String(manifest[ownerRange]).lowercased() var name = String(manifest[nameRange]).lowercased() if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { name.removeSubrange(suffixRange) } name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) if !name.isEmpty { result.insert(buildStatusCacheKey( service: service, ownerCanonicalName: owner, repositoryName: name )) } } } } private extension Array { func uniqued(on keyPath: KeyPath) -> [Element] { var seenIDs: Set = [] return filter { element in seenIDs.insert(element[keyPath: keyPath]).inserted } } }