Skip to content

Commit 3f476b8

Browse files
authored
fix(datagrid): edit JSON cells inline and open blob hex editor on double-click (#1588)
* fix(datagrid): edit JSON cells inline and open blob hex editor on double-click * refactor(datagrid): scope click-on-focused editing to editable cells
1 parent 08bef0b commit 3f476b8

14 files changed

Lines changed: 223 additions & 163 deletions

CHANGELOG.md

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

3434
### Fixed
3535

36+
- Double-clicking or pressing Enter on a JSON cell now edits its value inline, like other cells; on a blob cell it opens the hex editor. The chevron still opens the tree or hex editor. Neither cell responded to double-click before. (#1588)
3637
- Query results appear as soon as the database returns rows. Column defaults, foreign keys, and row counts now load in the background instead of holding up the grid, which removes a multi-second wait on remote databases whose system tables are slow to query. (#1574)
3738
- MySQL and MariaDB queries are ready to edit right away. Primary key and nullability come back with the rows, so an editable query no longer waits on a separate metadata query. (#1574)
3839
- Pagination and other status bar buttons no longer get blocked by the window resize zone in the bottom-right corner. (#1569)

TablePro/Views/Results/CellInteractionResolver.swift

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@ internal struct CellContext: Equatable {
1111
let isTableEditable: Bool
1212
let isRowDeleted: Bool
1313
let isImmutableColumn: Bool
14-
let columnName: String?
15-
let connectionId: UUID?
16-
let tableName: String?
1714
let displayFormatOverride: ValueDisplayFormat?
1815

1916
init(
@@ -22,19 +19,13 @@ internal struct CellContext: Equatable {
2219
isTableEditable: Bool,
2320
isRowDeleted: Bool,
2421
isImmutableColumn: Bool,
25-
columnName: String? = nil,
26-
connectionId: UUID? = nil,
27-
tableName: String? = nil,
2822
displayFormatOverride: ValueDisplayFormat? = nil
2923
) {
3024
self.columnType = columnType
3125
self.value = value
3226
self.isTableEditable = isTableEditable
3327
self.isRowDeleted = isRowDeleted
3428
self.isImmutableColumn = isImmutableColumn
35-
self.columnName = columnName
36-
self.connectionId = connectionId
37-
self.tableName = tableName
3829
self.displayFormatOverride = displayFormatOverride
3930
}
4031
}
@@ -59,31 +50,16 @@ internal struct CellInteractionResolver {
5950

6051
let isReadOnly = !context.isTableEditable || context.isImmutableColumn
6152

62-
if let override = context.displayFormatOverride {
63-
switch override {
64-
case .raw:
65-
return plainText(for: context, isReadOnly: isReadOnly)
66-
case .json:
67-
return isReadOnly ? .viewJson : .editJson
68-
case .phpSerialized:
69-
return .viewPhpSerialized
70-
case .uuid, .unixTimestamp, .unixTimestampMillis:
71-
break
72-
}
53+
if context.columnType?.isBlobType == true {
54+
return isReadOnly ? .viewBlob : .editBlob
7355
}
7456

75-
if let columnType = context.columnType {
76-
if columnType.isBlobType { return isReadOnly ? .viewBlob : .editBlob }
77-
if columnType.isJsonType { return isReadOnly ? .viewJson : .editJson }
78-
}
79-
80-
let value = context.value ?? ""
81-
switch CellValueContentDetector.detect(value) {
57+
switch context.displayFormatOverride {
8258
case .json:
8359
return isReadOnly ? .viewJson : .editJson
8460
case .phpSerialized:
8561
return .viewPhpSerialized
86-
case .plain:
62+
case .raw, .uuid, .unixTimestamp, .unixTimestampMillis, .none:
8763
return plainText(for: context, isReadOnly: isReadOnly)
8864
}
8965
}

TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ import Foundation
99
protocol DataGridCellAccessoryDelegate: AnyObject {
1010
func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int, openInNewTab: Bool)
1111
func dataGridCellDidClickChevron(row: Int, columnIndex: Int)
12+
func dataGridCellDidDoubleClick(row: Int, columnIndex: Int)
1213
}

TablePro/Views/Results/Cells/DataGridCellView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,10 @@ final class DataGridCellView: NSView {
347347
override func mouseDown(with event: NSEvent) {
348348
let point = convert(event.locationInWindow, from: nil)
349349
guard !accessoryHitRect.isEmpty, accessoryHitRect.contains(point) else {
350+
if event.clickCount == 2 {
351+
accessoryDelegate?.dataGridCellDidDoubleClick(row: cellRow, columnIndex: cellColumnIndex)
352+
return
353+
}
350354
super.mouseDown(with: event)
351355
return
352356
}

TablePro/Views/Results/DataGridCoordinator.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,4 +718,14 @@ extension TableViewCoordinator: DataGridCellAccessoryDelegate {
718718
func dataGridCellDidClickChevron(row: Int, columnIndex: Int) {
719719
handleChevronAction(row: row, columnIndex: columnIndex)
720720
}
721+
722+
func dataGridCellDidDoubleClick(row: Int, columnIndex: Int) {
723+
guard row >= 0, columnIndex >= 0, let tableView else { return }
724+
guard let tableColumn = DataGridView.tableColumnIndex(
725+
for: columnIndex,
726+
in: tableView,
727+
schema: identitySchema
728+
) else { return }
729+
handleCellInteraction(row: row, tableColumn: tableColumn, columnIndex: columnIndex, tableView: tableView)
730+
}
721731
}

TablePro/Views/Results/DataGridView.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,6 @@ struct DataGridView: NSViewRepresentable {
7474

7575
tableView.delegate = context.coordinator
7676
tableView.dataSource = context.coordinator
77-
tableView.target = context.coordinator
78-
tableView.doubleAction = #selector(TableViewCoordinator.handleDoubleClick(_:))
7977

8078
let rowNumberColumn = Self.makeRowNumberColumn()
8179
tableView.addTableColumn(rowNumberColumn)

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

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,7 @@ import AppKit
77
import SwiftUI
88

99
extension TableViewCoordinator {
10-
// MARK: - Click Handlers
11-
12-
@objc func handleDoubleClick(_ sender: NSTableView) {
13-
let row = sender.clickedRow
14-
let column = sender.clickedColumn
15-
guard row >= 0, column > 0 else { return }
16-
guard let columnIndex = DataGridView.dataColumnIndex(for: column, in: sender, schema: identitySchema) else { return }
17-
handleCellInteraction(row: row, tableColumn: column, columnIndex: columnIndex, tableView: sender)
18-
}
10+
// MARK: - Cell Interaction
1911

2012
func handleCellInteraction(row: Int, tableColumn: Int, columnIndex: Int, tableView: NSTableView) {
2113
guard let context = makeCellContext(row: row, columnIndex: columnIndex) else { return }
@@ -62,9 +54,6 @@ extension TableViewCoordinator {
6254
isTableEditable: isEditable,
6355
isRowDeleted: changeManager.isRowDeleted(row),
6456
isImmutableColumn: immutable.contains(columnName),
65-
columnName: columnName,
66-
connectionId: connectionId,
67-
tableName: tableName,
6857
displayFormatOverride: override
6958
)
7059
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ extension TableViewCoordinator {
2424

2525
if columnIndex < tableRows.columnTypes.count {
2626
let ct = tableRows.columnTypes[columnIndex]
27-
if ct.isJsonType || ct.isBlobType {
27+
if ct.isBlobType {
2828
return .blocked
2929
}
3030
}

TablePro/Views/Results/KeyHandlingTableView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,12 @@ final class KeyHandlingTableView: NSTableView {
177177
alreadyFocusedHere,
178178
selectedRowIndexes.count == 1,
179179
coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumn) == true {
180-
coordinator?.beginCellEdit(row: clickedRow, tableColumnIndex: clickedColumn)
180+
coordinator?.handleCellInteraction(
181+
row: clickedRow,
182+
tableColumn: clickedColumn,
183+
columnIndex: dataColumn,
184+
tableView: self
185+
)
181186
}
182187
}
183188

TableProTests/Views/Results/CellInteractionResolverTests.swift

Lines changed: 45 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,6 @@ struct CellInteractionResolverReadOnlyTests {
2929
#expect(resolver.resolve(context) == .viewInline(value: "hello"))
3030
}
3131

32-
@Test("read-only single-line text returns viewInline")
33-
func readOnlySingleLineReturnsViewInline() {
34-
let context = ContextFactory.make(value: "A", isTableEditable: false)
35-
#expect(resolver.resolve(context) == .viewInline(value: "A"))
36-
}
37-
3832
@Test("read-only nil value returns viewInline with NULL placeholder")
3933
func readOnlyNilValueReturnsViewInlineWithNull() {
4034
let context = ContextFactory.make(value: nil, isTableEditable: false)
@@ -47,55 +41,32 @@ struct CellInteractionResolverReadOnlyTests {
4741
#expect(resolver.resolve(context) == .viewInline(value: "line1\nline2"))
4842
}
4943

50-
@Test("read-only JSON column returns viewJson")
51-
func readOnlyJsonColumnReturnsViewJson() {
52-
let context = ContextFactory.make(value: #"{"k":1}"#, columnType: .json(rawType: "JSON"), isTableEditable: false)
53-
#expect(resolver.resolve(context) == .viewJson)
54-
}
55-
5644
@Test("read-only BLOB column returns viewBlob")
5745
func readOnlyBlobColumnReturnsViewBlob() {
5846
let context = ContextFactory.make(value: nil, columnType: .blob(rawType: "BLOB"), isTableEditable: false)
5947
#expect(resolver.resolve(context) == .viewBlob)
6048
}
6149

62-
@Test("immutable column on editable table follows read-only path for plain text")
50+
@Test("read-only JSON column shows its value inline; the chevron opens the JSON viewer")
51+
func readOnlyJsonColumnShowsInline() {
52+
let context = ContextFactory.make(value: #"{"k":1}"#, columnType: .json(rawType: "JSON"), isTableEditable: false)
53+
#expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#))
54+
}
55+
56+
@Test("immutable column on editable table follows the read-only inline path")
6357
func immutableColumnFollowsReadOnlyPath() {
6458
let context = ContextFactory.make(value: "id-123", isTableEditable: true, isImmutableColumn: true)
6559
#expect(resolver.resolve(context) == .viewInline(value: "id-123"))
6660
}
6761

68-
@Test("immutable JSON column on editable table still returns viewJson")
69-
func immutableJsonColumnReturnsViewJson() {
70-
let context = ContextFactory.make(value: "{}", columnType: .json(rawType: "JSON"), isTableEditable: true, isImmutableColumn: true)
71-
#expect(resolver.resolve(context) == .viewJson)
72-
}
73-
74-
@Test("read-only JSON-looking plain text without columnType returns viewJson via detector")
75-
func readOnlyJsonLikeTextWithoutTypeReturnsViewJson() {
62+
@Test("JSON-looking text is not content-routed; it shows inline")
63+
func jsonLikeTextShowsInline() {
7664
let context = ContextFactory.make(value: #"{"k":1}"#, columnType: nil, isTableEditable: false)
77-
#expect(resolver.resolve(context) == .viewJson)
78-
}
79-
80-
@Test("read-only PHP-shaped plain text returns viewPhpSerialized")
81-
func readOnlyPhpLikeTextReturnsViewPhpSerialized() {
82-
let context = ContextFactory.make(value: "a:0:{}", columnType: .text(rawType: "TEXT"), isTableEditable: false)
83-
#expect(resolver.resolve(context) == .viewPhpSerialized)
84-
}
85-
86-
@Test("read-only override .raw beats content sniffing")
87-
func readOnlyOverrideRawWins() {
88-
let context = ContextFactory.make(
89-
value: #"{"k":1}"#,
90-
columnType: .text(rawType: "TEXT"),
91-
isTableEditable: false,
92-
displayFormatOverride: .raw
93-
)
9465
#expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#))
9566
}
9667

97-
@Test("read-only override .json forces viewJson on non-JSON text")
98-
func readOnlyOverrideJsonForces() {
68+
@Test("read-only override .json shows the JSON viewer")
69+
func readOnlyOverrideJsonShowsViewer() {
9970
let context = ContextFactory.make(
10071
value: "plain",
10172
columnType: .text(rawType: "TEXT"),
@@ -105,8 +76,8 @@ struct CellInteractionResolverReadOnlyTests {
10576
#expect(resolver.resolve(context) == .viewJson)
10677
}
10778

108-
@Test("read-only override .phpSerialized forces viewPhpSerialized")
109-
func readOnlyOverridePhpSerializedForces() {
79+
@Test("read-only override .phpSerialized shows the PHP viewer")
80+
func readOnlyOverridePhpShowsViewer() {
11081
let context = ContextFactory.make(
11182
value: "plain",
11283
columnType: .text(rawType: "TEXT"),
@@ -115,17 +86,6 @@ struct CellInteractionResolverReadOnlyTests {
11586
)
11687
#expect(resolver.resolve(context) == .viewPhpSerialized)
11788
}
118-
119-
@Test("read-only override .raw on declared JSON column returns viewInline (override beats type)")
120-
func readOnlyOverrideRawBypassesJsonColumn() {
121-
let context = ContextFactory.make(
122-
value: #"{"k":1}"#,
123-
columnType: .json(rawType: "JSON"),
124-
isTableEditable: false,
125-
displayFormatOverride: .raw
126-
)
127-
#expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#))
128-
}
12989
}
13090

13191
@Suite("CellInteractionResolver - editable path")
@@ -144,90 +104,61 @@ struct CellInteractionResolverEditableTests {
144104
#expect(resolver.resolve(context) == .editOverlay(value: "line1\nline2"))
145105
}
146106

147-
@Test("editable plain text that looks like JSON returns editJson")
148-
func editableJsonLikeTextReturnsEditJson() {
149-
let context = ContextFactory.make(value: #"{"k":1}"#, isTableEditable: true)
150-
#expect(resolver.resolve(context) == .editJson)
107+
@Test("editable JSON column edits inline; the chevron opens the JSON editor")
108+
func editableJsonColumnEditsInline() {
109+
let context = ContextFactory.make(value: #"{"k":1}"#, columnType: .json(rawType: "JSON"), isTableEditable: true)
110+
#expect(resolver.resolve(context) == .editInline(value: #"{"k":1}"#))
151111
}
152112

153-
@Test("editable PHP-shaped text returns viewPhpSerialized (read-only)")
154-
func editablePhpLikeTextReturnsView() {
155-
let context = ContextFactory.make(value: "a:0:{}", isTableEditable: true)
156-
#expect(resolver.resolve(context) == .viewPhpSerialized)
113+
@Test("editable multiline JSON column opens the inline overlay editor")
114+
func editableMultilineJsonColumnEditsOverlay() {
115+
let value = "{\n\"k\": 1\n}"
116+
let context = ContextFactory.make(value: value, columnType: .json(rawType: "JSON"), isTableEditable: true)
117+
#expect(resolver.resolve(context) == .editOverlay(value: value))
157118
}
158119

159-
@Test("editable override .phpSerialized forces viewPhpSerialized")
160-
func editableOverridePhpForces() {
161-
let context = ContextFactory.make(
162-
value: "plain",
163-
isTableEditable: true,
164-
displayFormatOverride: .phpSerialized
165-
)
166-
#expect(resolver.resolve(context) == .viewPhpSerialized)
120+
@Test("editable BLOB column returns editBlob")
121+
func editableBlobColumnReturnsEditBlob() {
122+
let context = ContextFactory.make(value: "x", columnType: .blob(rawType: "BLOB"), isTableEditable: true)
123+
#expect(resolver.resolve(context) == .editBlob)
167124
}
168125

169-
@Test("editable override .json forces editJson")
170-
func editableOverrideJsonForces() {
171-
let context = ContextFactory.make(
172-
value: "plain",
173-
isTableEditable: true,
174-
displayFormatOverride: .json
175-
)
176-
#expect(resolver.resolve(context) == .editJson)
126+
@Test("JSON-looking text is not content-routed; it edits inline")
127+
func jsonLikeTextEditsInline() {
128+
let context = ContextFactory.make(value: #"{"k":1}"#, isTableEditable: true)
129+
#expect(resolver.resolve(context) == .editInline(value: #"{"k":1}"#))
177130
}
178131

179-
@Test("editable override .raw bypasses JSON content detection")
180-
func editableOverrideRawSkipsJson() {
181-
let context = ContextFactory.make(
182-
value: #"{"k":1}"#,
183-
columnType: .text(rawType: "TEXT"),
184-
isTableEditable: true,
185-
displayFormatOverride: .raw
186-
)
187-
#expect(resolver.resolve(context) == .editInline(value: #"{"k":1}"#))
132+
@Test("editable override .json opens the JSON editor")
133+
func editableOverrideJsonOpensEditor() {
134+
let context = ContextFactory.make(value: "plain", isTableEditable: true, displayFormatOverride: .json)
135+
#expect(resolver.resolve(context) == .editJson)
188136
}
189137

190-
@Test("editable override .raw bypasses PHP content detection")
191-
func editableOverrideRawSkipsPhp() {
192-
let context = ContextFactory.make(
193-
value: "a:0:{}",
194-
columnType: .text(rawType: "TEXT"),
195-
isTableEditable: true,
196-
displayFormatOverride: .raw
197-
)
198-
#expect(resolver.resolve(context) == .editInline(value: "a:0:{}"))
138+
@Test("editable override .phpSerialized shows the PHP viewer")
139+
func editableOverridePhpShowsViewer() {
140+
let context = ContextFactory.make(value: "plain", isTableEditable: true, displayFormatOverride: .phpSerialized)
141+
#expect(resolver.resolve(context) == .viewPhpSerialized)
199142
}
200143

201-
@Test("editable override .raw on multiline value returns editOverlay")
202-
func editableOverrideRawMultilineReturnsOverlay() {
144+
@Test("editable override .uuid still edits inline")
145+
func editableOverrideUuidEditsInline() {
203146
let context = ContextFactory.make(
204-
value: "line1\nline2",
147+
value: "0x00",
205148
columnType: .text(rawType: "TEXT"),
206149
isTableEditable: true,
207-
displayFormatOverride: .raw
150+
displayFormatOverride: .uuid
208151
)
209-
#expect(resolver.resolve(context) == .editOverlay(value: "line1\nline2"))
210-
}
211-
212-
@Test("editable JSON column returns editJson")
213-
func editableJsonColumnReturnsEditJson() {
214-
let context = ContextFactory.make(value: "{}", columnType: .json(rawType: "JSON"), isTableEditable: true)
215-
#expect(resolver.resolve(context) == .editJson)
216-
}
217-
218-
@Test("editable BLOB column returns editBlob")
219-
func editableBlobColumnReturnsEditBlob() {
220-
let context = ContextFactory.make(value: nil, columnType: .blob(rawType: "BLOB"), isTableEditable: true)
221-
#expect(resolver.resolve(context) == .editBlob)
152+
#expect(resolver.resolve(context) == .editInline(value: "0x00"))
222153
}
223154

224-
@Test("editable foreign key column returns editInline (FK popover is not opened by double-click)")
155+
@Test("editable foreign key column returns editInline")
225156
func editableForeignKeyReturnsEditInline() {
226157
let context = ContextFactory.make(value: "1", columnType: .integer(rawType: "INT"), isTableEditable: true)
227158
#expect(resolver.resolve(context) == .editInline(value: "1"))
228159
}
229160

230-
@Test("editable boolean column returns editInline, not a picker (pickers are chevron-only)")
161+
@Test("editable boolean column returns editInline, not a picker")
231162
func editableBooleanColumnReturnsEditInline() {
232163
let context = ContextFactory.make(value: "true", columnType: .boolean(rawType: "BOOL"), isTableEditable: true)
233164
#expect(resolver.resolve(context) == .editInline(value: "true"))

0 commit comments

Comments
 (0)