diff --git a/Sources/Pulse/LoggerStore/LoggerStore.swift b/Sources/Pulse/LoggerStore/LoggerStore.swift index f32f98ff..5b3b479d 100644 --- a/Sources/Pulse/LoggerStore/LoggerStore.swift +++ b/Sources/Pulse/LoggerStore/LoggerStore.swift @@ -817,9 +817,6 @@ extension LoggerStore { } } -#warning("add cancellation support") -#warning("check if priority for Task is OK") - /// Creates a copy of the current store at the given URL. The created copy /// has `.pulse` extension. /// @@ -833,7 +830,7 @@ extension LoggerStore { /// - returns: The information about the created store. @discardableResult public func export(to targetURL: URL, as documentType: DocumentType = .archive, options: ExportOptions = .init()) async throws -> Info { - try await Task.detached { + try await Task.detached(priority: .userInitiated) { try self._export(to: targetURL, as: documentType, options: options) }.value } diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleRouterView.swift b/Sources/PulseUI/Features/Console/Views/ConsoleRouterView.swift index f325ed14..cfee042d 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleRouterView.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleRouterView.swift @@ -109,7 +109,7 @@ extension ConsoleRouterView { private var destinationShareStore: some View { NavigationView { ShareStoreView(onDismiss: { router.isShowingShareStore = false }) - }.backport.presentationDetents([.medium]) + }.backport.presentationDetents([.medium, .large]) } } diff --git a/Sources/PulseUI/Features/Filters/Cells/ConsoleSearchLogLevelsCell.swift b/Sources/PulseUI/Features/Filters/Cells/ConsoleSearchLogLevelsCell.swift index dc3054b0..0cb0d0d5 100644 --- a/Sources/PulseUI/Features/Filters/Cells/ConsoleSearchLogLevelsCell.swift +++ b/Sources/PulseUI/Features/Filters/Cells/ConsoleSearchLogLevelsCell.swift @@ -6,16 +6,38 @@ import SwiftUI import Pulse struct ConsoleSearchLogLevelsCell: View { - @ObservedObject var viewModel: ConsoleSearchCriteriaViewModel + @Binding var selection: Set + + var isAllSelected: Bool { + selection.count == LoggerStore.Level.allCases.count + } + + func toggleSelectAll() { + if isAllSelected { + selection = [] + } else { + selection = Set(LoggerStore.Level.allCases) + } + } + + func binding(forLevel level: LoggerStore.Level) -> Binding { + Binding(get: { + self.selection.contains(level) + }, set: { isOn in + if isOn { + self.selection.insert(level) + } else { + self.selection.remove(level) + } + }) + } #if os(macOS) var body: some View { VStack(alignment: .leading, spacing: -16) { HStack { Spacer() - Button(viewModel.isAllLogLevelsEnabled ? "Deselect All" : "Select All") { - viewModel.isAllLogLevelsEnabled.toggle() - } + Button(isAllSelected ? "Deselect All" : "Select All", action: toggleSelectAll) } HStack(spacing: 24) { makeLevelsSection(levels: [.trace, .debug, .info]) @@ -30,17 +52,27 @@ struct ConsoleSearchLogLevelsCell: View { VStack(alignment: .leading) { Spacer() ForEach(levels, id: \.self) { level in - Toggle(level.name.capitalized, isOn: viewModel.binding(forLevel: level)) + Toggle(level.name.capitalized, isOn: binding(forLevel: level)) } } } #else var body: some View { - ForEach(LoggerStore.Level.allCases, id: \.self) { level in - Checkbox(level.name.capitalized, isOn: viewModel.binding(forLevel: level)) + Section { + ForEach(LoggerStore.Level.allCases, id: \.self) { level in + HStack { + Checkbox(level.name.capitalized, isOn: binding(forLevel: level)) + Circle() + .frame(width: 8, height: 8) + .foregroundColor(Color.textColor(for: level)) + } + } } - Button(viewModel.isAllLogLevelsEnabled ? "Deselect All" : "Select All") { - viewModel.isAllLogLevelsEnabled.toggle() + Section { + Button("Select Only Errors") { + selection = [.error, .critical] + } + Button(isAllSelected ? "Deselect All" : "Select All", action: toggleSelectAll) } } #endif diff --git a/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaView.swift b/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaView.swift index 101e23e8..5edcc906 100644 --- a/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaView.swift +++ b/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaView.swift @@ -115,7 +115,7 @@ extension ConsoleSearchCriteriaView { ConsoleSection(header: { ConsoleSectionHeader(icon: "flag", title: "Levels", filter: $viewModel.criteria.messages.logLevels) }, content: { - ConsoleSearchLogLevelsCell(viewModel: viewModel) + ConsoleSearchLogLevelsCell(selection: $viewModel.criteria.messages.logLevels.levels) }) } diff --git a/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaViewModel.swift b/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaViewModel.swift index 56e82112..27acbf07 100644 --- a/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaViewModel.swift +++ b/Sources/PulseUI/Features/Filters/ConsoleSearchCriteriaViewModel.swift @@ -98,33 +98,6 @@ final class ConsoleSearchCriteriaViewModel: ObservableObject { } } - // MARK: Binding (LogLevels) - - func binding(forLevel level: LoggerStore.Level) -> Binding { - Binding(get: { - self.criteria.messages.logLevels.levels.contains(level) - }, set: { isOn in - if isOn { - self.criteria.messages.logLevels.levels.insert(level) - } else { - self.criteria.messages.logLevels.levels.remove(level) - } - }) - } - - var isAllLogLevelsEnabled: Bool { - get { - criteria.messages.logLevels.levels.count == LoggerStore.Level.allCases.count - } - set { - if newValue { - criteria.messages.logLevels.levels = Set(LoggerStore.Level.allCases) - } else { - criteria.messages.logLevels.levels = [] - } - } - } - // MARK: Binding (Labels) var selectedLabels: Set { diff --git a/Sources/PulseUI/Features/Sessions/SessionListView.swift b/Sources/PulseUI/Features/Sessions/SessionListView.swift index 343ec40f..6d09f1e3 100644 --- a/Sources/PulseUI/Features/Sessions/SessionListView.swift +++ b/Sources/PulseUI/Features/Sessions/SessionListView.swift @@ -103,8 +103,11 @@ struct SessionListView: View { } } +#warning("fix this not working and not displaying data correctly") private func getFilteredSessions() -> [LoggerSessionEntity] { - sessions.filter { $0.formattedDate(isCompact: false).localizedCaseInsensitiveContains(filterTerm) } + sessions.filter { + $0.formattedDate.firstRange(of: filterTerm, options: [.caseInsensitive]) != nil + } } } diff --git a/Sources/PulseUI/Features/Settings/ConsoleSettings.swift b/Sources/PulseUI/Features/Settings/ConsoleSettings.swift index 833c9267..463394e9 100644 --- a/Sources/PulseUI/Features/Settings/ConsoleSettings.swift +++ b/Sources/PulseUI/Features/Settings/ConsoleSettings.swift @@ -17,9 +17,6 @@ final class ConsoleSettings: PersistentSettings { @UserDefault("link-detection") var isLinkDetectionEnabled = false - @UserDefaultRaw("sharing-level") - var sharingLevel: LoggerStore.Level = .trace - @UserDefaultRaw("sharing-output") var sharingOutput: ShareStoreOutput = .store diff --git a/Sources/PulseUI/Views/Checkbox.swift b/Sources/PulseUI/Views/Checkbox.swift index 9a4bffd0..ab64e47b 100644 --- a/Sources/PulseUI/Views/Checkbox.swift +++ b/Sources/PulseUI/Views/Checkbox.swift @@ -16,6 +16,7 @@ struct Checkbox: View { .font(.title3) .foregroundColor(isOn ? .blue : .separator) label() + .frame(maxWidth: .infinity, alignment: .leading) } .contentShape(Rectangle()) }.buttonStyle(.plain) diff --git a/Sources/PulseUI/Views/ShareStoreView.swift b/Sources/PulseUI/Views/ShareStoreView.swift index a09425cd..d8676074 100644 --- a/Sources/PulseUI/Views/ShareStoreView.swift +++ b/Sources/PulseUI/Views/ShareStoreView.swift @@ -9,7 +9,6 @@ import CoreData import Pulse import Combine -#warning("handle preselected sessions") struct ShareStoreView: View { /// Preselected sessions. var sessions: Set = [] @@ -19,7 +18,6 @@ struct ShareStoreView: View { @Environment(\.store) private var store: LoggerStore - #warning("test this on macos") #if os(macOS) let onShare: (ShareItems) -> Void #endif @@ -30,7 +28,9 @@ struct ShareStoreView: View { sectionShare } .onAppear { - if viewModel.sessions.isEmpty { + if !sessions.isEmpty { + viewModel.sessions = sessions + } else if viewModel.sessions.isEmpty { viewModel.sessions = [store.session.id] } viewModel.store = store @@ -38,43 +38,44 @@ struct ShareStoreView: View { .navigationTitle("Share Logs") #if os(iOS) .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(leading: leadingBarItems) + .navigationBarItems(leading: Button("Cancel", action: onDismiss)) #endif .sheet(item: $viewModel.shareItems) { ShareView($0).onCompletion(onDismiss) } #if os(macOS) + .onChange(of: viewModel.shareItems) { + onShare($0) + } .padding() #endif } - private var leadingBarItems: some View { - Button("Cancel", action: onDismiss) - } - + @ViewBuilder private var sectionSharingOptions: some View { Section { NavigationLink(destination: SessionPickerView(selection: $viewModel.sessions)) { - HStack { - Text("Sessions") - Spacer() - Text(viewModel.selectedSessionTitle) - .foregroundColor(.secondary) - } + InfoRow(title: "Sessions", details: viewModel.selectedSessionTitle) } - Picker("Minimum Log Level", selection: $viewModel.level) { - Text("Trace").tag(LoggerStore.Level.trace) - Text("Debug").tag(LoggerStore.Level.debug) - Text("Error").tag(LoggerStore.Level.error) + NavigationLink(destination: destinationLogLevels) { + InfoRow(title: "Log Levels", details: viewModel.selectedLevelsTitle) } + } + Section { Picker("Output Format", selection: $viewModel.output) { - Text("Pulse File").tag(ShareStoreOutput.store) + Text("Pulse").tag(ShareStoreOutput.store) Text("Plain Text").tag(ShareStoreOutput.text) Text("HTML").tag(ShareStoreOutput.html) } } } + private var destinationLogLevels: some View { + Form { + ConsoleSearchLogLevelsCell(selection: $viewModel.logLevels) + }.inlineNavigationTitle("Log Levels") + } + private var sectionShare: some View { Section { Button(action: { viewModel.buttonSharedTapped() }) { diff --git a/Sources/PulseUI/Views/ShareStoreViewModel.swift b/Sources/PulseUI/Views/ShareStoreViewModel.swift index 56ae0913..88417449 100644 --- a/Sources/PulseUI/Views/ShareStoreViewModel.swift +++ b/Sources/PulseUI/Views/ShareStoreViewModel.swift @@ -12,7 +12,7 @@ import Combine @MainActor final class ShareStoreViewModel: ObservableObject { // Sharing options @Published var sessions: Set = [] - @Published var level: LoggerStore.Level + @Published var logLevels = Set(LoggerStore.Level.allCases) @Published var output: ShareStoreOutput @Published private(set) var isPreparingForSharing = false @@ -22,7 +22,6 @@ import Combine var store: LoggerStore? init() { - level = ConsoleSettings.shared.sharingLevel output = ConsoleSettings.shared.sharingOutput } @@ -34,7 +33,6 @@ import Combine } private func saveSharingOptions() { - ConsoleSettings.shared.sharingLevel = level ConsoleSettings.shared.sharingOutput = output } @@ -47,7 +45,8 @@ import Combine Task { do { - self.shareItems = try await prepareForSharing(store: store, predicate: predicate, output: output) + let options = LoggerStore.ExportOptions(predicate: predicate, sessions: sessions) + self.shareItems = try await prepareForSharing(store: store, options: options) } catch { guard !(error is CancellationError) else { return } self.errorMessage = error.localizedDescription @@ -78,41 +77,56 @@ import Combine return try? store?.viewContext.fetch(request).first } -#warning("add sessions predicate") + var selectedLevelsTitle: String { + if logLevels.count == 1 { + return logLevels.first!.name.capitalized + } else if logLevels.count == 0 { + return "–" + } else if logLevels == [.error, .critical] { + return "Errors" + } else if logLevels == [.warning, .error, .critical] { + return "Warnings & Errors" + } else if logLevels.count == LoggerStore.Level.allCases.count { + return "All" + } else { + return "\(logLevels.count)" + } + } + private var predicate: NSPredicate? { var predicates: [NSPredicate] = [] - if level == .trace { - return nil + if logLevels != Set(LoggerStore.Level.allCases) { + predicates.append(.init(format: "level IN %@", logLevels.map(\.rawValue))) } - if level != .trace { - predicates.append(.init(format: "level >= %i", level.rawValue)) + if !sessions.isEmpty { + predicates.append(.init(format: "session IN %@", sessions)) } return NSCompoundPredicate(andPredicateWithSubpredicates: predicates) } - private func prepareForSharing(store: LoggerStore, predicate: NSPredicate?, output: ShareStoreOutput) async throws -> ShareItems { + private func prepareForSharing(store: LoggerStore, options: LoggerStore.ExportOptions) async throws -> ShareItems { switch output { case .store: - return try await prepareStoreForSharing(store: store, predicate: predicate) + return try await prepareStoreForSharing(store: store, options: options) case .text, .html: let output: ShareOutput = output == .text ? .plainText : .html - return try await prepareForSharing(store: store, output: output, predicate: predicate) + return try await prepareForSharing(store: store, output: output, options: options) } } - private func prepareStoreForSharing(store: LoggerStore, predicate: NSPredicate?) async throws -> ShareItems { + private func prepareStoreForSharing(store: LoggerStore, options: LoggerStore.ExportOptions) async throws -> ShareItems { let directory = TemporaryDirectory() let logsURL = directory.url.appendingPathComponent("logs-\(makeCurrentDate()).\(output.fileExtension)") - try await store.export(to: logsURL, options: .init(predicate: predicate)) + try await store.export(to: logsURL, options: options) return ShareItems([logsURL], cleanup: directory.remove) } - private func prepareForSharing(store: LoggerStore, output: ShareOutput, predicate: NSPredicate?) async throws -> ShareItems { + private func prepareForSharing(store: LoggerStore, output: ShareOutput, options: LoggerStore.ExportOptions) async throws -> ShareItems { let entities = try await withUnsafeThrowingContinuation { continuation in store.backgroundContext.perform { let request = NSFetchRequest(entityName: "\(LoggerMessageEntity.self)") - request.predicate = predicate + request.predicate = options.predicate // improtant: contains sessions let result = Result(catching: { try store.backgroundContext.fetch(request) }) continuation.resume(with: result) }