Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. (#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)
Expand Down
32 changes: 4 additions & 28 deletions TablePro/Views/Results/CellInteractionResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -22,19 +19,13 @@ 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
self.value = value
self.isTableEditable = isTableEditable
self.isRowDeleted = isRowDeleted
self.isImmutableColumn = isImmutableColumn
self.columnName = columnName
self.connectionId = connectionId
self.tableName = tableName
self.displayFormatOverride = displayFormatOverride
}
}
Expand All @@ -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)
Comment on lines +62 to 63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the JSON viewer for read-only JSON columns

In read-only result grids (isTableEditable == false), native JSON columns now hit the .raw/.none branch and return viewInline, because effectiveFormat defaults to .raw and the resolver no longer checks columnType.isJsonType. The documented fallback chevron is not available in that context (DataGridCellView only draws chevrons when the cell is editable), so users double-clicking or pressing Enter on JSON columns from read-only queries lose the structured JSON viewer entirely.

Useful? React with 👍 / 👎.

Comment on lines +62 to 63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore auto-routing for PHP serialized cells

When no display-format override is saved, effectiveFormat returns .raw, so this branch now sends PHP-serialized TEXT values to plainText instead of running CellValueContentDetector. That breaks the documented auto-open path (docs/features/php-viewer.mdx:12-15): double-click/Enter on a value like a:0:{} just starts text editing or inline viewing unless the user manually sets Display as > PHP Serialized.

Useful? React with 👍 / 👎.

}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
4 changes: 4 additions & 0 deletions TablePro/Views/Results/Cells/DataGridCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 0 additions & 2 deletions TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 1 addition & 12 deletions TablePro/Views/Results/Extensions/DataGridView+Click.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -62,9 +54,6 @@ extension TableViewCoordinator {
isTableEditable: isEditable,
isRowDeleted: changeManager.isRowDeleted(row),
isImmutableColumn: immutable.contains(columnName),
columnName: columnName,
connectionId: connectionId,
tableName: tableName,
displayFormatOverride: override
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
7 changes: 6 additions & 1 deletion TablePro/Views/Results/KeyHandlingTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,12 @@ final class KeyHandlingTableView: NSTableView {
alreadyFocusedHere,
selectedRowIndexes.count == 1,
coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumn) == true {
coordinator?.beginCellEdit(row: clickedRow, tableColumnIndex: clickedColumn)
coordinator?.handleCellInteraction(
row: clickedRow,
tableColumn: clickedColumn,
columnIndex: dataColumn,
tableView: self
)
}
}

Expand Down
159 changes: 45 additions & 114 deletions TableProTests/Views/Results/CellInteractionResolverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"),
Expand All @@ -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"),
Expand All @@ -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")
Expand All @@ -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"))
Expand Down
Loading
Loading