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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,6 @@ Libs/.downloaded
Libs/dylibs/
Libs/ios/
fix-1322-plugin-abi-and-registry-overhaul.diff

# Issue analysis blueprints (local only)
.analysis/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cancelling a pending connection no longer lets the abandoned attempt overwrite or drop a later successful connection to the same database (#1358)
- Cancelling a pending SSH connection now closes its tunnel instead of leaving the local forward port open (#1369)
- Importing connections from DBeaver now brings over the username (#1355)
- Copying rows now includes only the visible columns, in their current order, instead of every column (#1354)

## [0.43.1] - 2026-05-20

Expand Down
15 changes: 10 additions & 5 deletions TablePro/Core/Coordinators/RowEditingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ final class RowEditingCoordinator {
let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id)
parent.rowOperationsManager.copySelectedRowsToClipboard(
selectedIndices: indices,
tableRows: tableRows
tableRows: tableRows,
visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices()
)
}

Expand All @@ -179,21 +180,25 @@ final class RowEditingCoordinator {
parent.rowOperationsManager.copySelectedRowsToClipboard(
selectedIndices: indices,
tableRows: tableRows,
includeHeaders: true
includeHeaders: true,
visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices()
)
}

func copySelectedRowsAsJson(indices: Set<Int>) {
guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !indices.isEmpty else { return }
let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id)
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 Array(tableRows.rows[idx].values)
return projection.values(Array(tableRows.rows[idx].values))
}
guard !rows.isEmpty else { return }
let converter = JsonRowConverter(
columns: tableRows.columns,
columnTypes: tableRows.columnTypes
columns: projection.columns(tableRows.columns),
columnTypes: projection.columnTypes(tableRows.columnTypes)
)
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
}
Expand Down
15 changes: 9 additions & 6 deletions TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ final class RowOperationsManager {
func copySelectedRowsToClipboard(
selectedIndices: Set<Int>,
tableRows: TableRows,
includeHeaders: Bool = false
includeHeaders: Bool = false,
visibleColumnIndices: [Int]? = nil
) {
guard !selectedIndices.isEmpty else { return }

Expand All @@ -247,13 +248,14 @@ final class RowOperationsManager {

let indicesToCopy = isTruncated ? Array(sortedIndices.prefix(Self.maxClipboardRows)) : sortedIndices

let columnCount = tableRows.rows.first?.values.count ?? 1
let estimatedRowLength = columnCount * 12
let projection = VisibleColumnProjection(indices: visibleColumnIndices)
let columns = projection.columns(tableRows.columns)
let estimatedRowLength = max(columns.count, 1) * 12
var result = ""
result.reserveCapacity(indicesToCopy.count * estimatedRowLength)

if includeHeaders, !tableRows.columns.isEmpty {
for (colIdx, col) in tableRows.columns.enumerated() {
if includeHeaders, !columns.isEmpty {
for (colIdx, col) in columns.enumerated() {
if colIdx > 0 { result.append("\t") }
result.append(col)
}
Expand All @@ -262,7 +264,8 @@ final class RowOperationsManager {
for rowIndex in indicesToCopy {
guard rowIndex < tableRows.count else { continue }
if !result.isEmpty { result.append("\n") }
for (colIdx, cell) in tableRows.rows[rowIndex].values.enumerated() {
let cells = projection.values(Array(tableRows.rows[rowIndex].values))
for (colIdx, cell) in cells.enumerated() {
if colIdx > 0 { result.append("\t") }
switch cell {
case .null:
Expand Down
32 changes: 32 additions & 0 deletions TablePro/Models/Query/VisibleColumnProjection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// VisibleColumnProjection.swift
// TablePro
//

import TableProPluginKit

struct VisibleColumnProjection {
let indices: [Int]?

static let identity = VisibleColumnProjection(indices: nil)

func including(_ index: Int?) -> VisibleColumnProjection {
guard let index, let indices, !indices.contains(index) else { return self }
return VisibleColumnProjection(indices: indices + [index])
}

func columns(_ all: [String]) -> [String] {
guard let indices else { return all }
return indices.compactMap { all.indices.contains($0) ? all[$0] : nil }
}

func columnTypes(_ all: [ColumnType]) -> [ColumnType] {
guard let indices else { return all }
return indices.compactMap { all.indices.contains($0) ? all[$0] : nil }
}

func values(_ all: [PluginCellValue]) -> [PluginCellValue] {
guard let indices else { return all }
return indices.map { all.indices.contains($0) ? all[$0] : .null }
}
}
7 changes: 7 additions & 0 deletions TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
identitySchema.dataIndex(from: identifier)
}

func visibleColumnDataIndices() -> [Int]? {
guard let tableView else { return nil }
return tableView.tableColumns
.filter { !$0.isHidden && $0.identifier != ColumnIdentitySchema.rowNumberIdentifier }
.compactMap { dataColumnIndex(from: $0.identifier) }
}

func savedColumnLayout(binding: ColumnLayoutState) -> ColumnLayoutState? {
if tabType == .table,
let connectionId,
Expand Down
60 changes: 42 additions & 18 deletions TablePro/Views/Results/DataGridView+RowActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,14 @@ extension TableViewCoordinator {
func copyRows(at indices: Set<Int>) {
let sortedIndices = indices.sorted()
let tableRows = tableRowsProvider()
let columnTypes = tableRows.columnTypes
let projection = visibleColumnProjection
let columnTypes = projection.columnTypes(tableRows.columnTypes)
var tsvRows: [String] = []
var htmlRows: [[String]] = []

for index in sortedIndices {
guard let values = displayRow(at: index)?.values else { continue }
let formatted = formatRowValues(values: Array(values), columnTypes: columnTypes)
let formatted = formatRowValues(values: projection.values(Array(values)), columnTypes: columnTypes)
tsvRows.append(formatted.joined(separator: "\t"))
htmlRows.append(formatted)
}
Expand All @@ -59,14 +60,15 @@ extension TableViewCoordinator {
func copyRowsWithHeaders(at indices: Set<Int>) {
let sortedIndices = indices.sorted()
let tableRows = tableRowsProvider()
let columnTypes = tableRows.columnTypes
let columns = tableRows.columns
let projection = visibleColumnProjection
let columnTypes = projection.columnTypes(tableRows.columnTypes)
let columns = projection.columns(tableRows.columns)
var tsvRows: [String] = [columns.joined(separator: "\t")]
var htmlRows: [[String]] = []

for index in sortedIndices {
guard let values = displayRow(at: index)?.values else { continue }
let formatted = formatRowValues(values: Array(values), columnTypes: columnTypes)
let formatted = formatRowValues(values: projection.values(Array(values)), columnTypes: columnTypes)
tsvRows.append(formatted.joined(separator: "\t"))
htmlRows.append(formatted)
}
Expand Down Expand Up @@ -110,17 +112,18 @@ extension TableViewCoordinator {
func copyRowsAsInsert(at indices: Set<Int>) {
guard let tableName, let databaseType else { return }
let tableRows = tableRowsProvider()
let projection = visibleColumnProjection
let driver = resolveDriver()
do {
let converter = try SQLRowToStatementConverter(
tableName: tableName,
columns: tableRows.columns,
columns: projection.columns(tableRows.columns),
primaryKeyColumn: primaryKeyColumn,
databaseType: databaseType,
quoteIdentifier: driver?.quoteIdentifier,
escapeStringLiteral: driver?.escapeStringLiteral
)
let typedRows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } }
let typedRows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
guard !typedRows.isEmpty else { return }
ClipboardService.shared.writeText(converter.generateInserts(rows: typedRows))
} catch {
Expand All @@ -131,17 +134,19 @@ extension TableViewCoordinator {
func copyRowsAsUpdate(at indices: Set<Int>) {
guard let tableName, let databaseType else { return }
let tableRows = tableRowsProvider()
let pkIndex = primaryKeyColumn.flatMap { tableRows.columns.firstIndex(of: $0) }
let projection = visibleColumnProjection.including(pkIndex)
let driver = resolveDriver()
do {
let converter = try SQLRowToStatementConverter(
tableName: tableName,
columns: tableRows.columns,
columns: projection.columns(tableRows.columns),
primaryKeyColumn: primaryKeyColumn,
databaseType: databaseType,
quoteIdentifier: driver?.quoteIdentifier,
escapeStringLiteral: driver?.escapeStringLiteral
)
let typedRows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } }
let typedRows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
guard !typedRows.isEmpty else { return }
ClipboardService.shared.writeText(converter.generateUpdates(rows: typedRows))
} catch {
Expand All @@ -150,27 +155,38 @@ extension TableViewCoordinator {
}

func copyRowsAsJson(at indices: Set<Int>) {
let rows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } }
let projection = visibleColumnProjection
let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
guard !rows.isEmpty else { return }
let tableRows = tableRowsProvider()
let columnTypes = tableRows.columnTypes
let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: columnTypes)
let converter = JsonRowConverter(
columns: projection.columns(tableRows.columns),
columnTypes: projection.columnTypes(tableRows.columnTypes)
)
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
}

func copyRowsAsCsv(at indices: Set<Int>, includeHeaders: Bool) {
let rows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } }
let projection = visibleColumnProjection
let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
guard !rows.isEmpty else { return }
let tableRows = tableRowsProvider()
let converter = CsvRowConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes)
let converter = CsvRowConverter(
columns: projection.columns(tableRows.columns),
columnTypes: projection.columnTypes(tableRows.columnTypes)
)
ClipboardService.shared.writeCsv(converter.generateCsv(rows: rows, includeHeaders: includeHeaders))
}

func copyRowsAsMarkdown(at indices: Set<Int>) {
let rows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } }
let projection = visibleColumnProjection
let rows = indices.sorted().compactMap { displayRow(at: $0).map { projection.values(Array($0.values)) } }
guard !rows.isEmpty else { return }
let tableRows = tableRowsProvider()
let converter = MarkdownTableConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes)
let converter = MarkdownTableConverter(
columns: projection.columns(tableRows.columns),
columnTypes: projection.columnTypes(tableRows.columnTypes)
)
ClipboardService.shared.writeText(converter.generateMarkdown(rows: rows))
}

Expand Down Expand Up @@ -225,6 +241,10 @@ extension TableViewCoordinator {
}
}

private var visibleColumnProjection: VisibleColumnProjection {
VisibleColumnProjection(indices: visibleColumnDataIndices())
}

private func resolveDriver() -> (any DatabaseDriver)? {
guard let connectionId else { return nil }
return DatabaseManager.shared.driver(for: connectionId)
Expand All @@ -241,10 +261,14 @@ extension TableViewCoordinator {

if let values = displayRow(at: row)?.values {
let tableRows = tableRowsProvider()
let formatted = formatRowValues(values: Array(values), columnTypes: tableRows.columnTypes)
let projection = visibleColumnProjection
let formatted = formatRowValues(
values: projection.values(Array(values)),
columnTypes: projection.columnTypes(tableRows.columnTypes)
)
item.setString(formatted.joined(separator: "\t"), forType: .string)
item.setString(
HtmlTableEncoder.encode(rows: [formatted], headers: tableRows.columns),
HtmlTableEncoder.encode(rows: [formatted], headers: projection.columns(tableRows.columns)),
forType: .html
)
}
Expand Down
65 changes: 62 additions & 3 deletions TableProTests/Core/Services/RowOperationsManagerCopyTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation
import TableProPluginKit
@testable import TablePro
import TableProPluginKit
import Testing

private final class MockClipboardProvider: ClipboardProvider {
Expand Down Expand Up @@ -57,15 +57,17 @@ struct RowOperationsManagerCopyTests {
indices: Set<Int>,
rows: [[String?]],
columns: [String]? = nil,
includeHeaders: Bool = false
includeHeaders: Bool = false,
visibleColumnIndices: [Int]? = nil
) -> String? {
let clipboard = MockClipboardProvider()
ClipboardService.shared = clipboard
let tableRows = makeTableRows(rows: rows, columns: columns ?? Self.defaultColumns)
manager.copySelectedRowsToClipboard(
selectedIndices: indices,
tableRows: tableRows,
includeHeaders: includeHeaders
includeHeaders: includeHeaders,
visibleColumnIndices: visibleColumnIndices
)
return clipboard.lastWrittenText
}
Expand Down Expand Up @@ -207,4 +209,61 @@ struct RowOperationsManagerCopyTests {

#expect(result == "NULL\tNULL\tNULL")
}

@Test("Hidden columns are excluded from copied values")
func hiddenColumnsExcluded() {
let (manager, _) = makeManager()
let rows: [[String?]] = [["1", "Alice", "alice@test.com"]]

let result = copyAndCapture(manager: manager, indices: [0], rows: rows, visibleColumnIndices: [0, 2])

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

@Test("Hidden columns are excluded from headers too")
func hiddenColumnsExcludedFromHeaders() {
let (manager, _) = makeManager()
let rows: [[String?]] = [["1", "Alice", "alice@test.com"]]

let result = copyAndCapture(
manager: manager,
indices: [0],
rows: rows,
includeHeaders: true,
visibleColumnIndices: [0, 2]
)

let lines = result?.components(separatedBy: "\n") ?? []
#expect(lines.count == 2)
#expect(lines[0] == "id\temail")
#expect(lines[1] == "1\talice@test.com")
}

@Test("Copy follows visual column order")
func copyFollowsVisualOrder() {
let (manager, _) = makeManager()
let rows: [[String?]] = [["1", "Alice", "alice@test.com"]]

let result = copyAndCapture(
manager: manager,
indices: [0],
rows: rows,
includeHeaders: true,
visibleColumnIndices: [2, 0, 1]
)

let lines = result?.components(separatedBy: "\n") ?? []
#expect(lines[0] == "email\tid\tname")
#expect(lines[1] == "alice@test.com\t1\tAlice")
}

@Test("Nil visible indices copies every column unchanged")
func nilIndicesCopiesAllColumns() {
let (manager, _) = makeManager()
let rows: [[String?]] = [["1", "Alice", "alice@test.com"]]

let result = copyAndCapture(manager: manager, indices: [0], rows: rows, visibleColumnIndices: nil)

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