Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
- Multi-cell paste: paste TSV data from the clipboard into the grid starting from the focused cell, grouped as a single undo action
- Shift+Tab navigates to the previous cell in the data grid
- Copy rows writes TSV, HTML table, and plain text to the clipboard for richer paste in spreadsheet apps
- Row drag adds TSV and HTML representations alongside the internal drag type

### Changed

Expand All @@ -34,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
- Data grid undo/redo uses the window's UndoManager instead of a private instance, unifying Cmd+Z across editor and grid
- Right-click during cell editing shows the native text context menu instead of the row menu

### Fixed

Expand Down
67 changes: 31 additions & 36 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,21 @@ final class DataChangeManager: ChangeManaging {
return lo
}

private let undoManager: UndoManager = {
let manager = UndoManager()
manager.levelsOfUndo = 100
return manager
}()
var undoManagerProvider: (() -> UndoManager?)?
var onUndoApplied: ((UndoResult) -> Void)?

private var lastUndoResult: UndoResult?

// MARK: - Undo/Redo Properties

var canUndo: Bool { undoManager.canUndo }
var canRedo: Bool { undoManager.canRedo }
var canUndo: Bool { undoManagerProvider?()?.canUndo ?? false }
var canRedo: Bool { undoManagerProvider?()?.canRedo ?? false }

private func registerUndo(actionName: String, _ handler: @escaping (DataChangeManager) -> Void) {
guard let undoManager = undoManagerProvider?() else { return }
undoManager.registerUndo(withTarget: self, handler: handler)
undoManager.setActionName(actionName)
}

// MARK: - Helper Methods

Expand All @@ -138,7 +141,7 @@ final class DataChangeManager: ChangeManaging {

func clearChangesAndUndoHistory() {
clearChanges()
undoManager.removeAllActions()
undoManagerProvider?()?.removeAllActions(withTarget: self)
}

func configureForTable(
Expand All @@ -159,7 +162,7 @@ final class DataChangeManager: ChangeManaging {
modifiedCells.removeAll()
insertedRowData.removeAll()
changedRowIndices.removeAll()
undoManager.removeAllActions()
undoManagerProvider?()?.removeAllActions(withTarget: self)

changes.removeAll()
hasChanges = false
Expand Down Expand Up @@ -232,13 +235,12 @@ final class DataChangeManager: ChangeManaging {
newValue: newValue
))
}
undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Edit Cell")) { target in
target.applyDataUndo(.cellEdit(
rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName,
previousValue: oldValue, newValue: newValue, originalRow: nil
))
}
undoManager.setActionName(String(localized: "Edit Cell"))
changedRowIndices.insert(rowIndex)
hasChanges = !changes.isEmpty
reloadVersion += 1
Expand Down Expand Up @@ -287,13 +289,12 @@ final class DataChangeManager: ChangeManaging {
changedRowIndices.insert(rowIndex)
}

undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Edit Cell")) { target in
target.applyDataUndo(.cellEdit(
rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName,
previousValue: oldValue, newValue: newValue, originalRow: originalRow
))
}
undoManager.setActionName(String(localized: "Edit Cell"))
hasChanges = !changes.isEmpty
reloadVersion += 1
}
Expand All @@ -307,10 +308,9 @@ final class DataChangeManager: ChangeManaging {
changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1
deletedRowIndices.insert(rowIndex)
changedRowIndices.insert(rowIndex)
undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Delete Row")) { target in
target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow))
}
undoManager.setActionName(String(localized: "Delete Row"))
hasChanges = true
reloadVersion += 1
}
Expand All @@ -336,10 +336,9 @@ final class DataChangeManager: ChangeManaging {
changedRowIndices.insert(rowIndex)
batchData.append((rowIndex: rowIndex, originalRow: originalRow))
}
undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Delete Rows")) { target in
target.applyDataUndo(.batchRowDeletion(rows: batchData))
}
undoManager.setActionName(String(localized: "Delete Rows"))
hasChanges = true
reloadVersion += 1
}
Expand All @@ -351,10 +350,9 @@ final class DataChangeManager: ChangeManaging {
changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1
insertedRowIndices.insert(rowIndex)
changedRowIndices.insert(rowIndex)
undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Insert Row")) { target in
target.applyDataUndo(.rowInsertion(rowIndex: rowIndex))
}
undoManager.setActionName(String(localized: "Insert Row"))
hasChanges = true
reloadVersion += 1
}
Expand Down Expand Up @@ -476,10 +474,9 @@ final class DataChangeManager: ChangeManaging {
insertedRowData.removeValue(forKey: rowIndex)
}

undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Insert Rows")) { target in
target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues))
}
undoManager.setActionName(String(localized: "Insert Rows"))

let sortedDeleted = validRows.sorted()

Expand All @@ -506,13 +503,12 @@ final class DataChangeManager: ChangeManaging {
private func applyDataUndo(_ action: UndoAction) {
switch action {
case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue, let originalRow):
undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Edit Cell")) { target in
target.applyDataUndo(.cellEdit(
rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName,
previousValue: newValue, newValue: previousValue, originalRow: originalRow
))
}
undoManager.setActionName(String(localized: "Edit Cell"))

let matchedIndex = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .update)]
?? changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)]
Expand Down Expand Up @@ -567,13 +563,12 @@ final class DataChangeManager: ChangeManaging {

case .rowInsertion(let rowIndex):
let savedValues = insertedRowData[rowIndex]
undoManager.registerUndo(withTarget: self) { [savedValues] target in
registerUndo(actionName: String(localized: "Insert Row")) { [savedValues] target in
if let savedValues {
target.insertedRowData[rowIndex] = savedValues
}
target.applyDataUndo(.rowInsertion(rowIndex: rowIndex))
}
undoManager.setActionName(String(localized: "Insert Row"))

if insertedRowIndices.contains(rowIndex) {
undoRowInsertion(rowIndex: rowIndex)
Expand Down Expand Up @@ -606,10 +601,9 @@ final class DataChangeManager: ChangeManaging {
}

case .rowDeletion(let rowIndex, let originalRow):
undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Delete Row")) { target in
target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow))
}
undoManager.setActionName(String(localized: "Delete Row"))

