Skip to content

Commit dcf80e9

Browse files
authored
fix(datagrid): make the JSON results view follow the grid as shown (#2050)
1 parent 0c36b4d commit dcf80e9

10 files changed

Lines changed: 203 additions & 36 deletions

CHANGELOG.md

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

2323
### Fixed
2424

25+
- The JSON view of a result now follows the grid: rows come in the order you sorted them, a column filter leaves its rows out, and hidden columns stay hidden. It used to show every row in fetch order, including rows the filter had removed, and columns you had hidden.
26+
- Copy JSON in the JSON view and Copy as JSON in the grid now produce the same output for the same rows.
27+
- The JSON view now updates when you change a column filter without running a new query.
2528
- The SQL editor now returns the cursor to where you left it when a tab is reopened or the app restarts, and restores what you had selected, not just the caret. The saved position was never applied.
2629
- Autocomplete now keeps suggesting tables and columns after a connection drops and reconnects on its own. The reconnect cleared the schema it had loaded and never asked for it again, so a window that looked connected offered nothing but keywords for the rest of the session.
2730
- The JSON view of a result now updates when you run another query. It kept showing the previous result until you switched to the data grid and back, and a query that returned the same number of rows never updated at all.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// ResultJsonSerializer.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
/// The one place a result set becomes JSON.
10+
///
11+
/// The results pane's JSON view and the grid's Copy as JSON render the same rows, so they share
12+
/// this rather than each deciding for itself which rows and columns to include. Both follow the
13+
/// grid as shown: display order, no hidden columns, current column order.
14+
internal enum ResultJsonSerializer {
15+
internal struct Output {
16+
let json: String
17+
let rowCount: Int
18+
}
19+
20+
/// - Parameter selectedDisplayIndices: display positions to narrow to. Empty means every
21+
/// displayed row, which is what an untouched result set shows.
22+
internal static func serialize(
23+
tableRows: TableRows,
24+
displayIDs: [RowID]?,
25+
selectedDisplayIndices: Set<Int>,
26+
columns projection: VisibleColumnProjection
27+
) -> Output {
28+
let positions: [Int]
29+
if selectedDisplayIndices.isEmpty {
30+
positions = Array(0..<(displayIDs?.count ?? tableRows.rows.count))
31+
} else {
32+
positions = selectedDisplayIndices.sorted()
33+
}
34+
35+
let rows: [[PluginCellValue]] = positions.compactMap { displayIndex in
36+
DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows)
37+
.map { projection.values(Array($0.values)) }
38+
}
39+
40+
let converter = JsonRowConverter(
41+
columns: projection.columns(tableRows.columns),
42+
columnTypes: projection.columnTypes(tableRows.columnTypes)
43+
)
44+
return Output(json: converter.generateJson(rows: rows), rowCount: rows.count)
45+
}
46+
}
47+
48+
internal extension VisibleColumnProjection {
49+
/// Builds a projection from the layout the user arranged in the grid.
50+
///
51+
/// Reads the persisted layout rather than the live `NSTableView` columns, because the grid is
52+
/// not mounted while the results pane is showing JSON. Duplicate column names cannot be mapped
53+
/// back to a single index, so a result with duplicates keeps every column.
54+
static func fromColumnLayout(_ layout: ColumnLayoutState, columns: [String]) -> VisibleColumnProjection {
55+
guard Set(columns).count == columns.count else { return .identity }
56+
guard !layout.hiddenColumns.isEmpty || layout.columnOrder != nil else { return .identity }
57+
58+
let ordered: [String]
59+
if let columnOrder = layout.columnOrder {
60+
let known = columnOrder.filter { columns.contains($0) }
61+
ordered = known + columns.filter { !known.contains($0) }
62+
} else {
63+
ordered = columns
64+
}
65+
66+
let indices = ordered.compactMap { name -> Int? in
67+
guard !layout.hiddenColumns.contains(name) else { return nil }
68+
return columns.firstIndex(of: name)
69+
}
70+
return VisibleColumnProjection(indices: indices)
71+
}
72+
}

TablePro/Views/Main/Child/DataTabGridDelegate.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ final class DataTabGridDelegate: DataGridViewDelegate {
3131
onSortStateChanged?(state)
3232
}
3333

