From 81dd233adf0bac8041bd5a2b8c0418373699592b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 19 May 2026 19:24:29 +0700 Subject: [PATCH 1/3] feat(datagrid): copy focused cell value with Cmd+C, add Cmd+Shift+C for rows (#1332) --- CHANGELOG.md | 4 ++++ .../PasteboardActionRouter.swift | 9 ++++---- .../Models/UI/KeyboardShortcutModels.swift | 6 ++++-- TablePro/Resources/Localizable.xcstrings | 3 +++ TablePro/TableProApp.swift | 21 ++++++++++++------- .../Main/MainContentCommandActions.swift | 4 +--- .../Views/Results/KeyHandlingTableView.swift | 21 ++++++++++++++++++- docs/features/data-grid.mdx | 2 +- docs/features/keyboard-shortcuts.mdx | 6 +++--- 9 files changed, 55 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557c32ecc..671c9f62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Right-click a column header to copy all its values from the loaded rows (#1325) - Copy as submenu on the row context menu now offers CSV, CSV with Headers, Markdown table, and IN Clause for SQL `WHERE id IN (...)` lookups (#1325) +### Changed + +- `Cmd+C` now copies the focused cell value when one row is selected and a cell has focus; with multiple rows selected, or when no cell is focused, it still copies row(s) as TSV. `Cmd+Shift+C` now always copies row(s) as TSV. "Copy with Headers" stays in the Edit menu and row context menu without a default shortcut (#1332) + ### Fixed - DuckDB Spatial `GEOMETRY` columns render as WKT, not NULL (#1324) diff --git a/TablePro/Core/KeyboardHandling/PasteboardActionRouter.swift b/TablePro/Core/KeyboardHandling/PasteboardActionRouter.swift index 82eedaf81..5891645eb 100644 --- a/TablePro/Core/KeyboardHandling/PasteboardActionRouter.swift +++ b/TablePro/Core/KeyboardHandling/PasteboardActionRouter.swift @@ -29,13 +29,14 @@ enum PasteboardActionRouter { if let responder = firstResponder, responder is NSTextView || responder is TextView { return .textCopy - } else if hasRowSelection { + } + if hasRowSelection { return .copyRows - } else if hasTableSelection { + } + if hasTableSelection { return .copyTableNames - } else { - return .textCopy } + return .textCopy } static func resolvePasteAction( diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index caa67dc57..32440dd1f 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -64,6 +64,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case redo case cut case copy + case copyRowsExplicit case copyWithHeaders case copyAsJson case paste @@ -103,7 +104,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .executeQuery, .explainQuery, .formatQuery, .export, .importData, .quickSwitcher, .previousPage, .nextPage, .saveAsFavorite, .openTerminal: return .file - case .undo, .redo, .cut, .copy, .copyWithHeaders, .copyAsJson, .paste, + case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, .truncateTable, .previewFKReference: return .edit @@ -142,6 +143,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .redo: return String(localized: "Redo") case .cut: return String(localized: "Cut") case .copy: return String(localized: "Copy") + case .copyRowsExplicit: return String(localized: "Copy Rows") case .copyWithHeaders: return String(localized: "Copy with Headers") case .copyAsJson: return String(localized: "Copy as JSON") case .paste: return String(localized: "Paste") @@ -482,7 +484,7 @@ struct KeyboardSettings: Codable, Equatable { .redo: KeyCombo(key: "z", command: true, shift: true), .cut: KeyCombo(key: "x", command: true), .copy: KeyCombo(key: "c", command: true), - .copyWithHeaders: KeyCombo(key: "c", command: true, shift: true), + .copyRowsExplicit: KeyCombo(key: "c", command: true, shift: true), .copyAsJson: KeyCombo(key: "j", command: true, option: true), .paste: KeyCombo(key: "v", command: true), .delete: KeyCombo(key: "delete", command: true, isSpecialKey: true), diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 1671f91ea..890e0328a 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -12511,6 +12511,9 @@ } } } + }, + "Copy Rows" : { + }, "Copy SQL" : { "extractionState" : "stale", diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index cc74d8ae2..9e1d43350 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -18,9 +18,13 @@ import TableProPluginKit /// Custom Commands struct for pasteboard operations struct PasteboardCommands: Commands { var settingsManager: AppSettingsManager - @FocusedValue(\.commandActions) var actions: MainContentCommandActions? + @FocusedValue(\.commandActions) var focusedActions: MainContentCommandActions? + @Bindable var commandRegistry: CommandActionsRegistry + + private var actions: MainContentCommandActions? { + focusedActions ?? commandRegistry.current + } - /// Build a SwiftUI KeyboardShortcut from keyboard settings private func shortcut(for action: ShortcutAction) -> KeyboardShortcut? { settingsManager.keyboard.keyboardShortcut(for: action) } @@ -39,16 +43,20 @@ struct PasteboardCommands: Commands { hasTableSelection: actions?.hasTableSelection ?? false ) switch action { - case .textCopy: + case .textCopy, .copyRows: NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) - case .copyRows: - actions?.copySelectedRows() case .copyTableNames: actions?.copyTableNames() } } .optionalKeyboardShortcut(shortcut(for: .copy)) + Button("Copy Rows") { + NSApp.sendAction(#selector(KeyHandlingTableView.copyRowsAsTSV(_:)), to: nil, from: nil) + } + .optionalKeyboardShortcut(shortcut(for: .copyRowsExplicit)) + .disabled(!(actions?.hasRowSelection ?? false)) + Button("Copy with Headers") { actions?.copySelectedRowsWithHeaders() } @@ -458,8 +466,7 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .redo)) } - // Edit menu - pasteboard commands with FocusedValue support - PasteboardCommands(settingsManager: settingsManager) + PasteboardCommands(settingsManager: settingsManager, commandRegistry: commandRegistry) // Edit menu - Find + row operations (after pasteboard) CommandGroup(after: .pasteboard) { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index db4109c10..c170d06d9 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -173,9 +173,7 @@ final class MainContentCommandActions { observeKeyWindowOnly(AppCommands.shared.exportQueryResults) { [weak self] _ in self?.exportQueryResults() } observeKeyWindowOnly(AppCommands.shared.copySelectedRows) { [weak self] _ in - guard let self else { return } - let indices = self.selectionState.indices - self.coordinator?.copySelectedRowsToClipboard(indices: indices) + self?.copySelectedRows() } observeKeyWindowOnly(AppCommands.shared.pasteRows) { [weak self] _ in diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 4f9a08a47..47fcd4e54 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -118,9 +118,28 @@ final class KeyHandlingTableView: NSTableView { } @objc func copy(_ sender: Any?) { + if let cell = focusedDataCell() { + coordinator?.copyCellValue(at: cell.row, columnIndex: cell.columnIndex) + } else { + coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) + } + } + + @objc func copyRowsAsTSV(_ sender: Any?) { coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) } + private func focusedDataCell() -> (row: Int, columnIndex: Int)? { + guard selectedRowIndexes.count == 1, + focusedRow >= 0, + DataGridView.isDataTableColumn(focusedColumn), + let schema = coordinator?.identitySchema, + let dataColumn = DataGridView.dataColumnIndex(for: focusedColumn, in: self, schema: schema) else { + return nil + } + return (focusedRow, dataColumn) + } + @objc func paste(_ sender: Any?) { guard coordinator?.isEditable == true else { return } if focusedRow >= 0, @@ -137,7 +156,7 @@ final class KeyHandlingTableView: NSTableView { switch item.action { case #selector(delete(_:)), #selector(deleteBackward(_:)): return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty - case #selector(copy(_:)): + case #selector(copy(_:)), #selector(copyRowsAsTSV(_:)): return !selectedRowIndexes.isEmpty case #selector(paste(_:)): return coordinator?.isEditable == true && coordinator?.delegate != nil diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 37ccbb721..7fab9d9e9 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -321,7 +321,7 @@ Use arrow keys to move between cells. Press `Enter` to edit, `Escape` to cancel. Click a cell to select it. Drag or Shift+click to select a range. Click row numbers for entire rows; Cmd+click for non-contiguous rows. -Select rows and press `Cmd+C` for TSV, `Cmd+Shift+C` for TSV with headers, or `Cmd+Option+J` for JSON. +`Cmd+C` copies the focused cell value when a single row is selected and a cell has focus; otherwise it copies the selected row(s) as TSV. `Cmd+Shift+C` always copies the selected row(s) as TSV. `Cmd+Option+J` copies as JSON. Right-click a row and choose **Copy as** for additional formats: diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 8e07c7c94..181450b97 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -137,10 +137,10 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Action | Shortcut | |--------|----------| -| Copy selection | `Cmd+C` | -| Copy with Headers | `Cmd+Shift+C` | +| Copy (cell value if a cell is focused on a single row, otherwise row(s) TSV) | `Cmd+C` | +| Copy Rows as TSV | `Cmd+Shift+C` | | Copy as JSON | `Cmd+Option+J` | -| Copy as TSV | Available from context menu | +| Copy with Headers | Available from Edit menu and context menu | ## CSV Inspector From 9d4428df85c97b610da6f28792f87f0a19a5c741 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 19 May 2026 19:29:36 +0700 Subject: [PATCH 2/3] fix(datagrid): mirror structure tab selection into GridSelectionState so Cmd+Shift+C enables in structure tab --- .../Main/Child/MainEditorContentView.swift | 7 +++++-- .../Views/Structure/TableStructureView.swift | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 16417b738..7933bcaf1 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -433,8 +433,11 @@ struct MainEditorContentView: View { case .structure: if let tableName = tab.tableContext.tableName { TableStructureView( - tableName: tableName, connection: connection, - toolbarState: coordinator.toolbarState, coordinator: coordinator + tableName: tableName, + connection: connection, + toolbarState: coordinator.toolbarState, + coordinator: coordinator, + selectionState: selectionState ) .id(tableName) .frame(maxHeight: .infinity) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 6066069db..959296df9 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -21,6 +21,7 @@ struct TableStructureView: View { let connection: DatabaseConnection let toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator? + let selectionState: GridSelectionState @State var selectedTab: StructureTab = .columns @State var columns: [ColumnInfo] = [] @@ -53,11 +54,18 @@ struct TableStructureView: View { @State var actionHandler = StructureViewActionHandler() @State var gridDelegate: StructureGridDelegate - init(tableName: String, connection: DatabaseConnection, toolbarState: ConnectionToolbarState, coordinator: MainContentCoordinator?) { + init( + tableName: String, + connection: DatabaseConnection, + toolbarState: ConnectionToolbarState, + coordinator: MainContentCoordinator?, + selectionState: GridSelectionState + ) { self.tableName = tableName self.connection = connection self.toolbarState = toolbarState self.coordinator = coordinator + self.selectionState = selectionState let manager = StructureChangeManager() _structureChangeManager = State(wrappedValue: manager) @@ -87,7 +95,10 @@ struct TableStructureView: View { .onAppear { coordinator?.toolbarState.hasStructureChanges = structureChangeManager.hasChanges - gridDelegate.onSelectedRowsChanged = { self.selectedRows = $0 } + gridDelegate.onSelectedRowsChanged = { rows in + self.selectedRows = rows + self.selectionState.indices = rows + } gridDelegate.coordinator = coordinator gridDelegate.sortHandler = { [self] column, ascending in structureSortDescriptor = StructureSortDescriptor(column: column, ascending: ascending) @@ -114,6 +125,7 @@ struct TableStructureView: View { .onDisappear { coordinator?.toolbarState.hasStructureChanges = false coordinator?.structureActions = nil + selectionState.indices = [] } .onChange(of: structureChangeManager.hasChanges) { _, newValue in coordinator?.toolbarState.hasStructureChanges = newValue @@ -360,7 +372,8 @@ struct TableStructureView: View { type: .mysql ), toolbarState: ConnectionToolbarState(), - coordinator: nil + coordinator: nil, + selectionState: GridSelectionState() ) .frame(width: 800, height: 600) } From e5f602c9af68ccc7752982824da78f2059f6cd2e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 19 May 2026 19:33:57 +0700 Subject: [PATCH 3/3] fix(datagrid): mirror structure tab selection via onChange so clicks update selectionState --- TablePro/Views/Structure/TableStructureView.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 959296df9..662b6af37 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -86,6 +86,7 @@ struct TableStructureView: View { contentArea } .task(loadInitialData) + .onChange(of: selectedRows) { _, newRows in selectionState.indices = newRows } .onChange(of: selectedTab) { _, newValue in onSelectedTabChanged(newValue) } .onChange(of: columns) { onColumnsChanged() } .onChange(of: indexes) { onIndexesChanged() } @@ -95,10 +96,7 @@ struct TableStructureView: View { .onAppear { coordinator?.toolbarState.hasStructureChanges = structureChangeManager.hasChanges - gridDelegate.onSelectedRowsChanged = { rows in - self.selectedRows = rows - self.selectionState.indices = rows - } + gridDelegate.onSelectedRowsChanged = { self.selectedRows = $0 } gridDelegate.coordinator = coordinator gridDelegate.sortHandler = { [self] column, ascending in structureSortDescriptor = StructureSortDescriptor(column: column, ascending: ascending)