diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b5acfbec..16f51fbae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift
index 34e057b3a..2d3b38a81 100644
--- a/TablePro/Core/ChangeTracking/DataChangeManager.swift
+++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift
@@ -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
@@ -138,7 +141,7 @@ final class DataChangeManager: ChangeManaging {
func clearChangesAndUndoHistory() {
clearChanges()
- undoManager.removeAllActions()
+ undoManagerProvider?()?.removeAllActions(withTarget: self)
}
func configureForTable(
@@ -159,7 +162,7 @@ final class DataChangeManager: ChangeManaging {
modifiedCells.removeAll()
insertedRowData.removeAll()
changedRowIndices.removeAll()
- undoManager.removeAllActions()
+ undoManagerProvider?()?.removeAllActions(withTarget: self)
changes.removeAll()
hasChanges = false
@@ -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
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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()
@@ -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)]
@@ -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)
@@ -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)
@@ -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 {
@@ -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 {
@@ -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,
@@ -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
}
diff --git a/TablePro/Core/Services/Infrastructure/ClipboardService.swift b/TablePro/Core/Services/Infrastructure/ClipboardService.swift
index 1624b6eaf..71994137a 100644
--- a/TablePro/Core/Services/Infrastructure/ClipboardService.swift
+++ b/TablePro/Core/Services/Infrastructure/ClipboardService.swift
@@ -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)
}
@@ -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()
diff --git a/TablePro/Core/Services/Infrastructure/HtmlTableEncoder.swift b/TablePro/Core/Services/Infrastructure/HtmlTableEncoder.swift
new file mode 100644
index 000000000..923394035
--- /dev/null
+++ b/TablePro/Core/Services/Infrastructure/HtmlTableEncoder.swift
@@ -0,0 +1,36 @@
+//
+// HtmlTableEncoder.swift
+// TablePro
+//
+
+import Foundation
+
+enum HtmlTableEncoder {
+ static func encode(rows: [[String]], headers: [String]? = nil) -> String {
+ var html = "
"
+ if let headers {
+ html += ""
+ for header in headers {
+ html += "| \(escape(header)) | "
+ }
+ html += "
"
+ }
+ for row in rows {
+ html += ""
+ for cell in row {
+ html += "| \(escape(cell)) | "
+ }
+ html += "
"
+ }
+ html += "
"
+ return html
+ }
+
+ static func escape(_ string: String) -> String {
+ string
+ .replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+ .replacingOccurrences(of: "\"", with: """)
+ }
+}
diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift
index 5283502cd..de8cac6f1 100644
--- a/TablePro/Core/Services/Query/RowOperationsManager.swift
+++ b/TablePro/Core/Services/Query/RowOperationsManager.swift
@@ -176,7 +176,7 @@ final class RowOperationsManager {
return applyUndoResult(result, resultRows: &resultRows)
}
- private func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? {
+ func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? {
switch result.action {
case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _):
if rowIndex < resultRows.count {
diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings
index 541774d95..ffb2a392e 100644
--- a/TablePro/Resources/Localizable.xcstrings
+++ b/TablePro/Resources/Localizable.xcstrings
@@ -29536,6 +29536,9 @@
}
}
}
+ },
+ "Paste Cells" : {
+
},
"Paste your CREATE TABLE statement below:" : {
"extractionState" : "stale",
diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift
index 5db4a7248..1d53268ce 100644
--- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift
+++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift
@@ -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)
diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift
index 2c20996aa..d1a2ad00d 100644
--- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift
+++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift
@@ -101,37 +101,21 @@ extension MainContentCoordinator {
dataTabDelegate?.dataGridDidRemoveRows(at: IndexSet(integer: rowIndex))
}
- func undoLastChange() {
+ func handleUndoResult(_ result: UndoResult) {
guard let tabIndex = tabManager.selectedTabIndex,
tabIndex < tabManager.tabs.count else { return }
- let tabId = tabManager.tabs[tabIndex].id
- let buffer = rowDataStore.buffer(for: tabId)
- if let adjustedSelection = rowOperationsManager.undoLastChange(
- resultRows: &buffer.rows
+ let tab = tabManager.tabs[tabIndex]
+ let buffer = rowDataStore.buffer(for: tab.id)
+ if let adjustedSelection = rowOperationsManager.applyUndoResult(
+ result, resultRows: &buffer.rows
) {
selectionState.indices = adjustedSelection
}
- tabManager.tabs[tabIndex].hasUserInteraction = true
- querySortCache.removeValue(forKey: tabId)
- dataTabDelegate?.dataGridDidReplaceAllRows()
- }
-
- func redoLastChange() {
- guard let tabIndex = tabManager.selectedTabIndex,
- tabIndex < tabManager.tabs.count else { return }
-
- let tab = tabManager.tabs[tabIndex]
- let buffer = rowDataStore.buffer(for: tab.id)
- _ = rowOperationsManager.redoLastChange(
- resultRows: &buffer.rows,
- columns: buffer.columns
- )
-
tabManager.tabs[tabIndex].hasUserInteraction = true
querySortCache.removeValue(forKey: tab.id)
- dataTabDelegate?.dataGridDidReplaceAllRows()
+ dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo()
}
func copySelectedRowsToClipboard(indices: Set) {
diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift
index db053689a..699b7adc4 100644
--- a/TablePro/Views/Main/MainContentCommandActions.swift
+++ b/TablePro/Views/Main/MainContentCommandActions.swift
@@ -719,7 +719,7 @@ final class MainContentCommandActions {
if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure {
coordinator?.structureActions?.undo?()
} else {
- coordinator?.undoLastChange()
+ coordinator?.contentWindow?.undoManager?.undo()
}
}
@@ -727,7 +727,7 @@ final class MainContentCommandActions {
if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure {
coordinator?.structureActions?.redo?()
} else {
- coordinator?.redoLastChange()
+ coordinator?.contentWindow?.undoManager?.redo()
}
}
diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift
index 6e50c2188..ee1dd418f 100644
--- a/TablePro/Views/Main/MainContentCoordinator.swift
+++ b/TablePro/Views/Main/MainContentCoordinator.swift
@@ -383,6 +383,8 @@ final class MainContentCoordinator {
self.schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: connection.id)
SchemaProviderRegistry.shared.retain(for: connection.id)
urlFilterObservers = setupURLNotificationObservers()
+ changeManager.undoManagerProvider = { [weak self] in self?.contentWindow?.undoManager }
+ changeManager.onUndoApplied = { [weak self] result in self?.handleUndoResult(result) }
// Synchronous save at quit time. NotificationCenter with queue: .main
// delivers the closure on the main thread, satisfying assumeIsolated's
diff --git a/TablePro/Views/Results/CellTextField.swift b/TablePro/Views/Results/CellTextField.swift
index e296c99e0..71792d3e0 100644
--- a/TablePro/Views/Results/CellTextField.swift
+++ b/TablePro/Views/Results/CellTextField.swift
@@ -80,21 +80,4 @@ final class DataGridFieldEditor: NSTextView {
}
return super.performKeyEquivalent(with: event)
}
-
- override func rightMouseDown(with event: NSEvent) {
- window?.makeFirstResponder(nil)
-
- var view: NSView? = self
- while let parent = view?.superview {
- if let cellTextField = parent as? CellTextField {
- cellTextField.rightMouseDown(with: event)
- return
- }
- view = parent
- }
- }
-
- override func menu(for event: NSEvent) -> NSMenu? {
- nil
- }
}
diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift
index 7100c6710..898748a5d 100644
--- a/TablePro/Views/Results/DataGridCoordinator.swift
+++ b/TablePro/Views/Results/DataGridCoordinator.swift
@@ -31,16 +31,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
var primaryKeyColumn: String? { primaryKeyColumns.first }
var tabType: TabType?
- /// Check if undo is available
- func canUndo() -> Bool {
- changeManager.hasChanges
- }
-
- /// Check if redo is available
- func canRedo() -> Bool {
- changeManager.canRedo
- }
-
/// Capture current column widths and order from the live NSTableView
/// and persist directly to ColumnLayoutStorage. Called from dismantleNSView
/// to guarantee layout is saved even when the view is torn down without
@@ -258,12 +248,19 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
func applyFullReplace() {
guard let tableView else { return }
+ rowProvider.invalidateDisplayCache()
rebuildVisualStateCache()
updateCache()
tableView.reloadData()
lastIdentity = nil
}
+ func invalidateCachesForUndoRedo() {
+ rowProvider.invalidateDisplayCache()
+ rebuildVisualStateCache()
+ updateCache()
+ }
+
func rebuildColumnMetadataCache() {
var enumSet = Set()
var fkSet = Set()
diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift
index 78a71655b..78f836dc7 100644
--- a/TablePro/Views/Results/DataGridView+RowActions.swift
+++ b/TablePro/Views/Results/DataGridView+RowActions.swift
@@ -34,34 +34,38 @@ extension TableViewCoordinator {
func copyRows(at indices: Set) {
let sortedIndices = indices.sorted()
let columnTypes = rowProvider.columnTypes
- var lines: [String] = []
+ var tsvRows: [String] = []
+ var htmlRows: [[String]] = []
for index in sortedIndices {
guard let values = rowProvider.rowValues(at: index) else { continue }
- let line = formatRowForCopy(values: values, columnTypes: columnTypes)
- lines.append(line)
+ let formatted = formatRowValues(values: values, columnTypes: columnTypes)
+ tsvRows.append(formatted.joined(separator: "\t"))
+ htmlRows.append(formatted)
}
- let text = lines.joined(separator: "\n")
- ClipboardService.shared.writeText(text)
+ let tsv = tsvRows.joined(separator: "\n")
+ let html = HtmlTableEncoder.encode(rows: htmlRows)
+ ClipboardService.shared.writeTabular(tsv: tsv, html: html)
}
func copyRowsWithHeaders(at indices: Set) {
let sortedIndices = indices.sorted()
let columnTypes = rowProvider.columnTypes
- var lines: [String] = []
-
- // Add header row
- lines.append(rowProvider.columns.joined(separator: "\t"))
+ let columns = rowProvider.columns
+ var tsvRows: [String] = [columns.joined(separator: "\t")]
+ var htmlRows: [[String]] = []
for index in sortedIndices {
guard let values = rowProvider.rowValues(at: index) else { continue }
- let line = formatRowForCopy(values: values, columnTypes: columnTypes)
- lines.append(line)
+ let formatted = formatRowValues(values: values, columnTypes: columnTypes)
+ tsvRows.append(formatted.joined(separator: "\t"))
+ htmlRows.append(formatted)
}
- let text = lines.joined(separator: "\n")
- ClipboardService.shared.writeText(text)
+ let tsv = tsvRows.joined(separator: "\n")
+ let html = HtmlTableEncoder.encode(rows: htmlRows, headers: columns)
+ ClipboardService.shared.writeTabular(tsv: tsv, html: html)
}
@MainActor
@@ -136,12 +140,12 @@ extension TableViewCoordinator {
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
}
- private func formatRowForCopy(values: [String?], columnTypes: [ColumnType]?) -> String {
+ private func formatRowValues(values: [String?], columnTypes: [ColumnType]?) -> [String] {
values.enumerated().map { index, value in
guard let value else { return "NULL" }
let columnType = columnTypes.flatMap { $0.indices.contains(index) ? $0[index] : nil }
return BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy)
- }.joined(separator: "\t")
+ }
}
private func resolveDriver() -> (any DatabaseDriver)? {
@@ -157,6 +161,16 @@ extension TableViewCoordinator {
guard delegate != nil else { return nil }
let item = NSPasteboardItem()
item.setString(String(row), forType: Self.rowDragType)
+
+ if let values = rowProvider.rowValues(at: row) {
+ let formatted = formatRowValues(values: values, columnTypes: rowProvider.columnTypes)
+ item.setString(formatted.joined(separator: "\t"), forType: .string)
+ item.setString(
+ HtmlTableEncoder.encode(rows: [formatted], headers: rowProvider.columns),
+ forType: .html
+ )
+ }
+
return item
}
diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift
new file mode 100644
index 000000000..9ad521b8a
--- /dev/null
+++ b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift
@@ -0,0 +1,43 @@
+//
+// DataGridView+CellPaste.swift
+// TablePro
+//
+
+import AppKit
+
+extension TableViewCoordinator {
+ func pasteCellsFromClipboard(anchorRow: Int, anchorColumn: Int) -> Bool {
+ guard isEditable else { return false }
+ guard let text = ClipboardService.shared.readText(), !text.isEmpty else { return false }
+
+ let grid = text.components(separatedBy: "\n")
+ .filter { !$0.isEmpty }
+ .map { $0.components(separatedBy: "\t") }
+ guard !grid.isEmpty, grid[0].count > 1 || grid.count > 1 else { return false }
+
+ let maxRow = min(anchorRow + grid.count, cachedRowCount)
+ let maxCol = min(anchorColumn + (grid.first?.count ?? 0), rowProvider.columns.count)
+ guard anchorRow < maxRow, anchorColumn < maxCol else { return false }
+
+ let undoManager = tableView?.window?.undoManager
+ undoManager?.beginUndoGrouping()
+ undoManager?.setActionName(String(localized: "Paste Cells"))
+
+ for (gridRow, rowValues) in grid.enumerated() {
+ let targetRow = anchorRow + gridRow
+ guard targetRow < maxRow else { break }
+ guard !changeManager.isRowDeleted(targetRow) else { continue }
+
+ for (gridCol, cellValue) in rowValues.enumerated() {
+ let targetCol = anchorColumn + gridCol
+ guard targetCol < maxCol else { break }
+ commitCellEdit(row: targetRow, columnIndex: targetCol, newValue: cellValue)
+ }
+ }
+
+ undoManager?.endUndoGrouping()
+
+ tableView?.reloadData()
+ return true
+ }
+}
diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift
index 78d2e7703..c1f62d60d 100644
--- a/TablePro/Views/Results/KeyHandlingTableView.swift
+++ b/TablePro/Views/Results/KeyHandlingTableView.swift
@@ -118,25 +118,17 @@ final class KeyHandlingTableView: NSTableView {
coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes))
}
- /// Paste rows from clipboard
@objc func paste(_ sender: Any?) {
guard coordinator?.isEditable == true else { return }
+ if focusedRow >= 0, focusedColumn >= 1 {
+ let dataCol = DataGridView.dataColumnIndex(for: focusedColumn)
+ if coordinator?.pasteCellsFromClipboard(anchorRow: focusedRow, anchorColumn: dataCol) == true {
+ return
+ }
+ }
coordinator?.delegate?.dataGridPasteRows()
}
- /// Undo last change
- @objc func undo(_ sender: Any?) {
- guard coordinator?.isEditable == true else { return }
- coordinator?.delegate?.dataGridUndo()
- }
-
- /// Redo last undone change
- @objc func redo(_ sender: Any?) {
- guard coordinator?.isEditable == true else { return }
- coordinator?.delegate?.dataGridRedo()
- }
-
- /// Validate menu items and shortcuts
override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
switch item.action {
case #selector(delete(_:)), #selector(deleteBackward(_:)):
@@ -145,14 +137,10 @@ final class KeyHandlingTableView: NSTableView {
return !selectedRowIndexes.isEmpty
case #selector(paste(_:)):
return coordinator?.isEditable == true && coordinator?.delegate != nil
- case #selector(undo(_:)):
- return coordinator?.isEditable == true && (coordinator?.canUndo() ?? false)
- case #selector(redo(_:)):
- return coordinator?.isEditable == true && (coordinator?.canRedo() ?? false)
case #selector(insertNewline(_:)):
return selectedRow >= 0 && focusedColumn >= 1 && coordinator?.isEditable == true
case #selector(cancelOperation(_:)):
- return focusedRow >= 0 || focusedColumn >= 0
+ return false
default:
return super.validateUserInterfaceItem(item)
}
@@ -170,16 +158,18 @@ final class KeyHandlingTableView: NSTableView {
// Handle Tab manually (NSTableView cell navigation requires custom logic)
if key == .tab {
- handleTabKey()
+ if event.modifierFlags.contains(.shift) {
+ handleShiftTabKey()
+ } else {
+ handleTabKey()
+ }
return
}
- // Handle arrow keys (custom Shift+selection logic)
let row = selectedRow
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let isShiftHeld = modifiers.contains(.shift)
- // Ctrl+HJKL navigation (arrow key alternatives for keyboards without dedicated arrows)
if modifiers.contains(.control) {
switch key {
case .h:
@@ -280,8 +270,6 @@ final class KeyHandlingTableView: NSTableView {
}
@objc override func cancelOperation(_ sender: Any?) {
- focusedRow = -1
- focusedColumn = -1
}
// MARK: - Arrow Key and Tab Helpers
@@ -308,7 +296,6 @@ final class KeyHandlingTableView: NSTableView {
}
}
- /// Handle Tab key - navigate to next cell (manual implementation required for NSTableView)
private func handleTabKey() {
let row = selectedRow
guard row >= 0, focusedColumn >= 1 else { return }
@@ -332,6 +319,29 @@ final class KeyHandlingTableView: NSTableView {
scrollColumnToVisible(nextColumn)
}
+ private func handleShiftTabKey() {
+ let row = selectedRow
+ guard row >= 0, focusedColumn >= 1 else { return }
+
+ var prevColumn = focusedColumn - 1
+ var prevRow = row
+
+ if prevColumn < 1 {
+ prevColumn = numberOfColumns - 1
+ prevRow -= 1
+ }
+ if prevRow < 0 {
+ prevRow = 0
+ prevColumn = 1
+ }
+
+ selectRowIndexes(IndexSet(integer: prevRow), byExtendingSelection: false)
+ focusedRow = prevRow
+ focusedColumn = prevColumn
+ scrollRowToVisible(prevRow)
+ scrollColumnToVisible(prevColumn)
+ }
+
// MARK: - Arrow Key Selection Helpers
private func handleUpArrow(currentRow: Int, isShiftHeld: Bool) {
diff --git a/TablePro/Views/Results/RowDeltaApplying.swift b/TablePro/Views/Results/RowDeltaApplying.swift
index 2b3a225d8..affc103e3 100644
--- a/TablePro/Views/Results/RowDeltaApplying.swift
+++ b/TablePro/Views/Results/RowDeltaApplying.swift
@@ -5,6 +5,7 @@ protocol RowDeltaApplying: AnyObject {
func applyInsertedRows(_ indices: IndexSet)
func applyRemovedRows(_ indices: IndexSet)
func applyFullReplace()
+ func invalidateCachesForUndoRedo()
}
extension TableViewCoordinator: RowDeltaApplying {}
diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift
index 1acfcb9b0..df4b27d80 100644
--- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift
+++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift
@@ -20,6 +20,10 @@ private final class MockClipboardProvider: ClipboardProvider {
lastWrittenText = text
}
+ func writeTabular(tsv: String, html: String) {
+ lastWrittenText = tsv
+ }
+
var hasText: Bool { textToRead != nil }
}