34+
func dataGridDisplayOrderChanged() {
35+
coordinator?.gridDisplayRevision &+= 1
36+
}
37+
3438
func dataGridAddRow() {
3539
onAddRow?()
3640
}

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,9 @@ struct MainEditorContentView: View {
558558
tableRows: resolvedTableRows(for: tab),
559559
selectedRowIndices: selectionState.indices,
560560
displayIDs: coordinator.activeGridDisplayIDs,
561-
dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0
561+
dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0,
562+
displayRevision: coordinator.gridDisplayRevision,
563+
columnLayout: tab.columnLayout
562564
)
563565
.id(tab.id)
564566
case .data:

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ final class MainContentCoordinator {
141141
weak var rightPanelState: RightPanelState?
142142

143143
/// Direct reference to the data tab grid delegate — enables row mutation operations to
144+
/// Observable mirror of the grid's display revision, so views outside the grid re-render when
145+
/// the value filter or the displayed order changes. The grid's own state lives on a plain
146+
/// AppKit object reached through observation-ignored hops, so it cannot invalidate a view.
147+
var gridDisplayRevision: Int = 0
148+
144149
/// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate.
145150
@ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate?
146151

TablePro/Views/Results/DataGridCoordinator.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
1717
var paginationOffsetProvider: @MainActor () -> Int = { 0 }
1818
var changeManager: AnyChangeManager
1919
var isEditable: Bool
20-
var sortedIDs: [RowID]?
21-
var valueFilteredIDs: [RowID]?
20+
var sortedIDs: [RowID]? { didSet { bumpDisplayRevision() } }
21+
var valueFilteredIDs: [RowID]? { didSet { bumpDisplayRevision() } }
22+
/// Ticks whenever the displayed row order or the value filter changes.
23+
///
24+
/// `displayIDs` reaches SwiftUI only through weak, observation-ignored hops, so a filter change
25+
/// produces no signal on its own. Views that render the same rows outside the grid key off this
26+
/// instead of comparing the id array, which is O(rows) on every body evaluation.
27+
private(set) var displayRevision: Int = 0
2228
var valueFilterState = GridValueFilterState()
2329
var displayIDs: [RowID]? { valueFilteredIDs ?? sortedIDs }
2430
private(set) var columnDisplayFormats: [ValueDisplayFormat?] = []
@@ -348,6 +354,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
348354
tableView.removeRows(at: indices, withAnimation: Self.rowAnimation(.slideUp))
349355
}
350356

357+
private func bumpDisplayRevision() {
358+
displayRevision &+= 1
359+
delegate?.dataGridDisplayOrderChanged()
360+
}
361+
351362
/// Drops the row selection before a wholesale replacement.
352363
///
353364
/// `reloadData()` leaves `selectedRowIndexes` alone when the new result happens to have as

TablePro/Views/Results/DataGridView+RowActions.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,15 @@ extension TableViewCoordinator {
168168
}
169169

