Skip to content

Commit 2135b67

Browse files
authored
feat: JSON viewer with Text/Tree toggle for query results (#826)
* feat: JSON viewer with Text/Tree toggle for query results (#752) * docs: trim JSON viewer docs * fix: JSON viewer bugs — NULL data loss, tree expand, undo, display issues * fix: avoid spurious DB writes on JSON key reorder, consistent search filter * fix: remove redundant frame from JSON popover content, let NSPopover.contentSize be authority * fix: adaptive JSON viewer popover height based on content
1 parent 9dd0eb4 commit 2135b67

17 files changed

Lines changed: 772 additions & 80 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- JSON viewer with Text/Tree toggle for query results — tree view with expand/collapse, search, copy key path
1213
- MCP server: built-in Model Context Protocol server lets AI tools (Claude Desktop, Claude Code, Cursor) browse schemas, run queries, and export data through TablePro's connections
1314
- MCP server: connected clients list in Settings and status menu item showing server state
1415
- Import connections from TablePlus, Sequel Ace, and DBeaver with one-click migration

TablePro/Models/Settings/EditorSettings.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ internal enum EditorFontResolver {
6161
}
6262
}
6363

64+
internal enum JSONViewMode: String, Codable, CaseIterable {
65+
case text
66+
case tree
67+
}
68+
6469
/// Editor settings
6570
struct EditorSettings: Codable, Equatable {
6671
var showLineNumbers: Bool
@@ -69,14 +74,16 @@ struct EditorSettings: Codable, Equatable {
6974
var wordWrap: Bool
7075
var vimModeEnabled: Bool
7176
var uppercaseKeywords: Bool
77+
var jsonViewerPreferredMode: JSONViewMode
7278

7379
static let `default` = EditorSettings(
7480
showLineNumbers: true,
7581
highlightCurrentLine: true,
7682
tabWidth: 4,
7783
wordWrap: false,
7884
vimModeEnabled: false,
79-
uppercaseKeywords: false
85+
uppercaseKeywords: false,
86+
jsonViewerPreferredMode: .text
8087
)
8188

8289
init(
@@ -85,25 +92,27 @@ struct EditorSettings: Codable, Equatable {
8592
tabWidth: Int = 4,
8693
wordWrap: Bool = false,
8794
vimModeEnabled: Bool = false,
88-
uppercaseKeywords: Bool = false
95+
uppercaseKeywords: Bool = false,
96+
jsonViewerPreferredMode: JSONViewMode = .text
8997
) {
9098
self.showLineNumbers = showLineNumbers
9199
self.highlightCurrentLine = highlightCurrentLine
92100
self.tabWidth = tabWidth
93101
self.wordWrap = wordWrap
94102
self.vimModeEnabled = vimModeEnabled
95103
self.uppercaseKeywords = uppercaseKeywords
104+
self.jsonViewerPreferredMode = jsonViewerPreferredMode
96105
}
97106

98107
init(from decoder: Decoder) throws {
99108
let container = try decoder.container(keyedBy: CodingKeys.self)
100-
// Old fontFamily/fontSize keys are ignored (moved to ThemeFonts)
101109
showLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .showLineNumbers) ?? true
102110
highlightCurrentLine = try container.decodeIfPresent(Bool.self, forKey: .highlightCurrentLine) ?? true
103111
tabWidth = try container.decodeIfPresent(Int.self, forKey: .tabWidth) ?? 4
104112
wordWrap = try container.decodeIfPresent(Bool.self, forKey: .wordWrap) ?? false
105113
vimModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .vimModeEnabled) ?? false
106114
uppercaseKeywords = try container.decodeIfPresent(Bool.self, forKey: .uppercaseKeywords) ?? false
115+
jsonViewerPreferredMode = try container.decodeIfPresent(JSONViewMode.self, forKey: .jsonViewerPreferredMode) ?? .text
107116
}
108117

109118
/// Clamped tab width (1-16)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//
2+
// JSONTreeNode.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
import Foundation
8+
9+
internal enum JSONValueType {
10+
case object
11+
case array
12+
case string
13+
case number
14+
case boolean
15+
case null
16+
17+
var badgeLabel: String {
18+
switch self {
19+
case .object: return "obj"
20+
case .array: return "arr"
21+
case .string: return "str"
22+
case .number: return "num"
23+
case .boolean: return "bool"
24+
case .null: return "null"
25+
}
26+
}
27+
28+
var color: NSColor {
29+
switch self {
30+
case .object, .array: return .systemBlue
31+
case .string: return .systemRed
32+
case .number: return .systemPurple
33+
case .boolean, .null: return .systemOrange
34+
}
35+
}
36+
}
37+
38+
internal struct JSONTreeNode: Identifiable {
39+
let id = UUID()
40+
let key: String?
41+
let keyPath: String
42+
let valueType: JSONValueType
43+
let displayValue: String
44+
let rawValue: String?
45+
let children: [JSONTreeNode]
46+
47+
var childrenOrNil: [JSONTreeNode]? {
48+
children.isEmpty ? nil : children
49+
}
50+
}
51+
52+
internal enum JSONTreeParseError: Error {
53+
case invalidJSON
54+
case tooLarge
55+
}
56+
57+
internal enum JSONTreeParser {
58+
private static let maxNodes = 5_000
59+
private static let maxInputLength = 100_000
60+
61+
static func parse(_ jsonString: String) -> Result<JSONTreeNode, JSONTreeParseError> {
62+
guard (jsonString as NSString).length <= maxInputLength else {
63+
return .failure(.tooLarge)
64+
}
65+
guard let data = jsonString.data(using: .utf8),
66+
let jsonObject = try? JSONSerialization.jsonObject(with: data) else {
67+
return .failure(.invalidJSON)
68+
}
69+
var nodeCount = 0
70+
let root = buildNode(key: nil, keyPath: "$", value: jsonObject, nodeCount: &nodeCount)
71+
return .success(root)
72+
}
73+
74+
private static func buildNode(key: String?, keyPath: String, value: Any, nodeCount: inout Int) -> JSONTreeNode {
75+
nodeCount += 1
76+
77+
if let dict = value as? [String: Any] {
78+
let sortedKeys = dict.keys.sorted()
79+
var children: [JSONTreeNode] = []
80+
for k in sortedKeys {
81+
guard nodeCount < maxNodes else {
82+
children.append(truncationNode(remaining: dict.count - children.count))
83+
break
84+
}
85+
let childPath = keyPath + "." + k
86+
if let childValue = dict[k] {
87+
children.append(buildNode(key: k, keyPath: childPath, value: childValue, nodeCount: &nodeCount))
88+
}
89+
}
90+
return JSONTreeNode(
91+
key: key, keyPath: keyPath, valueType: .object,
92+
displayValue: "{\(dict.count) keys}", rawValue: nil, children: children
93+
)
94+
}
95+
96+
if let arr = value as? [Any] {
97+
var children: [JSONTreeNode] = []
98+
for (i, item) in arr.enumerated() {
99+
guard nodeCount < maxNodes else {
100+
children.append(truncationNode(remaining: arr.count - i))
101+
break
102+
}
103+
let childPath = keyPath + "[\(i)]"
104+
children.append(buildNode(key: "[\(i)]", keyPath: childPath, value: item, nodeCount: &nodeCount))
105+
}
106+
return JSONTreeNode(
107+
key: key, keyPath: keyPath, valueType: .array,
108+
displayValue: "[\(arr.count) items]", rawValue: nil, children: children
109+
)
110+
}
111+
112+
if let str = value as? String {
113+
let escaped = str.replacingOccurrences(of: "\"", with: "\\\"")
114+
let display: String
115+
let nsLen = (escaped as NSString).length
116+
if nsLen > 80 {
117+
let truncated = (escaped as NSString).substring(to: 80)
118+
display = "\"\(truncated)...\""
119+
} else {
120+
display = "\"\(escaped)\""
121+
}
122+
return JSONTreeNode(
123+
key: key, keyPath: keyPath, valueType: .string,
124+
displayValue: display, rawValue: str, children: []
125+
)
126+
}
127+
128+
if let num = value as? NSNumber {
129+
if CFBooleanGetTypeID() == CFGetTypeID(num) {
130+
let boolVal = num.boolValue
131+
return JSONTreeNode(
132+
key: key, keyPath: keyPath, valueType: .boolean,
133+
displayValue: boolVal ? "true" : "false",
134+
rawValue: boolVal ? "true" : "false", children: []
135+
)
136+
}
137+
let numStr = "\(num)"
138+
return JSONTreeNode(
139+
key: key, keyPath: keyPath, valueType: .number,
140+
displayValue: numStr, rawValue: numStr, children: []
141+
)
142+
}
143+
144+
if value is NSNull {
145+
return JSONTreeNode(
146+
key: key, keyPath: keyPath, valueType: .null,
147+
displayValue: "null", rawValue: nil, children: []
148+
)
149+
}
150+
151+
let fallback = "\(value)"
152+
return JSONTreeNode(
153+
key: key, keyPath: keyPath, valueType: .string,
154+
displayValue: fallback, rawValue: fallback, children: []
155+
)
156+
}
157+
158+
private static func truncationNode(remaining: Int) -> JSONTreeNode {
159+
JSONTreeNode(
160+
key: nil, keyPath: "", valueType: .null,
161+
displayValue: "… (\(remaining) more)", rawValue: nil, children: []
162+
)
163+
}
164+
}

TablePro/Resources/Localizable.xcstrings

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@
9191
}
9292
}
9393
}
94+
},
95+
":" : {
96+
9497
},
9598
".%@" : {
9699
"localizations" : {
@@ -7578,6 +7581,9 @@
75787581
}
75797582
}
75807583
}
7584+
},
7585+
"Collapse All" : {
7586+
75817587
},
75827588
"Collation" : {
75837589
"localizations" : {
@@ -9285,6 +9291,12 @@
92859291
}
92869292
}
92879293
}
9294+
},
9295+
"Copy Key" : {
9296+
9297+
},
9298+
"Copy Key Path" : {
9299+
92889300
},
92899301
"Copy Name" : {
92909302
"localizations" : {
@@ -11364,6 +11376,9 @@
1136411376
}
1136511377
}
1136611378
}
11379+
},
11380+
"Default view:" : {
11381+
1136711382
},
1136811383
"Default:" : {
1136911384
"extractionState" : "stale",
@@ -14220,6 +14235,9 @@
1422014235
}
1422114236
}
1422214237
}
14238+
},
14239+
"Expand All" : {
14240+
1422314241
},
1422414242
"Expired" : {
1422514243
"localizations" : {
@@ -16069,6 +16087,9 @@
1606916087
},
1607016088
"Fetch All Rows" : {
1607116089

16090+
},
16091+
"Fields" : {
16092+
1607216093
},
1607316094
"FIELDS" : {
1607416095
"localizations" : {
@@ -16304,6 +16325,9 @@
1630416325
}
1630516326
}
1630616327
}
16328+
},
16329+
"Filter keys or values..." : {
16330+
1630716331
},
1630816332
"Filter logic mode" : {
1630916333
"localizations" : {
@@ -16799,6 +16823,9 @@
1679916823
}
1680016824
}
1680116825
}
16826+
},
16827+
"Format JSON" : {
16828+
1680216829
},
1680316830
"Format Query" : {
1680416831

@@ -19668,6 +19695,12 @@
1966819695
}
1966919696
}
1967019697
}
19698+
},
19699+
"JSON Too Large" : {
19700+
19701+
},
19702+
"JSON Viewer" : {
19703+
1967119704
},
1967219705
"Jump Hosts" : {
1967319706
"localizations" : {
@@ -25048,6 +25081,9 @@
2504825081
},
2504925082
"Open in Editor" : {
2505025083

25084+
},
25085+
"Open JSON Viewer" : {
25086+
2505125087
},
2505225088
"Open MQL Editor" : {
2505325089
"extractionState" : "stale",
@@ -35772,6 +35808,9 @@
3577235808
}
3577335809
}
3577435810
}
35811+
},
35812+
"The text could not be parsed as JSON. Use text mode to view or edit." : {
35813+
3577535814
},
3577635815
"The text is not valid JSON. Save anyway?" : {
3577735816
"localizations" : {
@@ -36066,6 +36105,9 @@
3606636105
}
3606736106
}
3606836107
}
36108+
},
36109+
"This JSON document is too large for tree view. Use text mode instead." : {
36110+
3606936111
},
3607036112
"This keyword is already in use" : {
3607136113
"localizations" : {

TablePro/Views/Results/Extensions/DataGridView+Popovers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ extension TableViewCoordinator {
122122
PopoverPresenter.show(
123123
relativeTo: cellRect,
124124
of: tableView,
125-
contentSize: NSSize(width: 420, height: 340)
125+
contentSize: nil
126126
) { [weak self] dismiss in
127127
JSONEditorContentView(
128128
initialValue: currentValue,

0 commit comments

Comments
 (0)