mirror of
https://github.com/kean/Pulse.git
synced 2026-04-30 21:02:27 +00:00
Pulse 5.2 initial integration commit
This commit is contained in:
@@ -1,5 +1,86 @@
|
||||
# Pulse 5.x
|
||||
|
||||
## Pulse 5.2
|
||||
|
||||
*Apr 19, 2026*
|
||||
|
||||
This release introduces improvements to search, filters, session management, performance, and more. See them in action in the [recording](https://github.com/user-attachments/assets/778381e8-6c83-48e2-a601-a138100515c9).
|
||||
|
||||
### Search
|
||||
|
||||
- Rewrite the console search engine around a new `ConsoleSearchSession` that owns a private-queue Core Data context and runs fetching, matching, and live-update observation entirely off the main thread. Results are incremental, cancelable, and batched through a short flush interval, so the UI no longer hitches on large stores — tested end-to-end on stores with 1M+ messages
|
||||
- Preserve grouping while searching the console: search results are now rendered under the same section headers as the main list and share collapse state, so switching into and out of search no longer flattens the view
|
||||
- Bring the full search suggestions experience to PulseUI: the search bar now surfaces both recent searches and filter suggestions (domains, paths, status codes, methods) sourced from the store index, and tapping a suggested filter applies it directly to the active criteria. Previously this was only available in Rift, and PulseUI showed recent searches only
|
||||
- Add simplified search tokens so you can quickly filter by domain, path, status code, and HTTP method (work-in-progress)
|
||||
- Add a separate "Query" search scope so you can search URL query strings independently of the path
|
||||
- Merge "Original Request Headers" and "Current Request Headers" into a single "Request Headers" scope
|
||||
- Change the default network search scopes to include the response body alongside the URL — searching for a JSON value now works out of the box
|
||||
- Redesign the search scopes picker: it's now sectioned by Logs and Network, with per-section "All / None" toggles and a "Reset to Defaults" action
|
||||
- Scope changes within a search session are temporary. Use the new "Save as Default" action in the scopes picker to persist your preferred selection across app launches
|
||||
- Stop resetting scopes and string search options when the search sheet is dismissed
|
||||
- The scopes pill now shows a meaningful label ("All", "Logs", "Network", the scope title, …) instead of just a count
|
||||
- Extend `StringSearchOptions`: add a new "Multiple Words" kind that matches strings containing every space-separated word, and "Matching" / "Matching Word" matching rules for exact and word-boundary matches. Reword the existing rules for consistency ("Containing", "Starting With", "Ending With"). Applied uniformly across every search surface
|
||||
- Fix a bug where the URL scope silently stripped the query string before searching
|
||||
|
||||
### Filters
|
||||
|
||||
- Bring the advanced filters into the main console: every network and message predicate is now available
|
||||
- Add "Status Code", "Content Type", "Duration", "Request Body Size", and "Request State" to the built-in network filters, organised into Response, Request, and Advanced sections
|
||||
- Add "Path" to the "Field" options in custom network filters, backed by a new stored attribute on `NetworkTaskEntity`, and organize the field picker into groups with section headers for easier navigation
|
||||
- Add "Error Domain" and "Task Description" to the "Field" options in custom filters
|
||||
- Auto-save recent filters: the last-used filter combinations are now remembered and shown in a dedicated "Recent Filters" section, letting you re-apply previous criteria in one tap
|
||||
- Improve filters on watchOS: replace the Status Code range picker with a standard Picker offering predefined options (Any, Success 2xx, Redirects 3xx, Client Errors 4xx, Server Errors 5xx, All Errors), and remove Duration, Response Body Size, and Request Body Size filters that didn't fit the small screen. The available network filters on watchOS are now: Status Code, Content Type, HTTP Method, Custom Filters, Hosts, Task Type, Request State, Response Source, and Redirect. Message filters: Custom Filters, Levels, and Labels.
|
||||
|
||||
### Sessions
|
||||
|
||||
- Add a "Sessions" shortcut to the console toolbar for quick access to the sessions picker. When a non-current session is selected, the icon is highlighted as a reminder
|
||||
- Add a "Show Previous Session" footer to the console list: appears at the bottom of the list and loads the next older session on tap
|
||||
- Redesign session cells: session numbering, a "Current" badge for the active session, message counts, and relative timestamps for today's sessions (e.g. "5 min ago")
|
||||
- Add a context menu to session cells with a preview showing the session date, app version, and UUID
|
||||
- Add a "Remove Logs" swipe action and context-menu item for the current session that clears its logs without deleting the session itself
|
||||
- Move the Share swipe action to the leading edge and Delete to the trailing edge in the session list
|
||||
- Add "Select" mode to the session list. Each date-grouped section now shows a "Select All" / "Deselect All" button in Select mode, so you can grab every session for a given day in one tap. The selection count is shown in the navigation title and actions live on the bottom bar — which now also appears correctly in read-only stores
|
||||
- Bring the full sessions picker to watchOS and tvOS — previously these platforms only showed a truncated list
|
||||
|
||||
### Console list & toolbar
|
||||
|
||||
- Redesign the console heading: the navigation title is now inline and doubles as a menu for switching between "All", "Logs", and "Network", with a subtitle showing the current count (e.g. "21 Tasks"). While searching, the title reflects the search state
|
||||
- Add "Select" mode to the console list: enter via the context menu or a two-finger swipe gesture, pick multiple log entries, and share them using the toolbar at the bottom. Selection works in search mode too
|
||||
- Consolidate the regular and search toolbars into a single `ConsoleToolbarView`. Filters and the "Only Errors" toggle now live on the leading edge in both modes; the search scopes pill and string-options menu appear on the trailing edge only while searching
|
||||
- Add "Sort By" and "Group By" pills to the console toolbar (non-search mode). Sort By exposes the field and ascending/descending order; Group By is wired all the way through `ConsoleDataSource` so the list is now rendered as proper sections in PulseUI on iOS for the first time. Both menus remain available from the "More" menu
|
||||
- Add a quick-reset `xmark.circle` to the Filters, Sort By, and Group By pills: when non-default, tap to reset in one tap
|
||||
- Make grouped sections in the console list collapsible: tap a section header to toggle expansion, with a chevron indicating the current state. The group name is followed by the item count
|
||||
- Persist manually collapsed console sections across launches, scoped to the current `(mode, groupBy)`. "Collapse All" / "Expand All" stay in-memory only
|
||||
- Move "Filters" out of the inline navigation bar items and into the "More" menu
|
||||
- Unify all console toolbar pills (Filters, Only Errors, Sort By, Group By, search scopes/options) on a shared layout so they share the same height and visual style
|
||||
|
||||
### Customisation API (`ConsoleDelegate`)
|
||||
|
||||
- Add `ConsoleDelegate` protocol for per-task customization of `ConsoleView`. `ConsoleView.init` now accepts an optional `delegate` that can return a different `ConsoleListDisplaySettings` for each task. The protocol is `@MainActor` and uses `UserSettings.shared.listDisplayOptions` as the default
|
||||
- Add `ConsoleDelegate.console(contentViewFor:)` to replace the task cell's main content area (method + URL) with a caller-supplied SwiftUI view. When non-nil, it supersedes `ConsoleListDisplaySettings.ContentSettings` entirely; the header and footer still render from settings as usual
|
||||
- Add `ConsoleDelegate.console(responseBodyViewFor:)` to replace the built-in response body viewer with a caller-supplied SwiftUI view. Use it to plug in a protobuf decoder (via the integrator's own `SwiftProtobuf` types) or any other format the default `FileViewer` doesn't understand
|
||||
- Add `ConsoleDelegate.console(inspectorViewFor:)` to inject a custom SwiftUI section into the network inspector (e.g. decoded GraphQL variables, parsed protobuf). Rendered after the built-in response/metrics sections
|
||||
- Add `ConsoleDelegate.console(contextMenuFor:)` to inject app-specific items (e.g. "Replay", "Open in admin panel") into the task cell's context menu and into the network inspector's "More" menu
|
||||
- Add `ConsoleDelegate.console(redact:field:for:)` for masking sensitive strings (auth tokens, PII) before they are rendered. Applied to URL, host, header, task-description, and caller-supplied strings in the task cell as well as the inspector's navigation title. A new `ConsoleRedactionField` enum identifies which field is about to be rendered
|
||||
- Make `RichTextView` and `RichTextViewModel` public so integrators building custom `responseBodyViewFor:` views can reuse the standard text-viewer chrome (search, line numbers, link detection, share). Construct a `RichTextViewModel(string:contentType:)` from an `NSAttributedString` (optionally produced by the existing attributed-string helpers) and wrap it in `RichTextView`
|
||||
- Add `ConsoleListDisplaySettings.ContentSettings.customText` to render a caller-supplied string verbatim in place of the default method + URL content (e.g. a GraphQL operation name). When set, `showMethod` and `components` are ignored
|
||||
- Add `ConsoleListDisplaySettings.TaskField.url(components:)` to render the full URL, or a specific `URLComponent` when provided, in header/footer fields
|
||||
- Add `ConsoleListDisplaySettings.TaskField.custom(String)` to render a caller-supplied string verbatim in header/footer fields
|
||||
- Add `NetworkLogger.ContentType.isProtobuf` for matching `application/x-protobuf` and `application/grpc` content types
|
||||
|
||||
### Performance
|
||||
|
||||
- Optimize the console list rendering path: cache formatted timestamps on `LoggerMessageEntity` / `NetworkTaskEntity` so each visible cell no longer re-runs `DateFormatter`, switch the task cell to SwiftUI's `AttributedString` for header/footer text (measurably faster than bridging through `NSAttributedString`), and drop an expensive `.tracking()` modifier that was dominating cell layout time
|
||||
- Replace `ManagedObjectsCountObserver`'s `NSFetchedResultsController` with a direct `count(for:)` query driven by `NSManagedObjectContextObjectsDidChange`, so the toolbar log/task counters no longer materialize every matching object just to read `.count`
|
||||
- Optimize `LoggerStore.reduceDatabaseSize` — the new implementation is roughly 2× faster
|
||||
- Switch the SQLite journal mode from `DELETE` to WAL (Write-Ahead Logging), improving write performance and reducing contention between readers and writers
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix a crash when tapping "Remove All"
|
||||
- Fix a crash when saving to a Core Data store on devices with no disk space
|
||||
- Fix deprecation warnings
|
||||
|
||||
## Pulse 5.1.2
|
||||
|
||||
*Oct 10, 2024*
|
||||
|
||||
@@ -27,14 +27,20 @@
|
||||
0C8FCB622C45F06300C4FD84 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0C8FCB612C45F06300C4FD84 /* PulseUI */; };
|
||||
0C8FCB642C45F06900C4FD84 /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = 0C8FCB632C45F06900C4FD84 /* Pulse */; };
|
||||
0C8FCB662C45F06900C4FD84 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0C8FCB652C45F06900C4FD84 /* PulseUI */; };
|
||||
0C8FCB6A2C45F12900C4FD84 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB682C45F12900C4FD84 /* MockStore.swift */; };
|
||||
0C8FCB6E2C45F12900C4FD84 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB682C45F12900C4FD84 /* MockStore.swift */; };
|
||||
0C8FCB6F2C45F12900C4FD84 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB682C45F12900C4FD84 /* MockStore.swift */; };
|
||||
0C8FCB702C45F12900C4FD84 /* MockTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB692C45F12900C4FD84 /* MockTask.swift */; };
|
||||
0C8FCB742C45F12900C4FD84 /* MockTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB692C45F12900C4FD84 /* MockTask.swift */; };
|
||||
0C8FCB752C45F12900C4FD84 /* MockTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB692C45F12900C4FD84 /* MockTask.swift */; };
|
||||
0CA245732C85E87A00B432DA /* PulseProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 0CA245722C85E87A00B432DA /* PulseProxy */; };
|
||||
0CDACDE629EC6607007C15CD /* repos.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CDACDE129EC6607007C15CD /* repos.json */; };
|
||||
0CF7DEC72F9550AB007D44C2 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC42F9550AB007D44C2 /* MockStore.swift */; };
|
||||
0CF7DEC82F9550AB007D44C2 /* Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC52F9550AB007D44C2 /* Resources.swift */; };
|
||||
0CF7DEC92F9550AB007D44C2 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC22F9550AB007D44C2 /* Helpers.swift */; };
|
||||
0CF7DECA2F9550AB007D44C2 /* MockDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */; };
|
||||
0CF7DECC2F9550AB007D44C2 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC42F9550AB007D44C2 /* MockStore.swift */; };
|
||||
0CF7DECD2F9550AB007D44C2 /* Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC52F9550AB007D44C2 /* Resources.swift */; };
|
||||
0CF7DECE2F9550AB007D44C2 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC22F9550AB007D44C2 /* Helpers.swift */; };
|
||||
0CF7DECF2F9550AB007D44C2 /* MockDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */; };
|
||||
0CF7DED12F9550AB007D44C2 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC42F9550AB007D44C2 /* MockStore.swift */; };
|
||||
0CF7DED22F9550AB007D44C2 /* Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC52F9550AB007D44C2 /* Resources.swift */; };
|
||||
0CF7DED32F9550AB007D44C2 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC22F9550AB007D44C2 /* Helpers.swift */; };
|
||||
0CF7DED42F9550AB007D44C2 /* MockDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -98,9 +104,11 @@
|
||||
0C8FCB4C2C45F01E00C4FD84 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
|
||||
0C8FCB4D2C45F01E00C4FD84 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = CHANGELOG.md; path = ../CHANGELOG.md; sourceTree = "<group>"; };
|
||||
0C8FCB4E2C45F03500C4FD84 /* Pulse */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pulse; path = ..; sourceTree = "<group>"; };
|
||||
0C8FCB682C45F12900C4FD84 /* MockStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockStore.swift; sourceTree = "<group>"; };
|
||||
0C8FCB692C45F12900C4FD84 /* MockTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockTask.swift; sourceTree = "<group>"; };
|
||||
0CDACDE129EC6607007C15CD /* repos.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = repos.json; sourceTree = "<group>"; };
|
||||
0CF7DEC22F9550AB007D44C2 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = "<group>"; };
|
||||
0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataTask.swift; sourceTree = "<group>"; };
|
||||
0CF7DEC42F9550AB007D44C2 /* MockStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStore.swift; sourceTree = "<group>"; };
|
||||
0CF7DEC52F9550AB007D44C2 /* Resources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resources.swift; sourceTree = "<group>"; };
|
||||
49E82A8526D107A00070244F /* AlamofireIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlamofireIntegration.swift; sourceTree = "<group>"; };
|
||||
49E82A8826D1083D0070244F /* MoyaIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoyaIntegration.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -182,8 +190,8 @@
|
||||
0C70EA742A3F611B000B1071 /* iOS */,
|
||||
0C30B2F42A3F375400D65F8F /* iOS-paired */,
|
||||
0C6460D525F579B8002C55B1 /* tvOS */,
|
||||
0CF7DEC62F9550AB007D44C2 /* Shared */,
|
||||
0C0B31EB26D179A20045C9E1 /* Integrations */,
|
||||
0C8FCB672C45F0A800C4FD84 /* Shared */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
@@ -246,15 +254,6 @@
|
||||
path = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0C8FCB672C45F0A800C4FD84 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0C8FCB682C45F12900C4FD84 /* MockStore.swift */,
|
||||
0C8FCB692C45F12900C4FD84 /* MockTask.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0CDACDE029EC65F4007C15CD /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -263,6 +262,17 @@
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0CF7DEC62F9550AB007D44C2 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0CF7DEC22F9550AB007D44C2 /* Helpers.swift */,
|
||||
0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */,
|
||||
0CF7DEC42F9550AB007D44C2 /* MockStore.swift */,
|
||||
0CF7DEC52F9550AB007D44C2 /* Resources.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -415,11 +425,13 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0C0B31F126D179A20045C9E1 /* ViewController.swift in Sources */,
|
||||
0CF7DEC72F9550AB007D44C2 /* MockStore.swift in Sources */,
|
||||
0CF7DEC82F9550AB007D44C2 /* Resources.swift in Sources */,
|
||||
0CF7DEC92F9550AB007D44C2 /* Helpers.swift in Sources */,
|
||||
0CF7DECA2F9550AB007D44C2 /* MockDataTask.swift in Sources */,
|
||||
0C0B31ED26D179A20045C9E1 /* AppDelegate.swift in Sources */,
|
||||
0C0B31EF26D179A20045C9E1 /* SceneDelegate.swift in Sources */,
|
||||
0C57511A2B01BA40001074E5 /* DebugAnalyticsView.swift in Sources */,
|
||||
0C8FCB752C45F12900C4FD84 /* MockTask.swift in Sources */,
|
||||
0C8FCB6F2C45F12900C4FD84 /* MockStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -427,10 +439,12 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0CF7DED12F9550AB007D44C2 /* MockStore.swift in Sources */,
|
||||
0CF7DED22F9550AB007D44C2 /* Resources.swift in Sources */,
|
||||
0CF7DED32F9550AB007D44C2 /* Helpers.swift in Sources */,
|
||||
0CF7DED42F9550AB007D44C2 /* MockDataTask.swift in Sources */,
|
||||
0C6460DF25F579B8002C55B1 /* ContentView.swift in Sources */,
|
||||
0C6460DC25F579B8002C55B1 /* Pulse_tvOSApp.swift in Sources */,
|
||||
0C8FCB742C45F12900C4FD84 /* MockTask.swift in Sources */,
|
||||
0C8FCB6E2C45F12900C4FD84 /* MockStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -438,9 +452,11 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0CF7DECC2F9550AB007D44C2 /* MockStore.swift in Sources */,
|
||||
0CF7DECD2F9550AB007D44C2 /* Resources.swift in Sources */,
|
||||
0CF7DECE2F9550AB007D44C2 /* Helpers.swift in Sources */,
|
||||
0CF7DECF2F9550AB007D44C2 /* MockDataTask.swift in Sources */,
|
||||
0C70EA762A3F611B000B1071 /* Pulse_Demo_iOSApp.swift in Sources */,
|
||||
0C8FCB702C45F12900C4FD84 /* MockTask.swift in Sources */,
|
||||
0C8FCB6A2C45F12900C4FD84 /* MockStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
|
||||
public struct TestTemporaryDirectory {
|
||||
public let url: URL
|
||||
|
||||
public static var isFirstRun = true
|
||||
|
||||
public init() {
|
||||
let rootTempURL = Files.temporaryDirectory
|
||||
.appending(directory: "com.github.kean.logger-testing")
|
||||
|
||||
if TestTemporaryDirectory.isFirstRun {
|
||||
TestTemporaryDirectory.isFirstRun = false
|
||||
try? Files.removeItem(at: rootTempURL)
|
||||
}
|
||||
|
||||
url = rootTempURL.appending(directory: UUID().uuidString)
|
||||
try? Files.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
|
||||
public func remove() {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func benchmark<T>(title: String, operation: () throws -> T) rethrows -> T {
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
let value = try operation()
|
||||
let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
|
||||
print("Time elapsed for \(title): \(timeElapsed * 1000.0) ms.")
|
||||
return value
|
||||
}
|
||||
|
||||
public func benchmarkStart() -> CFAbsoluteTime {
|
||||
CFAbsoluteTimeGetCurrent()
|
||||
}
|
||||
|
||||
public func benchmarkEnd(_ startTime: CFAbsoluteTime, title: String) {
|
||||
let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
|
||||
print("Time elapsed for \(title): \(timeElapsed * 1000.0) ms.")
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
../../../Sources/PulseUI/Mocks/MockStore.swift
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Pulse
|
||||
|
||||
extension LoggerStore {
|
||||
package static let test: LoggerStore = {
|
||||
let store = try! LoggerStore(
|
||||
storeURL: URL(fileURLWithPath: "/dev/null/\(UUID().uuidString)"),
|
||||
options: [.create, .inMemory, .synchronous]
|
||||
)
|
||||
_populate(store: store)
|
||||
return store
|
||||
}()
|
||||
}
|
||||
|
||||
private struct Logger {
|
||||
let label: String
|
||||
let store: LoggerStore
|
||||
|
||||
func log(level: LoggerStore.Level, _ message: String, metadata: LoggerStore.Metadata? = nil) {
|
||||
self.store.storeMessage(label: label, level: level, message: message, metadata: metadata, file: #file, function: #function, line: 0)
|
||||
}
|
||||
}
|
||||
|
||||
public func _populate(store: LoggerStore) {
|
||||
func logger(named: String) -> Logger {
|
||||
Logger(label: named, store: store)
|
||||
}
|
||||
|
||||
logger(named: "application")
|
||||
.log(level: .info, "UIApplication.didFinishLaunching")
|
||||
|
||||
logger(named: "application")
|
||||
.log(level: .info, "UIApplication.willEnterForeground")
|
||||
|
||||
logger(named: "auth")
|
||||
.log(level: .trace, "Instantiated Session")
|
||||
|
||||
logger(named: "auth")
|
||||
.log(level: .trace, "Instantiated the new login request")
|
||||
|
||||
let networkLogger = NetworkLogger(store: store)
|
||||
|
||||
networkLogger.logTask(MockDataTask.login)
|
||||
|
||||
logger(named: "analytics")
|
||||
.log(level: .debug, "Will navigate to Dashboard")
|
||||
|
||||
networkLogger.logTask(MockDataTask.octocat)
|
||||
|
||||
networkLogger.logTask(MockDataTask.profileFailure)
|
||||
|
||||
let stackTrace = """
|
||||
Replace this implementation with code to handle the error appropriately. fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
2015-12-08 15:04:03.888 Conversion[76776:4410388] call stack:
|
||||
(
|
||||
0 Conversion 0x000694b5 -[ViewController viewDidLoad] + 128
|
||||
1 UIKit 0x27259f55 <redacted> + 1028
|
||||
...
|
||||
9 UIKit 0x274f67a7 <redacted> + 134
|
||||
10 FrontBoardServices 0x2b358ca5 <redacted> + 232
|
||||
11 FrontBoardServices 0x2b358f91 <redacted> + 44
|
||||
12 CoreFoundation 0x230e87c7 <redacted> + 14
|
||||
...
|
||||
16 CoreFoundation 0x23038ecd CFRunLoopRunInMode + 108
|
||||
17 UIKit 0x272c7607 <redacted> + 526
|
||||
18 UIKit 0x272c22dd UIApplicationMain + 144
|
||||
19 Conversion 0x000767b5 main + 108
|
||||
20 libdyld.dylib 0x34f34873 <redacted> + 2
|
||||
)
|
||||
"""
|
||||
|
||||
logger(named: "auth")
|
||||
.log(level: .warning, .init(stringLiteral: stackTrace))
|
||||
|
||||
logger(named: "default")
|
||||
.log(level: .critical, "💥 0xDEADBEEF")
|
||||
}
|
||||
|
||||
private func makeMockTask(with url: String, data: Data) -> MockDataTask {
|
||||
let url = URL(string: url)!
|
||||
let request = URLRequest(url: url)
|
||||
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
|
||||
return MockDataTask(
|
||||
request: request,
|
||||
response: response,
|
||||
responseBody: data,
|
||||
metrics: makeMetrics(with: url.absoluteString)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeMetrics(with url: String) -> NetworkLogger.Metrics {
|
||||
var metrics = MockDataTask.login.metrics
|
||||
metrics.transactions = metrics.transactions.map {
|
||||
var transaction = $0
|
||||
transaction.request = .init(URLRequest(url: URL(string: url)!))
|
||||
transaction.response = .init(HTTPURLResponse(url: URL(string: url)!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
return transaction
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
public struct ExportableStoreConstants {
|
||||
public static let sessionOne = LoggerStore.Session(
|
||||
id: UUID(uuidString: "1B39EDCF-DA05-4145-8389-63CC515C8664")!,
|
||||
startDate: ISO8601DateFormatter().date(from: "2033-01-05T08:42:07Z")!
|
||||
)
|
||||
|
||||
public static let sessionTwo = LoggerStore.Session(
|
||||
id: UUID(uuidString: "F8EA7E68-66C5-4AC6-8AC9-5C0CF5C4CC85")!,
|
||||
startDate: ISO8601DateFormatter().date(from: "2033-01-05T08:42:07Z")!
|
||||
)
|
||||
|
||||
/// - warning: This is important to set to make sure some blobs are recorded as files.
|
||||
public static let inlineLimit = 1000
|
||||
|
||||
/// Recorded in session #1 and #2
|
||||
public static let blobA = String(repeating: "A", count: 5000).data(using: .utf8)!
|
||||
|
||||
/// Recorded in session #1
|
||||
public static let blobB = String(repeating: "B", count: 7000).data(using: .utf8)!
|
||||
|
||||
/// Recorded in session #1 and #2
|
||||
public static let blobC = String(repeating: "C", count: 100).data(using: .utf8)!
|
||||
|
||||
/// Recorded in session #2
|
||||
public static let blobD = String(repeating: "D", count: 250).data(using: .utf8)!
|
||||
}
|
||||
|
||||
extension NetworkLogger {
|
||||
public func logTask(_ mockTask: MockDataTask, urlSession: URLSession = mockSession) {
|
||||
let dataTask = urlSession.dataTask(with: mockTask.request)
|
||||
dataTask.setValue(mockTask.response, forKey: "response")
|
||||
logTaskCreated(dataTask)
|
||||
logDataTask(dataTask, didReceive: mockTask.responseBody)
|
||||
logTask(dataTask, didFinishCollecting: mockTask.metrics)
|
||||
logTask(dataTask, didCompleteWithError: nil)
|
||||
}
|
||||
}
|
||||
|
||||
public let mockSession: URLSession = {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.httpAdditionalHeaders = [
|
||||
"User-Agent": "Pulse Demo/0.19 iOS"
|
||||
]
|
||||
return URLSession(configuration: .default)
|
||||
}()
|
||||
@@ -1 +0,0 @@
|
||||
../../../Sources/PulseUI/Mocks/MockTask.swift
|
||||
File diff suppressed because one or more lines are too long
+3
-5
@@ -13,14 +13,12 @@ let package = Package(
|
||||
products: [
|
||||
.library(name: "Pulse", targets: ["Pulse"]),
|
||||
.library(name: "PulseProxy", targets: ["PulseProxy"]),
|
||||
.library(name: "PulseUI", targets: ["PulseUI"])
|
||||
.library(name: "PulseUI", targets: ["PulseUI"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Pulse"),
|
||||
.target(name: "Pulse", dependencies: ["PulseObjCHelpers"]),
|
||||
.target(name: "PulseProxy", dependencies: ["Pulse"]),
|
||||
.target(name: "PulseUI", dependencies: ["Pulse"]),
|
||||
],
|
||||
swiftLanguageVersions: [
|
||||
.v5
|
||||
.target(name: "PulseObjCHelpers"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import CoreData
|
||||
import PulseObjCHelpers
|
||||
|
||||
extension PulseObjCExceptionCatcher {
|
||||
/// Executes the given throwing block, catching any Objective-C exceptions
|
||||
/// and converting them to Swift errors.
|
||||
public static func perform<T>(_ block: () throws -> T) throws -> T {
|
||||
var result: Result<T, Swift.Error>?
|
||||
try perform {
|
||||
result = Result(catching: block)
|
||||
}
|
||||
return try result!.get()
|
||||
}
|
||||
}
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
/// Saves the context, catching Objective-C exceptions (e.g. from CoreData
|
||||
/// when the disk is full) that would otherwise bypass Swift error handling.
|
||||
public func safeSave() throws {
|
||||
try PulseObjCExceptionCatcher.perform {
|
||||
try self.save()
|
||||
}
|
||||
}
|
||||
|
||||
package func fetch<T: NSManagedObject>(_ entity: T.Type, _ configure: (NSFetchRequest<T>) -> Void = { _ in }) throws -> [T] {
|
||||
let request = NSFetchRequest<T>(entityName: String(describing: entity))
|
||||
configure(request)
|
||||
@@ -39,7 +60,7 @@ extension NSManagedObjectContext {
|
||||
}
|
||||
|
||||
extension NSPersistentContainer {
|
||||
static var inMemoryReadonlyContainer: NSPersistentContainer {
|
||||
package static var inMemoryReadonlyContainer: NSPersistentContainer {
|
||||
let container = NSPersistentContainer(name: "EmptyStore", managedObjectModel: LoggerStore.model)
|
||||
let description = NSPersistentStoreDescription()
|
||||
description.type = NSInMemoryStoreType
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
@@ -113,6 +113,11 @@ extension URL {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPath() -> String? {
|
||||
let path = self.path
|
||||
return path.isEmpty ? nil : path
|
||||
}
|
||||
}
|
||||
|
||||
package struct LoggerBlogDataStore {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import CoreData
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
@@ -46,6 +46,12 @@ extension LoggerStore {
|
||||
/// There is no need to pass the ``create`` option. The ``sweep`` option
|
||||
/// will still work with the in-memory store.
|
||||
public static let inMemory = Options(rawValue: 1 << 4)
|
||||
|
||||
/// Disables SQLite durability features (WAL, fsync, shared locking)
|
||||
/// in favor of raw write speed. Intended for one-shot bulk ingestion
|
||||
/// — mock data generation, imports — where crash safety does not
|
||||
/// matter. A crash mid-write may leave the store corrupted.
|
||||
package static let unsafe = Options(rawValue: 1 << 5)
|
||||
}
|
||||
|
||||
/// The store configuration.
|
||||
@@ -81,7 +87,7 @@ extension LoggerStore {
|
||||
/// value is `8 MB`. The same limit applies to requests.
|
||||
public var responseBodySizeLimit: Int = 8 * 1048576
|
||||
|
||||
var inlineLimit = 16384 // 16 KB
|
||||
package var inlineLimit = 16384 // 16 KB
|
||||
|
||||
/// By default, two weeks. The messages and requests that are older that
|
||||
/// two weeks will get automatically deleted.
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import CoreData
|
||||
|
||||
private let timestampFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US")
|
||||
f.dateFormat = "HH:mm:ss.SSS"
|
||||
return f
|
||||
}()
|
||||
|
||||
public final class LoggerSessionEntity: NSManagedObject {
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var id: UUID
|
||||
@@ -25,6 +32,7 @@ public final class LoggerMessageEntity: NSManagedObject {
|
||||
@NSManaged public var task: NetworkTaskEntity?
|
||||
|
||||
public lazy var metadata = { KeyValueEncoding.decodeKeyValuePairs(rawMetadata) }()
|
||||
public lazy var formattedTimestamp: String = timestampFormatter.string(from: createdAt)
|
||||
}
|
||||
|
||||
public final class NetworkTaskEntity: NSManagedObject {
|
||||
@@ -44,6 +52,7 @@ public final class NetworkTaskEntity: NSManagedObject {
|
||||
|
||||
@NSManaged public var url: String?
|
||||
@NSManaged public var host: String?
|
||||
@NSManaged public var path: String?
|
||||
@NSManaged public var httpMethod: String?
|
||||
|
||||
// MARK: Response
|
||||
@@ -103,6 +112,16 @@ public final class NetworkTaskEntity: NSManagedObject {
|
||||
// MARK: Helpers
|
||||
|
||||
public lazy var metadata = { rawMetadata.map(KeyValueEncoding.decodeKeyValuePairs) }()
|
||||
public lazy var formattedTimestamp: String = timestampFormatter.string(from: createdAt)
|
||||
public lazy var parsedURLComponents: URLComponents? = url.flatMap { URLComponents(string: $0) }
|
||||
|
||||
// View-layer formatting caches populated by PulseUI. Keyed on the input
|
||||
// value so they self-invalidate when the underlying field changes.
|
||||
package var cachedFormattedURL: (key: AnyHashable, value: String?)?
|
||||
package var cachedFormattedDuration: (duration: Double, value: String)?
|
||||
package var cachedFormattedRequestBodySize: (size: Int64, value: String)?
|
||||
package var cachedFormattedResponseBodySize: (size: Int64, value: String)?
|
||||
package var cachedStatus: String?
|
||||
|
||||
/// Returns request state.
|
||||
public var state: State {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -79,7 +79,7 @@ enum AppInfo {
|
||||
}
|
||||
|
||||
extension LoggerStore.Info.AppInfo {
|
||||
static let current = LoggerStore.Info.AppInfo(
|
||||
package static let current = LoggerStore.Info.AppInfo(
|
||||
bundleIdentifier: AppInfo.bundleIdentifier,
|
||||
name: AppInfo.appName,
|
||||
version: AppInfo.appVersion,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
extension LoggerStore {
|
||||
@frozen public enum MetadataValue {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import CoreData
|
||||
|
||||
@@ -51,6 +51,7 @@ extension LoggerStore {
|
||||
Attribute("taskType", .integer16AttributeType),
|
||||
Attribute("url", .stringAttributeType),
|
||||
Attribute("host", .stringAttributeType),
|
||||
Attribute("path", .stringAttributeType),
|
||||
Attribute("httpMethod", .stringAttributeType),
|
||||
Attribute("statusCode", .integer32AttributeType),
|
||||
Attribute("errorCode", .integer32AttributeType),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
extension LoggerStore {
|
||||
/// A semantic version.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
@@ -246,8 +246,17 @@ public final class LoggerStore: @unchecked Sendable, Identifiable {
|
||||
private static func makeContainer(databaseURL: URL, options: Options) -> NSPersistentContainer {
|
||||
let container = NSPersistentContainer(name: databaseURL.lastPathComponent, managedObjectModel: Self.model)
|
||||
let store = NSPersistentStoreDescription(url: databaseURL)
|
||||
store.setValue("DELETE" as NSString, forPragmaNamed: "journal_mode")
|
||||
store.type = options.contains(.inMemory) ? NSInMemoryStoreType : NSSQLiteStoreType
|
||||
if options.contains(.unsafe) {
|
||||
// journal_mode=OFF — no rollback/WAL; writes go straight to the main file
|
||||
// synchronous=OFF — skip fsyncs; rely on the OS to flush
|
||||
// temp_store=MEMORY — keep transient b-trees in RAM
|
||||
// locking_mode=EXCLUSIVE — we're the only writer; skip the shared-cache handshake
|
||||
store.setValue("OFF" as NSString, forPragmaNamed: "journal_mode")
|
||||
store.setValue("OFF" as NSString, forPragmaNamed: "synchronous")
|
||||
store.setValue("MEMORY" as NSString, forPragmaNamed: "temp_store")
|
||||
store.setValue("EXCLUSIVE" as NSString, forPragmaNamed: "locking_mode")
|
||||
}
|
||||
container.persistentStoreDescriptions = [store]
|
||||
return container
|
||||
}
|
||||
@@ -271,7 +280,7 @@ public final class LoggerStore: @unchecked Sendable, Identifiable {
|
||||
entity.id = session.id
|
||||
entity.version = info.version
|
||||
entity.build = info.build
|
||||
try? backgroundContext.save()
|
||||
try? backgroundContext.safeSave()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +355,7 @@ extension LoggerStore {
|
||||
perform { _ in self._handle(event) }
|
||||
}
|
||||
|
||||
private func _handle(_ event: Event) {
|
||||
package func _handle(_ event: Event) {
|
||||
switch event {
|
||||
case .messageStored(let event): process(event)
|
||||
case .networkTaskCreated(let event): process(event)
|
||||
@@ -375,6 +384,7 @@ extension LoggerStore {
|
||||
|
||||
entity.url = event.originalRequest.url?.absoluteString
|
||||
entity.host = event.originalRequest.url.flatMap { $0.getHost() }
|
||||
entity.path = event.originalRequest.url.flatMap { $0.getPath() }
|
||||
entity.httpMethod = event.originalRequest.httpMethod
|
||||
entity.requestState = NetworkTaskEntity.State.pending.rawValue
|
||||
entity.originalRequest = makeRequest(for: event.originalRequest)
|
||||
@@ -401,6 +411,7 @@ extension LoggerStore {
|
||||
|
||||
entity.url = event.originalRequest.url?.absoluteString
|
||||
entity.host = event.originalRequest.url.flatMap { $0.getHost() }
|
||||
entity.path = event.originalRequest.url.flatMap { $0.getPath() }
|
||||
entity.httpMethod = event.originalRequest.httpMethod
|
||||
entity.statusCode = Int32(event.response?.statusCode ?? 0)
|
||||
entity.responseContentType = event.response?.contentType?.type
|
||||
@@ -744,7 +755,7 @@ extension LoggerStore {
|
||||
|
||||
private func saveAndReset() {
|
||||
do {
|
||||
try backgroundContext.save()
|
||||
try backgroundContext.safeSave()
|
||||
} catch {
|
||||
#if DEBUG
|
||||
debugPrint(error)
|
||||
@@ -963,7 +974,7 @@ extension LoggerStore {
|
||||
try removeMessages(with: NSCompoundPredicate(notPredicateWithSubpredicate: predicate))
|
||||
}
|
||||
if backgroundContext.hasChanges {
|
||||
try backgroundContext.save()
|
||||
try backgroundContext.safeSave()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,7 +1060,7 @@ extension LoggerStore {
|
||||
infoBlob.key = "info"
|
||||
infoBlob.data = try JSONEncoder().encode(info)
|
||||
|
||||
try document.context.save()
|
||||
try document.context.safeSave()
|
||||
try? document.close()
|
||||
}
|
||||
}
|
||||
@@ -1104,12 +1115,18 @@ extension LoggerStore {
|
||||
}
|
||||
|
||||
// First remove some old messages
|
||||
let messages = try backgroundContext.fetch(LoggerMessageEntity.self, sortedBy: \.createdAt, ascending: false)
|
||||
let count = messages.count
|
||||
let count = try backgroundContext.count(for: LoggerMessageEntity.self)
|
||||
guard count > 10 else { return } // Sanity check
|
||||
|
||||
let cutoffDate = messages[Int(Double(count) * configuration.trimRatio)].createdAt
|
||||
try removeMessages(before: cutoffDate)
|
||||
let offset = Int(Double(count) * configuration.trimRatio)
|
||||
let cutoffMessages = try backgroundContext.fetch(LoggerMessageEntity.self) {
|
||||
$0.sortDescriptors = [NSSortDescriptor(keyPath: \LoggerMessageEntity.createdAt, ascending: false)]
|
||||
$0.fetchOffset = offset
|
||||
$0.fetchLimit = 1
|
||||
}
|
||||
if let cutoffDate = cutoffMessages.first?.createdAt {
|
||||
try removeMessages(before: cutoffDate)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMessages(before date: Date) throws {
|
||||
@@ -1288,7 +1305,7 @@ extension LoggerStore {
|
||||
|
||||
extension Version {
|
||||
package static let minimumSupportedVersion = LoggerStore.Version(3, 1, 0)
|
||||
package static let currentStoreVersion = LoggerStore.Version(3, 6, 0)
|
||||
package static let currentStoreVersion = LoggerStore.Version(3, 7, 0)
|
||||
package static let currentProtocolVersion = LoggerStore.Version(4, 0, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
package protocol LoggerStoreProtocol: AnyObject {
|
||||
var storeURL: URL { get }
|
||||
var version: LoggerStore.Version { get }
|
||||
var container: NSPersistentContainer { get }
|
||||
var backgroundContext: NSManagedObjectContext { get }
|
||||
|
||||
/// Creates a new background context with the `LoggerBlogDataStore` user info
|
||||
/// key set up so that `LoggerBlobHandleEntity.data` works on the context.
|
||||
func newBackgroundContext() -> NSManagedObjectContext
|
||||
|
||||
/// The ID of the current (active) session, if applicable.
|
||||
var currentSessionID: UUID? { get }
|
||||
/// Whether the store is read-only.
|
||||
var isReadonly: Bool { get }
|
||||
|
||||
func info() async throws -> LoggerStore.Info
|
||||
func performChanges(_ changes: @escaping (NSManagedObjectContext) -> Void)
|
||||
func removeAll()
|
||||
func removeSessions(withIDs sessionIDs: Set<UUID>)
|
||||
func clearSessions(withIDs sessionIDs: Set<UUID>)
|
||||
}
|
||||
|
||||
extension LoggerStoreProtocol {
|
||||
package var viewContext: NSManagedObjectContext { container.viewContext }
|
||||
package var currentSessionID: UUID? { nil }
|
||||
package var isReadonly: Bool { true }
|
||||
package func removeAll() {}
|
||||
package func removeSessions(withIDs sessionIDs: Set<UUID>) {}
|
||||
package func clearSessions(withIDs sessionIDs: Set<UUID>) {}
|
||||
}
|
||||
|
||||
extension LoggerStore: LoggerStoreProtocol {
|
||||
package var currentSessionID: UUID? { session.id }
|
||||
package var isReadonly: Bool { options.contains(.readonly) }
|
||||
|
||||
package func performChanges(_ changes: @escaping (NSManagedObjectContext) -> Void) {
|
||||
perform(changes)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -526,6 +526,7 @@ extension NetworkLogger {
|
||||
public var isImage: Bool { type.hasPrefix("image/") }
|
||||
public var isHTML: Bool { type.contains("html") }
|
||||
public var isEncodedForm: Bool { type == "application/x-www-form-urlencoded" }
|
||||
public var isProtobuf: Bool { type.contains("protobuf") || type.contains("grpc") }
|
||||
|
||||
public var lastComponent: String {
|
||||
type.components(separatedBy: "/").last ?? type
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
@@ -16,7 +16,7 @@ Add **Pulse** and **PulseUI** libraries to your app.
|
||||
|
||||
## 2. Integrate Pulse Framework
|
||||
|
||||
**Pulse** framework contains APIs for logging, capturing, and mocking network requests, as well as connecting to the Pulse Pro apps.
|
||||
**Pulse** framework contains APIs for logging, capturing, and mocking network requests, as well as connecting to the Pulse Pro apps.
|
||||
|
||||
### 2.1. Capture Network Requests
|
||||
|
||||
@@ -44,7 +44,7 @@ NetworkLogger.enableProxy()
|
||||
#endif
|
||||
```
|
||||
|
||||
> important: **PulseProxy** uses swizzling and private APIs and it is not recommended that you include it in the production builds of your app.
|
||||
> important: **PulseProxy** uses method swizzling and private APIs, and it is not recommended that you include it in the production builds of your app. It is also not guaranteed to continue working with new versions of the system SDKs.
|
||||
|
||||
### 2.2. Collect Logs
|
||||
|
||||
@@ -79,7 +79,7 @@ NavigationLink(destination: ConsoleView()) {
|
||||
|
||||
## 4. Get Pulse Apps
|
||||
|
||||
Pulse also provides separate indispensable [macOS and iOS apps](https://pulselogger.com) that you can use to view logs collected by the Pulse SDK and even debug your apps in real-time with features like response mocking. The app are [available on the App Store](https://apps.apple.com/us/app/pulse-network-logger/id6661031747).
|
||||
Pulse also provides separate, indispensable [macOS and iOS apps](https://pulselogger.com) that you can use to view logs collected by the Pulse SDK and even debug your apps in real-time with features like response mocking. The apps are [available on the App Store](https://apps.apple.com/us/app/pulse-network-logger/id6661031747).
|
||||
|
||||
The apps require two more simple configuration steps.
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ Learn how to enable and configure network logging and debugging.
|
||||
|
||||
## Overview
|
||||
|
||||
Pulse works on the `URLSession` level, and it needs access to its callbacks to log network requests and capture network metrics. The framework is modular and provides multiple options that can accommodate almost any system. By the end of this article, you will have a system that:
|
||||
Pulse works at the `URLSession` level, and it needs access to its callbacks to log network requests and capture network metrics. The framework is modular and provides multiple options that can accommodate almost any system. By the end of this article, you will have a system that:
|
||||
|
||||
- Captures network requests and metrics
|
||||
- Supports debugging features powered by Pulse Pro, such as mocking
|
||||
- Supports debugging features powered by Pulse Pro, such as mocking
|
||||
|
||||
## Capture Network Requests
|
||||
|
||||
@@ -139,7 +139,7 @@ configuration.sensitiveDataFields = ["password"]
|
||||
let logger = NetworkLogger(configuration: configuration)
|
||||
```
|
||||
|
||||
You can then replace the default decoder with your custom instance:
|
||||
You can then replace the default logger with your custom instance:
|
||||
|
||||
```swift
|
||||
NetworkLogger.shared = logger
|
||||
@@ -153,7 +153,7 @@ If the built-in configuration options don't cover all of your use cases, you can
|
||||
|
||||
### Trace in Xcode Console
|
||||
|
||||
Pulse doesn't print anything in the Xcode Console by default, but it's easy to enable logging for network requests. ``LoggerStore`` re-translates all of the log events that it processes using ``LoggerStore/events`` publisher that you can leverage.
|
||||
Pulse doesn't print anything in the Xcode Console by default, but it's easy to enable logging for network requests. ``LoggerStore`` re-translates all of the log events that it processes using ``LoggerStore/events`` publisher that you can leverage.
|
||||
|
||||
```swift
|
||||
func register(store: LoggerStore) {
|
||||
@@ -174,10 +174,10 @@ private func process(event: LoggerStore.Event) {
|
||||
|
||||
## Network Debugging
|
||||
|
||||
In addition to logging, Pulse provides network debugging features, such as logging. If you use the recommended ``URLSessionProxy``, these features are enabled automatically, and you don't need to do anything. In other cases, make sure to inject ``MockingURLProtocol`` in the set of URL protocols used by your `URLSession`:
|
||||
In addition to logging, Pulse provides network debugging features, such as mocking. If you use the recommended ``URLSessionProxy``, these features are enabled automatically, and you don't need to do anything. In other cases, make sure to inject ``MockingURLProtocol`` in the set of URL protocols used by your `URLSession`:
|
||||
|
||||
```swift
|
||||
let configuration = URLSesionConfiguration.default
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? [])
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Pulse is highly customizable and you can tweak it to best match your style and y
|
||||
|
||||
### Configure Store
|
||||
|
||||
``LoggerStore`` is the primary way to configure how logs are stored. It uses a database to record logs in an efficient binary format and employes a number of space [optimizations techniques](https://kean.blog/post/pulse-2#space-savings), including fast [lzfse](https://developer.apple.com/documentation/compression/algorithm/lzfse) compression. The store automatically limits how much spaces it takes and also removed old logs.
|
||||
``LoggerStore`` is the primary way to configure how logs are stored. It uses a database to record logs in an efficient binary format and employs a number of space [optimization techniques](https://kean.blog/post/pulse-2#space-savings), including fast [lzfse](https://developer.apple.com/documentation/compression/algorithm/lzfse) compression. The store automatically limits how much space it takes and also removes old logs.
|
||||
|
||||
```swift
|
||||
LoggerStore.shared.configuration.sizeLimit = 512 * 1_000_000
|
||||
@@ -58,7 +58,7 @@ struct AnalyticsLogsView: View {
|
||||
}
|
||||
```
|
||||
|
||||
> important: In the current schema, the alogger creates an associated ``LoggerMessageEntity`` entity for every ``NetworkTaskEntity``, but it will likely change in the future.
|
||||
> important: In the current schema, the logger creates an associated ``LoggerMessageEntity`` entity for every ``NetworkTaskEntity``, but it will likely change in the future.
|
||||
|
||||
## Network Logging & Debugging
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -51,7 +51,9 @@ var isAutomaticNetworkLoggingEnabled: Bool {
|
||||
}
|
||||
|
||||
func isConfiguringSessionSafe(delegate: URLSessionDelegate?) -> Bool {
|
||||
if String(describing: delegate).contains("GTMSessionFetcher") {
|
||||
guard let delegate else { return true }
|
||||
let className = NSStringFromClass(type(of: delegate))
|
||||
if className.contains("GTMSessionFetcher") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#import "PulseObjCHelpers.h"
|
||||
|
||||
@implementation PulseObjCExceptionCatcher
|
||||
|
||||
+ (BOOL)performAndReturnError:(NSError **)error block:(void (NS_NOESCAPE ^)(void))block {
|
||||
@try {
|
||||
block();
|
||||
return YES;
|
||||
} @catch (NSException *exception) {
|
||||
if (error) {
|
||||
*error = [NSError errorWithDomain:@"com.github.kean.pulse"
|
||||
code:-1
|
||||
userInfo:@{
|
||||
NSLocalizedDescriptionKey: exception.reason ?: @"Unknown Objective-C exception"
|
||||
}];
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,17 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface PulseObjCExceptionCatcher : NSObject
|
||||
|
||||
/// Executes the given block, catching any Objective-C exceptions and
|
||||
/// converting them to NSError.
|
||||
+ (BOOL)performAndReturnError:(NSError *_Nullable *_Nullable)error block:(void (NS_NOESCAPE ^)(void))block;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
@@ -16,7 +16,7 @@ extension Character {
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
extension AttributedString {
|
||||
package init(_ string: String, _ configure: (inout AttributeContainer) -> Void) {
|
||||
var attributes = AttributeContainer()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
@@ -151,6 +151,8 @@ extension NetworkTaskEntity {
|
||||
case .taskDescription: return "Task Description"
|
||||
case .requestHeaderField(let name): return name
|
||||
case .responseHeaderField(let name): return name
|
||||
case .url: return "URL"
|
||||
case .custom: return "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,9 +167,9 @@ extension NetworkTaskEntity {
|
||||
case .method:
|
||||
httpMethod
|
||||
case .requestSize:
|
||||
byteCount(for: requestBodySize)
|
||||
formattedRequestBodySize()
|
||||
case .responseSize:
|
||||
byteCount(for: responseBodySize)
|
||||
formattedResponseBodySize()
|
||||
case .responseContentType:
|
||||
responseContentType.map(NetworkLogger.ContentType.init)?.lastComponent.uppercased()
|
||||
case .duration:
|
||||
@@ -184,6 +186,14 @@ extension NetworkTaskEntity {
|
||||
(currentRequest?.headers ?? [:])[key]
|
||||
case .responseHeaderField(let key):
|
||||
(response?.headers ?? [:])[key]
|
||||
case .url(let components):
|
||||
if let components {
|
||||
formattedURL(components: components)
|
||||
} else {
|
||||
url
|
||||
}
|
||||
case .custom(let value):
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,20 +208,37 @@ extension NetworkTaskEntity {
|
||||
}
|
||||
|
||||
package func getFormattedContent(settings: ConsoleListDisplaySettings.ContentSettings) -> String? {
|
||||
if let customText = settings.customText {
|
||||
return customText
|
||||
}
|
||||
if settings.showTaskDescription, let taskDescription, !taskDescription.isEmpty {
|
||||
return taskDescription
|
||||
}
|
||||
guard let url else {
|
||||
guard url != nil else {
|
||||
return nil
|
||||
}
|
||||
return NetworkTaskEntity.formattedURL(url, components: settings.components)
|
||||
return formattedURL(components: settings.components)
|
||||
}
|
||||
|
||||
package func formattedURL(components displayed: Set<ConsoleListDisplaySettings.URLComponent>) -> String? {
|
||||
let key = AnyHashable(displayed)
|
||||
if let cache = cachedFormattedURL, cache.key == key {
|
||||
return cache.value
|
||||
}
|
||||
let value = NetworkTaskEntity.formattedURL(parsed: parsedURLComponents, displayed: displayed)
|
||||
cachedFormattedURL = (key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
package static func formattedURL(_ url: String, components displayed: Set<ConsoleListDisplaySettings.URLComponent>) -> String? {
|
||||
formattedURL(parsed: URLComponents(string: url), displayed: displayed)
|
||||
}
|
||||
|
||||
fileprivate static func formattedURL(parsed: URLComponents?, displayed: Set<ConsoleListDisplaySettings.URLComponent>) -> String? {
|
||||
guard !displayed.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard var components = URLComponents(string: url) else {
|
||||
guard var components = parsed else {
|
||||
return nil
|
||||
}
|
||||
if displayed.count == 1 && displayed.first == .path {
|
||||
@@ -236,6 +263,26 @@ extension NetworkTaskEntity {
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
fileprivate func formattedRequestBodySize() -> String {
|
||||
let size = requestBodySize
|
||||
if let cache = cachedFormattedRequestBodySize, cache.size == size {
|
||||
return cache.value
|
||||
}
|
||||
let value = byteCount(for: size)
|
||||
cachedFormattedRequestBodySize = (size, value)
|
||||
return value
|
||||
}
|
||||
|
||||
fileprivate func formattedResponseBodySize() -> String {
|
||||
let size = responseBodySize
|
||||
if let cache = cachedFormattedResponseBodySize, cache.size == size {
|
||||
return cache.value
|
||||
}
|
||||
let value = byteCount(for: size)
|
||||
cachedFormattedResponseBodySize = (size, value)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private func byteCount(for size: Int64) -> String {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
@@ -63,6 +63,62 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BackportButtonRole
|
||||
|
||||
package enum BackportButtonRole {
|
||||
case cancel
|
||||
case close
|
||||
case confirm
|
||||
|
||||
package var title: String {
|
||||
switch self {
|
||||
case .cancel: "Cancel"
|
||||
case .close: "Close"
|
||||
case .confirm: "Done"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
package func makeButton(role: BackportButtonRole, title: String? = nil, action: @escaping () -> Void) -> some View {
|
||||
if #available(iOS 26, macOS 26, visionOS 26, tvOS 26, watchOS 26, *) {
|
||||
if let title {
|
||||
Button(title, role: ButtonRole(role), action: action)
|
||||
} else {
|
||||
Button(role: ButtonRole(role), action: action)
|
||||
}
|
||||
} else {
|
||||
Button(title ?? role.title, action: action)
|
||||
}
|
||||
}
|
||||
|
||||
package struct ButtonClose: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
package let role: BackportButtonRole
|
||||
|
||||
package init(role: BackportButtonRole = .close) {
|
||||
self.role = role
|
||||
}
|
||||
|
||||
package var body: some View {
|
||||
makeButton(role: role) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 26, macOS 26, visionOS 26, tvOS 26, watchOS 26, *)
|
||||
private extension ButtonRole {
|
||||
init(_ role: BackportButtonRole) {
|
||||
switch role {
|
||||
case .cancel: self = .cancel
|
||||
case .close: self = .close
|
||||
case .confirm: self = .confirm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows you to use `@StateObject` only for memory management (without observing).
|
||||
package final class IgnoringUpdates<T>: ObservableObject {
|
||||
package var value: T
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
protocol ConsoleDataSourceDelegate: AnyObject {
|
||||
/// The data source reloaded the entire dataset.
|
||||
func dataSourceDidRefresh(_ dataSource: ConsoleDataSource)
|
||||
|
||||
/// An incremental update. If the diff is nil, it means the app is displaying
|
||||
/// a grouped view that doesn't support diffing.
|
||||
func dataSource(_ dataSource: ConsoleDataSource, didUpdateWith diff: CollectionDifference<NSManagedObjectID>?)
|
||||
}
|
||||
|
||||
final class ConsoleDataSource: NSObject, NSFetchedResultsControllerDelegate {
|
||||
weak var delegate: ConsoleDataSourceDelegate?
|
||||
|
||||
/// - warning: Incompatible with the "group by" option.
|
||||
var sortDescriptors: [NSSortDescriptor] = [] {
|
||||
didSet { controller.fetchRequest.sortDescriptors = sortDescriptors }
|
||||
}
|
||||
|
||||
struct PredicateOptions {
|
||||
var filters = ConsoleFilters()
|
||||
var isOnlyErrors = false
|
||||
var predicate: NSPredicate?
|
||||
}
|
||||
|
||||
var predicate: PredicateOptions = .init() {
|
||||
didSet { refreshPredicate() }
|
||||
}
|
||||
|
||||
var filter: NSPredicate? {
|
||||
didSet { refreshPredicate() }
|
||||
}
|
||||
|
||||
static let fetchBatchSize = 100
|
||||
|
||||
private let store: LoggerStore
|
||||
private let mode: ConsoleMode
|
||||
private let options: ConsoleListOptions
|
||||
private let controller: NSFetchedResultsController<NSManagedObject>
|
||||
private var controllerDelegate: NSFetchedResultsControllerDelegate?
|
||||
private var cancellables: [AnyCancellable] = []
|
||||
|
||||
init(store: LoggerStore, mode: ConsoleMode, options: ConsoleListOptions = .init()) {
|
||||
self.store = store
|
||||
self.mode = mode
|
||||
self.options = options
|
||||
|
||||
let entityName: String
|
||||
let sortKey: String
|
||||
|
||||
switch mode {
|
||||
case .all, .logs:
|
||||
entityName = "\(LoggerMessageEntity.self)"
|
||||
sortKey = options.messageSortBy.key
|
||||
case .network:
|
||||
entityName = "\(NetworkTaskEntity.self)"
|
||||
sortKey = options.taskSortBy.key
|
||||
}
|
||||
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(key: sortKey, ascending: options.order == .ascending)
|
||||
].compactMap { $0 }
|
||||
request.fetchBatchSize = ConsoleDataSource.fetchBatchSize
|
||||
request.relationshipKeyPathsForPrefetching = ["request"]
|
||||
controller = NSFetchedResultsController(
|
||||
fetchRequest: request,
|
||||
managedObjectContext: store.viewContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
super.init()
|
||||
|
||||
let delegate = ConsoleFetchDelegate()
|
||||
delegate.delegate = self
|
||||
controllerDelegate = delegate
|
||||
|
||||
controller.delegate = controllerDelegate
|
||||
}
|
||||
|
||||
func bind(_ filters: ConsoleFiltersViewModel) {
|
||||
cancellables = []
|
||||
filters.$options.sink { [weak self] in
|
||||
self?.predicate = $0
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
try? controller.performFetch()
|
||||
delegate?.dataSourceDidRefresh(self)
|
||||
}
|
||||
|
||||
// MARK: Accessing Entities
|
||||
|
||||
var numberOfObjects: Int {
|
||||
controller.fetchedObjects?.count ?? 0
|
||||
}
|
||||
|
||||
func object(at indexPath: IndexPath) -> NSManagedObject {
|
||||
controller.object(at: indexPath)
|
||||
}
|
||||
|
||||
var entities: [NSManagedObject] {
|
||||
controller.fetchedObjects ?? []
|
||||
}
|
||||
|
||||
// MARK: NSFetchedResultsControllerDelegate
|
||||
|
||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
delegate?.dataSource(self, didUpdateWith: nil)
|
||||
}
|
||||
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>) {
|
||||
delegate?.dataSource(self, didUpdateWith: diff)
|
||||
}
|
||||
|
||||
// MARK: Predicate
|
||||
|
||||
private func refreshPredicate() {
|
||||
let predicate = ConsoleDataSource.makePredicate(mode: mode, options: predicate, filter: filter)
|
||||
controller.fetchRequest.predicate = predicate
|
||||
refresh()
|
||||
}
|
||||
|
||||
static func makePredicate(mode: ConsoleMode, options: PredicateOptions, filter: NSPredicate? = nil) -> NSPredicate? {
|
||||
let predicates = [
|
||||
_makePredicate(mode, options.filters, options.isOnlyErrors),
|
||||
options.predicate,
|
||||
filter
|
||||
].compactMap { $0 }
|
||||
switch predicates.count {
|
||||
case 0: return nil
|
||||
case 1: return predicates[0]
|
||||
default: return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Predicates
|
||||
|
||||
private func _makePredicate(_ mode: ConsoleMode, _ filters: ConsoleFilters, _ isOnlyErrors: Bool) -> NSPredicate? {
|
||||
func makeMessagesPredicate(isMessageOnly: Bool) -> NSPredicate? {
|
||||
var predicates: [NSPredicate] = []
|
||||
if isMessageOnly {
|
||||
predicates.append(NSPredicate(format: "task == NULL"))
|
||||
}
|
||||
if let predicate = ConsoleFilters.makeMessagePredicates(criteria: filters, isOnlyErrors: isOnlyErrors) {
|
||||
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 .network:
|
||||
return ConsoleFilters.makeNetworkPredicates(criteria: filters, isOnlyErrors: isOnlyErrors)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegates
|
||||
|
||||
// Using a separate class because the diff API is not supported for a fetch
|
||||
// controller with sections, and it prints an error message in logs if the
|
||||
// delegate implements it, which we want to avoid.
|
||||
|
||||
private final class ConsoleFetchDelegate: NSObject, NSFetchedResultsControllerDelegate {
|
||||
weak var delegate: NSFetchedResultsControllerDelegate?
|
||||
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>) {
|
||||
delegate?.controller?(controller, didChangeContentWith: diff)
|
||||
}
|
||||
}
|
||||
|
||||
package enum ConsoleUpdateEvent {
|
||||
/// Full refresh of data.
|
||||
case refresh
|
||||
/// Incremental update.
|
||||
case update(CollectionDifference<NSManagedObjectID>?)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
import SwiftUI
|
||||
|
||||
/// Allows customization of ``ConsoleView`` behavior on a per-task basis.
|
||||
///
|
||||
/// By default, the console uses the global ``UserSettings/shared`` to decide
|
||||
/// how to render every network task. Provide a ``ConsoleDelegate`` to vary
|
||||
/// the display options per task — including injecting custom strings into
|
||||
/// the header / footer via ``ConsoleListDisplaySettings/TaskField/custom(_:)``
|
||||
/// or into the content via ``ConsoleListDisplaySettings/ContentSettings/customText``.
|
||||
@MainActor
|
||||
public protocol ConsoleDelegate: AnyObject {
|
||||
/// Returns the display options to use when rendering the given network
|
||||
/// task in the console list and inspector.
|
||||
///
|
||||
/// The default implementation returns ``UserSettings/shared``
|
||||
/// `listDisplayOptions`.
|
||||
func console(listDisplayOptionsFor task: NetworkTaskEntity) -> ConsoleListDisplaySettings
|
||||
|
||||
/// Returns additional context menu items to append to the task cell's
|
||||
/// context menu, or `nil` to show only the built-in items.
|
||||
///
|
||||
/// The returned view is rendered inside the cell's context menu after the
|
||||
/// built-in sections. Typical uses include app-specific actions like
|
||||
/// "Replay", "Open in admin panel", or "Copy decoded payload".
|
||||
///
|
||||
/// ```swift
|
||||
/// func console(contextMenuFor task: NetworkTaskEntity) -> AnyView? {
|
||||
/// AnyView(
|
||||
/// Section {
|
||||
/// Button("Replay") { replay(task) }
|
||||
/// }
|
||||
/// )
|
||||
/// }
|
||||
/// ```
|
||||
func console(contextMenuFor task: NetworkTaskEntity) -> AnyView?
|
||||
|
||||
/// Returns a SwiftUI view that replaces the cell's main content area
|
||||
/// (the HTTP method + URL text) when rendering the given task, or `nil`
|
||||
/// to use the default content rendering.
|
||||
///
|
||||
/// When non-nil, this supersedes ``ConsoleListDisplaySettings/ContentSettings``
|
||||
/// entirely — `showMethod`, `showTaskDescription`, `components`,
|
||||
/// `customText`, `isMonospaced`, `lineLimit`, and `fontSize` are all
|
||||
/// ignored for this task. The header and footer still render from
|
||||
/// ``ConsoleListDisplaySettings`` as usual.
|
||||
///
|
||||
/// ```swift
|
||||
/// func console(contentViewFor task: NetworkTaskEntity) -> AnyView? {
|
||||
/// guard isGraphQL(task) else { return nil }
|
||||
/// return AnyView(GraphQLOperationLabel(task: task))
|
||||
/// }
|
||||
/// ```
|
||||
func console(contentViewFor task: NetworkTaskEntity) -> AnyView?
|
||||
|
||||
/// Returns a SwiftUI view that replaces the built-in response body
|
||||
/// viewer for the given task, or `nil` to use the default rendering.
|
||||
///
|
||||
/// Use this hook to decode and display formats the built-in viewer
|
||||
/// doesn't understand — most notably protobuf, where the integrator
|
||||
/// owns the schema / generated `SwiftProtobuf` types. The returned view
|
||||
/// replaces the `FileViewer` entirely; wrap the decoded output in a
|
||||
/// rich text viewer to reuse the standard search / line-number UI.
|
||||
///
|
||||
/// ```swift
|
||||
/// func console(responseBodyViewFor task: NetworkTaskEntity) -> AnyView? {
|
||||
/// guard task.response?.contentType?.isProtobuf == true,
|
||||
/// let data = task.responseBody?.data else { return nil }
|
||||
/// let decoded = try? MyProtobufMessage(serializedBytes: data)
|
||||
/// return AnyView(ProtobufMessageView(message: decoded))
|
||||
/// }
|
||||
/// ```
|
||||
func console(responseBodyViewFor task: NetworkTaskEntity) -> AnyView?
|
||||
|
||||
/// Returns a SwiftUI view to inject into the network inspector for the
|
||||
/// given task, or `nil` to show only the built-in sections.
|
||||
///
|
||||
/// The returned view is rendered as an additional section after the
|
||||
/// built-in response/metrics sections. Typical uses include decoded
|
||||
/// GraphQL variables, parsed protobuf, or business-context metadata
|
||||
/// that the built-in inspector doesn't know how to render.
|
||||
///
|
||||
/// ```swift
|
||||
/// func console(inspectorViewFor task: NetworkTaskEntity) -> AnyView? {
|
||||
/// guard isGraphQL(task) else { return nil }
|
||||
/// return AnyView(
|
||||
/// Section("GraphQL") {
|
||||
/// GraphQLOperationView(task: task)
|
||||
/// }
|
||||
/// )
|
||||
/// }
|
||||
/// ```
|
||||
func console(inspectorViewFor task: NetworkTaskEntity) -> AnyView?
|
||||
|
||||
/// Returns a redacted version of `value` for safe display, for example
|
||||
/// to mask auth tokens or user identifiers before they are rendered in
|
||||
/// the console list or the inspector header.
|
||||
///
|
||||
/// Called for URL, host, header, task-description, and caller-supplied
|
||||
/// strings shown in the task cell. The default implementation returns
|
||||
/// `value` unchanged.
|
||||
///
|
||||
/// ```swift
|
||||
/// func console(redact value: String, field: ConsoleRedactionField, for task: NetworkTaskEntity) -> String {
|
||||
/// switch field {
|
||||
/// case .requestHeader("Authorization"): return "Bearer ***"
|
||||
/// case .url: return value.replacingOccurrences(of: #/token=[^&]+/#, with: "token=***")
|
||||
/// default: return value
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
func console(redact value: String, field: ConsoleRedactionField, for task: NetworkTaskEntity) -> String
|
||||
}
|
||||
|
||||
extension ConsoleDelegate {
|
||||
public func console(listDisplayOptionsFor task: NetworkTaskEntity) -> ConsoleListDisplaySettings {
|
||||
UserSettings.shared.listDisplayOptions
|
||||
}
|
||||
|
||||
public func console(contextMenuFor task: NetworkTaskEntity) -> AnyView? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func console(inspectorViewFor task: NetworkTaskEntity) -> AnyView? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func console(responseBodyViewFor task: NetworkTaskEntity) -> AnyView? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func console(contentViewFor task: NetworkTaskEntity) -> AnyView? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func console(redact value: String, field: ConsoleRedactionField, for task: NetworkTaskEntity) -> String {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies which string the console is about to render, passed to
|
||||
/// ``ConsoleDelegate/console(redact:field:for:)`` so integrators can target
|
||||
/// specific fields for redaction.
|
||||
public enum ConsoleRedactionField: Sendable, Hashable {
|
||||
/// The URL or a URL component displayed as cell content or in a field.
|
||||
case url
|
||||
/// The `host` component displayed in a field.
|
||||
case host
|
||||
/// A request header value (e.g., `Authorization`).
|
||||
case requestHeader(String)
|
||||
/// A response header value (e.g., `Set-Cookie`).
|
||||
case responseHeader(String)
|
||||
/// The `URLSessionTask.taskDescription` rendered as cell content or in a field.
|
||||
case taskDescription
|
||||
/// A caller-supplied custom string (``ConsoleListDisplaySettings/TaskField/custom(_:)``
|
||||
/// or ``ConsoleListDisplaySettings/ContentSettings/customText``).
|
||||
case custom
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import CoreData
|
||||
import Pulse
|
||||
@@ -11,23 +11,28 @@ import SwiftUI
|
||||
///
|
||||
/// - warning: It's marked with `ObservableObject` to make it possible to be used
|
||||
/// with `@StateObject` and `@EnvironmentObject`, but it never changes.
|
||||
final class ConsoleEnvironment: ObservableObject {
|
||||
let title: String
|
||||
let store: LoggerStore
|
||||
let index: LoggerStoreIndex
|
||||
package final class ConsoleEnvironment: ObservableObject {
|
||||
package let title: String
|
||||
package let store: LoggerStoreProtocol
|
||||
package let index: LoggerStoreIndex
|
||||
|
||||
let filters: ConsoleFiltersViewModel
|
||||
let logCountObserver: ManagedObjectsCountObserver
|
||||
let taskCountObserver: ManagedObjectsCountObserver
|
||||
package let filters: ConsoleFiltersViewModel
|
||||
package let logCountObserver: ManagedObjectsCountObserver
|
||||
package let taskCountObserver: ManagedObjectsCountObserver
|
||||
|
||||
let router = ConsoleRouter()
|
||||
package let router = ConsoleRouter()
|
||||
|
||||
let initialMode: ConsoleMode
|
||||
package let initialMode: ConsoleMode
|
||||
|
||||
@Published var mode: ConsoleMode
|
||||
@Published var listOptions: ConsoleListOptions = .init()
|
||||
/// A delegate that allows integrators to customize how individual tasks
|
||||
/// are rendered. The console keeps a strong reference, so simple
|
||||
/// configuration objects can be passed in without holding them elsewhere.
|
||||
package var delegate: (any ConsoleDelegate)?
|
||||
|
||||
var bindingForNetworkMode: Binding<Bool> {
|
||||
@Published package var mode: ConsoleMode
|
||||
@Published package var listOptions: ConsoleListOptions = .init()
|
||||
|
||||
package var bindingForNetworkMode: Binding<Bool> {
|
||||
Binding(get: {
|
||||
self.mode == .network
|
||||
}, set: {
|
||||
@@ -37,8 +42,13 @@ final class ConsoleEnvironment: ObservableObject {
|
||||
|
||||
private var cancellables: [AnyCancellable] = []
|
||||
|
||||
init(store: LoggerStore, mode: ConsoleMode = .all) {
|
||||
package init(
|
||||
store: LoggerStoreProtocol,
|
||||
mode: ConsoleMode = .all,
|
||||
delegate: (any ConsoleDelegate)? = nil
|
||||
) {
|
||||
self.store = store
|
||||
self.delegate = delegate
|
||||
switch mode {
|
||||
case .all: self.title = "Console"
|
||||
case .logs: self.title = "Logs"
|
||||
@@ -52,25 +62,29 @@ final class ConsoleEnvironment: ObservableObject {
|
||||
case .network: self.mode = .network
|
||||
}
|
||||
|
||||
func makeDefaultOptions() -> ConsoleDataSource.PredicateOptions {
|
||||
var options = ConsoleDataSource.PredicateOptions()
|
||||
options.filters.shared.sessions.selection = [store.session.id]
|
||||
func makeDefaultOptions() -> ConsoleListPredicateOptions {
|
||||
var options = ConsoleListPredicateOptions()
|
||||
if let sessionID = store.currentSessionID {
|
||||
options.sessions = [sessionID]
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
self.index = LoggerStoreIndex(store: store)
|
||||
if let store = store as? LoggerStore {
|
||||
self.index = LoggerStoreIndex(store: store)
|
||||
} else {
|
||||
self.index = LoggerStoreIndex(context: store.backgroundContext)
|
||||
}
|
||||
self.filters = ConsoleFiltersViewModel(options: makeDefaultOptions())
|
||||
|
||||
self.logCountObserver = ManagedObjectsCountObserver(
|
||||
entity: LoggerMessageEntity.self,
|
||||
context: store.viewContext,
|
||||
sortDescriptior: NSSortDescriptor(keyPath: \LoggerMessageEntity.createdAt, ascending: false)
|
||||
context: store.viewContext
|
||||
)
|
||||
|
||||
self.taskCountObserver = ManagedObjectsCountObserver(
|
||||
entity: NetworkTaskEntity.self,
|
||||
context: store.viewContext,
|
||||
sortDescriptior: NSSortDescriptor(keyPath: \NetworkTaskEntity.createdAt, ascending: false)
|
||||
context: store.viewContext
|
||||
)
|
||||
|
||||
bind()
|
||||
@@ -90,7 +104,7 @@ final class ConsoleEnvironment: ObservableObject {
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshCountObservers(_ options: ConsoleDataSource.PredicateOptions) {
|
||||
private func refreshCountObservers(_ options: ConsoleListPredicateOptions) {
|
||||
func makePredicate(for mode: ConsoleMode) -> NSPredicate? {
|
||||
ConsoleDataSource.makePredicate(mode: mode, options: options)
|
||||
}
|
||||
@@ -98,7 +112,80 @@ final class ConsoleEnvironment: ObservableObject {
|
||||
taskCountObserver.setPredicate(makePredicate(for: .network))
|
||||
}
|
||||
|
||||
func removeAllLogs() {
|
||||
/// Returns the display options to apply to the given network task,
|
||||
/// consulting ``delegate`` and falling back to ``UserSettings/shared``.
|
||||
@MainActor
|
||||
package func listDisplayOptions(for task: NetworkTaskEntity) -> ConsoleListDisplaySettings {
|
||||
delegate?.console(listDisplayOptionsFor: task) ?? UserSettings.shared.listDisplayOptions
|
||||
}
|
||||
|
||||
/// Returns the text for a header/footer field, redacted through the
|
||||
/// ``delegate`` when available. Numeric / enum fields (sizes, durations,
|
||||
/// status codes, etc.) are passed through unchanged.
|
||||
@MainActor
|
||||
package func makeInfoText(for field: ConsoleListDisplaySettings.TaskField, task: NetworkTaskEntity) -> String? {
|
||||
guard let value = task.makeInfoText(for: field) else { return nil }
|
||||
return redact(value, field: field, task: task)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
package func makeInfoItem(for field: ConsoleListDisplaySettings.TaskField, task: NetworkTaskEntity) -> NetworkTaskEntity.InfoItem? {
|
||||
guard let value = makeInfoText(for: field, task: task) else { return nil }
|
||||
return NetworkTaskEntity.InfoItem(field: field, value: value)
|
||||
}
|
||||
|
||||
/// Returns the inspector's short title for a task, redacted through the
|
||||
/// ``delegate`` when available.
|
||||
@MainActor
|
||||
package func shortTitle(for task: NetworkTaskEntity) -> String {
|
||||
let options = listDisplayOptions(for: task)
|
||||
let value = task.getShortTitle(options: options)
|
||||
guard let delegate, !value.isEmpty else { return value }
|
||||
let field: ConsoleRedactionField = (options.content.showTaskDescription &&
|
||||
!(task.taskDescription ?? "").isEmpty) ? .taskDescription : .url
|
||||
return delegate.console(redact: value, field: field, for: task)
|
||||
}
|
||||
|
||||
/// Returns the main cell content string, redacted through the ``delegate``
|
||||
/// when available.
|
||||
@MainActor
|
||||
package func formattedContent(for task: NetworkTaskEntity, settings: ConsoleListDisplaySettings.ContentSettings) -> String? {
|
||||
guard let value = task.getFormattedContent(settings: settings) else { return nil }
|
||||
guard let delegate else { return value }
|
||||
let redactionField: ConsoleRedactionField
|
||||
if settings.customText != nil {
|
||||
redactionField = .custom
|
||||
} else if settings.showTaskDescription,
|
||||
let description = task.taskDescription, !description.isEmpty {
|
||||
redactionField = .taskDescription
|
||||
} else {
|
||||
redactionField = .url
|
||||
}
|
||||
return delegate.console(redact: value, field: redactionField, for: task)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func redact(_ value: String, field: ConsoleListDisplaySettings.TaskField, task: NetworkTaskEntity) -> String {
|
||||
guard let delegate else { return value }
|
||||
let redactionField: ConsoleRedactionField
|
||||
switch field {
|
||||
case .url: redactionField = .url
|
||||
case .host: redactionField = .host
|
||||
case .requestHeaderField(let name): redactionField = .requestHeader(name)
|
||||
case .responseHeaderField(let name): redactionField = .responseHeader(name)
|
||||
case .taskDescription: redactionField = .taskDescription
|
||||
case .custom: redactionField = .custom
|
||||
case .method, .requestSize, .responseSize, .responseContentType,
|
||||
.duration, .statusCode, .taskType:
|
||||
return value
|
||||
}
|
||||
return delegate.console(redact: value, field: redactionField, for: task)
|
||||
}
|
||||
|
||||
package func removeAllLogs() {
|
||||
guard !store.isReadonly else {
|
||||
return
|
||||
}
|
||||
store.removeAll()
|
||||
index.clear()
|
||||
|
||||
@@ -108,7 +195,7 @@ final class ConsoleEnvironment: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConsoleMode: String {
|
||||
public enum ConsoleMode: String, Sendable {
|
||||
/// Displays both messages and network tasks with the ability
|
||||
/// to switch between the two modes.
|
||||
case all
|
||||
@@ -119,12 +206,29 @@ public enum ConsoleMode: String {
|
||||
|
||||
package var hasLogs: Bool { self == .all || self == .logs }
|
||||
package var hasNetwork: Bool { self == .all || self == .network }
|
||||
|
||||
package func formattedCount(_ count: Int) -> String {
|
||||
let unit: String
|
||||
switch self {
|
||||
case .network: unit = count == 1 ? "Task" : "Tasks"
|
||||
case .logs: unit = count == 1 ? "Log" : "Logs"
|
||||
case .all: unit = count == 1 ? "Item" : "Items"
|
||||
}
|
||||
return "\(count) \(unit)"
|
||||
}
|
||||
|
||||
package var entityName: String {
|
||||
switch self {
|
||||
case .all, .logs: return "\(LoggerMessageEntity.self)"
|
||||
case .network: return "\(NetworkTaskEntity.self)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Environment
|
||||
|
||||
private struct LoggerStoreKey: EnvironmentKey {
|
||||
static let defaultValue: LoggerStore = .shared
|
||||
static let defaultValue: LoggerStoreProtocol = LoggerStore.shared
|
||||
}
|
||||
|
||||
private struct ConsoleRouterKey: EnvironmentKey {
|
||||
@@ -132,7 +236,7 @@ private struct ConsoleRouterKey: EnvironmentKey {
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
package var store: LoggerStore {
|
||||
package var store: LoggerStoreProtocol {
|
||||
get { self[LoggerStoreKey.self] }
|
||||
set { self[LoggerStoreKey.self] = newValue }
|
||||
}
|
||||
@@ -144,15 +248,16 @@ extension EnvironmentValues {
|
||||
}
|
||||
|
||||
extension View {
|
||||
func injecting(_ environment: ConsoleEnvironment) -> some View {
|
||||
self.background(ConsoleRouterView()) // important: order
|
||||
.environmentObject(environment)
|
||||
.environmentObject(environment.router)
|
||||
.environmentObject(environment.index)
|
||||
.environmentObject(environment.filters)
|
||||
.environmentObject(UserSettings.shared)
|
||||
.environment(\.router, environment.router)
|
||||
.environment(\.store, environment.store)
|
||||
.environment(\.managedObjectContext, environment.store.viewContext)
|
||||
package func injecting(_ environment: ConsoleEnvironment) -> some View {
|
||||
self.background(
|
||||
ConsoleRouterView(router: environment.router)
|
||||
)
|
||||
// important: order
|
||||
.environmentObject(environment)
|
||||
.environmentObject(environment.filters)
|
||||
.environmentObject(UserSettings.shared)
|
||||
.environment(\.router, environment.router)
|
||||
.environment(\.store, environment.store)
|
||||
.environment(\.managedObjectContext, environment.store.viewContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
|
||||
@@ -8,7 +8,9 @@ import SwiftUI
|
||||
import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
#if os(iOS)
|
||||
import WatchConnectivity
|
||||
#endif
|
||||
|
||||
public struct ConsoleView: View {
|
||||
@StateObject private var environment: ConsoleEnvironment // Never reloads
|
||||
@@ -20,29 +22,27 @@ public struct ConsoleView: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if #available(iOS 16, *) {
|
||||
if #available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) {
|
||||
contents
|
||||
} else {
|
||||
PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 16 or later").padding()
|
||||
PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 18 or later").padding()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private var contents: some View {
|
||||
ConsoleListView()
|
||||
.navigationTitle(environment.title)
|
||||
#if os(iOS) || os(visionOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
if !isCloseButtonHidden && presentationMode.wrappedValue.isPresented {
|
||||
Button("Close") {
|
||||
makeButton(role: .close) {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
trailingNavigationBarItems
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.injecting(environment)
|
||||
}
|
||||
|
||||
@@ -52,31 +52,17 @@ public struct ConsoleView: View {
|
||||
copy.isCloseButtonHidden = isHidden
|
||||
return copy
|
||||
}
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@ViewBuilder private var trailingNavigationBarItems: some View {
|
||||
Button(action: { environment.router.isShowingShareStore = true }) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
Button(action: { environment.router.isShowingFilters = true }) {
|
||||
Image(systemName: "line.horizontal.3.decrease.circle")
|
||||
}
|
||||
ConsoleContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ConsoleView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
NavigationView {
|
||||
ConsoleView(environment: .init(store: .mock))
|
||||
}.previewDisplayName("Console")
|
||||
NavigationView {
|
||||
ConsoleView(store: .mock, mode: .network)
|
||||
}.previewDisplayName("Network")
|
||||
}
|
||||
@available(iOS 18, macOS 15, visionOS 1, *)
|
||||
#Preview("Console") {
|
||||
NavigationStack {
|
||||
ConsoleView(store: LoggerStore.mock, delegate: MockConsoleDelegate.shared)
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(width: 500, height: 600)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
@@ -19,6 +19,15 @@ public struct ConsoleView: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if #available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) {
|
||||
contents
|
||||
} else {
|
||||
PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 18 or later").padding()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private var contents: some View {
|
||||
GeometryReader { _ in
|
||||
HStack {
|
||||
List {
|
||||
@@ -37,16 +46,18 @@ public struct ConsoleView: View {
|
||||
.onAppear { listViewModel.isViewVisible = true }
|
||||
.onDisappear { listViewModel.isViewVisible = false }
|
||||
}
|
||||
.disableScrollClip()
|
||||
.scrollClipDisabled()
|
||||
.injecting(environment)
|
||||
.environmentObject(listViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct ConsoleMenuView: View {
|
||||
@EnvironmentObject private var viewModel: ConsoleFiltersViewModel
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
@Environment(\.store) private var store
|
||||
@Environment(\.router) private var router
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
@@ -60,7 +71,12 @@ private struct ConsoleMenuView: View {
|
||||
Label(environment.bindingForNetworkMode.wrappedValue ? "Network Filters" : "Message Filters", systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
} header: { Text("Quick Filters") }
|
||||
if !(store.options.contains(.readonly)) {
|
||||
Section {
|
||||
Button(action: { router.isShowingSessions = true }) {
|
||||
Label("Sessions", systemImage: "list.clipboard")
|
||||
}
|
||||
} header: { Text("Sessions") }
|
||||
if !store.isReadonly {
|
||||
Section {
|
||||
if #available(iOS 16, tvOS 16, *) {
|
||||
NavigationLink {
|
||||
@@ -84,8 +100,11 @@ private struct ConsoleMenuView: View {
|
||||
} header: { Text("Settings") }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var destinationSettings: some View {
|
||||
SettingsView(store: store).padding()
|
||||
if let store = store as? LoggerStore {
|
||||
SettingsView(store: store).padding()
|
||||
}
|
||||
}
|
||||
|
||||
private var destinationFilters: some View {
|
||||
@@ -105,11 +124,10 @@ extension View {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ConsoleView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
ConsoleView(store: .mock)
|
||||
}
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ConsoleView(store: .mock)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(watchOS)
|
||||
|
||||
@@ -9,25 +9,39 @@ import Pulse
|
||||
|
||||
public struct ConsoleView: View {
|
||||
@StateObject private var environment: ConsoleEnvironment
|
||||
@StateObject private var listViewModel: IgnoringUpdates<ConsoleListViewModel>
|
||||
@StateObject private var listViewModel: ConsoleListViewModel
|
||||
|
||||
init(environment: ConsoleEnvironment) {
|
||||
_environment = StateObject(wrappedValue: environment)
|
||||
let listViewModel = ConsoleListViewModel(environment: environment, filters: environment.filters)
|
||||
_listViewModel = StateObject(wrappedValue: .init(listViewModel))
|
||||
_listViewModel = StateObject(wrappedValue: listViewModel)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if #available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) {
|
||||
contents
|
||||
} else {
|
||||
PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 18 or later").padding()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private var contents: some View {
|
||||
List {
|
||||
ConsoleToolbarView(environment: environment)
|
||||
ConsoleListContentView()
|
||||
.environmentObject(listViewModel.value)
|
||||
.environmentObject(listViewModel)
|
||||
}
|
||||
.navigationTitle(environment.title)
|
||||
.onAppear { listViewModel.value.isViewVisible = true }
|
||||
.onDisappear { listViewModel.value.isViewVisible = false }
|
||||
.navigationTitle(environment.mode.formattedCount(listViewModel.entities.count))
|
||||
.onAppear { listViewModel.isViewVisible = true }
|
||||
.onDisappear { listViewModel.isViewVisible = false }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button(action: { environment.router.isShowingSessions = true }) {
|
||||
Label("Sessions", systemImage: "list.clipboard")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(action: { environment.router.isShowingSettings = true }) {
|
||||
Image(systemName: "gearshape").font(.title3)
|
||||
}
|
||||
@@ -65,7 +79,8 @@ private struct ConsoleToolbarView: View {
|
||||
}
|
||||
.background(viewModel.isDefaultFilters(for: environment.mode) ? nil : Rectangle().foregroundColor(.blue).cornerRadius(8))
|
||||
}
|
||||
.font(.title3)
|
||||
.imageScale(.large)
|
||||
.font(.footnote)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowBackground(Color.clear)
|
||||
.buttonStyle(.bordered)
|
||||
@@ -74,13 +89,12 @@ private struct ConsoleToolbarView: View {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ConsoleView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
ConsoleView(store: .mock)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ConsoleView(store: .mock)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if !os(macOS)
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
|
||||
#if !os(macOS)
|
||||
|
||||
extension ConsoleView {
|
||||
/// Initializes the console view.
|
||||
///
|
||||
///
|
||||
/// - parameters:
|
||||
/// - store: The store to display. By default, `LoggerStore/shared`.
|
||||
/// - mode: The initial console mode. By default, ``ConsoleMode/all``. If you change
|
||||
/// the mode to ``ConsoleMode/network``, the console will display the
|
||||
/// network messages up on appearance.
|
||||
/// - delegate: The delegate that allows you to customize multiple aspects
|
||||
/// of the console view.
|
||||
/// - delegate: An optional ``ConsoleDelegate`` that can customize how
|
||||
/// individual tasks are rendered — e.g., vary
|
||||
/// ``ConsoleListDisplaySettings`` per task. By default, the console
|
||||
/// uses ``UserSettings/shared``.
|
||||
public init(
|
||||
store: LoggerStore = .shared,
|
||||
mode: ConsoleMode = .all
|
||||
mode: ConsoleMode = .all,
|
||||
delegate: (any ConsoleDelegate)? = nil
|
||||
) {
|
||||
self.init(environment: .init(store: store, mode: mode))
|
||||
self.init(environment: .init(store: store, mode: mode, delegate: delegate))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
|
||||
/// Persists section names the user has explicitly collapsed per
|
||||
/// `(mode, groupBy)` in a single JSON file shared across stores.
|
||||
final class CollapsedSectionsCache {
|
||||
static let shared = CollapsedSectionsCache()
|
||||
|
||||
private let fileURL: URL
|
||||
private var cache: [String: Set<String>]
|
||||
|
||||
init(fileURL: URL = URL.temp.appending(filename: "pulse-collapsed-sections.json")) {
|
||||
self.fileURL = fileURL
|
||||
self.cache = Self.load(from: fileURL)
|
||||
}
|
||||
|
||||
func sections(forKey key: String) -> Set<String> {
|
||||
cache[key] ?? []
|
||||
}
|
||||
|
||||
func setSections(_ sections: Set<String>, forKey key: String) {
|
||||
if sections.isEmpty {
|
||||
guard cache.removeValue(forKey: key) != nil else { return }
|
||||
} else {
|
||||
guard cache[key] != sections else { return }
|
||||
cache[key] = sections
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
private static func load(from url: URL) -> [String: Set<String>] {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode([String: Set<String>].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let data = try? JSONEncoder().encode(cache) else { return }
|
||||
try? data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
package protocol ConsoleDataSourceDelegate: AnyObject {
|
||||
/// The data source reloaded the entire dataset.
|
||||
func dataSourceDidRefresh(_ dataSource: ConsoleDataSource)
|
||||
|
||||
/// An incremental update. If the diff is nil, it means the app is displaying
|
||||
/// a grouped view that doesn't support diffing.
|
||||
func dataSource(_ dataSource: ConsoleDataSource, didUpdateWith diff: CollectionDifference<NSManagedObjectID>?)
|
||||
}
|
||||
|
||||
package final class ConsoleDataSource: NSObject, NSFetchedResultsControllerDelegate {
|
||||
package weak var delegate: ConsoleDataSourceDelegate?
|
||||
|
||||
/// - warning: Incompatible with the "group by" option.
|
||||
package var sortDescriptors: [NSSortDescriptor] = [] {
|
||||
didSet { controller.fetchRequest.sortDescriptors = sortDescriptors }
|
||||
}
|
||||
|
||||
package var predicate: ConsoleListPredicateOptions = .init() {
|
||||
didSet { refreshPredicate() }
|
||||
}
|
||||
|
||||
package var filter: NSPredicate? {
|
||||
didSet { refreshPredicate() }
|
||||
}
|
||||
|
||||
package static let fetchBatchSize = 100
|
||||
|
||||
package let store: LoggerStoreProtocol
|
||||
package let mode: ConsoleMode
|
||||
private let options: ConsoleListOptions
|
||||
private let controller: NSFetchedResultsController<NSManagedObject>
|
||||
private var controllerDelegate: NSFetchedResultsControllerDelegate?
|
||||
private var cancellables: [AnyCancellable] = []
|
||||
|
||||
package init(store: LoggerStoreProtocol, mode: ConsoleMode, options: ConsoleListOptions = .init()) {
|
||||
self.store = store
|
||||
self.mode = mode
|
||||
self.options = options
|
||||
|
||||
let sortKey: String
|
||||
let grouping: ConsoleListGroupBy
|
||||
|
||||
switch mode {
|
||||
case .all, .logs:
|
||||
sortKey = options.messageSortBy.key
|
||||
grouping = options.messageGroupBy
|
||||
case .network:
|
||||
sortKey = options.taskSortBy.key
|
||||
grouping = options.taskGroupBy
|
||||
}
|
||||
let entityName = mode.entityName
|
||||
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
request.sortDescriptors = [
|
||||
grouping.key.flatMap {
|
||||
guard $0 != "session" else { return nil }
|
||||
return NSSortDescriptor(key: $0, ascending: grouping.isAscending)
|
||||
},
|
||||
NSSortDescriptor(key: sortKey, ascending: options.order == .ascending)
|
||||
].compactMap { $0 }
|
||||
request.fetchBatchSize = ConsoleDataSource.fetchBatchSize
|
||||
if mode != .network {
|
||||
request.relationshipKeyPathsForPrefetching = ["request"]
|
||||
}
|
||||
controller = NSFetchedResultsController(
|
||||
fetchRequest: request,
|
||||
managedObjectContext: store.viewContext,
|
||||
sectionNameKeyPath: grouping.key,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
super.init()
|
||||
|
||||
controllerDelegate = {
|
||||
if grouping.key == nil {
|
||||
let delegate = ConsoleFetchDelegate()
|
||||
delegate.delegate = self
|
||||
return delegate
|
||||
} else {
|
||||
let delegate = ConsoleGroupedFetchDelegate()
|
||||
delegate.delegate = self
|
||||
return delegate
|
||||
}
|
||||
}()
|
||||
controller.delegate = controllerDelegate
|
||||
}
|
||||
|
||||
package func bind(_ filters: ConsoleFiltersViewModel) {
|
||||
cancellables = []
|
||||
filters.$options.sink { [weak self] in
|
||||
self?.predicate = $0
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
package func refresh() {
|
||||
try? controller.performFetch()
|
||||
delegate?.dataSourceDidRefresh(self)
|
||||
}
|
||||
|
||||
// MARK: Accessing Entities
|
||||
|
||||
package var numberOfObjects: Int {
|
||||
controller.fetchedObjects?.count ?? 0
|
||||
}
|
||||
|
||||
package func object(at indexPath: IndexPath) -> NSManagedObject {
|
||||
controller.object(at: indexPath)
|
||||
}
|
||||
|
||||
package var entities: [NSManagedObject] {
|
||||
controller.fetchedObjects ?? []
|
||||
}
|
||||
|
||||
package var sections: [NSFetchedResultsSectionInfo]? {
|
||||
controller.sectionNameKeyPath == nil ? nil : controller.sections
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
// MARK: Search
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
package func makeSearchSession(
|
||||
parameters: ConsoleSearchParameters,
|
||||
extendedPredicate: NSPredicate? = nil,
|
||||
extendedFetchLimit: Int = 1000
|
||||
) -> ConsoleSearchSession {
|
||||
ConsoleSearchSession(
|
||||
store: store,
|
||||
mode: mode,
|
||||
primaryPredicate: controller.fetchRequest.predicate,
|
||||
extendedPredicate: extendedPredicate,
|
||||
extendedFetchLimit: extendedFetchLimit,
|
||||
sortDescriptors: controller.fetchRequest.sortDescriptors ?? [],
|
||||
parameters: parameters
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: NSFetchedResultsControllerDelegate
|
||||
|
||||
package func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
delegate?.dataSource(self, didUpdateWith: nil)
|
||||
}
|
||||
|
||||
package func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>) {
|
||||
delegate?.dataSource(self, didUpdateWith: diff)
|
||||
}
|
||||
|
||||
// MARK: Predicate
|
||||
|
||||
private func refreshPredicate() {
|
||||
let predicate = ConsoleDataSource.makePredicate(mode: mode, options: predicate, filter: filter)
|
||||
controller.fetchRequest.predicate = predicate
|
||||
refresh()
|
||||
}
|
||||
|
||||
package static func makePredicate(mode: ConsoleMode, options: ConsoleListPredicateOptions, filter: NSPredicate? = nil) -> NSPredicate? {
|
||||
let predicates = [
|
||||
_makePredicate(mode, options),
|
||||
options.predicate,
|
||||
options.focus,
|
||||
filter
|
||||
].compactMap { $0 }
|
||||
switch predicates.count {
|
||||
case 0: return nil
|
||||
case 1: return predicates[0]
|
||||
default: return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
}
|
||||
}
|
||||
|
||||
package func name(for section: NSFetchedResultsSectionInfo) -> String {
|
||||
makeName(for: section, mode: mode, options: options)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Predicates
|
||||
|
||||
private func _makePredicate(_ mode: ConsoleMode, _ options: ConsoleListPredicateOptions) -> NSPredicate? {
|
||||
let filters = options.filters
|
||||
let sessions = options.sessions
|
||||
let isOnlyErrors = options.isOnlyErrors
|
||||
|
||||
func makeMessagesPredicate(isMessageOnly: Bool) -> NSPredicate? {
|
||||
var predicates: [NSPredicate] = []
|
||||
if isMessageOnly {
|
||||
predicates.append(NSPredicate(format: "task == NULL"))
|
||||
}
|
||||
if let predicate = ConsoleFilters.makeMessagePredicates(criteria: filters, sessions: sessions, isOnlyErrors: isOnlyErrors) {
|
||||
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 .network:
|
||||
return ConsoleFilters.makeNetworkPredicates(criteria: filters, sessions: sessions, isOnlyErrors: isOnlyErrors)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section Names
|
||||
|
||||
private func makeName(for section: NSFetchedResultsSectionInfo, mode: ConsoleMode, options: ConsoleListOptions) -> String {
|
||||
switch mode {
|
||||
case .all, .logs:
|
||||
switch options.messageGroupBy {
|
||||
case .level:
|
||||
let rawValue = Int16(Int(section.name) ?? 0)
|
||||
return (LoggerStore.Level(rawValue: rawValue) ?? .debug).name.capitalized
|
||||
case .session:
|
||||
let date = (section.objects?.last as? LoggerMessageEntity)?.createdAt
|
||||
return date.map(sessionDateFormatter.string) ?? "–"
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .network:
|
||||
switch options.taskGroupBy {
|
||||
case .taskType:
|
||||
let rawValue = Int16(Int(section.name) ?? 0)
|
||||
return NetworkLogger.TaskType(rawValue: rawValue)?.urlSessionTaskClassName ?? section.name
|
||||
case .statusCode:
|
||||
let rawValue = Int32(section.name) ?? 0
|
||||
return StatusCodeFormatter.string(for: rawValue)
|
||||
case .requestState:
|
||||
let rawValue = Int16(Int(section.name) ?? 0)
|
||||
guard let state = NetworkTaskEntity.State(rawValue: rawValue) else {
|
||||
return "Unknown State"
|
||||
}
|
||||
switch state {
|
||||
case .pending: return "Pending"
|
||||
case .success: return "Success"
|
||||
case .failure: return "Failure"
|
||||
}
|
||||
case .session:
|
||||
let date = (section.objects?.last as? NetworkTaskEntity)?.createdAt
|
||||
return date.map(sessionDateFormatter.string) ?? "–"
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
let name = section.name
|
||||
return name.isEmpty ? "–" : name
|
||||
}
|
||||
|
||||
private let sessionDateFormatter = DateFormatter(dateStyle: .medium, timeStyle: .medium, isRelative: true)
|
||||
|
||||
// MARK: - Delegates
|
||||
|
||||
// Using a separate class because the diff API is not supported for a fetch
|
||||
// controller with sections, and it prints an error message in logs if the
|
||||
// delegate implements it, which we want to avoid.
|
||||
|
||||
private final class ConsoleFetchDelegate: NSObject, NSFetchedResultsControllerDelegate {
|
||||
weak var delegate: NSFetchedResultsControllerDelegate?
|
||||
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>) {
|
||||
delegate?.controller?(controller, didChangeContentWith: diff)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ConsoleGroupedFetchDelegate: NSObject, NSFetchedResultsControllerDelegate {
|
||||
weak var delegate: NSFetchedResultsControllerDelegate?
|
||||
|
||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
delegate?.controllerDidChangeContent?(controller)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,56 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
|
||||
#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) || os(watchOS)
|
||||
|
||||
import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleListContentView: View {
|
||||
var hidesPreviousSessionButton: Bool = false
|
||||
|
||||
@EnvironmentObject var viewModel: ConsoleListViewModel
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS) || os(visionOS)
|
||||
if let sections = viewModel.sections, !sections.isEmpty {
|
||||
ForEach(sections, id: \.name) { section in
|
||||
let isCollapsed = viewModel.collapsedSections.contains(section.name)
|
||||
Section {
|
||||
if !isCollapsed {
|
||||
ForEach((section.objects as? [NSManagedObject]) ?? [], id: \.objectID) { entity in
|
||||
ConsoleEntityCell(entity: entity)
|
||||
.id(entity.objectID)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Button(action: { withAnimation { viewModel.toggleSection(section.name) } }) {
|
||||
HStack {
|
||||
Text(viewModel.name(for: section))
|
||||
Text("\(section.numberOfObjects)")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.rotationEffect(.degrees(isCollapsed ? -90 : 0))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
plainView
|
||||
}
|
||||
#else
|
||||
plainView
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -23,7 +59,9 @@ struct ConsoleListContentView: View {
|
||||
Text("Empty")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
#if !os(watchOS)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16))
|
||||
#endif
|
||||
} else {
|
||||
ForEach(viewModel.visibleEntities, id: \.objectID) { entity in
|
||||
let objectID = entity.objectID
|
||||
@@ -39,31 +77,37 @@ struct ConsoleListContentView: View {
|
||||
}
|
||||
}
|
||||
footerView
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footerView: some View {
|
||||
if let session = viewModel.previousSession {
|
||||
Button(action: { viewModel.buttonShowPreviousSessionTapped(for: session) }) {
|
||||
Text("Show Previous Session")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.accentColor)
|
||||
Spacer()
|
||||
Text(session.formattedDate(isCompact: false))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
#if os(iOS) && os(macOS) || os(visionOS)
|
||||
if !hidesPreviousSessionButton, let session = viewModel.previousSession {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
viewModel.buttonShowPreviousSessionTapped(for: session)
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 10) {
|
||||
Spacer()
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Show Previous Session")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if os(iOS) || os(visionOS)
|
||||
.frame(height: 24)
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
package struct ConsoleStaticList: View {
|
||||
package let entities: [NSManagedObject]
|
||||
|
||||
|
||||
@@ -1,39 +1,50 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
|
||||
struct ConsoleListOptions: Equatable {
|
||||
var messageSortBy: MessageSortBy = .dateCreated
|
||||
var taskSortBy: TaskSortBy = .dateCreated
|
||||
var order: Ordering = .descending
|
||||
package struct ConsoleListOptions: Equatable {
|
||||
package var messageSortBy: MessageSortBy = .dateCreated
|
||||
package var taskSortBy: TaskSortBy = .dateCreated
|
||||
#if os(macOS)
|
||||
package var order: Ordering = .ascending
|
||||
#else
|
||||
package var order: Ordering = .descending
|
||||
#endif
|
||||
|
||||
enum Ordering: String, CaseIterable {
|
||||
package var messageGroupBy: MessageGroupBy = .noGrouping
|
||||
package var taskGroupBy: TaskGroupBy = .noGrouping
|
||||
|
||||
package init() {}
|
||||
|
||||
package enum Ordering: String, CaseIterable {
|
||||
case descending = "Descending"
|
||||
case ascending = "Ascending"
|
||||
}
|
||||
|
||||
enum MessageSortBy: String, CaseIterable {
|
||||
package enum MessageSortBy: String, CaseIterable {
|
||||
case dateCreated = "Date"
|
||||
case level = "Level"
|
||||
|
||||
var key: String {
|
||||
package var key: String {
|
||||
switch self {
|
||||
case .dateCreated: return "createdAt"
|
||||
case .level: return "level"
|
||||
}
|
||||
}
|
||||
|
||||
package var pillTitle: String { rawValue }
|
||||
}
|
||||
|
||||
enum TaskSortBy: String, CaseIterable {
|
||||
package enum TaskSortBy: String, CaseIterable {
|
||||
case dateCreated = "Date"
|
||||
case duration = "Duration"
|
||||
case requestSize = "Request Size"
|
||||
case responseSize = "Response Size"
|
||||
|
||||
var key: String {
|
||||
package var key: String {
|
||||
switch self {
|
||||
case .dateCreated: return "createdAt"
|
||||
case .duration: return "duration"
|
||||
@@ -41,5 +52,76 @@ struct ConsoleListOptions: Equatable {
|
||||
case .responseSize: return "responseBodySize"
|
||||
}
|
||||
}
|
||||
|
||||
package var pillTitle: String {
|
||||
switch self {
|
||||
case .dateCreated: return "Date"
|
||||
case .duration: return "Duration"
|
||||
case .requestSize: return "Size"
|
||||
case .responseSize: return "Size"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package enum MessageGroupBy: String, CaseIterable, ConsoleListGroupBy {
|
||||
case noGrouping = "No Grouping"
|
||||
case label = "Label"
|
||||
case level = "Level"
|
||||
case file = "File"
|
||||
case session = "Session"
|
||||
|
||||
package var key: String? {
|
||||
switch self {
|
||||
case .noGrouping: return nil
|
||||
case .label: return "label"
|
||||
case .level: return "level"
|
||||
case .file: return "file"
|
||||
case .session: return "session"
|
||||
}
|
||||
}
|
||||
|
||||
package var isAscending: Bool {
|
||||
switch self {
|
||||
case .noGrouping, .label, .file: return true
|
||||
case .level, .session: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package enum TaskGroupBy: String, CaseIterable, ConsoleListGroupBy {
|
||||
case noGrouping = "No Grouping"
|
||||
case url = "URL"
|
||||
case host = "Host"
|
||||
case method = "Method"
|
||||
case taskType = "Task Type"
|
||||
case statusCode = "Status Code"
|
||||
case errorCode = "Error Code"
|
||||
case requestState = "State"
|
||||
case responseContentType = "Content Type"
|
||||
case session = "Session"
|
||||
|
||||
package var key: String? {
|
||||
switch self {
|
||||
case .noGrouping: return nil
|
||||
case .url: return "url"
|
||||
case .host: return "host"
|
||||
case .method: return "httpMethod"
|
||||
case .taskType: return "taskType"
|
||||
case .statusCode: return "statusCode"
|
||||
case .errorCode: return "errorCode"
|
||||
case .requestState: return "requestState"
|
||||
case .responseContentType: return "responseContentType"
|
||||
case .session: return "session"
|
||||
}
|
||||
}
|
||||
|
||||
package var isAscending: Bool {
|
||||
self != .errorCode && self != .session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package protocol ConsoleListGroupBy {
|
||||
var key: String? { get }
|
||||
var isAscending: Bool { get }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
|
||||
@@ -9,72 +9,166 @@ import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleListView: View {
|
||||
var initialSearchText: String?
|
||||
|
||||
@EnvironmentObject var environment: ConsoleEnvironment
|
||||
@EnvironmentObject var filters: ConsoleFiltersViewModel
|
||||
|
||||
var body: some View {
|
||||
_InternalConsoleListView(environment: environment, filters: filters)
|
||||
_ConsoleListView(environment: environment, filters: filters, initialSearchText: initialSearchText)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
private struct _InternalConsoleListView: View {
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct _ConsoleListView: View {
|
||||
private let environment: ConsoleEnvironment
|
||||
|
||||
@StateObject private var listViewModel: IgnoringUpdates<ConsoleListViewModel>
|
||||
@StateObject private var listViewModel: ConsoleListViewModel
|
||||
@StateObject private var searchBarViewModel: ConsoleSearchBarViewModel
|
||||
@StateObject private var searchViewModel: IgnoringUpdates<ConsoleSearchViewModel>
|
||||
@StateObject private var searchViewModel: ConsoleSearchViewModel
|
||||
|
||||
init(environment: ConsoleEnvironment, filters: ConsoleFiltersViewModel) {
|
||||
@Environment(\.store) private var store
|
||||
@Environment(\.router) private var router
|
||||
|
||||
@State private var editMode: EditMode = .inactive
|
||||
@State private var selection = Set<NSManagedObjectID>()
|
||||
@State private var shareItems: ShareItems?
|
||||
|
||||
init(environment: ConsoleEnvironment, filters: ConsoleFiltersViewModel, initialSearchText: String? = nil) {
|
||||
self.environment = environment
|
||||
|
||||
let listViewModel = ConsoleListViewModel(environment: environment, filters: filters)
|
||||
let searchBarViewModel = ConsoleSearchBarViewModel()
|
||||
let searchViewModel = ConsoleSearchViewModel(environment: environment, source: listViewModel, searchBar: searchBarViewModel)
|
||||
if let initialSearchText {
|
||||
searchBarViewModel.text = initialSearchText
|
||||
}
|
||||
let searchViewModel = ConsoleSearchViewModel(environment: environment, searchBar: searchBarViewModel)
|
||||
searchViewModel.isSearching = initialSearchText != nil
|
||||
|
||||
_listViewModel = StateObject(wrappedValue: IgnoringUpdates(listViewModel))
|
||||
_listViewModel = StateObject(wrappedValue: listViewModel)
|
||||
_searchBarViewModel = StateObject(wrappedValue: searchBarViewModel)
|
||||
_searchViewModel = StateObject(wrappedValue: IgnoringUpdates(searchViewModel))
|
||||
_searchViewModel = StateObject(wrappedValue: searchViewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
contents
|
||||
.environmentObject(listViewModel.value)
|
||||
.environmentObject(searchViewModel.value)
|
||||
list
|
||||
.environmentObject(listViewModel)
|
||||
.environmentObject(searchViewModel)
|
||||
.environmentObject(searchBarViewModel)
|
||||
.onAppear { listViewModel.value.isViewVisible = true }
|
||||
.onDisappear { listViewModel.value.isViewVisible = false }
|
||||
.onAppear { listViewModel.isViewVisible = true }
|
||||
.onDisappear { listViewModel.isViewVisible = false }
|
||||
}
|
||||
|
||||
@ViewBuilder private var contents: some View {
|
||||
_ConsoleListView()
|
||||
.environment(\.defaultMinListRowHeight, 8)
|
||||
.searchable(text: $searchBarViewModel.text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.onSubmit(of: .search, searchViewModel.value.onSubmitSearch)
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
private struct _ConsoleListView: View {
|
||||
@Environment(\.isSearching) private var isSearching
|
||||
@Environment(\.store) private var store
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if isSearching {
|
||||
private var list: some View {
|
||||
List(selection: editMode.isEditing ? $selection : nil) {
|
||||
ConsoleToolbarView()
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 9, trailing: 16))
|
||||
if searchViewModel.isSearching {
|
||||
ConsoleSearchListContentView()
|
||||
} else {
|
||||
ConsoleToolbarView()
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 8, trailing: 16))
|
||||
ConsoleListContentView()
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.listSectionSpacing(0)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.environment(\.defaultMinListRowHeight, 8)
|
||||
.environment(\.editMode, $editMode)
|
||||
.animation(.default, value: editMode)
|
||||
.animation(.default, value: searchViewModel.isSearching)
|
||||
.searchable(text: $searchBarViewModel.text, isPresented: $searchViewModel.isSearching)
|
||||
.searchPresentationToolbarBehavior(.avoidHidingContent)
|
||||
.textInputAutocapitalization(.never)
|
||||
.onSubmit(of: .search, searchViewModel.onSubmitSearch)
|
||||
.disableAutocorrection(true)
|
||||
.toolbar { toolbar }
|
||||
.onChange(of: editMode) {
|
||||
if !$0.isEditing {
|
||||
selection.removeAll()
|
||||
}
|
||||
}
|
||||
.sheet(item: $searchViewModel.editingFilterState) { state in
|
||||
ConsoleCustomFilterEditSheet(filter: state.filter, fieldGroups: state.filter.availableFieldGroups) {
|
||||
searchViewModel.applyEditedFilter($0)
|
||||
}
|
||||
}
|
||||
.sheet(item: $shareItems, content: ShareView.init)
|
||||
}
|
||||
|
||||
// MARK: Toolbar
|
||||
|
||||
@ToolbarContentBuilder private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .principal) {
|
||||
if editMode.isEditing {
|
||||
Text(selectionTitle)
|
||||
.font(.headline)
|
||||
} else {
|
||||
ConsoleNavigationTitleView()
|
||||
}
|
||||
}
|
||||
if editMode.isEditing {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
editMode = .inactive
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
Spacer()
|
||||
}
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
shareMenu
|
||||
}
|
||||
} else {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
Button(action: { router.isShowingShareStore = true }) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
ConsoleContextMenu(editMode: $editMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Selection
|
||||
|
||||
private var selectionTitle: String {
|
||||
switch selection.count {
|
||||
case 0: return "Select Items"
|
||||
case 1: return "1 Selected"
|
||||
default: return "\(selection.count) Selected"
|
||||
}
|
||||
}
|
||||
|
||||
private var shareMenu: some View {
|
||||
Menu {
|
||||
Button(action: { shareSelectedEntities(as: .pdf) }) {
|
||||
Label("PDF", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Button(action: { shareSelectedEntities(as: .html) }) {
|
||||
Label("HTML", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button(action: { shareSelectedEntities(as: .plainText) }) {
|
||||
Label("Text", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
} label: {
|
||||
Label("Share...", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(selection.isEmpty)
|
||||
}
|
||||
|
||||
private func shareSelectedEntities(as output: ShareOutput) {
|
||||
let entities = selection.compactMap { objectID in
|
||||
listViewModel.entities.first(where: { $0.objectID == objectID })
|
||||
}
|
||||
guard !entities.isEmpty, let store = store as? LoggerStore else { return }
|
||||
Task {
|
||||
if let items = try? await ShareService.share(entities, store: store, as: output) {
|
||||
shareItems = items
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
@@ -8,7 +8,7 @@ import Pulse
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, ConsoleEntitiesSource {
|
||||
final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject {
|
||||
#if os(iOS) || os(visionOS)
|
||||
@Published private(set) var visibleEntities: ArraySlice<NSManagedObject> = []
|
||||
#else
|
||||
@@ -17,8 +17,15 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
|
||||
@Published private(set) var entities: [NSManagedObject] = []
|
||||
@Published private(set) var sections: [NSFetchedResultsSectionInfo]?
|
||||
|
||||
/// Names of grouped sections the user has collapsed. Restored per
|
||||
/// `(mode, groupBy)` from on-disk cache when the data source is rebuilt.
|
||||
@Published var collapsedSections: Set<String> = []
|
||||
|
||||
@Published private(set) var mode: ConsoleMode
|
||||
|
||||
private var currentGroupByKey: String?
|
||||
private let collapsedSectionsCache = CollapsedSectionsCache.shared
|
||||
|
||||
var isViewVisible = false {
|
||||
didSet {
|
||||
guard oldValue != isViewVisible else { return }
|
||||
@@ -31,6 +38,7 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
|
||||
}
|
||||
|
||||
@Published private(set) var previousSession: LoggerSessionEntity?
|
||||
@Published private(set) var allSessionsCount: Int = 0
|
||||
|
||||
let events = PassthroughSubject<ConsoleUpdateEvent, Never>()
|
||||
|
||||
@@ -41,11 +49,11 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
|
||||
private var visibleObjectIDs: Set<NSManagedObjectID> = []
|
||||
#endif
|
||||
|
||||
private let store: LoggerStore
|
||||
private let store: LoggerStoreProtocol
|
||||
private let environment: ConsoleEnvironment
|
||||
private let filters: ConsoleFiltersViewModel
|
||||
private let sessions: ManagedObjectsObserver<LoggerSessionEntity>
|
||||
private var dataSource: ConsoleDataSource?
|
||||
@Published package private(set) var dataSource: ConsoleDataSource?
|
||||
private var cancellables: [AnyCancellable] = []
|
||||
private var filtersCancellable: AnyCancellable?
|
||||
|
||||
@@ -55,12 +63,14 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
|
||||
self.mode = environment.mode
|
||||
self.filters = filters
|
||||
self.sessions = .sessions(for: store.viewContext)
|
||||
self.allSessionsCount = self.sessions.objects.count
|
||||
|
||||
$entities.sink { [weak self] in
|
||||
self?.filters.entities.send($0)
|
||||
}.store(in: &cancellables)
|
||||
|
||||
sessions.$objects.dropFirst().sink { [weak self] in
|
||||
self?.allSessionsCount = $0.count
|
||||
self?.refreshPreviousSessionButton(sessions: $0)
|
||||
}.store(in: &cancellables)
|
||||
|
||||
@@ -83,18 +93,55 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
|
||||
private func resetDataSource(options: ConsoleListOptions) {
|
||||
dataSource = ConsoleDataSource(store: store, mode: mode, options: options)
|
||||
dataSource?.delegate = self
|
||||
let groupByKey = Self.groupByKey(mode: mode, options: options)
|
||||
currentGroupByKey = groupByKey
|
||||
collapsedSections = collapsedSectionsCache.sections(forKey: groupByKey)
|
||||
filtersCancellable = filters.$options.sink { [weak self] in
|
||||
self?.dataSource?.predicate = $0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Collapsible Sections
|
||||
|
||||
func toggleSection(_ name: String) {
|
||||
if collapsedSections.contains(name) {
|
||||
collapsedSections.remove(name)
|
||||
} else {
|
||||
collapsedSections.insert(name)
|
||||
}
|
||||
if let key = currentGroupByKey {
|
||||
collapsedSectionsCache.setSections(collapsedSections, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func collapseAllSections() {
|
||||
collapsedSections = Set((sections ?? []).map(\.name))
|
||||
}
|
||||
|
||||
func expandAllSections() {
|
||||
collapsedSections.removeAll()
|
||||
}
|
||||
|
||||
private static func groupByKey(mode: ConsoleMode, options: ConsoleListOptions) -> String {
|
||||
let groupBy: String
|
||||
switch mode {
|
||||
case .all, .logs: groupBy = options.messageGroupBy.rawValue
|
||||
case .network: groupBy = options.taskGroupBy.rawValue
|
||||
}
|
||||
return "\(mode.rawValue):\(groupBy)"
|
||||
}
|
||||
|
||||
func buttonShowPreviousSessionTapped(for session: LoggerSessionEntity) {
|
||||
filters.criteria.shared.sessions.selection.insert(session.id)
|
||||
filters.sessions.insert(session.id)
|
||||
refreshPreviousSessionButton(sessions: self.sessions.objects)
|
||||
}
|
||||
|
||||
private func refreshPreviousSessionButton(sessions: [LoggerSessionEntity]) {
|
||||
let selection = filters.criteria.shared.sessions.selection
|
||||
let selection = filters.sessions
|
||||
guard !selection.isEmpty else {
|
||||
previousSession = nil
|
||||
return
|
||||
}
|
||||
let isDisplayingPrefix = sessions.prefix(selection.count).allSatisfy {
|
||||
selection.contains($0.id)
|
||||
}
|
||||
@@ -112,6 +159,8 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
|
||||
guard isViewVisible else { return }
|
||||
|
||||
entities = dataSource.entities
|
||||
sections = dataSource.sections
|
||||
refreshPreviousSessionButton(sessions: sessions.objects)
|
||||
#if os(iOS) || os(visionOS)
|
||||
refreshVisibleEntities()
|
||||
#endif
|
||||
@@ -120,6 +169,7 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
|
||||
|
||||
func dataSource(_ dataSource: ConsoleDataSource, didUpdateWith diff: CollectionDifference<NSManagedObjectID>?) {
|
||||
entities = dataSource.entities
|
||||
sections = dataSource.sections
|
||||
#if os(iOS) || os(visionOS)
|
||||
if scrollPosition == .nearTop {
|
||||
refreshVisibleEntities()
|
||||
@@ -128,6 +178,10 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
|
||||
events.send(.update(diff))
|
||||
}
|
||||
|
||||
func name(for section: NSFetchedResultsSectionInfo) -> String {
|
||||
dataSource?.name(for: section) ?? ""
|
||||
}
|
||||
|
||||
// MARK: Visible Entities
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
package struct ConsoleListPredicateOptions: @unchecked Sendable {
|
||||
package var filters = ConsoleFilters()
|
||||
package var sessions: Set<UUID> = []
|
||||
package var isOnlyErrors = false
|
||||
package var predicate: NSPredicate?
|
||||
package var focus: NSPredicate?
|
||||
|
||||
package init() {}
|
||||
}
|
||||
@@ -1,28 +1,52 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleContextMenu: View {
|
||||
#if os(iOS) || os(visionOS)
|
||||
@Binding var editMode: EditMode
|
||||
#endif
|
||||
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
@Environment(\.router) private var router
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
#if os(iOS) || os(visionOS)
|
||||
Section {
|
||||
Button(action: { editMode = .active }) {
|
||||
Label("Select", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Section {
|
||||
Button(action: { router.isShowingFilters = true }) {
|
||||
Label("Filters", systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
Button(action: { router.isShowingSessions = true }) {
|
||||
Label("Sessions", systemImage: "list.bullet.clipboard")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ConsoleSortByMenu()
|
||||
Menu {
|
||||
ConsoleSortByMenuContent()
|
||||
} label: {
|
||||
Label("Sort By", systemImage: "arrow.up.arrow.down")
|
||||
}
|
||||
Menu {
|
||||
ConsoleGroupByMenuContent()
|
||||
ConsoleRemoveGroupingButton()
|
||||
} label: {
|
||||
Label("Group By", systemImage: "rectangle.3.group")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
if !UserDefaults.standard.bool(forKey: "pulse-disable-settings-prompts") {
|
||||
@@ -30,8 +54,8 @@ struct ConsoleContextMenu: View {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
|
||||
if !environment.store.options.contains(.readonly) {
|
||||
|
||||
if !environment.store.isReadonly {
|
||||
Button(role: .destructive, action: environment.removeAllLogs) {
|
||||
Label("Remove Logs", systemImage: "trash")
|
||||
}
|
||||
@@ -42,6 +66,9 @@ struct ConsoleContextMenu: View {
|
||||
Button(action: buttonGetPulseProTapped) {
|
||||
Label("Get Pulse Pro", systemImage: "link")
|
||||
}
|
||||
Button(action: buttonSponsorTapped) {
|
||||
Label("Sponsor", systemImage: "heart")
|
||||
}
|
||||
}
|
||||
if !UserDefaults.standard.bool(forKey: "pulse-disable-report-issue-prompts") {
|
||||
Button(action: buttonSendFeedbackTapped) {
|
||||
@@ -50,7 +77,7 @@ struct ConsoleContextMenu: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,40 +85,21 @@ struct ConsoleContextMenu: View {
|
||||
URL(string: "https://pulselogger.com").map(openURL)
|
||||
}
|
||||
|
||||
private func buttonSponsorTapped() {
|
||||
URL(string: "https://github.com/sponsors/kean").map(openURL)
|
||||
}
|
||||
|
||||
private func buttonSendFeedbackTapped() {
|
||||
URL(string: "https://github.com/kean/Pulse/issues").map(openURL)
|
||||
}
|
||||
|
||||
private func openURL(_ url: URL) {
|
||||
#if os(macOS)
|
||||
NSWorkspace.shared.open(url)
|
||||
#else
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConsoleSortByMenu: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
|
||||
var body: some View {
|
||||
Menu(content: {
|
||||
if environment.mode == .network {
|
||||
Picker("Sort By", selection: $environment.listOptions.taskSortBy) {
|
||||
ForEach(ConsoleListOptions.TaskSortBy.allCases, id: \.self) {
|
||||
Text($0.rawValue).tag($0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Picker("Sort By", selection: $environment.listOptions.messageSortBy) {
|
||||
ForEach(ConsoleListOptions.MessageSortBy.allCases, id: \.self) {
|
||||
Text($0.rawValue).tag($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Picker("Ordering", selection: $environment.listOptions.order) {
|
||||
Text("Descending").tag(ConsoleListOptions.Ordering.descending)
|
||||
Text("Ascending").tag(ConsoleListOptions.Ordering.ascending)
|
||||
}
|
||||
}, label: {
|
||||
Label("Sort By", systemImage: "arrow.up.arrow.down")
|
||||
})
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
|
||||
#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) || os(watchOS)
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
import CoreData
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleEntityCell: View {
|
||||
let entity: NSManagedObject
|
||||
var urlMatch: ConsoleSearchMatch?
|
||||
|
||||
init(entity: NSManagedObject) {
|
||||
self.entity = entity
|
||||
}
|
||||
|
||||
consuming func urlMatch(_ match: ConsoleSearchMatch?) -> ConsoleEntityCell {
|
||||
self.urlMatch = match
|
||||
return self
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch LoggerEntity(entity) {
|
||||
case .message(let message):
|
||||
_ConsoleMessageCell(message: message)
|
||||
case .task(let task):
|
||||
_ConsoleTaskCell(task: task)
|
||||
if entity.isDeleted {
|
||||
EmptyView()
|
||||
} else {
|
||||
switch LoggerEntity(entity) {
|
||||
case .message(let message):
|
||||
_ConsoleMessageCell(message: message)
|
||||
case .task(let task):
|
||||
_ConsoleTaskCell(task: task, urlMatch: urlMatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct _ConsoleMessageCell: View {
|
||||
let message: LoggerMessageEntity
|
||||
|
||||
@@ -59,9 +73,10 @@ private struct _ConsoleMessageCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct _ConsoleTaskCell: View {
|
||||
let task: NetworkTaskEntity
|
||||
var urlMatch: ConsoleSearchMatch?
|
||||
@State private var shareItems: ShareItems?
|
||||
@State private var sharedTask: NetworkTaskEntity?
|
||||
@Environment(\.store) private var store
|
||||
@@ -69,11 +84,11 @@ private struct _ConsoleTaskCell: View {
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS) || os(visionOS)
|
||||
let cell = ConsoleTaskCell(task: task, isDisclosureNeeded: true)
|
||||
let cell = ConsoleTaskCell(task: task, isDisclosureNeeded: true).urlMatch(urlMatch)
|
||||
.background(NavigationLink("", destination: inspector).opacity(0))
|
||||
#else
|
||||
let cell = NavigationLink(destination: inspector) {
|
||||
ConsoleTaskCell(task: task)
|
||||
ConsoleTaskCell(task: task).urlMatch(urlMatch)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -95,6 +110,9 @@ private struct _ConsoleTaskCell: View {
|
||||
#else
|
||||
ContextMenu.NetworkTaskContextMenuItems(task: task, sharedTask: $sharedTask)
|
||||
#endif
|
||||
if let custom = environment.delegate?.console(contextMenuFor: task) {
|
||||
custom
|
||||
}
|
||||
}
|
||||
#if os(iOS) || os(visionOS)
|
||||
.sheet(item: $shareItems, content: ShareView.init)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleTimestampView: View {
|
||||
let date: Date
|
||||
let timestamp: String
|
||||
|
||||
var body: some View {
|
||||
Text(ConsoleMessageCell.timeFormatter.string(from: date))
|
||||
Text(timestamp)
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 21))
|
||||
#else
|
||||
@@ -22,8 +22,9 @@ struct ConsoleTimestampView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct MockBadgeView: View {
|
||||
var body: some View {
|
||||
package struct MockBadgeView: View {
|
||||
package init() {}
|
||||
package var body: some View {
|
||||
Text("MOCK")
|
||||
.foregroundStyle(.background)
|
||||
.font(.caption2.weight(.semibold))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -23,7 +23,7 @@ public struct ConsoleListDisplaySettings: Hashable, Codable {
|
||||
/// The line limit for messages in the console. By default, `1`.
|
||||
public var lineLimit: Int
|
||||
|
||||
/// Additinoal fields to display below the in the header.
|
||||
/// Additional fields to display below the in the header.
|
||||
public var fields: [TaskField]
|
||||
|
||||
public init(fontSize: Int? = nil, lineLimit: Int = 1, fields: [TaskField]? = nil) {
|
||||
@@ -65,18 +65,26 @@ public struct ConsoleListDisplaySettings: Hashable, Codable {
|
||||
/// If enabled, use monospaced font to display the content.
|
||||
public var isMonospaced = false
|
||||
|
||||
/// A caller-supplied string rendered verbatim in place of the default
|
||||
/// method + URL content. When set, ``showMethod`` and ``components``
|
||||
/// are ignored. Typically produced by a ``ConsoleDelegate`` that
|
||||
/// derives per-task content (e.g., a GraphQL operation name).
|
||||
public var customText: String?
|
||||
|
||||
public init(
|
||||
showTaskDescription: Bool = false,
|
||||
showMethod: Bool = true,
|
||||
components: Set<URLComponent> = [.path],
|
||||
fontSize: Int? = nil,
|
||||
lineLimit: Int = 3
|
||||
lineLimit: Int = 3,
|
||||
customText: String? = nil
|
||||
) {
|
||||
self.showTaskDescription = showTaskDescription
|
||||
self.showMethod = showMethod
|
||||
self.components = components
|
||||
self.fontSize = fontSize ?? ConsoleListDisplaySettings.defaultContentFontSize
|
||||
self.lineLimit = lineLimit
|
||||
self.customText = customText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +111,7 @@ public struct ConsoleListDisplaySettings: Hashable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum URLComponent: String, CaseIterable, Codable {
|
||||
public enum URLComponent: String, Sendable, CaseIterable, Codable {
|
||||
case scheme, user, password, host, port, path, query, fragment
|
||||
}
|
||||
|
||||
@@ -119,6 +127,13 @@ public struct ConsoleListDisplaySettings: Hashable, Codable {
|
||||
case taskDescription
|
||||
case requestHeaderField(String)
|
||||
case responseHeaderField(String)
|
||||
/// A specific URL component (e.g., `.path`, `.host`). When `nil`, the
|
||||
/// full URL string is rendered.
|
||||
case url(components: Set<URLComponent>? = nil)
|
||||
/// A caller-supplied string rendered verbatim. Typically produced by a
|
||||
/// ``ConsoleDelegate`` that derives per-task information (e.g., a
|
||||
/// GraphQL operation name) and embeds it in the header or footer.
|
||||
case custom(String)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
@@ -46,7 +46,7 @@ package struct ConsoleMessageCell: View {
|
||||
if message.isPinned {
|
||||
BookmarkIconView()
|
||||
}
|
||||
ConsoleTimestampView(date: message.createdAt)
|
||||
ConsoleTimestampView(timestamp: message.formattedTimestamp)
|
||||
.padding(.trailing, 3)
|
||||
#endif
|
||||
}
|
||||
@@ -122,13 +122,10 @@ extension Color {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
struct ConsoleMessageCell_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ConsoleMessageCell(message: try! LoggerStore.mock.messages()[0])
|
||||
.injecting(ConsoleEnvironment(store: .mock))
|
||||
.padding()
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview(traits: .sizeThatFitsLayout) {
|
||||
ConsoleMessageCell(message: try! LoggerStore.mock.messages()[0])
|
||||
.injecting(ConsoleEnvironment(store: LoggerStore.mock))
|
||||
.padding()
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleNavigationTitleView: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
@EnvironmentObject private var listViewModel: ConsoleListViewModel
|
||||
@EnvironmentObject private var searchViewModel: ConsoleSearchViewModel
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
modeButton(.all, title: "All")
|
||||
modeButton(.logs, title: "Logs")
|
||||
modeButton(.network, title: "Network")
|
||||
} label: {
|
||||
headerView
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerView: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
Text(modeTitle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary, Color(.tertiarySystemFill))
|
||||
.offset(y: -1)
|
||||
}
|
||||
}
|
||||
|
||||
private func modeButton(_ mode: ConsoleMode, title: String) -> some View {
|
||||
Button {
|
||||
environment.mode = mode
|
||||
} label: {
|
||||
if environment.mode == mode {
|
||||
Label(title, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modeTitle: String {
|
||||
switch environment.mode {
|
||||
case .all: return "Console"
|
||||
case .logs: return "Logs"
|
||||
case .network: return "Network"
|
||||
}
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let total = environment.mode.formattedCount(listViewModel.entities.count)
|
||||
if searchViewModel.isSearching, searchViewModel.searchBar.text.isEmpty == false {
|
||||
return "\(searchViewModel.results.count)\(searchViewModel.hasMore ? "+" : "") / \(total)"
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
@@ -9,7 +9,7 @@ import Combine
|
||||
|
||||
package final class ConsoleRouter: ObservableObject {
|
||||
#if os(macOS)
|
||||
@Published package var selection: ConsoleSelectedItem?
|
||||
@Published package var _selection: (any Hashable)?
|
||||
#endif
|
||||
@Published package var shareItems: ShareItems?
|
||||
@Published package var isShowingFilters = false
|
||||
@@ -21,25 +21,35 @@ package final class ConsoleRouter: ObservableObject {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@available(macOS 15, *)
|
||||
package enum ConsoleSelectedItem: Hashable {
|
||||
case entity(NSManagedObjectID)
|
||||
case occurrence(NSManagedObjectID, ConsoleSearchOccurrence)
|
||||
}
|
||||
|
||||
@available(macOS 15, *)
|
||||
extension ConsoleRouter {
|
||||
// Selection for macOS split-view navigation
|
||||
package var selection: ConsoleSelectedItem? {
|
||||
get { _selection as? ConsoleSelectedItem }
|
||||
set { _selection = newValue }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct ConsoleRouterView: View {
|
||||
@EnvironmentObject var environment: ConsoleEnvironment
|
||||
@EnvironmentObject var router: ConsoleRouter
|
||||
@ObservedObject var router: ConsoleRouter
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16, macOS 13, *) {
|
||||
if #available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) {
|
||||
contents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
extension ConsoleRouterView {
|
||||
var contents: some View {
|
||||
Text("").invisible()
|
||||
@@ -54,11 +64,16 @@ extension ConsoleRouterView {
|
||||
NavigationView {
|
||||
ConsoleFiltersView()
|
||||
.inlineNavigationTitle("Filters")
|
||||
.navigationBarItems(trailing: Button("Done") {
|
||||
router.isShowingFilters = false
|
||||
})
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
makeButton(role: .confirm) {
|
||||
router.isShowingFilters = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -69,22 +84,25 @@ extension ConsoleRouterView {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
Button(action: { router.isShowingSessions = false }) {
|
||||
Text("Close")
|
||||
makeButton(role: .close) {
|
||||
router.isShowingSessions = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var destinationSettings: some View {
|
||||
NavigationView {
|
||||
SettingsView(store: environment.store)
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing: Button(action: { router.isShowingSettings = false }) {
|
||||
Text("Done")
|
||||
})
|
||||
if let store = environment.store as? LoggerStore {
|
||||
NavigationView {
|
||||
SettingsView(store: store)
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing: Button(action: { router.isShowingSettings = false }) {
|
||||
Text("Done")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,47 +115,109 @@ extension ConsoleRouterView {
|
||||
|
||||
#elseif os(watchOS)
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
extension ConsoleRouterView {
|
||||
var contents: some View {
|
||||
Text("").invisible()
|
||||
.sheet(isPresented: $router.isShowingSettings) {
|
||||
NavigationView {
|
||||
SettingsView(store: environment.store)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: {
|
||||
router.isShowingSettings = false
|
||||
}, label: {
|
||||
Image(systemName: "xmark")
|
||||
})
|
||||
}
|
||||
.sheet(isPresented: $router.isShowingSettings) { destinationSettings }
|
||||
.sheet(isPresented: $router.isShowingFilters) { destinationFilters }
|
||||
.sheet(isPresented: $router.isShowingSessions) { destinationSessions }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var destinationSettings: some View {
|
||||
if let store = environment.store as? LoggerStore {
|
||||
NavigationView {
|
||||
SettingsView(store: store)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: {
|
||||
router.isShowingSettings = false
|
||||
}, label: {
|
||||
Image(systemName: "xmark")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $router.isShowingFilters) {
|
||||
NavigationView {
|
||||
ConsoleFiltersView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: {
|
||||
router.isShowingFilters = false
|
||||
}, label: {
|
||||
Image(systemName: "xmark")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var destinationFilters: some View {
|
||||
NavigationView {
|
||||
ConsoleFiltersView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: {
|
||||
router.isShowingFilters = false
|
||||
}, label: {
|
||||
Image(systemName: "xmark")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var destinationSessions: some View {
|
||||
NavigationView {
|
||||
SessionsView()
|
||||
.navigationTitle("Sessions")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: {
|
||||
router.isShowingSessions = false
|
||||
}, label: {
|
||||
Image(systemName: "xmark")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(tvOS)
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
extension ConsoleRouterView {
|
||||
var contents: some View {
|
||||
Text("").invisible()
|
||||
.sheet(isPresented: $router.isShowingSessions) { destinationSessions }
|
||||
}
|
||||
|
||||
private var destinationSessions: some View {
|
||||
NavigationView {
|
||||
SessionsView()
|
||||
.navigationTitle("Sessions")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: { router.isShowingSessions = false }) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
@available(macOS 13, *)
|
||||
@available(macOS 15, *)
|
||||
extension ConsoleRouterView {
|
||||
var contents: some View {
|
||||
Text("").invisible()
|
||||
.sheet(isPresented: $router.isShowingFilters) { destinationFilters }
|
||||
.sheet(isPresented: $router.isShowingSettings) { destinationSettings }
|
||||
.sheet(isPresented: $router.isShowingShareStore) { destinationShareStore }
|
||||
}
|
||||
|
||||
private var destinationFilters: some View {
|
||||
ConsoleFiltersView()
|
||||
.frame(width: 320, height: 500)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { router.isShowingFilters = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var destinationSettings: some View {
|
||||
@@ -146,12 +226,15 @@ extension ConsoleRouterView {
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: { router.isShowingSettings = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
Button("Close") { router.isShowingSettings = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var destinationShareStore: some View {
|
||||
ShareStoreView(onDismiss: { router.isShowingShareStore = false })
|
||||
.frame(width: 340, height: 400)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
@@ -13,7 +13,8 @@ package struct ConsoleTaskCell: View {
|
||||
|
||||
@ScaledMetric(relativeTo: .body) private var fontMultiplier = 1.0
|
||||
@ObservedObject private var settings: UserSettings = .shared
|
||||
@Environment(\.store) private var store: LoggerStore
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
@Environment(\.store) private var store
|
||||
|
||||
package enum EditableArea {
|
||||
case header, content, footer
|
||||
@@ -21,19 +22,33 @@ package struct ConsoleTaskCell: View {
|
||||
|
||||
package var highlightedArea: EditableArea?
|
||||
|
||||
package var urlMatch: ConsoleSearchMatch?
|
||||
|
||||
package init(task: NetworkTaskEntity, isDisclosureNeeded: Bool = false, highlightedArea: EditableArea? = nil) {
|
||||
self.task = task
|
||||
self.isDisclosureNeeded = isDisclosureNeeded
|
||||
self.highlightedArea = highlightedArea
|
||||
}
|
||||
|
||||
package consuming func urlMatch(_ match: ConsoleSearchMatch?) -> ConsoleTaskCell {
|
||||
self.urlMatch = match
|
||||
return self
|
||||
}
|
||||
|
||||
package var body: some View {
|
||||
let displayOptions = environment.listDisplayOptions(for: task)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
header
|
||||
makeContent(settings: settings.listDisplayOptions.content)
|
||||
.highlighted(highlightedArea == .content)
|
||||
makeHeader(settings: displayOptions.header)
|
||||
Group {
|
||||
if let custom = environment.delegate?.console(contentViewFor: task) {
|
||||
custom
|
||||
} else {
|
||||
makeContent(settings: displayOptions.content)
|
||||
}
|
||||
}
|
||||
.highlighted(highlightedArea == .content)
|
||||
#if os(iOS) || os(watchOS)
|
||||
makeFooter(settings: settings.listDisplayOptions.footer)
|
||||
makeFooter(settings: displayOptions.footer)
|
||||
.highlighted(highlightedArea == .footer)
|
||||
#endif
|
||||
}
|
||||
@@ -42,25 +57,25 @@ package struct ConsoleTaskCell: View {
|
||||
// MARK: – Header
|
||||
|
||||
#if os(watchOS)
|
||||
private var header: some View {
|
||||
private func makeHeader(settings: ConsoleListDisplaySettings.HeaderSettings) -> some View {
|
||||
HStack {
|
||||
StatusIndicatorView(state: task.state(in: store))
|
||||
info
|
||||
makeInfo(settings: settings)
|
||||
}
|
||||
}
|
||||
#else
|
||||
private var header: some View {
|
||||
private func makeHeader(settings: ConsoleListDisplaySettings.HeaderSettings) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
if task.isMocked {
|
||||
MockBadgeView()
|
||||
}
|
||||
info.highlighted(highlightedArea == .header)
|
||||
makeInfo(settings: settings).highlighted(highlightedArea == .header)
|
||||
|
||||
Spacer()
|
||||
if task.isPinned {
|
||||
BookmarkIconView()
|
||||
}
|
||||
ConsoleTimestampView(date: task.createdAt)
|
||||
ConsoleTimestampView(timestamp: task.formattedTimestamp)
|
||||
.padding(.trailing, 3)
|
||||
}
|
||||
.overlay(alignment: .leading) {
|
||||
@@ -80,28 +95,25 @@ package struct ConsoleTaskCell: View {
|
||||
}
|
||||
|
||||
#endif
|
||||
private var info: some View {
|
||||
let status: Text = Text(ConsoleFormatter.status(for: task, store: store))
|
||||
.font(makeFont(size: settings.listDisplayOptions.header.fontSize).weight(.medium))
|
||||
.foregroundColor(task.state == .failure ? .red : .primary)
|
||||
private func makeInfo(settings: ConsoleListDisplaySettings.HeaderSettings) -> some View {
|
||||
let font = makeFont(size: settings.fontSize)
|
||||
var attributed = AttributedString(ConsoleFormatter.status(for: task, store: store))
|
||||
attributed.font = font.weight(.medium)
|
||||
attributed.foregroundColor = task.state == .failure ? .red : .primary
|
||||
|
||||
#if os(watchOS)
|
||||
return status // Not enough space for anything else
|
||||
return Text(attributed) // Not enough space for anything else
|
||||
#else
|
||||
|
||||
var text: Text {
|
||||
let details = settings.listDisplayOptions.header.fields
|
||||
.compactMap(task.makeInfoText)
|
||||
.joined(separator: " · ")
|
||||
guard !details.isEmpty else {
|
||||
return status
|
||||
}
|
||||
return status + Text(" · \(details)")
|
||||
.font(makeFont(size: settings.listDisplayOptions.header.fontSize))
|
||||
let details = settings.fields
|
||||
.compactMap { environment.makeInfoText(for: $0, task: task) }
|
||||
.joined(separator: " · ")
|
||||
if !details.isEmpty {
|
||||
var detailsAttr = AttributedString(" · \(details)")
|
||||
detailsAttr.font = font
|
||||
attributed.append(detailsAttr)
|
||||
}
|
||||
return text
|
||||
.tracking(-0.1)
|
||||
.lineLimit(settings.listDisplayOptions.header.lineLimit)
|
||||
return Text(attributed)
|
||||
.lineLimit(settings.lineLimit)
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
@@ -110,29 +122,23 @@ package struct ConsoleTaskCell: View {
|
||||
|
||||
private func makeContent(settings: ConsoleListDisplaySettings.ContentSettings) -> some View {
|
||||
let design: Font.Design? = settings.isMonospaced ? .monospaced : nil
|
||||
var method: Text? {
|
||||
guard settings.showMethod, let method = task.httpMethod else {
|
||||
return nil
|
||||
}
|
||||
return Text(method.appending(" "))
|
||||
.font(makeFont(size: settings.fontSize, design: design).weight(.medium).smallCaps())
|
||||
.tracking(-0.2)
|
||||
let font = makeFont(size: settings.fontSize, design: design)
|
||||
|
||||
let content = environment.formattedContent(for: task, settings: settings) ?? "–"
|
||||
var main = makeHighlightedContent(content)
|
||||
main.font = font
|
||||
|
||||
let attributed: AttributedString
|
||||
if settings.showMethod, let method = task.httpMethod {
|
||||
var methodAttr = AttributedString(method.appending(" "))
|
||||
methodAttr.font = font.weight(.medium).smallCaps()
|
||||
methodAttr.append(main)
|
||||
attributed = methodAttr
|
||||
} else {
|
||||
attributed = main
|
||||
}
|
||||
|
||||
var main: Text {
|
||||
Text(task.getFormattedContent(settings: settings) ?? "–")
|
||||
.font(makeFont(size: settings.fontSize, design: design))
|
||||
}
|
||||
|
||||
var text: Text {
|
||||
if let method {
|
||||
method + main
|
||||
} else {
|
||||
main
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
return Text(attributed)
|
||||
.lineLimit(settings.lineLimit)
|
||||
}
|
||||
|
||||
@@ -141,14 +147,14 @@ package struct ConsoleTaskCell: View {
|
||||
@ViewBuilder
|
||||
private func makeFooter(settings: ConsoleListDisplaySettings.FooterSettings) -> some View {
|
||||
let design: Font.Design? = settings.isMonospaced ? .monospaced : nil
|
||||
let fields = settings.fields.compactMap(task.makeInfoText)
|
||||
let fields = settings.fields.compactMap { environment.makeInfoText(for: $0, task: task) }
|
||||
if !fields.isEmpty {
|
||||
Text(fields.joined(separator: " · "))
|
||||
.lineLimit(settings.lineLimit)
|
||||
.font(makeFont(size: settings.fontSize, design: design))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
let additional = settings.additionalFields.compactMap(task.makeInfoItem)
|
||||
let additional = settings.additionalFields.compactMap { environment.makeInfoItem(for: $0, task: task) }
|
||||
if !additional.isEmpty {
|
||||
Divider().opacity(0.5).padding(.vertical, 2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -164,6 +170,18 @@ package struct ConsoleTaskCell: View {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeHighlightedContent(_ content: String) -> AttributedString {
|
||||
var attributed = AttributedString(content)
|
||||
guard let urlMatch else { return attributed }
|
||||
let matchedText = String(urlMatch.line[urlMatch.range])
|
||||
guard !matchedText.isEmpty, let range = attributed.range(of: matchedText) else {
|
||||
return attributed
|
||||
}
|
||||
attributed.foregroundColor = .secondary
|
||||
attributed[range].foregroundColor = .primary
|
||||
return attributed
|
||||
}
|
||||
|
||||
private func makeFont(size: Int, weight: Font.Weight? = nil, design: Font.Design? = nil) -> Font {
|
||||
if #available(iOS 16, tvOS 16, *) {
|
||||
return Font.system(size: CGFloat(design == .monospaced ? size - 1 : size) * fontMultiplier, weight: weight, design: design)
|
||||
@@ -195,12 +213,10 @@ private extension View {
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
struct ConsoleTaskCell_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ConsoleTaskCell(task: LoggerStore.preview.entity(for: .login))
|
||||
.padding()
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview(traits: .sizeThatFitsLayout) {
|
||||
ConsoleTaskCell(task: LoggerStore.preview.entity(for: .login))
|
||||
.padding()
|
||||
.injecting(ConsoleEnvironment(store: LoggerStore.preview))
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,154 +1,420 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleToolbarView: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
@EnvironmentObject private var filters: ConsoleFiltersViewModel
|
||||
@EnvironmentObject private var searchViewModel: ConsoleSearchViewModel
|
||||
@Environment(\.isSearching) private var isSearching
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits {
|
||||
horizontal
|
||||
vertical
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 9) {
|
||||
ConsoleSessionsPill()
|
||||
ConsoleFilterPill(isSearching: isSearching)
|
||||
}
|
||||
Spacer(minLength: 9)
|
||||
HStack(spacing: 9) {
|
||||
ConsoleSortByPill()
|
||||
ConsoleGroupByPill()
|
||||
ConsoleOnlyErrorsButton(isEnabled: $filters.options.isOnlyErrors)
|
||||
if isSearching {
|
||||
ConsoleSearchContextMenu(viewModel: searchViewModel)
|
||||
.transition(.scale(scale: 0.3, anchor: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.dynamicTypeSize(...DynamicTypeSize.accessibility2)
|
||||
}
|
||||
|
||||
private var horizontal: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
contents(isVertical: false)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Fallback for larger dynamic font sizes.
|
||||
private var vertical: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
contents(isVertical: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contents(isVertical: Bool) -> some View {
|
||||
switch environment.initialMode {
|
||||
case .all:
|
||||
ConsoleModePicker(environment: environment)
|
||||
case .logs, .network:
|
||||
ConsoleToolbarTitle()
|
||||
}
|
||||
if !isVertical {
|
||||
Spacer()
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
ConsoleListOptionsView()
|
||||
}.padding(.trailing, isVertical ? 0 : -2)
|
||||
.animation(.snappy, value: filters.options.isOnlyErrors)
|
||||
.animation(.snappy, value: filters.options.filters)
|
||||
.animation(.snappy, value: environment.listOptions)
|
||||
.animation(.snappy, value: searchViewModel.options)
|
||||
.animation(.snappy, value: searchViewModel.scopes)
|
||||
.animation(.snappy, value: isSearching)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConsoleModePicker: View {
|
||||
@ObservedObject private var environment: ConsoleEnvironment
|
||||
private let consolePillResetXmarkWidth: CGFloat = 24
|
||||
|
||||
@ObservedObject private var logsCounter: ManagedObjectsCountObserver
|
||||
@ObservedObject private var tasksCounter: ManagedObjectsCountObserver
|
||||
|
||||
init(environment: ConsoleEnvironment) {
|
||||
self.environment = environment
|
||||
self.logsCounter = environment.logCountObserver
|
||||
self.tasksCounter = environment.taskCountObserver
|
||||
}
|
||||
@available(iOS 18, tvOS 18, macOS 15, visionOS 1, *)
|
||||
private struct ConsolePillResetXmark: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
ConsoleModeButton(title: "Network", details: CountFormatter.string(from: tasksCounter.count), isSelected: environment.mode == .network) {
|
||||
environment.mode = .network
|
||||
Button(action: action) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.white.opacity(0.85))
|
||||
.padding(.leading, 2)
|
||||
.padding(.trailing, 8)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a Button-style trigger in the standard console pill background,
|
||||
/// optionally appending a reset xmark button when `isActive` and `resetAction` is set.
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct ConsoleTogglePill<Trigger: View>: View {
|
||||
let isActive: Bool
|
||||
var activeColor: Color = .accentColor
|
||||
let resetAction: (() -> Void)?
|
||||
@ViewBuilder let trigger: () -> Trigger
|
||||
|
||||
private var hasReset: Bool { isActive && resetAction != nil }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
trigger()
|
||||
.padding(.leading, 9)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.trailing, hasReset ? 4 : 8)
|
||||
if let resetAction, isActive {
|
||||
ConsolePillResetXmark(action: resetAction)
|
||||
}
|
||||
ConsoleModeButton(title: "Logs", details: CountFormatter.string(from: logsCounter.count), isSelected: environment.mode == .logs) {
|
||||
environment.mode = .logs
|
||||
}
|
||||
.frame(minWidth: 34) // Never narrower than tall — keep at least square
|
||||
.toolbarPillBackground(isActive: isActive, activeColor: activeColor)
|
||||
.animation(.snappy, value: isActive)
|
||||
}
|
||||
}
|
||||
|
||||
/// A pill whose entire surface is the label of a Menu, so tapping anywhere on the
|
||||
/// pill opens the menu. When `resetAction` is non-nil and `isActive`, an xmark
|
||||
/// reset button is overlaid on the trailing edge with its own tap area.
|
||||
@available(iOS 18, tvOS 18, macOS 15, visionOS 1, *)
|
||||
private struct ConsoleMenuPill<MenuContent: View>: View {
|
||||
let systemImage: String
|
||||
var title: String? = nil
|
||||
let isActive: Bool
|
||||
let resetAction: (() -> Void)?
|
||||
@ViewBuilder let menuContent: () -> MenuContent
|
||||
|
||||
private var hasReset: Bool { isActive && resetAction != nil }
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
menuContent()
|
||||
} label: {
|
||||
HStack(spacing: 0) {
|
||||
ToolbarPillLabel(title: title, systemImage: systemImage, isActive: isActive)
|
||||
.padding(.leading, 9)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.trailing, hasReset ? 4 : 8)
|
||||
if hasReset {
|
||||
// Reserve space for the overlayed xmark button so the menu
|
||||
// label visually leaves room for it.
|
||||
Color.clear
|
||||
.frame(width: consolePillResetXmarkWidth, height: 1)
|
||||
}
|
||||
}
|
||||
ConsoleModeButton(title: "All", details: CountFormatter.string(from: logsCounter.count + tasksCounter.count), isSelected: environment.mode == .all) {
|
||||
environment.mode = .all
|
||||
.frame(minWidth: 34) // Never narrower than tall — keep at least square
|
||||
.toolbarPillBackground(isActive: isActive)
|
||||
.animation(.snappy, value: isActive)
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.plain)
|
||||
.overlay(alignment: .trailing) {
|
||||
if let resetAction, isActive {
|
||||
ConsolePillResetXmark(action: resetAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConsoleToolbarTitle: View {
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct ConsoleFilterPill: View {
|
||||
let isSearching: Bool
|
||||
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
@EnvironmentObject private var listViewModel: ConsoleListViewModel
|
||||
@EnvironmentObject private var filters: ConsoleFiltersViewModel
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline.weight(.medium))
|
||||
private var isActive: Bool {
|
||||
!filters.isDefaultFilters(for: filters.mode)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
let kind = environment.initialMode == .network ? "Requests" : "Logs"
|
||||
return "\(listViewModel.entities.count) \(kind)"
|
||||
var body: some View {
|
||||
ConsoleTogglePill(isActive: isActive, resetAction: resetFilters) {
|
||||
Button(action: openFilters) {
|
||||
ToolbarPillLabel(
|
||||
title: "Filters",
|
||||
systemImage: "line.3.horizontal.decrease",
|
||||
isActive: isActive
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func openFilters() {
|
||||
environment.router.isShowingFilters = true
|
||||
}
|
||||
|
||||
private func resetFilters() {
|
||||
filters.resetAll()
|
||||
}
|
||||
}
|
||||
|
||||
package struct ConsoleModeButton: View {
|
||||
package let title: String
|
||||
package var details: String?
|
||||
package let isSelected: Bool
|
||||
package let action: () -> Void
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
package struct ConsoleOnlyErrorsButton: View {
|
||||
@Binding package var isEnabled: Bool
|
||||
|
||||
package init(title: String, details: String? = nil, isSelected: Bool, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.details = details
|
||||
self.isSelected = isSelected
|
||||
self.action = action
|
||||
package init(isEnabled: Binding<Bool>) {
|
||||
self._isEnabled = isEnabled
|
||||
}
|
||||
|
||||
package var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(title)
|
||||
.foregroundColor(isSelected ? Color.white : Color.secondary)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
if let details = details {
|
||||
Text("\(details)")
|
||||
.foregroundColor(isSelected ? Color.white.opacity(0.7) : Color.secondary.opacity(0.7))
|
||||
.font(.footnote)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
}
|
||||
ConsoleTogglePill(isActive: isEnabled, activeColor: .red, resetAction: nil) {
|
||||
Button(action: { isEnabled.toggle() }) {
|
||||
ToolbarPillLabel(
|
||||
systemImage: isEnabled ? "exclamationmark.octagon.fill" : "exclamationmark.octagon",
|
||||
isActive: isEnabled
|
||||
)
|
||||
}
|
||||
.padding(EdgeInsets(top: 8, leading: 9, bottom: 8, trailing: 8))
|
||||
.background(isSelected ? Color.accentColor : Color(.secondarySystemFill).opacity(0.8))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
struct ConsoleListOptionsView: View {
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct ConsoleSessionsPill: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
@EnvironmentObject private var filters: ConsoleFiltersViewModel
|
||||
|
||||
var body: some View {
|
||||
Button(action: { filters.options.isOnlyErrors.toggle() }) {
|
||||
Text(Image(systemName: filters.options.isOnlyErrors ? "exclamationmark.octagon.fill" : "exclamationmark.octagon"))
|
||||
.font(.body)
|
||||
.foregroundColor(filters.options.isOnlyErrors ? .white : .secondary)
|
||||
private var isActive: Bool {
|
||||
let selected = filters.options.sessions
|
||||
if let sessionID = environment.store.currentSessionID {
|
||||
return selected != [sessionID]
|
||||
}
|
||||
return !selected.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ConsoleTogglePill(isActive: isActive, resetAction: nil) {
|
||||
Button(action: { environment.router.isShowingSessions = true }) {
|
||||
ToolbarPillLabel(
|
||||
systemImage: "list.bullet.clipboard",
|
||||
isActive: isActive
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct ConsoleSortByPill: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
|
||||
private var isActive: Bool {
|
||||
if environment.mode == .network {
|
||||
return environment.listOptions.taskSortBy != .dateCreated
|
||||
|| environment.listOptions.order != .descending
|
||||
} else {
|
||||
return environment.listOptions.messageSortBy != .dateCreated
|
||||
|| environment.listOptions.order != .descending
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldLabel: String {
|
||||
environment.mode == .network
|
||||
? environment.listOptions.taskSortBy.pillTitle
|
||||
: environment.listOptions.messageSortBy.pillTitle
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ConsoleMenuPill(
|
||||
systemImage: environment.listOptions.order == .ascending ? "arrow.up" : "arrow.down",
|
||||
title: fieldLabel,
|
||||
isActive: isActive,
|
||||
resetAction: reset
|
||||
) {
|
||||
ConsoleSortByMenuContent()
|
||||
}
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
environment.listOptions.order = .descending
|
||||
if environment.mode == .network {
|
||||
environment.listOptions.taskSortBy = .dateCreated
|
||||
} else {
|
||||
environment.listOptions.messageSortBy = .dateCreated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleSortByMenuContent: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
|
||||
var body: some View {
|
||||
if environment.mode == .network {
|
||||
Picker("Sort By", selection: $environment.listOptions.taskSortBy) {
|
||||
ForEach(ConsoleListOptions.TaskSortBy.allCases, id: \.self) {
|
||||
Text($0.rawValue).tag($0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Picker("Sort By", selection: $environment.listOptions.messageSortBy) {
|
||||
ForEach(ConsoleListOptions.MessageSortBy.allCases, id: \.self) {
|
||||
Text($0.rawValue).tag($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Picker("Ordering", selection: $environment.listOptions.order) {
|
||||
Text("Descending").tag(ConsoleListOptions.Ordering.descending)
|
||||
Text("Ascending").tag(ConsoleListOptions.Ordering.ascending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
package struct ConsoleSearchContextMenu<ViewModel: ConsoleSearchOptionsHost>: View {
|
||||
@ObservedObject var viewModel: ViewModel
|
||||
@State private var isPresented = false
|
||||
|
||||
private var isOptionsActive: Bool {
|
||||
viewModel.options != .default
|
||||
}
|
||||
|
||||
private var isScopesActive: Bool {
|
||||
viewModel.scopes != viewModel.savedDefaultScopes
|
||||
}
|
||||
|
||||
private var isActive: Bool {
|
||||
isOptionsActive || isScopesActive
|
||||
}
|
||||
|
||||
package init(viewModel: ViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
package var body: some View {
|
||||
ConsoleTogglePill(isActive: isActive, resetAction: isActive ? reset : nil) {
|
||||
Button {
|
||||
isPresented = true
|
||||
} label: {
|
||||
ToolbarPillLabel(
|
||||
systemImage: "gearshape",
|
||||
isActive: isActive
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#if os(iOS) || os(visionOS)
|
||||
.sheet(isPresented: $isPresented) {
|
||||
ConsoleSearchOptionsSheet(viewModel: viewModel)
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
viewModel.options = .default
|
||||
viewModel.resetScopesToDefault()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
private struct ConsoleGroupByPill: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
@EnvironmentObject private var listViewModel: ConsoleListViewModel
|
||||
|
||||
private var isActive: Bool {
|
||||
if environment.mode == .network {
|
||||
return environment.listOptions.taskGroupBy != .noGrouping
|
||||
} else {
|
||||
return environment.listOptions.messageGroupBy != .noGrouping
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isActive {
|
||||
ConsoleMenuPill(
|
||||
systemImage: "rectangle.3.group",
|
||||
isActive: true,
|
||||
resetAction: nil
|
||||
) {
|
||||
Section {
|
||||
Button(action: listViewModel.collapseAllSections) {
|
||||
Label("Collapse All", systemImage: "rectangle.compress.vertical")
|
||||
}
|
||||
Button(action: listViewModel.expandAllSections) {
|
||||
Label("Expand All", systemImage: "rectangle.expand.vertical")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ConsoleRemoveGroupingButton()
|
||||
}
|
||||
Section {
|
||||
ConsoleGroupByMenuContent()
|
||||
}
|
||||
}
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleGroupByMenuContent: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
|
||||
var body: some View {
|
||||
if environment.mode == .network {
|
||||
Picker("Group By", selection: $environment.listOptions.taskGroupBy) {
|
||||
ForEach(ConsoleListOptions.TaskGroupBy.allCases.filter { $0 != .noGrouping }, id: \.self) {
|
||||
Text($0.rawValue).tag($0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Picker("Group By", selection: $environment.listOptions.messageGroupBy) {
|
||||
ForEach(ConsoleListOptions.MessageGroupBy.allCases.filter { $0 != .noGrouping }, id: \.self) {
|
||||
Text($0.rawValue).tag($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleRemoveGroupingButton: View {
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
|
||||
private var isActive: Bool {
|
||||
if environment.mode == .network {
|
||||
return environment.listOptions.taskGroupBy != .noGrouping
|
||||
} else {
|
||||
return environment.listOptions.messageGroupBy != .noGrouping
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isActive {
|
||||
Button(role: .destructive, action: remove) {
|
||||
Label("Remove Grouping", systemImage: "list.bullet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func remove() {
|
||||
if environment.mode == .network {
|
||||
environment.listOptions.taskGroupBy = .noGrouping
|
||||
} else {
|
||||
environment.listOptions.messageGroupBy = .noGrouping
|
||||
}
|
||||
.cornerRadius(4)
|
||||
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
|
||||
.padding(7)
|
||||
.background(filters.options.isOnlyErrors ? .red : Color(.secondarySystemFill).opacity(0.8))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
|
||||
#if os(iOS) || os(tvOS) || os(macOS) || os(watchOS) || os(visionOS)
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct FileViewer: View {
|
||||
@ObservedObject var viewModel: FileViewerViewModel
|
||||
|
||||
@@ -34,6 +35,9 @@ struct FileViewer: View {
|
||||
case .pdf(let document):
|
||||
PDFKitRepresentedView(document: document)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
#elseif os(macOS)
|
||||
case .pdf:
|
||||
PlaceholderView(imageName: "doc.richtext", title: "PDF Preview", subtitle: "PDF preview is not available")
|
||||
#endif
|
||||
case .other(let viewModel):
|
||||
RichTextView(viewModel: viewModel)
|
||||
@@ -44,29 +48,38 @@ struct FileViewer: View {
|
||||
// MARK: - Preview
|
||||
|
||||
#if DEBUG
|
||||
struct FileViewer_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/json", originalSize: 1200), data: { MockJSON.allPossibleValues }))
|
||||
}.previewDisplayName("JSON")
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview("JSON") {
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/json", originalSize: 1200), data: { MockJSON.allPossibleValues }))
|
||||
}
|
||||
}
|
||||
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "image/png", originalSize: 219543), data: { MockTask.octocat.responseBody }))
|
||||
}.previewDisplayName("Image")
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview("Image") {
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "image/png", originalSize: 219543), data: { MockTask.octocat.responseBody }))
|
||||
}
|
||||
}
|
||||
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/html", originalSize: 1200), data: { MockTask.profile.responseBody }))
|
||||
}.previewDisplayName("HTML")
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview("HTML") {
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/html", originalSize: 1200), data: { MockTask.profile.responseBody }))
|
||||
}
|
||||
}
|
||||
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/x-www-form-urlencoded", originalSize: 1200), data: { MockTask.patchRepo.originalRequest.httpBody ?? Data() }))
|
||||
}.previewDisplayName("Query Items")
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview("Query Items") {
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/x-www-form-urlencoded", originalSize: 1200), data: { MockTask.patchRepo.originalRequest.httpBody ?? Data() }))
|
||||
}
|
||||
}
|
||||
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/pdf", originalSize: 1000), data: { mockPDF }))
|
||||
}.previewDisplayName("PDF")
|
||||
}
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview("PDF") {
|
||||
PreviewContainer {
|
||||
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/pdf", originalSize: 1000), data: { mockPDF }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
@@ -37,10 +37,14 @@ struct ScrollableTextView: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
struct RichTextView: View {
|
||||
public struct RichTextView: View {
|
||||
let viewModel: RichTextViewModel
|
||||
|
||||
var body: some View {
|
||||
|
||||
public init(viewModel: RichTextViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if let attributedText = viewModel.attributedString {
|
||||
ScrollableTextView(attributedText: attributedText)
|
||||
} else {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
#if os(watchOS) || os(macOS)
|
||||
|
||||
struct RichTextView: View {
|
||||
public struct RichTextView: View {
|
||||
let viewModel: RichTextViewModel
|
||||
|
||||
var body: some View {
|
||||
public init(viewModel: RichTextViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ScrollView {
|
||||
if let string = viewModel.attributedString {
|
||||
Text(string)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
|
||||
@@ -9,7 +9,8 @@ import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
|
||||
struct RichTextView: View {
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
public struct RichTextView: View {
|
||||
@ObservedObject var viewModel: RichTextViewModel
|
||||
var isTextViewBarItemsHidden = false
|
||||
|
||||
@@ -18,13 +19,17 @@ struct RichTextView: View {
|
||||
|
||||
@Environment(\.textViewSearchContext) private var searchContext
|
||||
|
||||
func textViewBarItemsHidden(_ isHidden: Bool) -> RichTextView {
|
||||
public init(viewModel: RichTextViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
public func textViewBarItemsHidden(_ isHidden: Bool) -> RichTextView {
|
||||
var copy = self
|
||||
copy.isTextViewBarItemsHidden = isHidden
|
||||
return copy
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
public var body: some View {
|
||||
contents
|
||||
.onAppear { viewModel.prepare(searchContext) }
|
||||
.navigationBarItems(trailing: navigationBarTrailingItems)
|
||||
@@ -98,12 +103,11 @@ struct RichTextView: View {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct RichTextView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
RichTextView(viewModel: makePreviewViewModel())
|
||||
.inlineNavigationTitle("Rich Text View")
|
||||
}
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview {
|
||||
NavigationView {
|
||||
RichTextView(viewModel: makePreviewViewModel())
|
||||
.inlineNavigationTitle("Rich Text View")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
@@ -8,7 +8,7 @@ import Combine
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
|
||||
final class RichTextViewModel: ObservableObject {
|
||||
public final class RichTextViewModel: ObservableObject {
|
||||
// Search
|
||||
@Published var searchOptions: StringSearchOptions = .default
|
||||
@Published private(set) var selectedMatchIndex: Int = 0
|
||||
@@ -16,15 +16,15 @@ final class RichTextViewModel: ObservableObject {
|
||||
@Published var searchTerm: String = ""
|
||||
|
||||
// Configuration
|
||||
@Published var isLinkDetectionEnabled = true
|
||||
@Published public var isLinkDetectionEnabled = true
|
||||
var isToolbarHidden = false
|
||||
|
||||
let contentType: NetworkLogger.ContentType?
|
||||
let originalText: NSAttributedString
|
||||
public let contentType: NetworkLogger.ContentType?
|
||||
public let originalText: NSAttributedString
|
||||
|
||||
var onLinkTapped: ((URL) -> Bool)?
|
||||
public var onLinkTapped: ((URL) -> Bool)?
|
||||
|
||||
var isEmpty: Bool { originalText.length == 0 }
|
||||
public var isEmpty: Bool { originalText.length == 0 }
|
||||
|
||||
weak var textView: UXTextView? // Not proper MVVM
|
||||
var textStorage: NSTextStorage { textView?.textStorage ?? NSTextStorage(string: "") }
|
||||
@@ -41,11 +41,11 @@ final class RichTextViewModel: ObservableObject {
|
||||
let originalBackgroundColor: UXColor?
|
||||
}
|
||||
|
||||
convenience init(string: NSAttributedString = NSAttributedString()) {
|
||||
public convenience init(string: NSAttributedString = NSAttributedString()) {
|
||||
self.init(string: string, contentType: nil)
|
||||
}
|
||||
|
||||
init(string: NSAttributedString, contentType: NetworkLogger.ContentType?) {
|
||||
public init(string: NSAttributedString, contentType: NetworkLogger.ContentType?) {
|
||||
self.originalText = string
|
||||
self.contentType = contentType
|
||||
|
||||
@@ -229,19 +229,19 @@ package struct TextViewSearchContext {
|
||||
}
|
||||
|
||||
#if os(watchOS) || os(tvOS) || os(macOS)
|
||||
final class RichTextViewModel: ObservableObject {
|
||||
let text: String
|
||||
let attributedString: AttributedString?
|
||||
public final class RichTextViewModel: ObservableObject {
|
||||
public let text: String
|
||||
public let attributedString: AttributedString?
|
||||
|
||||
var isLinkDetectionEnabled = true
|
||||
var isEmpty: Bool { text.isEmpty }
|
||||
public var isLinkDetectionEnabled = true
|
||||
public var isEmpty: Bool { text.isEmpty }
|
||||
|
||||
init(string: String) {
|
||||
public init(string: String) {
|
||||
self.text = string
|
||||
self.attributedString = nil
|
||||
}
|
||||
|
||||
init(string: NSAttributedString, contentType: NetworkLogger.ContentType? = nil) {
|
||||
public init(string: NSAttributedString, contentType: NetworkLogger.ContentType? = nil) {
|
||||
#if os(macOS)
|
||||
self.attributedString = try? AttributedString(string, including: \.appKit)
|
||||
#else
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct WrappedTextView: UIViewRepresentable {
|
||||
let viewModel: RichTextViewModel
|
||||
|
||||
@@ -16,6 +17,22 @@ struct WrappedTextView: UIViewRepresentable {
|
||||
var onLinkTapped: ((URL) -> Bool)?
|
||||
var cancellables: [AnyCancellable] = []
|
||||
|
||||
func textView(_ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction) -> UIAction? {
|
||||
guard case .link(let URL) = textItem.content else { return defaultAction }
|
||||
if let onLinkTapped = onLinkTapped, onLinkTapped(URL) {
|
||||
return nil
|
||||
}
|
||||
if let (title, message) = parseTooltip(URL) {
|
||||
return UIAction { _ in
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(.init(title: "Done", style: .cancel))
|
||||
UIApplication.keyWindow?.rootViewController?.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
return defaultAction
|
||||
}
|
||||
|
||||
@available(iOS, deprecated: 17, message: "Use primaryActionFor instead")
|
||||
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
||||
if let onLinkTapped = onLinkTapped, onLinkTapped(URL) {
|
||||
return false
|
||||
@@ -32,13 +49,8 @@ struct WrappedTextView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UXTextView {
|
||||
let textView: UITextView
|
||||
if #available(iOS 16, *) {
|
||||
// Disables the new TextKit 2 which is extremely slow on iOS 16
|
||||
textView = UITextView(usingTextLayoutManager: false)
|
||||
} else {
|
||||
textView = UITextView()
|
||||
}
|
||||
// Disables the new TextKit 2 which is extremely slow on iOS 16
|
||||
let textView = UITextView(usingTextLayoutManager: false)
|
||||
configureTextView(textView)
|
||||
textView.delegate = context.coordinator
|
||||
textView.attributedText = viewModel.originalText
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleSearchContentTypeCell: View {
|
||||
@Binding var selection: ConsoleFilters.ContentType.ContentType
|
||||
|
||||
var body: some View {
|
||||
Picker("Content Type", selection: $selection) {
|
||||
Section {
|
||||
Text("Any").tag(ContentType.any)
|
||||
Text("JSON").tag(ContentType.json)
|
||||
Text("Text").tag(ContentType.plain)
|
||||
}
|
||||
Section {
|
||||
Text("HTML").tag(ContentType.html)
|
||||
Text("CSS").tag(ContentType.css)
|
||||
Text("CSV").tag(ContentType.csv)
|
||||
Text("JS").tag(ContentType.javascript)
|
||||
Text("XML").tag(ContentType.xml)
|
||||
Text("PDF").tag(ContentType.pdf)
|
||||
}
|
||||
Section {
|
||||
Text("Image").tag(ContentType.anyImage)
|
||||
Text("JPEG").tag(ContentType.jpeg)
|
||||
Text("PNG").tag(ContentType.png)
|
||||
Text("GIF").tag(ContentType.gif)
|
||||
Text("WebP").tag(ContentType.webp)
|
||||
}
|
||||
Section {
|
||||
Text("Video").tag(ContentType.anyVideo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private typealias ContentType = ConsoleFilters.ContentType.ContentType
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleSearchDurationCell: View {
|
||||
@Binding var selection: ConsoleFilters.Duration
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 0) {
|
||||
Text("Duration").lineLimit(1)
|
||||
Spacer(minLength: 16)
|
||||
Picker("Unit", selection: $selection.unit) {
|
||||
ForEach(ConsoleFilters.Duration.Unit.allCases) {
|
||||
Text($0.title).tag($0)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.padding(.trailing, 12)
|
||||
|
||||
RangePicker(range: $selection.range)
|
||||
}
|
||||
.frame(height: 18) // Ensure cells have consistent height
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleSearchHTTPMethodCell: View {
|
||||
@Binding var selection: ConsoleFilters.Request.HTTPMethodFilter
|
||||
|
||||
var body: some View {
|
||||
Picker("HTTP Method", selection: $selection) {
|
||||
Text("Any").tag(ConsoleFilters.Request.HTTPMethodFilter.any)
|
||||
ForEach(HTTPMethod.allCases, id: \.self) {
|
||||
Text($0.rawValue).tag(ConsoleFilters.Request.HTTPMethodFilter.some($0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleSearchRequestSizeCell: View {
|
||||
@Binding var selection: ConsoleFilters.ResponseSize
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("Body Size").lineLimit(1)
|
||||
Spacer(minLength: 16)
|
||||
Picker("Unit", selection: $selection.unit) {
|
||||
ForEach(ConsoleFilters.ResponseSize.MeasurementUnit.allCases) {
|
||||
Text($0.title).tag($0)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.padding(.trailing, 12)
|
||||
|
||||
RangePicker(range: $selection.range)
|
||||
}
|
||||
.frame(height: 18)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleSearchRequestStateCell: View {
|
||||
@Binding var selection: ConsoleFilters.Networking.RequestState
|
||||
|
||||
var body: some View {
|
||||
Picker("Request State", selection: $selection) {
|
||||
ForEach(ConsoleFilters.Networking.RequestState.allCases, id: \.self) {
|
||||
Text($0.title).tag($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleSearchResponseSizeCell: View {
|
||||
@Binding var selection: ConsoleFilters.ResponseSize
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("Body Size").lineLimit(1)
|
||||
Spacer(minLength: 16)
|
||||
Picker("Unit", selection: $selection.unit) {
|
||||
ForEach(ConsoleFilters.ResponseSize.MeasurementUnit.allCases) {
|
||||
Text($0.title).tag($0)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.padding(.trailing, 12)
|
||||
|
||||
RangePicker(range: $selection.range)
|
||||
}
|
||||
.frame(height: 18) // Ensure cells have consistent height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleSearchResponseSourceCell: View {
|
||||
@Binding var selection: ConsoleFilters.Networking.Source
|
||||
|
||||
var body: some View {
|
||||
Picker("Response Source", selection: $selection) {
|
||||
ForEach(ConsoleFilters.Networking.Source.allCases, id: \.self) {
|
||||
Text($0.title).tag($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleSearchStatusCodeCell: View {
|
||||
@Binding var selection: ValuesRange<String>
|
||||
|
||||
#if os(watchOS)
|
||||
var body: some View {
|
||||
Picker("Status Code", selection: statusCodeOption) {
|
||||
ForEach(StatusCodeOption.allCases) { option in
|
||||
Text(option.title).tag(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusCodeOption: Binding<StatusCodeOption> {
|
||||
Binding(
|
||||
get: { StatusCodeOption(range: selection) },
|
||||
set: { selection = $0.range }
|
||||
)
|
||||
}
|
||||
|
||||
private enum StatusCodeOption: Hashable, CaseIterable, Identifiable {
|
||||
case any, success, redirect, clientError, serverError, allErrors
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .any: "Any"
|
||||
case .success: "Success (2xx)"
|
||||
case .redirect: "Redirects (3xx)"
|
||||
case .clientError: "Client Errors (4xx)"
|
||||
case .serverError: "Server Errors (5xx)"
|
||||
case .allErrors: "All Errors"
|
||||
}
|
||||
}
|
||||
|
||||
var range: ValuesRange<String> {
|
||||
switch self {
|
||||
case .any: .empty
|
||||
case .success: ValuesRange(lowerBound: "200", upperBound: "299")
|
||||
case .redirect: ValuesRange(lowerBound: "300", upperBound: "399")
|
||||
case .clientError: ValuesRange(lowerBound: "400", upperBound: "499")
|
||||
case .serverError: ValuesRange(lowerBound: "500", upperBound: "599")
|
||||
case .allErrors: ValuesRange(lowerBound: "400", upperBound: "599")
|
||||
}
|
||||
}
|
||||
|
||||
init(range: ValuesRange<String>) {
|
||||
self = Self.allCases.first { $0.range == range } ?? .any
|
||||
}
|
||||
}
|
||||
#else
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text("Status Code").lineLimit(1)
|
||||
Spacer()
|
||||
RangePicker(range: $selection)
|
||||
}
|
||||
.frame(height: 18) // Ensure cells have consistent height
|
||||
|
||||
quickFilters
|
||||
}
|
||||
}
|
||||
|
||||
private var quickFilters: some View {
|
||||
SuggestionPills {
|
||||
SuggestionPill("Success (2xx)") { selection = ValuesRange(lowerBound: "200", upperBound: "299") }
|
||||
SuggestionPill("Redirects (3xx)") { selection = ValuesRange(lowerBound: "300", upperBound: "399") }
|
||||
SuggestionPill("Client Errors (4xx)") { selection = ValuesRange(lowerBound: "400", upperBound: "499") }
|
||||
SuggestionPill("Server Errors (5xx)") { selection = ValuesRange(lowerBound: "500", upperBound: "599") }
|
||||
SuggestionPill("All Errors") { selection = ValuesRange(lowerBound: "400", upperBound: "599") }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
struct ConsoleSearchTaskTypeCell: View {
|
||||
@Binding var selection: ConsoleFilters.Networking.TaskType
|
||||
|
||||
var body: some View {
|
||||
Picker("Task Type", selection: $selection) {
|
||||
Text("Any").tag(ConsoleFilters.Networking.TaskType.any)
|
||||
Text("Data").tag(ConsoleFilters.Networking.TaskType.some(.dataTask))
|
||||
Text("Download").tag(ConsoleFilters.Networking.TaskType.some(.downloadTask))
|
||||
Text("Upload").tag(ConsoleFilters.Networking.TaskType.some(.uploadTask))
|
||||
Text("Stream").tag(ConsoleFilters.Networking.TaskType.some(.streamTask))
|
||||
Text("WebSocket").tag(ConsoleFilters.Networking.TaskType.some(.webSocketTask))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,31 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleSearchTimePeriodCell: View {
|
||||
@Binding var selection: ConsoleFilters.Dates
|
||||
|
||||
var body: some View {
|
||||
DateRangePicker(title: "Start", date: $selection.startDate)
|
||||
DateRangePicker(title: "End", date: $selection.endDate)
|
||||
quickFilters
|
||||
VStack(spacing: 16) {
|
||||
DateRangePicker(title: "End", date: $selection.endDate)
|
||||
quickFilters
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var quickFilters: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Text("Quick Filters")
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button("Recent") { selection = .recent }
|
||||
Button("Today") { selection = .today }
|
||||
SuggestionPills {
|
||||
SuggestionPill("30 min") { selection = .last30Minutes }
|
||||
SuggestionPill("1 hour") { selection = .lastHour }
|
||||
SuggestionPill("Today") { selection = .today }
|
||||
SuggestionPill("Yesterday") { selection = .yesterday }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 4)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
@@ -1,34 +1,54 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import CoreData
|
||||
import Pulse
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
final class ConsoleFiltersViewModel: ObservableObject {
|
||||
@Published var mode: ConsoleMode = .all
|
||||
@Published var options = ConsoleDataSource.PredicateOptions()
|
||||
package final class ConsoleFiltersViewModel: ObservableObject {
|
||||
@Published package var mode: ConsoleMode = .all
|
||||
@Published package var options = ConsoleListPredicateOptions()
|
||||
|
||||
var criteria: ConsoleFilters {
|
||||
package var criteria: ConsoleFilters {
|
||||
get { options.filters }
|
||||
set { options.filters = newValue }
|
||||
}
|
||||
|
||||
let defaultCriteria: ConsoleFilters
|
||||
package var sessions: Set<UUID> {
|
||||
get { options.sessions }
|
||||
set { options.sessions = newValue }
|
||||
}
|
||||
|
||||
package let defaultCriteria: ConsoleFilters
|
||||
|
||||
// TODO: Refactor
|
||||
let entities = CurrentValueSubject<[NSManagedObject], Never>([])
|
||||
package let entities = CurrentValueSubject<[NSManagedObject], Never>([])
|
||||
|
||||
init(options: ConsoleDataSource.PredicateOptions) {
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
/// One store per mode — recents in network mode shouldn't appear when the
|
||||
/// user is in logs mode.
|
||||
private var recentStores: [ConsoleMode: ConsoleRecentFiltersStore] = [:]
|
||||
|
||||
package func recentFiltersStore(for mode: ConsoleMode) -> ConsoleRecentFiltersStore {
|
||||
if let existing = recentStores[mode] {
|
||||
return existing
|
||||
}
|
||||
let store = ConsoleRecentFiltersStore(mode: mode)
|
||||
recentStores[mode] = store
|
||||
return store
|
||||
}
|
||||
#endif
|
||||
|
||||
package init(options: ConsoleListPredicateOptions) {
|
||||
self.options = options
|
||||
self.defaultCriteria = options.filters
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
func isDefaultFilters(for mode: ConsoleMode) -> Bool {
|
||||
package func isDefaultFilters(for mode: ConsoleMode) -> Bool {
|
||||
guard criteria.shared == defaultCriteria.shared else { return false }
|
||||
if mode == .network {
|
||||
return criteria.network == defaultCriteria.network
|
||||
@@ -37,11 +57,23 @@ final class ConsoleFiltersViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func select(sessions: Set<UUID>) {
|
||||
self.criteria.shared.sessions.selection = sessions
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
package func resetAll() {
|
||||
criteria = defaultCriteria
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
/// Snapshots the current filters into the per-mode recents store. Called
|
||||
/// when the filters sheet dismisses. No-ops on default/empty filter sets
|
||||
/// (handled by the store itself).
|
||||
package func snapshotRecentFilters() {
|
||||
guard !isDefaultFilters(for: mode) else { return }
|
||||
recentFiltersStore(for: mode).save(criteria)
|
||||
}
|
||||
|
||||
/// Applies a recent filter set to the current criteria. Session selection
|
||||
/// is unaffected because it lives outside of `ConsoleFilters`.
|
||||
package func apply(_ entry: ConsoleRecentFilter) {
|
||||
criteria = entry.filters
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,69 +1,111 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
import Combine
|
||||
|
||||
#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
struct ConsoleFiltersView: View {
|
||||
@EnvironmentObject var environment: ConsoleEnvironment // important: reloads mode
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
package struct ConsoleFiltersView: View {
|
||||
@EnvironmentObject var viewModel: ConsoleFiltersViewModel
|
||||
@EnvironmentObject private var environment: ConsoleEnvironment
|
||||
|
||||
var body: some View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
package init() {}
|
||||
|
||||
package var body: some View {
|
||||
#if os(iOS) || os(watchOS) || os(tvOS) || os(visionOS)
|
||||
Form {
|
||||
form
|
||||
}
|
||||
.animation(.snappy, value: viewModel.criteria)
|
||||
#if os(iOS) || os(visionOS)
|
||||
.navigationBarItems(leading: buttonReset)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
buttonRecents
|
||||
}
|
||||
}
|
||||
.onDisappear { viewModel.snapshotRecentFilters() }
|
||||
#endif
|
||||
#else
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
form
|
||||
}
|
||||
HStack {
|
||||
Text(viewModel.mode == .network ? "Network Filters" : "Message Filters")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 34, alignment: .center)
|
||||
}
|
||||
.animation(.snappy, value: viewModel.criteria)
|
||||
.onDisappear { viewModel.snapshotRecentFilters() }
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
@State private var isShowingRecents = false
|
||||
|
||||
private var buttonRecents: some View {
|
||||
let store = viewModel.recentFiltersStore(for: viewModel.mode)
|
||||
return Button(action: { isShowingRecents = true }) {
|
||||
Text("Recents")
|
||||
}
|
||||
.disabled(store.recents.isEmpty)
|
||||
.sheet(isPresented: $isShowingRecents) {
|
||||
ConsoleRecentFiltersListView(store: store, mode: viewModel.mode) {
|
||||
viewModel.apply($0)
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
private var form: some View {
|
||||
#if os(tvOS) || os(watchOS)
|
||||
buttonReset
|
||||
#endif
|
||||
|
||||
sessionsSection
|
||||
|
||||
if environment.mode == .network {
|
||||
if viewModel.mode == .network {
|
||||
responseSection
|
||||
requestSection
|
||||
customNetworkFiltersSection
|
||||
domainsSection
|
||||
networkingSection
|
||||
} else {
|
||||
customMessageFiltersSection
|
||||
logLevelsSection
|
||||
labelsSection
|
||||
}
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
timePeriodSection
|
||||
#endif
|
||||
|
||||
buttonReset
|
||||
}
|
||||
|
||||
private var buttonReset: some View {
|
||||
Button(role: .destructive, action: viewModel.resetAll) { Text("Reset") }
|
||||
.disabled(viewModel.isDefaultFilters(for: environment.mode))
|
||||
Button("Reset All", role: .destructive, action: viewModel.resetAll)
|
||||
.disabled(viewModel.isDefaultFilters(for: viewModel.mode))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConsoleFiltersView (Sections)
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
extension ConsoleFiltersView {
|
||||
var sessionsSection: some View {
|
||||
ConsoleSection(isDividerHidden: true, header: {
|
||||
ConsoleSectionHeader(icon: "list.clipboard", title: "Sessions", filter: $viewModel.criteria.shared.sessions, default: viewModel.defaultCriteria.shared.sessions)
|
||||
}, content: {
|
||||
ConsoleSessionsPickerView(selection: $viewModel.criteria.shared.sessions.selection)
|
||||
})
|
||||
}
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
var timePeriodSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSectionHeader(icon: "calendar", title: "Time Period", filter: $viewModel.criteria.shared.dates)
|
||||
ConsoleSearchSectionHeader(icon: "calendar", title: "Time Period", filter: $viewModel.criteria.shared.dates)
|
||||
}, content: {
|
||||
ConsoleSearchTimePeriodCell(selection: $viewModel.criteria.shared.dates)
|
||||
})
|
||||
@@ -72,7 +114,7 @@ extension ConsoleFiltersView {
|
||||
|
||||
var logLevelsSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSectionHeader(icon: "flag", title: "Levels", filter: $viewModel.criteria.messages.logLevels)
|
||||
ConsoleSearchSectionHeader(icon: "flag", title: "Levels", filter: $viewModel.criteria.messages.logLevels)
|
||||
}, content: {
|
||||
ConsoleSearchLogLevelsCell(selection: $viewModel.criteria.messages.logLevels.levels)
|
||||
})
|
||||
@@ -80,17 +122,86 @@ extension ConsoleFiltersView {
|
||||
|
||||
var labelsSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSectionHeader(icon: "tag", title: "Labels", filter: $viewModel.criteria.messages.labels)
|
||||
ConsoleSearchSectionHeader(icon: "tag", title: "Labels", filter: $viewModel.criteria.messages.labels)
|
||||
}, content: {
|
||||
ConsoleLabelsSelectionView(viewModel: viewModel)
|
||||
ConsoleLabelsSelectionView(viewModel: viewModel, index: environment.index)
|
||||
})
|
||||
}
|
||||
|
||||
var domainsSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSectionHeader(icon: "server.rack", title: "Hosts", filter: $viewModel.criteria.network.host)
|
||||
ConsoleSearchSectionHeader(icon: "server.rack", title: "Hosts", filter: $viewModel.criteria.network.host)
|
||||
}, content: {
|
||||
ConsoleDomainsSelectionView(viewModel: viewModel)
|
||||
ConsoleDomainsSelectionView(viewModel: viewModel, index: environment.index)
|
||||
})
|
||||
}
|
||||
|
||||
var customMessageFiltersSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSearchSectionHeader(icon: "line.horizontal.3.decrease.circle", title: "Filters", filter: $viewModel.criteria.messages.custom) {
|
||||
ConsoleFilterLogicalOperatorPicker(
|
||||
selection: $viewModel.criteria.messages.custom.logicalOperator,
|
||||
activeFilterCount: viewModel.criteria.messages.custom.filters.filter({ !$0.value.isEmpty }).count
|
||||
)
|
||||
}
|
||||
}, content: {
|
||||
ConsoleSearchCustomFiltersSection(
|
||||
filters: $viewModel.criteria.messages.custom.filters,
|
||||
fieldGroups: ConsoleCustomFilter.messageFieldGroups,
|
||||
defaultFilter: .defaultMessageFilter()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
var customNetworkFiltersSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSearchSectionHeader(icon: "line.horizontal.3.decrease.circle", title: "Filters", filter: $viewModel.criteria.network.custom) {
|
||||
ConsoleFilterLogicalOperatorPicker(
|
||||
selection: $viewModel.criteria.network.custom.logicalOperator,
|
||||
activeFilterCount: viewModel.criteria.network.custom.filters.filter({ !$0.value.isEmpty }).count
|
||||
)
|
||||
}
|
||||
}, content: {
|
||||
ConsoleSearchCustomFiltersSection(
|
||||
filters: $viewModel.criteria.network.custom.filters,
|
||||
fieldGroups: ConsoleCustomFilter.networkFieldGroups,
|
||||
defaultFilter: .defaultNetworkFilter()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
var responseSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSearchSectionHeader(icon: "arrow.down.circle", title: "Response", filter: $viewModel.criteria.network.response)
|
||||
}, content: {
|
||||
ConsoleSearchStatusCodeCell(selection: $viewModel.criteria.network.response.statusCode.range)
|
||||
#if !os(watchOS)
|
||||
ConsoleSearchDurationCell(selection: $viewModel.criteria.network.response.duration)
|
||||
ConsoleSearchResponseSizeCell(selection: $viewModel.criteria.network.response.responseSize)
|
||||
#endif
|
||||
ConsoleSearchContentTypeCell(selection: $viewModel.criteria.network.response.contentType.contentType)
|
||||
})
|
||||
}
|
||||
|
||||
var requestSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSearchSectionHeader(icon: "arrow.up.circle", title: "Request", filter: $viewModel.criteria.network.request)
|
||||
}, content: {
|
||||
ConsoleSearchHTTPMethodCell(selection: $viewModel.criteria.network.request.httpMethod)
|
||||
#if !os(watchOS)
|
||||
ConsoleSearchRequestSizeCell(selection: $viewModel.criteria.network.request.requestSize)
|
||||
#endif
|
||||
})
|
||||
}
|
||||
|
||||
var networkingSection: some View {
|
||||
ConsoleSection(header: {
|
||||
ConsoleSearchSectionHeader(icon: "arrowshape.zigzag.right", title: "Advanced", filter: $viewModel.criteria.network.networking)
|
||||
}, content: {
|
||||
ConsoleSearchTaskTypeCell(selection: $viewModel.criteria.network.networking.taskType)
|
||||
ConsoleSearchRequestStateCell(selection: $viewModel.criteria.network.networking.requestState)
|
||||
ConsoleSearchResponseSourceCell(selection: $viewModel.criteria.network.networking.source)
|
||||
ConsoleSearchToggleCell(title: "Redirect", isOn: $viewModel.criteria.network.networking.isRedirect)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -98,37 +209,47 @@ extension ConsoleFiltersView {
|
||||
#if DEBUG
|
||||
import CoreData
|
||||
|
||||
@available(iOS 16, visionOS 1, *)
|
||||
struct ConsoleFiltersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
NavigationView {
|
||||
makePreview(isOnlyNetwork: false)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.previewDisplayName("Messages")
|
||||
|
||||
NavigationView {
|
||||
makePreview(isOnlyNetwork: true)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.previewDisplayName("Network")
|
||||
}
|
||||
.injecting(.init(store: .mock))
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, macOS 13, visionOS 1, *)
|
||||
private func makePreview(isOnlyNetwork: Bool) -> some View {
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview("Messages", traits: .fixedLayout(width: 280, height: 900)) {
|
||||
let store = LoggerStore.mock
|
||||
let entities: [NSManagedObject] = try! isOnlyNetwork ? store.tasks() : store.messages()
|
||||
let viewModel = ConsoleFiltersViewModel(options: .init())
|
||||
let entities: [NSManagedObject] = try! store.messages()
|
||||
let environment = ConsoleEnvironment(store: store)
|
||||
let viewModel = environment.filters
|
||||
viewModel.entities.send(entities)
|
||||
viewModel.mode = isOnlyNetwork ? .network : .all
|
||||
return ConsoleFiltersView()
|
||||
.injecting(ConsoleEnvironment(store: store))
|
||||
viewModel.mode = .logs
|
||||
|
||||
let content = ConsoleFiltersView()
|
||||
.environmentObject(viewModel)
|
||||
.environmentObject(environment)
|
||||
#if os(macOS)
|
||||
return content
|
||||
#else
|
||||
return NavigationView {
|
||||
content
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
#endif
|
||||
}
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
#Preview("Network", traits: .fixedLayout(width: 280, height: 900)) {
|
||||
let store = LoggerStore.mock
|
||||
let entities: [NSManagedObject] = try! store.tasks()
|
||||
let environment = ConsoleEnvironment(store: store)
|
||||
let viewModel = environment.filters
|
||||
viewModel.entities.send(entities)
|
||||
viewModel.mode = .network
|
||||
|
||||
let content = ConsoleFiltersView()
|
||||
.environmentObject(viewModel)
|
||||
.environmentObject(environment)
|
||||
#if os(macOS)
|
||||
return content
|
||||
#else
|
||||
return NavigationView {
|
||||
content
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(macOS) || os(visionOS)
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// A persisted entry in the recent filters list.
|
||||
package struct ConsoleRecentFilter: Identifiable, Hashable, Codable {
|
||||
package let id: UUID
|
||||
package var filters: ConsoleFilters
|
||||
package var lastUsedDate: Date
|
||||
|
||||
package init(id: UUID = UUID(), filters: ConsoleFilters, lastUsedDate: Date = Date()) {
|
||||
self.id = id
|
||||
self.filters = filters
|
||||
self.lastUsedDate = lastUsedDate
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists recently-used filter sets per ``ConsoleMode``. Mirrors
|
||||
/// ``ConsoleSearchRecentSearchesStore`` in shape — JSON-encoded into
|
||||
/// `UserDefaults`, capped, with most-recent-first ordering.
|
||||
package final class ConsoleRecentFiltersStore: ObservableObject {
|
||||
private let mode: ConsoleMode
|
||||
|
||||
/// Most-recent first.
|
||||
@Published package private(set) var recents: [ConsoleRecentFilter] = []
|
||||
|
||||
private static let limit = 10
|
||||
|
||||
package init(mode: ConsoleMode) {
|
||||
self.mode = mode
|
||||
self.recents = decode([ConsoleRecentFilter].self, from: UserDefaults.standard.string(forKey: storeKey) ?? "[]") ?? []
|
||||
}
|
||||
|
||||
/// Creates a store pre-populated with the given entries. Does **not** read
|
||||
/// from or write to `UserDefaults` — intended for previews and tests.
|
||||
init(mode: ConsoleMode, recents: [ConsoleRecentFilter]) {
|
||||
self.mode = mode
|
||||
self.recents = recents
|
||||
}
|
||||
|
||||
private var storeKey: String { "\(mode.rawValue)-recent-filters" }
|
||||
|
||||
/// Saves the given filters as the most recent entry. No-ops when the
|
||||
/// filters are equivalent to the empty default, or when they would be a
|
||||
/// duplicate of an existing entry.
|
||||
package func save(_ filters: ConsoleFilters) {
|
||||
if filters.isDefault { return }
|
||||
|
||||
// Move to front if an equivalent entry already exists, refreshing timestamp.
|
||||
if let existingIndex = recents.firstIndex(where: { $0.filters == filters }) {
|
||||
var existing = recents.remove(at: existingIndex)
|
||||
existing.lastUsedDate = Date()
|
||||
recents.insert(existing, at: 0)
|
||||
} else {
|
||||
recents.insert(ConsoleRecentFilter(filters: filters), at: 0)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
package func remove(_ entry: ConsoleRecentFilter) {
|
||||
recents.removeAll { $0.id == entry.id }
|
||||
persist()
|
||||
}
|
||||
|
||||
package func clear() {
|
||||
recents = []
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
while recents.count > Self.limit {
|
||||
recents.removeLast()
|
||||
}
|
||||
UserDefaults.standard.set(encode(recents) ?? "[]", forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func encode<T: Encodable>(_ value: T) -> String? {
|
||||
(try? JSONEncoder().encode(value)).flatMap {
|
||||
String(data: $0, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private func decode<T: Decodable>(_ type: T.Type, from string: String) -> T? {
|
||||
string.data(using: .utf8).flatMap {
|
||||
try? JSONDecoder().decode(type, from: $0)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,105 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
|
||||
import SwiftUI
|
||||
import Pulse
|
||||
|
||||
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
||||
struct ConsoleRecentFiltersListView: View {
|
||||
@ObservedObject var store: ConsoleRecentFiltersStore
|
||||
let mode: ConsoleMode
|
||||
var onSelect: (ConsoleRecentFilter) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationTitle("Recent Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
ButtonClose()
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
store.clear()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(store.recents.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if store.recents.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Recent Filters",
|
||||
systemImage: "line.3.horizontal.decrease.circle",
|
||||
description: Text("Filters you apply in the console will appear here.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(store.recents) { entry in
|
||||
Button(action: { apply(entry) }) {
|
||||
row(for: entry)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
store.remove(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
store.remove(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for entry: ConsoleRecentFilter) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.filters.summary(for: mode) ?? "Filters")
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text(subtitle(for: entry))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func subtitle(for entry: ConsoleRecentFilter) -> String {
|
||||
let count = entry.filters.activeFilterCount(for: mode)
|
||||
let filters = count == 1 ? "1 filter" : "\(count) filters"
|
||||
let date = entry.lastUsedDate.formatted(.relative(presentation: .named))
|
||||
return "\(filters) · \(date)"
|
||||
}
|
||||
|
||||
private func apply(_ entry: ConsoleRecentFilter) {
|
||||
onSelect(entry)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,71 +0,0 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
|
||||
/// Filter the logs displayed in the console.
|
||||
struct ConsoleFilters: Hashable {
|
||||
var shared = Shared()
|
||||
var messages = Messages()
|
||||
var network = Network()
|
||||
|
||||
struct Shared: Hashable {
|
||||
var sessions = Sessions()
|
||||
var dates = Dates()
|
||||
}
|
||||
|
||||
struct Messages: Hashable {
|
||||
var logLevels = LogLevels()
|
||||
var labels = Labels()
|
||||
}
|
||||
|
||||
struct Network: Hashable {
|
||||
var host = Host()
|
||||
var url = URL()
|
||||
}
|
||||
}
|
||||
|
||||
protocol ConsoleFilterProtocol: Hashable {
|
||||
init() // Initializes with the default values
|
||||
}
|
||||
|
||||
extension ConsoleFilters {
|
||||
struct Sessions: Hashable, ConsoleFilterProtocol {
|
||||
var selection: Set<UUID> = []
|
||||
}
|
||||
|
||||
struct Dates: Hashable, ConsoleFilterProtocol {
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
|
||||
static var today: Dates {
|
||||
Dates(startDate: Calendar.current.startOfDay(for: Date()))
|
||||
}
|
||||
|
||||
static var recent: Dates {
|
||||
Dates(startDate: Date().addingTimeInterval(-1200))
|
||||
}
|
||||
}
|
||||
|
||||
struct LogLevels: ConsoleFilterProtocol {
|
||||
var levels: Set<LoggerStore.Level> = Set(LoggerStore.Level.allCases)
|
||||
.subtracting([LoggerStore.Level.trace])
|
||||
}
|
||||
|
||||
struct Labels: ConsoleFilterProtocol {
|
||||
var hidden: Set<String> = []
|
||||
var focused: String?
|
||||
}
|
||||
|
||||
struct Host: ConsoleFilterProtocol {
|
||||
var hidden: Set<String> = []
|
||||
var focused: String?
|
||||
}
|
||||
|
||||
struct URL: ConsoleFilterProtocol {
|
||||
var hidden: Set<String> = []
|
||||
var focused: String?
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
import CoreData
|
||||
|
||||
extension ConsoleFilters {
|
||||
static func makeMessagePredicates(
|
||||
criteria: ConsoleFilters,
|
||||
isOnlyErrors: Bool
|
||||
) -> NSPredicate? {
|
||||
var predicates = [NSPredicate]()
|
||||
if isOnlyErrors {
|
||||
predicates.append(NSPredicate(format: "level IN %@", [LoggerStore.Level.critical, .error].map { $0.rawValue }))
|
||||
}
|
||||
predicates += makePredicates(for: criteria.shared)
|
||||
predicates += makePredicates(for: criteria.messages)
|
||||
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
}
|
||||
|
||||
static func makeNetworkPredicates(
|
||||
criteria: ConsoleFilters,
|
||||
isOnlyErrors: Bool
|
||||
) -> NSPredicate? {
|
||||
var predicates = [NSPredicate]()
|
||||
if isOnlyErrors {
|
||||
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.failure.rawValue))
|
||||
}
|
||||
predicates += makePredicates(for: criteria.shared, isNetwork: true)
|
||||
predicates += makePredicates(for: criteria.network)
|
||||
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
}
|
||||
}
|
||||
|
||||
private func makePredicates(for criteria: ConsoleFilters.Shared, isNetwork: Bool = false) -> [NSPredicate] {
|
||||
var predicates = [NSPredicate]()
|
||||
|
||||
if !criteria.sessions.selection.isEmpty {
|
||||
predicates.append(NSPredicate(format: "session IN %@", criteria.sessions.selection))
|
||||
}
|
||||
|
||||
if let startDate = criteria.dates.startDate {
|
||||
predicates.append(NSPredicate(format: "createdAt >= %@", startDate as NSDate))
|
||||
}
|
||||
if let endDate = criteria.dates.endDate {
|
||||
predicates.append(NSPredicate(format: "createdAt <= %@", endDate as NSDate))
|
||||
}
|
||||
|
||||
return predicates
|
||||
}
|
||||
|
||||
private func makePredicates(for criteria: ConsoleFilters.Messages) -> [NSPredicate] {
|
||||
var predicates = [NSPredicate]()
|
||||
|
||||
if criteria.logLevels.levels.count != LoggerStore.Level.allCases.count {
|
||||
predicates.append(NSPredicate(format: "level IN %@", Array(criteria.logLevels.levels.map { $0.rawValue })))
|
||||
}
|
||||
|
||||
if let focusedLabel = criteria.labels.focused {
|
||||
predicates.append(NSPredicate(format: "label == %@", focusedLabel))
|
||||
} else if !criteria.labels.hidden.isEmpty {
|
||||
predicates.append(NSPredicate(format: "NOT label IN %@", Array(criteria.labels.hidden)))
|
||||
}
|
||||
|
||||
return predicates
|
||||
}
|
||||
|
||||
private func makePredicates(for criteria: ConsoleFilters.Network) -> [NSPredicate] {
|
||||
var predicates = [NSPredicate]()
|
||||
|
||||
if let focusedHost = criteria.host.focused {
|
||||
predicates.append(NSPredicate(format: "host == %@", focusedHost))
|
||||
} else if !criteria.host.hidden.isEmpty {
|
||||
predicates.append(NSPredicate(format: "NOT host IN %@", Array(criteria.host.hidden)))
|
||||
}
|
||||
|
||||
if let focusedURL = criteria.url.focused {
|
||||
predicates.append(NSPredicate(format: "url == %@", focusedURL))
|
||||
} else if !criteria.url.hidden.isEmpty {
|
||||
predicates.append(NSPredicate(format: "NOT url IN %@", Array(criteria.url.hidden)))
|
||||
}
|
||||
|
||||
return predicates
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A user-defined filter that matches console entries (log messages or network
|
||||
/// tasks) against a text value using configurable search options.
|
||||
///
|
||||
/// Each filter targets a specific ``Field``, which maps to a Core Data attribute
|
||||
/// on `LoggerMessageEntity` or `NetworkTaskEntity`. Use ``makePredicate()`` to
|
||||
/// produce an `NSPredicate` for use in a fetch request, or ``matches(string:)``
|
||||
/// for in-memory filtering.
|
||||
package struct ConsoleCustomFilter: Hashable, Identifiable {
|
||||
package let id = UUID()
|
||||
/// The entity field to match against (e.g. `.message`, `.url`).
|
||||
package var field: Field
|
||||
/// How the ``value`` is compared to the field (kind, case sensitivity, rule).
|
||||
package var match: StringSearchOptions
|
||||
/// When `true`, the predicate is inverted — the filter passes entries that
|
||||
/// do *not* match the value.
|
||||
package var isNegated: Bool = false
|
||||
/// The text to search for.
|
||||
package var value: String
|
||||
/// Whether this filter participates in the active filter set.
|
||||
package var isEnabled: Bool = true
|
||||
|
||||
package init(field: Field, match: StringSearchOptions = .default, isNegated: Bool = false, value: String, isEnabled: Bool = true) {
|
||||
self.field = field
|
||||
self.match = match
|
||||
self.isNegated = isNegated
|
||||
self.value = value
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
/// A short human-readable label for the targeted field (e.g. `"URL"`).
|
||||
package var fieldTitle: String { field.title }
|
||||
|
||||
/// A short human-readable description of the match rule, optionally
|
||||
/// prefixed with `"Not "` when the filter is negated.
|
||||
package var matchTitle: String { (isNegated ? "Not " : "") + match.title }
|
||||
|
||||
/// Returns a copy of this filter with a new ``id``.
|
||||
package func duplicated() -> ConsoleCustomFilter {
|
||||
ConsoleCustomFilter(field: field, match: match, isNegated: isNegated, value: value, isEnabled: isEnabled)
|
||||
}
|
||||
|
||||
package static func == (lhs: ConsoleCustomFilter, rhs: ConsoleCustomFilter) -> Bool {
|
||||
(lhs.field, lhs.match, lhs.isNegated, lhs.value, lhs.isEnabled) == (rhs.field, rhs.match, rhs.isNegated, rhs.value, rhs.isEnabled)
|
||||
}
|
||||
|
||||
package func hash(into hasher: inout Hasher) {
|
||||
field.hash(into: &hasher)
|
||||
match.hash(into: &hasher)
|
||||
isNegated.hash(into: &hasher)
|
||||
value.hash(into: &hasher)
|
||||
isEnabled.hash(into: &hasher)
|
||||
}
|
||||
|
||||
/// Returns an `NSPredicate` suitable for a Core Data fetch request on the
|
||||
/// entity that owns ``field``. When ``isNegated`` is `true`, the predicate
|
||||
/// is wrapped in `NSCompoundPredicate(notPredicateWithSubpredicate:)`.
|
||||
package func makePredicate() -> NSPredicate {
|
||||
let predicate = match.predicate(key: field.key, value: value)
|
||||
return isNegated ? NSCompoundPredicate(notPredicateWithSubpredicate: predicate) : predicate
|
||||
}
|
||||
|
||||
/// Returns `true` when `string` satisfies the filter's match options.
|
||||
/// Applies negation when ``isNegated`` is `true`.
|
||||
///
|
||||
/// Use this for in-memory matching where a Core Data fetch is not involved.
|
||||
package func matches(string: String) -> Bool {
|
||||
let result = match.matches(string, value: value)
|
||||
return isNegated ? !result : result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
// `id` is a per-instance UUID used only for SwiftUI list identity. It must not
|
||||
// round-trip through JSON, otherwise re-applying a recent filter would clash
|
||||
// with the live one. Custom Codable regenerates `id` on decode.
|
||||
extension ConsoleCustomFilter: Codable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case field, match, isNegated, value, isEnabled
|
||||
}
|
||||
|
||||
package init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(
|
||||
field: try container.decode(Field.self, forKey: .field),
|
||||
match: try container.decode(StringSearchOptions.self, forKey: .match),
|
||||
isNegated: try container.decode(Bool.self, forKey: .isNegated),
|
||||
value: try container.decode(String.self, forKey: .value),
|
||||
isEnabled: try container.decode(Bool.self, forKey: .isEnabled)
|
||||
)
|
||||
}
|
||||
|
||||
package func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(field, forKey: .field)
|
||||
try container.encode(match, forKey: .match)
|
||||
try container.encode(isNegated, forKey: .isNegated)
|
||||
try container.encode(value, forKey: .value)
|
||||
try container.encode(isEnabled, forKey: .isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Field
|
||||
|
||||
extension ConsoleCustomFilter {
|
||||
/// Identifies which entity attribute a ``ConsoleCustomFilter`` targets.
|
||||
///
|
||||
/// ``title`` is shown in the UI; ``key`` is the Core Data attribute name
|
||||
/// passed to `StringSearchOptions.predicate(key:value:)`.
|
||||
package struct Field: Hashable, Codable {
|
||||
package var title: String
|
||||
package var key: String
|
||||
|
||||
package init(title: String, key: String) {
|
||||
self.title = title
|
||||
self.key = key
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled group of fields for display in a picker with visual separators.
|
||||
package struct FieldGroup: Hashable {
|
||||
package var title: String?
|
||||
package var fields: [Field]
|
||||
|
||||
package init(title: String? = nil, fields: [Field]) {
|
||||
self.title = title
|
||||
self.fields = fields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Fields
|
||||
|
||||
extension ConsoleCustomFilter {
|
||||
/// Ordered list of fields available when filtering `LoggerMessageEntity`.
|
||||
package static let messageFields: [Field] = [.level, .label, .message, .metadata, .file]
|
||||
|
||||
/// Message fields wrapped in a single group (no section header needed).
|
||||
package static let messageFieldGroups: [FieldGroup] = [
|
||||
FieldGroup(fields: messageFields)
|
||||
]
|
||||
|
||||
package var availableFieldGroups: [FieldGroup] {
|
||||
if Self.messageFields.contains(field) {
|
||||
return Self.messageFieldGroups
|
||||
} else {
|
||||
return Self.networkFieldGroups
|
||||
}
|
||||
}
|
||||
|
||||
package static func defaultMessageFilter() -> ConsoleCustomFilter {
|
||||
ConsoleCustomFilter(field: .message, match: .default, value: "")
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleCustomFilter.Field {
|
||||
/// Matches `LoggerMessageEntity.level` (stored as a raw `Int16`).
|
||||
package static let level = ConsoleCustomFilter.Field(title: "Level", key: "level")
|
||||
package static let label = ConsoleCustomFilter.Field(title: "Label", key: "label")
|
||||
/// Matches `LoggerMessageEntity.text`.
|
||||
package static let message = ConsoleCustomFilter.Field(title: "Message", key: "text")
|
||||
/// Matches `LoggerMessageEntity.rawMetadata` (newline-separated `key: value` pairs).
|
||||
package static let metadata = ConsoleCustomFilter.Field(title: "Metadata", key: "rawMetadata")
|
||||
package static let file = ConsoleCustomFilter.Field(title: "File", key: "file")
|
||||
}
|
||||
|
||||
// MARK: - Network Fields
|
||||
|
||||
extension ConsoleCustomFilter {
|
||||
/// Network fields organized into groups for the field picker.
|
||||
package static let networkFieldGroups: [FieldGroup] = [
|
||||
FieldGroup(title: "URL", fields: [.url, .host, .path]),
|
||||
FieldGroup(fields: [.method, .statusCode, .errorCode, .errorDomain]),
|
||||
FieldGroup(title: "Advanced", fields: [.taskDescription, .requestHeader, .responseHeader])
|
||||
]
|
||||
/// All network fields (flattened from groups).
|
||||
package static let allNetworkFields: [Field] = networkFieldGroups.flatMap(\.fields)
|
||||
|
||||
package static func defaultNetworkFilter() -> ConsoleCustomFilter {
|
||||
ConsoleCustomFilter(field: .url, match: .default, value: "")
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleCustomFilter.Field {
|
||||
package static let url = ConsoleCustomFilter.Field(title: "URL", key: "url")
|
||||
package static let host = ConsoleCustomFilter.Field(title: "Host", key: "host")
|
||||
/// Matches `NetworkTaskEntity.path`.
|
||||
package static let path = ConsoleCustomFilter.Field(title: "Path", key: "path")
|
||||
package static let method = ConsoleCustomFilter.Field(title: "Method", key: "httpMethod")
|
||||
/// Matches `NetworkTaskEntity.statusCode` (stored as `Int32`).
|
||||
package static let statusCode = ConsoleCustomFilter.Field(title: "Status Code", key: "statusCode")
|
||||
/// Matches `NetworkTaskEntity.errorCode` (stored as `Int32`).
|
||||
package static let errorCode = ConsoleCustomFilter.Field(title: "Error Code", key: "errorCode")
|
||||
package static let errorDomain = ConsoleCustomFilter.Field(title: "Error Domain", key: "errorDomain")
|
||||
/// Matches `NetworkTaskEntity.taskDescription`.
|
||||
package static let taskDescription = ConsoleCustomFilter.Field(title: "Task Description", key: "taskDescription")
|
||||
/// Matches headers on `NetworkRequestEntity` via a keypath predicate.
|
||||
package static let requestHeader = ConsoleCustomFilter.Field(title: "Request Headers", key: "originalRequest.httpHeaders")
|
||||
/// Matches headers on `NetworkResponseEntity` via a keypath predicate.
|
||||
package static let responseHeader = ConsoleCustomFilter.Field(title: "Response Headers", key: "response.httpHeaders")
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
import CoreData
|
||||
|
||||
extension ConsoleFilters {
|
||||
package static func makeMessagePredicates(
|
||||
criteria: ConsoleFilters,
|
||||
sessions: Set<UUID>,
|
||||
isOnlyErrors: Bool
|
||||
) -> NSPredicate? {
|
||||
var predicates = [NSPredicate]()
|
||||
if isOnlyErrors {
|
||||
predicates.append(NSPredicate(format: "level IN %@", [LoggerStore.Level.critical, .error].map { $0.rawValue }))
|
||||
}
|
||||
predicates += makePredicates(for: criteria.shared, sessions: sessions)
|
||||
predicates += makePredicates(for: criteria.messages)
|
||||
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
}
|
||||
|
||||
package static func makeNetworkPredicates(
|
||||
criteria: ConsoleFilters,
|
||||
sessions: Set<UUID>,
|
||||
isOnlyErrors: Bool
|
||||
) -> NSPredicate? {
|
||||
var predicates = [NSPredicate]()
|
||||
if isOnlyErrors {
|
||||
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.failure.rawValue))
|
||||
}
|
||||
predicates += makePredicates(for: criteria.shared, sessions: sessions, isNetwork: true)
|
||||
predicates += makePredicates(for: criteria.network)
|
||||
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
}
|
||||
}
|
||||
|
||||
private func makePredicates(for criteria: ConsoleFilters.Shared, sessions: Set<UUID>, isNetwork: Bool = false) -> [NSPredicate] {
|
||||
var predicates = [NSPredicate]()
|
||||
|
||||
if !sessions.isEmpty {
|
||||
predicates.append(NSPredicate(format: "session IN %@", sessions))
|
||||
}
|
||||
|
||||
if criteria.dates.isEnabled {
|
||||
if let startDate = criteria.dates.startDate {
|
||||
predicates.append(NSPredicate(format: "createdAt >= %@", startDate as NSDate))
|
||||
}
|
||||
if let endDate = criteria.dates.endDate {
|
||||
predicates.append(NSPredicate(format: "createdAt <= %@", endDate as NSDate))
|
||||
}
|
||||
}
|
||||
|
||||
return predicates
|
||||
}
|
||||
|
||||
private func makePredicates(for criteria: ConsoleFilters.Messages) -> [NSPredicate] {
|
||||
var predicates = [NSPredicate]()
|
||||
|
||||
if criteria.logLevels.isEnabled {
|
||||
if criteria.logLevels.levels.count != LoggerStore.Level.allCases.count {
|
||||
predicates.append(NSPredicate(format: "level IN %@", Array(criteria.logLevels.levels.map { $0.rawValue })))
|
||||
}
|
||||
}
|
||||
|
||||
if criteria.labels.isEnabled {
|
||||
if let focusedLabel = criteria.labels.focused {
|
||||
predicates.append(NSPredicate(format: "label == %@", focusedLabel))
|
||||
} else if !criteria.labels.hidden.isEmpty {
|
||||
predicates.append(NSPredicate(format: "NOT label IN %@", Array(criteria.labels.hidden)))
|
||||
}
|
||||
}
|
||||
|
||||
return predicates + makeStandalonePredicates(for: criteria)
|
||||
}
|
||||
|
||||
private func makePredicates(for criteria: ConsoleFilters.Network) -> [NSPredicate] {
|
||||
var predicates = [NSPredicate]()
|
||||
|
||||
if criteria.host.isEnabled {
|
||||
if let focusedHost = criteria.host.focused {
|
||||
predicates.append(NSPredicate(format: "host == %@", focusedHost))
|
||||
} else if !criteria.host.hidden.isEmpty {
|
||||
predicates.append(NSPredicate(format: "NOT host IN %@", Array(criteria.host.hidden)))
|
||||
}
|
||||
}
|
||||
|
||||
if criteria.url.isEnabled {
|
||||
if let focusedURL = criteria.url.focused {
|
||||
predicates.append(NSPredicate(format: "url == %@", focusedURL))
|
||||
} else if !criteria.url.hidden.isEmpty {
|
||||
predicates.append(NSPredicate(format: "NOT url IN %@", Array(criteria.url.hidden)))
|
||||
}
|
||||
}
|
||||
|
||||
return predicates + makeStandalonePredicates(for: criteria)
|
||||
}
|
||||
|
||||
private func makeStandalonePredicates(for criteria: ConsoleFilters.Messages) -> [NSPredicate] {
|
||||
if criteria.custom.isEnabled {
|
||||
let filterPredicates = criteria.custom.filters
|
||||
.filter { !$0.value.isEmpty && $0.isEnabled }
|
||||
.map { $0.makePredicate() }
|
||||
if !filterPredicates.isEmpty {
|
||||
switch criteria.custom.logicalOperator {
|
||||
case .and:
|
||||
return filterPredicates
|
||||
case .or:
|
||||
return [NSCompoundPredicate(orPredicateWithSubpredicates: filterPredicates)]
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private func makeStandalonePredicates(for criteria: ConsoleFilters.Network) -> [NSPredicate] {
|
||||
var predicates: [NSPredicate] = []
|
||||
if criteria.response.isEnabled {
|
||||
if let value = criteria.response.responseSize.byteCountRange.lowerBound {
|
||||
predicates.append(NSPredicate(format: "responseBodySize >= %d", value))
|
||||
}
|
||||
if let value = criteria.response.responseSize.byteCountRange.upperBound {
|
||||
predicates.append(NSPredicate(format: "responseBodySize <= %d", value))
|
||||
}
|
||||
if let value = Int(criteria.response.statusCode.range.lowerBound) {
|
||||
predicates.append(NSPredicate(format: "statusCode >= %d", value))
|
||||
}
|
||||
if let value = Int(criteria.response.statusCode.range.upperBound) {
|
||||
predicates.append(NSPredicate(format: "statusCode <= %d", value))
|
||||
}
|
||||
if let value = criteria.response.duration.durationRange.lowerBound {
|
||||
predicates.append(NSPredicate(format: "duration >= %f", value))
|
||||
}
|
||||
if let value = criteria.response.duration.durationRange.upperBound {
|
||||
predicates.append(NSPredicate(format: "duration <= %f", value))
|
||||
}
|
||||
switch criteria.response.contentType.contentType {
|
||||
case .any: break
|
||||
default: predicates.append(NSPredicate(format: "responseContentType CONTAINS %@", criteria.response.contentType.contentType.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
if criteria.request.isEnabled {
|
||||
if case .some(let method) = criteria.request.httpMethod {
|
||||
predicates.append(NSPredicate(format: "httpMethod ==[c] %@", method.rawValue))
|
||||
}
|
||||
if let value = criteria.request.requestSize.byteCountRange.lowerBound {
|
||||
predicates.append(NSPredicate(format: "requestBodySize >= %d", value))
|
||||
}
|
||||
if let value = criteria.request.requestSize.byteCountRange.upperBound {
|
||||
predicates.append(NSPredicate(format: "requestBodySize <= %d", value))
|
||||
}
|
||||
}
|
||||
|
||||
if criteria.networking.isEnabled {
|
||||
if criteria.networking.isRedirect {
|
||||
predicates.append(NSPredicate(format: "redirectCount >= 1"))
|
||||
}
|
||||
switch criteria.networking.source {
|
||||
case .any:
|
||||
break
|
||||
case .network:
|
||||
predicates.append(NSPredicate(format: "isFromCache == NO"))
|
||||
case .cache:
|
||||
predicates.append(NSPredicate(format: "isFromCache == YES"))
|
||||
}
|
||||
if case .some(let taskType) = criteria.networking.taskType {
|
||||
predicates.append(NSPredicate(format: "taskType == %i", taskType.rawValue))
|
||||
}
|
||||
switch criteria.networking.requestState {
|
||||
case .any:
|
||||
break
|
||||
case .pending:
|
||||
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.pending.rawValue))
|
||||
case .success:
|
||||
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.success.rawValue))
|
||||
case .failure:
|
||||
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.failure.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
if criteria.custom.isEnabled {
|
||||
let filterPredicates = criteria.custom.filters
|
||||
.filter { !$0.value.isEmpty && $0.isEnabled }
|
||||
.map { $0.makePredicate() }
|
||||
if !filterPredicates.isEmpty {
|
||||
switch criteria.custom.logicalOperator {
|
||||
case .and:
|
||||
predicates.append(contentsOf: filterPredicates)
|
||||
case .or:
|
||||
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: filterPredicates))
|
||||
}
|
||||
}
|
||||
}
|
||||
return predicates
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
||||
|
||||
import Foundation
|
||||
import Pulse
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
/// Filter the logs displayed in the console.
|
||||
package struct ConsoleFilters: Hashable, Codable {
|
||||
package var shared = Shared()
|
||||
package var messages = Messages()
|
||||
package var network = Network()
|
||||
|
||||
package init() {}
|
||||
|
||||
package struct Shared: Hashable, Codable {
|
||||
package var dates = Dates()
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct Messages: Hashable, Codable {
|
||||
package var logLevels = LogLevels()
|
||||
package var labels = Labels()
|
||||
package var custom = CustomMessageFilters()
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct Network: Hashable, Codable {
|
||||
package var host = Host()
|
||||
package var url = URL()
|
||||
package var custom = CustomNetworkFilters()
|
||||
package var response = Response()
|
||||
package var request = Request()
|
||||
package var networking = Networking()
|
||||
|
||||
package init() {}
|
||||
}
|
||||
}
|
||||
|
||||
package protocol ConsoleFilterProtocol: Hashable, Codable {
|
||||
init()
|
||||
var isDefault: Bool { get }
|
||||
var title: String { get }
|
||||
var description: String? { get }
|
||||
}
|
||||
|
||||
extension ConsoleFilterProtocol {
|
||||
package var isDefault: Bool { self == Self() }
|
||||
}
|
||||
|
||||
package protocol ConsoleFilterGroupProtocol: ConsoleFilterProtocol {
|
||||
var isEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
extension ConsoleFilters {
|
||||
package struct Dates: Hashable, Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var startDate: Date?
|
||||
package var endDate: Date?
|
||||
|
||||
package init() {}
|
||||
|
||||
private init(startDate: Date, endDate: Date? = nil) {
|
||||
self.startDate = startDate
|
||||
self.endDate = endDate
|
||||
}
|
||||
|
||||
package static var last30Minutes: Dates {
|
||||
Dates(startDate: Date().addingTimeInterval(-1800))
|
||||
}
|
||||
|
||||
package static var lastHour: Dates {
|
||||
Dates(startDate: Date().addingTimeInterval(-3600))
|
||||
}
|
||||
|
||||
package static var today: Dates {
|
||||
Dates(startDate: Calendar.current.startOfDay(for: Date()))
|
||||
}
|
||||
|
||||
package static var yesterday: Dates {
|
||||
let calendar = Calendar.current
|
||||
let startOfToday = calendar.startOfDay(for: Date())
|
||||
let startOfYesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday)!
|
||||
return Dates(startDate: startOfYesterday, endDate: startOfToday)
|
||||
}
|
||||
}
|
||||
|
||||
package struct LogLevels: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var levels: Set<LoggerStore.Level> = Set(LoggerStore.Level.allCases)
|
||||
.subtracting([LoggerStore.Level.trace])
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct Labels: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var hidden: Set<String> = []
|
||||
package var focused: String?
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct Host: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var hidden: Set<String> = []
|
||||
package var focused: String?
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct URL: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var hidden: Set<String> = []
|
||||
package var focused: String?
|
||||
|
||||
package init() {}
|
||||
}
|
||||
}
|
||||
|
||||
package enum ConsoleFilterLogicalOperator: Hashable, Codable {
|
||||
case and
|
||||
case or
|
||||
}
|
||||
|
||||
extension ConsoleFilters {
|
||||
package struct CustomMessageFilters: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var filters: [ConsoleCustomFilter] = [.defaultMessageFilter()]
|
||||
package var logicalOperator: ConsoleFilterLogicalOperator = .and
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct CustomNetworkFilters: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var filters: [ConsoleCustomFilter] = [.defaultNetworkFilter()]
|
||||
package var logicalOperator: ConsoleFilterLogicalOperator = .and
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct Response: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var statusCode = StatusCode()
|
||||
package var contentType = ContentType()
|
||||
package var responseSize = ResponseSize()
|
||||
package var duration = Duration()
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct StatusCode: Hashable, Codable, ConsoleFilterProtocol {
|
||||
package var range: ValuesRange<String> = .empty
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct ResponseSize: Hashable, Codable, ConsoleFilterProtocol {
|
||||
package var range: ValuesRange<String> = .empty
|
||||
package var unit: MeasurementUnit = .kilobytes
|
||||
|
||||
package var byteCountRange: ValuesRange<Int64?> {
|
||||
ValuesRange(lowerBound: byteCount(from: range.lowerBound),
|
||||
upperBound: byteCount(from: range.upperBound))
|
||||
}
|
||||
|
||||
private func byteCount(from string: String) -> Int64? {
|
||||
Int64(string).map { $0 * unit.multiplier }
|
||||
}
|
||||
|
||||
package enum MeasurementUnit: Identifiable, CaseIterable, Codable {
|
||||
case bytes, kilobytes, megabytes
|
||||
|
||||
package var title: String {
|
||||
switch self {
|
||||
case .bytes: return "Bytes"
|
||||
case .kilobytes: return "KB"
|
||||
case .megabytes: return "MB"
|
||||
}
|
||||
}
|
||||
|
||||
package var multiplier: Int64 {
|
||||
switch self {
|
||||
case .bytes: return 1
|
||||
case .kilobytes: return 1024
|
||||
case .megabytes: return 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
package var id: MeasurementUnit { self }
|
||||
}
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct Duration: Hashable, Codable, ConsoleFilterProtocol {
|
||||
package var range: ValuesRange<String> = .empty
|
||||
package var unit: Unit = .seconds
|
||||
|
||||
package var durationRange: ValuesRange<TimeInterval?> {
|
||||
ValuesRange(lowerBound: TimeInterval(range.lowerBound).map(unit.convert),
|
||||
upperBound: TimeInterval(range.upperBound).map(unit.convert))
|
||||
}
|
||||
|
||||
package enum Unit: Identifiable, CaseIterable, Codable {
|
||||
case minutes
|
||||
case seconds
|
||||
case milliseconds
|
||||
|
||||
package var title: String {
|
||||
switch self {
|
||||
case .minutes: return "Min"
|
||||
case .seconds: return "Sec"
|
||||
case .milliseconds: return "ms"
|
||||
}
|
||||
}
|
||||
|
||||
package func convert(_ value: TimeInterval) -> TimeInterval {
|
||||
switch self {
|
||||
case .minutes: return value * 60
|
||||
case .seconds: return value
|
||||
case .milliseconds: return value / 1000
|
||||
}
|
||||
}
|
||||
|
||||
package var id: Unit { self }
|
||||
}
|
||||
|
||||
package init() {}
|
||||
}
|
||||
|
||||
package struct ContentType: Hashable, Codable, ConsoleFilterProtocol {
|
||||
package var contentType = ContentType.any
|
||||
|
||||
package init() {}
|
||||
|
||||
package enum ContentType: String, CaseIterable, Codable {
|
||||
// common
|
||||
case any = ""
|
||||
case json = "application/json"
|
||||
case plain = "text/plain"
|
||||
case html = "text/html"
|
||||
|
||||
// uncommon
|
||||
case javascript = "application/javascript"
|
||||
case css = "text/css"
|
||||
case csv = "text/csv"
|
||||
case xml = "text/xml"
|
||||
case pdf = "application/pdf"
|
||||
|
||||
// image
|
||||
case gif = "image/gif"
|
||||
case jpeg = "image/jpeg"
|
||||
case png = "image/png"
|
||||
case webp = "image/webp"
|
||||
case anyImage = "image/"
|
||||
|
||||
// video
|
||||
case anyVideo = "video/"
|
||||
}
|
||||
}
|
||||
|
||||
package struct Request: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var httpMethod: HTTPMethodFilter = .any
|
||||
package var requestSize = ResponseSize()
|
||||
|
||||
package init() {}
|
||||
|
||||
package enum HTTPMethodFilter: Hashable, Codable {
|
||||
case any
|
||||
case some(HTTPMethod)
|
||||
}
|
||||
}
|
||||
|
||||
package struct Networking: Codable, ConsoleFilterGroupProtocol {
|
||||
package var isEnabled = true
|
||||
package var isRedirect = false
|
||||
package var source: Source = .any
|
||||
package var taskType: TaskType = .any
|
||||
package var requestState: RequestState = .any
|
||||
|
||||
package init() {}
|
||||
|
||||
package enum Source: CaseIterable, Codable {
|
||||
case any
|
||||
case network
|
||||
case cache
|
||||
|
||||
package var title: String {
|
||||
switch self {
|
||||
case .any: return "Any"
|
||||
case .cache: return "Cache"
|
||||
case .network: return "Network"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package enum TaskType: Hashable, Codable {
|
||||
case any
|
||||
case some(NetworkLogger.TaskType)
|
||||
}
|
||||
|
||||
package enum RequestState: Hashable, CaseIterable, Codable {
|
||||
case any
|
||||
case pending
|
||||
case success
|
||||
case failure
|
||||
|
||||
package var title: String {
|
||||
switch self {
|
||||
case .any: return "Any"
|
||||
case .pending: return "Pending"
|
||||
case .success: return "Success"
|
||||
case .failure: return "Failure"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter Titles & Descriptions
|
||||
|
||||
extension ConsoleFilters.Dates {
|
||||
package var title: String { "Time Period" }
|
||||
package var description: String? { nil }
|
||||
}
|
||||
|
||||
extension ConsoleFilters.LogLevels {
|
||||
package var title: String { "Log Levels" }
|
||||
package var description: String? {
|
||||
let defaultLevels = Self().levels
|
||||
guard levels != defaultLevels else { return nil }
|
||||
if levels.count == 1, let only = levels.first {
|
||||
return "\(only.name) only"
|
||||
} else if levels.isEmpty {
|
||||
return "no levels"
|
||||
}
|
||||
return "\(levels.count) levels"
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.Labels {
|
||||
package var title: String { "Labels" }
|
||||
package var description: String? {
|
||||
if let label = focused, !label.isEmpty {
|
||||
return label
|
||||
} else if !hidden.isEmpty {
|
||||
return "−\(hidden.count) label\(hidden.count == 1 ? "" : "s")"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.Host {
|
||||
package var title: String { "Hosts" }
|
||||
package var description: String? {
|
||||
if let host = focused, !host.isEmpty {
|
||||
return host
|
||||
} else if !hidden.isEmpty {
|
||||
return "−\(hidden.count) host\(hidden.count == 1 ? "" : "s")"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.URL {
|
||||
package var title: String { "URL" }
|
||||
package var description: String? {
|
||||
if let url = focused, !url.isEmpty {
|
||||
return url
|
||||
} else if !hidden.isEmpty {
|
||||
return "−\(hidden.count) URL\(hidden.count == 1 ? "" : "s")"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.CustomMessageFilters {
|
||||
package var title: String { "Custom Filters" }
|
||||
package var description: String? {
|
||||
let active = filters.filter { !$0.value.isEmpty }
|
||||
guard let first = active.first else { return nil }
|
||||
return "\(first.fieldTitle): \(first.value)"
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.CustomNetworkFilters {
|
||||
package var title: String { "Custom Filters" }
|
||||
package var description: String? {
|
||||
let active = filters.filter { !$0.value.isEmpty }
|
||||
guard let first = active.first else { return nil }
|
||||
return "\(first.fieldTitle): \(first.value)"
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.StatusCode {
|
||||
package var title: String { "Status Code" }
|
||||
package var description: String? {
|
||||
switch (range.lowerBound.isEmpty, range.upperBound.isEmpty) {
|
||||
case (false, false): return "\(range.lowerBound)–\(range.upperBound)"
|
||||
case (false, true): return "≥\(range.lowerBound)"
|
||||
case (true, false): return "≤\(range.upperBound)"
|
||||
case (true, true): return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.ContentType {
|
||||
package var title: String { "Content Type" }
|
||||
package var description: String? {
|
||||
contentType != .any ? contentType.rawValue : nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.ResponseSize {
|
||||
package var title: String { "Size" }
|
||||
package var description: String? {
|
||||
switch (range.lowerBound.isEmpty, range.upperBound.isEmpty) {
|
||||
case (false, false): return "\(range.lowerBound)–\(range.upperBound) \(unit.title)"
|
||||
case (false, true): return "≥\(range.lowerBound) \(unit.title)"
|
||||
case (true, false): return "≤\(range.upperBound) \(unit.title)"
|
||||
case (true, true): return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.Duration {
|
||||
package var title: String { "Duration" }
|
||||
package var description: String? {
|
||||
switch (range.lowerBound.isEmpty, range.upperBound.isEmpty) {
|
||||
case (false, false): return "\(range.lowerBound)–\(range.upperBound) \(unit.title)"
|
||||
case (false, true): return "≥\(range.lowerBound) \(unit.title)"
|
||||
case (true, false): return "≤\(range.upperBound) \(unit.title)"
|
||||
case (true, true): return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.Response {
|
||||
package var title: String { "Response" }
|
||||
package var description: String? {
|
||||
let parts = [statusCode.description, contentType.description, responseSize.description, duration.description].compactMap { $0 }
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.Request {
|
||||
package var title: String { "Request" }
|
||||
package var description: String? {
|
||||
if case .some(let method) = httpMethod {
|
||||
return method.rawValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsoleFilters.Networking {
|
||||
package var title: String { "Networking" }
|
||||
package var description: String? {
|
||||
if requestState != .any {
|
||||
return requestState.title
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Summary
|
||||
|
||||
extension ConsoleFilters {
|
||||
/// The all-defaults instance, cached so callers comparing against the
|
||||
/// empty state don't pay the allocation cost on every check.
|
||||
private static let empty = ConsoleFilters()
|
||||
|
||||
/// `true` when every section equals the all-defaults instance.
|
||||
package var isDefault: Bool { self == Self.empty }
|
||||
|
||||
/// The mode-relevant filter groups, excluding sessions (context-specific).
|
||||
package func filters(for mode: ConsoleMode) -> [any ConsoleFilterProtocol] {
|
||||
var result: [any ConsoleFilterProtocol]
|
||||
if mode == .network {
|
||||
result = [network.custom, network.response.statusCode, network.response.contentType, network.response.responseSize, network.response.duration, network.request, network.host, network.url, network.networking]
|
||||
} else {
|
||||
result = [messages.custom, messages.logLevels, messages.labels]
|
||||
}
|
||||
result.append(shared.dates)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Number of filter sections that differ from defaults for the given mode.
|
||||
package func activeFilterCount(for mode: ConsoleMode) -> Int {
|
||||
filters(for: mode).filter { !$0.isDefault }.count
|
||||
}
|
||||
|
||||
/// A short, mode-aware summary of the active filters, suitable for a recent
|
||||
/// filter chip. Returns `nil` when nothing meaningful is active.
|
||||
package func summary(for mode: ConsoleMode) -> String? {
|
||||
let segments = filters(for: mode).compactMap { $0.description }
|
||||
guard !segments.isEmpty else { return nil }
|
||||
let head = segments.prefix(3).joined(separator: " · ")
|
||||
let extra = segments.count > 3 ? " +\(segments.count - 2)" : ""
|
||||
return head + extra
|
||||
}
|
||||
|
||||
/// A longer-form description listing every active predicate, used as a
|
||||
/// subtitle in the recent filters list. Returns `nil` when there are no
|
||||
/// extra segments beyond the summary.
|
||||
package func detail(for mode: ConsoleMode) -> String? {
|
||||
let segments = filters(for: mode).compactMap { $0.description }
|
||||
guard segments.count > 1 else { return nil }
|
||||
return segments.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user