Files
Pulse/Sources/PulseUI/Features/Search/ConsoleSearchViewModel.swift

486 lines
16 KiB
Swift

// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
import CoreData
import Combine
#if os(iOS) || os(macOS) || os(visionOS)
final class ConsoleSearchBarViewModel: ObservableObject {
@Published var text: String = ""
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
final class ConsoleSearchViewModel: ObservableObject, ConsoleSearchSessionDelegate {
@Published var isSearching: Bool = false {
didSet {
guard oldValue != isSearching else { return }
if !isSearching {
session?.cancel()
session = nil
searchBar.text = ""
// Don't reset filters, scopes, or options: filters are part of
// the main list state and persist across search sessions, and
// scopes/options are user preferences.
}
}
}
@Published var options: StringSearchOptions = .default
@Published var scopes: Set<ConsoleSearchScope> = []
@Published private(set) var savedDefaultScopes: Set<ConsoleSearchScope> = []
@Published var editingFilterState: EditingFilterState?
struct EditingFilterState: Identifiable {
var id: UUID { filter.id }
var filter: ConsoleCustomFilter
var token: ConsoleSearchToken
}
func applyEditedFilter(_ filter: ConsoleCustomFilter) {
guard let state = editingFilterState else { return }
applyToken(state.token, filter: filter)
}
private func applyToken(_ token: ConsoleSearchToken, filter: ConsoleCustomFilter?) {
searchBar.text = ""
var criteria = filters.criteria
token.apply(to: &criteria, filter: filter)
filters.criteria = criteria
}
@Published private(set) var results: [ConsoleSearchResultViewModel] = []
@Published private(set) var hasMore = false
@Published private(set) var newResultsCount = 0
var isNewResultsButtonShown: Bool { newResultsCount > 0 }
@Published private(set) var isSpinnerNeeded = false
@Published private(set) var isPerformingSearch = false
/// Matches from sessions outside the active selection. Populated after the
/// primary search exhausts; empty when the user is already searching every
/// session.
@Published private(set) var extendedResults: [ConsoleSearchResultViewModel] = []
@Published private(set) var hasMoreExtended = false
@Published private(set) var isPerformingExtendedSearch = false
var hasRecentSearches: Bool { !recents.searches.isEmpty }
let searchBar: ConsoleSearchBarViewModel
let filters: ConsoleFiltersViewModel
var toolbarTitle: String {
if parameters.isEmpty {
return "Search"
}
let base = "\(results.count)\(hasMore ? "+" : "") results"
return isPerformingSearch ? "\(base) (searching…)" : base
}
var parameters: ConsoleSearchParameters {
let searchTerm = searchBar.text.trimmingCharacters(in: .whitespaces)
var params = ConsoleSearchParameters()
if !searchTerm.isEmpty {
params.terms.append(.init(text: searchTerm, options: options))
}
params.scopes = Array(scopes)
return params
}
var allScopes: [ConsoleSearchScope] {
ConsoleSearchScope.allScopes(for: environment.mode)
}
var availableLogScopes: [ConsoleSearchScope] {
environment.mode.hasLogs ? ConsoleSearchScope.messageScopes : []
}
var availableNetworkScopes: [ConsoleSearchScope] {
environment.mode.hasNetwork ? ConsoleSearchScope.networkScopes : []
}
var defaultScopes: [ConsoleSearchScope] {
isDeepSearch ? allScopes : ConsoleSearchScope.defaultScopes(for: environment.mode)
}
private var session: ConsoleSearchSession?
/// True from the moment a session is created until its first batch (or
/// `finished` with no batches) lands. Lets us atomically replace the
/// previous session's results instead of appending to them.
private var isAwaitingFirstResults = false
private var recents: ConsoleSearchRecentSearchesStore { suggestionsService.recents }
private var suggestionsService: ConsoleNetworkSearchSuggestionsService!
@Published private(set) var suggestionsViewModel: ConsoleSearchSuggestionsViewModel!
let environment: ConsoleEnvironment
private let store: LoggerStoreProtocol
private let index: LoggerStoreIndex
private var cancellables: [AnyCancellable] = []
private var isDeepSearch = false
private var configuredMode: ConsoleMode?
init(
environment: ConsoleEnvironment,
searchBar: ConsoleSearchBarViewModel,
isDeepSearch: Bool = false
) {
self.environment = environment
self.store = environment.store
self.index = environment.index
self.searchBar = searchBar
self.filters = environment.filters
self.isDeepSearch = isDeepSearch
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)
searchBar.$text
.sink { [weak self] in self?.handleTab(in: $0) }
.store(in: &cancellables)
// Filter changes used to reach us via the list VM's `.refresh` event,
// which piggy-backed on the data source's FRC refetch. The session now
// owns its own fetch, so we observe filters directly and rebuild.
// `@Published` fires in `willSet`, so hop to the main queue before
// reading `filters.options` — otherwise `updateSession` would rebuild
// the predicate from the pre-toggle value.
filters.$options
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.didUpdateSearchCriteria()
}
.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())
if configuredMode != mode {
configuredMode = mode
if isDeepSearch {
self.scopes = Set(allScopes)
} else {
let loaded = ConsoleSearchScope.loadPersistedScopes(for: mode)
self.scopes = loaded
self.savedDefaultScopes = loaded
}
}
DispatchQueue.main.async {
self.refreshNow()
}
}
func resetScopesToDefault() {
scopes = Set(defaultScopes)
if let mode = configuredMode {
ConsoleSearchScope.clearPersistedScopes(for: mode)
savedDefaultScopes = scopes
}
}
func saveCurrentScopesAsDefault() {
guard !isDeepSearch, let mode = configuredMode else { return }
ConsoleSearchScope.savePersistedScopes(scopes, for: mode)
savedDefaultScopes = scopes
}
func setScopes(_ newScopes: Set<ConsoleSearchScope>) {
scopes = newScopes
}
// MARK: Search
private func didUpdateSearchCriteria() {
newResultsCount = 0
updateSession()
}
private func updateSession() {
session?.cancel()
session = nil
let params = parameters
guard !params.isEmpty else {
isPerformingSearch = false
results = []
hasMore = false
extendedResults = []
hasMoreExtended = false
isPerformingExtendedSearch = false
return
}
let mode = environment.mode
let primaryPredicate = ConsoleDataSource.makePredicate(mode: mode, options: filters.options)
var extendedPredicate: NSPredicate?
if !filters.options.sessions.isEmpty {
var other = filters.options
other.sessions = []
extendedPredicate = ConsoleDataSource.makePredicate(mode: mode, options: other)
}
let session = ConsoleSearchSession(
store: store,
mode: mode,
primaryPredicate: primaryPredicate,
extendedPredicate: extendedPredicate,
sortDescriptors: makeSortDescriptors(mode: mode),
parameters: params
)
session.delegate = self
if isDeepSearch { session.cutoff = 1000 }
isPerformingSearch = true
isAwaitingFirstResults = true
hasMore = false
extendedResults = []
hasMoreExtended = false
isPerformingExtendedSearch = false
self.session = session
session.start()
}
private func makeSortDescriptors(mode: ConsoleMode) -> [NSSortDescriptor] {
let options = environment.listOptions
let sortKey: String
switch mode {
case .all, .logs: sortKey = options.messageSortBy.key
case .network: sortKey = options.taskSortBy.key
}
return [NSSortDescriptor(key: sortKey, ascending: options.order == .ascending)]
}
// MARK: ConsoleSearchSessionDelegate
func searchSession(_ session: ConsoleSearchSession, didEmit events: [ConsoleSearchSession.Event]) {
guard session === self.session else { return }
for event in events {
apply(event, from: session)
}
}
private func apply(_ event: ConsoleSearchSession.Event, from session: ConsoleSearchSession) {
switch event {
case .results(let results):
let resolved = results.map(makeResultViewModel)
if isAwaitingFirstResults {
isAwaitingFirstResults = false
self.results = resolved
} else {
self.results += resolved
}
case .finished(let hasMore):
isPerformingSearch = false
if isAwaitingFirstResults {
// Finished without delivering anything: clear leftover
// results from the previous search.
isAwaitingFirstResults = false
self.results = []
}
self.hasMore = hasMore
if !hasMore, !isDeepSearch {
isPerformingExtendedSearch = true
session.startExtendedSearch()
}
case .extendedResults(let results):
self.extendedResults += results.map(makeResultViewModel)
case .extendedFinished(let hasMore):
isPerformingExtendedSearch = false
hasMoreExtended = hasMore
case .newMatches(let count):
withAnimation {
newResultsCount += count
}
case .removedMatches(let ids):
if !ids.isEmpty {
results.removeAll { ids.contains($0.entity.objectID) }
extendedResults.removeAll { ids.contains($0.entity.objectID) }
}
}
}
private func makeResultViewModel(_ result: ConsoleSearchSession.Result) -> ConsoleSearchResultViewModel {
ConsoleSearchResultViewModel(
entity: store.viewContext.object(with: result.objectID),
occurrences: result.occurrences
)
}
// MARK: Actions
func refreshNow() {
if newResultsCount > 0 {
withAnimation {
newResultsCount = 0
}
}
updateSession()
}
func perform(_ suggestion: ConsoleSearchSuggestion) {
switch suggestion.action {
case .applyTerm(let term):
searchBar.text = term.text
options = term.options
recents.saveSearch(term)
case .applyFilter(let filter):
applyFilter(filter)
}
updateSearchTokens()
}
private func applyFilter(_ filter: ConsoleSearchFilterSuggestion) {
applyToken(filter.token, filter: filter.makeCustomFilter())
}
func onSubmitSearch() {
let searchTerm = searchBar.text.trimmingCharacters(in: .whitespaces)
if !searchTerm.isEmpty {
recents.saveSearch(.init(text: searchTerm, options: options))
}
}
func buttonShowNewlyAddedSearchResultsTapped() {
refreshNow()
}
func searchInOtherSessions() {
filters.sessions = []
}
func buttonClearRecentSearchesTapped() {
recents.clearRecentSearches()
updateSearchTokens()
}
var recentSearches: [ConsoleSearchTerm] {
recents.searches
}
func removeRecentSearch(_ term: ConsoleSearchTerm) {
recents.removeSearch(term)
updateSearchTokens()
}
func didScroll(to result: ConsoleSearchResultViewModel, isExtended: Bool = false) {
if isExtended {
guard extendedResults.count > 3 && extendedResults[extendedResults.endIndex - 2].entity.objectID == result.entity.objectID else {
return
}
guard !isPerformingExtendedSearch && hasMoreExtended else {
return
}
isPerformingExtendedSearch = true
session?.loadMoreExtended()
} else {
guard results.count > 3 && results[results.endIndex - 2].entity.objectID == result.entity.objectID else {
return
}
guard !isPerformingSearch && hasMore else {
return
}
isPerformingSearch = true
session?.loadMore()
}
}
// MARK: Tab Completion
private func handleTab(in text: String) {
guard text.contains("\t") else { return }
DispatchQueue.main.async { // Fixes text not clearing
if let first = self.suggestionsViewModel?.filters.first {
self.perform(first)
}
}
}
// MARK: Suggested Tokens
private func updateSearchTokens() {
self.suggestionsViewModel = suggestionsService.makeSuggestions(for: makeContextForSuggestions())
}
private func makeContextForSuggestions() -> ConsoleSearchSuggestionsContext {
ConsoleSearchSuggestionsContext(
searchText: searchBar.text.trimmingCharacters(in: .whitespaces),
index: index,
parameters: parameters
)
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
extension ConsoleSearchViewModel: ConsoleSearchOptionsHost {}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
package struct ConsoleSearchResultViewModel: Identifiable {
package var id: ConsoleSearchResultKey { ConsoleSearchResultKey(id: entity.objectID) }
package let entity: NSManagedObject
package let occurrences: [ConsoleSearchOccurrence]
package init(entity: NSManagedObject, occurrences: [ConsoleSearchOccurrence]) {
self.entity = entity
self.occurrences = occurrences
}
}
package struct ConsoleSearchResultKey: Hashable {
package let id: NSManagedObjectID
}
package struct ConsoleSearchParameters {
package var scopes: [ConsoleSearchScope] = []
package var terms: [ConsoleSearchTerm] = []
package var isEmpty: Bool {
terms.isEmpty
}
package init(scopes: [ConsoleSearchScope] = [], terms: [ConsoleSearchTerm] = []) {
self.scopes = scopes
self.terms = terms
}
}
#endif
package enum ConsoleUpdateEvent {
/// Full refresh of data.
case refresh
/// Incremental update.
case update(CollectionDifference<NSManagedObjectID>?)
}