From 6203986ced16ba118cc86c9289d07a41a3442fe8 Mon Sep 17 00:00:00 2001 From: sophiathedev Date: Tue, 18 Aug 2026 22:05:07 +0700 Subject: [PATCH 1/2] fix(json): stabilize tree filtering --- CHANGELOG.md | 4 + TablePro/Models/UI/JSONTreeNode.swift | 20 ++- TablePro/Models/UI/JSONTreeViewState.swift | 142 ++++++++++++++++++ TablePro/Views/Results/JSONTreeView.swift | 107 +++---------- .../Results/JSONTreeViewStateTests.swift | 129 ++++++++++++++++ docs/features/json-viewer.mdx | 2 +- 6 files changed, 318 insertions(+), 86 deletions(-) create mode 100644 TablePro/Models/UI/JSONTreeViewState.swift create mode 100644 TableProTests/Views/Results/JSONTreeViewStateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a9c2aa4..999da1502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Filtering a JSON tree now reveals nested key and value matches instead of leaving them behind collapsed parent rows. The filtered tree keeps stable row identities and is built once per search change rather than again on every redraw. + ## [0.66.0] - 2026-08-19 ### Added diff --git a/TablePro/Models/UI/JSONTreeNode.swift b/TablePro/Models/UI/JSONTreeNode.swift index 26ec42142..083e2a754 100644 --- a/TablePro/Models/UI/JSONTreeNode.swift +++ b/TablePro/Models/UI/JSONTreeNode.swift @@ -36,7 +36,7 @@ internal enum JSONValueType { } internal struct JSONTreeNode: Identifiable { - let id = UUID() + let id: UUID let key: String? let keyPath: String let valueType: JSONValueType @@ -44,6 +44,24 @@ 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 } diff --git a/TablePro/Models/UI/JSONTreeViewState.swift b/TablePro/Models/UI/JSONTreeViewState.swift new file mode 100644 index 000000000..480379df7 --- /dev/null +++ b/TablePro/Models/UI/JSONTreeViewState.swift @@ -0,0 +1,142 @@ +import Foundation + +internal struct JSONTreeViewState { + internal private(set) var visibleNodes: [JSONTreeNode] + internal var expandedNodeIDs: Set + internal private(set) var searchText: String + + private var rootNode: JSONTreeNode + private var expandedNodeIDsBeforeSearch: Set? + + internal init(rootNode: JSONTreeNode, searchText: String) { + let projection = Self.projection(rootNode: rootNode, searchText: searchText) + let defaultExpandedNodeIDs = Self.defaultExpandedNodeIDs(rootNode: rootNode) + + self.rootNode = rootNode + self.searchText = searchText + self.visibleNodes = projection.nodes + if searchText.isEmpty { + self.expandedNodeIDs = defaultExpandedNodeIDs + self.expandedNodeIDsBeforeSearch = nil + } else { + self.expandedNodeIDs = defaultExpandedNodeIDs.union(projection.containerIDs) + self.expandedNodeIDsBeforeSearch = defaultExpandedNodeIDs + } + } + + internal mutating func update(searchText: String) { + guard searchText != self.searchText else { return } + + let wasSearching = !self.searchText.isEmpty + let isSearching = !searchText.isEmpty + if !wasSearching && isSearching { + expandedNodeIDsBeforeSearch = expandedNodeIDs + } + + let projection = Self.projection(rootNode: rootNode, searchText: searchText) + visibleNodes = projection.nodes + if isSearching { + let priorExpandedNodeIDs = expandedNodeIDsBeforeSearch ?? expandedNodeIDs + expandedNodeIDs = priorExpandedNodeIDs.union(projection.containerIDs) + } else { + expandedNodeIDs = expandedNodeIDsBeforeSearch ?? Self.defaultExpandedNodeIDs(rootNode: rootNode) + expandedNodeIDsBeforeSearch = nil + } + self.searchText = searchText + } + + internal mutating func update(rootNode: JSONTreeNode) { + self.rootNode = rootNode + + let projection = Self.projection(rootNode: rootNode, searchText: searchText) + let defaultExpandedNodeIDs = Self.defaultExpandedNodeIDs(rootNode: rootNode) + visibleNodes = projection.nodes + if searchText.isEmpty { + expandedNodeIDs = defaultExpandedNodeIDs + expandedNodeIDsBeforeSearch = nil + } else { + expandedNodeIDs = defaultExpandedNodeIDs.union(projection.containerIDs) + expandedNodeIDsBeforeSearch = defaultExpandedNodeIDs + } + } + + internal mutating func expandAll() { + expandedNodeIDs = Self.allContainerIDs(rootNode: rootNode) + } + + internal mutating func collapseAll() { + expandedNodeIDs.removeAll() + } + + private struct Projection { + let nodes: [JSONTreeNode] + let containerIDs: Set + } + + private static func projection(rootNode: JSONTreeNode, searchText: String) -> Projection { + let nodes = rootNode.children.isEmpty ? [rootNode] : rootNode.children + guard !searchText.isEmpty else { + return Projection(nodes: nodes, containerIDs: []) + } + + var containerIDs: Set = [] + let filteredNodes = filteredNodes(nodes, matching: searchText, containerIDs: &containerIDs) + return Projection(nodes: filteredNodes, containerIDs: containerIDs) + } + + private static func filteredNodes( + _ nodes: [JSONTreeNode], + matching searchText: String, + containerIDs: inout Set + ) -> [JSONTreeNode] { + nodes.compactMap { node in + let filteredChildren = filteredNodes( + node.children, + matching: searchText, + containerIDs: &containerIDs + ) + + if !filteredChildren.isEmpty { + containerIDs.insert(node.id) + return projectedNode(node, children: filteredChildren) + } + + let keyMatches = node.key?.localizedStandardContains(searchText) == true + let valueMatches = node.displayValue.localizedStandardContains(searchText) + guard keyMatches || valueMatches else { return nil } + return projectedNode(node, children: []) + } + } + + private static func projectedNode(_ node: JSONTreeNode, children: [JSONTreeNode]) -> JSONTreeNode { + JSONTreeNode( + id: node.id, + key: node.key, + keyPath: node.keyPath, + valueType: node.valueType, + displayValue: node.displayValue, + rawValue: node.rawValue, + children: children + ) + } + + private static func defaultExpandedNodeIDs(rootNode: JSONTreeNode) -> Set { + Set(rootNode.children.compactMap { node in + node.children.isEmpty ? nil : node.id + }) + } + + private static func allContainerIDs(rootNode: JSONTreeNode) -> Set { + var ids: Set = [] + collectContainerIDs(rootNode, into: &ids) + return ids + } + + private static func collectContainerIDs(_ node: JSONTreeNode, into ids: inout Set) { + guard !node.children.isEmpty else { return } + ids.insert(node.id) + for child in node.children { + collectContainerIDs(child, into: &ids) + } + } +} diff --git a/TablePro/Views/Results/JSONTreeView.swift b/TablePro/Views/Results/JSONTreeView.swift index fdff1d971..5538b7610 100644 --- a/TablePro/Views/Results/JSONTreeView.swift +++ b/TablePro/Views/Results/JSONTreeView.swift @@ -9,7 +9,15 @@ internal struct JSONTreeView: View { let rootNode: JSONTreeNode @Binding var searchText: String - @State private var expandedNodeIDs: Set = [] + @State private var state: JSONTreeViewState + + init(rootNode: JSONTreeNode, searchText: Binding) { + self.rootNode = rootNode + self._searchText = searchText + self._state = State( + initialValue: JSONTreeViewState(rootNode: rootNode, searchText: searchText.wrappedValue) + ) + } var body: some View { VStack(spacing: 0) { @@ -17,16 +25,16 @@ internal struct JSONTreeView: View { Divider() List { JSONTreeContentView( - nodes: filteredRootNodes, - expandedNodeIDs: $expandedNodeIDs, + nodes: state.visibleNodes, + expandedNodeIDs: $state.expandedNodeIDs, onExpandAll: expandAll, onCollapseAll: collapseAll ) } .listStyle(.inset(alternatesRowBackgrounds: true)) } - .onAppear { expandRootLevel() } - .onChange(of: searchText) { expandMatchingNodes() } + .onChange(of: rootNode.id) { _, _ in state.update(rootNode: rootNode) } + .onChange(of: searchText) { _, newValue in state.update(searchText: newValue) } } // MARK: - Toolbar @@ -38,96 +46,27 @@ internal struct JSONTreeView: View { 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")) + Button(String(localized: "Expand All"), systemImage: "rectangle.expand.vertical", action: expandAll) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help(String(localized: "Expand All")) + Button(String(localized: "Collapse All"), systemImage: "rectangle.compress.vertical", action: collapseAll) + .labelStyle(.iconOnly) + .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) } + withAnimation(nil) { state.expandAll() } } 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 + withAnimation(nil) { state.collapseAll() } } } diff --git a/TableProTests/Views/Results/JSONTreeViewStateTests.swift b/TableProTests/Views/Results/JSONTreeViewStateTests.swift new file mode 100644 index 000000000..b01edddcb --- /dev/null +++ b/TableProTests/Views/Results/JSONTreeViewStateTests.swift @@ -0,0 +1,129 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("JSONTreeViewState") +struct JSONTreeViewStateTests { + @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 = try #require(account.children.first) + let city = try #require(profile.children.first) + + let state = JSONTreeViewState(rootNode: root, searchText: "needle") + let visibleAccount = try #require(state.visibleNodes.first) + let visibleProfile = try #require(visibleAccount.children.first) + let visibleCity = try #require(visibleProfile.children.first) + + #expect(visibleAccount.id == account.id) + #expect(visibleProfile.id == profile.id) + #expect(visibleCity.id == city.id) + #expect(state.expandedNodeIDs.contains(account.id)) + #expect(state.expandedNodeIDs.contains(profile.id)) + } + + @Test("repeating a filter keeps every visible node identity stable") + func repeatingFilterKeepsEveryVisibleNodeIdentityStable() throws { + let root = try parse(#"{"outer":{"inner":{"value":"needle"}}}"#) + var state = JSONTreeViewState(rootNode: root, searchText: "needle") + let firstIDs = visibleIDs(in: state.visibleNodes) + + state.update(searchText: "need") + state.update(searchText: "needle") + + #expect(visibleIDs(in: state.visibleNodes) == firstIDs) + } + + @Test("clearing a filter restores the disclosure state from before search") + func clearingFilterRestoresPreviousDisclosureState() throws { + let root = try parse(#"{"outer":{"inner":{"value":"needle"}}}"#) + let outer = try #require(root.children.first) + let inner = try #require(outer.children.first) + var state = JSONTreeViewState(rootNode: root, searchText: "") + + state.collapseAll() + state.update(searchText: "needle") + + #expect(state.expandedNodeIDs.contains(outer.id)) + #expect(state.expandedNodeIDs.contains(inner.id)) + + state.update(searchText: "") + + #expect(state.expandedNodeIDs.isEmpty) + } + + @Test("replacing the root discards identities and disclosure state from the old tree") + func replacingRootDiscardsOldTreeState() throws { + let originalRoot = try parse(#"{"outer":{"value":"needle"}}"#) + let replacementRoot = try parse(#"{"outer":{"value":"needle"}}"#) + let oldOuter = try #require(originalRoot.children.first) + let newOuter = try #require(replacementRoot.children.first) + var state = JSONTreeViewState(rootNode: originalRoot, searchText: "needle") + + state.update(rootNode: replacementRoot) + + let visibleOuter = try #require(state.visibleNodes.first) + #expect(visibleOuter.id == newOuter.id) + #expect(visibleOuter.id != oldOuter.id) + #expect(!state.expandedNodeIDs.contains(oldOuter.id)) + #expect(state.expandedNodeIDs.contains(newOuter.id)) + } + + @Test("a matching container does not expose unrelated descendants") + func matchingContainerDoesNotExposeUnrelatedDescendants() throws { + let root = try parse(#"{"matching-container":{"unrelated":"value"}}"#) + let container = try #require(root.children.first) + + let state = JSONTreeViewState(rootNode: root, searchText: "matching") + let visibleContainer = try #require(state.visibleNodes.first) + + #expect(visibleContainer.id == container.id) + #expect(visibleContainer.children.isEmpty) + } + + @Test("a primitive root remains searchable") + func primitiveRootRemainsSearchable() throws { + let root = try parse("42") + let matchingState = JSONTreeViewState(rootNode: root, searchText: "42") + let missingState = JSONTreeViewState(rootNode: root, searchText: "missing") + + #expect(matchingState.visibleNodes.first?.id == root.id) + #expect(missingState.visibleNodes.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 state = JSONTreeViewState(rootNode: root, searchText: "key4899") + + #expect(root.children.count == 4_900) + #expect(state.visibleNodes.count == 1) + #expect(state.visibleNodes.first?.key == "key4899") + } + + 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) + } + } +} diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index c7bfc5992..3cf470ac4 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -30,7 +30,7 @@ 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. Search matches keys and values, opens each matching path, and restores your previous expansion state when cleared. Read-only navigation. JSON editor popover From db7d69d4b2f6ce0e23153a94020d5ecdb9a91c93 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 19 Aug 2026 10:21:09 +0700 Subject: [PATCH 2/2] fix(datagrid): unify and correct JSON and PHP tree filtering --- CHANGELOG.md | 12 +- TablePro/Models/UI/FilterableTreeNode.swift | 32 ++ TablePro/Models/UI/JSONTreeNode.swift | 79 +++- TablePro/Models/UI/JSONTreeViewState.swift | 142 ------- TablePro/Models/UI/PhpTreeNode.swift | 33 +- TablePro/Models/UI/TreeDisclosureState.swift | 79 ++++ TablePro/Models/UI/TreeFilter.swift | 134 +++++++ TablePro/Models/UI/TreeProjectionCache.swift | 40 ++ .../Views/Results/FilterableTreeView.swift | 245 ++++++++++++ TablePro/Views/Results/JSONTreeView.swift | 121 +----- TablePro/Views/Results/PhpTreeView.swift | 193 +--------- .../Results/JSONTreeViewStateTests.swift | 129 ------- .../Views/Results/TreeFilterTests.swift | 352 ++++++++++++++++++ docs/features/json-viewer.mdx | 17 +- 14 files changed, 1027 insertions(+), 581 deletions(-) create mode 100644 TablePro/Models/UI/FilterableTreeNode.swift delete mode 100644 TablePro/Models/UI/JSONTreeViewState.swift create mode 100644 TablePro/Models/UI/TreeDisclosureState.swift create mode 100644 TablePro/Models/UI/TreeFilter.swift create mode 100644 TablePro/Models/UI/TreeProjectionCache.swift create mode 100644 TablePro/Views/Results/FilterableTreeView.swift delete mode 100644 TableProTests/Views/Results/JSONTreeViewStateTests.swift create mode 100644 TableProTests/Views/Results/TreeFilterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 999da1502..75a795533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- 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 tree now reveals nested key and value matches instead of leaving them behind collapsed parent rows. The filtered tree keeps stable row identities and is built once per search change rather than again on every redraw. +- 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 083e2a754..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,6 +33,7 @@ internal enum JSONValueType { case .string: return .systemRed case .number: return .systemPurple case .boolean, .null: return .systemOrange + case .truncated: return .secondaryLabelColor } } } @@ -67,13 +70,85 @@ internal struct JSONTreeNode: Identifiable { } } +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 @@ -160,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/JSONTreeViewState.swift b/TablePro/Models/UI/JSONTreeViewState.swift deleted file mode 100644 index 480379df7..000000000 --- a/TablePro/Models/UI/JSONTreeViewState.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation - -internal struct JSONTreeViewState { - internal private(set) var visibleNodes: [JSONTreeNode] - internal var expandedNodeIDs: Set - internal private(set) var searchText: String - - private var rootNode: JSONTreeNode - private var expandedNodeIDsBeforeSearch: Set? - - internal init(rootNode: JSONTreeNode, searchText: String) { - let projection = Self.projection(rootNode: rootNode, searchText: searchText) - let defaultExpandedNodeIDs = Self.defaultExpandedNodeIDs(rootNode: rootNode) - - self.rootNode = rootNode - self.searchText = searchText - self.visibleNodes = projection.nodes - if searchText.isEmpty { - self.expandedNodeIDs = defaultExpandedNodeIDs - self.expandedNodeIDsBeforeSearch = nil - } else { - self.expandedNodeIDs = defaultExpandedNodeIDs.union(projection.containerIDs) - self.expandedNodeIDsBeforeSearch = defaultExpandedNodeIDs - } - } - - internal mutating func update(searchText: String) { - guard searchText != self.searchText else { return } - - let wasSearching = !self.searchText.isEmpty - let isSearching = !searchText.isEmpty - if !wasSearching && isSearching { - expandedNodeIDsBeforeSearch = expandedNodeIDs - } - - let projection = Self.projection(rootNode: rootNode, searchText: searchText) - visibleNodes = projection.nodes - if isSearching { - let priorExpandedNodeIDs = expandedNodeIDsBeforeSearch ?? expandedNodeIDs - expandedNodeIDs = priorExpandedNodeIDs.union(projection.containerIDs) - } else { - expandedNodeIDs = expandedNodeIDsBeforeSearch ?? Self.defaultExpandedNodeIDs(rootNode: rootNode) - expandedNodeIDsBeforeSearch = nil - } - self.searchText = searchText - } - - internal mutating func update(rootNode: JSONTreeNode) { - self.rootNode = rootNode - - let projection = Self.projection(rootNode: rootNode, searchText: searchText) - let defaultExpandedNodeIDs = Self.defaultExpandedNodeIDs(rootNode: rootNode) - visibleNodes = projection.nodes - if searchText.isEmpty { - expandedNodeIDs = defaultExpandedNodeIDs - expandedNodeIDsBeforeSearch = nil - } else { - expandedNodeIDs = defaultExpandedNodeIDs.union(projection.containerIDs) - expandedNodeIDsBeforeSearch = defaultExpandedNodeIDs - } - } - - internal mutating func expandAll() { - expandedNodeIDs = Self.allContainerIDs(rootNode: rootNode) - } - - internal mutating func collapseAll() { - expandedNodeIDs.removeAll() - } - - private struct Projection { - let nodes: [JSONTreeNode] - let containerIDs: Set - } - - private static func projection(rootNode: JSONTreeNode, searchText: String) -> Projection { - let nodes = rootNode.children.isEmpty ? [rootNode] : rootNode.children - guard !searchText.isEmpty else { - return Projection(nodes: nodes, containerIDs: []) - } - - var containerIDs: Set = [] - let filteredNodes = filteredNodes(nodes, matching: searchText, containerIDs: &containerIDs) - return Projection(nodes: filteredNodes, containerIDs: containerIDs) - } - - private static func filteredNodes( - _ nodes: [JSONTreeNode], - matching searchText: String, - containerIDs: inout Set - ) -> [JSONTreeNode] { - nodes.compactMap { node in - let filteredChildren = filteredNodes( - node.children, - matching: searchText, - containerIDs: &containerIDs - ) - - if !filteredChildren.isEmpty { - containerIDs.insert(node.id) - return projectedNode(node, children: filteredChildren) - } - - let keyMatches = node.key?.localizedStandardContains(searchText) == true - let valueMatches = node.displayValue.localizedStandardContains(searchText) - guard keyMatches || valueMatches else { return nil } - return projectedNode(node, children: []) - } - } - - private static func projectedNode(_ node: JSONTreeNode, children: [JSONTreeNode]) -> JSONTreeNode { - JSONTreeNode( - id: node.id, - key: node.key, - keyPath: node.keyPath, - valueType: node.valueType, - displayValue: node.displayValue, - rawValue: node.rawValue, - children: children - ) - } - - private static func defaultExpandedNodeIDs(rootNode: JSONTreeNode) -> Set { - Set(rootNode.children.compactMap { node in - node.children.isEmpty ? nil : node.id - }) - } - - private static func allContainerIDs(rootNode: JSONTreeNode) -> Set { - var ids: Set = [] - collectContainerIDs(rootNode, into: &ids) - return ids - } - - private static func collectContainerIDs(_ node: JSONTreeNode, into ids: inout Set) { - guard !node.children.isEmpty else { return } - ids.insert(node.id) - for child in node.children { - collectContainerIDs(child, into: &ids) - } - } -} 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 5538b7610..08e1bb5f3 100644 --- a/TablePro/Views/Results/JSONTreeView.swift +++ b/TablePro/Views/Results/JSONTreeView.swift @@ -9,122 +9,13 @@ internal struct JSONTreeView: View { let rootNode: JSONTreeNode @Binding var searchText: String - @State private var state: JSONTreeViewState - - init(rootNode: JSONTreeNode, searchText: Binding) { - self.rootNode = rootNode - self._searchText = searchText - self._state = State( - initialValue: JSONTreeViewState(rootNode: rootNode, searchText: searchText.wrappedValue) - ) - } - var body: some View { - VStack(spacing: 0) { - treeToolbar - Divider() - List { - JSONTreeContentView( - nodes: state.visibleNodes, - expandedNodeIDs: $state.expandedNodeIDs, - onExpandAll: expandAll, - onCollapseAll: collapseAll - ) - } - .listStyle(.inset(alternatesRowBackgrounds: true)) - } - .onChange(of: rootNode.id) { _, _ in state.update(rootNode: rootNode) } - .onChange(of: searchText) { _, newValue in state.update(searchText: newValue) } - } - - // MARK: - Toolbar - - private var treeToolbar: some View { - HStack(spacing: 6) { - NativeSearchField( - text: $searchText, - placeholder: String(localized: "Filter keys or values..."), - controlSize: .small - ) - Button(String(localized: "Expand All"), systemImage: "rectangle.expand.vertical", action: expandAll) - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .help(String(localized: "Expand All")) - Button(String(localized: "Collapse All"), systemImage: "rectangle.compress.vertical", action: collapseAll) - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .help(String(localized: "Collapse All")) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - - // MARK: - Actions - - private func expandAll() { - withAnimation(nil) { state.expandAll() } - } - - private func collapseAll() { - withAnimation(nil) { state.collapseAll() } - } -} - -// 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/JSONTreeViewStateTests.swift b/TableProTests/Views/Results/JSONTreeViewStateTests.swift deleted file mode 100644 index b01edddcb..000000000 --- a/TableProTests/Views/Results/JSONTreeViewStateTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation -import Testing - -@testable import TablePro - -@Suite("JSONTreeViewState") -struct JSONTreeViewStateTests { - @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 = try #require(account.children.first) - let city = try #require(profile.children.first) - - let state = JSONTreeViewState(rootNode: root, searchText: "needle") - let visibleAccount = try #require(state.visibleNodes.first) - let visibleProfile = try #require(visibleAccount.children.first) - let visibleCity = try #require(visibleProfile.children.first) - - #expect(visibleAccount.id == account.id) - #expect(visibleProfile.id == profile.id) - #expect(visibleCity.id == city.id) - #expect(state.expandedNodeIDs.contains(account.id)) - #expect(state.expandedNodeIDs.contains(profile.id)) - } - - @Test("repeating a filter keeps every visible node identity stable") - func repeatingFilterKeepsEveryVisibleNodeIdentityStable() throws { - let root = try parse(#"{"outer":{"inner":{"value":"needle"}}}"#) - var state = JSONTreeViewState(rootNode: root, searchText: "needle") - let firstIDs = visibleIDs(in: state.visibleNodes) - - state.update(searchText: "need") - state.update(searchText: "needle") - - #expect(visibleIDs(in: state.visibleNodes) == firstIDs) - } - - @Test("clearing a filter restores the disclosure state from before search") - func clearingFilterRestoresPreviousDisclosureState() throws { - let root = try parse(#"{"outer":{"inner":{"value":"needle"}}}"#) - let outer = try #require(root.children.first) - let inner = try #require(outer.children.first) - var state = JSONTreeViewState(rootNode: root, searchText: "") - - state.collapseAll() - state.update(searchText: "needle") - - #expect(state.expandedNodeIDs.contains(outer.id)) - #expect(state.expandedNodeIDs.contains(inner.id)) - - state.update(searchText: "") - - #expect(state.expandedNodeIDs.isEmpty) - } - - @Test("replacing the root discards identities and disclosure state from the old tree") - func replacingRootDiscardsOldTreeState() throws { - let originalRoot = try parse(#"{"outer":{"value":"needle"}}"#) - let replacementRoot = try parse(#"{"outer":{"value":"needle"}}"#) - let oldOuter = try #require(originalRoot.children.first) - let newOuter = try #require(replacementRoot.children.first) - var state = JSONTreeViewState(rootNode: originalRoot, searchText: "needle") - - state.update(rootNode: replacementRoot) - - let visibleOuter = try #require(state.visibleNodes.first) - #expect(visibleOuter.id == newOuter.id) - #expect(visibleOuter.id != oldOuter.id) - #expect(!state.expandedNodeIDs.contains(oldOuter.id)) - #expect(state.expandedNodeIDs.contains(newOuter.id)) - } - - @Test("a matching container does not expose unrelated descendants") - func matchingContainerDoesNotExposeUnrelatedDescendants() throws { - let root = try parse(#"{"matching-container":{"unrelated":"value"}}"#) - let container = try #require(root.children.first) - - let state = JSONTreeViewState(rootNode: root, searchText: "matching") - let visibleContainer = try #require(state.visibleNodes.first) - - #expect(visibleContainer.id == container.id) - #expect(visibleContainer.children.isEmpty) - } - - @Test("a primitive root remains searchable") - func primitiveRootRemainsSearchable() throws { - let root = try parse("42") - let matchingState = JSONTreeViewState(rootNode: root, searchText: "42") - let missingState = JSONTreeViewState(rootNode: root, searchText: "missing") - - #expect(matchingState.visibleNodes.first?.id == root.id) - #expect(missingState.visibleNodes.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 state = JSONTreeViewState(rootNode: root, searchText: "key4899") - - #expect(root.children.count == 4_900) - #expect(state.visibleNodes.count == 1) - #expect(state.visibleNodes.first?.key == "key4899") - } - - 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) - } - } -} 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 3cf470ac4..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. Search matches keys and values, opens each matching path, and restores your previous expansion state when cleared. 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.