diff --git a/Sources/NetworkInspector/LeapInspector.swift b/Sources/NetworkInspector/LeapInspector.swift index 02f8190..96e66fc 100644 --- a/Sources/NetworkInspector/LeapInspector.swift +++ b/Sources/NetworkInspector/LeapInspector.swift @@ -4,9 +4,16 @@ import Foundation public enum LeapInspector { - + + public enum ResponseViewMode { + case tree + case raw + } + private static var isEnabled = false - + + public static var responseViewMode: ResponseViewMode = .tree + public static func enable(baseURLs: [String] = []) { guard !isEnabled else { return } isEnabled = true diff --git a/Sources/NetworkInspector/UI/LogDetail/JSONOutlineView.swift b/Sources/NetworkInspector/UI/LogDetail/JSONOutlineView.swift new file mode 100644 index 0000000..227009b --- /dev/null +++ b/Sources/NetworkInspector/UI/LogDetail/JSONOutlineView.swift @@ -0,0 +1,763 @@ +// +// JSONOutlineView.swift +// NetworkInspector +// + +import UIKit + +// MARK: - Model + +indirect enum JSONNode { + case object([(String, JSONNode)]) + case array([JSONNode]) + case primitive(NSAttributedString) + case error(String) + + static func parse(from data: Data) -> JSONNode { + guard let obj = try? JSONSerialization.jsonObject( + with: data, + options: [.fragmentsAllowed] + ) else { + let raw = String(decoding: data, as: UTF8.self) + return .error(raw.isEmpty ? "Empty response" : raw) + } + return JSONNode.from(any: obj) + } + + private static func from(any value: Any) -> JSONNode { + if let dict = value as? [String: Any] { + let sorted = dict.sorted { $0.key < $1.key } + return .object(sorted.map { ($0.key, JSONNode.from(any: $0.value)) }) + } + if let arr = value as? [Any] { + return .array(arr.map { JSONNode.from(any: $0) }) + } + if let str = value as? String { + return .primitive(JSONStyle.stringValue(str)) + } + if let num = value as? NSNumber { + if CFGetTypeID(num) == CFBooleanGetTypeID() { + return .primitive(JSONStyle.boolValue(num.boolValue)) + } + return .primitive(JSONStyle.numberValue(num)) + } + if value is NSNull { + return .primitive(JSONStyle.nullValue()) + } + return .primitive(JSONStyle.plain("\(value)")) + } +} + +// MARK: - Path / Row + +private struct NodePath: Hashable { + var components: [Int] = [] + + func appending(_ index: Int) -> NodePath { + var copy = self + copy.components.append(index) + return copy + } +} + +private enum RowKind { + case openContainer(isObject: Bool, isExpanded: Bool, childCount: Int) + case closeContainer(isObject: Bool) + case primitive(NSAttributedString) +} + +private struct OutlineRow { + let path: NodePath + let depth: Int + let key: String? + let kind: RowKind + let trailingComma: Bool + var matchesQuery: Bool = false +} + +// MARK: - View + +final class JSONOutlineView: UIView { + + private let tableView = UITableView(frame: .zero, style: .plain) + private let searchBar = UISearchBar() + private let matchCountLabel = UILabel() + private let prevButton = UIButton(type: .system) + private let nextButton = UIButton(type: .system) + private let clearButton = UIButton(type: .system) + private let chevronStack = UIStackView() + private let searchAccessory = SearchAccessoryView() + private var root: JSONNode = .primitive(NSAttributedString(string: "—")) + private var rows: [OutlineRow] = [] + private var collapsed: Set = [] + private var query: String = "" + private var matchRowIndices: [Int] = [] + private var currentMatchOrdinal: Int = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func load(data: Data?) { + guard let data, !data.isEmpty else { + root = .error("No response body") + rebuildRows() + return + } + root = JSONNode.parse(from: data) + collapsed.removeAll() + rebuildRows() + } +} + +// MARK: - Setup + +private extension JSONOutlineView { + + func setupViews() { + backgroundColor = .systemBackground + + searchBar.placeholder = "Search in response" + searchBar.delegate = self + searchBar.autocapitalizationType = .none + searchBar.autocorrectionType = .no + searchBar.returnKeyType = .next + // Disable the built-in clear button; we provide our own inside the accessory + // so it can sit alongside the chevron stack. + searchBar.searchTextField.clearButtonMode = .never + + matchCountLabel.font = .systemFont(ofSize: 12) + matchCountLabel.textColor = .secondaryLabel + matchCountLabel.textAlignment = .right + matchCountLabel.isHidden = true + + let chevronConfig = UIImage.SymbolConfiguration(pointSize: 10, weight: .semibold) + prevButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: chevronConfig), for: .normal) + prevButton.addTarget(self, action: #selector(onPrev), for: .touchUpInside) + prevButton.accessibilityLabel = "Previous match" + nextButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: chevronConfig), for: .normal) + nextButton.addTarget(self, action: #selector(onNext), for: .touchUpInside) + nextButton.accessibilityLabel = "Next match" + for button in [prevButton, nextButton] { + button.tintColor = .label + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4) + } + + let clearConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .regular) + clearButton.setImage( + UIImage(systemName: "xmark.circle.fill", withConfiguration: clearConfig), + for: .normal + ) + clearButton.tintColor = .tertiaryLabel + clearButton.addTarget(self, action: #selector(onClearText), for: .touchUpInside) + clearButton.accessibilityLabel = "Clear search" + clearButton.isHidden = true + + chevronStack.axis = .vertical + chevronStack.alignment = .center + chevronStack.distribution = .fillEqually + chevronStack.spacing = 0 + chevronStack.addArrangedSubview(prevButton) + chevronStack.addArrangedSubview(nextButton) + chevronStack.isHidden = true + + clearButton.translatesAutoresizingMaskIntoConstraints = false + chevronStack.translatesAutoresizingMaskIntoConstraints = false + searchAccessory.addSubview(chevronStack) + searchAccessory.addSubview(clearButton) + NSLayoutConstraint.activate([ + clearButton.centerXAnchor.constraint(equalTo: searchAccessory.centerXAnchor), + clearButton.centerYAnchor.constraint(equalTo: searchAccessory.centerYAnchor), + chevronStack.centerXAnchor.constraint(equalTo: searchAccessory.centerXAnchor), + chevronStack.centerYAnchor.constraint(equalTo: searchAccessory.centerYAnchor), + chevronStack.topAnchor.constraint(equalTo: searchAccessory.topAnchor), + chevronStack.bottomAnchor.constraint(equalTo: searchAccessory.bottomAnchor) + ]) + + // Reserve space on the right side of the text input so typed text doesn't + // run under the overlay buttons. UISearchBar's internal rightView is + // unreliable across iOS versions, so the visible accessory lives as an + // overlay (added below) instead. + let textPaddingSpacer = UIView() + textPaddingSpacer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textPaddingSpacer.widthAnchor.constraint(equalToConstant: SearchAccessoryView.preferredSize.width), + textPaddingSpacer.heightAnchor.constraint(equalToConstant: SearchAccessoryView.preferredSize.height) + ]) + searchBar.searchTextField.rightView = textPaddingSpacer + searchBar.searchTextField.rightViewMode = .always + + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.register(JSONOutlineCell.self, forCellReuseIdentifier: JSONOutlineCell.reuseId) + tableView.estimatedRowHeight = 24 + tableView.rowHeight = UITableView.automaticDimension + tableView.keyboardDismissMode = .onDrag + + searchBar.translatesAutoresizingMaskIntoConstraints = false + matchCountLabel.translatesAutoresizingMaskIntoConstraints = false + tableView.translatesAutoresizingMaskIntoConstraints = false + searchAccessory.translatesAutoresizingMaskIntoConstraints = false + addSubview(searchBar) + addSubview(matchCountLabel) + addSubview(tableView) + addSubview(searchAccessory) + + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: topAnchor), + searchBar.leadingAnchor.constraint(equalTo: leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: trailingAnchor), + + // Overlay the accessory on top of the search bar's text input, on the right. + searchAccessory.centerYAnchor.constraint(equalTo: searchBar.centerYAnchor), + searchAccessory.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor, constant: -18), + + matchCountLabel.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 2), + matchCountLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + matchCountLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + + tableView.topAnchor.constraint(equalTo: matchCountLabel.bottomAnchor, constant: 2), + tableView.leadingAnchor.constraint(equalTo: leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + @objc func onPrev() { advanceMatch(by: -1) } + @objc func onNext() { advanceMatch(by: 1) } + + @objc func onClearText() { + searchBar.text = "" + self.searchBar(searchBar, textDidChange: "") + } +} + +// MARK: - Search + +extension JSONOutlineView: UISearchBarDelegate { + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + query = searchText + // Expand everything while searching so all matches are visible. + collapsed.removeAll() + currentMatchOrdinal = 0 + rebuildRows() + scrollToCurrentMatch() + } + + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + updateAccessoryVisibility() + } + + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + updateAccessoryVisibility() + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + advanceMatch(by: 1) + } +} + +private extension JSONOutlineView { + + func nodeMatches(node: JSONNode, key: String?) -> Bool { + guard !query.isEmpty else { return false } + let q = query.lowercased() + if let key, key.lowercased().contains(q) { return true } + switch node { + case .primitive(let attr): + return attr.string.lowercased().contains(q) + case .error(let message): + return message.lowercased().contains(q) + case .object, .array: + return false + } + } + + func updateMatchIndices() { + matchRowIndices = rows.enumerated() + .filter { $0.element.matchesQuery } + .map { $0.offset } + + updateAccessoryVisibility() + + if matchRowIndices.isEmpty { + if query.isEmpty { + matchCountLabel.isHidden = true + matchCountLabel.text = nil + } else { + matchCountLabel.isHidden = false + matchCountLabel.text = "No matches" + } + } else { + if currentMatchOrdinal >= matchRowIndices.count { currentMatchOrdinal = 0 } + matchCountLabel.isHidden = false + matchCountLabel.text = "\(currentMatchOrdinal + 1) of \(matchRowIndices.count)" + } + } + + /// Focus state decides which accessory is shown inside the search field: + /// - Editing/highlighted → just the X clear button (when there's text) + /// - Not editing → vertical chevrons (when matches exist) + func updateAccessoryVisibility() { + let isFocused = searchBar.searchTextField.isFirstResponder + clearButton.isHidden = !(isFocused && !query.isEmpty) + chevronStack.isHidden = isFocused || matchRowIndices.isEmpty + + // Force the text field to re-layout its right view after visibility changes. + searchBar.searchTextField.setNeedsLayout() + searchBar.searchTextField.layoutIfNeeded() + } + + func advanceMatch(by step: Int) { + guard !matchRowIndices.isEmpty else { return } + let count = matchRowIndices.count + currentMatchOrdinal = ((currentMatchOrdinal + step) % count + count) % count + matchCountLabel.isHidden = false + matchCountLabel.text = "\(currentMatchOrdinal + 1) of \(matchRowIndices.count)" + tableView.reloadData() + scrollToCurrentMatch() + } + + func scrollToCurrentMatch() { + guard !matchRowIndices.isEmpty, currentMatchOrdinal < matchRowIndices.count else { return } + let rowIndex = matchRowIndices[currentMatchOrdinal] + guard rowIndex < rows.count else { return } + let indexPath = IndexPath(row: rowIndex, section: 0) + // Defer to next runloop so the table has finished laying out after reloadData. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard indexPath.row < self.tableView.numberOfRows(inSection: 0) else { return } + self.tableView.scrollToRow(at: indexPath, at: .middle, animated: true) + } + } +} + +// MARK: - Flattening + +private extension JSONOutlineView { + + func rebuildRows() { + rows.removeAll() + flatten(node: root, key: nil, path: NodePath(), depth: 0, trailingComma: false) + updateMatchIndices() + tableView.reloadData() + } + + func flatten(node: JSONNode, key: String?, path: NodePath, depth: Int, trailingComma: Bool) { + let matches = nodeMatches(node: node, key: key) + switch node { + case .object(let entries): + if entries.isEmpty { + rows.append(OutlineRow( + path: path, + depth: depth, + key: key, + kind: .primitive(JSONStyle.emptyContainer(isObject: true)), + trailingComma: trailingComma, + matchesQuery: matches + )) + return + } + let isExpanded = !collapsed.contains(path) + rows.append(OutlineRow( + path: path, + depth: depth, + key: key, + kind: .openContainer(isObject: true, isExpanded: isExpanded, childCount: entries.count), + trailingComma: !isExpanded && trailingComma, + matchesQuery: matches + )) + if isExpanded { + for (idx, entry) in entries.enumerated() { + let last = idx == entries.count - 1 + flatten( + node: entry.1, + key: entry.0, + path: path.appending(idx), + depth: depth + 1, + trailingComma: !last + ) + } + rows.append(OutlineRow( + path: path, + depth: depth, + key: nil, + kind: .closeContainer(isObject: true), + trailingComma: trailingComma + )) + } + case .array(let items): + if items.isEmpty { + rows.append(OutlineRow( + path: path, + depth: depth, + key: key, + kind: .primitive(JSONStyle.emptyContainer(isObject: false)), + trailingComma: trailingComma, + matchesQuery: matches + )) + return + } + let isExpanded = !collapsed.contains(path) + rows.append(OutlineRow( + path: path, + depth: depth, + key: key, + kind: .openContainer(isObject: false, isExpanded: isExpanded, childCount: items.count), + trailingComma: !isExpanded && trailingComma, + matchesQuery: matches + )) + if isExpanded { + for (idx, item) in items.enumerated() { + let last = idx == items.count - 1 + flatten( + node: item, + key: nil, + path: path.appending(idx), + depth: depth + 1, + trailingComma: !last + ) + } + rows.append(OutlineRow( + path: path, + depth: depth, + key: nil, + kind: .closeContainer(isObject: false), + trailingComma: trailingComma + )) + } + case .primitive(let value): + rows.append(OutlineRow( + path: path, + depth: depth, + key: key, + kind: .primitive(value), + trailingComma: trailingComma, + matchesQuery: matches + )) + case .error(let message): + rows.append(OutlineRow( + path: path, + depth: depth, + key: key, + kind: .primitive(NSAttributedString(string: message, attributes: [ + .foregroundColor: UIColor.secondaryLabel, + .font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular) + ])), + trailingComma: false, + matchesQuery: matches + )) + } + } +} + +// MARK: - Table + +extension JSONOutlineView: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: JSONOutlineCell.reuseId, + for: indexPath + ) as! JSONOutlineCell + let isCurrentMatch = !matchRowIndices.isEmpty + && currentMatchOrdinal < matchRowIndices.count + && matchRowIndices[currentMatchOrdinal] == indexPath.row + cell.configure( + with: rows[indexPath.row], + query: query, + isCurrentMatch: isCurrentMatch + ) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let row = rows[indexPath.row] + if case .openContainer = row.kind { + toggleCollapse(at: row.path) + } + } + + private func toggleCollapse(at path: NodePath) { + if collapsed.contains(path) { + collapsed.remove(path) + } else { + collapsed.insert(path) + } + rebuildRows() + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + // Resign focus so the chevron accessory takes over from the X clear button + // once the user starts browsing the JSON. + if searchBar.searchTextField.isFirstResponder { + searchBar.resignFirstResponder() + } + } +} + +// MARK: - Cell + +private final class JSONOutlineCell: UITableViewCell { + + static let reuseId = "JSONOutlineCell" + + private let disclosureLabel = UILabel() + private let contentLabel = UILabel() + private let stack = UIStackView() + private var leadingConstraint: NSLayoutConstraint! + + private static let indentStep: CGFloat = 16 + private static let baseLeading: CGFloat = 12 + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .systemBackground + + disclosureLabel.font = .systemFont(ofSize: 11, weight: .semibold) + disclosureLabel.textColor = .secondaryLabel + disclosureLabel.textAlignment = .center + disclosureLabel.setContentHuggingPriority(.required, for: .horizontal) + disclosureLabel.widthAnchor.constraint(equalToConstant: 14).isActive = true + + contentLabel.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + contentLabel.numberOfLines = 0 + + stack.axis = .horizontal + stack.alignment = .firstBaseline + stack.spacing = 4 + stack.addArrangedSubview(disclosureLabel) + stack.addArrangedSubview(contentLabel) + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + + leadingConstraint = stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Self.baseLeading) + + NSLayoutConstraint.activate([ + leadingConstraint, + stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 3), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -3), + stack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -12) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with row: OutlineRow, query: String, isCurrentMatch: Bool) { + leadingConstraint.constant = Self.baseLeading + CGFloat(row.depth) * Self.indentStep + contentView.backgroundColor = .clear + + let attributed: NSAttributedString + switch row.kind { + case .openContainer(let isObject, let isExpanded, let childCount): + disclosureLabel.text = isExpanded ? "▼" : "▶" + attributed = openContainerText( + key: row.key, + isObject: isObject, + isExpanded: isExpanded, + childCount: childCount, + trailingComma: row.trailingComma + ) + case .closeContainer(let isObject): + disclosureLabel.text = "" + let brace = isObject ? "}" : "]" + let text = row.trailingComma ? "\(brace)," : brace + attributed = NSAttributedString( + string: text, + attributes: JSONStyle.braceAttrs + ) + case .primitive(let value): + disclosureLabel.text = "" + attributed = primitiveText( + key: row.key, + value: value, + trailingComma: row.trailingComma + ) + } + + contentLabel.attributedText = applyMatchHighlights( + to: attributed, + query: query, + isCurrentMatch: isCurrentMatch + ) + } + + private func applyMatchHighlights( + to attributed: NSAttributedString, + query: String, + isCurrentMatch: Bool + ) -> NSAttributedString { + guard !query.isEmpty else { return attributed } + + let result = NSMutableAttributedString(attributedString: attributed) + let haystack = result.string.lowercased() + let needle = query.lowercased() + guard !needle.isEmpty else { return result } + + let baseHighlight = UIColor.systemYellow + let currentHighlight = UIColor.systemOrange + let highlight = isCurrentMatch ? currentHighlight : baseHighlight + + var searchRange = haystack.startIndex.. NSAttributedString { + let result = NSMutableAttributedString() + if let key { + result.append(JSONStyle.keyText(key)) + result.append(NSAttributedString(string: ": ", attributes: JSONStyle.braceAttrs)) + } + if isExpanded { + let brace = isObject ? "{" : "[" + result.append(NSAttributedString(string: brace, attributes: JSONStyle.braceAttrs)) + } else { + let openBrace = isObject ? "{" : "[" + let closeBrace = isObject ? "}" : "]" + let unit = isObject + ? (childCount == 1 ? "key" : "keys") + : (childCount == 1 ? "item" : "items") + let suffix = trailingComma ? "," : "" + result.append(NSAttributedString( + string: "\(openBrace) \(childCount) \(unit) \(closeBrace)\(suffix)", + attributes: JSONStyle.collapsedAttrs + )) + } + return result + } + + private func primitiveText( + key: String?, + value: NSAttributedString, + trailingComma: Bool + ) -> NSAttributedString { + let result = NSMutableAttributedString() + if let key { + result.append(JSONStyle.keyText(key)) + result.append(NSAttributedString(string: ": ", attributes: JSONStyle.braceAttrs)) + } + result.append(value) + if trailingComma { + result.append(NSAttributedString(string: ",", attributes: JSONStyle.braceAttrs)) + } + return result + } +} + +// MARK: - Styling + +private enum JSONStyle { + + static let font: UIFont = .monospacedSystemFont(ofSize: 13, weight: .regular) + + static var braceAttrs: [NSAttributedString.Key: Any] { + [.foregroundColor: UIColor.secondaryLabel, .font: font] + } + + static var collapsedAttrs: [NSAttributedString.Key: Any] { + [.foregroundColor: UIColor.tertiaryLabel, .font: font] + } + + static func keyText(_ key: String) -> NSAttributedString { + NSAttributedString( + string: "\"\(key)\"", + attributes: [.foregroundColor: UIColor.systemPurple, .font: font] + ) + } + + static func stringValue(_ string: String) -> NSAttributedString { + NSAttributedString( + string: "\"\(string)\"", + attributes: [.foregroundColor: UIColor.systemGreen, .font: font] + ) + } + + static func numberValue(_ number: NSNumber) -> NSAttributedString { + NSAttributedString( + string: "\(number)", + attributes: [.foregroundColor: UIColor.systemOrange, .font: font] + ) + } + + static func boolValue(_ value: Bool) -> NSAttributedString { + NSAttributedString( + string: value ? "true" : "false", + attributes: [.foregroundColor: UIColor.systemBlue, .font: font] + ) + } + + static func nullValue() -> NSAttributedString { + NSAttributedString( + string: "null", + attributes: [.foregroundColor: UIColor.systemGray, .font: font] + ) + } + + static func plain(_ string: String) -> NSAttributedString { + NSAttributedString( + string: string, + attributes: [.foregroundColor: UIColor.label, .font: font] + ) + } + + static func emptyContainer(isObject: Bool) -> NSAttributedString { + NSAttributedString( + string: isObject ? "{}" : "[]", + attributes: braceAttrs + ) + } +} + +// MARK: - Search accessory container + +/// Fixed-size container overlaid on the search bar to host the clear button and +/// the prev/next chevron stack. Overlay placement (rather than UISearchBar's +/// rightView) is more reliable across iOS versions. +private final class SearchAccessoryView: UIView { + static let preferredSize = CGSize(width: 30, height: 32) + + override var intrinsicContentSize: CGSize { Self.preferredSize } + + override func sizeThatFits(_ size: CGSize) -> CGSize { Self.preferredSize } + + /// Pass taps through to whatever sits underneath when no child is visible, + /// so the search bar remains tappable in its full width when the accessory + /// is empty. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + for subview in subviews where !subview.isHidden && subview.alpha > 0.01 { + let converted = subview.convert(point, from: self) + if let target = subview.hitTest(converted, with: event) { + return target + } + } + return nil + } +} diff --git a/Sources/NetworkInspector/UI/LogDetail/LogDetailViewController.swift b/Sources/NetworkInspector/UI/LogDetail/LogDetailViewController.swift index 1a9d771..ce418c0 100644 --- a/Sources/NetworkInspector/UI/LogDetail/LogDetailViewController.swift +++ b/Sources/NetworkInspector/UI/LogDetail/LogDetailViewController.swift @@ -11,6 +11,14 @@ import UIKit final class LogDetailViewController: UIViewController { private let log: NetworkLog + private let segmentedControl = UISegmentedControl(items: ["Request", "Status", "Response"]) + private let containerView = UIView() + private lazy var tabViewControllers: [UIViewController] = [ + RequestTabViewController(log: log), + ResponseStatusTabViewController(log: log), + ResponseBodyTabViewController(log: log) + ] + private var currentChild: UIViewController? init(log: NetworkLog) { self.log = log @@ -26,7 +34,9 @@ final class LogDetailViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .systemBackground setupNavigationItems() - setupTabs() + setupSegmentedControl() + setupContainer() + showChild(at: 0) } } @@ -40,93 +50,111 @@ private extension LogDetailViewController { } func setupNavigationItems() { + let menu = UIMenu(children: [ + UIAction(title: "Copy cURL", image: UIImage(systemName: "terminal")) { [weak self] _ in + self?.copyCurl() + }, + UIAction(title: "Copy Auth Token", image: UIImage(systemName: "key")) { [weak self] _ in + self?.copyAuthToken() + } + ]) + navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "Copy cURL", - style: .plain, - target: self, - action: #selector(copyCurl) + title: "Copy", + image: nil, + primaryAction: nil, + menu: menu ) } - func setupTabs() { - let requestVC = RequestTabViewController(log: log) - requestVC.tabBarItem = UITabBarItem( - title: "Request", - image: UIImage(systemName: "arrow.up"), - tag: 0 - ) + func setupSegmentedControl() { + segmentedControl.selectedSegmentIndex = 0 + segmentedControl.addTarget(self, action: #selector(onSegmentChanged), for: .valueChanged) + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(segmentedControl) - let statusVC = ResponseStatusTabViewController(log: log) - statusVC.tabBarItem = UITabBarItem( - title: "Status", - image: UIImage(systemName: "info.circle"), - tag: 1 - ) + NSLayoutConstraint.activate([ + segmentedControl.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + segmentedControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + segmentedControl.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + ]) + } - let bodyVC = ResponseBodyTabViewController(log: log) - bodyVC.tabBarItem = UITabBarItem( - title: "Response", - image: UIImage(systemName: "doc.text"), - tag: 2 - ) + func setupContainer() { + containerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(containerView) - let tabBarController = UITabBarController() - tabBarController.viewControllers = [ - requestVC, - statusVC, - bodyVC - ] + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: segmentedControl.bottomAnchor, constant: 8), + containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + @objc + func onSegmentChanged() { + showChild(at: segmentedControl.selectedSegmentIndex) + } + + func showChild(at index: Int) { + guard index >= 0, index < tabViewControllers.count else { return } + let newChild = tabViewControllers[index] + if newChild === currentChild { return } - // 🔴 1. Disable translucency (major glossy source) - tabBarController.tabBar.isTranslucent = false - tabBarController.tabBar.backgroundColor = .white - - // 🔴 2. Force opaque tab bar appearance (iOS 15+) - if #available(iOS 15.0, *) { - let appearance = UITabBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = .white - - appearance.stackedLayoutAppearance.normal.iconColor = .darkGray - appearance.stackedLayoutAppearance.selected.iconColor = .black - appearance.stackedLayoutAppearance.normal.titleTextAttributes = [ - .foregroundColor: UIColor.darkGray - ] - appearance.stackedLayoutAppearance.selected.titleTextAttributes = [ - .foregroundColor: UIColor.black - ] - - tabBarController.tabBar.standardAppearance = appearance - tabBarController.tabBar.scrollEdgeAppearance = appearance + if let currentChild { + currentChild.willMove(toParent: nil) + currentChild.view.removeFromSuperview() + currentChild.removeFromParent() } - // 🔴 3. Proper containment + layout - addChild(tabBarController) - tabBarController.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tabBarController.view) + addChild(newChild) + newChild.view.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(newChild.view) NSLayoutConstraint.activate([ - tabBarController.view.topAnchor.constraint(equalTo: view.topAnchor), - tabBarController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - tabBarController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tabBarController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + newChild.view.topAnchor.constraint(equalTo: containerView.topAnchor), + newChild.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + newChild.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + newChild.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor) ]) - tabBarController.didMove(toParent: self) + newChild.didMove(toParent: self) + currentChild = newChild } - @objc func copyCurl() { - let curlCommand = buildCurlCommand() - UIPasteboard.general.string = curlCommand + UIPasteboard.general.string = buildCurlCommand() + showCopiedToast(message: "cURL command copied to clipboard.") + } - let alert = UIAlertController( - title: "Copied", - message: "cURL command copied to clipboard.", - preferredStyle: .alert - ) - present(alert, animated: true) + func copyAuthToken() { + if let token = extractAuthToken() { + UIPasteboard.general.string = token + showCopiedToast(message: "Auth token copied to clipboard.") + } else { + showCopiedToast(title: "No Token", message: "No Authorization header on this request.") + } + } + func extractAuthToken() -> String? { + let authValue = log.request.headers.first { $0.key.caseInsensitiveCompare("Authorization") == .orderedSame }?.value + guard let raw = authValue?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else { + return nil + } + + let lowered = raw.lowercased() + for scheme in ["bearer ", "token ", "basic "] { + if lowered.hasPrefix(scheme) { + return String(raw.dropFirst(scheme.count)).trimmingCharacters(in: .whitespaces) + } + } + return raw + } + + func showCopiedToast(title: String = "Copied", message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + present(alert, animated: true) DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { alert.dismiss(animated: true) } diff --git a/Sources/NetworkInspector/UI/LogDetail/RawResponseView.swift b/Sources/NetworkInspector/UI/LogDetail/RawResponseView.swift new file mode 100644 index 0000000..b438d85 --- /dev/null +++ b/Sources/NetworkInspector/UI/LogDetail/RawResponseView.swift @@ -0,0 +1,166 @@ +// +// RawResponseView.swift +// NetworkInspector +// + +import UIKit + +final class RawResponseView: UIView { + + private let searchBar = UISearchBar() + private let matchCountLabel = UILabel() + private let textView = UITextView() + + private var baseText: String = "" + private var matchRanges: [NSRange] = [] + private var currentMatchIndex: Int = 0 + + init(data: Data?) { + super.init(frame: .zero) + if let data { + baseText = PrettyFormatter.prettyJSON(from: data) + } else { + baseText = "No response body" + } + textView.text = baseText + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension RawResponseView { + + func setupUI() { + backgroundColor = .systemBackground + + textView.isEditable = false + textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + + searchBar.placeholder = "Search in response" + searchBar.delegate = self + searchBar.autocapitalizationType = .none + searchBar.autocorrectionType = .no + searchBar.returnKeyType = .next + searchBar.showsBookmarkButton = true + searchBar.setImage(UIImage(systemName: "chevron.up"), for: .bookmark, state: .normal) + + matchCountLabel.font = .systemFont(ofSize: 12) + matchCountLabel.textColor = .secondaryLabel + matchCountLabel.textAlignment = .right + matchCountLabel.isHidden = true + + searchBar.translatesAutoresizingMaskIntoConstraints = false + textView.translatesAutoresizingMaskIntoConstraints = false + matchCountLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(searchBar) + addSubview(matchCountLabel) + addSubview(textView) + + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: topAnchor), + searchBar.leadingAnchor.constraint(equalTo: leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: trailingAnchor), + matchCountLabel.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 2), + matchCountLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + matchCountLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + textView.topAnchor.constraint(equalTo: matchCountLabel.bottomAnchor, constant: 2), + textView.bottomAnchor.constraint(equalTo: bottomAnchor), + textView.leadingAnchor.constraint(equalTo: leadingAnchor), + textView.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + } +} + +extension RawResponseView: UISearchBarDelegate { + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + recomputeMatches(for: searchText) + currentMatchIndex = 0 + applyHighlights() + scrollToCurrentMatch() + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + advanceMatch(by: 1) + } + + func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) { + advanceMatch(by: -1) + } +} + +private extension RawResponseView { + + func recomputeMatches(for searchText: String) { + matchRanges = [] + guard !searchText.isEmpty else { return } + + let lowercasedText = baseText.lowercased() + let lowercasedSearch = searchText.lowercased() + var searchRange = lowercasedText.startIndex..