Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- 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".
- BigQuery: switching to another table now loads its data right away, instead of leaving the grid empty until you close and reopen the tab.
- 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)
- MongoDB: opening a collection no longer crashes when a document contains a NaN or infinite number. (#1418)
Expand Down
20 changes: 17 additions & 3 deletions TablePro/Core/Services/Infrastructure/ClipboardService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@
//

import AppKit
import TableProPluginKit
import UniformTypeIdentifiers

struct GridRowsClipboardPayload: Codable, Equatable {
let columns: [String]
let rows: [[PluginCellValue]]
}

protocol ClipboardProvider {
func readText() -> String?
func readGridRows() -> GridRowsClipboardPayload?
func writeText(_ text: String)
func writeCsv(_ csv: String)
func writeRows(tsv: String, html: String?)
func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload)
var hasText: Bool { get }
var hasGridRows: Bool { get }
}
Expand All @@ -24,6 +31,11 @@ struct NSPasteboardClipboardProvider: ClipboardProvider {
NSPasteboard.general.string(forType: .string)
}

func readGridRows() -> GridRowsClipboardPayload? {
guard let data = NSPasteboard.general.data(forType: Self.gridRowsType) else { return nil }
return try? JSONDecoder().decode(GridRowsClipboardPayload.self, from: data)
}

