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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- In the CSV editor, right-click a column header to rename, insert, delete, or change the type of that column. (#1913)

### Fixed

- SSH tunnels using the None auth method now work on iPhone and iPad, not just the Mac. A connection synced from the Mac with None auth no longer fails to connect. (#1912)
Expand Down
86 changes: 86 additions & 0 deletions TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// InspectorColumnMenuBuilder.swift
// TablePro
//

import AppKit
import TableProPluginKit

@MainActor
enum InspectorColumnMenuBuilder {
static func structureItems(forColumn index: Int, currentType: InspectorColumnType) -> [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(_:)),
column: index
)
let insertAfter = actionItem(
title: String(localized: "Insert Column After"),
action: #selector(InspectorViewController.inspectorInsertColumnAfter(_:)),
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"),
action: #selector(InspectorViewController.inspectorDeleteColumn(_:)),
column: index
)
return [rename, insertBefore, insertAfter, changeType, .separator(), delete]
}

static func typeSubmenu(forColumn index: Int, currentType: InspectorColumnType) -> NSMenu {
let submenu = NSMenu()
for type in InspectorColumnType.allCases {
let item = actionItem(
title: typeLabel(type),
action: #selector(InspectorViewController.inspectorSetColumnType(_:)),
column: index
)
item.representedObject = ColumnTypeAssignment(column: index, type: type)
item.state = (type == currentType) ? .on : .off
submenu.addItem(item)
}
submenu.addItem(.separator())
let reset = actionItem(
title: String(localized: "Reset to Inferred"),
action: #selector(InspectorViewController.inspectorSetColumnType(_:)),
column: index
)
reset.representedObject = ColumnTypeAssignment(column: index, type: nil)
submenu.addItem(reset)
return submenu
}

static func typeSymbol(_ type: InspectorColumnType) -> String {
switch type {
case .text: return "textformat"
case .integer: return "number"
case .real: return "number.square"
case .boolean: return "checkmark.square"
case .date: return "calendar"
}
}

static func typeLabel(_ type: InspectorColumnType) -> String {
switch type {
case .text: return String(localized: "Text")
case .integer: return String(localized: "Integer")
case .real: return String(localized: "Real")
case .boolean: return String(localized: "Boolean")
case .date: return String(localized: "Date")
}
}

private static func actionItem(title: String, action: Selector, column: Int) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.tag = column
item.target = nil
return item
}
}
12 changes: 12 additions & 0 deletions TablePro/Views/Inspector/InspectorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
inspectorDocument?.setTypeOverride(assignment.type, forColumn: assignment.column)
}

fileprivate func columnStructureMenuItems(forColumn index: Int) -> [NSMenuItem] {
guard let inspector = inspectorDocument, index >= 0, index < inspector.columnNames.count else { return [] }
return InspectorColumnMenuBuilder.structureItems(
forColumn: index,
currentType: inspector.displayedType(forColumn: index)
)
}

private func renameLayoutKey(from oldName: String, to newName: String) {
if state.columnLayout.columnOrder != nil {
state.columnLayout.columnOrder = state.columnLayout.columnOrder?.map { $0 == oldName ? newName : $0 }
Expand Down Expand Up @@ -689,6 +697,10 @@ private final class InspectorGridDelegate: DataGridViewDelegate {
owner?.handleSortChanged(state)
}

func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] {
owner?.columnStructureMenuItems(forColumn: dataColumnIndex) ?? []
}

func dataGridUndo() {
owner?.handleUndo()
}
Expand Down
90 changes: 7 additions & 83 deletions TablePro/Views/Inspector/InspectorWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,99 +144,23 @@ final class InspectorWindowController: NSWindowController, NSWindowDelegate, NST
for (index, name) in columns.enumerated() {
let item = NSMenuItem(title: name, action: nil, keyEquivalent: "")
let type = inspector.displayedType(forColumn: index)
item.image = NSImage(systemSymbolName: Self.typeSymbol(type), accessibilityDescription: nil)
item.submenu = makeColumnSubmenu(columnIndex: index, currentType: type)
item.image = NSImage(
systemSymbolName: InspectorColumnMenuBuilder.typeSymbol(type),
accessibilityDescription: nil
)
item.submenu = columnSubmenu(forColumn: index, currentType: type)
menu.addItem(item)
}
}

