From 7d992f328073f53508666b29b7366ed139322764 Mon Sep 17 00:00:00 2001 From: jamie-hyeon_karrot Date: Mon, 1 Sep 2025 12:47:06 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20iOS=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=B2=84=EC=A0=84=EC=9D=84=2016.0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 전반의 iOS 배포 타겟을 15.0에서 16.0으로 상향 조정합니다. tvOS도 함께 15.0에서 16.0으로 업데이트되었습니다. 이를 통해 iOS 16의 새로운 API와 기능을 활용할 수 있게 되며, 더 이상 iOS 15를 지원하지 않습니다. --- Demo/Pulse.xcodeproj/project.pbxproj | 6 ++++-- Package.swift | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Demo/Pulse.xcodeproj/project.pbxproj b/Demo/Pulse.xcodeproj/project.pbxproj index 8ae2ec419..384eaf3b9 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", @@ -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) From 89295213c2bf40737db0e199a820641d299f86da Mon Sep 17 00:00:00 2001 From: jamie-hyeon_karrot Date: Mon, 1 Sep 2025 14:06:32 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=B6=95=EC=86=8C=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=9C=20JSON=20=EB=B7=B0=EC=96=B4=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 플랫폼(iOS, macOS, tvOS, watchOS)의 설정 화면에 축소 가능한 JSON 뷰어 옵션을 추가합니다. 사용자가 API 응답의 JSON을 접고 펼칠 수 있는 노드 형태로 볼 수 있도록 설정할 수 있습니다. UserSettings에 useCollapsibleJSONViewer 속성을 추가하여 앱 전체에서 설정을 공유합니다. 기본값은 false로 설정되어 기존 동작을 유지합니다. iOS 16, visionOS 1, macOS 13 버전 제한을 제거하여 더 넓은 버전 호환성을 제공합니다. --- .../Features/Settings/SettingsView-ios.swift | 8 ++++++-- .../Features/Settings/SettingsView-macos.swift | 10 ++++++++-- .../Features/Settings/SettingsView-tvos.swift | 13 +++++++++---- .../Features/Settings/SettingsView-watchos.swift | 4 ++++ Sources/PulseUI/Helpers/UserSettings.swift | 4 ++++ 5 files changed, 31 insertions(+), 8 deletions(-) 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/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 { From 75b6f2c4d305593c9243387d22699e506622bf64 Mon Sep 17 00:00:00 2001 From: jamie-hyeon_karrot Date: Mon, 1 Sep 2025 14:38:36 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20JSON=20=EB=A0=8C=EB=8D=94=EB=9F=AC?= =?UTF-8?q?=EC=97=90=20=EC=A0=91=EA=B8=B0/=ED=8E=BC=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON 뷰어에서 객체와 배열을 접고 펼칠 수 있는 기능을 구현합니다. 사용자가 JSON 구조를 더 쉽게 탐색할 수 있도록 시각적 토글 인디케이터(▶/▼)를 추가하고, 경로 기반으로 각 노드의 접힌 상태를 관리합니다. 주요 변경사항: - 각 JSON 요소에 경로 정보 추가 - 접기/펼치기 상태를 저장하는 collapsedPaths 집합 도입 - 시각적 토글 인디케이터 렌더링 로직 구현 --- .../PulseUI/Helpers/TextRendererJSON.swift | 106 +++++++++++------- 1 file changed, 63 insertions(+), 43 deletions(-) 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 } } From eccd40baa5dc2ca8ef3d6bbe004b99cdb818f42d Mon Sep 17 00:00:00 2001 From: jamie-hyeon_karrot Date: Mon, 1 Sep 2025 14:39:21 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20JSON=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대화형 JSON 뷰어 기능을 구현하여 JSON 데이터를 시각적으로 탐색할 수 있도록 함 - 중첩된 JSON 객체와 배열의 확장/축소 기능 지원 - 탭 제스처로 특정 노드 토글 가능 - 전체 확장/축소 메뉴 옵션 제공 - iOS, visionOS, tvOS, watchOS 플랫폼 지원 --- .../FileViewer/JSONViewer/JSONViewer.swift | 180 ++++++++++++++++++ .../JSONViewer/JSONViewerViewModel.swift | 82 ++++++++ 2 files changed, 262 insertions(+) create mode 100644 Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewer.swift create mode 100644 Sources/PulseUI/Features/FileViewer/JSONViewer/JSONViewerViewModel.swift 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 From a07767ee95e11d437b81b8507dc2794649a66773 Mon Sep 17 00:00:00 2001 From: jamie-hyeon_karrot Date: Mon, 1 Sep 2025 14:40:10 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=A0=91=EC=9D=84=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20JSON=20=EB=B7=B0=EC=96=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 설정에 따라 JSON 데이터를 접을 수 있는 뷰어로 표시하는 기능을 추가합니다. 기존의 텍스트 기반 렌더링과 새로운 JSONViewer 중 선택할 수 있습니다. 설정이 활성화되면 JSON 데이터를 계층 구조로 탐색할 수 있어 대용량 JSON 파일의 가독성과 사용성이 향상됩니다. --- Sources/PulseUI/Features/FileViewer/FileViewer.swift | 2 ++ .../Features/FileViewer/FileViewerViewModel.swift | 9 +++++++++ 2 files changed, 11 insertions(+) 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)) From 1a8725f9134b09af0d890700e495e630b9e35792 Mon Sep 17 00:00:00 2001 From: jamie-hyeon_karrot Date: Mon, 1 Sep 2025 14:56:36 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20tvOS=20Demo=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=B2=84=EC=A0=84=EC=9D=84=2016.0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Demo/Pulse.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Demo/Pulse.xcodeproj/project.pbxproj b/Demo/Pulse.xcodeproj/project.pbxproj index 384eaf3b9..76d413fcf 100644 --- a/Demo/Pulse.xcodeproj/project.pbxproj +++ b/Demo/Pulse.xcodeproj/project.pbxproj @@ -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; };