if deletedRowIndices.contains(rowIndex) {
undoRowDeletion(rowIndex: rowIndex)
Expand All @@ -626,10 +620,9 @@ final class DataChangeManager: ChangeManaging {
}

case .batchRowDeletion(let rows):
undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Delete Rows")) { target in
target.applyDataUndo(.batchRowDeletion(rows: rows))
}
undoManager.setActionName(String(localized: "Delete Rows"))

let isUndo = rows.contains { deletedRowIndices.contains($0.rowIndex) }
if isUndo {
Expand All @@ -651,10 +644,9 @@ final class DataChangeManager: ChangeManaging {
}

case .batchRowInsertion(let rowIndices, let rowValues):
undoManager.registerUndo(withTarget: self) { target in
registerUndo(actionName: String(localized: "Insert Rows")) { target in
target.applyDataUndo(.batchRowInsertion(rowIndices: rowIndices, rowValues: rowValues))
}
undoManager.setActionName(String(localized: "Insert Rows"))

let firstInserted = rowIndices.first.map { insertedRowIndices.contains($0) } ?? false
if firstInserted {
Expand Down Expand Up @@ -701,9 +693,12 @@ final class DataChangeManager: ChangeManaging {

hasChanges = !changes.isEmpty
reloadVersion += 1

if let result = lastUndoResult {
onUndoApplied?(result)
}
}

/// Re-apply a cell edit during redo without registering a duplicate undo
private func recordCellChangeForRedo(
rowIndex: Int,
columnIndex: Int,
Expand Down Expand Up @@ -784,16 +779,16 @@ final class DataChangeManager: ChangeManaging {
// MARK: - Undo/Redo Public API

func undoLastChange() -> UndoResult? {
guard undoManager.canUndo else { return nil }
guard let um = undoManagerProvider?(), um.canUndo else { return nil }
lastUndoResult = nil
undoManager.undo()
um.undo()
return lastUndoResult
}

func redoLastChange() -> UndoResult? {
guard undoManager.canRedo else { return nil }
guard let um = undoManagerProvider?(), um.canRedo else { return nil }
lastUndoResult = nil
undoManager.redo()
um.redo()
return lastUndoResult
}

Expand Down
22 changes: 11 additions & 11 deletions TablePro/Core/Services/Infrastructure/ClipboardService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,16 @@
import AppKit
import UniformTypeIdentifiers

/// Protocol for clipboard operations
/// Abstraction allows for mocking in tests
protocol ClipboardProvider {
/// Read text content from clipboard
/// - Returns: Text string if available, nil otherwise
func readText() -> String?

/// Write text content to clipboard
/// - Parameter text: Text to write
func writeText(_ text: String)

/// Check if clipboard contains text data
func writeTabular(tsv: String, html: String)
var hasText: Bool { get }
}

/// Concrete implementation using NSPasteboard
struct NSPasteboardClipboardProvider: ClipboardProvider {
private static let tsvType = NSPasteboard.PasteboardType("public.utf8-tab-separated-values-text")

func readText() -> String? {
NSPasteboard.general.string(forType: .string)
}
Expand All @@ -37,12 +30,19 @@ struct NSPasteboardClipboardProvider: ClipboardProvider {
pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier))
}

func writeTabular(tsv: String, html: String) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(tsv, forType: .string)
pb.setString(tsv, forType: Self.tsvType)
pb.setString(html, forType: .html)
}

var hasText: Bool {
NSPasteboard.general.string(forType: .string) != nil
}
}

/// Shared clipboard service instance
@MainActor
enum ClipboardService {
static var shared: ClipboardProvider = NSPasteboardClipboardProvider()
Expand Down
36 changes: 36 additions & 0 deletions TablePro/Core/Services/Infrastructure/HtmlTableEncoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// HtmlTableEncoder.swift
// TablePro
//

import Foundation

enum HtmlTableEncoder {
static func encode(rows: [[String]], headers: [String]? = nil) -> String {
var html = "<table>"
if let headers {
html += "<tr>"
for header in headers {
html += "<th>\(escape(header))</th>"
}
html += "</tr>"
}
for row in rows {
html += "<tr>"
for cell in row {
html += "<td>\(escape(cell))</td>"
}
html += "</tr>"
}
html += "</table>"
return html
}

static func escape(_ string: String) -> String {
string
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
}
2 changes: 1 addition & 1 deletion TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ final class RowOperationsManager {
return applyUndoResult(result, resultRows: &resultRows)
}

private func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set<Int>? {
func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set<Int>? {
switch result.action {
case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _):
if rowIndex < resultRows.count {
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -29536,6 +29536,9 @@
}
}
}
},
"Paste Cells" : {

},
"Paste your CREATE TABLE statement below:" : {
"extractionState" : "stale",
Expand Down
8 changes: 2 additions & 6 deletions TablePro/Views/Main/Child/DataTabGridDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,9 @@ final class DataTabGridDelegate: DataGridViewDelegate {
NotificationCenter.default.post(name: .exportQueryResults, object: nil)
}

func dataGridUndo() {
coordinator?.undoLastChange()
}
func dataGridUndo() {}

func dataGridRedo() {
coordinator?.redoLastChange()
}
func dataGridRedo() {}

func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) {
coordinator?.navigateToFKReference(value: value, fkInfo: fkInfo)
Expand Down
Loading
Loading