From 0931b9c01c0ae8a5ae4182c130f2e3fe2bd72164 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 22 Jul 2026 01:03:30 +0700 Subject: [PATCH 1/2] feat(inspector): insert columns left/right and delete selected columns in the CSV editor --- CHANGELOG.md | 1 + TablePro/TableProApp.swift | 15 +++ .../InspectorColumnMenuBuilder.swift | 25 ++-- .../InspectorDeleteConfirmation.swift | 19 +++ .../Inspector/InspectorViewController.swift | 119 ++++++++++++++---- .../Inspector/InspectorWindowController.swift | 7 +- .../InspectorColumnMenuBuilderTests.swift | 19 ++- .../InspectorDeleteConfirmationTests.swift | 6 + docs/features/csv-inspector.mdx | 6 +- 9 files changed, 176 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 952eec64e..abe459bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connect to PGlite through its socket server (`pglite-server`), with schema browsing, SQL editing, and row editing like PostgreSQL. Defaults to a loopback host with no TLS, and treats the connection as single-use to match PGlite's one-connection limit. (#1911) - In the CSV editor, right-click a column header to rename, insert, delete, or change the type of that column. (#1913) - In the CSV editor, insert a row above or below any row from the row's right-click menu or the Edit menu. Deleting rows that contain data now asks you to confirm first. (#1469) +- In the CSV editor, insert a column to the left or right, and delete several selected columns at once. Column verbs also appear in the Edit menu, and deleting a column that holds data asks you to confirm first. (#1469) - Add llama.cpp and MLX as local AI providers. Each preset points at the server's default local endpoint and needs no API key, alongside the existing Ollama and custom OpenAI-compatible options. (#1777) ### Fixed diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 50694ac72..94ee36d0a 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -620,6 +620,21 @@ struct AppMenuCommands: Commands { } .disabled(!keyWindowIsInspector) + Button(String(localized: "Insert Column Left")) { + NSApp.sendAction(#selector(InspectorViewController.inspectorInsertColumnLeft(_:)), to: nil, from: nil) + } + .disabled(!keyWindowIsInspector) + + Button(String(localized: "Insert Column Right")) { + NSApp.sendAction(#selector(InspectorViewController.inspectorInsertColumnRight(_:)), to: nil, from: nil) + } + .disabled(!keyWindowIsInspector) + + Button(String(localized: "Delete Column")) { + NSApp.sendAction(#selector(InspectorViewController.inspectorDeleteColumn(_:)), to: nil, from: nil) + } + .disabled(!keyWindowIsInspector) + Divider() // Table operations (work when tables selected in sidebar) diff --git a/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift b/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift index f95511d3c..82b4c84d0 100644 --- a/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift +++ b/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift @@ -8,30 +8,37 @@ import TableProPluginKit @MainActor enum InspectorColumnMenuBuilder { - static func structureItems(forColumn index: Int, currentType: InspectorColumnType) -> [NSMenuItem] { + static func structureItems( + forColumn index: Int, + currentType: InspectorColumnType, + deleteColumns: [Int] + ) -> [NSMenuItem] { let rename = actionItem( title: String(localized: "Rename Column…"), action: #selector(InspectorViewController.inspectorRenameColumn(_:)), column: index ) - let insertBefore = actionItem( - title: String(localized: "Insert Column Before"), - action: #selector(InspectorViewController.inspectorInsertColumnBefore(_:)), + let insertLeft = actionItem( + title: String(localized: "Insert Column Left"), + action: #selector(InspectorViewController.inspectorInsertColumnLeft(_:)), column: index ) - let insertAfter = actionItem( - title: String(localized: "Insert Column After"), - action: #selector(InspectorViewController.inspectorInsertColumnAfter(_:)), + let insertRight = actionItem( + title: String(localized: "Insert Column Right"), + action: #selector(InspectorViewController.inspectorInsertColumnRight(_:)), column: index ) let changeType = NSMenuItem(title: String(localized: "Change Type"), action: nil, keyEquivalent: "") changeType.submenu = typeSubmenu(forColumn: index, currentType: currentType) let delete = actionItem( - title: String(localized: "Delete Column"), + title: deleteColumns.count > 1 + ? String(localized: "Delete Columns") + : String(localized: "Delete Column"), action: #selector(InspectorViewController.inspectorDeleteColumn(_:)), column: index ) - return [rename, insertBefore, insertAfter, changeType, .separator(), delete] + delete.representedObject = deleteColumns + return [rename, insertLeft, insertRight, changeType, .separator(), delete] } static func typeSubmenu(forColumn index: Int, currentType: InspectorColumnType) -> NSMenu { diff --git a/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift b/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift index 4cb087c3f..a77be1843 100644 --- a/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift +++ b/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift @@ -17,6 +17,25 @@ enum InspectorDeleteConfirmation { : String(format: String(localized: "Delete %lld rows?"), count) } + static func columnDeleteTitle(count: Int) -> String { + count == 1 + ? String(localized: "Delete this column?") + : String(format: String(localized: "Delete %lld columns?"), count) + } + + static func confirmDeleteColumnsIfNeeded( + count: Int, + containsData: Bool, + window: NSWindow?, + proceed: @escaping @MainActor () -> Void + ) { + guard containsData else { + proceed() + return + } + present(messageText: columnDeleteTitle(count: count), window: window, proceed: proceed) + } + static func confirmDeleteRowsIfNeeded( rowsCells: [[String]], window: NSWindow?, diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index 776956232..7e2a40fa9 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -271,39 +271,94 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation } } - @objc func inspectorInsertColumnBefore(_ sender: Any?) { - guard let menuItem = sender as? NSMenuItem, - let inspector = inspectorDocument, - menuItem.tag >= 0, menuItem.tag < inspector.columnNames.count else { return } - let anchorIndex = menuItem.tag + @objc func inspectorInsertColumnLeft(_ sender: Any?) { + performInsertColumn(anchoredBy: sender, toRight: false) + } + + @objc func inspectorInsertColumnRight(_ sender: Any?) { + performInsertColumn(anchoredBy: sender, toRight: true) + } + + private func performInsertColumn(anchoredBy sender: Any?, toRight: Bool) { + guard let inspector = inspectorDocument, + let anchorIndex = columnInsertAnchor(from: sender, toRight: toRight) else { return } let anchorName = inspector.columnNames[anchorIndex] + let insertIndex = toRight ? anchorIndex + 1 : anchorIndex promptForColumnName(title: String(localized: "Insert Column"), initial: "") { [weak self] name in guard let self, let name, !name.isEmpty else { return } - self.inspectorDocument?.insertColumn(at: anchorIndex, name: name) - self.insertLayoutKey(name, relativeTo: anchorName, after: false) + self.inspectorDocument?.insertColumn(at: insertIndex, name: name) + self.insertLayoutKey(name, relativeTo: anchorName, after: toRight) } } - @objc func inspectorInsertColumnAfter(_ sender: Any?) { - guard let menuItem = sender as? NSMenuItem, - let inspector = inspectorDocument, - menuItem.tag >= 0, menuItem.tag < inspector.columnNames.count else { return } - let anchorIndex = menuItem.tag - let anchorName = inspector.columnNames[anchorIndex] - promptForColumnName(title: String(localized: "Insert Column"), initial: "") { [weak self] name in - guard let self, let name, !name.isEmpty else { return } - self.inspectorDocument?.insertColumn(at: anchorIndex + 1, name: name) - self.insertLayoutKey(name, relativeTo: anchorName, after: true) + private func columnInsertAnchor(from sender: Any?, toRight: Bool) -> Int? { + guard let inspector = inspectorDocument, !inspector.columnNames.isEmpty else { return nil } + let count = inspector.columnNames.count + if let menuItem = sender as? NSMenuItem, menuItem.tag >= 0, menuItem.tag < count { + return menuItem.tag } + if let affected = gridDelegate.coordinator?.selectionController.selection.affectedColumns, + let bound = toRight ? affected.max() : affected.min(), + bound >= 0, bound < count { + return bound + } + return toRight ? count - 1 : 0 } @objc func inspectorDeleteColumn(_ sender: Any?) { - guard let menuItem = sender as? NSMenuItem, - let inspector = inspectorDocument, - menuItem.tag >= 0, menuItem.tag < inspector.columnNames.count else { return } - let name = inspector.columnNames[menuItem.tag] - inspector.removeColumn(at: menuItem.tag) - removeLayoutKey(name) + let columns = columnDeleteTargets(from: sender) + performDeleteColumns(columns) + } + + private func columnDeleteTargets(from sender: Any?) -> [Int] { + guard let inspector = inspectorDocument else { return [] } + let count = inspector.columnNames.count + if let menuItem = sender as? NSMenuItem, let represented = menuItem.representedObject as? [Int] { + return represented.filter { $0 >= 0 && $0 < count }.sorted() + } + if let affected = gridDelegate.coordinator?.selectionController.selection.affectedColumns { + return affected.filter { $0 >= 0 && $0 < count }.sorted() + } + return [] + } + + private func performDeleteColumns(_ columns: [Int]) { + guard let inspector = inspectorDocument, !columns.isEmpty else { return } + let containsData = columnsContainData(columns, inspector: inspector) + InspectorDeleteConfirmation.confirmDeleteColumnsIfNeeded( + count: columns.count, + containsData: containsData, + window: view.window + ) { [weak self] in + self?.deleteColumns(columns) + } + } + + private func deleteColumns(_ columns: [Int]) { + guard let inspector = inspectorDocument else { return } + let undoManager = nsDocument?.undoManager + undoManager?.beginUndoGrouping() + for column in columns.sorted(by: >) where column >= 0 && column < inspector.columnNames.count { + let name = inspector.columnNames[column] + inspector.removeColumn(at: column) + removeLayoutKey(name) + } + undoManager?.setActionName( + columns.count > 1 ? String(localized: "Delete Columns") : String(localized: "Delete Column") + ) + undoManager?.endUndoGrouping() + } + + private func columnsContainData(_ columns: [Int], inspector: any InspectorDocument) -> Bool { + let rowCount = inspector.rowCount + for column in columns { + var row = 0 + while row < rowCount { + if !inspector.value(row: row, column: column).isEmpty { return true } + row += 1 + } + } + return false } @objc func inspectorSetColumnType(_ sender: Any?) { @@ -316,10 +371,19 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation guard let inspector = inspectorDocument, index >= 0, index < inspector.columnNames.count else { return [] } return InspectorColumnMenuBuilder.structureItems( forColumn: index, - currentType: inspector.displayedType(forColumn: index) + currentType: inspector.displayedType(forColumn: index), + deleteColumns: columnDeleteSelection(clicked: index) ) } + private func columnDeleteSelection(clicked: Int) -> [Int] { + guard let affected = gridDelegate.coordinator?.selectionController.selection.affectedColumns, + affected.contains(clicked), affected.count > 1 else { + return [clicked] + } + return affected.sorted() + } + fileprivate func rowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] { guard inspectorDocument != nil else { return [] } return InspectorRowMenuBuilder.structureItems(forRow: displayRow) @@ -398,8 +462,13 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation return nsDocument?.undoManager?.canRedo ?? false case #selector(saveDocument(_:)), #selector(saveDocumentAs(_:)), #selector(toggleInspectorFilter(_:)), #selector(inspectorAddRow(_:)), - #selector(inspectorInsertRowAbove(_:)), #selector(inspectorInsertRowBelow(_:)): + #selector(inspectorInsertRowAbove(_:)), #selector(inspectorInsertRowBelow(_:)), + #selector(inspectorInsertColumnLeft(_:)), #selector(inspectorInsertColumnRight(_:)): return nsDocument != nil + case #selector(inspectorDeleteColumn(_:)): + guard nsDocument != nil else { return false } + if let menuItem = item as? NSMenuItem, menuItem.representedObject is [Int] { return true } + return !(gridDelegate.coordinator?.selectionController.selection.affectedColumns.isEmpty ?? true) case #selector(inspectorDeleteSelectedRows(_:)): return !state.selectedRowIndices.isEmpty default: diff --git a/TablePro/Views/Inspector/InspectorWindowController.swift b/TablePro/Views/Inspector/InspectorWindowController.swift index 2c057fb89..f45985792 100644 --- a/TablePro/Views/Inspector/InspectorWindowController.swift +++ b/TablePro/Views/Inspector/InspectorWindowController.swift @@ -155,7 +155,12 @@ final class InspectorWindowController: NSWindowController, NSWindowDelegate, NST private func columnSubmenu(forColumn index: Int, currentType: InspectorColumnType) -> NSMenu { let submenu = NSMenu() - for item in InspectorColumnMenuBuilder.structureItems(forColumn: index, currentType: currentType) { + let items = InspectorColumnMenuBuilder.structureItems( + forColumn: index, + currentType: currentType, + deleteColumns: [index] + ) + for item in items { submenu.addItem(item) } return submenu diff --git a/TableProTests/Views/InspectorColumnMenuBuilderTests.swift b/TableProTests/Views/InspectorColumnMenuBuilderTests.swift index f64f519d0..d04a23b7b 100644 --- a/TableProTests/Views/InspectorColumnMenuBuilderTests.swift +++ b/TableProTests/Views/InspectorColumnMenuBuilderTests.swift @@ -13,12 +13,12 @@ import Testing struct InspectorColumnMenuBuilderTests { @Test("Structure items route each action to the clicked column") func structureItemsWiring() { - let items = InspectorColumnMenuBuilder.structureItems(forColumn: 3, currentType: .text) + let items = InspectorColumnMenuBuilder.structureItems(forColumn: 3, currentType: .text, deleteColumns: [3]) #expect(items.count == 6) #expect(items[0].action == #selector(InspectorViewController.inspectorRenameColumn(_:))) - #expect(items[1].action == #selector(InspectorViewController.inspectorInsertColumnBefore(_:))) - #expect(items[2].action == #selector(InspectorViewController.inspectorInsertColumnAfter(_:))) + #expect(items[1].action == #selector(InspectorViewController.inspectorInsertColumnLeft(_:))) + #expect(items[2].action == #selector(InspectorViewController.inspectorInsertColumnRight(_:))) #expect(items[3].submenu != nil) #expect(items[4].isSeparatorItem) #expect(items[5].action == #selector(InspectorViewController.inspectorDeleteColumn(_:))) @@ -31,12 +31,23 @@ struct InspectorColumnMenuBuilderTests { @Test("Delete Column is the last item, in its own trailing group") func deleteIsLastAfterSeparator() { - let items = InspectorColumnMenuBuilder.structureItems(forColumn: 0, currentType: .integer) + let items = InspectorColumnMenuBuilder.structureItems(forColumn: 0, currentType: .integer, deleteColumns: [0]) #expect(items.last?.action == #selector(InspectorViewController.inspectorDeleteColumn(_:))) #expect(items[items.count - 2].isSeparatorItem) } + @Test("Delete item carries the target columns and pluralizes its title") + func deleteTargetsAndTitle() { + let single = InspectorColumnMenuBuilder.structureItems(forColumn: 1, currentType: .text, deleteColumns: [1]) + #expect(single.last?.representedObject as? [Int] == [1]) + #expect(single.last?.title == "Delete Column") + + let multi = InspectorColumnMenuBuilder.structureItems(forColumn: 1, currentType: .text, deleteColumns: [1, 2, 3]) + #expect(multi.last?.representedObject as? [Int] == [1, 2, 3]) + #expect(multi.last?.title == "Delete Columns") + } + @Test("Type submenu checks the current type and offers Reset to Inferred") func typeSubmenuState() { let submenu = InspectorColumnMenuBuilder.typeSubmenu(forColumn: 1, currentType: .integer) diff --git a/TableProTests/Views/InspectorDeleteConfirmationTests.swift b/TableProTests/Views/InspectorDeleteConfirmationTests.swift index 5cd3f7298..60f95d6f8 100644 --- a/TableProTests/Views/InspectorDeleteConfirmationTests.swift +++ b/TableProTests/Views/InspectorDeleteConfirmationTests.swift @@ -30,4 +30,10 @@ struct InspectorDeleteConfirmationTests { #expect(InspectorDeleteConfirmation.rowDeleteTitle(count: 1) == "Delete this row?") #expect(InspectorDeleteConfirmation.rowDeleteTitle(count: 3).contains("3")) } + + @Test("Column delete title reflects the column count") + func columnDeleteTitles() { + #expect(InspectorDeleteConfirmation.columnDeleteTitle(count: 1) == "Delete this column?") + #expect(InspectorDeleteConfirmation.columnDeleteTitle(count: 2).contains("2")) + } } diff --git a/docs/features/csv-inspector.mdx b/docs/features/csv-inspector.mdx index 84bba5395..3d281ce51 100644 --- a/docs/features/csv-inspector.mdx +++ b/docs/features/csv-inspector.mdx @@ -49,9 +49,11 @@ Rows load in pages. The page size comes from the Default page size setting in [S Right-click a column header, or open the toolbar `Columns` menu, to reach the same per-column actions: - **Rename Column…** prompts for a new name; the column stays in place. -- **Insert Column Before / After** inserts a new column at the chosen position with a name you supply. +- **Insert Column Left / Right** inserts a new column next to the one you clicked, with a name you supply. - **Change Type ▸** overrides the inferred type as Text, Integer, Real, Boolean, or Date. **Reset to Inferred** drops the override. -- **Delete Column** removes the column. It sits last in the menu and is undoable with Cmd+Z. +- **Delete Column** removes the column. It sits last in the menu and is undoable with Cmd+Z. Deleting a column that holds data asks you to confirm first. + +Select several columns first (click a header, then Cmd-click others) and the menu reads **Delete Columns**, removing all of them in one undoable step. Insert Column Left / Right, and Delete Column, also appear in the Edit menu, where they act on the selected column. In the toolbar `Columns` menu, **Add Column…** at the top appends a new column at the end, and every column is listed below it with its current type and the same submenu. From 88c3e26c71d56dfefe0473786d311d89684d8797 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 22 Jul 2026 10:28:24 +0700 Subject: [PATCH 2/2] fix(inspector): operate CSV column verbs on fully selected columns, make header Cmd-click additive --- TablePro/TableProApp.swift | 6 +-- .../Inspector/InspectorColumnTargets.swift | 29 +++++++++++ .../Inspector/InspectorViewController.swift | 44 +++++++---------- .../Results/DataGridView+RowActions.swift | 8 +++ .../Selection/GridSelectionController.swift | 12 +++++ .../Views/Results/SortableHeaderView.swift | 2 +- .../Views/InspectorColumnTargetsTests.swift | 49 +++++++++++++++++++ docs/features/csv-inspector.mdx | 2 +- 8 files changed, 122 insertions(+), 30 deletions(-) create mode 100644 TablePro/Views/Inspector/InspectorColumnTargets.swift create mode 100644 TableProTests/Views/InspectorColumnTargetsTests.swift diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 94ee36d0a..548699bdb 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -620,17 +620,17 @@ struct AppMenuCommands: Commands { } .disabled(!keyWindowIsInspector) - Button(String(localized: "Insert Column Left")) { + Button("Insert Column Left") { NSApp.sendAction(#selector(InspectorViewController.inspectorInsertColumnLeft(_:)), to: nil, from: nil) } .disabled(!keyWindowIsInspector) - Button(String(localized: "Insert Column Right")) { + Button("Insert Column Right") { NSApp.sendAction(#selector(InspectorViewController.inspectorInsertColumnRight(_:)), to: nil, from: nil) } .disabled(!keyWindowIsInspector) - Button(String(localized: "Delete Column")) { + Button("Delete Column") { NSApp.sendAction(#selector(InspectorViewController.inspectorDeleteColumn(_:)), to: nil, from: nil) } .disabled(!keyWindowIsInspector) diff --git a/TablePro/Views/Inspector/InspectorColumnTargets.swift b/TablePro/Views/Inspector/InspectorColumnTargets.swift new file mode 100644 index 000000000..274e83184 --- /dev/null +++ b/TablePro/Views/Inspector/InspectorColumnTargets.swift @@ -0,0 +1,29 @@ +// +// InspectorColumnTargets.swift +// TablePro +// + +import Foundation + +enum InspectorColumnTargets { + static func deleteMenuSelection(clicked: Int, fullySelected: IndexSet) -> [Int] { + guard fullySelected.contains(clicked), fullySelected.count > 1 else { return [clicked] } + return fullySelected.sorted() + } + + static func deleteTargets(explicit: [Int]?, fullySelected: IndexSet, columnCount: Int) -> [Int] { + let candidates = explicit ?? Array(fullySelected) + return candidates.filter { $0 >= 0 && $0 < columnCount }.sorted() + } + + static func insertAnchor(clicked: Int?, fullySelected: IndexSet, columnCount: Int, toRight: Bool) -> Int? { + guard columnCount > 0 else { return nil } + if let clicked, clicked >= 0, clicked < columnCount { + return clicked + } + if let bound = toRight ? fullySelected.max() : fullySelected.min(), bound >= 0, bound < columnCount { + return bound + } + return toRight ? columnCount - 1 : 0 + } +} diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index 7e2a40fa9..ff1bed7a2 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -292,17 +292,14 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation } private func columnInsertAnchor(from sender: Any?, toRight: Bool) -> Int? { - guard let inspector = inspectorDocument, !inspector.columnNames.isEmpty else { return nil } - let count = inspector.columnNames.count - if let menuItem = sender as? NSMenuItem, menuItem.tag >= 0, menuItem.tag < count { - return menuItem.tag - } - if let affected = gridDelegate.coordinator?.selectionController.selection.affectedColumns, - let bound = toRight ? affected.max() : affected.min(), - bound >= 0, bound < count { - return bound - } - return toRight ? count - 1 : 0 + guard let inspector = inspectorDocument else { return nil } + let clicked = (sender as? NSMenuItem).map(\.tag) + return InspectorColumnTargets.insertAnchor( + clicked: clicked, + fullySelected: selectedFullColumns(), + columnCount: inspector.columnNames.count, + toRight: toRight + ) } @objc func inspectorDeleteColumn(_ sender: Any?) { @@ -312,14 +309,15 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation private func columnDeleteTargets(from sender: Any?) -> [Int] { guard let inspector = inspectorDocument else { return [] } - let count = inspector.columnNames.count - if let menuItem = sender as? NSMenuItem, let represented = menuItem.representedObject as? [Int] { - return represented.filter { $0 >= 0 && $0 < count }.sorted() - } - if let affected = gridDelegate.coordinator?.selectionController.selection.affectedColumns { - return affected.filter { $0 >= 0 && $0 < count }.sorted() - } - return [] + return InspectorColumnTargets.deleteTargets( + explicit: (sender as? NSMenuItem)?.representedObject as? [Int], + fullySelected: selectedFullColumns(), + columnCount: inspector.columnNames.count + ) + } + + private func selectedFullColumns() -> IndexSet { + gridDelegate.coordinator?.selectionController.selectedFullColumns() ?? IndexSet() } private func performDeleteColumns(_ columns: [Int]) { @@ -377,11 +375,7 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation } private func columnDeleteSelection(clicked: Int) -> [Int] { - guard let affected = gridDelegate.coordinator?.selectionController.selection.affectedColumns, - affected.contains(clicked), affected.count > 1 else { - return [clicked] - } - return affected.sorted() + InspectorColumnTargets.deleteMenuSelection(clicked: clicked, fullySelected: selectedFullColumns()) } fileprivate func rowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] { @@ -468,7 +462,7 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation case #selector(inspectorDeleteColumn(_:)): guard nsDocument != nil else { return false } if let menuItem = item as? NSMenuItem, menuItem.representedObject is [Int] { return true } - return !(gridDelegate.coordinator?.selectionController.selection.affectedColumns.isEmpty ?? true) + return !selectedFullColumns().isEmpty case #selector(inspectorDeleteSelectedRows(_:)): return !state.selectedRowIndices.isEmpty default: diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 6c4f50d1f..9eb932bab 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -340,6 +340,14 @@ extension TableViewCoordinator { } } + func extendColumnSelection(_ dataColumnIndex: Int) { + let totalRows = displayIDs?.count ?? tableRowsProvider().rows.count + selectionController.addEntireColumn(dataColumnIndex, totalRows: totalRows) + if let keyTableView = tableView as? KeyHandlingTableView { + keyTableView.deselectAll(nil) + } + } + func copyGridSelection(_ selection: GridSelection) { guard let rect = selection.boundingRectangle else { return } let tableRows = tableRowsProvider() diff --git a/TablePro/Views/Results/Selection/GridSelectionController.swift b/TablePro/Views/Results/Selection/GridSelectionController.swift index ca300ea0c..abcd75e56 100644 --- a/TablePro/Views/Results/Selection/GridSelectionController.swift +++ b/TablePro/Views/Results/Selection/GridSelectionController.swift @@ -152,6 +152,18 @@ final class GridSelectionController { update(.single(rect, anchor: anchor, active: anchor)) } + func addEntireColumn(_ column: Int, totalRows: Int) { + guard column >= 0, totalRows > 0 else { return } + let rect = GridRect(rows: 0...(totalRows - 1), columns: column...column) + let anchor = GridCoord(row: 0, column: column) + let addition = GridSelection.single(rect, anchor: anchor, active: anchor) + update(selection.isEmpty ? addition : selection.union(addition)) + } + + func selectedFullColumns() -> IndexSet { + fullySelectedColumns(in: selection) + } + func selectEntireRow(_ row: Int, totalColumns: Int) { guard row >= 0, totalColumns > 0 else { return } let rect = GridRect(rows: row...row, columns: 0...(totalColumns - 1)) diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index eb5e11a97..06cc1ef9b 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -303,7 +303,7 @@ final class SortableHeaderView: NSTableHeaderView { } if modifierFlags.contains(.command) && !modifierFlags.contains(.shift) { - coordinator.selectColumn(dataIndex) + coordinator.extendColumnSelection(dataIndex) return } diff --git a/TableProTests/Views/InspectorColumnTargetsTests.swift b/TableProTests/Views/InspectorColumnTargetsTests.swift new file mode 100644 index 000000000..10efd013a --- /dev/null +++ b/TableProTests/Views/InspectorColumnTargetsTests.swift @@ -0,0 +1,49 @@ +// +// InspectorColumnTargetsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("InspectorColumnTargets") +struct InspectorColumnTargetsTests { + @Test("Delete menu targets the whole selection only when the clicked column is inside it") + func deleteMenuSelection() { + #expect(InspectorColumnTargets.deleteMenuSelection( + clicked: 1, fullySelected: IndexSet([1, 2, 3])) == [1, 2, 3]) + #expect(InspectorColumnTargets.deleteMenuSelection( + clicked: 5, fullySelected: IndexSet([1, 2, 3])) == [5]) + #expect(InspectorColumnTargets.deleteMenuSelection( + clicked: 2, fullySelected: IndexSet([2])) == [2]) + #expect(InspectorColumnTargets.deleteMenuSelection( + clicked: 0, fullySelected: IndexSet()) == [0]) + } + + @Test("Delete targets prefer the explicit list and fall back to the selection, filtered to range") + func deleteTargets() { + #expect(InspectorColumnTargets.deleteTargets( + explicit: [3, 1], fullySelected: IndexSet([9]), columnCount: 5) == [1, 3]) + #expect(InspectorColumnTargets.deleteTargets( + explicit: nil, fullySelected: IndexSet([0, 2]), columnCount: 5) == [0, 2]) + #expect(InspectorColumnTargets.deleteTargets( + explicit: [1, 99], fullySelected: IndexSet(), columnCount: 3) == [1]) + } + + @Test("Insert anchor prefers the clicked column, then the selection bound, then the edge") + func insertAnchor() { + #expect(InspectorColumnTargets.insertAnchor( + clicked: 2, fullySelected: IndexSet([0, 4]), columnCount: 5, toRight: true) == 2) + #expect(InspectorColumnTargets.insertAnchor( + clicked: nil, fullySelected: IndexSet([1, 3]), columnCount: 5, toRight: false) == 1) + #expect(InspectorColumnTargets.insertAnchor( + clicked: nil, fullySelected: IndexSet([1, 3]), columnCount: 5, toRight: true) == 3) + #expect(InspectorColumnTargets.insertAnchor( + clicked: nil, fullySelected: IndexSet(), columnCount: 5, toRight: false) == 0) + #expect(InspectorColumnTargets.insertAnchor( + clicked: nil, fullySelected: IndexSet(), columnCount: 5, toRight: true) == 4) + #expect(InspectorColumnTargets.insertAnchor( + clicked: 3, fullySelected: IndexSet(), columnCount: 0, toRight: false) == nil) + } +} diff --git a/docs/features/csv-inspector.mdx b/docs/features/csv-inspector.mdx index 3d281ce51..759da4d4d 100644 --- a/docs/features/csv-inspector.mdx +++ b/docs/features/csv-inspector.mdx @@ -53,7 +53,7 @@ Right-click a column header, or open the toolbar `Columns` menu, to reach the sa - **Change Type ▸** overrides the inferred type as Text, Integer, Real, Boolean, or Date. **Reset to Inferred** drops the override. - **Delete Column** removes the column. It sits last in the menu and is undoable with Cmd+Z. Deleting a column that holds data asks you to confirm first. -Select several columns first (click a header, then Cmd-click others) and the menu reads **Delete Columns**, removing all of them in one undoable step. Insert Column Left / Right, and Delete Column, also appear in the Edit menu, where they act on the selected column. +Cmd-click several column headers to select whole columns, and the menu reads **Delete Columns**, removing all of them in one undoable step. Insert Column Left / Right, and Delete Column, also appear in the Edit menu, where they act on the selected columns. In the toolbar `Columns` menu, **Add Column…** at the top appends a new column at the end, and every column is listed below it with its current type and the same submenu.