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..548699bdb 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -620,6 +620,21 @@ struct AppMenuCommands: Commands { } .disabled(!keyWindowIsInspector) + Button("Insert Column Left") { + NSApp.sendAction(#selector(InspectorViewController.inspectorInsertColumnLeft(_:)), to: nil, from: nil) + } + .disabled(!keyWindowIsInspector) + + Button("Insert Column Right") { + NSApp.sendAction(#selector(InspectorViewController.inspectorInsertColumnRight(_:)), to: nil, from: nil) + } + .disabled(!keyWindowIsInspector) + + Button("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/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/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..ff1bed7a2 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -271,39 +271,92 @@ 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 - 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, name: name) - self.insertLayoutKey(name, relativeTo: anchorName, after: false) - } + @objc func inspectorInsertColumnLeft(_ sender: Any?) { + performInsertColumn(anchoredBy: sender, toRight: false) } - @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 + @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 + 1, name: name) - self.insertLayoutKey(name, relativeTo: anchorName, after: true) + self.inspectorDocument?.insertColumn(at: insertIndex, name: name) + self.insertLayoutKey(name, relativeTo: anchorName, after: toRight) } } + private func columnInsertAnchor(from sender: Any?, toRight: Bool) -> Int? { + 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?) { - 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 [] } + 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]) { + 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 +369,15 @@ 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] { + InspectorColumnTargets.deleteMenuSelection(clicked: clicked, fullySelected: selectedFullColumns()) + } + fileprivate func rowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] { guard inspectorDocument != nil else { return [] } return InspectorRowMenuBuilder.structureItems(forRow: displayRow) @@ -398,8 +456,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 !selectedFullColumns().isEmpty 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/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/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/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/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..759da4d4d 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. + +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.