diff --git a/apple/TablePro/Core/Menu/EditMenuBuilder.swift b/apple/TablePro/Core/Menu/EditMenuBuilder.swift index 48da255e55..4127e1c2c8 100644 --- a/apple/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/EditMenuBuilder.swift @@ -123,6 +123,13 @@ enum EditMenuBuilder { private static func copySpecialSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { MenuItemFactory.submenu(String(localized: "Copy Special"), items: [ + MenuItemFactory.item( + String(localized: "Copy Cell Value"), + action: #selector(GridCopyCommandResponding.copyCellValue(_:)), + shortcut: .copyCellValue, + keyboard: keyboard + ), + MenuItemFactory.separator, MenuItemFactory.item( String(localized: "Copy Rows"), action: #selector(MainSplitViewController.copySelectedRows(_:)), @@ -140,6 +147,43 @@ enum EditMenuBuilder { action: #selector(MainSplitViewController.copyRowsAsJson(_:)), shortcut: .copyAsJson, keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Copy as CSV"), + action: #selector(GridCopyCommandResponding.copyRowsAsCsv(_:)), + shortcut: .copyAsCsv, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Copy as CSV with Headers"), + action: #selector(GridCopyCommandResponding.copyRowsAsCsvWithHeaders(_:)), + shortcut: .copyAsCsvWithHeaders, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Copy as Markdown"), + action: #selector(GridCopyCommandResponding.copyRowsAsMarkdown(_:)), + shortcut: .copyAsMarkdown, + keyboard: keyboard + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Copy as IN Clause"), + action: #selector(GridCopyCommandResponding.copyRowsAsInClause(_:)), + shortcut: .copyAsInClause, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Copy as INSERT Statements"), + action: #selector(GridCopyCommandResponding.copyRowsAsInsertStatements(_:)), + shortcut: .copyAsInsert, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Copy as UPDATE Statements"), + action: #selector(GridCopyCommandResponding.copyRowsAsUpdateStatements(_:)), + shortcut: .copyAsUpdate, + keyboard: keyboard ) ]) } diff --git a/apple/TablePro/Core/Menu/GridCopyCommandResponding.swift b/apple/TablePro/Core/Menu/GridCopyCommandResponding.swift new file mode 100644 index 0000000000..817306eb56 --- /dev/null +++ b/apple/TablePro/Core/Menu/GridCopyCommandResponding.swift @@ -0,0 +1,18 @@ +// +// GridCopyCommandResponding.swift +// TablePro +// + +import AppKit + +// The focused grid answers for its own selection, so these work in any window that shows one; elsewhere they dim. +@MainActor +@objc protocol GridCopyCommandResponding { + func copyCellValue(_ sender: Any?) + func copyRowsAsCsv(_ sender: Any?) + func copyRowsAsCsvWithHeaders(_ sender: Any?) + func copyRowsAsMarkdown(_ sender: Any?) + func copyRowsAsInClause(_ sender: Any?) + func copyRowsAsInsertStatements(_ sender: Any?) + func copyRowsAsUpdateStatements(_ sender: Any?) +} diff --git a/apple/TablePro/Core/Services/Infrastructure/ClipboardService.swift b/apple/TablePro/Core/Services/Infrastructure/ClipboardService.swift index ffeb972d4d..111178a6bf 100644 --- a/apple/TablePro/Core/Services/Infrastructure/ClipboardService.swift +++ b/apple/TablePro/Core/Services/Infrastructure/ClipboardService.swift @@ -67,6 +67,8 @@ protocol ClipboardProvider { func writeCsv(_ csv: String) func writeImage(_ image: NSImage) func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) + func writeText(_ text: String, gridRows: GridRowsClipboardPayload) + func writeCsv(_ csv: String, gridRows: GridRowsClipboardPayload) func writeCells(_ cells: GridCellsClipboardPayload) var hasText: Bool { get } var hasGridRows: Bool { get } @@ -145,8 +147,22 @@ struct NSPasteboardClipboardProvider: ClipboardProvider { if let html { pb.setString(html, forType: .html) } + setGridRows(gridRows) + } + + func writeText(_ text: String, gridRows: GridRowsClipboardPayload) { + writeText(text) + setGridRows(gridRows) + } + + func writeCsv(_ csv: String, gridRows: GridRowsClipboardPayload) { + writeCsv(csv) + setGridRows(gridRows) + } + + private func setGridRows(_ gridRows: GridRowsClipboardPayload) { if let data = try? JSONEncoder().encode(gridRows) { - pb.setData(data, forType: Self.gridRowsType) + NSPasteboard.general.setData(data, forType: Self.gridRowsType) } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift index a1478d2b93..5c18c5f4ba 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift @@ -26,7 +26,7 @@ extension MainSplitViewController { @objc func copy(_ sender: Any?) { guard let actions = commandActions else { return } if actions.hasRowSelection { - actions.copySelectedRows() + actions.copySelectedRowsInCopyFormat() return } actions.copyTableNames() diff --git a/apple/TablePro/Models/Settings/AppSettings.swift b/apple/TablePro/Models/Settings/AppSettings.swift index c21e93ab70..17a1259b13 100644 --- a/apple/TablePro/Models/Settings/AppSettings.swift +++ b/apple/TablePro/Models/Settings/AppSettings.swift @@ -86,6 +86,29 @@ enum DefaultSortBehavior: String, Codable, CaseIterable, Identifiable, Equatable } } +// IN Clause, INSERT and UPDATE are left out: they need a column or a single table, so no grid can always produce them. +enum RowCopyFormat: String, Codable, CaseIterable, Identifiable { + case rows + case rowsWithHeaders + case json + case csv + case csvWithHeaders + case markdown + + var id: String { rawValue } + + var displayName: String { + switch self { + case .rows: return "TSV" + case .rowsWithHeaders: return String(localized: "TSV with Headers") + case .json: return "JSON" + case .csv: return "CSV" + case .csvWithHeaders: return String(localized: "CSV with Headers") + case .markdown: return "Markdown" + } + } +} + /// Data grid settings struct DataGridSettings: Codable, Equatable { var rowHeight: DataGridRowHeight @@ -101,6 +124,7 @@ struct DataGridSettings: Codable, Equatable { var truncateQueryResults: Bool var defaultSortBehavior: DefaultSortBehavior var defaultSortDirection: SortDirection + var rowCopyFormat: RowCopyFormat static let `default` = DataGridSettings( rowHeight: .normal, @@ -115,7 +139,8 @@ struct DataGridSettings: Codable, Equatable { queryResultRowCap: 10_000, truncateQueryResults: true, defaultSortBehavior: .none, - defaultSortDirection: .ascending + defaultSortDirection: .ascending, + rowCopyFormat: .rows ) init( @@ -131,7 +156,8 @@ struct DataGridSettings: Codable, Equatable { queryResultRowCap: Int = 10_000, truncateQueryResults: Bool = true, defaultSortBehavior: DefaultSortBehavior = .none, - defaultSortDirection: SortDirection = .ascending + defaultSortDirection: SortDirection = .ascending, + rowCopyFormat: RowCopyFormat = .rows ) { self.rowHeight = rowHeight self.dateFormat = dateFormat @@ -146,6 +172,7 @@ struct DataGridSettings: Codable, Equatable { self.truncateQueryResults = truncateQueryResults self.defaultSortBehavior = defaultSortBehavior self.defaultSortDirection = defaultSortDirection + self.rowCopyFormat = rowCopyFormat } init(from decoder: Decoder) throws { @@ -169,6 +196,7 @@ struct DataGridSettings: Codable, Equatable { truncateQueryResults = container.decodeSetting(.truncateQueryResults, default: fallback.truncateQueryResults) defaultSortBehavior = container.decodeSetting(.defaultSortBehavior, default: fallback.defaultSortBehavior) defaultSortDirection = container.decodeSetting(.defaultSortDirection, default: fallback.defaultSortDirection) + rowCopyFormat = container.decodeSetting(.rowCopyFormat, default: fallback.rowCopyFormat) } // MARK: - Validated Properties diff --git a/apple/TablePro/Models/UI/DataGridConfiguration.swift b/apple/TablePro/Models/UI/DataGridConfiguration.swift index adef635396..f42660aec7 100644 --- a/apple/TablePro/Models/UI/DataGridConfiguration.swift +++ b/apple/TablePro/Models/UI/DataGridConfiguration.swift @@ -49,6 +49,10 @@ struct DataGridConfiguration: Equatable { /// question is what the grid holds, not which tab it sits in. var appliesRowSortPreferences: Bool = false + // Records: a table, a query, a file or a listing. Off on the Structure grids, whose Copy writes column and + // index definitions a paste reads back, and which have no INSERT or UPDATE to offer. + var appliesRowCopyFormat: Bool = false + /// Whether a heading click or the heading menu orders the rows. A Cassandra table cannot be ordered by an /// arbitrary column, so its table grid offers no sort rather than one the server refuses. var supportsColumnSort: Bool = true diff --git a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift index e421115cce..5b05d84865 100644 --- a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -100,9 +100,16 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case redo case cut case copy + case copyCellValue case copyRowsExplicit case copyWithHeaders case copyAsJson + case copyAsCsv + case copyAsCsvWithHeaders + case copyAsMarkdown + case copyAsInClause + case copyAsInsert + case copyAsUpdate case paste case delete case selectAll @@ -185,7 +192,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .previewSQL, .find, .findAndReplace, .findNext, .findPrevious, .useSelectionForFind, .aiReviewQuery, .aiExplainQuery, .aiOptimizeQuery: return .editor - case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, + case .undo, .redo, .cut, .copy, .copyCellValue, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, + .copyAsCsv, .copyAsCsvWithHeaders, .copyAsMarkdown, .copyAsInClause, .copyAsInsert, .copyAsUpdate, .paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, .setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7, .restorePreviousValues, @@ -224,6 +232,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7, .delete, .truncateTable, .previewFKReference, .saveAsFavorite, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters, .jumpToColumn, + .copyCellValue, .copyAsCsv, .copyAsCsvWithHeaders, .copyAsMarkdown, .copyAsInClause, + .copyAsInsert, .copyAsUpdate, /// Named rather than left to the `default:` below. It reverses a row the grid is /// showing, so it belongs in the grid's context the way Add Row and Delete do, and /// inheriting `.global` would let the recorder call a grid combo free for it. @@ -302,6 +312,13 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .copyRowsExplicit: return String(localized: "Copy Rows") case .copyWithHeaders: return String(localized: "Copy with Headers") case .copyAsJson: return String(localized: "Copy as JSON") + case .copyCellValue: return String(localized: "Copy Cell Value") + case .copyAsCsv: return String(localized: "Copy as CSV") + case .copyAsCsvWithHeaders: return String(localized: "Copy as CSV with Headers") + case .copyAsMarkdown: return String(localized: "Copy as Markdown") + case .copyAsInClause: return String(localized: "Copy as IN Clause") + case .copyAsInsert: return String(localized: "Copy as INSERT Statements") + case .copyAsUpdate: return String(localized: "Copy as UPDATE Statements") case .paste: return String(localized: "Paste") case .delete: return String(localized: "Delete") case .selectAll: return String(localized: "Select All") diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 74119f23c0..760272d39f 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -49258,6 +49258,12 @@ } } } + }, + "Copy as CSV" : { + + }, + "Copy as CSV with Headers" : { + }, "Copy as Hex" : { "localizations" : { @@ -49298,6 +49304,12 @@ } } } + }, + "Copy as IN Clause" : { + + }, + "Copy as INSERT Statements" : { + }, "Copy as JSON" : { "localizations" : { @@ -49338,6 +49350,12 @@ } } } + }, + "Copy as Markdown" : { + + }, + "Copy as UPDATE Statements" : { + }, "Copy Average" : { @@ -49351,6 +49369,9 @@ } } } + }, + "Copy Cell Value" : { + }, "Copy Column Name" : { "localizations" : { @@ -50462,6 +50483,9 @@ } } } + }, + "Copy rows as:" : { + }, "Copy rows into %@ on the server" : { "localizations" : { @@ -50795,6 +50819,9 @@ } } } + }, + "Copy uses this format for selected rows. A single cell still copies its value, and Edit > Copy Special offers every format." : { + }, "Copy Value" : { "localizations" : { @@ -50928,6 +50955,9 @@ } } } + }, + "Copying" : { + }, "Correct the Key Passphrase on the connection's Network tab." : { "extractionState" : "manual", @@ -199954,6 +199984,9 @@ } } } + }, + "TSV with Headers" : { + }, "TTL has to be a whole number of seconds above 0, or -1 or NULL for no expiry." : { "extractionState" : "manual", diff --git a/apple/TablePro/Views/Agent/AgentResultRowsView.swift b/apple/TablePro/Views/Agent/AgentResultRowsView.swift index 4cdbfc35a9..cbd8bb3d51 100644 --- a/apple/TablePro/Views/Agent/AgentResultRowsView.swift +++ b/apple/TablePro/Views/Agent/AgentResultRowsView.swift @@ -115,7 +115,8 @@ internal struct AgentResultRowsView: View { databaseType: connection?.type, showRowNumbers: true, supportsColumnCommands: false, - supportsValueFilter: false + supportsValueFilter: false, + appliesRowCopyFormat: true ), delegate: gridDelegate, selectedRowIndices: $selectedRows, diff --git a/apple/TablePro/Views/DataFiles/DataFileContentView.swift b/apple/TablePro/Views/DataFiles/DataFileContentView.swift index 38367b5496..ca68516b15 100644 --- a/apple/TablePro/Views/DataFiles/DataFileContentView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileContentView.swift @@ -100,6 +100,7 @@ struct DataFileContentView: View { config.showRowNumbers = true config.hiddenColumns = controller.columnLayout.hiddenColumns config.supportsValueFilter = false + config.appliesRowCopyFormat = true config.filterMenuColumnTypes = controller.columnNames.ids.map { DataFileColumnTypes.filterType(for: controller.kind(of: $0)) } diff --git a/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift b/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift index ee1e45023b..b6ccf9dfa1 100644 --- a/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift +++ b/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift @@ -48,6 +48,11 @@ final class DataFileGridDelegate: DataGridViewDelegate { } func dataGridPasteRows() { + // Rows copied as CSV, JSON or Markdown carry their values, which the tab-separated parser cannot read back. + if let copied = ClipboardService.shared.readGridRows() { + controller.pasteRows(DataFilePasteParser.rows(from: copied, into: controller.columnNames.displayNames)) + return + } guard let raw = ClipboardService.shared.readText(), !raw.isEmpty else { return } let rows = DataFilePasteParser.rows(from: raw) controller.pasteRows(rows) @@ -124,6 +129,33 @@ enum DataFilePasteParser { .map { line in line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) } .filter { !($0.count == 1 && $0[0].isEmpty) } } + + // By name when every copied column is one of the file's, once, so rows copied with columns reordered or + // hidden land where they came from. Anything else pastes in order, as its text would. + @MainActor + static func rows(from copied: GridRowsClipboardPayload, into columns: [String]) -> [[String]] { + let fileColumns = Set(columns) + guard Set(copied.columns).count == copied.columns.count, copied.columns.allSatisfy(fileColumns.contains) else { + return copied.rows.map { $0.map(text(of:)) } + } + let sources = columns.map { copied.columns.firstIndex(of: $0) } + return copied.rows.map { row in + sources.map { source in + guard let source, row.indices.contains(source) else { return "" } + return text(of: row[source]) + } + } + } + + // NULL pastes as the empty value this grid's own row copy writes for it. + @MainActor + static func text(of cell: PluginCellValue) -> String { + switch cell { + case .text(let text): return text + case .null: return "" + case .bytes(let data): return BlobFormattingService.shared.format(data, for: .copy) ?? "" + } + } } enum DataFileColumnTypes { diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index 8f47c69c43..91eeea55cc 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -1011,6 +1011,7 @@ struct MainEditorContentView: View { showRowNumbers: settingsManager.dataGrid.showRowNumbers, hiddenColumns: tab.columnLayout.hiddenColumns, appliesRowSortPreferences: true, + appliesRowCopyFormat: true, supportsColumnSort: tab.tabType != .table || PluginManager.shared.supportsColumnSort(for: connection.type), editRefusalMessage: refusal?.message diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index 61fab9e682..b79bee4a3e 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -1564,3 +1564,14 @@ final class MainContentCommandActions: ObservableObject { } } } + +extension MainContentCommandActions { + // Copy with the grid not focused: the grid's format when one is mounted, else Copy Rows. + func copySelectedRowsInCopyFormat() { + guard dataGridOwnsSelection, let grid = coordinator?.dataTabDelegate?.tableViewCoordinator else { + copySelectedRows() + return + } + grid.copyRowsInCopyFormat(at: resolvedRowSelection()) + } +} diff --git a/apple/TablePro/Views/Results/DataGridCoordinator.swift b/apple/TablePro/Views/Results/DataGridCoordinator.swift index 4e87200f75..195bd5b4c8 100644 --- a/apple/TablePro/Views/Results/DataGridCoordinator.swift +++ b/apple/TablePro/Views/Results/DataGridCoordinator.swift @@ -121,6 +121,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return AppSettingsManager.shared.dataGrid.defaultSortDirection } + var appliesRowCopyFormat: Bool = false + + var rowCopyFormat: RowCopyFormat { + guard appliesRowCopyFormat else { return .rows } + return AppSettingsManager.shared.dataGrid.rowCopyFormat + } + private var columnIndexByDataIndex: [Int: Int] = [:] /// Display position to data index, rebuilt lazily. `presentedDataColumns` reads it; a drag and /// the cell-range fill both ask per event and per row, and deriving it walks every attached @@ -277,6 +284,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData primaryKeyColumns = configuration.primaryKeyColumns tabType = configuration.tabType appliesRowSortPreferences = configuration.appliesRowSortPreferences + appliesRowCopyFormat = configuration.appliesRowCopyFormat supportsColumnSort = configuration.supportsColumnSort } diff --git a/apple/TablePro/Views/Results/DataGridRowView.swift b/apple/TablePro/Views/Results/DataGridRowView.swift index 6e11b954ce..916d5da596 100644 --- a/apple/TablePro/Views/Results/DataGridRowView.swift +++ b/apple/TablePro/Views/Results/DataGridRowView.swift @@ -466,6 +466,16 @@ class DataGridRowView: NSTableRowView { return item } + // Copy over a cell range copies the range, so the clicked cell needs a command of its own. + private func makeCopyCellValueItem(dataColumn: Int) -> NSMenuItem { + let item = NSMenuItem( + title: String(localized: "Copy Cell Value"), action: #selector(copyClickedCellValue(_:)), keyEquivalent: "" + ) + item.representedObject = dataColumn + item.target = self + return item + } + /// Deliberately not `menu(for:)`. The table view owns context-menu handling because it /// is the only level that can re-target the selection to the clicked row first; a row /// view answering `menuForEvent:` would swallow the event and act on the old selection. @@ -494,6 +504,9 @@ class DataGridRowView: NSTableRowView { } menu.addItem(makeCopyItem(target: target)) + if case .cell(let clickedColumn) = target { + menu.addItem(makeCopyCellValueItem(dataColumn: clickedColumn)) + } let copyAsMenu = NSMenu() @@ -550,12 +563,7 @@ class DataGridRowView: NSTableRowView { copyAsMenu.addItem(inClauseItem) } - /// The statements leave out the columns the server owns, which only the schema names, so the items stay away - /// until it has arrived, as Duplicate does. - if let dbType = coordinator.databaseType, - dbType != .mongodb && dbType != .redis, - coordinator.tableName != nil, - coordinator.tableRowsProvider().hasAuthoritativeSchema { + if coordinator.canCopyRowsAsInsert { copyAsMenu.addItem(NSMenuItem.separator()) let insertItem = NSMenuItem( @@ -565,10 +573,7 @@ class DataGridRowView: NSTableRowView { insertItem.target = self copyAsMenu.addItem(insertItem) - /// A table without a key is matched on every column, so one no match can compare leaves nothing to find - /// the row by. - let matchPolicy = coordinator.tableRowsProvider().rowMatchPolicy - if !coordinator.primaryKeyColumns.isEmpty || matchPolicy.excludedColumns.isEmpty { + if coordinator.canCopyRowsAsUpdate { let updateItem = NSMenuItem( title: String(localized: "UPDATE Statement(s)"), action: #selector(copyAsUpdate), @@ -790,13 +795,23 @@ class DataGridRowView: NSTableRowView { if let columnIndex = focusedDataColumnIndex(in: coordinator) { coordinator.copyCellValue(at: rowIndex, columnIndex: columnIndex) } else { - copySelectedOrCurrentRow() + copySelectedOrCurrentRowInCopyFormat() } case .row, .none: - copySelectedOrCurrentRow() + copySelectedOrCurrentRowInCopyFormat() } } + private func copySelectedOrCurrentRowInCopyFormat() { + guard let coordinator else { return } + coordinator.copyRowsInCopyFormat(at: coordinator.currentRowSelection(fallbackRow: rowIndex)) + } + + @objc private func copyClickedCellValue(_ sender: NSMenuItem) { + guard let coordinator, let columnIndex = sender.representedObject as? Int else { return } + coordinator.copyCellValue(at: rowIndex, columnIndex: columnIndex) + } + @objc private func pasteRows() { coordinator?.delegate?.dataGridPasteRows() } @@ -823,6 +838,7 @@ class DataGridRowView: NSTableRowView { private func focusedDataColumnIndex(in coordinator: TableViewCoordinator) -> Int? { guard let tableView = coordinator.tableView as? KeyHandlingTableView, + !tableView.isCursorSeededByRowSelection, tableView.focusedRow == rowIndex, tableView.presentsDataColumn(at: tableView.focusedColumn) else { return nil } return DataGridView.dataColumnIndex( diff --git a/apple/TablePro/Views/Results/DataGridView+RowActions.swift b/apple/TablePro/Views/Results/DataGridView+RowActions.swift index 5f0bc81876..2f9d9ba207 100644 --- a/apple/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/apple/TablePro/Views/Results/DataGridView+RowActions.swift @@ -26,13 +26,32 @@ extension TableViewCoordinator { delegate?.dataGridAddRow() } + func copyRows(at indices: Set, as format: RowCopyFormat) { + switch format { + case .rows: delegate?.dataGridCopyRows(indices) + case .rowsWithHeaders: copyRowsWithHeaders(at: indices) + case .json: copyRowsAsJson(at: indices) + case .csv: copyRowsAsCsv(at: indices, includeHeaders: false) + case .csvWithHeaders: copyRowsAsCsv(at: indices, includeHeaders: true) + case .markdown: copyRowsAsMarkdown(at: indices) + } + } + + func copyRowsInCopyFormat(at indices: Set) { + copyRows(at: indices, as: rowCopyFormat) + } + func copyRowsWithHeaders(at indices: Set) { - let sortedIndices = indices.sorted() + copyRowsAsTSV(at: indices, includeHeaders: true) + } + + func copyRowsAsTSV(at indices: Set, includeHeaders: Bool) { + let sortedIndices = clipboardDisplayIndices(indices) let tableRows = tableRowsProvider() let projection = visibleColumnProjection let columnTypes = projection.columnTypes(tableRows.columnTypes) let columns = projection.columns(tableRows.columns) - var tsvRows: [String] = [columns.joined(separator: "\t")] + var tsvRows: [String] = includeHeaders ? [columns.joined(separator: "\t")] : [] var htmlRows: [[String]] = [] var copiedRows: [Row] = [] @@ -43,16 +62,38 @@ extension TableViewCoordinator { htmlRows.append(formatted) copiedRows.append(row) } + guard !copiedRows.isEmpty else { return } let tsv = tsvRows.joined(separator: "\n") - let html = HtmlTableEncoder.encode(rows: htmlRows, headers: columns) - let payload = GridRowsClipboardPayload( + let html = includeHeaders ? HtmlTableEncoder.encode(rows: htmlRows, headers: columns) : nil + ClipboardService.shared.writeRows( + tsv: tsv, + html: html, + gridRows: rowsPayload(copiedRows, columns: columns, projection: projection) + ) + } + + // The ceiling each format's converter already puts on its text, taken first so the paste payload holds the same rows. + private func clipboardDisplayIndices(_ indices: Set) -> [Int] { + let sorted = indices.sorted() + let limit = RowOperationsManager.maxClipboardRows + guard sorted.count > limit else { return sorted } + rowActionsLogger.warning("Clipboard copy truncated: \(sorted.count) rows selected, capping at \(limit)") + return Array(sorted.prefix(limit)) + } + + // Carried by every row copy, so a paste into a grid takes the rows rather than parsing the text. + private func rowsPayload( + _ rows: [Row], + columns: [String], + projection: VisibleColumnProjection + ) -> GridRowsClipboardPayload { + GridRowsClipboardPayload( columns: columns, - copying: copiedRows, - expressionColumns: copiedRows.map { changeManager.expressionColumns(forRow: $0.id) }, + copying: rows, + expressionColumns: rows.map { changeManager.expressionColumns(forRow: $0.id) }, projection: projection ) - ClipboardService.shared.writeRows(tsv: tsv, html: html, gridRows: payload) } func copyCellValue(at rowIndex: Int, columnIndex: Int) { @@ -94,6 +135,18 @@ extension TableViewCoordinator { return BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy) } + // The statements leave out the columns the server owns, which only the schema names, so they wait for it as Duplicate does. + var canCopyRowsAsInsert: Bool { + guard let databaseType, databaseType != .mongodb, databaseType != .redis, tableName != nil else { return false } + return tableRowsProvider().hasAuthoritativeSchema + } + + // A table without a key is matched on every column, so one no match can compare leaves nothing to find the row by. + var canCopyRowsAsUpdate: Bool { + guard canCopyRowsAsInsert else { return false } + return !primaryKeyColumns.isEmpty || tableRowsProvider().rowMatchPolicy.excludedColumns.isEmpty + } + func copyRowsAsInsert(at indices: Set) { guard let tableName, let databaseType else { return } let tableRows = tableRowsProvider() @@ -192,38 +245,47 @@ extension TableViewCoordinator { func copyRowsAsJson(at indices: Set) { guard !indices.isEmpty else { return } + let tableRows = tableRowsProvider() + let projection = selectedColumnProjection() + let displayIndices = clipboardDisplayIndices(indices) let output = ResultJsonSerializer.serialize( - tableRows: tableRowsProvider(), + tableRows: tableRows, displayIDs: displayIDs, - selectedDisplayIndices: indices, - columns: selectedColumnProjection() + selectedDisplayIndices: Set(displayIndices), + columns: projection ) guard output.rowCount > 0 else { return } - ClipboardService.shared.writeText(output.json) + let copied = displayIndices.compactMap(displayRow(at:)) + ClipboardService.shared.writeText( + output.json, + gridRows: rowsPayload(copied, columns: projection.columns(tableRows.columns), projection: projection) + ) } func copyRowsAsCsv(at indices: Set, includeHeaders: Bool) { let projection = selectedColumnProjection() - let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } - guard !rows.isEmpty else { return } + let copied = clipboardDisplayIndices(indices).compactMap(displayRow(at:)) + guard !copied.isEmpty else { return } let tableRows = tableRowsProvider() - let converter = CsvRowConverter( - columns: projection.columns(tableRows.columns), - columnTypes: projection.columnTypes(tableRows.columnTypes) + let columns = projection.columns(tableRows.columns) + let converter = CsvRowConverter(columns: columns, columnTypes: projection.columnTypes(tableRows.columnTypes)) + ClipboardService.shared.writeCsv( + converter.generateCsv(rows: copied.map { projection.values(Array($0.values)) }, includeHeaders: includeHeaders), + gridRows: rowsPayload(copied, columns: columns, projection: projection) ) - ClipboardService.shared.writeCsv(converter.generateCsv(rows: rows, includeHeaders: includeHeaders)) } func copyRowsAsMarkdown(at indices: Set) { let projection = selectedColumnProjection() - let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } } - guard !rows.isEmpty else { return } + let copied = clipboardDisplayIndices(indices).compactMap(displayRow(at:)) + guard !copied.isEmpty else { return } let tableRows = tableRowsProvider() - let converter = MarkdownTableConverter( - columns: projection.columns(tableRows.columns), - columnTypes: projection.columnTypes(tableRows.columnTypes) + let columns = projection.columns(tableRows.columns) + let converter = MarkdownTableConverter(columns: columns, columnTypes: projection.columnTypes(tableRows.columnTypes)) + ClipboardService.shared.writeText( + converter.generateMarkdown(rows: copied.map { projection.values(Array($0.values)) }), + gridRows: rowsPayload(copied, columns: columns, projection: projection) ) - ClipboardService.shared.writeText(converter.generateMarkdown(rows: rows)) } func copyRowsAsInClause(at indices: Set, columnIndex: Int) { diff --git a/apple/TablePro/Views/Results/HeldRowsGridDelegate.swift b/apple/TablePro/Views/Results/HeldRowsGridDelegate.swift index 4dbb156759..82285bbe20 100644 --- a/apple/TablePro/Views/Results/HeldRowsGridDelegate.swift +++ b/apple/TablePro/Views/Results/HeldRowsGridDelegate.swift @@ -15,8 +15,17 @@ import Combine @MainActor internal final class HeldRowsGridDelegate: ObservableObject, DataGridViewDelegate { internal var onSortStateChanged: ((SortState) -> Void)? + private weak var gridCoordinator: TableViewCoordinator? + + internal func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { + gridCoordinator = tableViewCoordinator + } internal func dataGridSortStateChanged(_ state: SortState) { onSortStateChanged?(state) } + + internal func dataGridCopyRows(_ indices: Set) { + gridCoordinator?.copyRowsAsTSV(at: indices, includeHeaders: false) + } } diff --git a/apple/TablePro/Views/Results/KeyHandlingTableView+CopyCommands.swift b/apple/TablePro/Views/Results/KeyHandlingTableView+CopyCommands.swift new file mode 100644 index 0000000000..35f91a9678 --- /dev/null +++ b/apple/TablePro/Views/Results/KeyHandlingTableView+CopyCommands.swift @@ -0,0 +1,80 @@ +// +// KeyHandlingTableView+CopyCommands.swift +// TablePro +// + +import AppKit + +extension KeyHandlingTableView: GridCopyCommandResponding { + func copyCellValue(_ sender: Any?) { + guard let cell = pasteAnchorCell() else { return } + coordinator?.copyCellValue(at: cell.row, columnIndex: cell.column) + } + + func copyRowsAsCsv(_ sender: Any?) { + copySelectedRows(as: .csv) + } + + func copyRowsAsCsvWithHeaders(_ sender: Any?) { + copySelectedRows(as: .csvWithHeaders) + } + + func copyRowsAsMarkdown(_ sender: Any?) { + copySelectedRows(as: .markdown) + } + + func copyRowsAsInClause(_ sender: Any?) { + guard let coordinator, let column = inClauseColumn() else { return } + coordinator.copyRowsAsInClause(at: coordinator.currentRowSelection(), columnIndex: column) + } + + func copyRowsAsInsertStatements(_ sender: Any?) { + guard let coordinator, coordinator.canCopyRowsAsInsert else { return } + coordinator.copyRowsAsInsert(at: coordinator.currentRowSelection()) + } + + func copyRowsAsUpdateStatements(_ sender: Any?) { + guard let coordinator, coordinator.canCopyRowsAsUpdate else { return } + coordinator.copyRowsAsUpdate(at: coordinator.currentRowSelection()) + } + + // Nil for any other action, so the caller falls through to its own validation. + func canPerformCopyCommand(_ action: Selector?) -> Bool? { + switch action { + case #selector(GridCopyCommandResponding.copyCellValue(_:)): + return pasteAnchorCell() != nil + case #selector(GridCopyCommandResponding.copyRowsAsCsv(_:)), + #selector(GridCopyCommandResponding.copyRowsAsCsvWithHeaders(_:)), + #selector(GridCopyCommandResponding.copyRowsAsMarkdown(_:)): + return hasRowsToCopy + case #selector(GridCopyCommandResponding.copyRowsAsInClause(_:)): + return hasRowsToCopy && inClauseColumn() != nil + case #selector(GridCopyCommandResponding.copyRowsAsInsertStatements(_:)): + return hasRowsToCopy && coordinator?.canCopyRowsAsInsert == true + case #selector(GridCopyCommandResponding.copyRowsAsUpdateStatements(_:)): + return hasRowsToCopy && coordinator?.canCopyRowsAsUpdate == true + default: + return nil + } + } + + // The Structure grids list definitions, and an INSERT built from them would target the table they describe. + private var hasRowsToCopy: Bool { + guard let coordinator, coordinator.appliesRowCopyFormat else { return false } + return !coordinator.currentRowSelection().isEmpty + } + + private func copySelectedRows(as format: RowCopyFormat) { + guard let coordinator, coordinator.appliesRowCopyFormat else { return } + coordinator.copyRows(at: coordinator.currentRowSelection(), as: format) + } + + // The one column a cell selection covers, else the focused cell's, as the row menu takes the clicked one. + private func inClauseColumn() -> Int? { + if let coordinator, !coordinator.selectionController.isEmpty { + let columns = coordinator.dataColumnIndices(in: coordinator.selectionController.selection.affectedColumns) + if columns.count == 1 { return columns[0] } + } + return pasteAnchorCell()?.column + } +} diff --git a/apple/TablePro/Views/Results/KeyHandlingTableView.swift b/apple/TablePro/Views/Results/KeyHandlingTableView.swift index f9b7885840..8bc52f3445 100644 --- a/apple/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/apple/TablePro/Views/Results/KeyHandlingTableView.swift @@ -341,11 +341,12 @@ final class KeyHandlingTableView: NSTableView { coordinator?.copyGridSelection(controller.selection) return } - if let cell = focusedDataCell() { + // A row-number click seeds the cursor for the keyboard only: the user picked the whole row. + if !isCursorSeededByRowSelection, let cell = focusedDataCell() { coordinator?.copyCellValue(at: cell.row, columnIndex: cell.columnIndex) return } - coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) + coordinator?.copyRowsInCopyFormat(at: Set(selectedRowIndexes)) } @objc func copyRowsAsTSV(_ sender: Any?) { @@ -427,6 +428,9 @@ final class KeyHandlingTableView: NSTableView { if let command = Self.setValueCommand(for: item.action) { return canApplySetValue(command) } + if let canCopy = canPerformCopyCommand(item.action) { + return canCopy + } switch item.action { case #selector(delete(_:)), #selector(deleteBackward(_:)): let hasGridSelection = gridSelection?.isEmpty == false diff --git a/apple/TablePro/Views/Settings/Sections/DataGridSection.swift b/apple/TablePro/Views/Settings/Sections/DataGridSection.swift index 8e1c7e7b32..40561049ea 100644 --- a/apple/TablePro/Views/Settings/Sections/DataGridSection.swift +++ b/apple/TablePro/Views/Settings/Sections/DataGridSection.swift @@ -55,6 +55,18 @@ struct DataGridSection: View { Text("Default row sort is applied when a table first opens. Sort direction also sets which way the first click on a column header sorts.") } + Section { + Picker("Copy rows as:", selection: $settings.rowCopyFormat) { + ForEach(RowCopyFormat.allCases) { format in + Text(format.displayName).tag(format) + } + } + } header: { + Text("Copying") + } footer: { + Text("Copy uses this format for selected rows. A single cell still copies its value, and Edit > Copy Special offers every format.") + } + Section("Pagination") { Picker("Default page size:", selection: $settings.defaultPageSize) { Text("100 rows").tag(100) diff --git a/apple/TablePro/Views/Structure/StructureListGrid.swift b/apple/TablePro/Views/Structure/StructureListGrid.swift index e20c82921d..3f199c06ce 100644 --- a/apple/TablePro/Views/Structure/StructureListGrid.swift +++ b/apple/TablePro/Views/Structure/StructureListGrid.swift @@ -203,6 +203,7 @@ internal struct StructureListGrid: View { databaseType: databaseType, supportsColumnCommands: false, supportsValueFilter: false, + appliesRowCopyFormat: true, supportsColumnSort: sortState != nil, columnMinimumWidths: Self.minimumWidths(minimumWidths, columns: columns) ), diff --git a/apple/TablePro/Views/TableListing/TableListingTabView.swift b/apple/TablePro/Views/TableListing/TableListingTabView.swift index 0ab0fbc232..dfaf957086 100644 --- a/apple/TablePro/Views/TableListing/TableListingTabView.swift +++ b/apple/TablePro/Views/TableListing/TableListingTabView.swift @@ -86,7 +86,8 @@ internal struct TableListingTabView: View { databaseType: databaseType, showRowNumbers: true, supportsColumnCommands: false, - supportsValueFilter: false + supportsValueFilter: false, + appliesRowCopyFormat: true ), delegate: model.gridDelegate, selectedRowIndices: $model.selectedRows, diff --git a/apple/TableProTests/Helpers/RecordingClipboard.swift b/apple/TableProTests/Helpers/RecordingClipboard.swift index a35c7167a7..034d794186 100644 --- a/apple/TableProTests/Helpers/RecordingClipboard.swift +++ b/apple/TableProTests/Helpers/RecordingClipboard.swift @@ -33,6 +33,14 @@ final class RecordingClipboard: ClipboardProvider { replace(text: tsv, html: html, gridRows: gridRows) } + func writeText(_ text: String, gridRows: GridRowsClipboardPayload) { + replace(text: text, gridRows: gridRows) + } + + func writeCsv(_ csv: String, gridRows: GridRowsClipboardPayload) { + replace(text: csv, gridRows: gridRows) + } + func writeCells(_ cells: GridCellsClipboardPayload) { replace(text: cells.text, gridCells: cells) } diff --git a/apple/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift b/apple/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift index 7ef8c3d6c7..80d8a950f1 100644 --- a/apple/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift +++ b/apple/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift @@ -51,6 +51,21 @@ struct SettingsFieldDecodingTests { #expect(settings.showRowNumbers == false) } + @Test("Data Grid settings saved before the copy format copy rows as TSV, and an unknown format falls back alone") + func rowCopyFormatFallsBack() throws { + let legacy = try decode(DataGridSettings.self, #"{"showRowNumbers":false}"#) + #expect(legacy.rowCopyFormat == .rows) + + let unknown = try decode(DataGridSettings.self, #"{"rowCopyFormat":"xml","showRowNumbers":false}"#) + #expect(unknown.rowCopyFormat == .rows) + #expect(unknown.showRowNumbers == false) + + var chosen = DataGridSettings.default + chosen.rowCopyFormat = .csvWithHeaders + let decoded = try decode(DataGridSettings.self, String(decoding: try JSONEncoder().encode(chosen), as: UTF8.self)) + #expect(decoded.rowCopyFormat == .csvWithHeaders) + } + @Test("A bad MCP value falls back alone and keeps the secure default") func mcpKeepsOtherFields() throws { let settings = try decode( diff --git a/apple/TableProTests/Models/ShortcutUniquenessTests.swift b/apple/TableProTests/Models/ShortcutUniquenessTests.swift index 9150cabcd9..df4c9e81f9 100644 --- a/apple/TableProTests/Models/ShortcutUniquenessTests.swift +++ b/apple/TableProTests/Models/ShortcutUniquenessTests.swift @@ -50,10 +50,10 @@ struct ShortcutUniquenessTests { } // Counted, not listed: a default for any of these takes a combo already in use, so it has to be on purpose. - @Test("Twenty-two actions ship unbound") + @Test("Twenty-nine actions ship unbound") func unboundActionsAreCounted() { let unbound = ShortcutAction.allCases.filter { KeyboardSettings.defaultShortcuts[$0] == nil } - #expect(unbound.count == 22, "Unbound: \(unbound.map(\.rawValue).sorted())") + #expect(unbound.count == 29, "Unbound: \(unbound.map(\.rawValue).sorted())") for action in unbound { #expect(KeyboardSettings.default.shortcut(for: action) == nil, "\(action.rawValue)") } diff --git a/apple/TableProTests/Views/Results/RowCopyFormatTests.swift b/apple/TableProTests/Views/Results/RowCopyFormatTests.swift new file mode 100644 index 0000000000..7d3a8598b8 --- /dev/null +++ b/apple/TableProTests/Views/Results/RowCopyFormatTests.swift @@ -0,0 +1,436 @@ +// +// RowCopyFormatTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class RowCopyLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class RowCopyDelegateSpy: DataGridViewDelegate { + var copiedRows: Set? + + func dataGridCopyRows(_ indices: Set) { + copiedRows = indices + } +} + +@MainActor +private final class RowCopySelectionBox { + var value: Set = [] + + func binding() -> Binding> { + Binding(get: { self.value }, set: { self.value = $0 }) + } +} + +@MainActor +private struct RowCopyGrid { + let tableView: KeyHandlingTableView + let coordinator: TableViewCoordinator + let selection: RowCopySelectionBox + + init( + appliesRowCopyFormat: Bool = true, + delegate: (any DataGridViewDelegate)? = nil, + hasAuthoritativeSchema: Bool = false + ) { + let tableRows = TableRows.from( + queryRows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")], [.text("3"), .text("Carol")]], + columns: ["id", "name"], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], + hasAuthoritativeSchema: hasAuthoritativeSchema + ) + let box = RowCopySelectionBox() + selection = box + coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: box.binding(), + delegate: delegate, + layoutPersister: RowCopyLayoutPersister() + ) + coordinator.tableRowsProvider = { tableRows } + coordinator.appliesRowCopyFormat = appliesRowCopyFormat + + tableView = KeyHandlingTableView() + tableView.coordinator = coordinator + tableView.delegate = coordinator + tableView.dataSource = coordinator + tableView.allowsMultipleSelection = true + tableView.addTableColumn(DataGridView.makeRowNumberColumn()) + + coordinator.tableView = tableView + coordinator.rebuildColumnMetadataCache(from: tableRows) + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: tableRows.columnTypes, + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 100 } + ) + coordinator.selectionController.tableView = tableView + coordinator.selectionController.coordinator = coordinator + coordinator.invalidateColumnIndexCache() + coordinator.updateCache() + tableView.reloadData() + } + + // What a row-number click leaves behind: the rows, and a cursor seeded for the keyboard. + func selectRowsByNumber(_ rows: IndexSet) { + tableView.focusedRow = -1 + tableView.focusedColumn = -1 + tableView.isCursorSeededByRowSelection = true + tableView.selectRowIndexes(rows, byExtendingSelection: false) + coordinator.tableViewSelectionDidChange( + Notification(name: NSTableView.selectionDidChangeNotification, object: tableView) + ) + } + + func focusCell(row: Int, dataColumn: Int) { + tableView.isCursorSeededByRowSelection = false + tableView.focusedRow = row + tableView.focusedColumn = coordinator.tableColumnIndex(for: dataColumn) ?? -1 + } + + func selectCells(rows: ClosedRange, displayColumns: ClosedRange) { + let anchor = GridCoord(row: rows.lowerBound, displayColumn: displayColumns.lowerBound) + coordinator.selectionController.update( + .single(GridRect(rows: rows, columns: displayColumns), anchor: anchor, active: anchor) + ) + } + + func isEnabled(_ action: Selector) -> Bool { + tableView.validateUserInterfaceItem(NSMenuItem(title: "", action: action, keyEquivalent: "")) + } +} + +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct RowCopyFormatTests { + // MARK: - Copy + + @Test( + "Copy writes a header line and a CSV line per selected row when rows copy as CSV with Headers", + .appSettings { $0.dataGrid.rowCopyFormat = .csvWithHeaders } + ) + func copyUsesTheChosenFormat() throws { + let clipboard = try #require(RecordingClipboard.current) + let grid = RowCopyGrid() + grid.selectRowsByNumber(IndexSet(0...2)) + + grid.tableView.copy(nil) + + #expect(clipboard.text == "id,name\n1,Alice\n2,Bob\n3,Carol\n") + #expect(clipboard.gridRows?.rows.count == 3) + } + + @Test( + "One row picked by its number copies as a row, not as the cell the keyboard cursor was seeded on", + .appSettings { $0.dataGrid.rowCopyFormat = .csvWithHeaders } + ) + func rowPickedByNumberCopiesTheRow() throws { + let clipboard = try #require(RecordingClipboard.current) + let grid = RowCopyGrid() + grid.selectRowsByNumber(IndexSet(integer: 1)) + #expect(grid.tableView.focusedRow == 1) + + grid.tableView.copy(nil) + + #expect(clipboard.text == "id,name\n2,Bob\n") + } + + @Test( + "A cell the user put the cursor on still copies its value", + .appSettings { $0.dataGrid.rowCopyFormat = .csvWithHeaders } + ) + func focusedCellCopiesItsValue() throws { + let clipboard = try #require(RecordingClipboard.current) + let grid = RowCopyGrid() + grid.selectRowsByNumber(IndexSet(integer: 1)) + grid.focusCell(row: 1, dataColumn: 1) + + grid.tableView.copy(nil) + + #expect(clipboard.text == "Bob") + } + + @Test( + "A cell range still copies as cells", + .appSettings { $0.dataGrid.rowCopyFormat = .json } + ) + func cellRangeCopiesAsCells() throws { + let clipboard = try #require(RecordingClipboard.current) + let grid = RowCopyGrid() + grid.selectCells(rows: 0...1, displayColumns: 0...1) + + grid.tableView.copy(nil) + + #expect(clipboard.text == "1\tAlice\n2\tBob") + #expect(clipboard.gridCells != nil) + } + + @Test( + "The TSV default keeps the grid's own row copy", + .appSettings { $0.dataGrid.rowCopyFormat = .rows } + ) + func defaultFormatKeepsTheGridsCopy() { + let spy = RowCopyDelegateSpy() + let grid = RowCopyGrid(delegate: spy) + grid.selectRowsByNumber(IndexSet(0...1)) + + grid.tableView.copy(nil) + + #expect(spy.copiedRows == Set([0, 1])) + } + + @Test( + "A grid of definitions keeps its own row copy whatever the format", + .appSettings { $0.dataGrid.rowCopyFormat = .csvWithHeaders } + ) + func definitionsGridKeepsItsCopy() throws { + let clipboard = try #require(RecordingClipboard.current) + let spy = RowCopyDelegateSpy() + let grid = RowCopyGrid(appliesRowCopyFormat: false, delegate: spy) + grid.selectRowsByNumber(IndexSet(0...1)) + + grid.tableView.copy(nil) + + #expect(spy.copiedRows == Set([0, 1])) + #expect(clipboard.text == nil) + } + + @Test( + "The row menu's Copy on a row number follows the format", + .appSettings { $0.dataGrid.rowCopyFormat = .markdown } + ) + func rowMenuCopyFollowsTheFormat() throws { + let clipboard = try #require(RecordingClipboard.current) + let grid = RowCopyGrid() + grid.selectRowsByNumber(IndexSet(0...1)) + let rowView = DataGridRowView() + rowView.coordinator = grid.coordinator + rowView.rowIndex = 0 + let item = NSMenuItem(title: "Copy", action: nil, keyEquivalent: "") + item.representedObject = DataGridRowView.CopyContextTarget.row + + _ = rowView.perform(NSSelectorFromString("copyFromContextMenu:"), with: item) + + #expect(clipboard.text?.hasPrefix("| id | name |") == true) + #expect(clipboard.text?.contains("Bob") == true) + #expect(clipboard.text?.contains("Carol") != true) + } + + @Test("Rows a held result shows copy as TSV") + func heldRowsCopyAsTSV() throws { + let clipboard = try #require(RecordingClipboard.current) + let delegate = HeldRowsGridDelegate() + let grid = RowCopyGrid(delegate: delegate) + delegate.dataGridAttach(tableViewCoordinator: grid.coordinator) + + delegate.dataGridCopyRows([0, 2]) + + #expect(clipboard.text == "1\tAlice\n3\tCarol") + #expect(clipboard.html == nil) + #expect(clipboard.gridRows?.rows.count == 2) + } + + @Test("Rows copied as CSV paste into a data file by column name, not as one cell per line") + func dataFilePasteReadsTheCopiedRows() async throws { + let clipboard = try #require(RecordingClipboard.current) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RowCopyFormatTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("people.csv") + try Data("name,city\nAlice,Lima\n".utf8).write(to: url) + let undoManager = UndoManager() + let controller = DataFileController() + controller.undoManager = undoManager + controller.load(url: url, kind: try #require(DataFileKind.classify(url))) + await controller.waitForPendingWork() + clipboard.writeCsv( + "city,name\n\"Oslo, NO\",Bob\n", + gridRows: GridRowsClipboardPayload(columns: ["city", "name"], rows: [[.text("Oslo, NO"), .text("Bob")]]) + ) + + DataFileGridDelegate(controller: controller).dataGridPasteRows() + await controller.waitForPendingWork() + + #expect(controller.tableRows.rows.count == 2) + #expect(controller.tableRows.rows.last.map { Array($0.values) } == [.text("Bob"), .text("Oslo, NO")]) + } + + @Test("Rows from another source, or with a repeated column name, paste into a file in order") + func dataFilePasteFallsBackToOrder() { + let fromTable = GridRowsClipboardPayload(columns: ["id", "email"], rows: [[.text("7"), .null]]) + #expect(DataFilePasteParser.rows(from: fromTable, into: ["name", "city"]) == [["7", ""]]) + + let repeated = GridRowsClipboardPayload(columns: ["id", "id"], rows: [[.text("1"), .text("2")]]) + #expect(DataFilePasteParser.rows(from: repeated, into: ["id", "id (2)"]) == [["1", "2"]]) + + let hidden = GridRowsClipboardPayload(columns: ["city"], rows: [[.text("Lima")]]) + #expect(DataFilePasteParser.rows(from: hidden, into: ["name", "city"]) == [["", "Lima"]]) + } + + @Test("A formatted copy past the row cap stops there, in the text and the paste payload alike") + func formattedCopyStopsAtTheCap() throws { + let clipboard = try #require(RecordingClipboard.current) + let cap = RowOperationsManager.maxClipboardRows + let rowCount = cap + 2 + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: RowCopyLayoutPersister() + ) + let tableRows = TableRows.from( + queryRows: (0.. Data picks the format Copy uses for rows, and Edit > Copy Special adds Copy Cell Value and the other formats.