Skip to content

Commit 57fc132

Browse files
committed
fix(datagrid): keep pasted rows aligned when a cell value contains a comma
1 parent 340cbf5 commit 57fc132

8 files changed

Lines changed: 332 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222

2323
### Fixed
2424

25+
- Pasting copied rows no longer misplaces values when a cell contains a comma (such as a user agent string); each value stays in its own column, and a real NULL is kept distinct from the literal text "NULL".
2526
- BigQuery: switching to another table now loads its data right away, instead of leaving the grid empty until you close and reopen the tab.
2627
- Custom and OpenAI-compatible AI providers now work when the base URL already ends in `/v1`, instead of building a doubled `/v1/v1/` path that failed. (#1400)
2728
- MongoDB: opening a collection no longer crashes when a document contains a NaN or infinite number. (#1418)

TablePro/Core/Services/Infrastructure/ClipboardService.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44
//
55

66
import AppKit
7+
import TableProPluginKit
78
import UniformTypeIdentifiers
89

10+
struct GridRowsClipboardPayload: Codable, Equatable {
11+
let columns: [String]
12+
let rows: [[PluginCellValue]]
13+
}
14+
915
protocol ClipboardProvider {
1016
func readText() -> String?
17+
func readGridRows() -> GridRowsClipboardPayload?
1118
func writeText(_ text: String)
1219
func writeCsv(_ csv: String)
13-
func writeRows(tsv: String, html: String?)
20+
func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload)
1421
var hasText: Bool { get }
1522
var hasGridRows: Bool { get }
1623
}
@@ -24,6 +31,11 @@ struct NSPasteboardClipboardProvider: ClipboardProvider {
2431
NSPasteboard.general.string(forType: .string)
2532
}
2633

34+
func readGridRows() -> GridRowsClipboardPayload? {
35+
guard let data = NSPasteboard.general.data(forType: Self.gridRowsType) else { return nil }
36+
return try? JSONDecoder().decode(GridRowsClipboardPayload.self, from: data)
37+
}
38+
2739
func writeText(_ text: String) {
2840
let pb = NSPasteboard.general
2941
pb.clearContents()
@@ -39,15 +51,17 @@ struct NSPasteboardClipboardProvider: ClipboardProvider {
3951
pb.setString(csv, forType: Self.csvType)
4052
}
4153

42-
func writeRows(tsv: String, html: String?) {
54+
func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) {
4355
let pb = NSPasteboard.general
4456
pb.clearContents()
4557
pb.setString(tsv, forType: .string)
4658
pb.setString(tsv, forType: Self.tsvType)
4759
if let html {
4860
pb.setString(html, forType: .html)
4961
}
50-
pb.setString("1", forType: Self.gridRowsType)
62+
if let data = try? JSONEncoder().encode(gridRows) {
63+
pb.setData(data, forType: Self.gridRowsType)
64+
}
5165
}
5266

5367
var hasText: Bool {

TablePro/Core/Services/Query/RowOperationsManager.swift

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ final class RowOperationsManager {
253253
let estimatedRowLength = max(columns.count, 1) * 12
254254
var result = ""
255255
result.reserveCapacity(indicesToCopy.count * estimatedRowLength)
256+
var structuredRows: [[PluginCellValue]] = []
257+
structuredRows.reserveCapacity(indicesToCopy.count)
256258

257259
if includeHeaders, !columns.isEmpty {
258260
for (colIdx, col) in columns.enumerated() {
@@ -265,6 +267,7 @@ final class RowOperationsManager {
265267
guard rowIndex < tableRows.count else { continue }
266268
if !result.isEmpty { result.append("\n") }
267269
let cells = projection.values(Array(tableRows.rows[rowIndex].values))
270+
structuredRows.append(cells)
268271
for (colIdx, cell) in cells.enumerated() {
269272
if colIdx > 0 { result.append("\t") }
270273
switch cell {
@@ -282,7 +285,8 @@ final class RowOperationsManager {
282285
result.append("\n(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)")
283286
}
284287

285-
ClipboardService.shared.writeRows(tsv: result, html: nil)
288+
let payload = GridRowsClipboardPayload(columns: columns, rows: structuredRows)
289+
ClipboardService.shared.writeRows(tsv: result, html: nil, gridRows: payload)
286290
}
287291

288292
func pasteRowsFromClipboard(
@@ -293,15 +297,20 @@ final class RowOperationsManager {
293297
parser: RowDataParser? = nil
294298
) -> PasteRowsResult {
295299
let clipboardProvider = clipboard ?? ClipboardService.shared
296-
guard let clipboardText = clipboardProvider.readText() else {
297-
return PasteRowsResult(pastedRows: [], delta: .none)
298-
}
299-
300300
let schema = TableSchema(
301301
columns: columns,
302302
primaryKeyColumns: primaryKeyColumns
303303
)
304304

305+
if parser == nil, let payload = clipboardProvider.readGridRows() {
306+
let parsedRows = Self.reconcileStructuredRows(payload, schema: schema)
307+
return insertParsedRows(parsedRows, into: &tableRows)
308+
}
309+
310+
guard let clipboardText = clipboardProvider.readText() else {
311+
return PasteRowsResult(pastedRows: [], delta: .none)
312+
}
313+
305314
let rowParser = parser ?? Self.detectParser(for: clipboardText)
306315
let parseResult = rowParser.parse(clipboardText, schema: schema)
307316

@@ -315,47 +324,54 @@ final class RowOperationsManager {
315324
}
316325
}
317326

318-
static func detectParser(for text: String) -> RowDataParser {
319-
var tabLines = 0
320-
var commaLines = 0
321-
var nonEmptyLines = 0
322-
var lineHasTab = false
323-
var lineHasComma = false
324-
var lineIsEmpty = true
327+
private static func reconcileStructuredRows(
328+
_ payload: GridRowsClipboardPayload,
329+
schema: TableSchema
330+
) -> [ParsedRow] {
331+
let sourceForDestination = sourceColumnIndices(from: payload.columns, to: schema.columns)
325332

326-
for char in text {
327-
if char.isNewline {
328-
if !lineIsEmpty {
329-
nonEmptyLines += 1
330-
if lineHasTab { tabLines += 1 }
331-
if lineHasComma { commaLines += 1 }
332-
}
333-
lineHasTab = false
334-
lineHasComma = false
335-
lineIsEmpty = true
336-
} else {
337-
if !char.isWhitespace { lineIsEmpty = false }
338-
if char == "\t" { lineHasTab = true }
339-
if char == "," { lineHasComma = true }
333+
return payload.rows.enumerated().map { index, row in
334+
var values: [PluginCellValue] = sourceForDestination.map { sourceIndex in
335+
guard let sourceIndex, sourceIndex < row.count else { return .null }
336+
return row[sourceIndex]
337+
}
338+
339+
if let pkIndex = schema.primaryKeyIndex, pkIndex < values.count {
340+
values[pkIndex] = .text("__DEFAULT__")
340341
}
342+
343+
return ParsedRow(values: values, sourceLineNumber: index + 1)
341344
}
342-
if !lineIsEmpty {
343-
nonEmptyLines += 1
344-
if lineHasTab { tabLines += 1 }
345-
if lineHasComma { commaLines += 1 }
345+
}
346+
347+
private static func sourceColumnIndices(from source: [String], to destination: [String]) -> [Int?] {
348+
var sourceIndexByName: [String: Int] = [:]
349+
for (index, name) in source.enumerated() where sourceIndexByName[name] == nil {
350+
sourceIndexByName[name] = index
346351
}
347352

348-
guard nonEmptyLines > 0 else { return TSVRowParser() }
353+
let byName = destination.map { sourceIndexByName[$0] }
354+
guard byName.allSatisfy({ $0 == nil }) else { return byName }
349355

350-
let tabCount = tabLines
351-
let commaCount = commaLines
356+
return destination.indices.map { $0 < source.count ? $0 : nil }
357+
}
358+
359+
static func detectParser(for text: String) -> RowDataParser {
360+
var containsTab = false
361+
var containsComma = false
362+
363+
for char in text {
364+
if char == "\t" {
365+
containsTab = true
366+
break
367+
}
368+
if char == "," { containsComma = true }
369+
}
352370

353-
if tabCount > commaCount {
371+
if containsTab {
354372
return TSVRowParser()
355-
} else if commaCount > 0 {
356-
return CSVRowParser()
357373
}
358-
return TSVRowParser()
374+
return containsComma ? CSVRowParser() : TSVRowParser()
359375
}
360376

361377
private func insertParsedRows(

TablePro/Views/Results/DataGridView+RowActions.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,24 @@ extension TableViewCoordinator {
4242
let tableRows = tableRowsProvider()
4343
let projection = visibleColumnProjection
4444
let columnTypes = projection.columnTypes(tableRows.columnTypes)
45+
let columns = projection.columns(tableRows.columns)
4546
var tsvRows: [String] = []
4647
var htmlRows: [[String]] = []
48+
var structuredRows: [[PluginCellValue]] = []
4749

4850
for index in sortedIndices {
4951
guard let values = displayRow(at: index)?.values else { continue }
50-
let formatted = formatRowValues(values: projection.values(Array(values)), columnTypes: columnTypes)
52+
let projected = projection.values(Array(values))
53+
let formatted = formatRowValues(values: projected, columnTypes: columnTypes)
5154
tsvRows.append(formatted.joined(separator: "\t"))
5255
htmlRows.append(formatted)
56+
structuredRows.append(projected)
5357
}
5458

5559
let tsv = tsvRows.joined(separator: "\n")
5660
let html = HtmlTableEncoder.encode(rows: htmlRows)
57-
ClipboardService.shared.writeRows(tsv: tsv, html: html)
61+
let payload = GridRowsClipboardPayload(columns: columns, rows: structuredRows)
62+
ClipboardService.shared.writeRows(tsv: tsv, html: html, gridRows: payload)
5863
}
5964

6065
func copyRowsWithHeaders(at indices: Set<Int>) {
@@ -65,17 +70,21 @@ extension TableViewCoordinator {
6570
let columns = projection.columns(tableRows.columns)
6671
var tsvRows: [String] = [columns.joined(separator: "\t")]
6772
var htmlRows: [[String]] = []
73+
var structuredRows: [[PluginCellValue]] = []
6874

6975
for index in sortedIndices {
7076
guard let values = displayRow(at: index)?.values else { continue }
71-
let formatted = formatRowValues(values: projection.values(Array(values)), columnTypes: columnTypes)
77+
let projected = projection.values(Array(values))
78+
let formatted = formatRowValues(values: projected, columnTypes: columnTypes)
7279
tsvRows.append(formatted.joined(separator: "\t"))
7380
htmlRows.append(formatted)
81+
structuredRows.append(projected)
7482
}
7583

7684
let tsv = tsvRows.joined(separator: "\n")
7785
let html = HtmlTableEncoder.encode(rows: htmlRows, headers: columns)
78-
ClipboardService.shared.writeRows(tsv: tsv, html: html)
86+
let payload = GridRowsClipboardPayload(columns: columns, rows: structuredRows)
87+
ClipboardService.shared.writeRows(tsv: tsv, html: html, gridRows: payload)
7988
}
8089

8190
@MainActor

TableProTests/Core/Services/ClipboardServiceTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import AppKit
77
@testable import TablePro
8+
import TableProPluginKit
89
import Testing
910
import UniformTypeIdentifiers
1011

@@ -42,4 +43,19 @@ struct ClipboardServiceTests {
4243
let pb = NSPasteboard.general
4344
#expect(pb.string(forType: Self.tsvType) == nil)
4445
}
46+
47+
@Test("writeRows round-trips structured grid rows through readGridRows losslessly")
48+
func writeRowsRoundTripsStructuredRows() {
49+
let provider = NSPasteboardClipboardProvider()
50+
let payload = GridRowsClipboardPayload(
51+
columns: ["id", "name", "blob"],
52+
rows: [
53+
[.text("1"), .text("Smith, John"), .null],
54+
[.text("2"), .text("NULL"), .bytes(Data([0x01, 0x02, 0xFF]))],
55+
]
56+
)
57+
provider.writeRows(tsv: "1\tSmith, John\tNULL", html: nil, gridRows: payload)
58+
59+
#expect(provider.readGridRows() == payload)
60+
}
4561
}

TableProTests/Core/Services/RowOperationsManagerCopyTests.swift

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import Testing
55

66
private final class MockClipboardProvider: ClipboardProvider {
77
var lastWrittenText: String?
8+
var lastWrittenGridRows: GridRowsClipboardPayload?
89
var textToRead: String?
10+
var gridRowsToRead: GridRowsClipboardPayload?
911
var lastWasGridRows = false
1012

1113
func readText() -> String? { textToRead }
1214

15+
func readGridRows() -> GridRowsClipboardPayload? { gridRowsToRead }
16+
1317
func writeText(_ text: String) {
1418
lastWrittenText = text
1519
lastWasGridRows = false
@@ -20,8 +24,9 @@ private final class MockClipboardProvider: ClipboardProvider {
2024
lastWasGridRows = false
2125
}
2226

23-
func writeRows(tsv: String, html: String?) {
27+
func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) {
2428
lastWrittenText = tsv
29+
lastWrittenGridRows = gridRows
2530
lastWasGridRows = true
2631
}
2732

@@ -266,4 +271,32 @@ struct RowOperationsManagerCopyTests {
266271

267272
#expect(result == "1\tAlice\talice@test.com")
268273
}
274+
275+
@Test("Copy writes structured grid rows with column names and raw cell values")
276+
func copyWritesStructuredGridRows() {
277+
let (manager, _) = makeManager()
278+
let rows: [[String?]] = [["1", "Smith, John", nil]]
279+
let clipboard = MockClipboardProvider()
280+
ClipboardService.shared = clipboard
281+
let tableRows = makeTableRows(rows: rows)
282+
283+
manager.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows)
284+
285+
#expect(clipboard.lastWrittenGridRows?.columns == ["id", "name", "email"])
286+
#expect(clipboard.lastWrittenGridRows?.rows == [[.text("1"), .text("Smith, John"), .null]])
287+
}
288+
289+
@Test("Structured grid rows follow visible column projection and visual order")
290+
func structuredGridRowsFollowProjection() {
291+
let (manager, _) = makeManager()
292+
let rows: [[String?]] = [["1", "Alice", "alice@test.com"]]
293+
let clipboard = MockClipboardProvider()
294+
ClipboardService.shared = clipboard
295+
let tableRows = makeTableRows(rows: rows)
296+
297+
manager.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows, visibleColumnIndices: [2, 0])
298+
299+
#expect(clipboard.lastWrittenGridRows?.columns == ["email", "id"])
300+
#expect(clipboard.lastWrittenGridRows?.rows == [[.text("alice@test.com"), .text("1")]])
301+
}
269302
}

0 commit comments

Comments
 (0)