diff --git a/Pulse.xcodeproj/project.pbxproj b/Pulse.xcodeproj/project.pbxproj index 171bd138b..de3c33d7a 100644 --- a/Pulse.xcodeproj/project.pbxproj +++ b/Pulse.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ 0C7A0DFB297C33F300B4B69D /* ConsoleRouterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7A0DFA297C33F300B4B69D /* ConsoleRouterView.swift */; }; 0C7A0DFD297C382300B4B69D /* ConsoleShareButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7A0DFC297C382300B4B69D /* ConsoleShareButton.swift */; }; 0C7A0E00297C51CE00B4B69D /* ConsoleListOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7A0DFF297C51CE00B4B69D /* ConsoleListOptions.swift */; }; + 0C7A0E02297CE71400B4B69D /* FormattersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7A0E01297CE71400B4B69D /* FormattersTests.swift */; }; 0C7A834026D1CA290082634F /* repos.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CFF9BD825D6199A0069DB6A /* repos.json */; }; 0C9F04F92884F34A0035239F /* Pulse_Demo_macOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9F04F82884F34A0035239F /* Pulse_Demo_macOSApp.swift */; }; 0C9F04FD2884F34A0035239F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0C9F04FC2884F34A0035239F /* Assets.xcassets */; }; @@ -545,6 +546,7 @@ 0C7A0DFA297C33F300B4B69D /* ConsoleRouterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleRouterView.swift; sourceTree = ""; }; 0C7A0DFC297C382300B4B69D /* ConsoleShareButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleShareButton.swift; sourceTree = ""; }; 0C7A0DFF297C51CE00B4B69D /* ConsoleListOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleListOptions.swift; sourceTree = ""; }; + 0C7A0E01297CE71400B4B69D /* FormattersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattersTests.swift; sourceTree = ""; }; 0C9F04F62884F34A0035239F /* Pulse Demo macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pulse Demo macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 0C9F04F82884F34A0035239F /* Pulse_Demo_macOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pulse_Demo_macOSApp.swift; sourceTree = ""; }; 0C9F04FC2884F34A0035239F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -1483,6 +1485,7 @@ 0CAF1D78297B5FF4002E2722 /* ConsoleSearchServiceTests.swift */, 0CAF1D76297B0810002E2722 /* ConsoleSearchSuggestionTests.swift */, 0C63A389297A44B300F6A6A5 /* StringSearchOptionsTests.swift */, + 0C7A0E01297CE71400B4B69D /* FormattersTests.swift */, 0CF0D6C9296F18FD00EED9D4 /* MockTests.swift */, ); path = PulseUITests; @@ -2228,6 +2231,7 @@ files = ( 0C63A38A297A44B300F6A6A5 /* StringSearchOptionsTests.swift in Sources */, 0CAF1D77297B0810002E2722 /* ConsoleSearchSuggestionTests.swift in Sources */, + 0C7A0E02297CE71400B4B69D /* FormattersTests.swift in Sources */, 0CF0D6CB296F18FD00EED9D4 /* ConsoleViewModelTests.swift in Sources */, 0CB63A292975A94D00525165 /* ConsoleSearchTokenTests.swift in Sources */, 0CAF1D79297B5FF4002E2722 /* ConsoleSearchServiceTests.swift in Sources */, diff --git a/Sources/PulseUI/Features/Console/ConsoleView-ios.swift b/Sources/PulseUI/Features/Console/ConsoleView-ios.swift index 59e034c84..08c6917fa 100644 --- a/Sources/PulseUI/Features/Console/ConsoleView-ios.swift +++ b/Sources/PulseUI/Features/Console/ConsoleView-ios.swift @@ -123,7 +123,6 @@ private struct _ConsoleRegularContentView: View { footerView } - #warning("implement on other platforms and move to the list?") @ViewBuilder private var footerView: some View { if #available(iOS 15, *), viewModel.searchCriteriaViewModel.criteria.shared.dates == .session, viewModel.list.options.order == .descending { @@ -143,8 +142,13 @@ private struct _ConsoleRegularContentView: View { #if DEBUG struct ConsoleView_Previews: PreviewProvider { static var previews: some View { - NavigationView { - ConsoleView(viewModel: .init(store: .mock)) + Group { + NavigationView { + ConsoleView(viewModel: .init(store: .mock)) + }.previewDisplayName("Console") + NavigationView { + ConsoleView.network(store: .mock) + }.previewDisplayName("Network") } } } diff --git a/Sources/PulseUI/Features/Console/ConsoleView-macos.swift b/Sources/PulseUI/Features/Console/ConsoleView-macos.swift index b978f71dc..da5685564 100644 --- a/Sources/PulseUI/Features/Console/ConsoleView-macos.swift +++ b/Sources/PulseUI/Features/Console/ConsoleView-macos.swift @@ -104,7 +104,7 @@ private struct ConsoleToolbarItems: View { var body: some View { ConsoleSettingsButton(store: viewModel.store) Spacer() - ConsoleToolbarModePickerButton(viewModel: viewModel.searchCriteriaViewModel) + ConsoleToolbarModePickerButton(viewModel: viewModel) .keyboardShortcut("n", modifiers: [.command, .shift]) ConsoleToolbarToggleOnlyErrorsButton(viewModel: viewModel.searchCriteriaViewModel) .keyboardShortcut("e", modifiers: [.command, .shift]) @@ -146,13 +146,16 @@ private struct FilterPopoverToolbarButton: View { } private struct ConsoleToolbarModePickerButton: View { - @ObservedObject var viewModel: ConsoleSearchCriteriaViewModel + let viewModel: ConsoleViewModel + @State private var mode: ConsoleMode = .all var body: some View { - Button(action: { viewModel.isOnlyNetwork.toggle() }) { - Image(systemName: viewModel.isOnlyNetwork ? "arrow.down.circle.fill" : "arrow.down.circle") - .foregroundColor(viewModel.isOnlyNetwork ? Color.accentColor : Color.secondary) - }.help("Automatically Scroll to Recent Messages (⇧⌘N)") + Button(action: { mode = (mode == .tasks ? .all : .tasks) }) { + Image(systemName: mode == .tasks ? "arrow.down.circle.fill" : "arrow.down.circle") + .foregroundColor(mode == .tasks ? Color.accentColor : Color.secondary) + } + .help("Automatically Scroll to Recent Messages (⇧⌘N)") + .onChange(of: mode) { viewModel.mode = $0 } } } diff --git a/Sources/PulseUI/Features/Console/ConsoleView-tvos.swift b/Sources/PulseUI/Features/Console/ConsoleView-tvos.swift index 8a8df15ac..781cf5b5c 100644 --- a/Sources/PulseUI/Features/Console/ConsoleView-tvos.swift +++ b/Sources/PulseUI/Features/Console/ConsoleView-tvos.swift @@ -44,10 +44,12 @@ public struct ConsoleView: View { private struct ConsoleMenuView: View { let store: LoggerStore + let consoleViewModel: ConsoleViewModel @ObservedObject var viewModel: ConsoleSearchCriteriaViewModel @ObservedObject var router: ConsoleRouter init(viewModel: ConsoleViewModel) { + self.consoleViewModel = viewModel self.store = viewModel.store self.viewModel = viewModel.searchCriteriaViewModel self.router = viewModel.router @@ -58,11 +60,11 @@ private struct ConsoleMenuView: View { Toggle(isOn: $viewModel.isOnlyErrors) { Label("Errors Only", systemImage: "exclamationmark.octagon") } - Toggle(isOn: $viewModel.isOnlyNetwork) { + Toggle(isOn: consoleViewModel.bindingForNetworkMode) { Label("Network Only", systemImage: "arrow.down.circle") } NavigationLink(destination: destinationFilters) { - Label(viewModel.isOnlyNetwork ? "Network Filters" : "Message Filters", systemImage: "line.3.horizontal.decrease.circle") + Label(consoleViewModel.bindingForNetworkMode.wrappedValue ? "Network Filters" : "Message Filters", systemImage: "line.3.horizontal.decrease.circle") } } header: { Text("Quick Filters") } if !store.isArchive { diff --git a/Sources/PulseUI/Features/Console/ConsoleView-watchos.swift b/Sources/PulseUI/Features/Console/ConsoleView-watchos.swift index 116955d68..109b170db 100644 --- a/Sources/PulseUI/Features/Console/ConsoleView-watchos.swift +++ b/Sources/PulseUI/Features/Console/ConsoleView-watchos.swift @@ -38,20 +38,22 @@ public struct ConsoleView: View { } private struct ConsoleToolbarView: View { + var consoleViewModel: ConsoleViewModel @ObservedObject var viewModel: ConsoleSearchCriteriaViewModel @ObservedObject var router: ConsoleRouter init(viewModel: ConsoleViewModel) { + self.consoleViewModel = viewModel self.viewModel = viewModel.searchCriteriaViewModel self.router = viewModel.router } var body: some View { HStack { - Button(action: { viewModel.isOnlyNetwork.toggle() } ) { + Button(action: { consoleViewModel.bindingForNetworkMode.wrappedValue.toggle() } ) { Image(systemName: "arrow.down.circle") } - .background(viewModel.isOnlyNetwork ? Rectangle().foregroundColor(.blue).cornerRadius(8) : nil) + .background(consoleViewModel.bindingForNetworkMode.wrappedValue ? Rectangle().foregroundColor(.blue).cornerRadius(8) : nil) Button(action: { viewModel.isOnlyErrors.toggle() }) { Image(systemName: "exclamationmark.octagon") diff --git a/Sources/PulseUI/Features/Console/ConsoleViewModel.swift b/Sources/PulseUI/Features/Console/ConsoleViewModel.swift index e03047228..191dddcc2 100644 --- a/Sources/PulseUI/Features/Console/ConsoleViewModel.swift +++ b/Sources/PulseUI/Features/Console/ConsoleViewModel.swift @@ -9,7 +9,7 @@ import SwiftUI final class ConsoleViewModel: ObservableObject { let title: String - let isNetworkModeEnabled: Bool + let isNetwork: Bool let store: LoggerStore let list: ConsoleListViewModel @@ -36,6 +36,21 @@ final class ConsoleViewModel: ObservableObject { didSet { refreshListsVisibility() } } + var mode: ConsoleMode = .all { + didSet { + list.update(mode: mode) + searchCriteriaViewModel.mode = mode + } + } + + var bindingForNetworkMode: Binding { + Binding(get: { + self.mode == .tasks + }, set: { + self.mode = $0 ? .tasks : .all + }) + } + var onDismiss: (() -> Void)? private var cancellables: [AnyCancellable] = [] @@ -43,7 +58,7 @@ final class ConsoleViewModel: ObservableObject { init(store: LoggerStore, isOnlyNetwork: Bool = false) { self.title = isOnlyNetwork ? "Network" : "Console" self.store = store - self.isNetworkModeEnabled = isOnlyNetwork + self.isNetwork = isOnlyNetwork self.searchCriteriaViewModel = ConsoleSearchCriteriaViewModel(store: store) self.searchBarViewModel = ConsoleSearchBarViewModel() diff --git a/Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift b/Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift index 5fa96a132..52152e01f 100644 --- a/Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift +++ b/Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift @@ -39,7 +39,7 @@ struct ConsoleListContentView: View { } private func makeName(for section: NSFetchedResultsSectionInfo) -> String { - if !viewModel.isOnlyNetwork { + if viewModel.mode != .tasks { if viewModel.options.messageGroupBy == .level { let rawValue = Int16(Int(section.name) ?? 0) return (LoggerStore.Level(rawValue: rawValue) ?? .debug).name.capitalized diff --git a/Sources/PulseUI/Features/Console/List/ConsoleListViewModel.swift b/Sources/PulseUI/Features/Console/List/ConsoleListViewModel.swift index b94761d22..2cd958ba8 100644 --- a/Sources/PulseUI/Features/Console/List/ConsoleListViewModel.swift +++ b/Sources/PulseUI/Features/Console/List/ConsoleListViewModel.swift @@ -25,22 +25,29 @@ final class ConsoleListViewModel: NSObject, NSFetchedResultsControllerDelegate, } } + @Published private(set) var mode: ConsoleMode = .all + /// This exist strickly to workaround List performance issues private var scrollPosition: ScrollPosition = .nearTop private var visibleEntityCountLimit = fetchBatchSize private var visibleObjectIDs: Set = [] - private(set) var isOnlyNetwork = false - var grouping: ConsoleListGroupBy { isOnlyNetwork ? options.taskGroupBy : options.messageGroupBy } + var grouping: ConsoleListGroupBy { mode == .tasks ? options.taskGroupBy : options.messageGroupBy } private let store: LoggerStore private let searchCriteriaViewModel: ConsoleSearchCriteriaViewModel private var controller: NSFetchedResultsController? private var cancellables: [AnyCancellable] = [] + let logCountObserver: ManagedObjectsCountObserver + let taskCountObserver: ManagedObjectsCountObserver + init(store: LoggerStore, criteria: ConsoleSearchCriteriaViewModel) { self.store = store self.searchCriteriaViewModel = criteria + self.logCountObserver = ManagedObjectsCountObserver(entity: LoggerMessageEntity.self, context: store.viewContext, sortDescriptior: NSSortDescriptor(key: "createdAt", ascending: false)) + self.taskCountObserver = ManagedObjectsCountObserver(entity: NetworkTaskEntity.self, context: store.viewContext, sortDescriptior: NSSortDescriptor(key: "createdAt", ascending: false)) + super.init() $entities.sink { [entitiesSubject] in @@ -65,21 +72,24 @@ final class ConsoleListViewModel: NSObject, NSFetchedResultsControllerDelegate, .sink { [weak self] _ in self?.refresh() } .store(in: &cancellables) - // important: no drop first and refreshes immediately - searchCriteriaViewModel.$isOnlyNetwork.sink { [weak self] in - self?.isOnlyNetwork = $0 - self?.refreshController() - }.store(in: &cancellables) - $options.dropFirst().receive(on: DispatchQueue.main).sink { [weak self] _ in self?.refreshController() }.store(in: &cancellables) + + refreshController() } + func update(mode: ConsoleMode) { + self.mode = mode + self.refreshController() + } + + // MARK: - NSFetchedResultsController + func refreshController() { let request: NSFetchRequest - let sortKey = isOnlyNetwork ? options.taskSortBy.key : options.messageSortBy.key - if isOnlyNetwork { + let sortKey = mode == .tasks ? options.taskSortBy.key : options.messageSortBy.key + if mode == .tasks { request = .init(entityName: "\(NetworkTaskEntity.self)") request.sortDescriptors = [ grouping.key.map { NSSortDescriptor(key: $0, ascending: grouping.isAscending) }, @@ -94,29 +104,55 @@ final class ConsoleListViewModel: NSObject, NSFetchedResultsControllerDelegate, ].compactMap { $0 } } request.fetchBatchSize = fetchBatchSize - controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: store.viewContext, sectionNameKeyPath: grouping.key, cacheName: nil) + controller = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: store.viewContext, + sectionNameKeyPath: grouping.key, + cacheName: nil + ) controller?.delegate = self refresh() } func refresh() { - // Search messages guard let controller = controller else { return assertionFailure() } - let criteria = searchCriteriaViewModel - if isOnlyNetwork { - controller.fetchRequest.predicate = ConsoleSearchCriteria.makeNetworkPredicates(criteria: criteria.criteria, isOnlyErrors: criteria.isOnlyErrors, filterTerm: criteria.filterTerm) - } else { - controller.fetchRequest.predicate = ConsoleSearchCriteria.makeMessagePredicates(criteria: criteria.criteria, isOnlyErrors: criteria.isOnlyErrors, filterTerm: criteria.filterTerm) - } + controller.fetchRequest.predicate = makePredicate(for: mode) try? controller.performFetch() + logCountObserver.setPredicate(makePredicate(for: .logs)) + taskCountObserver.setPredicate(makePredicate(for: .tasks)) + reloadMessages() didRefresh.send(()) } + private func makePredicate(for mode: ConsoleMode) -> NSPredicate? { + let criteria = searchCriteriaViewModel + + func makeMessagesPredicate(isMessageOnly: Bool) -> NSPredicate? { + var predicates: [NSPredicate] = [] + if isMessageOnly { + predicates.append(NSPredicate(format: "task == NULL")) + } + if let predicate = ConsoleSearchCriteria.makeMessagePredicates(criteria: criteria.criteria, isOnlyErrors: criteria.isOnlyErrors, filterTerm: criteria.filterTerm) { + predicates.append(predicate) + } + return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + } + + switch mode { + case .all: + return makeMessagesPredicate(isMessageOnly: false) + case .logs: + return makeMessagesPredicate(isMessageOnly: true) + case .tasks: + return ConsoleSearchCriteria.makeNetworkPredicates(criteria: criteria.criteria, isOnlyErrors: criteria.isOnlyErrors, filterTerm: criteria.filterTerm) + } + } + // MARK: - NSFetchedResultsControllerDelegate func controller(_ controller: NSFetchedResultsController, didChangeContentWith diff: CollectionDifference) { @@ -186,3 +222,9 @@ final class ConsoleListViewModel: NSObject, NSFetchedResultsControllerDelegate, } private let fetchBatchSize = 100 + +enum ConsoleMode: String { + case all + case logs + case tasks +} diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleContextMenu-ios.swift b/Sources/PulseUI/Features/Console/Views/ConsoleContextMenu-ios.swift index a15b42d81..82d7c1d60 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleContextMenu-ios.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleContextMenu-ios.swift @@ -80,7 +80,7 @@ struct ConsoleContextMenu: View { @ViewBuilder private var sortByMenu: some View { Menu(content: { - if viewModel.searchCriteriaViewModel.isOnlyNetwork { + if viewModel.mode == .tasks { Picker("Sort By", selection: $listViewModel.options.taskSortBy) { ForEach(ConsoleListOptions.TaskSortBy.allCases, id: \.self) { Text($0.rawValue).tag($0) @@ -105,7 +105,7 @@ struct ConsoleContextMenu: View { @ViewBuilder private var groupByMenu: some View { Menu(content: { - if viewModel.searchCriteriaViewModel.isOnlyNetwork { + if viewModel.mode == .tasks { Picker("Group By", selection: $listViewModel.options.taskGroupBy) { ForEach(ConsoleListOptions.TaskGroupBy.allCases, id: \.self) { Text($0.rawValue).tag($0) diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleEntityCell.swift b/Sources/PulseUI/Features/Console/Views/ConsoleEntityCell.swift index 222de0903..e7090af6f 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleEntityCell.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleEntityCell.swift @@ -30,9 +30,15 @@ private struct _ConsoleMessageCell: View { @State private var shareItems: ShareItems? var body: some View { +#if os(iOS) + let cell = ConsoleMessageCell(viewModel: .init(message: message), isDisclosureNeeded: true) + .background(NavigationLink("", destination: LazyConsoleDetailsView(message: message).id(message.objectID)).opacity(0)) +#else let cell = NavigationLink(destination: LazyConsoleDetailsView(message: message).id(message.objectID)) { - ConsoleMessageCell(viewModel: .init(message: message)) + ConsoleMessageCell(viewModel: .init(message: message), isDisclosureNeeded: true) } +#endif + #if os(iOS) if #available(iOS 15, *) { cell.swipeActions(edge: .leading, allowsFullSwipe: true) { @@ -74,9 +80,15 @@ private struct _ConsoleTaskCell: View { @State private var shareItems: ShareItems? var body: some View { +#if os(iOS) + let cell = ConsoleTaskCell(viewModel: .init(task: task), isDisclosureNeeded: true) + .background(NavigationLink("", destination: LazyNetworkInspectorView(task: task).id(task.objectID)).opacity(0)) +#else let cell = NavigationLink(destination: LazyNetworkInspectorView(task: task).id(task.objectID)) { ConsoleTaskCell(viewModel: .init(task: task)) } +#endif + #if os(iOS) if #available(iOS 15, *) { cell.swipeActions(edge: .leading, allowsFullSwipe: true) { diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift b/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift index 2372a5f61..020208809 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift @@ -9,6 +9,7 @@ import Combine struct ConsoleMessageCell: View { let viewModel: ConsoleMessageCellViewModel + var isDisclosureNeeded = false var body: some View { let contents = VStack(alignment: .leading, spacing: 4) { @@ -22,11 +23,16 @@ struct ConsoleMessageCell: View { PinView(viewModel: viewModel.pinViewModel, font: ConsoleConstants.fontTitle) .frame(width: 4, height: 4) // don't affect layout #endif - Text(viewModel.time) - .lineLimit(1) - .font(ConsoleConstants.fontTitle) - .foregroundColor(titleColor) - .backport.monospacedDigit() + HStack(spacing: 3) { + Text(viewModel.time) + .lineLimit(1) + .font(ConsoleConstants.fontTitle) + .foregroundColor(titleColor) + .backport.monospacedDigit() + if isDisclosureNeeded { + ListDisclosureIndicator() + } + } } Text(viewModel.preprocessedText) .font(ConsoleConstants.fontBody) @@ -48,6 +54,17 @@ struct ConsoleMessageCell: View { } } +struct ListDisclosureIndicator: View { + var body: some View { + Image(systemName: "chevron.right") + .foregroundColor(.separator) + .lineLimit(1) + .font(ConsoleConstants.fontTitle) + .foregroundColor(.secondary) + .padding(.trailing, -12) + } +} + #if DEBUG struct ConsoleMessageCell_Previews: PreviewProvider { static var previews: some View { diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleTaskCell.swift b/Sources/PulseUI/Features/Console/Views/ConsoleTaskCell.swift index 007e484af..dd90ad406 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleTaskCell.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleTaskCell.swift @@ -10,14 +10,16 @@ import CoreData struct ConsoleTaskCell: View { @ObservedObject var viewModel: ConsoleTaskCellViewModel @ObservedObject var progressViewModel: ProgressViewModel + let isDisclosureNeeded: Bool - init(viewModel: ConsoleTaskCellViewModel) { + init(viewModel: ConsoleTaskCellViewModel, isDisclosureNeeded: Bool = false) { self.viewModel = viewModel self.progressViewModel = viewModel.progress + self.isDisclosureNeeded = isDisclosureNeeded } var body: some View { - let contents = VStack(alignment: .leading, spacing: 5) { + let contents = VStack(alignment: .leading, spacing: 6) { title message if viewModel.task.state == .pending { @@ -53,11 +55,18 @@ struct ConsoleTaskCell: View { .frame(width: 4, height: 4) // don't affect layout #endif #if !os(watchOS) - Text(viewModel.time) - .font(ConsoleConstants.fontTitle) - .foregroundColor(viewModel.task.state == .failure ? .red : .secondary) - .lineLimit(1) - .backport.monospacedDigit() + HStack(spacing: 3) { + Text(viewModel.time) + .font(ConsoleConstants.fontTitle) + .foregroundColor(viewModel.task.state == .failure ? .red : .secondary) + .lineLimit(1) + .backport.monospacedDigit() + if isDisclosureNeeded { + if isDisclosureNeeded { + ListDisclosureIndicator() + } + } + } #endif } } diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleToolbarView.swift b/Sources/PulseUI/Features/Console/Views/ConsoleToolbarView.swift index 32e64c24f..9c321a28e 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleToolbarView.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleToolbarView.swift @@ -14,20 +14,50 @@ struct ConsoleToolbarView: View { var body: some View { HStack(alignment: .bottom, spacing: 0) { - ConsoleToolbarTitle(viewModel: viewModel) + if viewModel.isNetwork { + ConsoleToolbarTitle(viewModel: viewModel) + } else { + ConsoleModePicker(viewModel: viewModel) + } Spacer() HStack(spacing: 14) { - ConsoleFiltersView( - isNetworkModeEnabled: viewModel.isNetworkModeEnabled, - viewModel: viewModel.searchCriteriaViewModel, - router: viewModel.router - ) + ConsoleFiltersView(viewModel: viewModel.searchCriteriaViewModel, router: viewModel.router) } } .buttonStyle(.plain) } } +private struct ConsoleModePicker: View { + let viewModel: ConsoleViewModel + @ObservedObject var logsCounter: ManagedObjectsCountObserver + @ObservedObject var tasksCounter: ManagedObjectsCountObserver + + @State private var mode: ConsoleMode = .all + @State private var title: String = "" + + init(viewModel: ConsoleViewModel) { + self.viewModel = viewModel + self.logsCounter = viewModel.list.logCountObserver + self.tasksCounter = viewModel.list.taskCountObserver + } + + var body: some View { + HStack(spacing: 12) { + ConsoleModeButton(title: "All", isSelected: mode == .all) { + viewModel.mode = .all + } + ConsoleModeButton(title: "Logs", details: "\(logsCounter.count)", isSelected: mode == .logs) { + viewModel.mode = .logs + } + ConsoleModeButton(title: "Tasks", details: "\(tasksCounter.count)", isSelected: mode == .tasks) { + viewModel.mode = .tasks + } + } + .onReceive(viewModel.list.$mode) { mode = $0 } + } +} + private struct ConsoleToolbarTitle: View { let viewModel: ConsoleViewModel @@ -41,26 +71,40 @@ private struct ConsoleToolbarTitle: View { } private var titlePublisher: some Publisher { - viewModel.list.$entities.combineLatest(viewModel.searchCriteriaViewModel.$isOnlyNetwork) - .map { entities, isOnlyNetwork in - "\(entities.count) \(isOnlyNetwork ? "Requests" : "Messages")" + viewModel.list.$entities.map { entities in + "\(entities.count) Requests" + } + } +} + +private struct ConsoleModeButton: View { + let title: String + var details: String? + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Text(title) + .foregroundColor(isSelected ? Color.blue : Color.secondary) + .font(.subheadline.weight(.medium)) + if let details = details { + Text("(\(details))") + .foregroundColor(Color.separator) + .font(.subheadline) + } } + } + .buttonStyle(.plain) } } struct ConsoleFiltersView: View { - let isNetworkModeEnabled: Bool @ObservedObject var viewModel: ConsoleSearchCriteriaViewModel @ObservedObject var router: ConsoleRouter var body: some View { - if !isNetworkModeEnabled { - Button(action: { viewModel.isOnlyNetwork.toggle() }) { - Image(systemName: viewModel.isOnlyNetwork ? "arrow.down.circle.fill" : "arrow.down.circle") - .font(.system(size: 20)) - .foregroundColor(.accentColor) - } - } Button(action: { viewModel.isOnlyErrors.toggle() }) { Image(systemName: viewModel.isOnlyErrors ? "exclamationmark.octagon.fill" : "exclamationmark.octagon") .font(.system(size: 20)) diff --git a/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaView.swift b/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaView.swift index cf6f2efd9..3651bb287 100644 --- a/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaView.swift +++ b/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaView.swift @@ -36,7 +36,7 @@ struct ConsoleSearchCriteriaView: View { buttonReset #elseif os(macOS) HStack { - Text(viewModel.isOnlyNetwork ? "Network Filters" : "Message Filters") + Text(viewModel.mode == .tasks ? "Network Filters" : "Message Filters") .font(.headline) Spacer() buttonReset @@ -47,7 +47,7 @@ struct ConsoleSearchCriteriaView: View { timePeriodSection generalSection - if viewModel.isOnlyNetwork { + if viewModel.mode == .tasks { #if os(iOS) || os(macOS) if #available(iOS 15, *) { customNetworkFiltersSection @@ -224,7 +224,7 @@ private func makePreview(isOnlyNetwork: Bool) -> some View { let entities: [NSManagedObject] = try! isOnlyNetwork ? store.allTasks() : store.allMessages() let viewModel = ConsoleSearchCriteriaViewModel(store: store) viewModel.bind(CurrentValueSubject(entities)) - viewModel.isOnlyNetwork = isOnlyNetwork + viewModel.mode = isOnlyNetwork ? .tasks : .all return ConsoleSearchCriteriaView(viewModel: viewModel) } #endif diff --git a/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaViewModel.swift b/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaViewModel.swift index 6448a7db1..31c740f01 100644 --- a/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaViewModel.swift +++ b/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaViewModel.swift @@ -11,9 +11,9 @@ final class ConsoleSearchCriteriaViewModel: ObservableObject { var isButtonResetEnabled: Bool { !isCriteriaDefault } @Published var isOnlyErrors = false - @Published var isOnlyNetwork = false @Published var filterTerm = "" // Legacy, used on non-iOS platforms @Published var criteria = ConsoleSearchCriteria() + @Published var mode: ConsoleMode = .all @Published private(set) var labels: [String] = [] @Published private(set) var domains: [String] = [] @@ -63,10 +63,10 @@ final class ConsoleSearchCriteriaViewModel: ObservableObject { var isCriteriaDefault: Bool { guard criteria.shared == defaultCriteria.shared else { return false } - if isOnlyNetwork { - return criteria.messages == defaultCriteria.messages - } else { + if mode == .tasks { return criteria.network == defaultCriteria.network + } else { + return criteria.messages == defaultCriteria.messages } } @@ -89,7 +89,7 @@ final class ConsoleSearchCriteriaViewModel: ObservableObject { } private func reloadCounters() { - if isOnlyNetwork { + if mode == .tasks { guard let tasks = entities as? [NetworkTaskEntity] else { return assertionFailure() } diff --git a/Sources/PulseUI/Features/Search/Views/ConsoleSearchSuggestionView.swift b/Sources/PulseUI/Features/Search/Views/ConsoleSearchSuggestionView.swift index 9d5417f29..ff67cbd5c 100644 --- a/Sources/PulseUI/Features/Search/Views/ConsoleSearchSuggestionView.swift +++ b/Sources/PulseUI/Features/Search/Views/ConsoleSearchSuggestionView.swift @@ -36,10 +36,20 @@ struct ConsoleSearchSuggestionView: View { .lineLimit(1) Spacer() if isActionable { - Text("\\t") - .foregroundColor(.separator) + ShortcutTooltip(title: "Tab") } } } } } + +struct ShortcutTooltip: View { + let title: String + + var body: some View { + Text(title) + .font(.caption) + .foregroundColor(.separator) + .background(Rectangle().frame(width: 34, height: 28).foregroundColor(Color.separator.opacity(0.2)).cornerRadius(8)) + } +} diff --git a/Sources/PulseUI/Features/Search/Views/ConsoleSearchToolbar.swift b/Sources/PulseUI/Features/Search/Views/ConsoleSearchToolbar.swift index cb990cbf6..28c664933 100644 --- a/Sources/PulseUI/Features/Search/Views/ConsoleSearchToolbar.swift +++ b/Sources/PulseUI/Features/Search/Views/ConsoleSearchToolbar.swift @@ -27,7 +27,7 @@ struct ConsoleSearchToolbar: View { Spacer() HStack(spacing: 14) { ConsoleSearchContextMenu(viewModel: viewModel.searchViewModel) - ConsoleFiltersView(isNetworkModeEnabled: viewModel.isNetworkModeEnabled, viewModel: viewModel.searchCriteriaViewModel, router: viewModel.router) + ConsoleFiltersView(viewModel: viewModel.searchCriteriaViewModel, router: viewModel.router) } } .buttonStyle(.plain) diff --git a/Sources/PulseUI/Helpers/Formatters.swift b/Sources/PulseUI/Helpers/Formatters.swift index 066cd2898..fc60f6a31 100644 --- a/Sources/PulseUI/Helpers/Formatters.swift +++ b/Sources/PulseUI/Helpers/Formatters.swift @@ -168,3 +168,18 @@ extension ByteCountFormatter { ByteCountFormatter.string(fromByteCount: count, countStyle: .file) } } + +enum CountFormatter { + private static let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 1 + return formatter + }() + + static func string(from count: Int) -> String { + if count < 1000 { return "\(count)" } + let number = NSNumber(floatLiteral: Double(count) / 1000.0) + return (numberFormatter.string(from: number) ?? "–") + "k" + } +} diff --git a/Sources/PulseUI/Helpers/ManagedObjectsObserver.swift b/Sources/PulseUI/Helpers/ManagedObjectsObserver.swift index ddf39f023..f1bb475ce 100644 --- a/Sources/PulseUI/Helpers/ManagedObjectsObserver.swift +++ b/Sources/PulseUI/Helpers/ManagedObjectsObserver.swift @@ -30,3 +30,37 @@ final class ManagedObjectsObserver: NSObject, ObservableObje self.objects = self.controller.fetchedObjects ?? [] } } + +final class ManagedObjectsCountObserver: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { + let controller: NSFetchedResultsController + + @Published private(set) var count = 0 + + init(entity: T.Type, context: NSManagedObjectContext, sortDescriptior: NSSortDescriptor) { + let request = NSFetchRequest(entityName: "\(T.self)") + request.fetchBatchSize = 1 + request.sortDescriptors = [sortDescriptior] + + self.controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil) + + super.init() + + self.controller.delegate = self + self.refresh() + } + + func setPredicate(_ predicate: NSPredicate?) { + controller.fetchRequest.predicate = predicate + refresh() + } + + func refresh() { + try? controller.performFetch() + self.count = controller.fetchedObjects?.count ?? 0 + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + self.count = controller.fetchedObjects?.count ?? 0 + } +} + diff --git a/Sources/PulseUI/PulseUI.docc/Documentation.md b/Sources/PulseUI/PulseUI.docc/Documentation.md index d5015964d..cb74615b3 100644 --- a/Sources/PulseUI/PulseUI.docc/Documentation.md +++ b/Sources/PulseUI/PulseUI.docc/Documentation.md @@ -20,6 +20,8 @@ The easiest way to integrate PulseUI is by using ``ConsoleView``. Alternatively, you can use native `UIHostingController` to present it in any `UIKit` context. +> tip: If you use Pulse to log only network requests, and not text messages, use `ConsoleView.network()` to show a view specialized to only display network requests. + ## Custom Views PulseUI gives you complete access to the underlying data and its model. You can easily create custom views into your log data by using affordances provided by SwiftUI: diff --git a/Tests/PulseUITests/FormattersTests.swift b/Tests/PulseUITests/FormattersTests.swift new file mode 100644 index 000000000..da8c6b77c --- /dev/null +++ b/Tests/PulseUITests/FormattersTests.swift @@ -0,0 +1,18 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020–2023 Alexander Grebenyuk (github.com/kean). + +import XCTest +@testable import Pulse +@testable import PulseUI + +final class FormattersTests: XCTestCase { + func testCountFormatter() throws { + XCTAssertEqual(CountFormatter.string(from: 10), "10") + XCTAssertEqual(CountFormatter.string(from: 999), "999") + XCTAssertEqual(CountFormatter.string(from: 1000), "1k") + XCTAssertEqual(CountFormatter.string(from: 1049), "1k") + XCTAssertEqual(CountFormatter.string(from: 1099), "1.1k") + XCTAssertEqual(CountFormatter.string(from: 1100), "1.1k") + } +}