mirror of
https://github.com/kean/Pulse.git
synced 2026-05-30 21:07:33 +00:00
376 lines
12 KiB
Swift
376 lines
12 KiB
Swift
// The MIT License (MIT)
|
||
//
|
||
// Copyright (c) 2020–2023 Alexander Grebenyuk (github.com/kean).
|
||
|
||
#if os(iOS) || os(macOS)
|
||
|
||
import SwiftUI
|
||
import Pulse
|
||
import CoreData
|
||
import Combine
|
||
|
||
final class ConsoleSearchBarViewModel: ObservableObject {
|
||
@Published var text: String = ""
|
||
@Published var tokens: [ConsoleSearchToken] = []
|
||
}
|
||
|
||
@available(iOS 15, *)
|
||
final class ConsoleSearchViewModel: ObservableObject, ConsoleSearchOperationDelegate {
|
||
var isSearchActive: Bool = false {
|
||
didSet {
|
||
guard oldValue != isSearchActive else { return }
|
||
if !isSearchActive {
|
||
searchService.clearCache()
|
||
operation?.cancel()
|
||
operation = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
@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) results"
|
||
}
|
||
}
|
||
|
||
var parameters: ConsoleSearchParameters {
|
||
var tokens = searchBar.tokens
|
||
let searchTerm = searchBar.text.trimmingCharacters(in: .whitespaces)
|
||
if !searchTerm.isEmpty {
|
||
tokens.append(.term(.init(text: searchTerm, options: options)))
|
||
}
|
||
return ConsoleSearchParameters(tokens: tokens, scopes: scopes)
|
||
}
|
||
|
||
var allScopes: [ConsoleSearchScope] {
|
||
(list.mode.hasLogs ? ConsoleSearchScope.messageScopes : []) +
|
||
(list.mode.hasNetwork ? ConsoleSearchScope.networkScopes : [])
|
||
}
|
||
|
||
private var dirtyDate: Date?
|
||
private var buffer: [ConsoleSearchResultViewModel] = []
|
||
private var operation: ConsoleSearchOperation?
|
||
private var refreshResultsOperation: ConsoleSearchOperation?
|
||
|
||
private var recents: ConsoleSearchRecentSearchesStore { suggestionsService.recents }
|
||
private var suggestionsService: ConsoleNetworkSearchSuggestionsService!
|
||
@Published private(set) var suggestionsViewModel: ConsoleSearchSuggestionsViewModel!
|
||
|
||
private let list: ConsoleListViewModel
|
||
private let searchService = ConsoleSearchService()
|
||
|
||
private let store: LoggerStore
|
||
private let index: LoggerStoreIndex
|
||
private let queue = DispatchQueue(label: "com.github.pulse.console-search-view")
|
||
private var cancellables: [AnyCancellable] = []
|
||
private let context: NSManagedObjectContext
|
||
|
||
init(environment: ConsoleEnvironment, list: ConsoleListViewModel, searchBar: ConsoleSearchBarViewModel) {
|
||
self.store = environment.store
|
||
self.index = environment.index
|
||
self.list = list
|
||
self.searchBar = searchBar
|
||
|
||
self.context = store.newBackgroundContext()
|
||
|
||
#if os(iOS)
|
||
searchBar.$text.sink {
|
||
if $0.last == "\t" {
|
||
DispatchQueue.main.async {
|
||
self.autocompleteCurrentFilter()
|
||
}
|
||
}
|
||
}.store(in: &cancellables)
|
||
#endif
|
||
|
||
let text = searchBar.$text
|
||
.map { $0.trimmingCharacters(in: .whitespaces ) }
|
||
.removeDuplicates()
|
||
|
||
let didChangeSearchCriteria = Publishers.CombineLatest4(
|
||
text.removeDuplicates(),
|
||
searchBar.$tokens.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)
|
||
|
||
list.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.suggestionsService = ConsoleNetworkSearchSuggestionsService(mode: mode)
|
||
self.suggestionsViewModel = suggestionsService.makeSuggestions(for: makeContextForSuggestions())
|
||
DispatchQueue.main.async {
|
||
self.refreshNow()
|
||
}
|
||
}
|
||
|
||
private func didReceive(_ event: ConsoleListViewModel.Event) {
|
||
switch event {
|
||
case .refresh:
|
||
refreshNow()
|
||
case .update:
|
||
checkForNewSearchMatches(for: list.entities)
|
||
}
|
||
}
|
||
|
||
private func autocompleteCurrentFilter() {
|
||
if let filter = suggestionsViewModel.filters.first,
|
||
case .autocomplete(let text) = filter.action {
|
||
searchBar.text = text
|
||
}
|
||
}
|
||
|
||
// 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: list.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 isSearchActive else {
|
||
return // Off-screen
|
||
}
|
||
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 {
|
||
self.operation = nil
|
||
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(_ token: ConsoleSearchToken) {
|
||
switch token {
|
||
case .filter(let filter):
|
||
searchBar.text = ""
|
||
searchBar.tokens.append(token)
|
||
recents.saveFilter(filter)
|
||
case .term(let term):
|
||
searchBar.text = term.text
|
||
options = term.options
|
||
recents.saveSearch(term)
|
||
}
|
||
}
|
||
|
||
func onSubmitSearch() {
|
||
#if os(macOS)
|
||
if let suggestionID = UUID(uuidString: searchBar.text),
|
||
let suggestion = suggestionsViewModel.getSuggestion(withID: suggestionID) {
|
||
perform(suggestion)
|
||
return
|
||
}
|
||
#endif
|
||
let searchTerm = searchBar.text.trimmingCharacters(in: .whitespaces)
|
||
if !searchTerm.isEmpty {
|
||
recents.saveSearch(.init(text: searchTerm, options: options))
|
||
}
|
||
}
|
||
|
||
func buttonShowMoreResultsTapped() {
|
||
isSearching = true
|
||
operation?.resume()
|
||
}
|
||
|
||
func buttonShowNewlyAddedSearchResultsTapped() {
|
||
refreshNow()
|
||
}
|
||
|
||
func buttonClearRecentSearchesTapped() {
|
||
recents.clearRecentSearches()
|
||
updateSearchTokens()
|
||
}
|
||
|
||
// MARK: Suggested Tokens
|
||
|
||
private func updateSearchTokens() {
|
||
let context = makeContextForSuggestions()
|
||
queue.async {
|
||
let viewModel = self.suggestionsService.makeSuggestions(for: context)
|
||
DispatchQueue.main.async {
|
||
self.suggestionsViewModel = viewModel
|
||
}
|
||
}
|
||
}
|
||
|
||
private func makeContextForSuggestions() -> ConsoleSearchSuggestionsContext {
|
||
ConsoleSearchSuggestionsContext(
|
||
searchText: searchBar.text.trimmingCharacters(in: .whitespaces),
|
||
index: index,
|
||
parameters: parameters
|
||
)
|
||
}
|
||
}
|
||
|
||
@available(iOS 15, tvOS 15, *)
|
||
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 filters: [ConsoleSearchFilter] = []
|
||
var scopes: [ConsoleSearchScope] = []
|
||
var terms: [ConsoleSearchTerm] = []
|
||
|
||
init(tokens: [ConsoleSearchToken], scopes: Set<ConsoleSearchScope>) {
|
||
for token in tokens {
|
||
switch token {
|
||
case .filter(let filter): self.filters.append(filter)
|
||
case .term(let string): self.terms.append(string)
|
||
}
|
||
}
|
||
self.scopes = Array(scopes)
|
||
}
|
||
|
||
var isEmpty: Bool {
|
||
filters.isEmpty && terms.isEmpty
|
||
}
|
||
}
|
||
|
||
#endif
|