private func makeColumnSubmenu(columnIndex: Int, currentType: InspectorColumnType) -> NSMenu {
let submenu = NSMenu()
let rename = NSMenuItem(
title: String(localized: "Rename…"),
action: #selector(InspectorViewController.inspectorRenameColumn(_:)),
keyEquivalent: ""
)
rename.tag = columnIndex
submenu.addItem(rename)

let insertBefore = NSMenuItem(
title: String(localized: "Insert Column Before"),
action: #selector(InspectorViewController.inspectorInsertColumnBefore(_:)),
keyEquivalent: ""
)
insertBefore.tag = columnIndex
submenu.addItem(insertBefore)

let insertAfter = NSMenuItem(
title: String(localized: "Insert Column After"),
action: #selector(InspectorViewController.inspectorInsertColumnAfter(_:)),
keyEquivalent: ""
)
insertAfter.tag = columnIndex
submenu.addItem(insertAfter)

submenu.addItem(.separator())

let typeItem = NSMenuItem(title: String(localized: "Type"), action: nil, keyEquivalent: "")
typeItem.submenu = makeTypeSubmenu(columnIndex: columnIndex, currentType: currentType)
submenu.addItem(typeItem)

submenu.addItem(.separator())

let delete = NSMenuItem(
title: String(localized: "Delete"),
action: #selector(InspectorViewController.inspectorDeleteColumn(_:)),
keyEquivalent: ""
)
delete.tag = columnIndex
submenu.addItem(delete)
return submenu
}

