Files
Pulse/Sources/PulseUI/Features/Search/ConsoleSearchViewModel.swift
2024-07-14 14:27:22 -04:00

352 lines
11 KiB
Swift

// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
import CoreData
import Combine
protocol ConsoleEntitiesSource {
var events: PassthroughSubject<ConsoleUpdateEvent, Never> { get }
var entities: [NSManagedObject] { get }
}
#if os(iOS) || os(macOS) || os(visionOS)
final class ConsoleSearchBarViewModel: ObservableObject {
@Published var text: String = ""
}
@available(iOS 15, visionOS 1.0, *)
final class ConsoleSearchViewModel: ObservableObject, ConsoleSearchOperationDelegate {
@Published var options: StringSearchOptions = .default
@Published var scopes: Set<ConsoleSearchScope> = []
@Published private(set) var results: [ConsoleSearchResultViewModel] = []
@Published private(set) var hasMore = false
@Published private(set) var isNewResultsButtonShown = false
@Published private(set) var isSpinnerNeeded = false
@Published private(set) var isSearching = false
var hasRecentSearches: Bool { !recents.searches.isEmpty }
let searchBar: ConsoleSearchBarViewModel
var toolbarTitle: String {
if parameters.isEmpty {
return "Search"
} else {
return "\(results.count)\(hasMore ? "+" : "") results"
}
}
var parameters: ConsoleSearchParameters {
var term: ConsoleSearchTerm?
let searchTerm = searchBar.text.trimmingCharacters(in: .whitespaces)
if !searchTerm.isEmpty {
term = ConsoleSearchTerm(text: searchTerm, options: options)
}
return ConsoleSearchParameters(term: term, scopes: scopes)
}
var allScopes: [ConsoleSearchScope] {
(environment.mode.hasLogs ? ConsoleSearchScope.messageScopes : []) +
(environment.mode.hasNetwork ? ConsoleSearchScope.networkScopes : [])
}
private var dirtyDate: Date?
private var buffer: [ConsoleSearchResultViewModel] = []
private var operation: ConsoleSearchOperation?
private var refreshResultsOperation: ConsoleSearchOperation?
private let recents: ConsoleSearchRecentSearchesStore
@Published private(set) var suggestionsViewModel: ConsoleSearchSuggestionsViewModel!
private let source: ConsoleEntitiesSource
private let searchService = ConsoleSearchService()
private let environment: ConsoleEnvironment
private let store: LoggerStore
private let index: LoggerStoreIndex
private var cancellables: [AnyCancellable] = []
private let context: NSManagedObjectContext
init(environment: ConsoleEnvironment, source: ConsoleEntitiesSource, searchBar: ConsoleSearchBarViewModel) {
self.environment = environment
self.store = environment.store
self.index = environment.index
self.recents = ConsoleSearchRecentSearchesStore(mode: environment.mode)
self.source = source
self.searchBar = searchBar
self.context = store.newBackgroundContext()
self.scopes = Set(allScopes)
let text = searchBar.$text
.map { $0.trimmingCharacters(in: .whitespaces ) }
.removeDuplicates()
let didChangeSearchCriteria = Publishers.CombineLatest3(
text.removeDuplicates(),
$options.removeDuplicates(),
$scopes
)
.map { _, _, _ in }
.dropFirst()
.receive(on: DispatchQueue.main)
didChangeSearchCriteria
.throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] in
self?.didUpdateSearchCriteria()
}.store(in: &cancellables)
didChangeSearchCriteria.sink { [weak self] in
self?.updateSearchTokens()
}.store(in: &cancellables)
source.events.sink { [weak self] in
self?.didReceive($0)
}.store(in: &cancellables)
environment.$mode.sink { [weak self] in
self?.configure(mode: $0)
}.store(in: &cancellables)
}
private func configure(mode: ConsoleMode) {
self.suggestionsViewModel = getRecentSearches()
DispatchQueue.main.async {
self.refreshNow()
}
}
private func didReceive(_ event: ConsoleUpdateEvent) {
switch event {
case .refresh:
refreshNow()
case .update:
checkForNewSearchMatches(for: source.entities)
}
}
// MARK: Search
private func didUpdateSearchCriteria() {
isNewResultsButtonShown = false
startSearch(parameters: parameters)
}
private func startSearch(parameters: ConsoleSearchParameters) {
operation?.cancel()
operation = nil
hasMore = false
guard !parameters.isEmpty else {
isSearching = false
results = []
return
}
isSearching = true
buffer = []
// We want to continue showing old results for just a little bit longer
// to prevent screen from flickering. If the search is slow, we'll just
// remove the results eventually.
if !results.isEmpty {
dirtyDate = Date()
}
let operation = ConsoleSearchOperation(entities: source.entities, parameters: parameters, service: searchService, context: context)
operation.delegate = self
operation.resume()
self.operation = operation
}
// MARK: Refresh Results
private func didReloadEntities(for entities: [NSManagedObject]) {
checkForNewSearchMatches(for: entities)
}
private func checkForNewSearchMatches(for entities: [NSManagedObject]) {
guard operation == nil && refreshResultsOperation == nil else {
return // Let's wait until the next refresh
}
guard !isNewResultsButtonShown else {
return // We already know there are new results
}
guard !parameters.isEmpty else {
return
}
let operation = ConsoleSearchOperation(entities: entities, parameters: parameters, service: searchService, context: context)
operation.delegate = self
operation.resume()
self.refreshResultsOperation = operation
}
// MARK: ConsoleSearchOperationDelegate
func searchOperation(_ operation: ConsoleSearchOperation, didAddResults results: [ConsoleSearchResultViewModel]) {
if operation === self.operation {
if let dirtyDate = dirtyDate {
self.buffer += results
if Date().timeIntervalSince(dirtyDate) > 0.25 {
self.dirtyDate = nil
self.results = buffer
self.buffer = []
}
} else {
self.results += results
}
} else if operation === self.refreshResultsOperation {
// If the first element changed, that should be enough of the
// indicator that there are new search matches. We can assume
// that the messages are only ever inserted at the top and skip
// a ton of work.
if results.first?.entity.objectID !== self.results.first?.entity.objectID {
withAnimation {
self.isNewResultsButtonShown = true
}
}
self.refreshResultsOperation?.cancel()
self.refreshResultsOperation = nil
}
}
func searchOperationDidFinish(_ operation: ConsoleSearchOperation, hasMore: Bool) {
if operation === self.operation {
isSearching = false
if dirtyDate != nil {
self.dirtyDate = nil
self.results = buffer
}
self.hasMore = hasMore
} else if operation === self.refreshResultsOperation {
self.refreshResultsOperation = nil
}
}
// MARK: Actions
func refreshNow() {
if isNewResultsButtonShown {
withAnimation {
isNewResultsButtonShown = false
}
}
startSearch(parameters: parameters)
}
func perform(_ suggestion: ConsoleSearchSuggestion) {
switch suggestion.action {
case .apply(let token):
apply(token)
case .autocomplete(let text):
searchBar.text = text
}
updateSearchTokens()
}
private func apply(_ term: ConsoleSearchTerm) {
searchBar.text = term.text
options = term.options
recents.saveSearch(term)
}
func onSubmitSearch() {
let searchTerm = searchBar.text.trimmingCharacters(in: .whitespaces)
if !searchTerm.isEmpty {
recents.saveSearch(.init(text: searchTerm, options: options))
}
}
func buttonShowNewlyAddedSearchResultsTapped() {
refreshNow()
}
func buttonClearRecentSearchesTapped() {
recents.clearRecentSearches()
updateSearchTokens()
}
func didScroll(to result: ConsoleSearchResultViewModel) {
guard results.count > 3 && results[results.endIndex - 2].entity.objectID == result.entity.objectID else {
return
}
guard !isSearching && hasMore else {
return
}
isSearching = true
operation?.resume() // Load more
}
// MARK: Suggested Tokens
private func updateSearchTokens() {
let searchText = searchBar.text.trimmingCharacters(in: .whitespaces)
if searchText.isEmpty {
self.suggestionsViewModel = getRecentSearches()
} else {
self.suggestionsViewModel = ConsoleSearchSuggestionsViewModel(searches: [])
}
}
private func getRecentSearches() -> ConsoleSearchSuggestionsViewModel {
let recentSearches = recents.searches.prefix(3).map { term in
ConsoleSearchSuggestion(text: {
AttributedString("\(term.options.title) ") { $0.foregroundColor = .primary } +
AttributedString(term.text) { $0.foregroundColor = .accentColor }
}(), action: .apply(term))
}
return ConsoleSearchSuggestionsViewModel(searches: recentSearches)
}
}
@available(iOS 15, visionOS 1.0, *)
struct ConsoleSearchResultViewModel: Identifiable {
var id: ConsoleSearchResultKey { ConsoleSearchResultKey(id: entity.objectID) }
let entity: NSManagedObject
let occurrences: [ConsoleSearchOccurrence]
}
struct ConsoleSearchResultKey: Hashable {
let id: NSManagedObjectID
}
struct ConsoleSearchParameters: Equatable, Hashable {
var scopes: [ConsoleSearchScope] = []
var term: ConsoleSearchTerm?
init(term: ConsoleSearchTerm?, scopes: Set<ConsoleSearchScope>) {
self.term = term
self.scopes = Array(scopes)
}
var isEmpty: Bool {
term == nil
}
}
@available(iOS 15, visionOS 1.0, *)
struct ConsoleSearchSuggestionsViewModel {
let searches: [ConsoleSearchSuggestion]
}
@available(iOS 15, visionOS 1.0, *)
struct ConsoleSearchSuggestion: Identifiable {
let id = UUID()
let text: AttributedString
var action: Action
enum Action {
case apply(ConsoleSearchTerm)
case autocomplete(String)
}
}
#endif