func writeText(_ text: String) {
let pb = NSPasteboard.general
pb.clearContents()
Expand All @@ -39,15 +51,17 @@ struct NSPasteboardClipboardProvider: ClipboardProvider {
pb.setString(csv, forType: Self.csvType)
}

func writeRows(tsv: String, html: String?) {
func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(tsv, forType: .string)
pb.setString(tsv, forType: Self.tsvType)
if let html {
pb.setString(html, forType: .html)
}
pb.setString("1", forType: Self.gridRowsType)
if let data = try? JSONEncoder().encode(gridRows) {
pb.setData(data, forType: Self.gridRowsType)
}
}

var hasText: Bool {
Expand Down
90 changes: 53 additions & 37 deletions TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ final class RowOperationsManager {
let estimatedRowLength = max(columns.count, 1) * 12
var result = ""
result.reserveCapacity(indicesToCopy.count * estimatedRowLength)
var structuredRows: [[PluginCellValue]] = []
structuredRows.reserveCapacity(indicesToCopy.count)

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

ClipboardService.shared.writeRows(tsv: result, html: nil)
let payload = GridRowsClipboardPayload(columns: columns, rows: structuredRows)
ClipboardService.shared.writeRows(tsv: result, html: nil, gridRows: payload)
}

func pasteRowsFromClipboard(
Expand All @@ -293,15 +297,20 @@ final class RowOperationsManager {
parser: RowDataParser? = nil
) -> PasteRowsResult {
let clipboardProvider = clipboard ?? ClipboardService.shared
guard let clipboardText = clipboardProvider.readText() else {
return PasteRowsResult(pastedRows: [], delta: .none)
}

let schema = TableSchema(
columns: columns,
primaryKeyColumns: primaryKeyColumns
)

if parser == nil, let payload = clipboardProvider.readGridRows() {
let parsedRows = Self.reconcileStructuredRows(payload, schema: schema)
return insertParsedRows(parsedRows, into: &tableRows)
}

guard let clipboardText = clipboardProvider.readText() else {
return PasteRowsResult(pastedRows: [], delta: .none)
}

let rowParser = parser ?? Self.detectParser(for: clipboardText)
let parseResult = rowParser.parse(clipboardText, schema: schema)

Expand All @@ -315,47 +324,54 @@ final class RowOperationsManager {
}
}

static func detectParser(for text: String) -> RowDataParser {
var tabLines = 0
var commaLines = 0
var nonEmptyLines = 0
var lineHasTab = false
var lineHasComma = false
var lineIsEmpty = true
private static func reconcileStructuredRows(
_ payload: GridRowsClipboardPayload,
schema: TableSchema
) -> [ParsedRow] {
let sourceForDestination = sourceColumnIndices(from: payload.columns, to: schema.columns)

for char in text {
if char.isNewline {
if !lineIsEmpty {
nonEmptyLines += 1
if lineHasTab { tabLines += 1 }
if lineHasComma { commaLines += 1 }
}
lineHasTab = false
lineHasComma = false
lineIsEmpty = true
} else {
if !char.isWhitespace { lineIsEmpty = false }
if char == "\t" { lineHasTab = true }
if char == "," { lineHasComma = true }
return payload.rows.enumerated().map { index, row in
var values: [PluginCellValue] = sourceForDestination.map { sourceIndex in
guard let sourceIndex, sourceIndex < row.count else { return .null }
return row[sourceIndex]
}

if let pkIndex = schema.primaryKeyIndex, pkIndex < values.count {
values[pkIndex] = .text("__DEFAULT__")
}

return ParsedRow(values: values, sourceLineNumber: index + 1)
}
if !lineIsEmpty {
nonEmptyLines += 1
if lineHasTab { tabLines += 1 }
if lineHasComma { commaLines += 1 }
}

private static func sourceColumnIndices(from source: [String], to destination: [String]) -> [Int?] {
var sourceIndexByName: [String: Int] = [:]
for (index, name) in source.enumerated() where sourceIndexByName[name] == nil {
sourceIndexByName[name] = index
}

guard nonEmptyLines > 0 else { return TSVRowParser() }
let byName = destination.map { sourceIndexByName[$0] }
guard byName.allSatisfy({ $0 == nil }) else { return byName }

let tabCount = tabLines
let commaCount = commaLines
return destination.indices.map { $0 < source.count ? $0 : nil }
}

static func detectParser(for text: String) -> RowDataParser {
var containsTab = false
var containsComma = false

for char in text {
if char == "\t" {
containsTab = true
break
}
if char == "," { containsComma = true }
}

if tabCount > commaCount {
if containsTab {
return TSVRowParser()
} else if commaCount > 0 {
return CSVRowParser()
}
return TSVRowParser()
return containsComma ? CSVRowParser() : TSVRowParser()
}

private func insertParsedRows(
Expand Down
17 changes: 13 additions & 4 deletions TablePro/Views/Results/DataGridView+RowActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,24 @@ extension TableViewCoordinator {
let tableRows = tableRowsProvider()
let projection = visibleColumnProjection
let columnTypes = projection.columnTypes(tableRows.columnTypes)
let columns = projection.columns(tableRows.columns)
var tsvRows: [String] = []
var htmlRows: [[String]] = []
var structuredRows: [[PluginCellValue]] = []

for index in sortedIndices {
guard let values = displayRow(at: index)?.values else { continue }
let formatted = formatRowValues(values: projection.values(Array(values)), columnTypes: columnTypes)
let projected = projection.values(Array(values))
let formatted = formatRowValues(values: projected, columnTypes: columnTypes)
tsvRows.append(formatted.joined(separator: "\t"))
htmlRows.append(formatted)
structuredRows.append(projected)
}

let tsv = tsvRows.joined(separator: "\n")
let html = HtmlTableEncoder.encode(rows: htmlRows)
ClipboardService.shared.writeRows(tsv: tsv, html: html)
let payload = GridRowsClipboardPayload(columns: columns, rows: structuredRows)
ClipboardService.shared.writeRows(tsv: tsv, html: html, gridRows: payload)
}

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

for index in sortedIndices {
guard let values = displayRow(at: index)?.values else { continue }
let formatted = formatRowValues(values: projection.values(Array(values)), columnTypes: columnTypes)
let projected = projection.values(Array(values))
let formatted = formatRowValues(values: projected, columnTypes: columnTypes)
tsvRows.append(formatted.joined(separator: "\t"))
htmlRows.append(formatted)
structuredRows.append(projected)
}

let tsv = tsvRows.joined(separator: "\n")
let html = HtmlTableEncoder.encode(rows: htmlRows, headers: columns)
ClipboardService.shared.writeRows(tsv: tsv, html: html)
let payload = GridRowsClipboardPayload(columns: columns, rows: structuredRows)
ClipboardService.shared.writeRows(tsv: tsv, html: html, gridRows: payload)
}

@MainActor
Expand Down
16 changes: 16 additions & 0 deletions TableProTests/Core/Services/ClipboardServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import AppKit
@testable import TablePro
import TableProPluginKit
import Testing
import UniformTypeIdentifiers

Expand Down Expand Up @@ -42,4 +43,19 @@ struct ClipboardServiceTests {
let pb = NSPasteboard.general
#expect(pb.string(forType: Self.tsvType) == nil)
}

@Test("writeRows round-trips structured grid rows through readGridRows losslessly")
func writeRowsRoundTripsStructuredRows() {
let provider = NSPasteboardClipboardProvider()
let payload = GridRowsClipboardPayload(
columns: ["id", "name", "blob"],
rows: [
[.text("1"), .text("Smith, John"), .null],
[.text("2"), .text("NULL"), .bytes(Data([0x01, 0x02, 0xFF]))],
]
)
provider.writeRows(tsv: "1\tSmith, John\tNULL", html: nil, gridRows: payload)

#expect(provider.readGridRows() == payload)
}
}
35 changes: 34 additions & 1 deletion TableProTests/Core/Services/RowOperationsManagerCopyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import Testing

private final class MockClipboardProvider: ClipboardProvider {
var lastWrittenText: String?
var lastWrittenGridRows: GridRowsClipboardPayload?
var textToRead: String?
var gridRowsToRead: GridRowsClipboardPayload?
var lastWasGridRows = false

func readText() -> String? { textToRead }

func readGridRows() -> GridRowsClipboardPayload? { gridRowsToRead }

func writeText(_ text: String) {
lastWrittenText = text
lastWasGridRows = false
Expand All @@ -20,8 +24,9 @@ private final class MockClipboardProvider: ClipboardProvider {
lastWasGridRows = false
}

func writeRows(tsv: String, html: String?) {
func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) {
lastWrittenText = tsv
lastWrittenGridRows = gridRows
lastWasGridRows = true
}

Expand Down Expand Up @@ -266,4 +271,32 @@ struct RowOperationsManagerCopyTests {

#expect(result == "1\tAlice\talice@test.com")
}

@Test("Copy writes structured grid rows with column names and raw cell values")
func copyWritesStructuredGridRows() {
let (manager, _) = makeManager()
let rows: [[String?]] = [["1", "Smith, John", nil]]
let clipboard = MockClipboardProvider()
ClipboardService.shared = clipboard
let tableRows = makeTableRows(rows: rows)

manager.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows)

#expect(clipboard.lastWrittenGridRows?.columns == ["id", "name", "email"])
#expect(clipboard.lastWrittenGridRows?.rows == [[.text("1"), .text("Smith, John"), .null]])
}

@Test("Structured grid rows follow visible column projection and visual order")
func structuredGridRowsFollowProjection() {
let (manager, _) = makeManager()
let rows: [[String?]] = [["1", "Alice", "alice@test.com"]]
let clipboard = MockClipboardProvider()
ClipboardService.shared = clipboard
let tableRows = makeTableRows(rows: rows)

manager.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows, visibleColumnIndices: [2, 0])

#expect(clipboard.lastWrittenGridRows?.columns == ["email", "id"])
#expect(clipboard.lastWrittenGridRows?.rows == [[.text("alice@test.com"), .text("1")]])
}
}
Loading
Loading