Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Connection URL parsing: SSH `user:password@host` split, `safeModeLevel` from TablePlus URLs, case-insensitive query params
- Connection URL export: SSH password, Redis database index, MongoDB auth params (`authSource`, `authMechanism`, `replicaSet`), and multi-host
- SSH Private Key auth resolves keys from `~/.ssh/config` and default locations (`id_ed25519`, `id_rsa`, `id_ecdsa`) when no explicit key path is set
- Click a focused cell to start editing without a second click
- Data grid focus ring follows the system accent color and contrast settings
- Data grid cells expose accessibility row and column index ranges to VoiceOver on all dataset sizes

### Changed

Expand All @@ -30,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- QueryTab.resultVersion split: schemaVersion (column shape) on QueryTab, row mutations through delegate deltas, sort completion through a single delegate replace call. Pin toggle, sort completion, and applyMultiStatementResults no longer fan out a redundant reload signal.
- Row data lives in a per-coordinator RowDataStore keyed by tab.id rather than on QueryTab itself, so SwiftUI's @Observable tracking on tabManager.tabs no longer fires for row writes.
- DataGridConfiguration is Equatable; DataGridIdentity covers tabType, tableName, and primaryKeyColumns so updateNSView short-circuits when nothing structural changed. DataTabGridDelegate properties are wired in onAppear / onChange instead of in the body.
- Date picker popover font follows the data grid font setting

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Results/CellOverlayEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
newPanel.contentView = scrollView
newPanel.contentView?.wantsLayer = true
newPanel.contentView?.layer?.borderWidth = 2
newPanel.contentView?.layer?.borderColor = NSColor.selectedControlColor.safeCGColor
newPanel.contentView?.layer?.borderColor = NSColor.keyboardFocusIndicatorColor.safeCGColor
newPanel.contentView?.layer?.cornerRadius = 2
newPanel.contentView?.layer?.masksToBounds = true

Expand Down
50 changes: 9 additions & 41 deletions TablePro/Views/Results/DataGridCellFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,40 +23,16 @@ final class CellChevronButton: NSButton {
var cellColumnIndex: Int = -1
}

/// Factory for creating data grid cell views
@MainActor
final class DataGridCellFactory {
private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell")
private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCell")

/// Large dataset threshold - above this, disable expensive visual features
private let largeDatasetThreshold = 5_000

// MARK: - Cached Settings

/// Cached NULL display string (updated via settings notification)
private var nullDisplayString: String = AppSettingsManager.shared.dataGrid.nullDisplay
private var settingsObserver: NSObjectProtocol?

// MARK: - Cached VoiceOver State

private static var cachedVoiceOverEnabled: Bool = NSWorkspace.shared.isVoiceOverEnabled
// Observer lives for app lifetime — no removal needed since DataGridCellFactory is a static singleton cache
private static let voiceOverObserver: NSObjectProtocol? = {
NotificationCenter.default.addObserver(
forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
DataGridCellFactory.cachedVoiceOverEnabled = NSWorkspace.shared.isVoiceOverEnabled
}
}
}()

init() {
_ = Self.voiceOverObserver

settingsObserver = NotificationCenter.default.addObserver(
forName: .dataGridSettingsDidChange,
object: nil,
Expand Down Expand Up @@ -119,9 +95,8 @@ final class DataGridCellFactory {

cell.stringValue = "\(row + 1)"
cell.textColor = visualState.isDeleted ? ThemeEngine.shared.colors.dataGrid.deletedText : .secondaryLabelColor
if Self.cachedVoiceOverEnabled {
cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), row + 1))
}
cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), row + 1))
cellView.setAccessibilityRowIndexRange(NSRange(location: row, length: 1))

return cellView
}
Expand Down Expand Up @@ -259,23 +234,16 @@ final class DataGridCellFactory {
gridCellView.changeBackgroundColor = nil
}

if isLargeDataset {
gridCellView.layer?.borderWidth = 0
} else if isFocused {
gridCellView.layer?.borderWidth = 2
gridCellView.layer?.borderColor = ThemeEngine.shared.colors.dataGrid.focusBorderCG
} else {
gridCellView.layer?.borderWidth = 0
}
gridCellView.isFocusedCell = isFocused

CATransaction.commit()

if Self.cachedVoiceOverEnabled {
let accessibilityValue = rawValue ?? String(localized: "NULL")
cell.setAccessibilityLabel(
String(format: String(localized: "Row %d, column %d: %@"), row + 1, columnIndex + 1, accessibilityValue)
)
}
let accessibilityValue = rawValue ?? String(localized: "NULL")
cell.setAccessibilityLabel(
String(format: String(localized: "Row %d, column %d: %@"), row + 1, columnIndex + 1, accessibilityValue)
)
gridCellView.setAccessibilityRowIndexRange(NSRange(location: row, length: 1))
gridCellView.setAccessibilityColumnIndexRange(NSRange(location: columnIndex, length: 1))

