Commit 00205e7daa
Unsigned
Layout: unified · split
Hutch/Views/Repositories/RepositoryListViewModel.swift +55 −11
| @@ -38,10 +38,13 @@ final class RepositoryListViewModel { | |||
| 38 | private(set) var hasMore = false | 38 | private(set) var hasMore = false |
| 39 | private(set) var isSearching = false | 39 | private(set) var isSearching = false |
| 40 | private(set) var isCreatingRepository = false | 40 | private(set) var isCreatingRepository = false |
| 41 | private(set) var hasLoadedSearchIndex = false | ||
| 42 | private var searchIndex: [RepositorySummary] = [] | ||
| 41 | private let client: SRHTClient | 43 | private let client: SRHTClient |
| 42 | 44 | ||
| 43 | private static let gitCacheKey = "git.repositories" | 45 | private static let gitCacheKey = "git.repositories" |
| 44 | private static let hgCacheKey = "hg.repositories" | 46 | private static let hgCacheKey = "hg.repositories" |
| 47 | private static let minimumRemoteSearchLength = 3 | ||
| 45 | 48 | ||
| 46 | init(client: SRHTClient) { | 49 | init(client: SRHTClient) { |
| 47 | self.client = client | 50 | self.client = client |
| @@ -147,22 +150,23 @@ final class RepositoryListViewModel { | |||
| 147 | 150 | ||
| 148 | do { | 151 | do { |
| 149 | var filteredResults: [RepositorySummary] | 152 | var filteredResults: [RepositorySummary] |
| 150 | 153 | ||
| 151 | if isSearch { | 154 | if isSearch { |
| 152 | // For search queries, fetch all repositories from both services. | 155 | if hasLoadedSearchIndex || repositories.isEmpty == false { |
| 153 | filteredResults = try await fetchAllRepositories() | 156 | filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query) |
| 154 | 157 | } else if Self.shouldRefreshSearchIndex(for: query) { | |
| 155 | // Perform client-side filtering | 158 | let repositories = try await fetchAllRepositories(useCache: true) |
| 156 | let lowercasedQuery = query.lowercased() | 159 | updateSearchIndex(with: repositories) |
| 157 | filteredResults = filteredResults.filter { repo in | 160 | filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query) |
| 158 | repo.name.lowercased().contains(lowercasedQuery) || | 161 | } else { |
| 159 | repo.description?.lowercased().contains(lowercasedQuery) ?? false | 162 | filteredResults = [] |
| 160 | } | 163 | } |
| 161 | } else { | 164 | } else { |
| 162 | let repositories = try await fetchAllRepositories(useCache: true) | 165 | let repositories = try await fetchAllRepositories(useCache: true) |
| 166 | updateSearchIndex(with: repositories) | ||
| 163 | filteredResults = repositories | 167 | filteredResults = repositories |
| 164 | } | 168 | } |
| 165 | 169 | ||
| 166 | repositories = filteredResults.sorted(by: repositorySortOrder) | 170 | repositories = filteredResults.sorted(by: repositorySortOrder) |
| 167 | } catch { | 171 | } catch { |
| 168 | // Only show error if we have no cached data to fall back on | 172 | // Only show error if we have no cached data to fall back on |
| @@ -240,6 +244,7 @@ final class RepositoryListViewModel { | |||
| 240 | repository = result.createRepository.repositorySummary(service: .hg) | 244 | repository = result.createRepository.repositorySummary(service: .hg) |
| 241 | } | 245 | } |
| 242 | repositories.insert(repository, at: 0) | 246 | repositories.insert(repository, at: 0) |
| 247 | insertIntoSearchIndex(repository) | ||
| 243 | return repository | 248 | return repository |
| 244 | } catch { | 249 | } catch { |
| 245 | self.error = repositoryCreationErrorMessage(for: error) | 250 | self.error = repositoryCreationErrorMessage(for: error) |
| @@ -371,6 +376,10 @@ final class RepositoryListViewModel { | |||
| 371 | let branch: String | 376 | let branch: String |
| 372 | } | 377 | } |
| 373 | 378 | ||
| 379 | private var repositoriesForSearchIndex: [RepositorySummary] { | ||
| 380 | searchIndex | ||
| 381 | } | ||
| 382 | |||
| 374 | private func fetchPage( | 383 | private func fetchPage( |
| 375 | service: SRHTService, | 384 | service: SRHTService, |
| 376 | cursor: String?, | 385 | cursor: String?, |
| @@ -502,7 +511,9 @@ final class RepositoryListViewModel { | |||
| 502 | return [] | 511 | return [] |
| 503 | } | 512 | } |
| 504 | if !cachedRepositories.isEmpty { | 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 | return lhs.updated > rhs.updated | 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 | } | ||