diff --git a/CHANGELOG.md b/CHANGELOG.md index c2add669f..b746cfd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Models/UI/FilterableTreeNode.swift b/TablePro/Models/UI/FilterableTreeNode.swift new file mode 100644 index 000000000..973093a1f --- /dev/null +++ b/TablePro/Models/UI/FilterableTreeNode.swift @@ -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)" + } +} diff --git a/TablePro/Models/UI/JSONTreeNode.swift b/TablePro/Models/UI/JSONTreeNode.swift index 26ec42142..dad37c371 100644 --- a/TablePro/Models/UI/JSONTreeNode.swift +++ b/TablePro/Models/UI/JSONTreeNode.swift @@ -13,6 +13,7 @@ internal enum JSONValueType { case number case boolean case null + case truncated var badgeLabel: String { switch self { @@ -22,6 +23,7 @@ internal enum JSONValueType { case .number: return "num" case .boolean: return "bool" case .null: return "null" + case .truncated: return "..." } } @@ -31,12 +33,13 @@ 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 @@ -44,18 +47,108 @@ internal struct JSONTreeNode: Identifiable { 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 @@ -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: [] ) } diff --git a/TablePro/Models/UI/PhpTreeNode.swift b/TablePro/Models/UI/PhpTreeNode.swift index f8101b0e6..5c3321288 100644 --- a/TablePro/Models/UI/PhpTreeNode.swift +++ b/TablePro/Models/UI/PhpTreeNode.swift @@ -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] @@ -63,6 +64,7 @@ internal struct PhpTreeNode: Identifiable { keyPath: String, nodeType: PhpNodeType, displayValue: String, + rawValue: String? = nil, visibilityBadge: String? = nil, children: [PhpTreeNode] = [] ) { @@ -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 } @@ -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 @@ -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): diff --git a/TablePro/Models/UI/TreeDisclosureState.swift b/TablePro/Models/UI/TreeDisclosureState.swift new file mode 100644 index 000000000..a555163d7 --- /dev/null +++ b/TablePro/Models/UI/TreeDisclosureState.swift @@ -0,0 +1,79 @@ +// +// TreeDisclosureState.swift +// TablePro +// + +import Foundation + +internal struct TreeDisclosureState { + private var expandedKeyPaths: Set = [] + private var collapsedKeyPaths: Set = [] + private var filterExpandedKeyPaths: Set = [] + private var filterCollapsedKeyPaths: Set = [] + + internal init() {} + + internal func isExpanded( + _ keyPath: String, + autoRevealedKeyPaths: Set, + defaultExpandedKeyPaths: Set, + 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, isFiltered: Bool) { + guard isFiltered else { + expandedKeyPaths = containerKeyPaths + collapsedKeyPaths = [] + return + } + filterExpandedKeyPaths = containerKeyPaths + filterCollapsedKeyPaths = [] + } + + internal mutating func collapseAll(containerKeyPaths: Set, 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, + collapsedSet: inout Set + ) { + guard expanded else { + collapsedSet.insert(keyPath) + expandedSet.remove(keyPath) + return + } + expandedSet.insert(keyPath) + collapsedSet.remove(keyPath) + } +} diff --git a/TablePro/Models/UI/TreeFilter.swift b/TablePro/Models/UI/TreeFilter.swift new file mode 100644 index 000000000..35df83f54 --- /dev/null +++ b/TablePro/Models/UI/TreeFilter.swift @@ -0,0 +1,134 @@ +// +// TreeFilter.swift +// TablePro +// + +import Foundation + +internal enum TreeNodeLimits { + static let maxNodes = 5_000 +} + +internal struct TreeDocumentInfo { + let allContainerKeyPaths: Set + let defaultExpandedKeyPaths: Set + let isTruncated: Bool + + static var empty: TreeDocumentInfo { + TreeDocumentInfo(allContainerKeyPaths: [], defaultExpandedKeyPaths: [], isTruncated: false) + } +} + +internal struct TreeProjection { + let nodes: [Node] + let autoRevealedKeyPaths: Set + let matchCount: Int + let isFiltered: Bool +} + +internal enum TreeFilter { + static func documentInfo(rootNode: Node) -> TreeDocumentInfo { + var containers: Set = [] + var truncated = false + collectDocumentInfo(rootNode, containers: &containers, truncated: &truncated) + + let roots = topLevelNodes(of: rootNode) + let defaults = Set(roots.filter(\.isContainer).map(\.keyPath)) + return TreeDocumentInfo( + allContainerKeyPaths: containers, + defaultExpandedKeyPaths: defaults, + isTruncated: truncated + ) + } + + static func projection( + rootNode: Node, + searchText: String + ) -> TreeProjection { + let roots = topLevelNodes(of: rootNode) + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { + return TreeProjection(nodes: roots, autoRevealedKeyPaths: [], matchCount: 0, isFiltered: false) + } + + var revealed: Set = [] + var matches = 0 + let nodes = filter(roots, query: query, revealed: &revealed, matches: &matches) + return TreeProjection( + nodes: nodes, + autoRevealedKeyPaths: revealed, + matchCount: matches, + isFiltered: true + ) + } + + static func containerKeyPaths(in nodes: [Node]) -> Set { + var paths: Set = [] + for node in nodes { + collectContainers(node, into: &paths) + } + return paths + } + + private static func topLevelNodes(of rootNode: Node) -> [Node] { + rootNode.children.isEmpty ? [rootNode] : rootNode.children + } + + private static func filter( + _ nodes: [Node], + query: String, + revealed: inout Set, + matches: inout Int + ) -> [Node] { + nodes.compactMap { node in + var childRevealed: Set = [] + var childMatches = 0 + let filteredChildren = filter( + node.children, + query: query, + revealed: &childRevealed, + matches: &childMatches + ) + + if matchesQuery(node, query: query) { + matches += 1 + childMatches + guard childMatches > 0 else { return node.replacingChildren(node.children) } + revealed.insert(node.keyPath) + revealed.formUnion(childRevealed) + return node.replacingChildren(node.children) + } + + guard !filteredChildren.isEmpty else { return nil } + matches += childMatches + revealed.insert(node.keyPath) + revealed.formUnion(childRevealed) + return node.replacingChildren(filteredChildren) + } + } + + private static func matchesQuery(_ node: Node, query: String) -> Bool { + if let key = node.key, SidebarNameFilter.matches(query: query, candidate: key) { return true } + return SidebarNameFilter.matches(query: query, candidate: node.searchableText) + } + + private static func collectContainers(_ node: Node, into paths: inout Set) { + guard node.isContainer else { return } + paths.insert(node.keyPath) + for child in node.children { + collectContainers(child, into: &paths) + } + } + + private static func collectDocumentInfo( + _ node: Node, + containers: inout Set, + truncated: inout Bool + ) { + if node.isTruncationMarker { truncated = true } + guard node.isContainer else { return } + containers.insert(node.keyPath) + for child in node.children { + collectDocumentInfo(child, containers: &containers, truncated: &truncated) + } + } +} diff --git a/TablePro/Models/UI/TreeProjectionCache.swift b/TablePro/Models/UI/TreeProjectionCache.swift new file mode 100644 index 000000000..cc925e9df --- /dev/null +++ b/TablePro/Models/UI/TreeProjectionCache.swift @@ -0,0 +1,40 @@ +// +// TreeProjectionCache.swift +// TablePro +// + +import Foundation + +@MainActor +internal final class TreeProjectionCache { + internal private(set) var documentComputations = 0 + internal private(set) var projectionComputations = 0 + + private var documentID: Node.ID? + private var cachedDocumentInfo = TreeDocumentInfo.empty + private var projectionID: Node.ID? + private var projectionQuery: String? + private var cachedProjection: TreeProjection? + + internal init() {} + + internal func documentInfo(for rootNode: Node) -> TreeDocumentInfo { + if documentID == rootNode.id { return cachedDocumentInfo } + documentID = rootNode.id + cachedDocumentInfo = TreeFilter.documentInfo(rootNode: rootNode) + documentComputations += 1 + return cachedDocumentInfo + } + + internal func projection(for rootNode: Node, searchText: String) -> TreeProjection { + if projectionID == rootNode.id, projectionQuery == searchText, let cachedProjection { + return cachedProjection + } + let projection = TreeFilter.projection(rootNode: rootNode, searchText: searchText) + projectionID = rootNode.id + projectionQuery = searchText + cachedProjection = projection + projectionComputations += 1 + return projection + } +} diff --git a/TablePro/Views/Results/FilterableTreeView.swift b/TablePro/Views/Results/FilterableTreeView.swift new file mode 100644 index 000000000..723b5917c --- /dev/null +++ b/TablePro/Views/Results/FilterableTreeView.swift @@ -0,0 +1,245 @@ +// +// FilterableTreeView.swift +// TablePro +// + +import SwiftUI + +internal struct FilterableTreeView: View { + let rootNode: Node + @Binding var searchText: String + let fullValueModeName: String + let row: (Node) -> Row + + @State private var disclosure = TreeDisclosureState() + @State private var cache = TreeProjectionCache() + + internal init( + rootNode: Node, + searchText: Binding, + fullValueModeName: String, + @ViewBuilder row: @escaping (Node) -> Row + ) { + self.rootNode = rootNode + self._searchText = searchText + self.fullValueModeName = fullValueModeName + self.row = row + } + + var body: some View { + let documentInfo = cache.documentInfo(for: rootNode) + let projection = cache.projection(for: rootNode, searchText: searchText) + + VStack(spacing: 0) { + treeToolbar(projection: projection) + Divider() + if projection.isFiltered, documentInfo.isTruncated, !projection.nodes.isEmpty { + truncationNotice + Divider() + } + content(projection: projection, documentInfo: documentInfo) + } + .onChange(of: searchText) { _, newValue in + guard newValue.trimmingCharacters(in: .whitespaces).isEmpty else { return } + disclosure.endFiltering() + } + } + + // MARK: - Toolbar + + private func treeToolbar(projection: TreeProjection) -> some View { + HStack(spacing: 6) { + NativeSearchField( + text: $searchText, + placeholder: String(localized: "Filter keys or values..."), + controlSize: .small + ) + if projection.isFiltered { + Text(String(format: String(localized: "%lld matches"), projection.matchCount)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + .accessibilityLabel( + String(format: String(localized: "%lld matching rows"), projection.matchCount) + ) + } + Button(String(localized: "Expand All"), systemImage: "rectangle.expand.vertical") { + expandAll(projection: projection) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help(String(localized: "Expand All")) + Button(String(localized: "Collapse All"), systemImage: "rectangle.compress.vertical") { + collapseAll(projection: projection) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help(String(localized: "Collapse All")) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + + private var truncationNotice: some View { + Label( + String( + format: String(localized: "Only the first %1$lld nodes were loaded. Switch to %2$@ to see the whole value."), + TreeNodeLimits.maxNodes, + fullValueModeName + ), + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + // MARK: - Content + + @ViewBuilder + private func content(projection: TreeProjection, documentInfo: TreeDocumentInfo) -> some View { + if projection.isFiltered, projection.nodes.isEmpty { + noMatchesView(isTruncated: documentInfo.isTruncated) + } else { + List { + FilterableTreeContentView( + nodes: projection.nodes, + disclosure: $disclosure, + autoRevealedKeyPaths: projection.autoRevealedKeyPaths, + defaultExpandedKeyPaths: documentInfo.defaultExpandedKeyPaths, + isFiltered: projection.isFiltered, + onExpandAll: { expandAll(projection: projection) }, + onCollapseAll: { collapseAll(projection: projection) }, + row: row + ) + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + .animation(nil, value: projection.isFiltered) + .animation(nil, value: searchText) + } + } + + @ViewBuilder + private func noMatchesView(isTruncated: Bool) -> some View { + if isTruncated { + ContentUnavailableView { + Label(String(localized: "No Results"), systemImage: "magnifyingglass") + } description: { + Text( + String( + format: String( + localized: "Only the first %1$lld nodes were loaded, so this value was not searched in full. Switch to %2$@ to search all of it." + ), + TreeNodeLimits.maxNodes, + fullValueModeName + ) + ) + } + } else { + ContentUnavailableView.search(text: searchText) + } + } + + // MARK: - Actions + + private func expandAll(projection: TreeProjection) { + withAnimation(nil) { + disclosure.expandAll( + containerKeyPaths: cache.documentInfo(for: rootNode).allContainerKeyPaths, + isFiltered: projection.isFiltered + ) + } + } + + private func collapseAll(projection: TreeProjection) { + withAnimation(nil) { + disclosure.collapseAll( + containerKeyPaths: cache.documentInfo(for: rootNode).allContainerKeyPaths, + isFiltered: projection.isFiltered + ) + } + } +} + +// MARK: - Recursive Tree Content + +private struct FilterableTreeContentView: View { + let nodes: [Node] + @Binding var disclosure: TreeDisclosureState + let autoRevealedKeyPaths: Set + let defaultExpandedKeyPaths: Set + let isFiltered: Bool + let onExpandAll: () -> Void + let onCollapseAll: () -> Void + let row: (Node) -> Row + + var body: some View { + ForEach(nodes) { node in + if node.children.isEmpty { + decorated(node) + } else { + DisclosureGroup(isExpanded: binding(for: node)) { + FilterableTreeContentView( + nodes: node.children, + disclosure: $disclosure, + autoRevealedKeyPaths: autoRevealedKeyPaths, + defaultExpandedKeyPaths: defaultExpandedKeyPaths, + isFiltered: isFiltered, + onExpandAll: onExpandAll, + onCollapseAll: onCollapseAll, + row: row + ) + } label: { + decorated(node) + } + } + } + } + + private func decorated(_ node: Node) -> some View { + row(node) + .accessibilityElement(children: .ignore) + .accessibilityLabel(node.accessibilityDescription) + .contextMenu { nodeContextMenu(for: node) } + } + + private func binding(for node: Node) -> Binding { + Binding( + get: { + disclosure.isExpanded( + node.keyPath, + autoRevealedKeyPaths: autoRevealedKeyPaths, + defaultExpandedKeyPaths: defaultExpandedKeyPaths, + isFiltered: isFiltered + ) + }, + set: { expanded in + disclosure.setExpanded(expanded, keyPath: node.keyPath, isFiltered: isFiltered) + } + ) + } + + @ViewBuilder + private func nodeContextMenu(for node: Node) -> some View { + Button(String(localized: "Copy Value")) { + ClipboardService.shared.writeText(node.copyableValue) + } + if !node.keyPath.isEmpty { + Button(String(localized: "Copy Key Path")) { + ClipboardService.shared.writeText(node.keyPath) + } + } + if let key = node.key { + Button(String(localized: "Copy Key")) { + ClipboardService.shared.writeText(key) + } + } + Divider() + if !node.children.isEmpty { + Button(String(localized: "Expand All")) { onExpandAll() } + Button(String(localized: "Collapse All")) { onCollapseAll() } + } + } +} diff --git a/TablePro/Views/Results/JSONTreeView.swift b/TablePro/Views/Results/JSONTreeView.swift index fdff1d971..08e1bb5f3 100644 --- a/TablePro/Views/Results/JSONTreeView.swift +++ b/TablePro/Views/Results/JSONTreeView.swift @@ -9,183 +9,13 @@ internal struct JSONTreeView: View { let rootNode: JSONTreeNode @Binding var searchText: String - @State private var expandedNodeIDs: Set = [] - var body: some View { - VStack(spacing: 0) { - treeToolbar - Divider() - List { - JSONTreeContentView( - nodes: filteredRootNodes, - expandedNodeIDs: $expandedNodeIDs, - onExpandAll: expandAll, - onCollapseAll: collapseAll - ) - } - .listStyle(.inset(alternatesRowBackgrounds: true)) - } - .onAppear { expandRootLevel() } - .onChange(of: searchText) { expandMatchingNodes() } - } - - // MARK: - Toolbar - - private var treeToolbar: some View { - HStack(spacing: 6) { - NativeSearchField( - text: $searchText, - placeholder: String(localized: "Filter keys or values..."), - controlSize: .small - ) - Button(action: expandAll) { - Image(systemName: "rectangle.expand.vertical") - } - .buttonStyle(.borderless) - .help(String(localized: "Expand All")) - Button(action: collapseAll) { - Image(systemName: "rectangle.compress.vertical") - } - .buttonStyle(.borderless) - .help(String(localized: "Collapse All")) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - - // MARK: - Filtering - - private var filteredRootNodes: [JSONTreeNode] { - let nodes = rootNode.children.isEmpty ? [rootNode] : rootNode.children - if searchText.isEmpty { return nodes } - return Self.filterNodes(nodes, matching: searchText) - } - - private static func filterNodes(_ nodes: [JSONTreeNode], matching query: String) -> [JSONTreeNode] { - nodes.compactMap { node in - let keyMatches = node.key?.localizedCaseInsensitiveContains(query) ?? false - let valueMatches = node.displayValue.localizedCaseInsensitiveContains(query) - let filteredChildren = filterNodes(node.children, matching: query) - - if !filteredChildren.isEmpty { - return JSONTreeNode( - key: node.key, keyPath: node.keyPath, valueType: node.valueType, - displayValue: node.displayValue, rawValue: node.rawValue, - children: filteredChildren - ) - } - if keyMatches || valueMatches { - return JSONTreeNode( - key: node.key, keyPath: node.keyPath, valueType: node.valueType, - displayValue: node.displayValue, rawValue: node.rawValue, - children: [] - ) - } - return nil - } - } - - private func expandMatchingNodes() { - if searchText.isEmpty { - expandedNodeIDs.removeAll() - expandRootLevel() - return - } - expandedNodeIDs.formUnion(collectMatchingContainerIDs(filteredRootNodes)) - } - - private func collectMatchingContainerIDs(_ nodes: [JSONTreeNode]) -> Set { - var ids: Set = [] - for node in nodes where !node.children.isEmpty { - ids.insert(node.id) - ids.formUnion(collectMatchingContainerIDs(node.children)) - } - return ids - } - - // MARK: - Actions - - private func expandAll() { - withAnimation(nil) { expandedNodeIDs = collectAllContainerIDs(rootNode) } - } - - private func collapseAll() { - withAnimation(nil) { expandedNodeIDs.removeAll() } - } - - private func expandRootLevel() { - for child in rootNode.children where !child.children.isEmpty { - expandedNodeIDs.insert(child.id) - } - } - - private func collectAllContainerIDs(_ node: JSONTreeNode) -> Set { - var ids: Set = [] - if !node.children.isEmpty { - ids.insert(node.id) - for child in node.children { - ids.formUnion(collectAllContainerIDs(child)) - } - } - return ids - } -} - -// MARK: - Recursive Tree Content - -private struct JSONTreeContentView: View { - let nodes: [JSONTreeNode] - @Binding var expandedNodeIDs: Set - let onExpandAll: () -> Void - let onCollapseAll: () -> Void - - var body: some View { - ForEach(nodes) { node in - if node.children.isEmpty { - JSONTreeRowView(node: node) - .contextMenu { nodeContextMenu(for: node) } - } else { - DisclosureGroup( - isExpanded: Binding( - get: { expandedNodeIDs.contains(node.id) }, - set: { expanded in - if expanded { expandedNodeIDs.insert(node.id) } else { expandedNodeIDs.remove(node.id) } - } - ) - ) { - JSONTreeContentView( - nodes: node.children, - expandedNodeIDs: $expandedNodeIDs, - onExpandAll: onExpandAll, - onCollapseAll: onCollapseAll - ) - } label: { - JSONTreeRowView(node: node) - .contextMenu { nodeContextMenu(for: node) } - } - } - } - } - - @ViewBuilder - private func nodeContextMenu(for node: JSONTreeNode) -> some View { - Button(String(localized: "Copy Value")) { - ClipboardService.shared.writeText(node.rawValue ?? node.displayValue) - } - if !node.keyPath.isEmpty { - Button(String(localized: "Copy Key Path")) { - ClipboardService.shared.writeText(node.keyPath) - } - } - if let key = node.key { - Button(String(localized: "Copy Key")) { - ClipboardService.shared.writeText(key) - } - } - Divider() - if !node.children.isEmpty { - Button(String(localized: "Expand All")) { onExpandAll() } - Button(String(localized: "Collapse All")) { onCollapseAll() } + FilterableTreeView( + rootNode: rootNode, + searchText: $searchText, + fullValueModeName: String(localized: "Text") + ) { node in + JSONTreeRowView(node: node) } } } diff --git a/TablePro/Views/Results/PhpTreeView.swift b/TablePro/Views/Results/PhpTreeView.swift index 8507504d9..087703f89 100644 --- a/TablePro/Views/Results/PhpTreeView.swift +++ b/TablePro/Views/Results/PhpTreeView.swift @@ -3,190 +3,19 @@ // TablePro // -import Foundation import SwiftUI internal struct PhpTreeView: View { let rootNode: PhpTreeNode @Binding var searchText: String - @State private var expandedNodeIDs: Set = [] - - var body: some View { - VStack(spacing: 0) { - treeToolbar - Divider() - List { - PhpTreeContentView( - nodes: filteredRootNodes, - expandedNodeIDs: $expandedNodeIDs, - onExpandAll: expandAll, - onCollapseAll: collapseAll - ) - } - .listStyle(.inset(alternatesRowBackgrounds: true)) - } - .onAppear { expandRootLevel() } - .onChange(of: searchText) { expandMatchingNodes() } - } - - // MARK: - Toolbar - - private var treeToolbar: some View { - HStack(spacing: 6) { - NativeSearchField( - text: $searchText, - placeholder: String(localized: "Filter keys or values..."), - controlSize: .small - ) - Button(action: expandAll) { - Image(systemName: "rectangle.expand.vertical") - } - .buttonStyle(.borderless) - .help(String(localized: "Expand All")) - Button(action: collapseAll) { - Image(systemName: "rectangle.compress.vertical") - } - .buttonStyle(.borderless) - .help(String(localized: "Collapse All")) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - - // MARK: - Filtering - - private var filteredRootNodes: [PhpTreeNode] { - let nodes = rootNode.children.isEmpty ? [rootNode] : rootNode.children - if searchText.isEmpty { return nodes } - return Self.filterNodes(nodes, matching: searchText) - } - - private static func filterNodes(_ nodes: [PhpTreeNode], matching query: String) -> [PhpTreeNode] { - nodes.compactMap { node in - let keyMatches = node.key?.localizedCaseInsensitiveContains(query) ?? false - let valueMatches = node.displayValue.localizedCaseInsensitiveContains(query) - let filteredChildren = filterNodes(node.children, matching: query) - - if !filteredChildren.isEmpty { - return PhpTreeNode( - id: node.id, key: node.key, keyPath: node.keyPath, - nodeType: node.nodeType, displayValue: node.displayValue, - visibilityBadge: node.visibilityBadge, children: filteredChildren - ) - } - if keyMatches || valueMatches { - return PhpTreeNode( - id: node.id, key: node.key, keyPath: node.keyPath, - nodeType: node.nodeType, displayValue: node.displayValue, - visibilityBadge: node.visibilityBadge, children: [] - ) - } - return nil - } - } - - private func expandMatchingNodes() { - if searchText.isEmpty { - expandedNodeIDs.removeAll() - expandRootLevel() - return - } - expandedNodeIDs.formUnion(collectMatchingContainerIDs(filteredRootNodes)) - } - - private func collectMatchingContainerIDs(_ nodes: [PhpTreeNode]) -> Set { - var ids: Set = [] - for node in nodes where !node.children.isEmpty { - ids.insert(node.id) - ids.formUnion(collectMatchingContainerIDs(node.children)) - } - return ids - } - - // MARK: - Actions - - private func expandAll() { - withAnimation(nil) { expandedNodeIDs = collectAllContainerIDs(rootNode) } - } - - private func collapseAll() { - withAnimation(nil) { expandedNodeIDs.removeAll() } - } - - private func expandRootLevel() { - for child in rootNode.children where !child.children.isEmpty { - expandedNodeIDs.insert(child.id) - } - } - - private func collectAllContainerIDs(_ node: PhpTreeNode) -> Set { - var ids: Set = [] - if !node.children.isEmpty { - ids.insert(node.id) - for child in node.children { - ids.formUnion(collectAllContainerIDs(child)) - } - } - return ids - } -} - -// MARK: - Recursive Tree Content - -private struct PhpTreeContentView: View { - let nodes: [PhpTreeNode] - @Binding var expandedNodeIDs: Set - let onExpandAll: () -> Void - let onCollapseAll: () -> Void - var body: some View { - ForEach(nodes) { node in - if node.children.isEmpty { - PhpTreeRowView(node: node) - .contextMenu { nodeContextMenu(for: node) } - } else { - DisclosureGroup( - isExpanded: Binding( - get: { expandedNodeIDs.contains(node.id) }, - set: { expanded in - if expanded { expandedNodeIDs.insert(node.id) } else { expandedNodeIDs.remove(node.id) } - } - ) - ) { - PhpTreeContentView( - nodes: node.children, - expandedNodeIDs: $expandedNodeIDs, - onExpandAll: onExpandAll, - onCollapseAll: onCollapseAll - ) - } label: { - PhpTreeRowView(node: node) - .contextMenu { nodeContextMenu(for: node) } - } - } - } - } - - @ViewBuilder - private func nodeContextMenu(for node: PhpTreeNode) -> some View { - Button(String(localized: "Copy Value")) { - ClipboardService.shared.writeText(node.displayValue) - } - if !node.keyPath.isEmpty { - Button(String(localized: "Copy Key Path")) { - ClipboardService.shared.writeText(node.keyPath) - } - } - if let key = node.key { - Button(String(localized: "Copy Key")) { - ClipboardService.shared.writeText(key) - } - } - Divider() - if !node.children.isEmpty { - Button(String(localized: "Expand All")) { onExpandAll() } - Button(String(localized: "Collapse All")) { onCollapseAll() } + FilterableTreeView( + rootNode: rootNode, + searchText: $searchText, + fullValueModeName: String(localized: "Raw") + ) { node in + PhpTreeRowView(node: node) } } } @@ -219,15 +48,5 @@ private struct PhpTreeRowView: View { TypeBadge(node.nodeType.badgeLabel) } .padding(.vertical, 1) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - } - - private var accessibilityLabel: String { - let prefix = node.key ?? "" - let head = (node.displayValue as NSString).length > 120 - ? (node.displayValue as NSString).substring(to: 120) - : node.displayValue - return "\(prefix), \(node.nodeType.badgeLabel), \(head)" } } diff --git a/TableProTests/Views/Results/TreeFilterTests.swift b/TableProTests/Views/Results/TreeFilterTests.swift new file mode 100644 index 000000000..208e3a0a0 --- /dev/null +++ b/TableProTests/Views/Results/TreeFilterTests.swift @@ -0,0 +1,352 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("TreeFilter") +struct TreeFilterTests { + @Test("nested matches preserve identities and reveal their ancestors") + func nestedMatchesPreserveIdentitiesAndRevealAncestors() throws { + let root = try parse(#"{"account":{"profile":{"city":"needle"}}}"#) + let account = try #require(root.children.first) + let profile = account.children.first + let profileNode = try #require(profile) + + let projection = TreeFilter.projection(rootNode: root, searchText: "needle") + let visibleAccount = try #require(projection.nodes.first) + let visibleProfile = visibleAccount.children.first + let visibleProfileNode = try #require(visibleProfile) + + #expect(visibleAccount.id == account.id) + #expect(visibleProfileNode.id == profileNode.id) + #expect(projection.autoRevealedKeyPaths.contains(account.keyPath)) + #expect(projection.autoRevealedKeyPaths.contains(profileNode.keyPath)) + } + + @Test("repeating a filter keeps every visible node identity stable") + func repeatingFilterKeepsEveryVisibleNodeIdentityStable() throws { + let root = try parse(#"{"outer":{"inner":{"value":"needle"}}}"#) + let first = visibleIDs(in: TreeFilter.projection(rootNode: root, searchText: "needle").nodes) + _ = TreeFilter.projection(rootNode: root, searchText: "need") + let again = visibleIDs(in: TreeFilter.projection(rootNode: root, searchText: "needle").nodes) + + #expect(first == again) + } + + @Test("a container matched by key keeps its full contents expandable") + func containerMatchedByKeyKeepsFullContents() throws { + let root = try parse(#"{"user":{"address":{"city":"Paris","zip":"75001"}}}"#) + + let projection = TreeFilter.projection(rootNode: root, searchText: "address") + let user = try #require(projection.nodes.first) + let address = try #require(user.children.first) + + #expect(address.key == "address") + #expect(address.children.count == 2) + #expect(!projection.autoRevealedKeyPaths.contains(address.keyPath)) + } + + @Test("a key match that also matches a descendant keeps the siblings of that descendant") + func keyMatchWithDescendantMatchKeepsSiblings() throws { + let root = try parse(#"{"address":{"city":"Paris","zip":"75001"}}"#) + + let projection = TreeFilter.projection(rootNode: root, searchText: "s") + let address = try #require(projection.nodes.first) + let keys = address.children.compactMap(\.key).sorted() + + #expect(address.key == "address") + #expect(keys == ["city", "zip"]) + #expect(projection.autoRevealedKeyPaths.contains(address.keyPath)) + } + + @Test("a query matching nothing yields an empty projection") + func queryMatchingNothingYieldsEmptyProjection() throws { + let root = try parse(#"{"outer":{"inner":"value"}}"#) + + let projection = TreeFilter.projection(rootNode: root, searchText: "zzznomatch") + + #expect(projection.nodes.isEmpty) + #expect(projection.isFiltered) + #expect(projection.matchCount == 0) + } + + @Test("a whitespace-only query does not empty the tree") + func whitespaceOnlyQueryDoesNotEmptyTheTree() throws { + let root = try parse(#"{"outer":{"inner":"value"}}"#) + + let projection = TreeFilter.projection(rootNode: root, searchText: " ") + + #expect(projection.nodes.count == root.children.count) + #expect(!projection.isFiltered) + } + + @Test("matching is accent-insensitive") + func matchingIsAccentInsensitive() throws { + let root = try parse(#"{"name":"café"}"#) + + let projection = TreeFilter.projection(rootNode: root, searchText: "cafe") + + #expect(projection.nodes.count == 1) + } + + @Test("a value longer than the display cap is still searchable in full") + func longValueRemainsSearchableBeyondDisplayCap() throws { + let padding = String(repeating: "a", count: 400) + let root = try parse("{\"note\":\"\(padding)needle\"}") + let note = try #require(root.children.first) + + #expect((note.displayValue as NSString).length < 400) + + let projection = TreeFilter.projection(rootNode: root, searchText: "needle") + + #expect(projection.nodes.count == 1) + } + + @Test("a primitive root remains searchable") + func primitiveRootRemainsSearchable() throws { + let root = try parse("42") + + #expect(TreeFilter.projection(rootNode: root, searchText: "42").nodes.count == 1) + #expect(TreeFilter.projection(rootNode: root, searchText: "missing").nodes.isEmpty) + } + + @Test("filtering a near-limit tree keeps only the matching row") + func filteringNearLimitTreeKeepsOnlyMatchingRow() throws { + let entries = (0 ..< 4_900).map { "\"key\($0)\":\($0)" }.joined(separator: ",") + let root = try parse("{\(entries)}") + + let projection = TreeFilter.projection(rootNode: root, searchText: "key4899") + + #expect(root.children.count == 4_900) + #expect(projection.nodes.count == 1) + #expect(projection.nodes.first?.key == "key4899") + } + + @Test("a document past the node cap reports truncation") + func documentPastNodeCapReportsTruncation() throws { + let entries = (0 ..< 5_001).map { "\"key\($0)\":\($0)" }.joined(separator: ",") + let root = try parse("{\(entries)}") + + let info = TreeFilter.documentInfo(rootNode: root) + + #expect(info.isTruncated) + } + + @Test("a document inside the node cap reports no truncation") + func documentInsideNodeCapReportsNoTruncation() throws { + let root = try parse(#"{"outer":{"inner":"value"}}"#) + + #expect(!TreeFilter.documentInfo(rootNode: root).isTruncated) + } + + @Test("document info collects every container and defaults to the top level") + func documentInfoCollectsContainersAndTopLevelDefaults() throws { + let root = try parse(#"{"outer":{"inner":{"leaf":1}},"flat":2}"#) + let outer = try #require(root.children.first) + let inner = try #require(outer.children.first) + + let info = TreeFilter.documentInfo(rootNode: root) + + #expect(info.allContainerKeyPaths.contains(outer.keyPath)) + #expect(info.allContainerKeyPaths.contains(inner.keyPath)) + #expect(info.defaultExpandedKeyPaths == [outer.keyPath]) + } + + @Test("key paths survive a re-parse of the same document") + func keyPathsSurviveReparse() throws { + let json = #"{"outer":{"inner":{"leaf":1}}}"# + let first = try parse(json) + let second = try parse(json) + + let firstInfo = TreeFilter.documentInfo(rootNode: first) + let secondInfo = TreeFilter.documentInfo(rootNode: second) + + #expect(firstInfo.allContainerKeyPaths == secondInfo.allContainerKeyPaths) + #expect(first.children.first?.id != second.children.first?.id) + } + + @Test("the PHP tree filters through the same implementation") + func phpTreeFiltersThroughTheSameImplementation() throws { + let value = try #require(PhpSerializeParser.parse(#"a:1:{s:4:"name";s:5:"café";}"#)) + let root = PhpTreeBuilder.build(from: value) + + #expect(TreeFilter.projection(rootNode: root, searchText: "name").nodes.count == 1) + #expect(TreeFilter.projection(rootNode: root, searchText: "cafe").nodes.count == 1) + } + + @Test("a PHP string longer than the display cap is searchable in full") + func phpLongStringRemainsSearchable() throws { + let padding = String(repeating: "a", count: 200) + let payload = "\(padding)needle" + let serialized = "a:1:{s:4:\"note\";s:\(payload.utf8.count):\"\(payload)\";}" + let value = try #require(PhpSerializeParser.parse(serialized)) + let root = PhpTreeBuilder.build(from: value) + let note = try #require(root.children.first) + + #expect((note.displayValue as NSString).length < 200) + #expect(TreeFilter.projection(rootNode: root, searchText: "needle").nodes.count == 1) + } + + @Test("copying a JSON container yields the subtree, not its summary") + func copyingJsonContainerYieldsSubtree() throws { + let root = try parse(#"{"outer":{"a":1,"b":"two"}}"#) + let outer = try #require(root.children.first) + + #expect(outer.displayValue == "{2 keys}") + #expect(outer.copyableValue == #"{"a":1,"b":"two"}"#) + } + + @Test("copying a truncated JSON string yields the whole value") + func copyingTruncatedStringYieldsWholeValue() throws { + let padding = String(repeating: "a", count: 400) + let root = try parse("{\"note\":\"\(padding)\"}") + let note = try #require(root.children.first) + + #expect((note.copyableValue as NSString).length == 400) + } + + private func parse(_ json: String) throws -> JSONTreeNode { + try JSONTreeParser.parse(json).get() + } + + private func visibleIDs(in nodes: [JSONTreeNode]) -> [UUID] { + nodes.flatMap { node in [node.id] + visibleIDs(in: node.children) } + } +} + +@Suite("TreeProjectionCache") +@MainActor +struct TreeProjectionCacheTests { + @Test("repeated reads for the same document and query compute once") + func repeatedReadsComputeOnce() throws { + let root = try JSONTreeParser.parse(#"{"outer":{"inner":"needle"}}"#).get() + let cache = TreeProjectionCache() + + for _ in 0 ..< 10 { + _ = cache.projection(for: root, searchText: "needle") + _ = cache.documentInfo(for: root) + } + + #expect(cache.projectionComputations == 1) + #expect(cache.documentComputations == 1) + } + + @Test("a changed query recomputes the projection but not the document") + func changedQueryRecomputesProjectionOnly() throws { + let root = try JSONTreeParser.parse(#"{"outer":{"inner":"needle"}}"#).get() + let cache = TreeProjectionCache() + + _ = cache.documentInfo(for: root) + _ = cache.projection(for: root, searchText: "n") + _ = cache.projection(for: root, searchText: "ne") + _ = cache.documentInfo(for: root) + + #expect(cache.projectionComputations == 2) + #expect(cache.documentComputations == 1) + } + + @Test("a replaced document recomputes both") + func replacedDocumentRecomputesBoth() throws { + let first = try JSONTreeParser.parse(#"{"outer":{"inner":"needle"}}"#).get() + let second = try JSONTreeParser.parse(#"{"outer":{"inner":"needle"}}"#).get() + let cache = TreeProjectionCache() + + _ = cache.projection(for: first, searchText: "needle") + _ = cache.documentInfo(for: first) + _ = cache.projection(for: second, searchText: "needle") + _ = cache.documentInfo(for: second) + + #expect(cache.projectionComputations == 2) + #expect(cache.documentComputations == 2) + } +} + +@Suite("TreeDisclosureState") +struct TreeDisclosureStateTests { + private let auto: Set = ["$.match"] + private let defaults: Set = ["$.top"] + private let containers: Set = ["$.top", "$.match", "$.other"] + + @Test("top-level containers are expanded by default") + func topLevelContainersExpandByDefault() { + let state = TreeDisclosureState() + + #expect(isExpanded(state, "$.top", filtered: false)) + #expect(!isExpanded(state, "$.other", filtered: false)) + } + + @Test("a filter reveals matching ancestors without touching saved intent") + func filterRevealsMatchingAncestors() { + let state = TreeDisclosureState() + + #expect(isExpanded(state, "$.match", filtered: true)) + #expect(!isExpanded(state, "$.match", filtered: false)) + } + + @Test("collapse all before a search does not veto the search reveal") + func collapseAllBeforeSearchDoesNotVetoReveal() { + var state = TreeDisclosureState() + state.collapseAll(containerKeyPaths: containers, isFiltered: false) + + #expect(!isExpanded(state, "$.top", filtered: false)) + #expect(isExpanded(state, "$.match", filtered: true)) + } + + @Test("a collapse made during a search survives the next keystroke") + func collapseDuringSearchSurvivesNextKeystroke() { + var state = TreeDisclosureState() + state.setExpanded(false, keyPath: "$.match", isFiltered: true) + + #expect(!isExpanded(state, "$.match", filtered: true)) + } + + @Test("clearing the filter restores the pre-search layout") + func clearingFilterRestoresPreSearchLayout() { + var state = TreeDisclosureState() + state.setExpanded(true, keyPath: "$.other", isFiltered: false) + state.expandAll(containerKeyPaths: containers, isFiltered: true) + state.endFiltering() + + #expect(isExpanded(state, "$.other", filtered: false)) + #expect(!isExpanded(state, "$.match", filtered: false)) + } + + @Test("expand all outside a search persists across filtering") + func expandAllOutsideSearchPersists() { + var state = TreeDisclosureState() + state.expandAll(containerKeyPaths: containers, isFiltered: false) + state.endFiltering() + + #expect(isExpanded(state, "$.other", filtered: false)) + #expect(isExpanded(state, "$.match", filtered: false)) + } + + @Test("an expansion made during a search does not leak into saved intent") + func inSearchExpansionDoesNotLeak() { + var state = TreeDisclosureState() + state.setExpanded(true, keyPath: "$.other", isFiltered: true) + + #expect(isExpanded(state, "$.other", filtered: true)) + + state.endFiltering() + + #expect(!isExpanded(state, "$.other", filtered: false)) + } + + @Test("a collapse outside a search persists") + func collapseOutsideSearchPersists() { + var state = TreeDisclosureState() + state.setExpanded(false, keyPath: "$.top", isFiltered: false) + + #expect(!isExpanded(state, "$.top", filtered: false)) + } + + private func isExpanded(_ state: TreeDisclosureState, _ keyPath: String, filtered: Bool) -> Bool { + state.isExpanded( + keyPath, + autoRevealedKeyPaths: auto, + defaultExpandedKeyPaths: defaults, + isFiltered: filtered + ) + } +} diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index c7bfc5992..61742d2a5 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -30,14 +30,25 @@ TablePro also auto-detects two cases: `BINARY(16)` columns with id-like names an The viewer opens as a popover anchored to the cell, with two modes: - **Text**: syntax-highlighted JSON, pretty-printed. Editable when the cell is editable. -- **Tree**: collapsible tree with a search field. Read-only navigation. +- **Tree**: collapsible tree with a search field. Read-only navigation. See [Filtering a tree](#filtering-a-tree). JSON editor popover JSON editor popover -Both modes keep your original key order and exact number values, including integers larger than JavaScript can represent. Pretty-printing caps at 500 KB; larger values show as stored. Tree mode handles documents up to 100 KB and 5,000 nodes; beyond that it shows a "JSON Too Large" placeholder and you read the value in Text mode. +Both modes keep your original key order and exact number values, including integers larger than JavaScript can represent. Pretty-printing caps at 500 KB; larger values show as stored. Tree mode parses documents up to 100 KB, and above that shows a "JSON Too Large" placeholder so you read the value in Text mode. A document that parses but runs past 5,000 nodes loads only the first 5,000 and marks the cut with a `…` row; filtering then says so, because the rows past the cut were never searched. + +### Filtering a tree + +The search field above the tree filters keys and values as you type. Both the JSON tree and the [PHP serialized tree](#php-serialized-viewer) work the same way. + +- Matching ignores case and accents, so `cafe` finds `café`. It does not ignore full-width and half-width differences: `ABC` does not find `ABC`. +- A row matches on its whole value, not the shortened form shown in the row, so a match deep inside a long string still finds it. +- Matches nested under collapsed parents open on their own, so you see the match rather than the parent hiding it. +- A key that matches keeps its full contents and stays expandable, so you can search for a key and then read what is under it. +- Rows you open or close while filtering stay that way for as long as the field has text. Clearing the field restores the layout you had before you started, which is why **Expand All** during a filter applies to the filtered view rather than to the whole document. +- A filter that matches nothing says so. If the document was cut off at 5,000 nodes, it says that too instead of claiming the value does not contain what you typed. The mode you switch to becomes the default for the next open. The same preference lives in **Settings > Data > JSON Viewer > Default view** (`Cmd+,`). @@ -51,7 +62,7 @@ Opening a value and seeing it pretty-printed is not an edit. The row is marked c Set **Display As > PHP Serialized** on a text column, then double-click or `Enter`. The viewer is read-only: PHP serialized values round-trip through PHP itself, so TablePro does not write them back. -- **Tree**: collapsible tree with search. Nodes show type badges (`str`, `int`, `arr`, `obj`, `ser`, `ref`). Protected object members show a `protected` badge; private members show `private (ClassName)`. Custom-serialized classes (the `C:` token) appear as a single opaque leaf; references (`r:`/`R:`) show as `→ #N` and are not followed. +- **Tree**: collapsible tree with search, filtered the same way as the JSON tree. See [Filtering a tree](#filtering-a-tree). Nodes show type badges (`str`, `int`, `arr`, `obj`, `ser`, `ref`). Protected object members show a `protected` badge; private members show `private (ClassName)`. Custom-serialized classes (the `C:` token) appear as a single opaque leaf; references (`r:`/`R:`) show as `→ #N` and are not followed. - **Raw**: the original serialized string with text selection. Values above 5 MB are not parsed; the tree shows a placeholder and Raw mode still works. Trees cap at 5,000 nodes and 256 nesting levels, with a truncation marker beyond that. The toolbar pop-out button opens the value in its own window.