From a85501b92be5303d3ea5aa0c71e61093da86b4e1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 17 Jul 2026 02:26:39 +0700 Subject: [PATCH] fix(datagrid): apply Copy as and Delete to the whole cell-range selection --- CHANGELOG.md | 2 + .../Coordinators/RowEditingCoordinator.swift | 7 +- .../Main/MainContentCommandActions.swift | 17 +- TablePro/Views/Results/DataGridRowView.swift | 33 ++- .../Extensions/DataGridView+Selection.swift | 13 ++ .../Views/Results/KeyHandlingTableView.swift | 17 +- .../RowEditingCoordinatorCopyTests.swift | 122 +++++++++++ .../Main/CommandActionsDispatchTests.swift | 105 +++++++++ .../Results/DataGridRowViewCopyTests.swift | 206 ++++++++++++++++++ .../KeyHandlingTableViewCopyTests.swift | 114 ++++++++++ docs/features/data-grid.mdx | 2 +- 11 files changed, 598 insertions(+), 40 deletions(-) create mode 100644 TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift create mode 100644 TableProTests/Views/Results/KeyHandlingTableViewCopyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 765f08e55..670129e3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed Copy as, the context menu's Delete, and the Edit menu's copy and delete commands acting on only the active row instead of every row in a cell-range or column selection in the data grid. +- Fixed the Edit menu's Copy as JSON copying the wrong rows when the grid was sorted or filtered. - Fixed tables picked in the sidebar showing up unchecked and getting silently skipped when exporting to SQL or MQL. The export sheet now checks them and applies the format's default per-table options, so Export works right away. - Fixed ClickHouse queries showing only a success message with no result table. TablePro guessed whether a query returns data from its first word, which failed for queries starting with a comment or with SELECT on its own line. It now reads what the server actually returned, so any query that produces a result set shows the grid, including empty results and queries with their own FORMAT clause. (#1886) - Fixed ClickHouse INSERT statements always reporting 0 rows affected. (#1886) diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index cdba4e287..f4b5ba2c7 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -280,12 +280,13 @@ final class RowEditingCoordinator { func copySelectedRowsAsJson(indices: Set) { guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !indices.isEmpty else { return } let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id) + let displayIDs = parent.activeGridDisplayIDs let projection = VisibleColumnProjection( indices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() ) - let rows = indices.sorted().compactMap { idx -> [PluginCellValue]? in - guard idx >= 0, idx < tableRows.count else { return nil } - return projection.values(Array(tableRows.rows[idx].values)) + let rows = indices.sorted().compactMap { displayIndex -> [PluginCellValue]? in + DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) + .map { projection.values(Array($0.values)) } } guard !rows.isEmpty else { return } let converter = JsonRowConverter( diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index a7e718438..2e4541e12 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -176,6 +176,10 @@ final class MainContentCommandActions { } } + private func resolvedRowSelection() -> Set { + coordinator?.dataTabDelegate?.tableViewCoordinator?.currentRowSelection() ?? selectionState.indices + } + func deleteSelectedRows(rowIndices: Set? = nil) { let fromDataGrid = rowIndices != nil @@ -184,7 +188,7 @@ final class MainContentCommandActions { return } - let indices = rowIndices ?? selectionState.indices + let indices = rowIndices ?? resolvedRowSelection() if !indices.isEmpty { coordinator?.deleteSelectedRows(indices: indices) } else if !fromDataGrid, !selectedTables.wrappedValue.isEmpty { @@ -217,19 +221,16 @@ final class MainContentCommandActions { if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator?.structureActions?.copyRows?() } else { - let indices = selectionState.indices - coordinator?.copySelectedRowsToClipboard(indices: indices) + coordinator?.copySelectedRowsToClipboard(indices: resolvedRowSelection()) } } func copySelectedRowsWithHeaders() { - let indices = selectionState.indices - coordinator?.copySelectedRowsWithHeaders(indices: indices) + coordinator?.copySelectedRowsWithHeaders(indices: resolvedRowSelection()) } func copySelectedRowsAsJson() { - let indices = selectionState.indices - coordinator?.copySelectedRowsAsJson(indices: indices) + coordinator?.copySelectedRowsAsJson(indices: resolvedRowSelection()) } func pasteRows() { @@ -280,7 +281,7 @@ final class MainContentCommandActions { } var hasRowSelection: Bool { - !selectionState.indices.isEmpty + !resolvedRowSelection().isEmpty } var hasTableSelection: Bool { diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 31ec870d7..d0e0f5e81 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -386,12 +386,8 @@ class DataGridRowView: NSTableRowView { } @objc private func deleteRow() { - let indices: Set = if let selected = coordinator?.selectedRowIndices, !selected.isEmpty { - selected - } else { - [rowIndex] - } - coordinator?.delegate?.dataGridDeleteRows(indices) + guard let coordinator else { return } + coordinator.delegate?.dataGridDeleteRows(coordinator.currentRowSelection(fallbackRow: rowIndex)) } @objc private func duplicateRow() { @@ -402,18 +398,14 @@ class DataGridRowView: NSTableRowView { coordinator?.undoDeleteRow(at: rowIndex) } - private func selectedOrCurrentIndices(in coordinator: TableViewCoordinator) -> Set { - coordinator.selectedRowIndices.isEmpty ? [rowIndex] : coordinator.selectedRowIndices - } - @objc private func copySelectedOrCurrentRowWithHeaders() { guard let coordinator else { return } - coordinator.copyRowsWithHeaders(at: selectedOrCurrentIndices(in: coordinator)) + coordinator.copyRowsWithHeaders(at: coordinator.currentRowSelection(fallbackRow: rowIndex)) } @objc private func copySelectedOrCurrentRow() { guard let coordinator else { return } - coordinator.delegate?.dataGridCopyRows(selectedOrCurrentIndices(in: coordinator)) + coordinator.delegate?.dataGridCopyRows(coordinator.currentRowSelection(fallbackRow: rowIndex)) } @objc private func copyFromContextMenu(_ sender: NSMenuItem) { @@ -486,12 +478,12 @@ class DataGridRowView: NSTableRowView { @objc private func copyAsInsert() { guard let coordinator else { return } - coordinator.copyRowsAsInsert(at: selectedOrCurrentIndices(in: coordinator)) + coordinator.copyRowsAsInsert(at: coordinator.currentRowSelection(fallbackRow: rowIndex)) } @objc private func copyAsUpdate() { guard let coordinator else { return } - coordinator.copyRowsAsUpdate(at: selectedOrCurrentIndices(in: coordinator)) + coordinator.copyRowsAsUpdate(at: coordinator.currentRowSelection(fallbackRow: rowIndex)) } @objc private func exportResults() { @@ -504,27 +496,30 @@ class DataGridRowView: NSTableRowView { @objc private func copyAsJson() { guard let coordinator else { return } - coordinator.copyRowsAsJson(at: selectedOrCurrentIndices(in: coordinator)) + coordinator.copyRowsAsJson(at: coordinator.currentRowSelection(fallbackRow: rowIndex)) } @objc private func copyAsCsv() { guard let coordinator else { return } - coordinator.copyRowsAsCsv(at: selectedOrCurrentIndices(in: coordinator), includeHeaders: false) + coordinator.copyRowsAsCsv(at: coordinator.currentRowSelection(fallbackRow: rowIndex), includeHeaders: false) } @objc private func copyAsCsvWithHeaders() { guard let coordinator else { return } - coordinator.copyRowsAsCsv(at: selectedOrCurrentIndices(in: coordinator), includeHeaders: true) + coordinator.copyRowsAsCsv(at: coordinator.currentRowSelection(fallbackRow: rowIndex), includeHeaders: true) } @objc private func copyAsMarkdown() { guard let coordinator else { return } - coordinator.copyRowsAsMarkdown(at: selectedOrCurrentIndices(in: coordinator)) + coordinator.copyRowsAsMarkdown(at: coordinator.currentRowSelection(fallbackRow: rowIndex)) } @objc private func copyAsInClause(_ sender: NSMenuItem) { guard let coordinator, let columnIndex = sender.representedObject as? Int else { return } - coordinator.copyRowsAsInClause(at: selectedOrCurrentIndices(in: coordinator), columnIndex: columnIndex) + coordinator.copyRowsAsInClause( + at: coordinator.currentRowSelection(fallbackRow: rowIndex), + columnIndex: columnIndex + ) } @objc private func previewForeignKey(_ sender: NSMenuItem) { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 9bb56063a..8ed2686be 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -28,6 +28,19 @@ extension TableViewCoordinator { } } + func currentRowSelection(fallbackRow: Int? = nil) -> Set { + if !selectionController.isEmpty { + return Set(selectionController.selection.affectedRows) + } + if !selectedRowIndices.isEmpty { + return selectedRowIndices + } + if let fallbackRow, fallbackRow >= 0 { + return [fallbackRow] + } + return [] + } + func tableViewSelectionDidChange(_ notification: Notification) { guard let tableView = notification.object as? NSTableView else { return } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 2e7a53ea0..5f3b7db7b 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -221,13 +221,10 @@ final class KeyHandlingTableView: NSTableView { } @objc func delete(_ sender: Any?) { - guard coordinator?.isEditable == true else { return } - if let controller = gridSelection, !controller.isEmpty { - coordinator?.delegate?.dataGridDeleteRows(Set(controller.selection.affectedRows)) - return - } - guard !selectedRowIndexes.isEmpty else { return } - coordinator?.delegate?.dataGridDeleteRows(Set(selectedRowIndexes)) + guard let coordinator, coordinator.isEditable else { return } + let indices = coordinator.currentRowSelection() + guard !indices.isEmpty else { return } + coordinator.delegate?.dataGridDeleteRows(indices) } @objc func copy(_ sender: Any?) { @@ -243,7 +240,8 @@ final class KeyHandlingTableView: NSTableView { } @objc func copyRowsAsTSV(_ sender: Any?) { - coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) + guard let coordinator else { return } + coordinator.delegate?.dataGridCopyRows(coordinator.currentRowSelection()) } @objc override func selectAll(_ sender: Any?) { @@ -289,7 +287,8 @@ final class KeyHandlingTableView: NSTableView { let hasGridSelection = gridSelection?.isEmpty == false return hasGridSelection || !selectedRowIndexes.isEmpty case #selector(copyRowsAsTSV(_:)): - return !selectedRowIndexes.isEmpty + let hasGridSelection = gridSelection?.isEmpty == false + return hasGridSelection || !selectedRowIndexes.isEmpty case #selector(paste(_:)): return coordinator?.isEditable == true && coordinator?.delegate != nil case #selector(insertNewline(_:)): diff --git a/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift b/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift new file mode 100644 index 000000000..7432ec1d5 --- /dev/null +++ b/TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift @@ -0,0 +1,122 @@ +// +// RowEditingCoordinatorCopyTests.swift +// TableProTests +// + +import Foundation +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class RowEditingCopyClipboard: ClipboardProvider { + var text: String? + var hasGridRowsValue = false + + func readText() -> String? { text } + func readGridRows() -> GridRowsClipboardPayload? { nil } + func writeText(_ text: String) { self.text = text; hasGridRowsValue = false } + func writeCsv(_ csv: String) { text = csv; hasGridRowsValue = false } + func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { text = tsv; hasGridRowsValue = true } + var hasText: Bool { text != nil } + var hasGridRows: Bool { hasGridRowsValue } +} + +@MainActor +private final class RowEditingCopyLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@Suite("RowEditingCoordinator copy as JSON") +@MainActor +struct RowEditingCoordinatorCopyTests { + private func makeCoordinator() -> MainContentCoordinator { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + var tab = QueryTab(title: "Q1", query: "SELECT id, name FROM users", tabType: .query) + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + let tableRows = TableRows.from( + queryRows: [ + [.text("1"), .text("Alice")], + [.text("2"), .text("Bob")] + ], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + coordinator.setActiveTableRows(tableRows, for: tab.id) + return coordinator + } + + private func attachGrid( + to coordinator: MainContentCoordinator, + sortedIDs: [RowID]? + ) -> (DataTabGridDelegate, TableViewCoordinator) { + let delegate = DataTabGridDelegate() + let tableViewCoordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: RowEditingCopyLayoutPersister() + ) + tableViewCoordinator.sortedIDs = sortedIDs + delegate.dataGridAttach(tableViewCoordinator: tableViewCoordinator) + coordinator.dataTabDelegate = delegate + return (delegate, tableViewCoordinator) + } + + @Test("Copy as JSON resolves display positions through the sorted order") + func copyAsJsonResolvesDisplayOrder() { + let clipboard = RowEditingCopyClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + let coordinator = makeCoordinator() + let attached = attachGrid(to: coordinator, sortedIDs: [.existing(1), .existing(0)]) + + coordinator.copySelectedRowsAsJson(indices: [0]) + + #expect(clipboard.text?.contains("Bob") == true) + #expect(clipboard.text?.contains("Alice") != true) + withExtendedLifetime(attached) {} + } + + @Test("Copy as JSON keeps storage order when no display mapping exists") + func copyAsJsonWithoutDisplayMapping() { + let clipboard = RowEditingCopyClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + let coordinator = makeCoordinator() + + coordinator.copySelectedRowsAsJson(indices: [0]) + + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") != true) + } + + @Test("Copy as JSON skips display positions past the current rows") + func copyAsJsonSkipsOutOfRangeIndices() { + let clipboard = RowEditingCopyClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + let coordinator = makeCoordinator() + + coordinator.copySelectedRowsAsJson(indices: [1, 99]) + + #expect(clipboard.text?.contains("Bob") == true) + } +} diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index d2cdb9b87..66cc1afd7 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -12,6 +12,27 @@ import SwiftUI import TableProPluginKit import Testing +@MainActor +private final class CommandActionsClipboard: ClipboardProvider { + var text: String? + var hasGridRowsValue = false + + func readText() -> String? { text } + func readGridRows() -> GridRowsClipboardPayload? { nil } + func writeText(_ text: String) { self.text = text; hasGridRowsValue = false } + func writeCsv(_ csv: String) { text = csv; hasGridRowsValue = false } + func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { text = tsv; hasGridRowsValue = true } + var hasText: Bool { text != nil } + var hasGridRows: Bool { hasGridRowsValue } +} + +@MainActor +private final class CommandActionsLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + @MainActor @Suite("CommandActions Dispatch") struct CommandActionsDispatchTests { // MARK: - Helpers @@ -211,4 +232,88 @@ struct CommandActionsDispatchTests { #expect(!structureSaveCalled) } + + // MARK: - Row selection resolution + + private func seedRows(_ coordinator: MainContentCoordinator) { + guard let tabId = coordinator.tabManager.selectedTabId else { return } + let tableRows = TableRows.from( + queryRows: [ + [.text("1"), .text("Alice")], + [.text("2"), .text("Bob")] + ], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + coordinator.setActiveTableRows(tableRows, for: tabId) + } + + private func attachGridWithRangeSelection( + to coordinator: MainContentCoordinator + ) -> (DataTabGridDelegate, TableViewCoordinator) { + let delegate = DataTabGridDelegate() + let tableViewCoordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: CommandActionsLayoutPersister() + ) + tableViewCoordinator.selectionController.update( + .single( + GridRect(rows: 0...1, columns: 0...0), + anchor: GridCoord(row: 0, column: 0), + active: GridCoord(row: 1, column: 0) + ) + ) + delegate.dataGridAttach(tableViewCoordinator: tableViewCoordinator) + coordinator.dataTabDelegate = delegate + return (delegate, tableViewCoordinator) + } + + @Test("copySelectedRowsAsJson honors the grid range selection") + func copySelectedRowsAsJson_usesRangeSelection() { + let clipboard = CommandActionsClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") + seedRows(coordinator) + let attached = attachGridWithRangeSelection(to: coordinator) + + actions.copySelectedRowsAsJson() + + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(attached) {} + } + + @Test("copySelectedRowsAsJson falls back to the row selection without a grid") + func copySelectedRowsAsJson_fallsBackWithoutGrid() { + let clipboard = CommandActionsClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") + seedRows(coordinator) + coordinator.selectionState.indices = [0] + + actions.copySelectedRowsAsJson() + + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") != true) + } + + @Test("hasRowSelection reflects a grid range selection") + func hasRowSelection_reflectsRangeSelection() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") + seedRows(coordinator) + let attached = attachGridWithRangeSelection(to: coordinator) + + #expect(actions.hasRowSelection) + withExtendedLifetime(attached) {} + } } diff --git a/TableProTests/Views/Results/DataGridRowViewCopyTests.swift b/TableProTests/Views/Results/DataGridRowViewCopyTests.swift index bf9472ef9..5289e6022 100644 --- a/TableProTests/Views/Results/DataGridRowViewCopyTests.swift +++ b/TableProTests/Views/Results/DataGridRowViewCopyTests.swift @@ -29,10 +29,15 @@ private final class DataGridRowViewCopyLayoutPersister: ColumnLayoutPersisting { @MainActor private final class DataGridRowViewCopyDelegateSpy: DataGridViewDelegate { var copiedRows: Set? + var deletedRows: Set? func dataGridCopyRows(_ indices: Set) { copiedRows = indices } + + func dataGridDeleteRows(_ indices: Set) { + deletedRows = indices + } } @Suite("DataGridRowView context menu copy") @@ -205,4 +210,205 @@ struct DataGridRowViewCopyTests { #expect(clipboard.text == "1\tAlice\n2\tBob") } + + private func makeRangeSelectedRowView( + delegate: (any DataGridViewDelegate)? = nil + ) -> (DataGridRowView, TableViewCoordinator) { + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], + selectedRows: [1], + delegate: delegate + ) + coordinator.selectionController.update( + .single( + GridRect(rows: 0...1, columns: 0...1), + anchor: GridCoord(row: 0, column: 0), + active: GridCoord(row: 1, column: 0) + ) + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 1 + return (rowView, coordinator) + } + + private func withClipboard(_ body: (DataGridRowViewCopyClipboard) -> Void) { + let clipboard = DataGridRowViewCopyClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + body(clipboard) + } + + @Test("Copy as Rows sends every row of the range selection") + func copyRowsUsesRangeSelection() { + let delegate = DataGridRowViewCopyDelegateSpy() + let (rowView, coordinator) = makeRangeSelectedRowView(delegate: delegate) + + invokeCopyRows(on: rowView) + + #expect(delegate.copiedRows == Set([0, 1])) + withExtendedLifetime(coordinator) {} + } + + @Test("Copy as With Headers includes every row of the range selection") + func copyWithHeadersUsesRangeSelection() { + withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + + _ = rowView.perform(NSSelectorFromString("copySelectedOrCurrentRowWithHeaders")) + + #expect(clipboard.text == "c0\tc1\n1\tAlice\n2\tBob") + withExtendedLifetime(coordinator) {} + } + } + + @Test("Copy as JSON includes every row of the range selection") + func copyAsJsonUsesRangeSelection() { + withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + + _ = rowView.perform(NSSelectorFromString("copyAsJson")) + + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(coordinator) {} + } + } + + @Test("Copy as CSV includes every row of the range selection") + func copyAsCsvUsesRangeSelection() { + withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + + _ = rowView.perform(NSSelectorFromString("copyAsCsv")) + + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(coordinator) {} + } + } + + @Test("Copy as CSV with Headers includes every row of the range selection") + func copyAsCsvWithHeadersUsesRangeSelection() { + withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + + _ = rowView.perform(NSSelectorFromString("copyAsCsvWithHeaders")) + + #expect(clipboard.text?.contains("c0") == true) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(coordinator) {} + } + } + + @Test("Copy as Markdown includes every row of the range selection") + func copyAsMarkdownUsesRangeSelection() { + withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + + _ = rowView.perform(NSSelectorFromString("copyAsMarkdown")) + + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(coordinator) {} + } + } + + @Test("Copy as IN Clause includes every row of the range selection") + func copyAsInClauseUsesRangeSelection() { + withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + let item = NSMenuItem(title: "IN Clause", action: nil, keyEquivalent: "") + item.representedObject = 1 + + _ = rowView.perform(NSSelectorFromString("copyAsInClause:"), with: item) + + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + withExtendedLifetime(coordinator) {} + } + } + + @Test("Copy as INSERT emits a statement for every row of the range selection") + func copyAsInsertUsesRangeSelection() { + withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + coordinator.tableName = "users" + coordinator.databaseType = .mysql + + _ = rowView.perform(NSSelectorFromString("copyAsInsert")) + + let statements = clipboard.text?.components(separatedBy: "INSERT INTO").count ?? 0 + #expect(statements - 1 == 2) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + } + } + + @Test("Copy as UPDATE emits a statement for every row of the range selection") + func copyAsUpdateUsesRangeSelection() { + withClipboard { clipboard in + let (rowView, coordinator) = makeRangeSelectedRowView() + coordinator.tableName = "users" + coordinator.databaseType = .mysql + coordinator.primaryKeyColumns = ["c0"] + + _ = rowView.perform(NSSelectorFromString("copyAsUpdate")) + + let statements = clipboard.text?.components(separatedBy: "UPDATE").count ?? 0 + #expect(statements - 1 == 2) + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + } + } + + @Test("Delete targets every row of the range selection") + func deleteUsesRangeSelection() { + let delegate = DataGridRowViewCopyDelegateSpy() + let (rowView, coordinator) = makeRangeSelectedRowView(delegate: delegate) + + _ = rowView.perform(NSSelectorFromString("deleteRow")) + + #expect(delegate.deletedRows == Set([0, 1])) + withExtendedLifetime(coordinator) {} + } + + @Test("Copy as JSON covers every row of a column selection") + func copyAsJsonUsesColumnSelection() { + withClipboard { clipboard in + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] + ) + coordinator.selectionController.selectEntireColumn(0, totalRows: 2) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 0 + + _ = rowView.perform(NSSelectorFromString("copyAsJson")) + + #expect(clipboard.text?.contains("Alice") == true) + #expect(clipboard.text?.contains("Bob") == true) + } + } + + @Test("Copy as JSON falls back to the clicked row without any selection") + func copyAsJsonFallsBackToClickedRow() { + withClipboard { clipboard in + let coordinator = makeCoordinator( + rows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")] + ) + let rowView = DataGridRowView() + rowView.coordinator = coordinator + rowView.rowIndex = 1 + + _ = rowView.perform(NSSelectorFromString("copyAsJson")) + + #expect(clipboard.text?.contains("Bob") == true) + #expect(clipboard.text?.contains("Alice") != true) + } + } } diff --git a/TableProTests/Views/Results/KeyHandlingTableViewCopyTests.swift b/TableProTests/Views/Results/KeyHandlingTableViewCopyTests.swift new file mode 100644 index 000000000..d9036813c --- /dev/null +++ b/TableProTests/Views/Results/KeyHandlingTableViewCopyTests.swift @@ -0,0 +1,114 @@ +// +// KeyHandlingTableViewCopyTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class KeyHandlingCopyLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class KeyHandlingCopyDelegateSpy: DataGridViewDelegate { + var copiedRows: Set? + var deletedRows: Set? + + func dataGridCopyRows(_ indices: Set) { + copiedRows = indices + } + + func dataGridDeleteRows(_ indices: Set) { + deletedRows = indices + } +} + +@Suite("KeyHandlingTableView selection-scoped commands") +@MainActor +struct KeyHandlingTableViewCopyTests { + private func makeSUT( + delegate: any DataGridViewDelegate + ) -> (KeyHandlingTableView, TableViewCoordinator) { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: KeyHandlingCopyLayoutPersister() + ) + let tableView = KeyHandlingTableView() + tableView.coordinator = coordinator + coordinator.tableView = tableView + return (tableView, coordinator) + } + + private func applyRangeSelection(to coordinator: TableViewCoordinator) { + coordinator.selectionController.update( + .single( + GridRect(rows: 0...1, columns: 0...1), + anchor: GridCoord(row: 0, column: 0), + active: GridCoord(row: 1, column: 0) + ) + ) + } + + @Test("Copy Rows sends every row of the range selection") + func copyRowsAsTsvUsesRangeSelection() { + let delegate = KeyHandlingCopyDelegateSpy() + let (tableView, coordinator) = makeSUT(delegate: delegate) + applyRangeSelection(to: coordinator) + + tableView.copyRowsAsTSV(nil) + + #expect(delegate.copiedRows == Set([0, 1])) + } + + @Test("Delete removes every row of the range selection") + func deleteUsesRangeSelection() { + let delegate = KeyHandlingCopyDelegateSpy() + let (tableView, coordinator) = makeSUT(delegate: delegate) + applyRangeSelection(to: coordinator) + + tableView.delete(nil) + + #expect(delegate.deletedRows == Set([0, 1])) + } + + @Test("Copy Rows stays enabled when only a range selection exists") + func validateCopyRowsWithRangeSelectionOnly() { + let delegate = KeyHandlingCopyDelegateSpy() + let (tableView, coordinator) = makeSUT(delegate: delegate) + applyRangeSelection(to: coordinator) + + let item = NSMenuItem( + title: "Copy Rows", + action: #selector(KeyHandlingTableView.copyRowsAsTSV(_:)), + keyEquivalent: "" + ) + + #expect(tableView.validateUserInterfaceItem(item)) + } + + @Test("Copy Rows stays disabled without any selection") + func validateCopyRowsWithoutSelection() { + let delegate = KeyHandlingCopyDelegateSpy() + let (tableView, _) = makeSUT(delegate: delegate) + + let item = NSMenuItem( + title: "Copy Rows", + action: #selector(KeyHandlingTableView.copyRowsAsTSV(_:)), + keyEquivalent: "" + ) + + #expect(!tableView.validateUserInterfaceItem(item)) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index b369452bc..b9d710e55 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -122,7 +122,7 @@ With no row selected, it shows table statistics: data, index, and total size, ro ## Select and Copy -Click a cell to select it, drag or `Shift`-click for a range, and click row numbers for whole rows. +Click a cell to select it, drag or `Shift`-click for a range, and click row numbers for whole rows. Copy, **Copy as**, and Delete act on the whole current selection, whether that is a cell range, a column (`Cmd`-click its header), or rows picked by their row numbers. - `Cmd+C`: the focused cell, or the selected rows as TSV. - `Cmd+Shift+C`: the selected rows as TSV.