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 @@ -11,6 +11,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)
- 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
33 changes: 24 additions & 9 deletions Plugins/CSVInspectorPlugin/CSVDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,60 +187,71 @@ public final class CSVDocument: NSDocument, InspectorDocument {
}

public func appendRow() {
let name = String(localized: "Add Row")
let index = store.appendRow(values: [])
registerUndo { document in
document.removeRow(at: index, suppressUndo: false)
document.removeRow(at: index, suppressUndo: false, actionName: name)
}
setUndoActionName(name)
onChange?()
}

public func insertRow(at index: Int) {
let name = String(localized: "Insert Row")
store.insertRow([], at: index)
registerUndo { document in
document.removeRow(at: index, suppressUndo: false)
document.removeRow(at: index, suppressUndo: false, actionName: name)
}
setUndoActionName(name)
onChange?()
}

public func removeRow(at index: Int) {
removeRow(at: index, suppressUndo: false)
removeRow(at: index, suppressUndo: false, actionName: String(localized: "Delete Row"))
}

private func removeRow(at index: Int, suppressUndo: Bool) {
private func removeRow(at index: Int, suppressUndo: Bool, actionName: String) {
guard let removed = store.removeRow(at: index) else { return }
if !suppressUndo {
registerUndo { document in
document.reinsertRow(removed, at: index)
document.reinsertRow(removed, at: index, actionName: actionName)
}
setUndoActionName(actionName)

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 Preserve Paste name when generating redo

The initial undo label is now restored after the paste loop, but undoing a multi-row paste executes each appendRow undo handler, which calls this method and changes the redo group's action name to Add Row. Consequently, after Undo Paste, the Edit menu presents the next action as “Redo Add Row” rather than “Redo Paste”; the enclosing paste group needs to retain or restore its name while its inverse actions are registered.

Useful? React with 👍 / 👎.

}
onChange?()
}

private func reinsertRow(_ values: [String], at index: Int) {
private func reinsertRow(_ values: [String], at index: Int, actionName: String) {
store.insertRow(values, at: index)
registerUndo { document in
document.removeRow(at: index, suppressUndo: false)
document.removeRow(at: index, suppressUndo: false, actionName: actionName)
}
setUndoActionName(actionName)
onChange?()
}

public func removeRows(at indices: IndexSet) {
let removed = store.removeRows(at: indices)
guard !removed.isEmpty else { return }
let name = removed.count == 1
? String(localized: "Delete Row")
: String(localized: "Delete Rows")
registerUndo { document in
document.reinsertRows(removed)
document.reinsertRows(removed, actionName: name)
}
setUndoActionName(name)
onChange?()
}

private func reinsertRows(_ rows: [(index: Int, cells: [String])]) {
private func reinsertRows(_ rows: [(index: Int, cells: [String])], actionName: String) {
for entry in rows.sorted(by: { $0.index < $1.index }) {
store.insertRow(entry.cells, at: entry.index)
}
let originalIndices = IndexSet(rows.map(\.index))
registerUndo { document in
document.removeRows(at: originalIndices)
}
setUndoActionName(actionName)
onChange?()
}

Expand Down Expand Up @@ -318,6 +329,10 @@ public final class CSVDocument: NSDocument, InspectorDocument {
undoManager?.registerUndo(withTarget: self, handler: action)
}

private func setUndoActionName(_ name: String) {
undoManager?.setActionName(name)
}

private func shiftTypeOverrides(insertingAt index: Int) {
typeOverrides = typeOverrides.reduce(into: [Int: InspectorColumnType]()) { result, entry in
result[entry.key >= index ? entry.key + 1 : entry.key] = entry.value
Expand Down
17 changes: 17 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,23 @@ struct AppMenuCommands: Commands {

Divider()

Button("Insert Row Above") {
NSApp.sendAction(#selector(InspectorViewController.inspectorInsertRowAbove(_:)), to: nil, from: nil)
}
.disabled(!keyWindowIsInspector)

Button("Insert Row Below") {
NSApp.sendAction(#selector(InspectorViewController.inspectorInsertRowBelow(_:)), to: nil, from: nil)
}
.disabled(!keyWindowIsInspector)

Button("Delete Rows") {
NSApp.sendAction(#selector(InspectorViewController.inspectorDeleteSelectedRows(_:)), to: nil, from: nil)
}
.disabled(!keyWindowIsInspector)

Divider()

// Table operations (work when tables selected in sidebar)
Button("Truncate Table") {
actions?.truncateTables()
Expand Down
52 changes: 52 additions & 0 deletions TablePro/Views/Inspector/InspectorDeleteConfirmation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// InspectorDeleteConfirmation.swift
// TablePro
//

import AppKit

@MainActor
enum InspectorDeleteConfirmation {
static func rowsContainData(_ rowsCells: [[String]]) -> Bool {
rowsCells.contains { cells in cells.contains { !$0.isEmpty } }
}

static func rowDeleteTitle(count: Int) -> String {
count == 1
? String(localized: "Delete this row?")
: String(format: String(localized: "Delete %lld rows?"), count)
}

static func confirmDeleteRowsIfNeeded(
rowsCells: [[String]],
window: NSWindow?,
proceed: @escaping @MainActor () -> Void
) {
guard rowsContainData(rowsCells) else {
proceed()
return
}
present(messageText: rowDeleteTitle(count: rowsCells.count), window: window, proceed: proceed)
}

private static func present(
messageText: String,
window: NSWindow?,
proceed: @escaping @MainActor () -> Void
) {
guard let window else {
proceed()
return
}
let alert = NSAlert()
alert.messageText = messageText
alert.alertStyle = .warning
let deleteButton = alert.addButton(withTitle: String(localized: "Delete"))
deleteButton.hasDestructiveAction = true
alert.addButton(withTitle: String(localized: "Cancel"))
alert.beginSheetModal(for: window) { response in
guard response == .alertFirstButtonReturn else { return }
proceed()
}
}
}
17 changes: 17 additions & 0 deletions TablePro/Views/Inspector/InspectorRowInsertion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// InspectorRowInsertion.swift
// TablePro
//

import Foundation

enum InspectorRowInsertion {
static func storeIndex(anchorDisplayRow: Int?, below: Bool, displayToStore: [Int], rowCount: Int) -> Int {
guard let displayRow = anchorDisplayRow,
displayRow >= 0, displayRow < displayToStore.count else {
return below ? rowCount : 0
}
let storeRow = displayToStore[displayRow]
return below ? storeRow + 1 : storeRow
}
}
31 changes: 31 additions & 0 deletions TablePro/Views/Inspector/InspectorRowMenuBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// InspectorRowMenuBuilder.swift
// TablePro
//

import AppKit

@MainActor
enum InspectorRowMenuBuilder {
static func structureItems(forRow displayRow: Int) -> [NSMenuItem] {
[
actionItem(
title: String(localized: "Insert Row Above"),
action: #selector(InspectorViewController.inspectorInsertRowAbove(_:)),
row: displayRow
),
actionItem(
title: String(localized: "Insert Row Below"),
action: #selector(InspectorViewController.inspectorInsertRowBelow(_:)),
row: displayRow
),
]
}

private static func actionItem(title: String, action: Selector, row: Int) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.tag = row
item.target = nil
return item
}
}
60 changes: 56 additions & 4 deletions TablePro/Views/Inspector/InspectorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,14 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation

let undoManager = nsDocument?.undoManager
undoManager?.beginUndoGrouping()
undoManager?.setActionName(String(localized: "Paste"))
for row in rows {
let newRowIndex = inspectorDocument.rowCount
inspectorDocument.appendRow()
for (column, value) in row.enumerated() {
inspectorDocument.setCell(row: newRowIndex, column: column, to: value)
}
}
undoManager?.setActionName(String(localized: "Paste"))
undoManager?.endUndoGrouping()
}

Expand All @@ -162,8 +162,19 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
return displayToStore[index]
}
guard !storeIndices.isEmpty else { return }
pendingPostRefresh = .selectClamped(displayRow: sortedDisplay.first ?? 0)
inspectorDocument.removeRows(at: IndexSet(storeIndices))
let columnCount = inspectorDocument.columnNames.count
let rowsCells = storeIndices.map { storeRow in
(0..<columnCount).map { inspectorDocument.value(row: storeRow, column: $0) }
}
let firstDisplayRow = sortedDisplay.first ?? 0
InspectorDeleteConfirmation.confirmDeleteRowsIfNeeded(
rowsCells: rowsCells,
window: view.window
) { [weak self] in
guard let self else { return }
self.pendingPostRefresh = .selectClamped(displayRow: firstDisplayRow)
self.inspectorDocument?.removeRows(at: IndexSet(storeIndices))
}
}

fileprivate func handleSortChanged(_ newState: SortState) {
Expand Down Expand Up @@ -206,6 +217,37 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
handleDeleteRows(state.selectedRowIndices)
}

@objc func inspectorInsertRowAbove(_ sender: Any?) {
performInsertRow(anchoredBy: sender, below: false)
}

@objc func inspectorInsertRowBelow(_ sender: Any?) {
performInsertRow(anchoredBy: sender, below: true)
}

private func performInsertRow(anchoredBy sender: Any?, below: Bool) {
guard let inspectorDocument else { return }
let storeIndex = insertStoreIndex(anchoredBy: sender, below: below)
pendingPostRefresh = .focusStoreIndex(storeIndex)
inspectorDocument.insertRow(at: storeIndex)
}

private func insertStoreIndex(anchoredBy sender: Any?, below: Bool) -> Int {
let anchorDisplayRow: Int? = if let item = sender as? NSMenuItem {
item.tag
} else if below {
state.selectedRowIndices.max()
} else {
state.selectedRowIndices.min()
}
return InspectorRowInsertion.storeIndex(
anchorDisplayRow: anchorDisplayRow,
below: below,
displayToStore: displayToStore,
rowCount: inspectorDocument?.rowCount ?? 0
)
}

@objc func inspectorAddColumn(_ sender: Any?) {
promptForColumnName(title: String(localized: "Add Column"), initial: "") { [weak self] name in
guard let self, let name, !name.isEmpty else { return }
Expand Down Expand Up @@ -278,6 +320,11 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
)
}

fileprivate func rowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] {
guard inspectorDocument != nil else { return [] }
return InspectorRowMenuBuilder.structureItems(forRow: displayRow)
}

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 @@ -350,7 +397,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
case #selector(redo(_:)):
return nsDocument?.undoManager?.canRedo ?? false
case #selector(saveDocument(_:)), #selector(saveDocumentAs(_:)),
#selector(toggleInspectorFilter(_:)), #selector(inspectorAddRow(_:)):
#selector(toggleInspectorFilter(_:)), #selector(inspectorAddRow(_:)),
#selector(inspectorInsertRowAbove(_:)), #selector(inspectorInsertRowBelow(_:)):
return nsDocument != nil
Comment on lines 399 to 402

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 Require a selected row for Edit-menu insertion

The Edit-menu insert commands validate whenever an inspector document exists, even with no selected row. In that state insertStoreIndex falls back to index 0 for Above and rowCount for Below, so invoking commands documented to act on the selected row silently inserts at the beginning or end instead. Disable these actions unless a row is selected (or make the empty-table behavior explicit).

Useful? React with 👍 / 👎.

case #selector(inspectorDeleteSelectedRows(_:)):
return !state.selectedRowIndices.isEmpty
Expand Down Expand Up @@ -701,6 +749,10 @@ private final class InspectorGridDelegate: DataGridViewDelegate {
owner?.columnStructureMenuItems(forColumn: dataColumnIndex) ?? []
}

func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] {
owner?.rowStructureMenuItems(forRow: displayRow) ?? []
}

func dataGridUndo() {
owner?.handleUndo()
}
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Views/Results/DataGridRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,14 @@ class DataGridRowView: NSTableRowView {
}

if coordinator.isEditable {
let rowStructureItems = coordinator.delegate?.dataGridRowStructureMenuItems(forRow: rowIndex) ?? []
if !rowStructureItems.isEmpty {
menu.addItem(NSMenuItem.separator())
for item in rowStructureItems {
menu.addItem(item)
}
}

let duplicateItem = NSMenuItem(
title: String(localized: "Duplicate"), action: #selector(duplicateRow), keyEquivalent: "")
duplicateItem.target = self
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 @@ -28,6 +28,7 @@ protocol DataGridViewDelegate: AnyObject {
func dataGridHideColumn(_ columnName: String)
func dataGridShowAllColumns()
func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem]
func dataGridRowStructureMenuItems(forRow displayRow: 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 @@ -57,6 +58,7 @@ extension DataGridViewDelegate {
func dataGridHideColumn(_ columnName: String) {}
func dataGridShowAllColumns() {}
func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] { [] }
func dataGridRowStructureMenuItems(forRow displayRow: 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
1 change: 1 addition & 0 deletions TableProTests/PluginTestSources/CSVDocument.swift
Loading
Loading