diff --git a/TablePro/Core/Services/Infrastructure/ClipboardService.swift b/TablePro/Core/Services/Infrastructure/ClipboardService.swift index 71994137a..66cf2e82d 100644 --- a/TablePro/Core/Services/Infrastructure/ClipboardService.swift +++ b/TablePro/Core/Services/Infrastructure/ClipboardService.swift @@ -12,12 +12,14 @@ import UniformTypeIdentifiers protocol ClipboardProvider { func readText() -> String? func writeText(_ text: String) - func writeTabular(tsv: String, html: String) + func writeRows(tsv: String, html: String?) var hasText: Bool { get } + var hasGridRows: Bool { get } } struct NSPasteboardClipboardProvider: ClipboardProvider { private static let tsvType = NSPasteboard.PasteboardType("public.utf8-tab-separated-values-text") + private static let gridRowsType = NSPasteboard.PasteboardType("com.TablePro.gridRows") func readText() -> String? { NSPasteboard.general.string(forType: .string) @@ -30,17 +32,24 @@ struct NSPasteboardClipboardProvider: ClipboardProvider { pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)) } - func writeTabular(tsv: String, html: String) { + func writeRows(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) + if let html { + pb.setString(html, forType: .html) + } + pb.setString("1", forType: Self.gridRowsType) } var hasText: Bool { NSPasteboard.general.string(forType: .string) != nil } + + var hasGridRows: Bool { + NSPasteboard.general.types?.contains(Self.gridRowsType) == true + } } @MainActor diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 4e8620f4b..dde885422 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -281,7 +281,7 @@ final class RowOperationsManager { result.append("\n(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)") } - ClipboardService.shared.writeText(result) + ClipboardService.shared.writeRows(tsv: result, html: nil) } func pasteRowsFromClipboard( diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 87cd7b5d0..3f073dba8 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -49,7 +49,7 @@ extension TableViewCoordinator { let tsv = tsvRows.joined(separator: "\n") let html = HtmlTableEncoder.encode(rows: htmlRows) - ClipboardService.shared.writeTabular(tsv: tsv, html: html) + ClipboardService.shared.writeRows(tsv: tsv, html: html) } func copyRowsWithHeaders(at indices: Set) { @@ -69,7 +69,7 @@ extension TableViewCoordinator { let tsv = tsvRows.joined(separator: "\n") let html = HtmlTableEncoder.encode(rows: htmlRows, headers: columns) - ClipboardService.shared.writeTabular(tsv: tsv, html: html) + ClipboardService.shared.writeRows(tsv: tsv, html: html) } @MainActor diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift index c209dfd97..e42b4ed25 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift @@ -8,6 +8,7 @@ import AppKit extension TableViewCoordinator { func pasteCellsFromClipboard(anchorRow: Int, anchorColumn: Int) -> Bool { guard isEditable else { return false } + if ClipboardService.shared.hasGridRows { return false } guard let text = ClipboardService.shared.readText(), !text.isEmpty else { return false } let grid = text.components(separatedBy: "\n") @@ -15,8 +16,13 @@ extension TableViewCoordinator { .map { $0.components(separatedBy: "\t") } guard !grid.isEmpty, grid[0].count > 1 || grid.count > 1 else { return false } + let dataColumnCount = tableRowsProvider().columns.count + if dataColumnCount > 0, grid.allSatisfy({ $0.count == dataColumnCount }) { + return false + } + let maxRow = min(anchorRow + grid.count, cachedRowCount) - let maxCol = min(anchorColumn + (grid.first?.count ?? 0), tableRowsProvider().columns.count) + let maxCol = min(anchorColumn + (grid.first?.count ?? 0), dataColumnCount) guard anchorRow < maxRow, anchorColumn < maxCol else { return false } let undoManager = tableView?.window?.undoManager diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index f3aa7ef33..89abda3d3 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -5,18 +5,22 @@ import Testing private final class MockClipboardProvider: ClipboardProvider { var lastWrittenText: String? var textToRead: String? + var lastWasGridRows = false func readText() -> String? { textToRead } func writeText(_ text: String) { lastWrittenText = text + lastWasGridRows = false } - func writeTabular(tsv: String, html: String) { + func writeRows(tsv: String, html: String?) { lastWrittenText = tsv + lastWasGridRows = true } var hasText: Bool { textToRead != nil } + var hasGridRows: Bool { lastWasGridRows } } @MainActor diff --git a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift new file mode 100644 index 000000000..3d48f0332 --- /dev/null +++ b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift @@ -0,0 +1,103 @@ +// +// CellPasteRoutingTests.swift +// TableProTests +// +// Locks the contract that pasteCellsFromClipboard defers to row paste +// when the clipboard carries the in-app gridRows tag or has a row-shaped +// TSV. Without these checks, Cmd+V on a focused cell after Cmd+C on a row +// silently overwrites the row's tail columns. +// + +import AppKit +import Foundation +import SwiftUI +import Testing +@testable import TablePro + +@MainActor +private final class StubClipboard: ClipboardProvider { + var text: String? + var hasGridRowsValue = false + + func readText() -> String? { text } + func writeText(_ text: String) { self.text = text; hasGridRowsValue = false } + func writeRows(tsv: String, html: String?) { self.text = tsv; hasGridRowsValue = true } + var hasText: Bool { text != nil } + var hasGridRows: Bool { hasGridRowsValue } +} + +@Suite("pasteCellsFromClipboard routing") +@MainActor +struct CellPasteRoutingTests { + private func makeCoordinator(columns: [String], rowCount: Int) -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil + ) + let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) + let rows = (0..