Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Demo/Pulse.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions Sources/PulseUI/Features/FileViewer/FileViewer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions Sources/PulseUI/Features/FileViewer/FileViewerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand All @@ -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)
Expand All @@ -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))
Expand Down
180 changes: 180 additions & 0 deletions Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewer.swift
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<String> = []
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<String>, 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
}
}
8 changes: 6 additions & 2 deletions Sources/PulseUI/Features/Settings/SettingsView-ios.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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")
Expand All @@ -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 {
Expand Down
Loading
Loading