From 3f37596613d395dac5838a66230c9fd06265d0e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 10 Jun 2026 22:26:09 +0700 Subject: [PATCH 1/3] fix(datagrid): Delete key and right-click respect multi-row/cell-range selection D12A: delete(_:) checks gridSelection.affectedRows first, mirroring copy(). D12B: rightMouseDown override skips super when clicking inside existing selection, calls NSMenu.popUpContextMenu directly to preserve the selection set. --- CHANGELOG.md | 2 ++ .../Views/Results/KeyHandlingTableView.swift | 24 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32625cb83..02791051a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Delete key now respects cell-range selection in the data grid, removing all rows covered by the selection instead of ignoring it. +- Right-clicking a row inside a multi-row selection no longer collapses the selection before the context menu appears. - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. - Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633) diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 0b39899f7..ed6b6d3ec 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -222,6 +222,12 @@ final class KeyHandlingTableView: NSTableView { @objc func delete(_ sender: Any?) { guard coordinator?.isEditable == true else { return } + if let controller = gridSelection, !controller.isEmpty { + let rows = controller.selection.affectedRows + guard !rows.isEmpty else { return } + coordinator?.delegate?.dataGridDeleteRows(Set(rows)) + return + } guard !selectedRowIndexes.isEmpty else { return } coordinator?.delegate?.dataGridDeleteRows(Set(selectedRowIndexes)) } @@ -279,7 +285,8 @@ final class KeyHandlingTableView: NSTableView { override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { switch item.action { case #selector(delete(_:)), #selector(deleteBackward(_:)): - return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty + let hasGridSelection = gridSelection?.isEmpty == false + return coordinator?.isEditable == true && (hasGridSelection || !selectedRowIndexes.isEmpty) case #selector(copy(_:)): let hasGridSelection = gridSelection?.isEmpty == false return hasGridSelection || !selectedRowIndexes.isEmpty @@ -407,7 +414,7 @@ final class KeyHandlingTableView: NSTableView { private func deleteSelectedRowsIfPossible() { guard coordinator?.isEditable == true else { return } - guard !selectedRowIndexes.isEmpty else { return } + guard gridSelection?.isEmpty == false || !selectedRowIndexes.isEmpty else { return } delete(nil) } @@ -521,6 +528,19 @@ final class KeyHandlingTableView: NSTableView { scrollColumnToVisible(prevColumn) } + override func rightMouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + let clickedRow = row(at: point) + if clickedRow >= 0, selectedRowIndexes.contains(clickedRow) { + window?.makeFirstResponder(self) + if let menu = menu(for: event) { + NSMenu.popUpContextMenu(menu, with: event, for: self) + } + return + } + super.rightMouseDown(with: event) + } + override func menu(for event: NSEvent) -> NSMenu? { let point = convert(event.locationInWindow, from: nil) let clickedRow = row(at: point) From 6e51390ec46dd47a5c1b9580cd50bbefb96f010c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 15 Jun 2026 23:40:47 +0700 Subject: [PATCH 2/3] refactor(datagrid): handle cell-range selection in right-click and simplify delete --- CHANGELOG.md | 2 +- .../Views/Results/KeyHandlingTableView.swift | 18 ++++++++++++++---- .../Views/Results/CellSelectionTests.swift | 10 ++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc2c4469..b032124f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Delete key now respects cell-range selection in the data grid, removing all rows covered by the selection instead of ignoring it. -- Right-clicking a row inside a multi-row selection no longer collapses the selection before the context menu appears. +- Right-clicking inside a multi-row or cell-range selection no longer collapses the selection before the context menu appears. - Oracle connections no longer crash the app during connect. A short or unexpected handshake packet from the server (such as session-setup metadata or an error) now surfaces the error or continues instead of trapping. (#1683) - MongoDB filters on `_id` and other ObjectId fields now match. A 24-character hex value is matched as an ObjectId as well as a string, so filtering by `_id` returns the row instead of nothing. (#1682) - The sidebar and inspector keep their width per connection, the sidebar keeps its collapsed state, and the inspector keeps its selected tab, when you quit and reopen the app. diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index ed6b6d3ec..365d0c7a9 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -223,9 +223,7 @@ final class KeyHandlingTableView: NSTableView { @objc func delete(_ sender: Any?) { guard coordinator?.isEditable == true else { return } if let controller = gridSelection, !controller.isEmpty { - let rows = controller.selection.affectedRows - guard !rows.isEmpty else { return } - coordinator?.delegate?.dataGridDeleteRows(Set(rows)) + coordinator?.delegate?.dataGridDeleteRows(Set(controller.selection.affectedRows)) return } guard !selectedRowIndexes.isEmpty else { return } @@ -531,7 +529,7 @@ final class KeyHandlingTableView: NSTableView { override func rightMouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) let clickedRow = row(at: point) - if clickedRow >= 0, selectedRowIndexes.contains(clickedRow) { + if clickedRow >= 0, clickIsInsideSelection(row: clickedRow, point: point) { window?.makeFirstResponder(self) if let menu = menu(for: event) { NSMenu.popUpContextMenu(menu, with: event, for: self) @@ -541,6 +539,18 @@ final class KeyHandlingTableView: NSTableView { super.rightMouseDown(with: event) } + private func clickIsInsideSelection(row clickedRow: Int, point: NSPoint) -> Bool { + if selectedRowIndexes.contains(clickedRow) { return true } + guard let controller = gridSelection, !controller.isEmpty else { return false } + let clickedColumn = column(at: point) + guard clickedColumn >= 0, + let schema = coordinator?.identitySchema, + let dataColumn = DataGridView.dataColumnIndex(for: clickedColumn, in: self, schema: schema) else { + return false + } + return controller.selection.contains(row: clickedRow, column: dataColumn) + } + override func menu(for event: NSEvent) -> NSMenu? { let point = convert(event.locationInWindow, from: nil) let clickedRow = row(at: point) diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift index 37a30ad80..cf83d74b1 100644 --- a/TableProTests/Views/Results/CellSelectionTests.swift +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -63,6 +63,16 @@ struct GridSelectionTests { #expect(selection.boundingRectangle == rect) } + @Test("a cell-range spanning rows reports every covered row") + func cellRangeAffectsEveryCoveredRow() { + let selection = GridSelection.single( + GridRect(rows: 2...5, columns: 1...3), + anchor: GridCoord(row: 2, column: 1), + active: GridCoord(row: 5, column: 3) + ) + #expect(selection.affectedRows == IndexSet(integersIn: 2...5)) + } + @Test("multiple rectangles report union of affected rows and columns") func multipleRectanglesUnion() { let selection = GridSelection( From 8661e6e3e4689b6687bebb0f333bf4a6550b6927 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 15 Jun 2026 23:59:04 +0700 Subject: [PATCH 3/3] fix(datagrid): extend cell selection from the focused cell on Shift+Arrow --- CHANGELOG.md | 1 + .../Views/Results/KeyHandlingTableView.swift | 47 ++++++++---- .../Selection/GridSelectionController.swift | 6 +- .../Views/Results/CellSelectionTests.swift | 73 ++++++++++++++++++- 4 files changed, 107 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 194ececb3..566aabd35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Shift+Arrow in the data grid now starts and extends a cell selection from the focused cell, instead of doing nothing until a range already existed. Cmd+Shift+Arrow extends to the row or column edge. - Oracle connections no longer crash the app during connect. A short or unexpected handshake packet from the server (such as session-setup metadata or an error) now surfaces the error or continues instead of trapping. (#1683) - MongoDB filters on `_id` and other ObjectId fields now match. A 24-character hex value is matched as an ObjectId as well as a string, so filtering by `_id` returns the row instead of nothing. (#1682) - The sidebar and inspector keep their width per connection, the sidebar keeps its collapsed state, and the inspector keeps its selected tab, when you quit and reopen the app. diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 0b39899f7..02f2ecce4 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -316,24 +316,16 @@ final class KeyHandlingTableView: NSTableView { switch key { case .leftArrow: - handleHorizontalArrow(direction: .left, modifiers: modifiers, currentRow: row) + handleArrow(.left, modifiers: modifiers, currentRow: row, event: event) return case .rightArrow: - handleHorizontalArrow(direction: .right, modifiers: modifiers, currentRow: row) + handleArrow(.right, modifiers: modifiers, currentRow: row, event: event) return case .upArrow: - if modifiers.contains(.shift) { - gridSelection?.extendActiveCell(direction: .up, jumpToEdge: modifiers.contains(.command), totalRows: totalRows(), totalColumns: totalDataColumns()) - return - } - super.keyDown(with: event) + handleArrow(.up, modifiers: modifiers, currentRow: row, event: event) return case .downArrow: - if modifiers.contains(.shift) { - gridSelection?.extendActiveCell(direction: .down, jumpToEdge: modifiers.contains(.command), totalRows: totalRows(), totalColumns: totalDataColumns()) - return - } - super.keyDown(with: event) + handleArrow(.down, modifiers: modifiers, currentRow: row, event: event) return case .home, .end, .pageUp, .pageDown: super.keyDown(with: event) @@ -373,18 +365,41 @@ final class KeyHandlingTableView: NSTableView { return combo.matches(event) } - private func handleHorizontalArrow(direction: GridSelectionController.Direction, modifiers: NSEvent.ModifierFlags, currentRow: Int) { - if modifiers.contains(.shift), let controller = gridSelection, !controller.isEmpty { - controller.extendActiveCell(direction: direction, jumpToEdge: modifiers.contains(.command), totalRows: totalRows(), totalColumns: totalDataColumns()) + private func handleArrow(_ direction: GridSelectionController.Direction, modifiers: NSEvent.ModifierFlags, currentRow: Int, event: NSEvent) { + if modifiers.contains(.shift) { + if extendGridSelection(direction: direction, jumpToEdge: modifiers.contains(.command)) { + return + } + super.keyDown(with: event) return } + gridSelection?.clear() switch direction { case .left: handleLeftArrow(currentRow: currentRow) case .right: handleRightArrow(currentRow: currentRow) - default: break + case .up, .down: super.keyDown(with: event) } } + private func extendGridSelection(direction: GridSelectionController.Direction, jumpToEdge: Bool) -> Bool { + guard let controller = gridSelection else { return false } + let seed = controller.isEmpty ? focusedGridCoord() : nil + guard !controller.isEmpty || seed != nil else { return false } + controller.extendActiveCell( + from: seed, + direction: direction, + jumpToEdge: jumpToEdge, + totalRows: totalRows(), + totalColumns: totalDataColumns() + ) + return true + } + + private func focusedGridCoord() -> GridCoord? { + guard let cell = focusedDataCell() else { return nil } + return GridCoord(row: cell.row, column: cell.columnIndex) + } + @objc override func insertNewline(_ sender: Any?) { let row = selectedRow guard row >= 0, diff --git a/TablePro/Views/Results/Selection/GridSelectionController.swift b/TablePro/Views/Results/Selection/GridSelectionController.swift index aacff4dcd..ca300ea0c 100644 --- a/TablePro/Views/Results/Selection/GridSelectionController.swift +++ b/TablePro/Views/Results/Selection/GridSelectionController.swift @@ -159,10 +159,10 @@ final class GridSelectionController { update(.single(rect, anchor: anchor, active: anchor)) } - func extendActiveCell(direction: Direction, jumpToEdge: Bool, totalRows: Int, totalColumns: Int) { - guard let active = selection.activeCell else { return } + func extendActiveCell(from seed: GridCoord? = nil, direction: Direction, jumpToEdge: Bool, totalRows: Int, totalColumns: Int) { + guard let active = selection.activeCell ?? seed else { return } + let origin = selection.anchor ?? seed ?? active let next = step(from: active, direction: direction, jumpToEdge: jumpToEdge, totalRows: totalRows, totalColumns: totalColumns) - let origin = selection.anchor ?? active update(.single(GridRect.between(origin, next), anchor: origin, active: next)) } diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift index 37a30ad80..d328b93a1 100644 --- a/TableProTests/Views/Results/CellSelectionTests.swift +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -281,13 +281,84 @@ struct GridSelectionControllerTests { #expect(controller.selection.rectangles == [GridRect(rows: 2...2, columns: 2...9)]) } - @Test("extendActiveCell is a no-op when the selection is empty") + @Test("extendActiveCell without a seed is a no-op when the selection is empty") func extendActiveCellNoOpEmpty() { let controller = GridSelectionController() controller.extendActiveCell(direction: .down, jumpToEdge: false, totalRows: 10, totalColumns: 10) #expect(controller.selection.isEmpty) } + @Test("extendActiveCell with a seed begins a range anchored at the focused cell") + func extendActiveCellSeedsFromFocusedCell() { + let controller = GridSelectionController() + let focused = GridCoord(row: 3, column: 4) + + controller.extendActiveCell(from: focused, direction: .down, jumpToEdge: false, totalRows: 10, totalColumns: 10) + + #expect(controller.selection.rectangles == [GridRect(rows: 3...4, columns: 4...4)]) + #expect(controller.selection.anchor == focused) + #expect(controller.selection.activeCell == GridCoord(row: 4, column: 4)) + } + + @Test("a seeded extend grows by one cell in each direction from the focused cell") + func extendActiveCellSeedsInEveryDirection() { + let focused = GridCoord(row: 3, column: 3) + let cases: [(GridSelectionController.Direction, GridRect, GridCoord)] = [ + (.up, GridRect(rows: 2...3, columns: 3...3), GridCoord(row: 2, column: 3)), + (.down, GridRect(rows: 3...4, columns: 3...3), GridCoord(row: 4, column: 3)), + (.left, GridRect(rows: 3...3, columns: 2...3), GridCoord(row: 3, column: 2)), + (.right, GridRect(rows: 3...3, columns: 3...4), GridCoord(row: 3, column: 4)) + ] + for (direction, expectedRect, expectedActive) in cases { + let controller = GridSelectionController() + controller.extendActiveCell(from: focused, direction: direction, jumpToEdge: false, totalRows: 10, totalColumns: 10) + #expect(controller.selection.rectangles == [expectedRect]) + #expect(controller.selection.anchor == focused) + #expect(controller.selection.activeCell == expectedActive) + } + } + + @Test("repeated seeded extend keeps the anchor fixed and reverses by shrinking") + func extendActiveCellAnchorStaysFixedOnReverse() { + let controller = GridSelectionController() + let focused = GridCoord(row: 2, column: 2) + + controller.extendActiveCell(from: focused, direction: .down, jumpToEdge: false, totalRows: 10, totalColumns: 10) + controller.extendActiveCell(direction: .down, jumpToEdge: false, totalRows: 10, totalColumns: 10) + #expect(controller.selection.rectangles == [GridRect(rows: 2...4, columns: 2...2)]) + + controller.extendActiveCell(direction: .up, jumpToEdge: false, totalRows: 10, totalColumns: 10) + #expect(controller.selection.rectangles == [GridRect(rows: 2...3, columns: 2...2)]) + #expect(controller.selection.anchor == focused) + #expect(controller.selection.activeCell == GridCoord(row: 3, column: 2)) + } + + @Test("a seeded extend with jumpToEdge runs from the focused cell to the grid edge") + func extendActiveCellSeedsToEdge() { + let controller = GridSelectionController() + let focused = GridCoord(row: 2, column: 2) + + controller.extendActiveCell(from: focused, direction: .right, jumpToEdge: true, totalRows: 10, totalColumns: 10) + + #expect(controller.selection.rectangles == [GridRect(rows: 2...2, columns: 2...9)]) + #expect(controller.selection.anchor == focused) + #expect(controller.selection.activeCell == GridCoord(row: 2, column: 9)) + } + + @Test("the seed is ignored when a selection already exists") + func extendActiveCellIgnoresSeedWhenSelectionExists() { + let controller = GridSelectionController() + let origin = GridCoord(row: 2, column: 2) + _ = controller.beginDrag(at: origin, modifiers: []) + controller.continueDrag(to: origin) + controller.endDrag(dragged: true, originalCoord: origin) + + controller.extendActiveCell(from: GridCoord(row: 9, column: 9), direction: .down, jumpToEdge: false, totalRows: 10, totalColumns: 10) + + #expect(controller.selection.rectangles == [GridRect(rows: 2...3, columns: 2...2)]) + #expect(controller.selection.anchor == origin) + } + @Test("clear empties the selection") func clearEmpties() { let controller = GridSelectionController()