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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 16 additions & 9 deletions TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions TablePro/Views/Inspector/InspectorColumnTargets.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
19 changes: 19 additions & 0 deletions TablePro/Views/Inspector/InspectorDeleteConfirmation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down
115 changes: 89 additions & 26 deletions TablePro/Views/Inspector/InspectorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion TablePro/Views/Inspector/InspectorWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
Comment on lines +159 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor selected columns in the toolbar delete submenu

After Cmd-clicking multiple headers, opening the toolbar Columns menu and choosing Delete for one of those columns still deletes only that one: this explicit [index] payload takes precedence over the grid selection in columnDeleteTargets. The header context menu correctly supplies the multi-column target set, but the toolbar is documented as exposing the same submenu, so this route silently bypasses the new multi-column delete behavior.

Useful? React with 👍 / 👎.

for item in items {
submenu.addItem(item)
}
return submenu
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Views/Results/DataGridView+RowActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Views/Results/Selection/GridSelectionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow header selection when the grid has no rows

When an empty CSV is open (or the current filter produces no visible rows), Cmd-clicking headers cannot build a multi-column selection because this returns without updating selection. As a result the advertised Delete Columns path and the Edit-menu column actions cannot operate on selected columns in that state; header selections should be representable even when totalRows is zero.

Useful? React with 👍 / 👎.

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))
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Results/SortableHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ final class SortableHeaderView: NSTableHeaderView {
}

if modifierFlags.contains(.command) && !modifierFlags.contains(.shift) {
coordinator.selectColumn(dataIndex)
coordinator.extendColumnSelection(dataIndex)
return
}

Expand Down
19 changes: 15 additions & 4 deletions TableProTests/Views/InspectorColumnMenuBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(_:)))
Expand All @@ -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)
Expand Down
Loading
Loading