From 82f9611a9327934687a84cffef78a6ba9abb8585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 5 Jun 2026 12:55:01 +0700 Subject: [PATCH 1/2] fix(datagrid): edit JSON cells inline and open blob hex editor on double-click --- CHANGELOG.md | 1 + .../Results/CellInteractionResolver.swift | 32 +--- .../Cells/DataGridCellAccessoryDelegate.swift | 1 + .../Results/Cells/DataGridCellView.swift | 4 + .../Views/Results/DataGridCoordinator.swift | 10 ++ TablePro/Views/Results/DataGridView.swift | 2 - .../Extensions/DataGridView+Click.swift | 13 +- .../Extensions/DataGridView+Editing.swift | 9 +- .../Views/Results/KeyHandlingTableView.swift | 10 +- .../CellInteractionResolverTests.swift | 159 +++++------------- .../DataGridCellViewDoubleClickTests.swift | 89 ++++++++++ .../InlineEditEligibilityTests.swift | 61 +++++++ docs/features/data-grid.mdx | 2 +- docs/features/json-viewer.mdx | 8 +- 14 files changed, 229 insertions(+), 172 deletions(-) create mode 100644 TableProTests/Views/Results/DataGridCellViewDoubleClickTests.swift create mode 100644 TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 835e1e0e5..16d3a325d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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. - 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) - 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) - Pagination and other status bar buttons no longer get blocked by the window resize zone in the bottom-right corner. (#1569) diff --git a/TablePro/Views/Results/CellInteractionResolver.swift b/TablePro/Views/Results/CellInteractionResolver.swift index 6df8a3a3b..b3e1892b4 100644 --- a/TablePro/Views/Results/CellInteractionResolver.swift +++ b/TablePro/Views/Results/CellInteractionResolver.swift @@ -11,9 +11,6 @@ internal struct CellContext: Equatable { let isTableEditable: Bool let isRowDeleted: Bool let isImmutableColumn: Bool - let columnName: String? - let connectionId: UUID? - let tableName: String? let displayFormatOverride: ValueDisplayFormat? init( @@ -22,9 +19,6 @@ internal struct CellContext: Equatable { isTableEditable: Bool, isRowDeleted: Bool, isImmutableColumn: Bool, - columnName: String? = nil, - connectionId: UUID? = nil, - tableName: String? = nil, displayFormatOverride: ValueDisplayFormat? = nil ) { self.columnType = columnType @@ -32,9 +26,6 @@ internal struct CellContext: Equatable { self.isTableEditable = isTableEditable self.isRowDeleted = isRowDeleted self.isImmutableColumn = isImmutableColumn - self.columnName = columnName - self.connectionId = connectionId - self.tableName = tableName self.displayFormatOverride = displayFormatOverride } } @@ -59,31 +50,16 @@ internal struct CellInteractionResolver { let isReadOnly = !context.isTableEditable || context.isImmutableColumn - if let override = context.displayFormatOverride { - switch override { - case .raw: - return plainText(for: context, isReadOnly: isReadOnly) - case .json: - return isReadOnly ? .viewJson : .editJson - case .phpSerialized: - return .viewPhpSerialized - case .uuid, .unixTimestamp, .unixTimestampMillis: - break - } + if context.columnType?.isBlobType == true { + return isReadOnly ? .viewBlob : .editBlob } - if let columnType = context.columnType { - if columnType.isBlobType { return isReadOnly ? .viewBlob : .editBlob } - if columnType.isJsonType { return isReadOnly ? .viewJson : .editJson } - } - - let value = context.value ?? "" - switch CellValueContentDetector.detect(value) { + switch context.displayFormatOverride { case .json: return isReadOnly ? .viewJson : .editJson case .phpSerialized: return .viewPhpSerialized - case .plain: + case .raw, .uuid, .unixTimestamp, .unixTimestampMillis, .none: return plainText(for: context, isReadOnly: isReadOnly) } } diff --git a/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift b/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift index 8ddaa3f4a..186e7b661 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift @@ -9,4 +9,5 @@ import Foundation protocol DataGridCellAccessoryDelegate: AnyObject { func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int, openInNewTab: Bool) func dataGridCellDidClickChevron(row: Int, columnIndex: Int) + func dataGridCellDidDoubleClick(row: Int, columnIndex: Int) } diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 353eb3c42..c78a07b19 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -347,6 +347,10 @@ final class DataGridCellView: NSView { override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) guard !accessoryHitRect.isEmpty, accessoryHitRect.contains(point) else { + if event.clickCount == 2 { + accessoryDelegate?.dataGridCellDidDoubleClick(row: cellRow, columnIndex: cellColumnIndex) + return + } super.mouseDown(with: event) return } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 475f8c0f1..ee3ace610 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -718,4 +718,14 @@ extension TableViewCoordinator: DataGridCellAccessoryDelegate { func dataGridCellDidClickChevron(row: Int, columnIndex: Int) { handleChevronAction(row: row, columnIndex: columnIndex) } + + func dataGridCellDidDoubleClick(row: Int, columnIndex: Int) { + guard row >= 0, columnIndex >= 0, let tableView else { return } + guard let tableColumn = DataGridView.tableColumnIndex( + for: columnIndex, + in: tableView, + schema: identitySchema + ) else { return } + handleCellInteraction(row: row, tableColumn: tableColumn, columnIndex: columnIndex, tableView: tableView) + } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e7ccfceb5..f268a06c8 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -74,8 +74,6 @@ struct DataGridView: NSViewRepresentable { tableView.delegate = context.coordinator tableView.dataSource = context.coordinator - tableView.target = context.coordinator - tableView.doubleAction = #selector(TableViewCoordinator.handleDoubleClick(_:)) let rowNumberColumn = Self.makeRowNumberColumn() tableView.addTableColumn(rowNumberColumn) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 3c432422a..68769773d 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -7,15 +7,7 @@ import AppKit import SwiftUI extension TableViewCoordinator { - // MARK: - Click Handlers - - @objc func handleDoubleClick(_ sender: NSTableView) { - let row = sender.clickedRow - let column = sender.clickedColumn - guard row >= 0, column > 0 else { return } - guard let columnIndex = DataGridView.dataColumnIndex(for: column, in: sender, schema: identitySchema) else { return } - handleCellInteraction(row: row, tableColumn: column, columnIndex: columnIndex, tableView: sender) - } + // MARK: - Cell Interaction func handleCellInteraction(row: Int, tableColumn: Int, columnIndex: Int, tableView: NSTableView) { guard let context = makeCellContext(row: row, columnIndex: columnIndex) else { return } @@ -62,9 +54,6 @@ extension TableViewCoordinator { isTableEditable: isEditable, isRowDeleted: changeManager.isRowDeleted(row), isImmutableColumn: immutable.contains(columnName), - columnName: columnName, - connectionId: connectionId, - tableName: tableName, displayFormatOverride: override ) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 186e9c85e..bdbe32196 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -24,7 +24,7 @@ extension TableViewCoordinator { if columnIndex < tableRows.columnTypes.count { let ct = tableRows.columnTypes[columnIndex] - if ct.isJsonType || ct.isBlobType { + if ct.isBlobType { return .blocked } } @@ -40,13 +40,6 @@ extension TableViewCoordinator { return .editable(value: value) } - func canStartInlineEdit(row: Int, columnIndex: Int) -> Bool { - if case .editable = editEligibility(row: row, columnIndex: columnIndex) { - return true - } - return false - } - func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { false } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 050d54120..31b5ccaf8 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -175,9 +175,13 @@ final class KeyHandlingTableView: NSTableView { if modifiers.isEmpty, alreadyFocusedHere, - selectedRowIndexes.count == 1, - coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumn) == true { - coordinator?.beginCellEdit(row: clickedRow, tableColumnIndex: clickedColumn) + selectedRowIndexes.count == 1 { + coordinator?.handleCellInteraction( + row: clickedRow, + tableColumn: clickedColumn, + columnIndex: dataColumn, + tableView: self + ) } } diff --git a/TableProTests/Views/Results/CellInteractionResolverTests.swift b/TableProTests/Views/Results/CellInteractionResolverTests.swift index ac903252e..3ea3a1c3a 100644 --- a/TableProTests/Views/Results/CellInteractionResolverTests.swift +++ b/TableProTests/Views/Results/CellInteractionResolverTests.swift @@ -29,12 +29,6 @@ struct CellInteractionResolverReadOnlyTests { #expect(resolver.resolve(context) == .viewInline(value: "hello")) } - @Test("read-only single-line text returns viewInline") - func readOnlySingleLineReturnsViewInline() { - let context = ContextFactory.make(value: "A", isTableEditable: false) - #expect(resolver.resolve(context) == .viewInline(value: "A")) - } - @Test("read-only nil value returns viewInline with NULL placeholder") func readOnlyNilValueReturnsViewInlineWithNull() { let context = ContextFactory.make(value: nil, isTableEditable: false) @@ -47,55 +41,32 @@ struct CellInteractionResolverReadOnlyTests { #expect(resolver.resolve(context) == .viewInline(value: "line1\nline2")) } - @Test("read-only JSON column returns viewJson") - func readOnlyJsonColumnReturnsViewJson() { - let context = ContextFactory.make(value: #"{"k":1}"#, columnType: .json(rawType: "JSON"), isTableEditable: false) - #expect(resolver.resolve(context) == .viewJson) - } - @Test("read-only BLOB column returns viewBlob") func readOnlyBlobColumnReturnsViewBlob() { let context = ContextFactory.make(value: nil, columnType: .blob(rawType: "BLOB"), isTableEditable: false) #expect(resolver.resolve(context) == .viewBlob) } - @Test("immutable column on editable table follows read-only path for plain text") + @Test("read-only JSON column shows its value inline; the chevron opens the JSON viewer") + func readOnlyJsonColumnShowsInline() { + let context = ContextFactory.make(value: #"{"k":1}"#, columnType: .json(rawType: "JSON"), isTableEditable: false) + #expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#)) + } + + @Test("immutable column on editable table follows the read-only inline path") func immutableColumnFollowsReadOnlyPath() { let context = ContextFactory.make(value: "id-123", isTableEditable: true, isImmutableColumn: true) #expect(resolver.resolve(context) == .viewInline(value: "id-123")) } - @Test("immutable JSON column on editable table still returns viewJson") - func immutableJsonColumnReturnsViewJson() { - let context = ContextFactory.make(value: "{}", columnType: .json(rawType: "JSON"), isTableEditable: true, isImmutableColumn: true) - #expect(resolver.resolve(context) == .viewJson) - } - - @Test("read-only JSON-looking plain text without columnType returns viewJson via detector") - func readOnlyJsonLikeTextWithoutTypeReturnsViewJson() { + @Test("JSON-looking text is not content-routed; it shows inline") + func jsonLikeTextShowsInline() { let context = ContextFactory.make(value: #"{"k":1}"#, columnType: nil, isTableEditable: false) - #expect(resolver.resolve(context) == .viewJson) - } - - @Test("read-only PHP-shaped plain text returns viewPhpSerialized") - func readOnlyPhpLikeTextReturnsViewPhpSerialized() { - let context = ContextFactory.make(value: "a:0:{}", columnType: .text(rawType: "TEXT"), isTableEditable: false) - #expect(resolver.resolve(context) == .viewPhpSerialized) - } - - @Test("read-only override .raw beats content sniffing") - func readOnlyOverrideRawWins() { - let context = ContextFactory.make( - value: #"{"k":1}"#, - columnType: .text(rawType: "TEXT"), - isTableEditable: false, - displayFormatOverride: .raw - ) #expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#)) } - @Test("read-only override .json forces viewJson on non-JSON text") - func readOnlyOverrideJsonForces() { + @Test("read-only override .json shows the JSON viewer") + func readOnlyOverrideJsonShowsViewer() { let context = ContextFactory.make( value: "plain", columnType: .text(rawType: "TEXT"), @@ -105,8 +76,8 @@ struct CellInteractionResolverReadOnlyTests { #expect(resolver.resolve(context) == .viewJson) } - @Test("read-only override .phpSerialized forces viewPhpSerialized") - func readOnlyOverridePhpSerializedForces() { + @Test("read-only override .phpSerialized shows the PHP viewer") + func readOnlyOverridePhpShowsViewer() { let context = ContextFactory.make( value: "plain", columnType: .text(rawType: "TEXT"), @@ -115,17 +86,6 @@ struct CellInteractionResolverReadOnlyTests { ) #expect(resolver.resolve(context) == .viewPhpSerialized) } - - @Test("read-only override .raw on declared JSON column returns viewInline (override beats type)") - func readOnlyOverrideRawBypassesJsonColumn() { - let context = ContextFactory.make( - value: #"{"k":1}"#, - columnType: .json(rawType: "JSON"), - isTableEditable: false, - displayFormatOverride: .raw - ) - #expect(resolver.resolve(context) == .viewInline(value: #"{"k":1}"#)) - } } @Suite("CellInteractionResolver - editable path") @@ -144,90 +104,61 @@ struct CellInteractionResolverEditableTests { #expect(resolver.resolve(context) == .editOverlay(value: "line1\nline2")) } - @Test("editable plain text that looks like JSON returns editJson") - func editableJsonLikeTextReturnsEditJson() { - let context = ContextFactory.make(value: #"{"k":1}"#, isTableEditable: true) - #expect(resolver.resolve(context) == .editJson) + @Test("editable JSON column edits inline; the chevron opens the JSON editor") + func editableJsonColumnEditsInline() { + let context = ContextFactory.make(value: #"{"k":1}"#, columnType: .json(rawType: "JSON"), isTableEditable: true) + #expect(resolver.resolve(context) == .editInline(value: #"{"k":1}"#)) } - @Test("editable PHP-shaped text returns viewPhpSerialized (read-only)") - func editablePhpLikeTextReturnsView() { - let context = ContextFactory.make(value: "a:0:{}", isTableEditable: true) - #expect(resolver.resolve(context) == .viewPhpSerialized) + @Test("editable multiline JSON column opens the inline overlay editor") + func editableMultilineJsonColumnEditsOverlay() { + let value = "{\n\"k\": 1\n}" + let context = ContextFactory.make(value: value, columnType: .json(rawType: "JSON"), isTableEditable: true) + #expect(resolver.resolve(context) == .editOverlay(value: value)) } - @Test("editable override .phpSerialized forces viewPhpSerialized") - func editableOverridePhpForces() { - let context = ContextFactory.make( - value: "plain", - isTableEditable: true, - displayFormatOverride: .phpSerialized - ) - #expect(resolver.resolve(context) == .viewPhpSerialized) + @Test("editable BLOB column returns editBlob") + func editableBlobColumnReturnsEditBlob() { + let context = ContextFactory.make(value: "x", columnType: .blob(rawType: "BLOB"), isTableEditable: true) + #expect(resolver.resolve(context) == .editBlob) } - @Test("editable override .json forces editJson") - func editableOverrideJsonForces() { - let context = ContextFactory.make( - value: "plain", - isTableEditable: true, - displayFormatOverride: .json - ) - #expect(resolver.resolve(context) == .editJson) + @Test("JSON-looking text is not content-routed; it edits inline") + func jsonLikeTextEditsInline() { + let context = ContextFactory.make(value: #"{"k":1}"#, isTableEditable: true) + #expect(resolver.resolve(context) == .editInline(value: #"{"k":1}"#)) } - @Test("editable override .raw bypasses JSON content detection") - func editableOverrideRawSkipsJson() { - let context = ContextFactory.make( - value: #"{"k":1}"#, - columnType: .text(rawType: "TEXT"), - isTableEditable: true, - displayFormatOverride: .raw - ) - #expect(resolver.resolve(context) == .editInline(value: #"{"k":1}"#)) + @Test("editable override .json opens the JSON editor") + func editableOverrideJsonOpensEditor() { + let context = ContextFactory.make(value: "plain", isTableEditable: true, displayFormatOverride: .json) + #expect(resolver.resolve(context) == .editJson) } - @Test("editable override .raw bypasses PHP content detection") - func editableOverrideRawSkipsPhp() { - let context = ContextFactory.make( - value: "a:0:{}", - columnType: .text(rawType: "TEXT"), - isTableEditable: true, - displayFormatOverride: .raw - ) - #expect(resolver.resolve(context) == .editInline(value: "a:0:{}")) + @Test("editable override .phpSerialized shows the PHP viewer") + func editableOverridePhpShowsViewer() { + let context = ContextFactory.make(value: "plain", isTableEditable: true, displayFormatOverride: .phpSerialized) + #expect(resolver.resolve(context) == .viewPhpSerialized) } - @Test("editable override .raw on multiline value returns editOverlay") - func editableOverrideRawMultilineReturnsOverlay() { + @Test("editable override .uuid still edits inline") + func editableOverrideUuidEditsInline() { let context = ContextFactory.make( - value: "line1\nline2", + value: "0x00", columnType: .text(rawType: "TEXT"), isTableEditable: true, - displayFormatOverride: .raw + displayFormatOverride: .uuid ) - #expect(resolver.resolve(context) == .editOverlay(value: "line1\nline2")) - } - - @Test("editable JSON column returns editJson") - func editableJsonColumnReturnsEditJson() { - let context = ContextFactory.make(value: "{}", columnType: .json(rawType: "JSON"), isTableEditable: true) - #expect(resolver.resolve(context) == .editJson) - } - - @Test("editable BLOB column returns editBlob") - func editableBlobColumnReturnsEditBlob() { - let context = ContextFactory.make(value: nil, columnType: .blob(rawType: "BLOB"), isTableEditable: true) - #expect(resolver.resolve(context) == .editBlob) + #expect(resolver.resolve(context) == .editInline(value: "0x00")) } - @Test("editable foreign key column returns editInline (FK popover is not opened by double-click)") + @Test("editable foreign key column returns editInline") func editableForeignKeyReturnsEditInline() { let context = ContextFactory.make(value: "1", columnType: .integer(rawType: "INT"), isTableEditable: true) #expect(resolver.resolve(context) == .editInline(value: "1")) } - @Test("editable boolean column returns editInline, not a picker (pickers are chevron-only)") + @Test("editable boolean column returns editInline, not a picker") func editableBooleanColumnReturnsEditInline() { let context = ContextFactory.make(value: "true", columnType: .boolean(rawType: "BOOL"), isTableEditable: true) #expect(resolver.resolve(context) == .editInline(value: "true")) diff --git a/TableProTests/Views/Results/DataGridCellViewDoubleClickTests.swift b/TableProTests/Views/Results/DataGridCellViewDoubleClickTests.swift new file mode 100644 index 000000000..d64c89087 --- /dev/null +++ b/TableProTests/Views/Results/DataGridCellViewDoubleClickTests.swift @@ -0,0 +1,89 @@ +// +// DataGridCellViewDoubleClickTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@MainActor +private final class RecordingAccessoryDelegate: DataGridCellAccessoryDelegate { + var doubleClicks: [(row: Int, columnIndex: Int)] = [] + var chevronClicks: [(row: Int, columnIndex: Int)] = [] + var fkClicks: [(row: Int, columnIndex: Int, openInNewTab: Bool)] = [] + + func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int, openInNewTab: Bool) { + fkClicks.append((row, columnIndex, openInNewTab)) + } + + func dataGridCellDidClickChevron(row: Int, columnIndex: Int) { + chevronClicks.append((row, columnIndex)) + } + + func dataGridCellDidDoubleClick(row: Int, columnIndex: Int) { + doubleClicks.append((row, columnIndex)) + } +} + +@Suite("DataGridCellView double-click") +@MainActor +struct DataGridCellViewDoubleClickTests { + private func makeCell(row: Int, columnIndex: Int) -> DataGridCellView { + let cell = DataGridCellView(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + cell.configure( + kind: .json, + content: DataGridCellContent(displayText: "{}", rawValue: "{}", placeholder: nil), + state: DataGridCellState( + visualState: .empty, + isFocused: false, + isEditable: true, + isLargeDataset: false, + row: row, + columnIndex: columnIndex + ), + palette: .placeholder + ) + return cell + } + + private func mouseDownEvent(clickCount: Int) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: .leftMouseDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: clickCount, + pressure: 1 + )) + } + + @Test("Double-click reports the cell's row and column to the delegate") + func doubleClickReportsCellPosition() throws { + let cell = makeCell(row: 3, columnIndex: 2) + let delegate = RecordingAccessoryDelegate() + cell.accessoryDelegate = delegate + + cell.mouseDown(with: try mouseDownEvent(clickCount: 2)) + + #expect(delegate.doubleClicks.count == 1) + #expect(delegate.doubleClicks.first?.row == 3) + #expect(delegate.doubleClicks.first?.columnIndex == 2) + #expect(delegate.chevronClicks.isEmpty) + #expect(delegate.fkClicks.isEmpty) + } + + @Test("Single click does not report a double-click") + func singleClickDoesNotReportDoubleClick() throws { + let cell = makeCell(row: 1, columnIndex: 0) + let delegate = RecordingAccessoryDelegate() + cell.accessoryDelegate = delegate + + cell.mouseDown(with: try mouseDownEvent(clickCount: 1)) + + #expect(delegate.doubleClicks.isEmpty) + } +} diff --git a/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift b/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift new file mode 100644 index 000000000..9eed4025d --- /dev/null +++ b/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift @@ -0,0 +1,61 @@ +// +// InlineEditEligibilityTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class StubColumnLayoutPersister: ColumnLayoutPersisting { + func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} + func clear(for tableName: String, connectionId: UUID) {} +} + +@Suite("Inline edit eligibility") +@MainActor +struct InlineEditEligibilityTests { + private func makeCoordinator(columnType: ColumnType, value: String) -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: StubColumnLayoutPersister() + ) + let tableRows = TableRows.from( + queryRows: [[PluginCellValue.text(value)]], + columns: ["col"], + columnTypes: [columnType] + ) + coordinator.tableRowsProvider = { tableRows } + return coordinator + } + + private func isEditable(_ eligibility: TableViewCoordinator.EditEligibility) -> Bool { + if case .editable = eligibility { return true } + return false + } + + @Test("JSON column is eligible for inline editing") + func jsonColumnIsInlineEditable() { + let coordinator = makeCoordinator(columnType: .json(rawType: "JSON"), value: #"{"k":1}"#) + #expect(isEditable(coordinator.editEligibility(row: 0, columnIndex: 0))) + } + + @Test("BLOB column is not eligible for inline editing") + func blobColumnIsNotInlineEditable() { + let coordinator = makeCoordinator(columnType: .blob(rawType: "BLOB"), value: "x") + #expect(!isEditable(coordinator.editEligibility(row: 0, columnIndex: 0))) + } + + @Test("plain text column is eligible for inline editing") + func textColumnIsInlineEditable() { + let coordinator = makeCoordinator(columnType: .text(rawType: "TEXT"), value: "hello") + #expect(isEditable(coordinator.editEligibility(row: 0, columnIndex: 0))) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 75db1bc73..ad9680062 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -215,7 +215,7 @@ If ENUM/SET metadata is unavailable (e.g., from a complex query), the cell falls ### JSON Viewer -Double-click a JSON cell to open the viewer. Switch between **Text** and **Tree** modes with the toggle at the top. +Click the chevron in a JSON cell to open the viewer. Double-click or `Enter` edits the value inline instead. Switch between **Text** and **Tree** modes with the toggle at the top. Text mode shows syntax-highlighted JSON with brace matching. Tree mode shows a collapsible outline you can search, expand/collapse all, and right-click to copy values or key paths like `$.users[0].email`. diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index 642ed1b89..5eaeceb85 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -9,12 +9,12 @@ Open JSON values from cells in two view modes: Text and Tree. ## Open -- Double-click a cell whose value parses as JSON, or -- Click the chevron in a JSON-typed column. +- **JSON-typed column**: click the chevron in the cell. Double-click or `Enter` edits the value inline instead. +- **`TEXT` or `VARCHAR` column holding JSON**: right-click the header and pick **Display as > JSON**. Double-click or `Enter` then opens the viewer. -The viewer opens as a popover anchored to the cell. JSON values stored in a `TEXT` or `VARCHAR` column open the viewer the same way; double-click runs a parse and routes to the structured viewer when the value parses cleanly. +The viewer opens as a popover anchored to the cell. -To opt out for a specific column, right-click the column header and pick **Display as > Raw Value**. Pick **Display as > JSON** to force JSON routing on a column whose values do not start with `{` or `[`. +To stop treating a column as JSON, right-click the header and pick **Display as > Raw Value**. ## Modes From 6dae692eb0bb1162460c530b4b5f99d15e31e9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 5 Jun 2026 13:07:46 +0700 Subject: [PATCH 2/2] refactor(datagrid): scope click-on-focused editing to editable cells --- CHANGELOG.md | 2 +- .../Results/Extensions/DataGridView+Editing.swift | 7 +++++++ TablePro/Views/Results/KeyHandlingTableView.swift | 3 ++- .../Extensions/InlineEditEligibilityTests.swift | 11 +++-------- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d3a325d..1fceaa727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- 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. +- 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) - 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) - 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) - Pagination and other status bar buttons no longer get blocked by the window resize zone in the bottom-right corner. (#1569) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index bdbe32196..89041c21c 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -40,6 +40,13 @@ extension TableViewCoordinator { return .editable(value: value) } + func canStartInlineEdit(row: Int, columnIndex: Int) -> Bool { + if case .editable = editEligibility(row: row, columnIndex: columnIndex) { + return true + } + return false + } + func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { false } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 31b5ccaf8..0b39899f7 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -175,7 +175,8 @@ final class KeyHandlingTableView: NSTableView { if modifiers.isEmpty, alreadyFocusedHere, - selectedRowIndexes.count == 1 { + selectedRowIndexes.count == 1, + coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumn) == true { coordinator?.handleCellInteraction( row: clickedRow, tableColumn: clickedColumn, diff --git a/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift b/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift index 9eed4025d..20346a515 100644 --- a/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift +++ b/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift @@ -36,26 +36,21 @@ struct InlineEditEligibilityTests { return coordinator } - private func isEditable(_ eligibility: TableViewCoordinator.EditEligibility) -> Bool { - if case .editable = eligibility { return true } - return false - } - @Test("JSON column is eligible for inline editing") func jsonColumnIsInlineEditable() { let coordinator = makeCoordinator(columnType: .json(rawType: "JSON"), value: #"{"k":1}"#) - #expect(isEditable(coordinator.editEligibility(row: 0, columnIndex: 0))) + #expect(coordinator.canStartInlineEdit(row: 0, columnIndex: 0)) } @Test("BLOB column is not eligible for inline editing") func blobColumnIsNotInlineEditable() { let coordinator = makeCoordinator(columnType: .blob(rawType: "BLOB"), value: "x") - #expect(!isEditable(coordinator.editEligibility(row: 0, columnIndex: 0))) + #expect(!coordinator.canStartInlineEdit(row: 0, columnIndex: 0)) } @Test("plain text column is eligible for inline editing") func textColumnIsInlineEditable() { let coordinator = makeCoordinator(columnType: .text(rawType: "TEXT"), value: "hello") - #expect(isEditable(coordinator.editEligibility(row: 0, columnIndex: 0))) + #expect(coordinator.canStartInlineEdit(row: 0, columnIndex: 0)) } }