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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Fixed Copy as, the context menu's Delete, and the Edit menu's copy and delete commands acting on only the active row instead of every row in a cell-range or column selection in the data grid.
- Fixed the Edit menu's Copy as JSON copying the wrong rows when the grid was sorted or filtered.
- Fixed tables picked in the sidebar showing up unchecked and getting silently skipped when exporting to SQL or MQL. The export sheet now checks them and applies the format's default per-table options, so Export works right away.
- Fixed ClickHouse queries showing only a success message with no result table. TablePro guessed whether a query returns data from its first word, which failed for queries starting with a comment or with SELECT on its own line. It now reads what the server actually returned, so any query that produces a result set shows the grid, including empty results and queries with their own FORMAT clause. (#1886)
- Fixed ClickHouse INSERT statements always reporting 0 rows affected. (#1886)
Expand Down
7 changes: 4 additions & 3 deletions TablePro/Core/Coordinators/RowEditingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,12 +280,13 @@ final class RowEditingCoordinator {
func copySelectedRowsAsJson(indices: Set<Int>) {
guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !indices.isEmpty else { return }
let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id)
let displayIDs = parent.activeGridDisplayIDs
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 projection.values(Array(tableRows.rows[idx].values))
let rows = indices.sorted().compactMap { displayIndex -> [PluginCellValue]? in
DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows)
.map { projection.values(Array($0.values)) }
}
guard !rows.isEmpty else { return }
let converter = JsonRowConverter(
Expand Down
17 changes: 9 additions & 8 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ final class MainContentCommandActions {
}
}

private func resolvedRowSelection() -> Set<Int> {
coordinator?.dataTabDelegate?.tableViewCoordinator?.currentRowSelection() ?? selectionState.indices
}

func deleteSelectedRows(rowIndices: Set<Int>? = nil) {
let fromDataGrid = rowIndices != nil

Expand All @@ -184,7 +188,7 @@ final class MainContentCommandActions {
return
}

let indices = rowIndices ?? selectionState.indices
let indices = rowIndices ?? resolvedRowSelection()
if !indices.isEmpty {
coordinator?.deleteSelectedRows(indices: indices)
} else if !fromDataGrid, !selectedTables.wrappedValue.isEmpty {
Expand Down Expand Up @@ -217,19 +221,16 @@ final class MainContentCommandActions {
if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure {
coordinator?.structureActions?.copyRows?()
} else {
let indices = selectionState.indices
coordinator?.copySelectedRowsToClipboard(indices: indices)
coordinator?.copySelectedRowsToClipboard(indices: resolvedRowSelection())
}
}

func copySelectedRowsWithHeaders() {
let indices = selectionState.indices
coordinator?.copySelectedRowsWithHeaders(indices: indices)
coordinator?.copySelectedRowsWithHeaders(indices: resolvedRowSelection())
}

func copySelectedRowsAsJson() {
let indices = selectionState.indices
coordinator?.copySelectedRowsAsJson(indices: indices)
coordinator?.copySelectedRowsAsJson(indices: resolvedRowSelection())
}

func pasteRows() {
Expand Down Expand Up @@ -280,7 +281,7 @@ final class MainContentCommandActions {
}

var hasRowSelection: Bool {
!selectionState.indices.isEmpty
!resolvedRowSelection().isEmpty
}

var hasTableSelection: Bool {
Expand Down
33 changes: 14 additions & 19 deletions TablePro/Views/Results/DataGridRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,12 +386,8 @@ class DataGridRowView: NSTableRowView {
}

@objc private func deleteRow() {
let indices: Set<Int> = if let selected = coordinator?.selectedRowIndices, !selected.isEmpty {
selected
} else {
[rowIndex]
}
coordinator?.delegate?.dataGridDeleteRows(indices)
guard let coordinator else { return }
coordinator.delegate?.dataGridDeleteRows(coordinator.currentRowSelection(fallbackRow: rowIndex))
}

@objc private func duplicateRow() {
Expand All @@ -402,18 +398,14 @@ class DataGridRowView: NSTableRowView {
coordinator?.undoDeleteRow(at: rowIndex)
}

private func selectedOrCurrentIndices(in coordinator: TableViewCoordinator) -> Set<Int> {
coordinator.selectedRowIndices.isEmpty ? [rowIndex] : coordinator.selectedRowIndices
}

@objc private func copySelectedOrCurrentRowWithHeaders() {
guard let coordinator else { return }
coordinator.copyRowsWithHeaders(at: selectedOrCurrentIndices(in: coordinator))
coordinator.copyRowsWithHeaders(at: coordinator.currentRowSelection(fallbackRow: rowIndex))
}

@objc private func copySelectedOrCurrentRow() {
guard let coordinator else { return }
coordinator.delegate?.dataGridCopyRows(selectedOrCurrentIndices(in: coordinator))
coordinator.delegate?.dataGridCopyRows(coordinator.currentRowSelection(fallbackRow: rowIndex))
}

@objc private func copyFromContextMenu(_ sender: NSMenuItem) {
Expand Down Expand Up @@ -486,12 +478,12 @@ class DataGridRowView: NSTableRowView {

@objc private func copyAsInsert() {
guard let coordinator else { return }
coordinator.copyRowsAsInsert(at: selectedOrCurrentIndices(in: coordinator))
coordinator.copyRowsAsInsert(at: coordinator.currentRowSelection(fallbackRow: rowIndex))
}

@objc private func copyAsUpdate() {
guard let coordinator else { return }
coordinator.copyRowsAsUpdate(at: selectedOrCurrentIndices(in: coordinator))
coordinator.copyRowsAsUpdate(at: coordinator.currentRowSelection(fallbackRow: rowIndex))
}

@objc private func exportResults() {
Expand All @@ -504,27 +496,30 @@ class DataGridRowView: NSTableRowView {

@objc private func copyAsJson() {
guard let coordinator else { return }
coordinator.copyRowsAsJson(at: selectedOrCurrentIndices(in: coordinator))
coordinator.copyRowsAsJson(at: coordinator.currentRowSelection(fallbackRow: rowIndex))
}

@objc private func copyAsCsv() {
guard let coordinator else { return }
coordinator.copyRowsAsCsv(at: selectedOrCurrentIndices(in: coordinator), includeHeaders: false)
coordinator.copyRowsAsCsv(at: coordinator.currentRowSelection(fallbackRow: rowIndex), includeHeaders: false)
}

@objc private func copyAsCsvWithHeaders() {
guard let coordinator else { return }
coordinator.copyRowsAsCsv(at: selectedOrCurrentIndices(in: coordinator), includeHeaders: true)
coordinator.copyRowsAsCsv(at: coordinator.currentRowSelection(fallbackRow: rowIndex), includeHeaders: true)
}

@objc private func copyAsMarkdown() {
guard let coordinator else { return }
coordinator.copyRowsAsMarkdown(at: selectedOrCurrentIndices(in: coordinator))
coordinator.copyRowsAsMarkdown(at: coordinator.currentRowSelection(fallbackRow: rowIndex))
}

@objc private func copyAsInClause(_ sender: NSMenuItem) {
guard let coordinator, let columnIndex = sender.representedObject as? Int else { return }
coordinator.copyRowsAsInClause(at: selectedOrCurrentIndices(in: coordinator), columnIndex: columnIndex)
coordinator.copyRowsAsInClause(
at: coordinator.currentRowSelection(fallbackRow: rowIndex),
columnIndex: columnIndex
)
}

@objc private func previewForeignKey(_ sender: NSMenuItem) {
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Views/Results/Extensions/DataGridView+Selection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ extension TableViewCoordinator {
}
}

func currentRowSelection(fallbackRow: Int? = nil) -> Set<Int> {
if !selectionController.isEmpty {
return Set(selectionController.selection.affectedRows)
}
if !selectedRowIndices.isEmpty {
return selectedRowIndices
}
if let fallbackRow, fallbackRow >= 0 {
return [fallbackRow]
}
return []
}

func tableViewSelectionDidChange(_ notification: Notification) {
guard let tableView = notification.object as? NSTableView else { return }

Expand Down
17 changes: 8 additions & 9 deletions TablePro/Views/Results/KeyHandlingTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,10 @@ final class KeyHandlingTableView: NSTableView {
}

@objc func delete(_ sender: Any?) {
guard coordinator?.isEditable == true else { return }
if let controller = gridSelection, !controller.isEmpty {
coordinator?.delegate?.dataGridDeleteRows(Set(controller.selection.affectedRows))
return
}
guard !selectedRowIndexes.isEmpty else { return }
coordinator?.delegate?.dataGridDeleteRows(Set(selectedRowIndexes))
guard let coordinator, coordinator.isEditable else { return }
let indices = coordinator.currentRowSelection()
guard !indices.isEmpty else { return }
coordinator.delegate?.dataGridDeleteRows(indices)
}

@objc func copy(_ sender: Any?) {
Expand All @@ -243,7 +240,8 @@ final class KeyHandlingTableView: NSTableView {
}

@objc func copyRowsAsTSV(_ sender: Any?) {
coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes))
guard let coordinator else { return }
coordinator.delegate?.dataGridCopyRows(coordinator.currentRowSelection())
}

@objc override func selectAll(_ sender: Any?) {
Expand Down Expand Up @@ -289,7 +287,8 @@ final class KeyHandlingTableView: NSTableView {
let hasGridSelection = gridSelection?.isEmpty == false
return hasGridSelection || !selectedRowIndexes.isEmpty
case #selector(copyRowsAsTSV(_:)):
return !selectedRowIndexes.isEmpty
let hasGridSelection = gridSelection?.isEmpty == false
return hasGridSelection || !selectedRowIndexes.isEmpty
case #selector(paste(_:)):
return coordinator?.isEditable == true && coordinator?.delegate != nil
case #selector(insertNewline(_:)):
Expand Down
122 changes: 122 additions & 0 deletions TableProTests/Core/Coordinators/RowEditingCoordinatorCopyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//
// RowEditingCoordinatorCopyTests.swift
// TableProTests
//

import Foundation
import SwiftUI
import TableProPluginKit
import Testing

@testable import TablePro

@MainActor
private final class RowEditingCopyClipboard: ClipboardProvider {
var text: String?
var hasGridRowsValue = false

func readText() -> String? { text }
func readGridRows() -> GridRowsClipboardPayload? { nil }
func writeText(_ text: String) { self.text = text; hasGridRowsValue = false }
func writeCsv(_ csv: String) { text = csv; hasGridRowsValue = false }
func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { text = tsv; hasGridRowsValue = true }
var hasText: Bool { text != nil }
var hasGridRows: Bool { hasGridRowsValue }
}

@MainActor
private final class RowEditingCopyLayoutPersister: ColumnLayoutPersisting {
func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil }
func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {}
func clear(for key: ColumnLayoutTableKey) {}
}

@Suite("RowEditingCoordinator copy as JSON")
@MainActor
struct RowEditingCoordinatorCopyTests {
private func makeCoordinator() -> MainContentCoordinator {
let tabManager = QueryTabManager()
let coordinator = MainContentCoordinator(
connection: TestFixtures.makeConnection(),
tabManager: tabManager,
changeManager: DataChangeManager(),
toolbarState: ConnectionToolbarState()
)
var tab = QueryTab(title: "Q1", query: "SELECT id, name FROM users", tabType: .query)
tab.execution.lastExecutedAt = Date()
tabManager.tabs.append(tab)
tabManager.selectedTabId = tab.id

let tableRows = TableRows.from(
queryRows: [
[.text("1"), .text("Alice")],
[.text("2"), .text("Bob")]
],
columns: ["id", "name"],
columnTypes: [.text(rawType: nil), .text(rawType: nil)]
)
coordinator.setActiveTableRows(tableRows, for: tab.id)
return coordinator
}

private func attachGrid(
to coordinator: MainContentCoordinator,
sortedIDs: [RowID]?
) -> (DataTabGridDelegate, TableViewCoordinator) {
let delegate = DataTabGridDelegate()
let tableViewCoordinator = TableViewCoordinator(
changeManager: AnyChangeManager(DataChangeManager()),
isEditable: true,
selectedRowIndices: .constant([]),
delegate: delegate,
layoutPersister: RowEditingCopyLayoutPersister()
)
tableViewCoordinator.sortedIDs = sortedIDs
delegate.dataGridAttach(tableViewCoordinator: tableViewCoordinator)
coordinator.dataTabDelegate = delegate
return (delegate, tableViewCoordinator)
}

@Test("Copy as JSON resolves display positions through the sorted order")
func copyAsJsonResolvesDisplayOrder() {
let clipboard = RowEditingCopyClipboard()
ClipboardService.shared = clipboard
defer { ClipboardService.shared = NSPasteboardClipboardProvider() }

let coordinator = makeCoordinator()
let attached = attachGrid(to: coordinator, sortedIDs: [.existing(1), .existing(0)])

coordinator.copySelectedRowsAsJson(indices: [0])

#expect(clipboard.text?.contains("Bob") == true)
#expect(clipboard.text?.contains("Alice") != true)
withExtendedLifetime(attached) {}
}

@Test("Copy as JSON keeps storage order when no display mapping exists")
func copyAsJsonWithoutDisplayMapping() {
let clipboard = RowEditingCopyClipboard()
ClipboardService.shared = clipboard
defer { ClipboardService.shared = NSPasteboardClipboardProvider() }

let coordinator = makeCoordinator()

coordinator.copySelectedRowsAsJson(indices: [0])

#expect(clipboard.text?.contains("Alice") == true)
#expect(clipboard.text?.contains("Bob") != true)
}

@Test("Copy as JSON skips display positions past the current rows")
func copyAsJsonSkipsOutOfRangeIndices() {
let clipboard = RowEditingCopyClipboard()
ClipboardService.shared = clipboard
defer { ClipboardService.shared = NSPasteboardClipboardProvider() }

let coordinator = makeCoordinator()

coordinator.copySelectedRowsAsJson(indices: [1, 99])

#expect(clipboard.text?.contains("Bob") == true)
}
}
Loading
Loading