diff --git a/Demo/Pulse.xcodeproj/project.pbxproj b/Demo/Pulse.xcodeproj/project.pbxproj index 8ae2ec419..76d413fcf 100644 --- a/Demo/Pulse.xcodeproj/project.pbxproj +++ b/Demo/Pulse.xcodeproj/project.pbxproj @@ -477,7 +477,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = Sources/Integrations/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -499,7 +499,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = Sources/Integrations/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -567,7 +567,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_PACKAGE_NAME = pulse; - TVOS_DEPLOYMENT_TARGET = 15.0; + TVOS_DEPLOYMENT_TARGET = 16.0; WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Debug; @@ -618,7 +618,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_PACKAGE_NAME = pulse; - TVOS_DEPLOYMENT_TARGET = 15.0; + TVOS_DEPLOYMENT_TARGET = 16.0; VALIDATE_PRODUCT = YES; WATCHOS_DEPLOYMENT_TARGET = 8.0; }; @@ -697,6 +697,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -740,6 +741,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Package.swift b/Package.swift index 15977adbc..ee09c89a5 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "Pulse", platforms: [ - .iOS(.v15), - .tvOS(.v15), + .iOS(.v16), + .tvOS(.v16), .macOS(.v13), .watchOS(.v9), .visionOS(.v1) diff --git a/Sources/PulseUI/Features/FileViewer/FileViewer.swift b/Sources/PulseUI/Features/FileViewer/FileViewer.swift index 751056d4e..54b1425b7 100644 --- a/Sources/PulseUI/Features/FileViewer/FileViewer.swift +++ b/Sources/PulseUI/Features/FileViewer/FileViewer.swift @@ -30,6 +30,8 @@ struct FileViewer: View { ScrollView { ImageViewer(viewModel: viewModel) } + case .json(let viewModel): + JSONViewer(viewModel: viewModel) #if os(iOS) || os(visionOS) case .pdf(let document): PDFKitRepresentedView(document: document) diff --git a/Sources/PulseUI/Features/FileViewer/FileViewerViewModel.swift b/Sources/PulseUI/Features/FileViewer/FileViewerViewModel.swift index 952d757f5..64b6b1e35 100644 --- a/Sources/PulseUI/Features/FileViewer/FileViewerViewModel.swift +++ b/Sources/PulseUI/Features/FileViewer/FileViewerViewModel.swift @@ -16,6 +16,7 @@ final class FileViewerViewModel: ObservableObject { private let context: FileViewerViewModelContext var contentType: NetworkLogger.ContentType? { context.contentType } private let getData: () -> Data + private let settings = UserSettings.shared private(set) lazy var contents: Contents = render(data: getData()) @@ -27,6 +28,7 @@ final class FileViewerViewModel: ObservableObject { enum Contents { case image(ImagePreviewViewModel) + case json(JSONViewerViewModel) case other(RichTextViewModel) #if os(iOS) || os(macOS) || os(visionOS) case pdf(PDFDocument) @@ -38,6 +40,13 @@ final class FileViewerViewModel: ObservableObject { return .image(ImagePreviewViewModel(image: image, data: data, context: context)) } else if contentType?.isPDF ?? false, let pdf = makePDF(data: data) { return pdf + } else if let json = try? JSONSerialization.jsonObject(with: data, options: []) { + if settings.useCollapsibleJSONViewer { + return .json(JSONViewerViewModel(json: json, error: context.error, contentType: contentType)) + } else { + let string = TextRenderer().render(json: json, error: context.error) + return .other(RichTextViewModel(string: string, contentType: contentType)) + } } else { let string = TextRenderer().render(data, contentType: contentType, error: context.error) return .other(RichTextViewModel(string: string, contentType: contentType)) diff --git a/Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewer.swift b/Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewer.swift new file mode 100644 index 000000000..487fc2a3d --- /dev/null +++ b/Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewer.swift @@ -0,0 +1,180 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). + +#if os(iOS) || os(visionOS) + +import Pulse +import SwiftUI +import UIKit + +struct JSONViewer: View { + @ObservedObject var viewModel: JSONViewerViewModel + @State private var shareItems: ShareItems? + + var body: some View { + JSONTextView(viewModel: viewModel) + .navigationBarItems(trailing: navigationBarTrailingItems) + .sheet(item: $shareItems, content: ShareView.init) + } + + @ViewBuilder + private var navigationBarTrailingItems: some View { + Menu(content: { + Button(action: { viewModel.expandAll() }) { + Label("Expand All", systemImage: "arrow.down.right.and.arrow.up.left") + } + Button(action: { viewModel.collapseAll() }) { + Label("Collapse All", systemImage: "arrow.up.left.and.arrow.down.right") + } + Divider() + AttributedStringShareMenu(shareItems: $shareItems) { + viewModel.renderedString + } + }, label: { + Image(systemName: "ellipsis.circle") + }) + } +} + +struct JSONTextView: UIViewRepresentable { + @ObservedObject var viewModel: JSONViewerViewModel + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView(usingTextLayoutManager: true) + textView.isEditable = false + textView.isSelectable = true + textView.backgroundColor = .systemBackground + textView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + textView.isScrollEnabled = true + textView.alwaysBounceVertical = true + + let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) + tapGesture.cancelsTouchesInView = false + textView.addGestureRecognizer(tapGesture) + + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + let previousOffset = textView.contentOffset + + textView.attributedText = viewModel.renderedString + + // Force layout update + if let textLayoutManager = textView.textLayoutManager { + textLayoutManager.ensureLayout(for: textLayoutManager.documentRange) + } + + // Maintain scroll position after toggle + if context.coordinator.shouldPreserveOffset { + textView.setContentOffset(previousOffset, animated: false) + context.coordinator.shouldPreserveOffset = false + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(viewModel: viewModel) + } + + class Coordinator: NSObject { + let viewModel: JSONViewerViewModel + var shouldPreserveOffset = false + + init(viewModel: JSONViewerViewModel) { + self.viewModel = viewModel + } + + @objc + func handleTap(_ gesture: UITapGestureRecognizer) { + guard let textView = gesture.view as? UITextView else { return } + + let location = gesture.location(in: textView) + guard let characterIndex = findCharacterIndex(at: location, in: textView) else { + // Tapped on empty space, not on text + return + } + + if let node = findNode(at: characterIndex, in: textView.textStorage) { + shouldPreserveOffset = true + viewModel.toggleNode(node) + } + } + + private func findCharacterIndex(at location: CGPoint, in textView: UITextView) -> Int? { + var adjustedLocation = location + adjustedLocation.x -= textView.textContainerInset.left + adjustedLocation.y -= textView.textContainerInset.top + + guard let textLayoutManager = textView.textLayoutManager, + let fragment = textLayoutManager.textLayoutFragment(for: adjustedLocation) else { + return nil // No text fragment at this location + } + + // Simplified: Get approximate character index + let relativeLocation = CGPoint( + x: adjustedLocation.x - fragment.layoutFragmentFrame.minX, + y: adjustedLocation.y - fragment.layoutFragmentFrame.minY + ) + + // Find the line fragment + for lineFragment in fragment.textLineFragments { + if lineFragment.typographicBounds.contains(relativeLocation) { + let index = lineFragment.characterIndex(for: CGPoint( + x: relativeLocation.x - lineFragment.typographicBounds.minX, + y: relativeLocation.y - lineFragment.typographicBounds.minY + )) + + let startOffset = textView.textLayoutManager?.offset( + from: textView.textLayoutManager!.documentRange.location, + to: fragment.rangeInElement.location + ) ?? 0 + + return startOffset + index + } + } + + return nil // No line fragment at this location + } + + private func findNode(at index: Int, in textStorage: NSTextStorage) -> JSONContainerNode? { + guard index < textStorage.length else { return nil } + + // Only check the exact position and one character before (for the space after arrow) + // The arrow "▶ " or "▼ " is 2 characters, so we check current and previous position + for offset in [0, -1] { + let checkIndex = index + offset + guard checkIndex >= 0 && checkIndex < textStorage.length else { continue } + + var effectiveRange = NSRange() + if let node = textStorage.attribute(.node, at: checkIndex, effectiveRange: &effectiveRange) as? JSONContainerNode { + // Only return the node if we're within the arrow's range + if NSLocationInRange(index, effectiveRange) { + return node + } + } + } + + return nil + } + } +} + +#elseif os(tvOS) || os(watchOS) + +import Pulse +import SwiftUI + +struct JSONViewer: View { + @ObservedObject var viewModel: JSONViewerViewModel + + var body: some View { + ScrollView { + Text(AttributedString(viewModel.renderedString)) + .font(.system(.body, design: .monospaced)) + .padding() + } + } +} + +#endif \ No newline at end of file diff --git a/Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewerViewModel.swift b/Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewerViewModel.swift new file mode 100644 index 000000000..8ce22ca1d --- /dev/null +++ b/Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewerViewModel.swift @@ -0,0 +1,82 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). + +import SwiftUI +import Pulse +import Combine + +final class JSONViewerViewModel: ObservableObject { + let json: Any + let error: NetworkLogger.DecodingError? + let contentType: NetworkLogger.ContentType? + + @Published private(set) var renderedString: NSAttributedString + + private var collapsedPaths: Set = [] + private let nodesByPath: [String: JSONContainerNode] + + init(json: Any, error: NetworkLogger.DecodingError? = nil, contentType: NetworkLogger.ContentType? = nil) { + self.json = json + self.error = error + self.contentType = contentType + self.nodesByPath = Self.collectNodes(from: json) + self.renderedString = Self.render(json: json, error: error, collapsedPaths: [], nodesByPath: nodesByPath) + } + + func toggleNode(_ node: JSONContainerNode) { + guard let path = node.path else { return } + + if collapsedPaths.contains(path) { + collapsedPaths.remove(path) + } else { + collapsedPaths.insert(path) + } + + updateRendering() + } + + func expandAll() { + collapsedPaths.removeAll() + updateRendering() + } + + func collapseAll() { + collapsedPaths = Set(nodesByPath.keys) + updateRendering() + } + + private func updateRendering() { + renderedString = Self.render(json: json, error: error, collapsedPaths: collapsedPaths, nodesByPath: nodesByPath) + } + + private static func render(json: Any, error: NetworkLogger.DecodingError?, collapsedPaths: Set, nodesByPath: [String: JSONContainerNode]) -> NSAttributedString { + TextRendererJSON(json: json, error: error, collapsedPaths: collapsedPaths, nodesByPath: nodesByPath).render() + } + + private static func collectNodes(from json: Any) -> [String: JSONContainerNode] { + var nodes: [String: JSONContainerNode] = [:] + + func traverse(_ value: Any, path: String) { + switch value { + case let object as [String: Any]: + nodes[path] = JSONContainerNode(kind: .object, json: object, path: path) + for (key, subValue) in object { + traverse(subValue, path: "\(path).\(key)") + } + + case let array as [Any]: + nodes[path] = JSONContainerNode(kind: .array, json: array, path: path) + for (index, subValue) in array.enumerated() { + traverse(subValue, path: "\(path)[\(index)]") + } + + default: + break + } + } + + traverse(json, path: "$") + return nodes + } +} \ No newline at end of file diff --git a/Sources/PulseUI/Features/Settings/SettingsView-ios.swift b/Sources/PulseUI/Features/Settings/SettingsView-ios.swift index 4e7ee1d8b..d64e7cc1b 100644 --- a/Sources/PulseUI/Features/Settings/SettingsView-ios.swift +++ b/Sources/PulseUI/Features/Settings/SettingsView-ios.swift @@ -8,7 +8,6 @@ import SwiftUI import Pulse import UniformTypeIdentifiers -@available(iOS 16, visionOS 1, *) public struct SettingsView: View { private let store: LoggerStore @State private var newHeaderName = "" @@ -25,6 +24,12 @@ public struct SettingsView: View { store === RemoteLogger.shared.store { RemoteLoggerSettingsView(viewModel: .shared) } + Section("Display") { + Toggle("Collapsible JSON Viewer", isOn: $settings.useCollapsibleJSONViewer) + Text("When enabled, JSON responses will be displayed with collapsible nodes") + .font(.caption) + .foregroundColor(.secondary) + } Section("Other") { NavigationLink(destination: StoreDetailsView(source: .store(store)), label: { Text("Store Info") @@ -37,7 +42,6 @@ public struct SettingsView: View { } #if DEBUG -@available(iOS 16, visionOS 1, *) struct SettingsView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/Sources/PulseUI/Features/Settings/SettingsView-macos.swift b/Sources/PulseUI/Features/Settings/SettingsView-macos.swift index 1a9b556c7..c6641536b 100644 --- a/Sources/PulseUI/Features/Settings/SettingsView-macos.swift +++ b/Sources/PulseUI/Features/Settings/SettingsView-macos.swift @@ -7,13 +7,13 @@ import SwiftUI import Pulse -@available(macOS 13, *) struct SettingsView: View { @State private var isPresentingShareStoreView = false @State private var shareItems: ShareItems? @Environment(\.store) private var store @EnvironmentObject private var environment: ConsoleEnvironment + @EnvironmentObject private var settings: UserSettings var body: some View { List { @@ -25,6 +25,13 @@ struct SettingsView: View { .foregroundColor(.secondary) } } + Section("Display") { + Toggle("Collapsible JSON Viewer", isOn: $settings.useCollapsibleJSONViewer) + Text("When enabled, JSON responses will be displayed with collapsible nodes") + .font(.caption) + .foregroundColor(.secondary) + } + Section("Store") { // TODO: load this info async // if #available(macOS 13, *), let info = try? store.info() { @@ -51,7 +58,6 @@ struct SettingsView: View { // MARK: - Preview #if DEBUG -@available(macOS 13, *) struct UserSettingsView_Previews: PreviewProvider { static var previews: some View { SettingsView() diff --git a/Sources/PulseUI/Features/Settings/SettingsView-tvos.swift b/Sources/PulseUI/Features/Settings/SettingsView-tvos.swift index 0f13312df..06a400d50 100644 --- a/Sources/PulseUI/Features/Settings/SettingsView-tvos.swift +++ b/Sources/PulseUI/Features/Settings/SettingsView-tvos.swift @@ -9,6 +9,7 @@ import Pulse public struct SettingsView: View { private let store: LoggerStore + @EnvironmentObject private var settings: UserSettings public init(store: LoggerStore = .shared) { self.store = store @@ -20,11 +21,15 @@ public struct SettingsView: View { store === RemoteLogger.shared.store { RemoteLoggerSettingsView(viewModel: .shared) } + Section("Display") { + Toggle("Collapsible JSON Viewer", isOn: $settings.useCollapsibleJSONViewer) + Text("When enabled, JSON responses will be displayed with collapsible nodes") + .font(.caption) + .foregroundColor(.secondary) + } Section { - if #available(tvOS 16, *) { - NavigationLink(destination: StoreDetailsView(source: .store(store))) { - Text("Store Info") - } + NavigationLink(destination: StoreDetailsView(source: .store(store))) { + Text("Store Info") } if !store.options.contains(.readonly) { Button(role: .destructive, action: { store.removeAll() }) { diff --git a/Sources/PulseUI/Features/Settings/SettingsView-watchos.swift b/Sources/PulseUI/Features/Settings/SettingsView-watchos.swift index 195787e70..6b69f2955 100644 --- a/Sources/PulseUI/Features/Settings/SettingsView-watchos.swift +++ b/Sources/PulseUI/Features/Settings/SettingsView-watchos.swift @@ -9,6 +9,7 @@ import Pulse public struct SettingsView: View { private let store: LoggerStore + @EnvironmentObject private var settings: UserSettings @State private var isShowingShareView = false @@ -32,6 +33,9 @@ public struct SettingsView: View { #endif } } + Section("Display") { + Toggle("Collapsible JSON", isOn: $settings.useCollapsibleJSONViewer) + } Section { Button("Share Store") { isShowingShareView = true } } diff --git a/Sources/PulseUI/Helpers/TextRendererJSON.swift b/Sources/PulseUI/Helpers/TextRendererJSON.swift index 9b6e9e19f..90d795f47 100644 --- a/Sources/PulseUI/Helpers/TextRendererJSON.swift +++ b/Sources/PulseUI/Helpers/TextRendererJSON.swift @@ -26,26 +26,32 @@ package final class TextRendererJSON { private var elements: [(NSRange, JSONElement, JSONContainerNode?)] = [] private var errorRange: NSRange? private var string = "" + private var collapsedPaths: Set = [] + private var nodesByPath: [String: JSONContainerNode] = [:] - package init(json: Any, error: NetworkLogger.DecodingError? = nil, options: TextRenderer.Options = .init()) { + package init(json: Any, error: NetworkLogger.DecodingError? = nil, options: TextRenderer.Options = .init(), collapsedPaths: Set = [], nodesByPath: [String: JSONContainerNode] = [:]) { self.options = options self.helper = TextHelper() self.json = json self.error = error + self.collapsedPaths = collapsedPaths + self.nodesByPath = nodesByPath } package func render() -> NSAttributedString { - render(json: json, isFree: true) + render(json: json, path: "$", isFree: true) let output = NSMutableAttributedString(string: string, attributes: helper.attributes(role: .body2, style: .monospaced, color: color(for: .key))) for (range, element, node) in elements { output.addAttribute(.foregroundColor, value: color(for: element), range: range) -#if os(macOS) - if let node = node, TextRendererJSON.makeErrorAttributes != nil { + if let node = node { output.addAttribute(.node, value: node, range: range) - output.addAttribute(.cursor, value: NSCursor.pointingHand, range: range) - } +#if os(macOS) + if TextRendererJSON.makeErrorAttributes != nil { + output.addAttribute(.cursor, value: NSCursor.pointingHand, range: range) + } #endif + } } if let range = errorRange { output.addAttributes(makeErrorAttributes(), range: range) @@ -77,17 +83,17 @@ package final class TextRendererJSON { // MARK: - Walk JSON - private func render(json: Any, isFree: Bool) { + private func render(json: Any, path: String, isFree: Bool) { switch json { case let object as [String: Any]: if isFree { indent() } - renderObject(object) + renderObject(object, path: path) case let string as String: renderString(string) case let array as [Any]: - renderArray(array) + renderArray(array, path: path) case let number as NSNumber: renderNumber(number) default: @@ -99,16 +105,17 @@ package final class TextRendererJSON { } } - private func render(json: Any, key: NetworkLogger.DecodingError.CodingKey, isFree: Bool) { + private func render(json: Any, key: NetworkLogger.DecodingError.CodingKey, path: String, isFree: Bool) { codingPath.append(key) - render(json: json, isFree: isFree) + render(json: json, path: path, isFree: isFree) codingPath.removeLast() } - private func renderObject(_ object: [String: Any]) { - let node = JSONContainerNode(kind: .object, json: object) - append("{", .punctuation, node) - newline() + private func renderObject(_ object: [String: Any], path: String) { + let node = renderToggleIndicator(path: path, kind: .object, json: object, openBracket: "{", closeBracket: "{ ... }") + guard node == nil || !collapsedPaths.contains(path) else { return } + + newline() let keys = object.keys.sorted() for index in keys.indices { let key = keys[index] @@ -117,7 +124,8 @@ package final class TextRendererJSON { append("\"\(key)\"", .key) append(": ", .punctuation) indentation += 1 - render(json: object[key]!, key: .string(key), isFree: false) + let subPath = "\(path).\(key)" + render(json: object[key]!, key: .string(key), path: subPath, isFree: false) indentation -= 1 if index < keys.endIndex - 1 { append(",", .punctuation) @@ -125,35 +133,47 @@ package final class TextRendererJSON { newline() } indent() - append("}", .punctuation, node) + append("}", .punctuation, nil) } - private func renderArray(_ array: [Any]) { - let node = JSONContainerNode(kind: .array, json: array) - if array is [String] || array is [Int] || array is [NSNumber] { - append("[", .punctuation, node) - for index in array.indices { - render(json: array[index], key: .int(index), isFree: true) - if index < array.endIndex - 1 { - append(", ", .punctuation) - } + private func renderArray(_ array: [Any], path: String) { + let node = renderToggleIndicator(path: path, kind: .array, json: array, openBracket: "[", closeBracket: "[ ... ]") + guard node == nil || !collapsedPaths.contains(path) else { return } + + append("\n", .punctuation) + indentation += 1 + for index in array.indices { + let subPath = "\(path)[\(index)]" + render(json: array[index], key: .int(index), path: subPath, isFree: true) + if index < array.endIndex - 1 { + append(",", .punctuation) } - append("]", .punctuation, node) + newline() + } + indentation -= 1 + indent() + append("]", .punctuation, nil) + } + + private func renderToggleIndicator(path: String, kind: JSONContainerNode.Kind, json: Any, openBracket: String, closeBracket: String) -> JSONContainerNode? { + guard !nodesByPath.isEmpty else { + // Traditional rendering without collapse indicators + append(openBracket, .punctuation, nil) + return nil + } + + let node = nodesByPath[path] ?? JSONContainerNode(kind: kind, json: json, path: path) + let isCollapsed = collapsedPaths.contains(path) + + if isCollapsed { + append("▶ ", .punctuation, node) + append(closeBracket, .punctuation, nil) } else { - append("[", .punctuation, node) - append("\n", .punctuation) - indentation += 1 - for index in array.indices { - render(json: array[index], key: .int(index), isFree: true) - if index < array.endIndex - 1 { - append(",", .punctuation) - } - newline() - } - indentation -= 1 - indent() - append("]", .punctuation, node) + append("▼ ", .punctuation, node) + append(openBracket, .punctuation, nil) } + + return node } private func renderString(_ string: String) { @@ -276,11 +296,11 @@ package final class JSONContainerNode { package let kind: Kind package let json: Any - package var isExpanded = true - package var expanded: NSAttributedString? + package let path: String? - package init(kind: Kind, json: Any) { + package init(kind: Kind, json: Any, path: String? = nil) { self.kind = kind self.json = json + self.path = path } } diff --git a/Sources/PulseUI/Helpers/UserSettings.swift b/Sources/PulseUI/Helpers/UserSettings.swift index 8faaf112f..7fccd9cb7 100644 --- a/Sources/PulseUI/Helpers/UserSettings.swift +++ b/Sources/PulseUI/Helpers/UserSettings.swift @@ -51,6 +51,10 @@ public final class UserSettings: ObservableObject { @AppStorage("com.github.kean.pulse.isRemoteLoggingAllowed") public var isRemoteLoggingHidden = false + /// If enabled, uses the new collapsible JSON viewer for API responses. By default, `true`. + @AppStorage("com.github.kean.pulse.useCollapsibleJSONViewer") + public var useCollapsibleJSONViewer = true + /// Task cell display options. public var listDisplayOptions: ConsoleListDisplaySettings { get {