private func makeTypeSubmenu(columnIndex: Int, currentType: InspectorColumnType) -> NSMenu {
private func columnSubmenu(forColumn index: Int, currentType: InspectorColumnType) -> NSMenu {
let submenu = NSMenu()
for type in InspectorColumnType.allCases {
let item = NSMenuItem(
title: Self.typeLabel(type),
action: #selector(InspectorViewController.inspectorSetColumnType(_:)),
keyEquivalent: ""
)
item.representedObject = ColumnTypeAssignment(column: columnIndex, type: type)
item.state = (type == currentType) ? .on : .off
for item in InspectorColumnMenuBuilder.structureItems(forColumn: index, currentType: currentType) {
submenu.addItem(item)
}
submenu.addItem(.separator())
let reset = NSMenuItem(
title: String(localized: "Reset to Inferred"),
action: #selector(InspectorViewController.inspectorSetColumnType(_:)),
keyEquivalent: ""
)
reset.representedObject = ColumnTypeAssignment(column: columnIndex, type: nil)
submenu.addItem(reset)
return submenu
}

private static func typeSymbol(_ type: InspectorColumnType) -> String {
switch type {
case .text: return "textformat"
case .integer: return "number"
case .real: return "number.square"
case .boolean: return "checkmark.square"
case .date: return "calendar"
}
}

private static func typeLabel(_ type: InspectorColumnType) -> String {
switch type {
case .text: return String(localized: "Text")
case .integer: return String(localized: "Integer")
case .real: return String(localized: "Real")
case .boolean: return String(localized: "Boolean")
case .date: return String(localized: "Date")
}
}

private func makeItem(
identifier: NSToolbarItem.Identifier,
label: String,
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Results/DataGridViewDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ protocol DataGridViewDelegate: AnyObject {
func dataGridCanClearResults() -> Bool
func dataGridHideColumn(_ columnName: String)
func dataGridShowAllColumns()
func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem]
func dataGridVisualState(forRow row: Int) -> RowVisualState?
func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView?
func dataGridEmptySpaceMenu() -> NSMenu?
Expand Down Expand Up @@ -55,6 +56,7 @@ extension DataGridViewDelegate {
func dataGridCanClearResults() -> Bool { false }
func dataGridHideColumn(_ columnName: String) {}
func dataGridShowAllColumns() {}
func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] { [] }
func dataGridVisualState(forRow row: Int) -> RowVisualState? { nil }
func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? { nil }
func dataGridEmptySpaceMenu() -> NSMenu? { nil }
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Views/Results/Extensions/DataGridView+Sort.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,18 @@ extension TableViewCoordinator {
showAllItem.target = self
menu.addItem(showAllItem)
}

appendColumnStructureItems(to: menu, forColumnIdentifier: column.identifier)
}

private func appendColumnStructureItems(to menu: NSMenu, forColumnIdentifier identifier: NSUserInterfaceItemIdentifier) {
guard let dataColumnIndex = dataColumnIndex(from: identifier),
let structureItems = delegate?.dataGridColumnStructureMenuItems(forColumn: dataColumnIndex),
!structureItems.isEmpty else { return }
menu.addItem(NSMenuItem.separator())
for item in structureItems {
menu.addItem(item)
}
}

@objc func sortAscending(_ sender: NSMenuItem) {
Expand Down
58 changes: 58 additions & 0 deletions TableProTests/Views/InspectorColumnMenuBuilderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// InspectorColumnMenuBuilderTests.swift
// TableProTests
//

import AppKit
@testable import TablePro
import TableProPluginKit
import Testing

@MainActor
@Suite("InspectorColumnMenuBuilder")
struct InspectorColumnMenuBuilderTests {
@Test("Structure items route each action to the clicked column")
func structureItemsWiring() {
let items = InspectorColumnMenuBuilder.structureItems(forColumn: 3, currentType: .text)

#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[3].submenu != nil)
#expect(items[4].isSeparatorItem)
#expect(items[5].action == #selector(InspectorViewController.inspectorDeleteColumn(_:)))

for index in [0, 1, 2, 5] {
#expect(items[index].tag == 3)
#expect(items[index].target == nil)
}
}

@Test("Delete Column is the last item, in its own trailing group")
func deleteIsLastAfterSeparator() {
let items = InspectorColumnMenuBuilder.structureItems(forColumn: 0, currentType: .integer)

#expect(items.last?.action == #selector(InspectorViewController.inspectorDeleteColumn(_:)))
#expect(items[items.count - 2].isSeparatorItem)
}

@Test("Type submenu checks the current type and offers Reset to Inferred")
func typeSubmenuState() {
let submenu = InspectorColumnMenuBuilder.typeSubmenu(forColumn: 1, currentType: .integer)

let typeItems = submenu.items.filter { !$0.isSeparatorItem }
#expect(typeItems.count == InspectorColumnType.allCases.count + 1)

let checked = submenu.items.filter { $0.state == .on }
#expect(checked.count == 1)
let checkedAssignment = checked.first?.representedObject as? ColumnTypeAssignment
#expect(checkedAssignment?.type == .integer)

let reset = submenu.items.last
#expect(reset?.action == #selector(InspectorViewController.inspectorSetColumnType(_:)))
let resetAssignment = reset?.representedObject as? ColumnTypeAssignment
#expect(resetAssignment != nil)
#expect(resetAssignment?.type == nil)
}
}
12 changes: 7 additions & 5 deletions docs/features/csv-inspector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Rows load in pages. The page size comes from the Default page size setting in [S

- Double-click a cell (or press Return on it) to edit. Return commits and closes the editor. Tab commits and moves to the next cell, wrapping to the next row.
- The toolbar `Add Row` button appends a new row, scrolls to it, and selects it.
- Select rows and press `Delete` (or the toolbar `Delete` button) to remove them. The next row takes selection so arrow keys keep working from where you were.
- Select rows and press `Delete` (the toolbar `Delete` button, or right-click a row and choose Delete) to remove them. The next row takes selection so arrow keys keep working from where you were.
- Cmd+Z and Cmd+Shift+Z undo and redo every change. A bulk delete or a paste is a single undo step.

## Column operations
Expand All @@ -44,12 +44,14 @@ Rows load in pages. The page size comes from the Default page size setting in [S
<img className="hidden dark:block" src="/images/csv-inspector-columns-menu-dark.png" alt="CSV Inspector Columns Menu" />
</Frame>

The toolbar `Columns` button opens a menu. **Add Column…** at the top appends a new column at the end. Below it, every column is listed with its current type, each with a submenu:
Right-click a column header, or open the toolbar `Columns` menu, to reach the same per-column actions:

- **Rename…** prompts for a new name; the column stays in place.
- **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.
- **Type ▸** overrides the inferred type as Text, Integer, Real, Boolean, or Date. **Reset to Inferred** drops the override.
- **Delete** removes the column (undoable).
- **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.

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.

## Filter and sort

Expand Down
Loading