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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `Cmd+F` on a table tab used to toggle the filter panel, which meant it closed the panel when it was already open and never searched anything. The filter panel keeps `Cmd+Option+F` and its funnel button in the status bar.
- Find Next and Find Previous work on the data grid when its find bar is open, instead of staying dimmed on a table tab.
- The PHP serialized viewer's tree filter now behaves like the JSON one. Both ignore accents, so `cafe` finds `café`. (#2204)

### Fixed

- Filtering a JSON or PHP tree now reveals nested key and value matches instead of leaving them behind collapsed parent rows. (#2204)
- A tree filter that matches a key now lets you open that key and read what is inside it. (#2204)
- Expanding or collapsing rows while a tree filter is active no longer springs back on the next keystroke. (#2204)
- Clearing a tree filter now restores the rows you had open before you started filtering. (#2204)
- A tree filter that finds nothing now says so instead of showing an empty list, and says when the value was too large to load in full. (#2204)
- Filtering a tree now searches the whole of a long string value instead of only the shortened form shown in the row. (#2204)
- Copy Value on a JSON object or array now copies that part of the document instead of a summary like `{3 keys}`, and long strings copy in full. (#2204)

## [0.66.0] - 2026-08-19

Expand Down
32 changes: 32 additions & 0 deletions TablePro/Models/UI/FilterableTreeNode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// FilterableTreeNode.swift
// TablePro
//

import Foundation

internal protocol FilterableTreeNode: Identifiable {
var key: String? { get }
var keyPath: String { get }
var displayValue: String { get }
var searchableText: String { get }
var copyableValue: String { get }
var badgeLabel: String { get }
var isTruncationMarker: Bool { get }
var children: [Self] { get }

func replacingChildren(_ children: [Self]) -> Self
}

internal extension FilterableTreeNode {
var isContainer: Bool {
!children.isEmpty
}

var accessibilityDescription: String {
let value = displayValue as NSString
let head = value.length > 120 ? value.substring(to: 120) : displayValue
guard let key, !key.isEmpty else { return "\(badgeLabel), \(head)" }
return "\(key), \(badgeLabel), \(head)"
}
}
99 changes: 96 additions & 3 deletions TablePro/Models/UI/JSONTreeNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal enum JSONValueType {
case number
case boolean
case null
case truncated

var badgeLabel: String {
switch self {
Expand All @@ -22,6 +23,7 @@ internal enum JSONValueType {
case .number: return "num"
case .boolean: return "bool"
case .null: return "null"
case .truncated: return "..."
}
}

Expand All @@ -31,31 +33,122 @@ internal enum JSONValueType {
case .string: return .systemRed
case .number: return .systemPurple
case .boolean, .null: return .systemOrange
case .truncated: return .secondaryLabelColor
}
}
}

internal struct JSONTreeNode: Identifiable {
let id = UUID()
let id: UUID
let key: String?
let keyPath: String
let valueType: JSONValueType
let displayValue: String
let rawValue: String?
let children: [JSONTreeNode]

init(
id: UUID = UUID(),
key: String?,
keyPath: String,
valueType: JSONValueType,
displayValue: String,
rawValue: String?,
children: [JSONTreeNode]
) {
self.id = id
self.key = key
self.keyPath = keyPath
self.valueType = valueType
self.displayValue = displayValue
self.rawValue = rawValue
self.children = children
}

var childrenOrNil: [JSONTreeNode]? {
children.isEmpty ? nil : children
}
}

extension JSONTreeNode: FilterableTreeNode {
internal var searchableText: String {
rawValue ?? displayValue
}

internal var badgeLabel: String {
valueType.badgeLabel
}

internal var isTruncationMarker: Bool {
valueType == .truncated
}

internal var copyableValue: String {
switch valueType {
case .object, .array: return jsonRepresentation
default: return rawValue ?? displayValue
}
}

internal func replacingChildren(_ children: [JSONTreeNode]) -> JSONTreeNode {
JSONTreeNode(
id: id, key: key, keyPath: keyPath, valueType: valueType,
displayValue: displayValue, rawValue: rawValue, children: children
)
}

private var jsonRepresentation: String {
switch valueType {
case .object:
let members = children.compactMap { child -> String? in
guard !child.isTruncationMarker, let key = child.key else { return nil }
return "\(Self.jsonQuoted(key)):\(child.jsonRepresentation)"
}
return "{\(members.joined(separator: ","))}"
case .array:
let elements = children
.filter { !$0.isTruncationMarker }
.map(\.jsonRepresentation)
return "[\(elements.joined(separator: ","))]"
case .string:
return Self.jsonQuoted(rawValue ?? "")
case .null:
return "null"
case .truncated:
return "null"
case .number, .boolean:
return rawValue ?? displayValue
}
}

private static func jsonQuoted(_ value: String) -> String {
var output = "\""
for scalar in value.unicodeScalars {
switch scalar {
case "\"": output += "\\\""
case "\\": output += "\\\\"
case "\n": output += "\\n"
case "\r": output += "\\r"
case "\t": output += "\\t"
default:
guard scalar.value < 0x20 else {
output.unicodeScalars.append(scalar)
continue
}
output += String(format: "\\u%04x", scalar.value)
}
}
return output + "\""
}
}

internal enum JSONTreeParseError: Error {
case invalidJSON
case tooLarge
}

internal enum JSONTreeParser {
private static let maxNodes = 5_000
private static let maxNodes = TreeNodeLimits.maxNodes
private static let maxInputLength = 100_000
private static let maxDisplayLength = 300

Expand Down Expand Up @@ -142,7 +235,7 @@ internal enum JSONTreeParser {

private static func truncationNode(remaining: Int) -> JSONTreeNode {
JSONTreeNode(
key: nil, keyPath: "", valueType: .null,
key: nil, keyPath: "", valueType: .truncated,
displayValue: "… (\(remaining) more)", rawValue: nil, children: []
)
}
Expand Down
33 changes: 31 additions & 2 deletions TablePro/Models/UI/PhpTreeNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ internal struct PhpTreeNode: Identifiable {
let keyPath: String
let nodeType: PhpNodeType
let displayValue: String
let rawValue: String?
let visibilityBadge: String?
let children: [PhpTreeNode]

Expand All @@ -63,6 +64,7 @@ internal struct PhpTreeNode: Identifiable {
keyPath: String,
nodeType: PhpNodeType,
displayValue: String,
rawValue: String? = nil,
visibilityBadge: String? = nil,
children: [PhpTreeNode] = []
) {
Expand All @@ -71,6 +73,7 @@ internal struct PhpTreeNode: Identifiable {
self.keyPath = keyPath
self.nodeType = nodeType
self.displayValue = displayValue
self.rawValue = rawValue
self.visibilityBadge = visibilityBadge
self.children = children
}
Expand All @@ -80,8 +83,34 @@ internal struct PhpTreeNode: Identifiable {
}
}

extension PhpTreeNode: FilterableTreeNode {
internal var searchableText: String {
rawValue ?? displayValue
}

internal var badgeLabel: String {
nodeType.badgeLabel
}

internal var isTruncationMarker: Bool {
nodeType == .truncated
}

internal var copyableValue: String {
rawValue ?? displayValue
}

internal func replacingChildren(_ children: [PhpTreeNode]) -> PhpTreeNode {
PhpTreeNode(
id: id, key: key, keyPath: keyPath, nodeType: nodeType,
displayValue: displayValue, rawValue: rawValue,
visibilityBadge: visibilityBadge, children: children
)
}
}

internal enum PhpTreeBuilder {
static let maxNodes = 5_000
static let maxNodes = TreeNodeLimits.maxNodes

static func build(from phpValue: PhpValue) -> PhpTreeNode {
var nodeCount = 0
Expand Down Expand Up @@ -126,7 +155,7 @@ internal enum PhpTreeBuilder {
case .string(let stringValue):
return PhpTreeNode(
key: key, keyPath: keyPath, nodeType: .string,
displayValue: stringDisplay(stringValue), visibilityBadge: badge
displayValue: stringDisplay(stringValue), rawValue: stringValue, visibilityBadge: badge
)

case .array(let entries):
Expand Down
79 changes: 79 additions & 0 deletions TablePro/Models/UI/TreeDisclosureState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// TreeDisclosureState.swift
// TablePro
//

import Foundation

internal struct TreeDisclosureState {
private var expandedKeyPaths: Set<String> = []
private var collapsedKeyPaths: Set<String> = []
private var filterExpandedKeyPaths: Set<String> = []
private var filterCollapsedKeyPaths: Set<String> = []

internal init() {}

internal func isExpanded(
_ keyPath: String,
autoRevealedKeyPaths: Set<String>,
defaultExpandedKeyPaths: Set<String>,
isFiltered: Bool
) -> Bool {
if isFiltered {
if filterExpandedKeyPaths.contains(keyPath) { return true }
if filterCollapsedKeyPaths.contains(keyPath) { return false }
if autoRevealedKeyPaths.contains(keyPath) { return true }
}
if expandedKeyPaths.contains(keyPath) { return true }
if collapsedKeyPaths.contains(keyPath) { return false }
return defaultExpandedKeyPaths.contains(keyPath)
}

internal mutating func setExpanded(_ expanded: Bool, keyPath: String, isFiltered: Bool) {
guard isFiltered else {
apply(expanded, keyPath: keyPath, expandedSet: &expandedKeyPaths, collapsedSet: &collapsedKeyPaths)
return
}
apply(expanded, keyPath: keyPath, expandedSet: &filterExpandedKeyPaths, collapsedSet: &filterCollapsedKeyPaths)
}

internal mutating func expandAll(containerKeyPaths: Set<String>, isFiltered: Bool) {
guard isFiltered else {
expandedKeyPaths = containerKeyPaths
collapsedKeyPaths = []
return
}
filterExpandedKeyPaths = containerKeyPaths
filterCollapsedKeyPaths = []
}

internal mutating func collapseAll(containerKeyPaths: Set<String>, isFiltered: Bool) {
guard isFiltered else {
collapsedKeyPaths = containerKeyPaths
expandedKeyPaths = []
return
}
filterCollapsedKeyPaths = containerKeyPaths
filterExpandedKeyPaths = []
}

internal mutating func endFiltering() {
filterExpandedKeyPaths.removeAll()
filterCollapsedKeyPaths.removeAll()
}

private func apply(
_ expanded: Bool,
keyPath: String,
expandedSet: inout Set<String>,
collapsedSet: inout Set<String>
) {
guard expanded else {
collapsedSet.insert(keyPath)
expandedSet.remove(keyPath)
return
}
expandedSet.insert(keyPath)
collapsedSet.remove(keyPath)
}
}
Loading
Loading