170170
func copyRowsAsJson(at indices: Set<Int>) {
171-
let projection = selectedColumnProjection()
172-
let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
173-
guard !rows.isEmpty else { return }
174-
let tableRows = tableRowsProvider()
175-
let converter = JsonRowConverter(
176-
columns: projection.columns(tableRows.columns),
177-
columnTypes: projection.columnTypes(tableRows.columnTypes)
171+
guard !indices.isEmpty else { return }
172+
let output = ResultJsonSerializer.serialize(
173+
tableRows: tableRowsProvider(),
174+
displayIDs: displayIDs,
175+
selectedDisplayIndices: indices,
176+
columns: selectedColumnProjection()
178177
)
179-
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
178+
guard output.rowCount > 0 else { return }
179+
ClipboardService.shared.writeText(output.json)
180180
}
181181

182182
func copyRowsAsCsv(at indices: Set<Int>, includeHeaders: Bool) {

TablePro/Views/Results/DataGridViewDelegate.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ protocol DataGridViewDelegate: AnyObject {
3636
func dataGridDidRemoveRows(at indices: IndexSet)
3737
func dataGridDidReplaceAllRows()
3838
func dataGridAttach(tableViewCoordinator: TableViewCoordinator)
39+
func dataGridDisplayOrderChanged()
3940
}
4041

4142
extension DataGridViewDelegate {
43+
func dataGridDisplayOrderChanged() {}
4244
func dataGridDidEditCell(row: Int, column: Int, newValue: String?) {}
4345
func dataGridDeleteRows(_ indices: Set<Int>) {}
4446
func dataGridCopyRows(_ indices: Set<Int>) {}

TablePro/Views/Results/ResultsJsonView.swift

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ internal struct ResultsJsonView: View {
1111
let selectedRowIndices: Set<Int>
1212
let displayIDs: [RowID]?
1313
let dataRevision: Int
14+
let displayRevision: Int
15+
let columnLayout: ColumnLayoutState
1416

1517
@State private var viewMode: JSONViewMode
1618
@State private var treeSearchText = ""
@@ -27,12 +29,16 @@ internal struct ResultsJsonView: View {
2729
tableRows: TableRows,
2830
selectedRowIndices: Set<Int>,
2931
displayIDs: [RowID]?,
30-
dataRevision: Int
32+
dataRevision: Int,
33+
displayRevision: Int,
34+
columnLayout: ColumnLayoutState
3135
) {
3236
self.tableRows = tableRows
3337
self.selectedRowIndices = selectedRowIndices
3438
self.displayIDs = displayIDs
3539
self.dataRevision = dataRevision
40+
self.displayRevision = displayRevision
41+
self.columnLayout = columnLayout
3642
self._viewMode = State(initialValue: AppSettingsManager.shared.editor.jsonViewerPreferredMode)
3743
self._resolvedRowCount = State(
3844
initialValue: selectedRowIndices.isEmpty ? tableRows.count : selectedRowIndices.count
@@ -41,11 +47,20 @@ internal struct ResultsJsonView: View {
4147

4248
private struct RenderKey: Equatable {
4349
let dataRevision: Int
50+
let displayRevision: Int
4451
let selectedRowIndices: Set<Int>
52+
let hiddenColumns: Set<String>
53+
let columnOrder: [String]?
4554
}
4655

4756
private var renderKey: RenderKey {
48-
RenderKey(dataRevision: dataRevision, selectedRowIndices: selectedRowIndices)
57+
RenderKey(
58+
dataRevision: dataRevision,
59+
displayRevision: displayRevision,
60+
selectedRowIndices: selectedRowIndices,
61+
hiddenColumns: columnLayout.hiddenColumns,
62+
columnOrder: columnLayout.columnOrder
63+
)
4964
}
5065

5166
private var rowCountText: String {
@@ -169,9 +184,15 @@ internal struct ResultsJsonView: View {
169184
let snapshot = tableRows
170185
let ids = displayIDs
171186
let selectedIndices = selectedRowIndices
187+
let layout = columnLayout
172188

173189
let result = await Task.detached(priority: .userInitiated) {
174-
Self.computeJson(tableRows: snapshot, displayIDs: ids, selectedIndices: selectedIndices)
190+
Self.computeJson(
191+
tableRows: snapshot,
192+
displayIDs: ids,
193+
selectedIndices: selectedIndices,
194+
columnLayout: layout
195+
)
175196
}.value
176197

177198
guard !Task.isCancelled else { return }
@@ -189,31 +210,25 @@ internal struct ResultsJsonView: View {
189210
hasRendered = true
190211
}
191212

192-
/// Selection indices are display positions, so they are resolved through
193-
/// ``DisplayRowMapping`` rather than used to subscript `tableRows.rows` directly: a
194-
/// per-column value filter or a sort makes the two diverge.
213+
/// Renders the rows the grid is showing, through the same serializer as Copy as JSON.
195214
nonisolated static func computeJson(
196215
tableRows: TableRows,
197216
displayIDs: [RowID]?,
198-
selectedIndices: Set<Int>
217+
selectedIndices: Set<Int>,
218+
columnLayout: ColumnLayoutState
199219
) -> (json: String, pretty: String, resolvedCount: Int, parseResult: Result<JSONTreeNode, JSONTreeParseError>) {
200-
let displayRows: [[PluginCellValue]]
201-
if selectedIndices.isEmpty {
202-
displayRows = tableRows.rows.map { Array($0.values) }
203-
} else {
204-
displayRows = selectedIndices.sorted().compactMap { displayIndex in
205-
DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows)
206-
.map { Array($0.values) }
207-
}
208-
}
209-
let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes)
210-
let json = converter.generateJson(rows: displayRows)
211-
let pretty = json.prettyPrintedAsJson() ?? json
220+
let output = ResultJsonSerializer.serialize(
221+
tableRows: tableRows,
222+
displayIDs: displayIDs,
223+
selectedDisplayIndices: selectedIndices,
224+
columns: .fromColumnLayout(columnLayout, columns: tableRows.columns)
225+
)
226+
let pretty = output.json.prettyPrintedAsJson() ?? output.json
212227
return (
213-
json: json,
228+
json: output.json,
214229
pretty: pretty,
215-
resolvedCount: displayRows.count,
216-
parseResult: JSONTreeParser.parse(json)
230+
resolvedCount: output.rowCount,
231+
parseResult: JSONTreeParser.parse(output.json)
217232
)
218233
}
219234
}

TableProTests/Views/Results/ResultsJsonViewTests.swift

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ struct ResultsJsonViewTests {
3131

3232
private func compute(
3333
displayIDs: [RowID]? = nil,
34-
selectedIndices: Set<Int>
34+
selectedIndices: Set<Int>,
35+
columnLayout: ColumnLayoutState = ColumnLayoutState()
3536
) -> (json: String, pretty: String, resolvedCount: Int, parseResult: Result<JSONTreeNode, JSONTreeParseError>) {
3637
ResultsJsonView.computeJson(
3738
tableRows: makeTableRows(),
3839
displayIDs: displayIDs,
39-
selectedIndices: selectedIndices
40+
selectedIndices: selectedIndices,
41+
columnLayout: columnLayout
4042
)
4143
}
4244

@@ -49,12 +51,63 @@ struct ResultsJsonViewTests {
4951
#expect(result.json.contains("\"d\""))
5052
}
5153

54+
@Test("no selection follows the displayed order, not the fetch order")
55+
func noSelectionFollowsDisplayOrder() {
56+
let result = compute(displayIDs: [.existing(2), .existing(0)], selectedIndices: [])
57+
58+
#expect(result.resolvedCount == 2)
59+
let first = result.json.range(of: "\"c\"")
60+
let second = result.json.range(of: "\"a\"")
61+
#expect(first != nil)
62+
#expect(second != nil)
63+
if let first, let second {
64+
#expect(first.lowerBound < second.lowerBound)
65+
}
66+
}
67+
68+
@Test("no selection excludes rows a value filter removed")
69+
func noSelectionExcludesFilteredRows() {
70+
let result = compute(displayIDs: [.existing(0), .existing(2)], selectedIndices: [])
71+
72+
#expect(result.resolvedCount == 2)
73+
#expect(!result.json.contains("\"b\""))
74+
#expect(!result.json.contains("\"d\""))
75+
}
76+
77+
@Test("a hidden column is left out")
78+
func hiddenColumnIsExcluded() {
79+
var layout = ColumnLayoutState()
80+
layout.hiddenColumns = ["status"]
81+
82+
let result = compute(selectedIndices: [], columnLayout: layout)
83+
84+
#expect(!result.json.contains("status"))
85+
#expect(result.json.contains("name"))
86+
}
87+
88+
@Test("columns follow the order the user arranged")
89+
func columnsFollowUserOrder() {
90+
var layout = ColumnLayoutState()
91+
layout.columnOrder = ["name", "status"]
92+
93+
let result = compute(selectedIndices: [], columnLayout: layout)
94+
95+
let name = result.json.range(of: "name")
96+
let status = result.json.range(of: "status")
97+
#expect(name != nil)
98+
#expect(status != nil)
99+
if let name, let status {
100+
#expect(name.lowerBound < status.lowerBound)
101+
}
102+
}
103+
52104
@Test("an empty result renders an empty array")
53105
func emptyResultRendersEmptyArray() {
54106
let result = ResultsJsonView.computeJson(
55107
tableRows: TableRows(),
56108
displayIDs: nil,
57-
selectedIndices: []
109+
selectedIndices: [],
110+
columnLayout: ColumnLayoutState()
58111
)
59112

60113
#expect(result.resolvedCount == 0)

0 commit comments

Comments
 (0)