Skip to content

Commit d5d53ce

Browse files
authored
fix(datagrid): edit date cells inline and add date presets to Set Value (#1233)
* fix(datagrid): edit date cells inline and add date presets to Set Value * refactor(datagrid): consolidate isTimeOnly and tighten Set Value DATE presets * fix(datagrid): isolate inline editor undo, dismiss editor on row mutations, follow FK preview on row changes * fix(datagrid): route Cmd+Z through first responder and track FK popover by focused row
1 parent 868a646 commit d5d53ce

22 files changed

Lines changed: 524 additions & 343 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Right-click Set Value submenu on date, datetime, and timestamp cells now offers current-value SQL function presets (`CURRENT_DATE` for date columns, `CURRENT_TIME` for time columns, `NOW()` and `CURRENT_TIMESTAMP` for datetime and timestamp columns).
13+
1014
### Changed
1115

1216
- The connection window shows the connecting state inline with a Cancel button instead of an empty sidebar.
17+
- Date, datetime, and timestamp cells use the same inline text editor as other columns; the popover date picker is removed.
18+
- The foreign key preview popover now follows the selected row when you arrow up or down, refreshing both the anchor and the displayed reference row. Arrow left or right (column change) and row mutations dismiss the popover.
1319

1420
### Fixed
1521

1622
- Closing the connection window during a slow connect no longer leaves a stuck "Connecting…" window or a stray failure alert (#1185).
23+
- Editing a NULL cell and dismissing without typing no longer flips the value to an empty string or marks the row as modified.
24+
- Data grid cells with a chevron accessory (enum, boolean, JSON, blob) no longer truncate short values that fit the full cell width.
25+
- Double-clicking an enum, set, or boolean cell now opens the inline text editor; the chevron still opens the picker popover.
26+
- Double-clicking another cell while editing no longer delays the new editor or silently drops pending changes on the previous cell.
27+
- DATE columns no longer render a phantom `00:00:00` time suffix; the display now matches the stored value and the editor.
28+
- Editing a cell, dismissing the editor, then pressing Cmd+Z multiple times no longer crashes. The inline editor now keeps its typing-undo stack private instead of pushing entries onto the window's undo manager.
29+
- Pressing Cmd+Z right after Add Row no longer leaves a stranded cell editor floating over the removed row; the editor now dismisses whenever rows are inserted, removed, or replaced.
30+
- Adding a new row no longer renders the new row view on top of the auto-opened cell editor mid-animation; the editor is kept at the front of the table view's subview list whenever a sibling is added.
31+
- Cmd+Z and Cmd+Shift+Z while editing a cell now undo and redo typing in the editor instead of jumping straight to data-grid changes; the menu commands route through the first responder before falling back to the window's undo manager.
1732

1833
## [0.40.1] - 2026-05-12
1934

TablePro/Core/Services/ColumnType.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ enum ColumnType: Equatable {
7676
}
7777
}
7878

79+
var isTimeOnly: Bool {
80+
guard isDateType, let raw = rawType?.uppercased() else { return false }
81+
return raw == "TIME"
82+
|| raw == "TIMETZ"
83+
|| raw == "TIME WITHOUT TIME ZONE"
84+
|| raw == "TIME WITH TIME ZONE"
85+
}
86+
7987
/// Whether this type represents long text that should use multi-line editor
8088
/// Checks for TEXT, LONGTEXT, MEDIUMTEXT, TINYTEXT, CLOB types
8189
var isLongText: Bool {

TablePro/Core/Services/Formatting/CellDisplayFormatter.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ enum CellDisplayFormatter {
2626
displayValue = ValueDisplayFormatService.applyFormat(value, format: displayFormat)
2727
} else if let columnType {
2828
if columnType.isDateType {
29-
if let formatted = DateFormattingService.shared.format(dateString: displayValue) {
29+
if let formatted = DateFormattingService.shared.format(
30+
dateString: displayValue,
31+
columnType: columnType
32+
) {
3033
displayValue = formatted
3134
}
3235
} else if BlobFormattingService.shared.requiresFormatting(columnType: columnType) {

TablePro/Core/Services/Formatting/DateFormattingService.swift

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ final class DateFormattingService {
1717

1818
/// Cached formatter for current user-selected format
1919
private var formatter: DateFormatter
20+
private var dateOnlyFormatter: DateFormatter
21+
private var timeOnlyFormatter: DateFormatter
2022

2123
/// Current date format option
2224
private(set) var currentFormat: DateFormatOption
@@ -37,7 +39,9 @@ final class DateFormattingService {
3739
// Initialize with default format (ISO 8601)
3840
// Will be updated by AppSettingsManager after it completes initialization
3941
self.currentFormat = .iso8601
40-
self.formatter = Self.createFormatter(for: .iso8601)
42+
self.formatter = Self.createFormatter(format: DateFormatOption.iso8601.formatString)
43+
self.dateOnlyFormatter = Self.createFormatter(format: DateFormatOption.iso8601.dateOnlyFormatString)
44+
self.timeOnlyFormatter = Self.createFormatter(format: DateFormatOption.iso8601.timeOnlyFormatString)
4145
self.parsers = Self.createParsers()
4246
formatCache.countLimit = 100_000
4347
}
@@ -48,7 +52,9 @@ final class DateFormattingService {
4852
func updateFormat(_ format: DateFormatOption) {
4953
guard format != currentFormat else { return }
5054
currentFormat = format
51-
formatter = Self.createFormatter(for: format)
55+
formatter = Self.createFormatter(format: format.formatString)
56+
dateOnlyFormatter = Self.createFormatter(format: format.dateOnlyFormatString)
57+
timeOnlyFormatter = Self.createFormatter(format: format.timeOnlyFormatString)
5258
// Clear cache when format changes since all cached values are now stale
5359
formatCache.removeAllObjects()
5460
}
@@ -62,44 +68,59 @@ final class DateFormattingService {
6268

6369
/// Format a string date value (parse then format)
6470
/// - Parameter dateString: Date string from database (ISO 8601, MySQL timestamp, etc.)
71+
/// - Parameter columnType: Column type, used to pick date-only / time-only / datetime variant
6572
/// - Returns: Formatted date string, or nil if unparseable
66-
func format(dateString: String) -> String? {
67-
// Check cache first
68-
let cacheKey = dateString as NSString
73+
func format(dateString: String, columnType: ColumnType? = nil) -> String? {
74+
let targetFormatter = formatter(for: columnType)
75+
let cacheKey = "\(formatBucket(for: columnType))|\(dateString)" as NSString
6976
if let cached = formatCache.object(forKey: cacheKey) {
70-
// Empty string in cache means unparseable
7177
return cached.length == 0 ? nil : cached as String
7278
}
7379

7480
if let date = parsers[lastSuccessfulParserIndex].date(from: dateString) {
75-
let result = format(date)
81+
let result = targetFormatter.string(from: date)
7682
formatCache.setObject(result as NSString, forKey: cacheKey)
7783
return result
7884
}
7985
for index in parsers.indices where index != lastSuccessfulParserIndex {
8086
if let date = parsers[index].date(from: dateString) {
8187
lastSuccessfulParserIndex = index
82-
let result = format(date)
88+
let result = targetFormatter.string(from: date)
8389
formatCache.setObject(result as NSString, forKey: cacheKey)
8490
return result
8591
}
8692
}
8793

88-
// Could not parse - cache empty string to avoid re-parsing
8994
formatCache.setObject("" as NSString, forKey: cacheKey)
9095
return nil
9196
}
9297

98+
private func formatter(for columnType: ColumnType?) -> DateFormatter {
99+
switch columnType {
100+
case .date:
101+
return dateOnlyFormatter
102+
case .timestamp, .datetime:
103+
return columnType?.isTimeOnly == true ? timeOnlyFormatter : formatter
104+
default:
105+
return formatter
106+
}
107+
}
108+
109+
private func formatBucket(for columnType: ColumnType?) -> String {
110+
switch columnType {
111+
case .date: return "d"
112+
case .timestamp, .datetime: return columnType?.isTimeOnly == true ? "t" : "dt"
113+
default: return "dt"
114+
}
115+
}
116+
93117
// MARK: - Private Helper Methods
94118

95-
/// Create formatter for a specific format option
96-
/// - Parameter option: The date format option
97-
/// - Returns: Configured DateFormatter
98-
private static func createFormatter(for option: DateFormatOption) -> DateFormatter {
119+
private static func createFormatter(format: String) -> DateFormatter {
99120
let formatter = DateFormatter()
100-
formatter.dateFormat = option.formatString
101-
formatter.locale = Locale.current // Use user's locale for localized formatting
102-
formatter.timeZone = TimeZone.current // Use user's timezone
121+
formatter.dateFormat = format
122+
formatter.locale = Locale.current
123+
formatter.timeZone = TimeZone.current
103124
return formatter
104125
}
105126

TablePro/Models/Settings/AppSettings.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,21 @@ enum DateFormatOption: String, Codable, CaseIterable, Identifiable {
109109
}
110110

111111
var formatString: String { rawValue }
112+
113+
var dateOnlyFormatString: String {
114+
switch self {
115+
case .iso8601, .iso8601Date: return "yyyy-MM-dd"
116+
case .usLong, .usShort: return "MM/dd/yyyy"
117+
case .euLong, .euShort: return "dd/MM/yyyy"
118+
}
119+
}
120+
121+
var timeOnlyFormatString: String {
122+
switch self {
123+
case .usLong: return "hh:mm:ss a"
124+
default: return "HH:mm:ss"
125+
}
126+
}
112127
}
113128

114129
/// Data grid settings

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -800,17 +800,23 @@ final class MainContentCommandActions {
800800
func undoChange() {
801801
if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure {
802802
coordinator?.structureActions?.undo?()
803-
} else {
804-
coordinator?.contentWindow?.undoManager?.undo()
803+
return
804+
}
805+
if NSApp.sendAction(NSSelectorFromString("undo:"), to: nil, from: nil) {
806+
return
805807
}
808+
coordinator?.contentWindow?.undoManager?.undo()
806809
}
807810

808811
func redoChange() {
809812
if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure {
810813
coordinator?.structureActions?.redo?()
811-
} else {
812-
coordinator?.contentWindow?.undoManager?.redo()
814+
return
815+
}
816+
if NSApp.sendAction(NSSelectorFromString("redo:"), to: nil, from: nil) {
817+
return
813818
}
819+
coordinator?.contentWindow?.undoManager?.redo()
814820
}
815821

816822
// MARK: - Group B Broadcast Subscribers

TablePro/Views/Results/CellOverlayEditor.swift

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,21 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
1919
private(set) var row: Int = -1
2020
private(set) var column: Int = -1
2121
private(set) var columnIndex: Int = -1
22+
private var initialValue: String = ""
2223

2324
var onCommit: ((_ row: Int, _ columnIndex: Int, _ newValue: String) -> Void)?
2425
var onTabNavigation: ((_ row: Int, _ column: Int, _ forward: Bool) -> Void)?
2526

2627
var isActive: Bool { container != nil }
2728

29+
var containerView: NSView? { container }
30+
31+
func raiseToFront() {
32+
guard let container, let tableView, container.superview === tableView else { return }
33+
guard tableView.subviews.last !== container else { return }
34+
tableView.addSubview(container)
35+
}
36+
2837
// MARK: - Show / Dismiss
2938

3039
func show(
@@ -34,12 +43,13 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
3443
columnIndex: Int,
3544
value: String
3645
) {
37-
dismiss(commit: false)
46+
dismiss(commit: true)
3847

3948
self.tableView = tableView
4049
self.row = row
4150
self.column = column
4251
self.columnIndex = columnIndex
52+
self.initialValue = value
4353

4454
let cellFrame = tableView.frameOfCell(atColumn: column, row: row)
4555
guard !cellFrame.isEmpty else { return }
@@ -112,18 +122,20 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
112122
guard let activeContainer = container, let activeTextView = textView else { return }
113123

114124
let newValue = activeTextView.string
125+
let originalValue = initialValue
115126

116127
removeDismissObservers()
117128

118129
activeContainer.removeFromSuperview()
119130
container = nil
120131
textView = nil
132+
initialValue = ""
121133

122134
if let tableView {
123135
tableView.window?.makeFirstResponder(tableView)
124136
}
125137

126-
if commit {
138+
if commit, newValue != originalValue {
127139
onCommit?(row, columnIndex, newValue)
128140
}
129141
}
@@ -139,7 +151,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
139151
object: clipView,
140152
queue: .main
141153
) { [weak self] _ in
142-
Task { @MainActor [weak self] in
154+
MainActor.assumeIsolated {
143155
self?.dismiss(commit: true)
144156
}
145157
}
@@ -150,7 +162,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
150162
object: tableView,
151163
queue: .main
152164
) { [weak self] _ in
153-
Task { @MainActor [weak self] in
165+
MainActor.assumeIsolated {
154166
self?.dismiss(commit: false)
155167
}
156168
}
@@ -160,7 +172,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
160172
object: nil,
161173
queue: .main
162174
) { [weak self] _ in
163-
Task { @MainActor [weak self] in
175+
MainActor.assumeIsolated {
164176
self?.dismiss(commit: true)
165177
}
166178
}
@@ -171,15 +183,14 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
171183
object: editorWindow,
172184
queue: .main
173185
) { [weak self] _ in
174-
Task { @MainActor [weak self] in
186+
MainActor.assumeIsolated {
175187
self?.dismiss(commit: true)
176188
}
177189
}
178190
}
179191

180192
outsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
181-
guard let self else { return event }
182-
Task { @MainActor [weak self] in
193+
MainActor.assumeIsolated {
183194
self?.handleOutsideClick(event: event)
184195
}
185196
return event
@@ -263,10 +274,14 @@ private final class OverlayContainerView: NSView {
263274
// MARK: - Overlay Text View
264275

265276
private final class OverlayTextView: NSTextView {
277+
private let storedUndoManager = UndoManager()
278+
266279
weak var overlayEditor: CellOverlayEditor?
267280

268281
private static let menuKeyEquivalents: Set<String> = ["s"]
269282

283+
override var undoManager: UndoManager? { storedUndoManager }
284+
270285
override func performKeyEquivalent(with event: NSEvent) -> Bool {
271286
if event.modifierFlags.contains(.command),
272287
let chars = event.charactersIgnoringModifiers,

TablePro/Views/Results/Cells/DataGridCellKind.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ enum DataGridCellKind: Equatable {
1010
case foreignKey
1111
case dropdown
1212
case boolean
13-
case date
1413
case json
1514
case blob
1615
}

TablePro/Views/Results/Cells/DataGridCellRegistry.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ final class DataGridCellRegistry {
4343
if isDropdownColumn { return .dropdown }
4444
if let type = columnType {
4545
if type.isBooleanType { return .boolean }
46-
if type.isDateType { return .date }
4746
if type.isJsonType { return .json }
4847
if type.isBlobType { return .blob }
4948
}

TablePro/Views/Results/Cells/DataGridCellView.swift

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,18 @@ final class DataGridCellView: NSView {
215215

216216
private func drawText(reservingTrailingWidth trailing: CGFloat) {
217217
guard !displayText.isEmpty else { return }
218-
let availableWidth = bounds.width - 2 * DataGridMetrics.cellHorizontalInset - trailing
219-
guard availableWidth > 0 else { return }
218+
let totalAvailable = bounds.width - 2 * DataGridMetrics.cellHorizontalInset
219+
guard totalAvailable > 0 else { return }
220220
guard let context = NSGraphicsContext.current?.cgContext else { return }
221221

222222
let fullLine = cachedCTLine()
223-
let lineToDraw: CTLine
224223
let typographicWidth = CTLineGetTypographicBounds(fullLine, nil, nil, nil)
224+
225+
let availableWidth: CGFloat = typographicWidth > Double(totalAvailable)
226+
? totalAvailable - trailing
227+
: totalAvailable
228+
229+
let lineToDraw: CTLine
225230
if typographicWidth > Double(availableWidth) {
226231
let ellipsis = makeEllipsisLine()
227232
lineToDraw = CTLineCreateTruncatedLine(fullLine, Double(availableWidth), .end, ellipsis) ?? fullLine
@@ -284,7 +289,7 @@ final class DataGridCellView: NSView {
284289
let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width
285290
let y = (bounds.height - size.height) / 2
286291
return NSRect(x: x, y: y, width: size.width, height: size.height)
287-
case .dropdown, .boolean, .date, .json, .blob:
292+
case .dropdown, .boolean, .json, .blob:
288293
guard isEditableCell else { return .zero }
289294
let size = NSSize(width: 12, height: 14)
290295
let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width
@@ -301,7 +306,7 @@ final class DataGridCellView: NSView {
301306
return
302307
case .foreignKey:
303308
image = onEmphasizedSelection ? Self.fkArrowEmphasized : Self.fkArrowNormal
304-
case .dropdown, .boolean, .date, .json, .blob:
309+
case .dropdown, .boolean, .json, .blob:
305310
if visualState.isDeleted {
306311
image = Self.chevronDisabled
307312
} else if onEmphasizedSelection {
@@ -332,7 +337,7 @@ final class DataGridCellView: NSView {
332337
case .foreignKey:
333338
accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex)
334339
return
335-
case .dropdown, .boolean, .date, .json, .blob:
340+
case .dropdown, .boolean, .json, .blob:
336341
guard !visualState.isDeleted else {
337342
super.mouseDown(with: event)
338343
return

0 commit comments

Comments
 (0)