From 6d04fd289132a8c26f0c5656beb873d458e7eb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 28 Apr 2026 10:14:37 +0700 Subject: [PATCH 1/8] refactor(datagrid): keyboard fixes, tabular clipboard, Shift+Tab navigation --- .../Infrastructure/ClipboardService.swift | 22 ++++---- .../Infrastructure/HtmlTableEncoder.swift | 36 ++++++++++++ TablePro/Views/Results/CellTextField.swift | 17 ------ .../Results/DataGridView+RowActions.swift | 44 ++++++++++----- .../Views/Results/KeyHandlingTableView.swift | 55 ++++++++++--------- .../RowOperationsManagerCopyTests.swift | 4 ++ 6 files changed, 109 insertions(+), 69 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/HtmlTableEncoder.swift 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 += "" + } + html += "" + } + for row in rows { + html += "" + for cell in row { + html += "" + } + html += "" + } + html += "
\(escape(header))
\(escape(cell))
" + 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/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/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/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 78d2e7703..81e3ddc32 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -152,7 +152,7 @@ final class KeyHandlingTableView: NSTableView { 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,35 +170,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: - handleLeftArrow(currentRow: row) - return - case .j: - handleDownArrow(currentRow: row, isShiftHeld: isShiftHeld) - return - case .k: - handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) - return - case .l: - handleRightArrow(currentRow: row) - return - default: - break - } - } - switch key { case .upArrow: handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) @@ -280,8 +263,6 @@ final class KeyHandlingTableView: NSTableView { } @objc override func cancelOperation(_ sender: Any?) { - focusedRow = -1 - focusedColumn = -1 } // MARK: - Arrow Key and Tab Helpers @@ -308,7 +289,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 +312,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/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 } } From e29dcb550a13ba9d5d73e9f1915a181e186fc583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 28 Apr 2026 10:23:17 +0700 Subject: [PATCH 2/8] refactor(datagrid): use window UndoManager instead of private instance --- .../ChangeTracking/DataChangeManager.swift | 67 +++++++++---------- .../Services/Query/RowOperationsManager.swift | 2 +- .../Main/Child/DataTabGridDelegate.swift | 8 +-- ...MainContentCoordinator+RowOperations.swift | 26 ++----- .../Main/MainContentCommandActions.swift | 4 +- .../Views/Main/MainContentCoordinator.swift | 2 + .../Views/Results/DataGridCoordinator.swift | 10 --- .../Views/Results/KeyHandlingTableView.swift | 18 ----- 8 files changed, 43 insertions(+), 94 deletions(-) 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/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/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..acac3506b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -101,34 +101,18 @@ 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() 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/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 7100c6710..982d28e25 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 diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 81e3ddc32..deac1c913 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -118,25 +118,11 @@ final class KeyHandlingTableView: NSTableView { coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) } - /// Paste rows from clipboard @objc func paste(_ sender: Any?) { guard coordinator?.isEditable == true else { 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,10 +131,6 @@ 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(_:)): From 97907457cbede5f252e9c2305a44200f2a781019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 28 Apr 2026 10:24:55 +0700 Subject: [PATCH 3/8] feat(datagrid): multi-cell paste from TSV clipboard with undo grouping --- .../Extensions/DataGridView+CellPaste.swift | 43 +++++++++++++++++++ .../Views/Results/KeyHandlingTableView.swift | 6 +++ 2 files changed, 49 insertions(+) create mode 100644 TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift 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 deac1c913..c56026f54 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -120,6 +120,12 @@ final class KeyHandlingTableView: NSTableView { @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() } From c3a0e67c382c58f09c819f9740fb16b82d2eb2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 28 Apr 2026 10:25:23 +0700 Subject: [PATCH 4/8] docs: update CHANGELOG for datagrid phase 3 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 26336d6a850c5d5e71e6fcd66905c44ffd68c363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 28 Apr 2026 13:58:03 +0700 Subject: [PATCH 5/8] fix(datagrid): restore Ctrl+H/J/K/L navigation, invalidate display cache on undo --- .../Views/Results/DataGridCoordinator.swift | 1 + .../Views/Results/KeyHandlingTableView.swift | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 982d28e25..64166b448 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -248,6 +248,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyFullReplace() { guard let tableView else { return } + rowProvider.invalidateDisplayCache() rebuildVisualStateCache() updateCache() tableView.reloadData() diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index c56026f54..c1f62d60d 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -170,6 +170,25 @@ final class KeyHandlingTableView: NSTableView { let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let isShiftHeld = modifiers.contains(.shift) + if modifiers.contains(.control) { + switch key { + case .h: + handleLeftArrow(currentRow: row) + return + case .j: + handleDownArrow(currentRow: row, isShiftHeld: isShiftHeld) + return + case .k: + handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) + return + case .l: + handleRightArrow(currentRow: row) + return + default: + break + } + } + switch key { case .upArrow: handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) From db122bc27e0980b37da7a47779fa13af388f00d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 28 Apr 2026 14:11:23 +0700 Subject: [PATCH 6/8] debug(datagrid): add OSLog tracing for undo CPU investigation --- .../ChangeTracking/DataChangeManager.swift | 19 +++++++++++++ ...MainContentCoordinator+RowOperations.swift | 17 +++++++++++ .../Views/Results/DataGridCoordinator.swift | 13 +++++++++ TablePro/Views/Results/DataGridView.swift | 28 +++++++++++++++++++ .../Extensions/DataGridView+Columns.swift | 1 + 5 files changed, 78 insertions(+) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 2d3b38a81..4f7422e40 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -499,8 +499,24 @@ final class DataChangeManager: ChangeManaging { // MARK: - Core Undo Application + private static let undoTrace = Logger(subsystem: "com.TablePro", category: "UndoTrace") + // swiftlint:disable:next function_body_length private func applyDataUndo(_ action: UndoAction) { + let traceStart = Date() + let actionName: String + switch action { + case .cellEdit: actionName = "cellEdit" + case .rowInsertion: actionName = "rowInsertion" + case .rowDeletion: actionName = "rowDeletion" + case .batchRowDeletion: actionName = "batchRowDeletion" + case .batchRowInsertion: actionName = "batchRowInsertion" + } + Self.undoTrace.info("applyDataUndo START action=\(actionName) changes=\(self.changes.count)") + defer { + let elapsed = Date().timeIntervalSince(traceStart) * 1000 + Self.undoTrace.info("applyDataUndo END elapsed=\(elapsed)ms") + } switch action { case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue, let originalRow): registerUndo(actionName: String(localized: "Edit Cell")) { target in @@ -695,7 +711,10 @@ final class DataChangeManager: ChangeManaging { reloadVersion += 1 if let result = lastUndoResult { + let cbStart = Date() onUndoApplied?(result) + let cbElapsed = Date().timeIntervalSince(cbStart) * 1000 + Self.undoTrace.info("onUndoApplied callback elapsed=\(cbElapsed)ms") } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index acac3506b..baa348d19 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -6,6 +6,7 @@ // import Foundation +import os extension MainContentCoordinator { // MARK: - Row Operations @@ -102,20 +103,36 @@ extension MainContentCoordinator { } func handleUndoResult(_ result: UndoResult) { + let traceStart = Date() + let trace = Logger(subsystem: "com.TablePro", category: "UndoTrace") + trace.info("handleUndoResult START") + defer { + let elapsed = Date().timeIntervalSince(traceStart) * 1000 + trace.info("handleUndoResult END elapsed=\(elapsed)ms") + } + guard let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] let buffer = rowDataStore.buffer(for: tab.id) + + let applyStart = Date() if let adjustedSelection = rowOperationsManager.applyUndoResult( result, resultRows: &buffer.rows ) { selectionState.indices = adjustedSelection } + let applyElapsed = Date().timeIntervalSince(applyStart) * 1000 + trace.info("applyUndoResult elapsed=\(applyElapsed)ms rows=\(buffer.rows.count)") tabManager.tabs[tabIndex].hasUserInteraction = true querySortCache.removeValue(forKey: tab.id) + + let replaceStart = Date() dataTabDelegate?.dataGridDidReplaceAllRows() + let replaceElapsed = Date().timeIntervalSince(replaceStart) * 1000 + trace.info("dataGridDidReplaceAllRows elapsed=\(replaceElapsed)ms") } func copySelectedRowsToClipboard(indices: Set) { diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 64166b448..03b100424 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -6,6 +6,7 @@ // import AppKit +import os import SwiftUI // MARK: - Coordinator @@ -85,6 +86,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData /// Guards against two-frame bounce when async column layout write-back triggers updateNSView var isWritingColumnLayout: Bool = false var isEscapeCancelling = false + var viewForRowCallCount: Int = 0 /// Debounced task for persisting column layout after resize/reorder var layoutPersistTask: Task? @@ -248,11 +250,22 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyFullReplace() { guard let tableView else { return } + let trace = Logger(subsystem: "com.TablePro", category: "UndoTrace") + let start = Date() + let beforeCallCount = viewForRowCallCount + rowProvider.invalidateDisplayCache() + let t1 = Date().timeIntervalSince(start) * 1000 rebuildVisualStateCache() + let t2 = Date().timeIntervalSince(start) * 1000 updateCache() + let t3 = Date().timeIntervalSince(start) * 1000 tableView.reloadData() + let t4 = Date().timeIntervalSince(start) * 1000 lastIdentity = nil + + let cellCalls = viewForRowCallCount - beforeCallCount + trace.info("applyFullReplace invalidateCache=\(t1)ms rebuildVS=\(t2 - t1)ms updateCache=\(t3 - t2)ms reloadData=\(t4 - t3)ms total=\(t4)ms rows=\(self.cachedRowCount) cellCalls=\(cellCalls)") } func rebuildColumnMetadataCache() { diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index f04b30c55..0789a16e5 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -7,6 +7,7 @@ // import AppKit +import os import SwiftUI /// Position of a cell in the grid (row, column) @@ -201,6 +202,16 @@ struct DataGridView: NSViewRepresentable { let coordinator = context.coordinator + let trace = Logger(subsystem: "com.TablePro", category: "UndoTrace") + let traceStart = Date() + let inV = changeManager.reloadVersion + defer { + let elapsed = Date().timeIntervalSince(traceStart) * 1000 + if elapsed > 1 { + trace.info("updateNSView reloadVersion=\(inV) elapsed=\(elapsed)ms") + } + } + // Don't reload while editing (field editor or overlay) if tableView.editedRow >= 0 { return } if let editor = context.coordinator.overlayEditor, editor.isActive { return } @@ -278,7 +289,9 @@ struct DataGridView: NSViewRepresentable { // Re-apply pending cell edits only when changes have been modified if changeManager.reloadVersion != coordinator.lastReapplyVersion { + let reapplyStart = Date() coordinator.lastReapplyVersion = changeManager.reloadVersion + var cellCount = 0 for rowChange in changeManager.rowChanges { for cellChange in rowChange.cellChanges { coordinator.rowProvider.updateValue( @@ -286,8 +299,11 @@ struct DataGridView: NSViewRepresentable { at: rowChange.rowIndex, columnIndex: cellChange.columnIndex ) + cellCount += 1 } } + let reapplyElapsed = Date().timeIntervalSince(reapplyStart) * 1000 + trace.info("reapplyCellChanges cells=\(cellCount) elapsed=\(reapplyElapsed)ms") } coordinator.updateCache() @@ -542,7 +558,11 @@ struct DataGridView: NSViewRepresentable { metadataChanged: Bool = false, paginationChanged: Bool = false ) { + let trace = Logger(subsystem: "com.TablePro", category: "UndoTrace") + let start = Date() + let beforeCallCount = coordinator.viewForRowCallCount if needsFullReload { + trace.info("reloadAndSync FULL_RELOAD") tableView.reloadData() } else if metadataChanged { // FK metadata arrived (Phase 2) — reload only FK columns to show arrow buttons. @@ -568,16 +588,24 @@ struct DataGridView: NSViewRepresentable { } else if versionChanged { let changedRows = changeManager.consumeChangedRowIndices() if changedRows.count > 500 { + trace.info("reloadAndSync VERSION_CHANGED count=\(changedRows.count) BIG -> full reloadData") tableView.reloadData() } else if !changedRows.isEmpty { let rowIndexSet = IndexSet(changedRows) let columnIndexSet = IndexSet(integersIn: 0.. partial reload") tableView.reloadData(forRowIndexes: rowIndexSet, columnIndexes: columnIndexSet) } else if !changeManager.hasChanges { + trace.info("reloadAndSync VERSION_CHANGED no changes -> full reloadData") tableView.reloadData() } } + let elapsed = Date().timeIntervalSince(start) * 1000 + let cellCalls = coordinator.viewForRowCallCount - beforeCallCount + if elapsed > 1 { + trace.info("reloadAndSync total elapsed=\(elapsed)ms cellCalls=\(cellCalls)") + } coordinator.lastReloadVersion = changeManager.reloadVersion // Scroll to first row when page changes diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index f776041f1..db9e4f549 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -8,6 +8,7 @@ import SwiftUI extension TableViewCoordinator { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + viewForRowCallCount &+= 1 guard let column = tableColumn else { return nil } let columnId = column.identifier.rawValue From 1cd03d67057d81d89ea5e790077f69aef4d6a65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 28 Apr 2026 14:24:03 +0700 Subject: [PATCH 7/8] fix(datagrid): drop full reload on undo, rely on SwiftUI partial reload --- .../Extensions/MainContentCoordinator+RowOperations.swift | 8 ++++---- TablePro/Views/Results/DataGridCoordinator.swift | 6 ++++++ TablePro/Views/Results/RowDeltaApplying.swift | 1 + 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index baa348d19..58bf5f3b7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -129,10 +129,10 @@ extension MainContentCoordinator { tabManager.tabs[tabIndex].hasUserInteraction = true querySortCache.removeValue(forKey: tab.id) - let replaceStart = Date() - dataTabDelegate?.dataGridDidReplaceAllRows() - let replaceElapsed = Date().timeIntervalSince(replaceStart) * 1000 - trace.info("dataGridDidReplaceAllRows elapsed=\(replaceElapsed)ms") + let invalidateStart = Date() + dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() + let invalidateElapsed = Date().timeIntervalSince(invalidateStart) * 1000 + trace.info("invalidateCachesForUndoRedo elapsed=\(invalidateElapsed)ms") } func copySelectedRowsToClipboard(indices: Set) { diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 03b100424..f3843e5c4 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -268,6 +268,12 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData trace.info("applyFullReplace invalidateCache=\(t1)ms rebuildVS=\(t2 - t1)ms updateCache=\(t3 - t2)ms reloadData=\(t4 - t3)ms total=\(t4)ms rows=\(self.cachedRowCount) cellCalls=\(cellCalls)") } + func invalidateCachesForUndoRedo() { + rowProvider.invalidateDisplayCache() + rebuildVisualStateCache() + updateCache() + } + func rebuildColumnMetadataCache() { var enumSet = Set() var fkSet = Set() 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 {} From 2122346ddb2c8e3dc9f5b3fc76342f7c0e525eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 28 Apr 2026 14:36:28 +0700 Subject: [PATCH 8/8] chore(datagrid): remove undo tracing instrumentation --- .../ChangeTracking/DataChangeManager.swift | 19 ------------- TablePro/Resources/Localizable.xcstrings | 3 ++ ...MainContentCoordinator+RowOperations.swift | 17 ----------- .../Views/Results/DataGridCoordinator.swift | 13 --------- TablePro/Views/Results/DataGridView.swift | 28 ------------------- .../Extensions/DataGridView+Columns.swift | 1 - 6 files changed, 3 insertions(+), 78 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 4f7422e40..2d3b38a81 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -499,24 +499,8 @@ final class DataChangeManager: ChangeManaging { // MARK: - Core Undo Application - private static let undoTrace = Logger(subsystem: "com.TablePro", category: "UndoTrace") - // swiftlint:disable:next function_body_length private func applyDataUndo(_ action: UndoAction) { - let traceStart = Date() - let actionName: String - switch action { - case .cellEdit: actionName = "cellEdit" - case .rowInsertion: actionName = "rowInsertion" - case .rowDeletion: actionName = "rowDeletion" - case .batchRowDeletion: actionName = "batchRowDeletion" - case .batchRowInsertion: actionName = "batchRowInsertion" - } - Self.undoTrace.info("applyDataUndo START action=\(actionName) changes=\(self.changes.count)") - defer { - let elapsed = Date().timeIntervalSince(traceStart) * 1000 - Self.undoTrace.info("applyDataUndo END elapsed=\(elapsed)ms") - } switch action { case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue, let originalRow): registerUndo(actionName: String(localized: "Edit Cell")) { target in @@ -711,10 +695,7 @@ final class DataChangeManager: ChangeManaging { reloadVersion += 1 if let result = lastUndoResult { - let cbStart = Date() onUndoApplied?(result) - let cbElapsed = Date().timeIntervalSince(cbStart) * 1000 - Self.undoTrace.info("onUndoApplied callback elapsed=\(cbElapsed)ms") } } 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/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 58bf5f3b7..d1a2ad00d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -6,7 +6,6 @@ // import Foundation -import os extension MainContentCoordinator { // MARK: - Row Operations @@ -103,36 +102,20 @@ extension MainContentCoordinator { } func handleUndoResult(_ result: UndoResult) { - let traceStart = Date() - let trace = Logger(subsystem: "com.TablePro", category: "UndoTrace") - trace.info("handleUndoResult START") - defer { - let elapsed = Date().timeIntervalSince(traceStart) * 1000 - trace.info("handleUndoResult END elapsed=\(elapsed)ms") - } - guard let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] let buffer = rowDataStore.buffer(for: tab.id) - - let applyStart = Date() if let adjustedSelection = rowOperationsManager.applyUndoResult( result, resultRows: &buffer.rows ) { selectionState.indices = adjustedSelection } - let applyElapsed = Date().timeIntervalSince(applyStart) * 1000 - trace.info("applyUndoResult elapsed=\(applyElapsed)ms rows=\(buffer.rows.count)") tabManager.tabs[tabIndex].hasUserInteraction = true querySortCache.removeValue(forKey: tab.id) - - let invalidateStart = Date() dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() - let invalidateElapsed = Date().timeIntervalSince(invalidateStart) * 1000 - trace.info("invalidateCachesForUndoRedo elapsed=\(invalidateElapsed)ms") } func copySelectedRowsToClipboard(indices: Set) { diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index f3843e5c4..898748a5d 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -6,7 +6,6 @@ // import AppKit -import os import SwiftUI // MARK: - Coordinator @@ -86,7 +85,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData /// Guards against two-frame bounce when async column layout write-back triggers updateNSView var isWritingColumnLayout: Bool = false var isEscapeCancelling = false - var viewForRowCallCount: Int = 0 /// Debounced task for persisting column layout after resize/reorder var layoutPersistTask: Task? @@ -250,22 +248,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyFullReplace() { guard let tableView else { return } - let trace = Logger(subsystem: "com.TablePro", category: "UndoTrace") - let start = Date() - let beforeCallCount = viewForRowCallCount - rowProvider.invalidateDisplayCache() - let t1 = Date().timeIntervalSince(start) * 1000 rebuildVisualStateCache() - let t2 = Date().timeIntervalSince(start) * 1000 updateCache() - let t3 = Date().timeIntervalSince(start) * 1000 tableView.reloadData() - let t4 = Date().timeIntervalSince(start) * 1000 lastIdentity = nil - - let cellCalls = viewForRowCallCount - beforeCallCount - trace.info("applyFullReplace invalidateCache=\(t1)ms rebuildVS=\(t2 - t1)ms updateCache=\(t3 - t2)ms reloadData=\(t4 - t3)ms total=\(t4)ms rows=\(self.cachedRowCount) cellCalls=\(cellCalls)") } func invalidateCachesForUndoRedo() { diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 0789a16e5..f04b30c55 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -7,7 +7,6 @@ // import AppKit -import os import SwiftUI /// Position of a cell in the grid (row, column) @@ -202,16 +201,6 @@ struct DataGridView: NSViewRepresentable { let coordinator = context.coordinator - let trace = Logger(subsystem: "com.TablePro", category: "UndoTrace") - let traceStart = Date() - let inV = changeManager.reloadVersion - defer { - let elapsed = Date().timeIntervalSince(traceStart) * 1000 - if elapsed > 1 { - trace.info("updateNSView reloadVersion=\(inV) elapsed=\(elapsed)ms") - } - } - // Don't reload while editing (field editor or overlay) if tableView.editedRow >= 0 { return } if let editor = context.coordinator.overlayEditor, editor.isActive { return } @@ -289,9 +278,7 @@ struct DataGridView: NSViewRepresentable { // Re-apply pending cell edits only when changes have been modified if changeManager.reloadVersion != coordinator.lastReapplyVersion { - let reapplyStart = Date() coordinator.lastReapplyVersion = changeManager.reloadVersion - var cellCount = 0 for rowChange in changeManager.rowChanges { for cellChange in rowChange.cellChanges { coordinator.rowProvider.updateValue( @@ -299,11 +286,8 @@ struct DataGridView: NSViewRepresentable { at: rowChange.rowIndex, columnIndex: cellChange.columnIndex ) - cellCount += 1 } } - let reapplyElapsed = Date().timeIntervalSince(reapplyStart) * 1000 - trace.info("reapplyCellChanges cells=\(cellCount) elapsed=\(reapplyElapsed)ms") } coordinator.updateCache() @@ -558,11 +542,7 @@ struct DataGridView: NSViewRepresentable { metadataChanged: Bool = false, paginationChanged: Bool = false ) { - let trace = Logger(subsystem: "com.TablePro", category: "UndoTrace") - let start = Date() - let beforeCallCount = coordinator.viewForRowCallCount if needsFullReload { - trace.info("reloadAndSync FULL_RELOAD") tableView.reloadData() } else if metadataChanged { // FK metadata arrived (Phase 2) — reload only FK columns to show arrow buttons. @@ -588,24 +568,16 @@ struct DataGridView: NSViewRepresentable { } else if versionChanged { let changedRows = changeManager.consumeChangedRowIndices() if changedRows.count > 500 { - trace.info("reloadAndSync VERSION_CHANGED count=\(changedRows.count) BIG -> full reloadData") tableView.reloadData() } else if !changedRows.isEmpty { let rowIndexSet = IndexSet(changedRows) let columnIndexSet = IndexSet(integersIn: 0.. partial reload") tableView.reloadData(forRowIndexes: rowIndexSet, columnIndexes: columnIndexSet) } else if !changeManager.hasChanges { - trace.info("reloadAndSync VERSION_CHANGED no changes -> full reloadData") tableView.reloadData() } } - let elapsed = Date().timeIntervalSince(start) * 1000 - let cellCalls = coordinator.viewForRowCallCount - beforeCallCount - if elapsed > 1 { - trace.info("reloadAndSync total elapsed=\(elapsed)ms cellCalls=\(cellCalls)") - } coordinator.lastReloadVersion = changeManager.reloadVersion // Scroll to first row when page changes diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index db9e4f549..f776041f1 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -8,7 +8,6 @@ import SwiftUI extension TableViewCoordinator { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - viewForRowCallCount &+= 1 guard let column = tableColumn else { return nil } let columnId = column.identifier.rawValue