Commit 00205e7daa
Unsigned
Layout: unified · split
Hutch/Views/Repositories/RepositoryListViewModel.swift +55 −11
| @@ -38,10 +38,13 @@ final class RepositoryListViewModel { | ||
| 38 | 38 | private(set) var hasMore = false |
| 39 | 39 | private(set) var isSearching = false |
| 40 | 40 | private(set) var isCreatingRepository = false |
| 41 | private(set) var hasLoadedSearchIndex = false | |
| 42 | private var searchIndex: [RepositorySummary] = [] | |
| 41 | 43 | private let client: SRHTClient |
| 42 | 44 | |
| 43 | 45 | private static let gitCacheKey = "git.repositories" |
| 44 | 46 | private static let hgCacheKey = "hg.repositories" |
| 47 | private static let minimumRemoteSearchLength = 3 | |
| 45 | 48 | |
| 46 | 49 | init(client: SRHTClient) { |
| 47 | 50 | self.client = client |
| @@ -147,22 +150,23 @@ final class RepositoryListViewModel { | ||
| 147 | 150 | |
| 148 | 151 | do { |
| 149 | 152 | var filteredResults: [RepositorySummary] |
| 150 | ||
| 153 | ||
| 151 | 154 | if isSearch { |
| 152 | // For search queries, fetch all repositories from both services. | |
| 153 | filteredResults = try await fetchAllRepositories() | |
| 154 | ||
| 155 | // Perform client-side filtering | |
| 156 | let lowercasedQuery = query.lowercased() | |
| 157 | filteredResults = filteredResults.filter { repo in | |
| 158 | repo.name.lowercased().contains(lowercasedQuery) || | |
| 159 | repo.description?.lowercased().contains(lowercasedQuery) ?? false | |
| 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 = [] | |
| 160 | 163 | } |
| 161 | 164 | } else { |
| 162 | 165 | let repositories = try await fetchAllRepositories(useCache: true) |
| 166 | updateSearchIndex(with: repositories) | |
| 163 | 167 | filteredResults = repositories |
| 164 | 168 | } |
| 165 | ||
| 169 | ||
| 166 | 170 | repositories = filteredResults.sorted(by: repositorySortOrder) |
| 167 | 171 | } catch { |
| 168 | 172 | // Only show error if we have no cached data to fall back on |
| @@ -240,6 +244,7 @@ final class RepositoryListViewModel { | ||
| 240 | 244 | repository = result.createRepository.repositorySummary(service: .hg) |
| 241 | 245 | } |
| 242 | 246 | repositories.insert(repository, at: 0) |
| 247 | insertIntoSearchIndex(repository) | |
| 243 | 248 | return repository |
| 244 | 249 | } catch { |
| 245 | 250 | self.error = repositoryCreationErrorMessage(for: error) |
| @@ -371,6 +376,10 @@ final class RepositoryListViewModel { | ||
| 371 | 376 | let branch: String |
| 372 | 377 | } |
| 373 | 378 | |
| 379 | private var repositoriesForSearchIndex: [RepositorySummary] { | |
| 380 | searchIndex | |
| 381 | } | |
| 382 | ||
| 374 | 383 | private func fetchPage( |
| 375 | 384 | service: SRHTService, |
| 376 | 385 | cursor: String?, |
| @@ -502,7 +511,9 @@ final class RepositoryListViewModel { | ||
| 502 | 511 | return [] |
| 503 | 512 | } |
| 504 | 513 | if !cachedRepositories.isEmpty { |
| 505 | repositories = cachedRepositories.sorted(by: repositorySortOrder) | |
| 514 | let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) | |
| 515 | repositories = sortedRepositories | |
| 516 | updateSearchIndex(with: sortedRepositories) | |
| 506 | 517 | } |
| 507 | 518 | } |
| 508 | 519 | |
| @@ -545,4 +556,37 @@ final class RepositoryListViewModel { | ||
| 545 | 556 | } |
| 546 | 557 | return lhs.updated > rhs.updated |
| 547 | 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 | ||
| 585 | private 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 | } | |
| 548 | 592 | } |
HutchTests/RepositoryListViewModelTests.swift added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct RepositoryListViewModelTests { | |
| 6 | ||
| 7 | @Test | |
| 8 | @MainActor | |
| 9 | func searchIndexRefreshRequiresMinimumQueryLength() { | |
| 10 | #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: "ab") == false) | |
| 11 | #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: "abc") == true) | |
| 12 | #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: " abc ") == true) | |
| 13 | } | |
| 14 | ||
| 15 | @Test | |
| 16 | @MainActor | |
| 17 | func filterRepositoriesMatchesNameAndDescriptionLocally() { | |
| 18 | let repositories = [ | |
| 19 | makeRepository(id: 1, service: .git, name: "Hutch", description: "SourceHut client"), | |
| 20 | makeRepository(id: 2, service: .hg, name: "Mail", description: "patch queue"), | |
| 21 | makeRepository(id: 3, service: .git, name: "Tree", description: nil) | |
| 22 | ] | |
| 23 | ||
| 24 | let nameMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "hut") | |
| 25 | let descriptionMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "patch") | |
| 26 | ||
| 27 | #expect(nameMatches.map(\.id) == [1]) | |
| 28 | #expect(descriptionMatches.map(\.id) == [2]) | |
| 29 | } | |
| 30 | ||
| 31 | @MainActor | |
| 32 | private func makeRepository( | |
| 33 | id: Int, | |
| 34 | service: SRHTService, | |
| 35 | name: String, | |
| 36 | description: String? | |
| 37 | ) -> RepositorySummary { | |
| 38 | RepositorySummary( | |
| 39 | id: id, | |
| 40 | rid: "rid-\(id)", | |
| 41 | service: service, | |
| 42 | name: name, | |
| 43 | description: description, | |
| 44 | visibility: .public, | |
| 45 | updated: Date(timeIntervalSince1970: TimeInterval(id)), | |
| 46 | owner: Entity(canonicalName: "~owner"), | |
| 47 | head: Reference(name: "main", target: nil) | |
| 48 | ) | |
| 49 | } | |
| 50 | } | |