return gridCellView
}
Expand Down
23 changes: 19 additions & 4 deletions TablePro/Views/Results/DataGridCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@

import AppKit

/// Custom cell view that uses a background subview for change-state coloring.
/// AppKit's `NSTableRowView` sets `backgroundStyle` to `.emphasized` when the
/// row is selected — we hide the background view so the native selection highlight
/// shows through.
final class DataGridCellView: NSTableCellView {
var fkArrowButton: FKArrowButton?
var chevronButton: CellChevronButton?
var textFieldTrailing: NSLayoutConstraint?

var isFocusedCell: Bool = false {
didSet {
guard oldValue != isFocusedCell else { return }
updateFocusBorder()
}
}

private lazy var backgroundView: NSView = {
let view = NSView()
view.wantsLayer = true
Expand Down Expand Up @@ -43,6 +46,18 @@ final class DataGridCellView: NSTableCellView {
override var backgroundStyle: NSView.BackgroundStyle {
didSet {
backgroundView.isHidden = (backgroundStyle == .emphasized) || (changeBackgroundColor == nil)
if isFocusedCell { updateFocusBorder() }
}
}

private func updateFocusBorder() {
if isFocusedCell {
layer?.borderWidth = 2
layer?.borderColor = backgroundStyle == .emphasized
? NSColor.white.withAlphaComponent(0.8).cgColor
: NSColor.keyboardFocusIndicatorColor.cgColor
} else {
layer?.borderWidth = 0
}
}
}
3 changes: 2 additions & 1 deletion TablePro/Views/Results/DatePickerCellEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ final class DatePickerCellEditor: NSDatePicker {
private func setupUI() {
datePickerStyle = .textFieldAndStepper
datePickerElements = [.yearMonthDay, .hourMinuteSecond]
font = .monospacedSystemFont(ofSize: 13, weight: .regular)
let pointSize = ThemeEngine.shared.dataGridFonts.regular.pointSize
font = .monospacedSystemFont(ofSize: pointSize, weight: .regular)
isBezeled = false
isBordered = false
drawsBackground = false
Expand Down
92 changes: 51 additions & 41 deletions TablePro/Views/Results/Extensions/DataGridView+Editing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,67 @@ import AppKit
import SwiftUI

extension TableViewCoordinator {
func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool {
guard isEditable,
let tableColumn = tableColumn else { return false }
enum InlineEditEligibility {
case eligible
case needsOverlayEditor(value: String)
case blocked
}

let columnId = tableColumn.identifier.rawValue
guard columnId != "__rowNumber__",
!changeManager.isRowDeleted(row) else { return false }
func inlineEditEligibility(row: Int, columnIndex: Int) -> InlineEditEligibility {
guard isEditable else { return .blocked }
guard row >= 0, columnIndex >= 0, columnIndex < rowProvider.columns.count else { return .blocked }
guard !changeManager.isRowDeleted(row) else { return .blocked }

let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? []
if !immutable.isEmpty,
let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier),
columnIndex < rowProvider.columns.count,
immutable.contains(rowProvider.columns[columnIndex]) {
return false
if immutable.contains(rowProvider.columns[columnIndex]) {
return .blocked
}

// Popover-editor columns (date/FK/JSON) are only editable via
// double-click (handleDoubleClick). Block inline editing for them.
if let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier) {
if columnIndex < rowProvider.columns.count {
let columnName = rowProvider.columns[columnIndex]
if rowProvider.columnForeignKeys[columnName] != nil { return false }
}
if columnIndex < rowProvider.columnTypes.count {
let ct = rowProvider.columnTypes[columnIndex]
if ct.isDateType || ct.isJsonType || ct.isEnumType || ct.isSetType || ct.isBlobType || ct.isBooleanType { return false }
}
if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) {
return false
}
if let typePickerCols = typePickerColumns, typePickerCols.contains(columnIndex) {
return false
}
let columnName = rowProvider.columns[columnIndex]
if rowProvider.columnForeignKeys[columnName] != nil { return .blocked }

// Text columns containing JSON use JSON editor popover
if let value = rowProvider.value(atRow: row, column: columnIndex),
value.looksLikeJson {
return false
if columnIndex < rowProvider.columnTypes.count {
let ct = rowProvider.columnTypes[columnIndex]
if ct.isBooleanType || ct.isDateType || ct.isJsonType
|| ct.isBlobType || ct.isEnumType || ct.isSetType {
return .blocked
}
}

// Multiline values use overlay editor — block inline field editor
if let value = rowProvider.value(atRow: row, column: columnIndex),
value.containsLineBreak {
let tableColumnIdx = tableView.column(withIdentifier: tableColumn.identifier)
guard tableColumnIdx >= 0 else { return false }
showOverlayEditor(tableView: tableView, row: row, column: tableColumnIdx, columnIndex: columnIndex, value: value)
return false
}
if dropdownColumns?.contains(columnIndex) == true { return .blocked }
if typePickerColumns?.contains(columnIndex) == true { return .blocked }

if let value = rowProvider.value(atRow: row, column: columnIndex) {
if value.containsLineBreak { return .needsOverlayEditor(value: value) }
if value.looksLikeJson { return .blocked }
}

return true
return .eligible
}

func canStartInlineEdit(row: Int, columnIndex: Int) -> Bool {
if case .eligible = inlineEditEligibility(row: row, columnIndex: columnIndex) {
return true
}
return false
}

func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool {
guard let tableColumn else { return false }
guard tableColumn.identifier.rawValue != "__rowNumber__" else { return false }
guard let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier) else { return false }

switch inlineEditEligibility(row: row, columnIndex: columnIndex) {
case .eligible:
return true
case .needsOverlayEditor(let value):
let tableColumnIdx = tableView.column(withIdentifier: tableColumn.identifier)
guard tableColumnIdx >= 0 else { return false }
showOverlayEditor(tableView: tableView, row: row, column: tableColumnIdx, columnIndex: columnIndex, value: value)
return false
case .blocked:
return false
}
}

// MARK: - Overlay Editor (Multiline)
Expand Down
17 changes: 12 additions & 5 deletions TablePro/Views/Results/KeyHandlingTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ final class KeyHandlingTableView: NSTableView {
// MARK: - TablePlus-Style Cell Focus

override func mouseDown(with event: NSEvent) {
// Become first responder to capture keyboard events (especially Delete key)
window?.makeFirstResponder(self)

let point = convert(event.locationInWindow, from: nil)
Expand All @@ -71,32 +70,40 @@ final class KeyHandlingTableView: NSTableView {
return
}

// Reset anchor/pivot when clicking without Shift
if clickedRow >= 0 && !event.modifierFlags.contains(.shift) {
selectionAnchor = clickedRow
selectionPivot = clickedRow
}

let alreadyFocusedHere = clickedRow >= 0
&& clickedColumn >= 0
&& clickedRow == focusedRow
&& clickedColumn == focusedColumn

super.mouseDown(with: event)

// Only handle editing for valid clicks on data cells (not row number column)
guard clickedRow >= 0,
clickedColumn >= 0,
clickedColumn < numberOfColumns else {
return
}

// Skip row number column
let column = tableColumns[clickedColumn]
if column.identifier.rawValue == "__rowNumber__" {
focusedRow = -1
focusedColumn = -1
return
}

// Update focus (edit mode is triggered by double-click, not single click)
focusedRow = clickedRow
focusedColumn = clickedColumn

if alreadyFocusedHere && event.clickCount == 1 && selectedRowIndexes.count == 1 {
let dataColumnIndex = DataGridView.dataColumnIndex(for: clickedColumn)
if coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumnIndex) == true {
editColumn(clickedColumn, row: clickedRow, with: nil, select: true)
}
}
}

// MARK: - Standard Edit Menu Actions
Expand Down
33 changes: 21 additions & 12 deletions TablePro/Views/Results/TableRowViewWithMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ final class TableRowViewWithMenu: NSTableRowView {
menu.addItem(NSMenuItem.separator())
}

// Set Value (editable + column clicked)
if coordinator.isEditable && dataColumnIndex >= 0 {
let setValueMenu = NSMenu()

Expand All @@ -140,17 +139,27 @@ final class TableRowViewWithMenu: NSTableRowView {
emptyItem.target = self
setValueMenu.addItem(emptyItem)

let nullItem = NSMenuItem(
title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "")
nullItem.representedObject = dataColumnIndex
nullItem.target = self
setValueMenu.addItem(nullItem)

let defaultItem = NSMenuItem(
title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "")
defaultItem.representedObject = dataColumnIndex
defaultItem.target = self
setValueMenu.addItem(defaultItem)
let columnName = dataColumnIndex < coordinator.rowProvider.columns.count
? coordinator.rowProvider.columns[dataColumnIndex]
: nil

let isNullable = columnName.flatMap { coordinator.rowProvider.columnNullable[$0] } ?? true
if isNullable {
let nullItem = NSMenuItem(
title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "")
nullItem.representedObject = dataColumnIndex
nullItem.target = self
setValueMenu.addItem(nullItem)
}

let hasDefault = columnName.flatMap({ coordinator.rowProvider.columnDefaults[$0] ?? nil }) != nil
if hasDefault {
let defaultItem = NSMenuItem(
title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "")
defaultItem.representedObject = dataColumnIndex
defaultItem.target = self
setValueMenu.addItem(defaultItem)
}

let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "")
setValueItem.submenu = setValueMenu
Expand Down
Loading