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 @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- In the CSV editor, split a column into several by a delimiter or regular expression, and merge a column with the one next to it using a separator. Both are single undo steps. (#1469)
- In the CSV editor, switch the first row between header and data with `Cmd+Shift+H` when auto-detection guesses wrong. Files without a header are no longer saved with a generated header row. (#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
8 changes: 8 additions & 0 deletions Plugins/CSVInspectorPlugin/CSVDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,14 @@ public final class CSVDocument: NSDocument, InspectorDocument {
}
}

public func toggleHeaderRow() {
guard store.hasHeaderRow || store.rowCount > 0 else { return }
performStructuralChange(name: String(localized: "Switch Header Row")) {
store.toggleHeaderRow()
recomputeInferredTypes()
}
}

private func recomputeInferredTypes() {
let sample = store.pageRows(offset: 0, limit: Self.typeInferenceSampleSize)
inferredTypes = CSVTypeInferrer.inferColumns(rows: sample, columnCount: store.columnCount)
Expand Down
29 changes: 28 additions & 1 deletion Plugins/CSVInspectorPlugin/CSVRowStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class CSVRowStore {
let headerRef: RowRef
let logicalRows: [RowRef]
let columnTransforms: [ColumnTransform]
let hasHeaderRow: Bool
}

struct Snapshot: InspectorDataSnapshot {
Expand Down Expand Up @@ -72,6 +73,7 @@ final class CSVRowStore {
let data: Data
private let parser: CSVStreamingParser
private(set) var columnNames: [String]
private(set) var hasHeaderRow: Bool
private var headerRef: RowRef
private var logicalRows: [RowRef]
private var columnTransforms: [ColumnTransform] = []
Expand All @@ -89,6 +91,7 @@ final class CSVRowStore {

var resolvedColumnNames: [String] = []
var resolvedHeaderRef: RowRef = .materialized([])
var resolvedHasHeaderRow = false
if let first = ranges.first {
let headerCells = data.withUnsafeBytes { raw -> [String] in
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] }
Expand All @@ -97,6 +100,7 @@ final class CSVRowStore {
if Self.isLikelyHeader(headerCells) {
resolvedColumnNames = headerCells
resolvedHeaderRef = .original(first)
resolvedHasHeaderRow = true
ranges.removeFirst()
} else {
let synthetic = (0..<headerCells.count).map { "Column \($0 + 1)" }
Expand All @@ -108,6 +112,7 @@ final class CSVRowStore {
self.data = data
self.parser = streamingParser
self.columnNames = resolvedColumnNames
self.hasHeaderRow = resolvedHasHeaderRow
self.headerRef = resolvedHeaderRef
self.logicalRows = ranges.map { .original($0) }
}
Expand Down Expand Up @@ -324,12 +329,33 @@ final class CSVRowStore {
finishStructuralRewrite()
}

func toggleHeaderRow() {
if hasHeaderRow {
let headerCells = columnNames
let synthetic = (0..<columnNames.count).map { "Column \($0 + 1)" }
logicalRows.insert(.materialized(headerCells), at: 0)
columnNames = synthetic
headerRef = .materialized(synthetic)
hasHeaderRow = false
} else {
guard !logicalRows.isEmpty else { return }
let firstCells = cells(forRow: 0)
logicalRows.removeFirst()
columnNames = firstCells
headerRef = .materialized(firstCells)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep promoted header cells in sync with column edits

After promoting a headerless CSV and then adding or removing a column without toggling the header back off, this stores the promoted header as a fixed headerRef while insertColumn/removeColumn update only columnNames and data rows. CSVWriter subsequently writes that stale header source, so, for example, promoting 1,2\n3,4\n, adding column c, and saving produces a two-cell header followed by three-cell data rows; the new column name is lost and the saved CSV has mismatched widths.

Useful? React with 👍 / 👎.

hasHeaderRow = true
}
cache.removeAll()
cacheOrder.removeAll()
}

func captureState() -> StoreState {
StoreState(
columnNames: columnNames,
headerRef: headerRef,
logicalRows: logicalRows,
columnTransforms: columnTransforms
columnTransforms: columnTransforms,
hasHeaderRow: hasHeaderRow
)
}

Expand All @@ -338,6 +364,7 @@ final class CSVRowStore {
headerRef = state.headerRef
logicalRows = state.logicalRows
columnTransforms = state.columnTransforms
hasHeaderRow = state.hasHeaderRow
cache.removeAll()
cacheOrder.removeAll()
}
Expand Down
10 changes: 6 additions & 4 deletions Plugins/CSVInspectorPlugin/CSVWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ struct CSVWriter {
buffer.reserveCapacity(Self.flushThreshold + 4_096)
buffer.append(contentsOf: dialect.bomBytes)

append(store.headerSource, from: store, into: &buffer)
if buffer.count >= Self.flushThreshold {
try handle.write(contentsOf: buffer)
buffer.removeAll(keepingCapacity: true)
if store.hasHeaderRow {
append(store.headerSource, from: store, into: &buffer)
if buffer.count >= Self.flushThreshold {
try handle.write(contentsOf: buffer)
buffer.removeAll(keepingCapacity: true)
}
}

for row in 0..<store.rowCount {
Expand Down
2 changes: 2 additions & 0 deletions Plugins/TableProPluginKit/DocumentInspectorPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ public protocol InspectorDocument: AnyObject {
func renameColumn(at index: Int, to name: String)
func splitColumn(at index: Int, separator: String, isRegex: Bool)
func mergeColumns(at index: Int, separator: String)
func toggleHeaderRow()
func setTypeOverride(_ type: InspectorColumnType?, forColumn index: Int)
var onChange: (() -> Void)? { get set }
}

public extension InspectorDocument {
func splitColumn(at index: Int, separator: String, isRegex: Bool) {}
func mergeColumns(at index: Int, separator: String) {}
func toggleHeaderRow() {}
}
5 changes: 4 additions & 1 deletion TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case addRow
case duplicateRow
case truncateTable
case toggleHeaderRow
case previewFKReference
case saveAsFavorite
case previousPage
Expand Down Expand Up @@ -138,7 +139,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
return .editor
case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson,
.paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow,
.truncateTable, .previewFKReference, .saveAsFavorite, .previousPage,
.truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage,
.nextPage, .firstPage, .lastPage, .refresh, .export, .importData:
return .dataGrid
case .newTab, .closeTab, .reopenClosedTab, .quickSwitcher, .toggleTableBrowser,
Expand Down Expand Up @@ -216,6 +217,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .addRow: return String(localized: "Add Row")
case .duplicateRow: return String(localized: "Duplicate Row")
case .truncateTable: return String(localized: "Truncate Table")
case .toggleHeaderRow: return String(localized: "Switch First Row Between Header/Data")
case .previewFKReference: return String(localized: "Preview FK Reference")
case .saveAsFavorite: return String(localized: "Save as Favorite")
case .toggleTableBrowser: return String(localized: "Toggle Table Browser")
Expand Down Expand Up @@ -433,6 +435,7 @@ struct KeyboardSettings: Codable, Equatable {
.addRow: .character("n", command: true, shift: true),
.duplicateRow: .character("d", command: true, shift: true),
.truncateTable: .special(.delete, option: true),
.toggleHeaderRow: .character("h", command: true, shift: true),
.previewFKReference: .special(.space),
.saveAsFavorite: .character("d", command: true),
.previousPage: .character("[", command: true),
Expand Down
6 changes: 6 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,12 @@ struct AppMenuCommands: Commands {
}
.disabled(!keyWindowIsInspector)

Button("Switch First Row Between Header/Data") {
NSApp.sendAction(#selector(InspectorViewController.inspectorToggleHeaderRow(_:)), to: nil, from: nil)
}
.optionalKeyboardShortcut(shortcut(for: .toggleHeaderRow))
.disabled(!keyWindowIsInspector)

Divider()

// Table operations (work when tables selected in sidebar)
Expand Down
7 changes: 6 additions & 1 deletion TablePro/Views/Inspector/InspectorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
handleDeleteRows(state.selectedRowIndices)
}

@objc func inspectorToggleHeaderRow(_ sender: Any?) {
inspectorDocument?.toggleHeaderRow()
Comment on lines +220 to +221

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 Clear row selection when toggling the header

When a row is selected, this action changes the backing row indices but leaves state.selectedRowIndices intact. For example, with an unfiltered CSV, selecting the first data row and demoting the header inserts a new row at index 0; invoking Delete Selected Rows then deletes the former header row rather than the row that was selected before the toggle. Clear or rebase the selection before refreshing the grid.

Useful? React with 👍 / 👎.

}

@objc func inspectorInsertRowAbove(_ sender: Any?) {
performInsertRow(anchoredBy: sender, below: false)
}
Expand Down Expand Up @@ -588,7 +592,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
#selector(toggleInspectorFilter(_:)), #selector(inspectorAddRow(_:)),
#selector(inspectorInsertRowAbove(_:)), #selector(inspectorInsertRowBelow(_:)),
#selector(inspectorInsertColumnLeft(_:)), #selector(inspectorInsertColumnRight(_:)),
#selector(inspectorSplitColumn(_:)), #selector(inspectorMergeColumns(_:)):
#selector(inspectorSplitColumn(_:)), #selector(inspectorMergeColumns(_:)),
#selector(inspectorToggleHeaderRow(_:)):
return nsDocument != nil
case #selector(inspectorDeleteColumn(_:)):
guard nsDocument != nil else { return false }
Expand Down
66 changes: 66 additions & 0 deletions TableProTests/Plugins/CSVInspectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,52 @@ struct CSVRowStoreTests {
#expect(store.columnNames == ["first", "last"])
#expect(store.cells(forRow: 0) == ["Alice", "Smith"])
}

@Test("toggleHeaderRow demotes the header into a data row")
func toggleHeaderToData() {
let store = makeStore("name,age\nAlice,30\n")
#expect(store.hasHeaderRow)
store.toggleHeaderRow()
#expect(!store.hasHeaderRow)
#expect(store.columnNames == ["Column 1", "Column 2"])
#expect(store.rowCount == 2)
#expect(store.cells(forRow: 0) == ["name", "age"])
#expect(store.cells(forRow: 1) == ["Alice", "30"])
}

@Test("toggleHeaderRow promotes the first data row into the header")
func toggleDataToHeader() {
let store = makeStore("1,2\n3,4\n")
#expect(!store.hasHeaderRow)
store.toggleHeaderRow()
#expect(store.hasHeaderRow)
#expect(store.columnNames == ["1", "2"])
#expect(store.rowCount == 1)
#expect(store.cells(forRow: 0) == ["3", "4"])
}

@Test("toggleHeaderRow twice returns to the original state")
func toggleHeaderRoundTrip() {
let store = makeStore("name,age\nAlice,30\nBob,25\n")
store.toggleHeaderRow()
store.toggleHeaderRow()
#expect(store.hasHeaderRow)
#expect(store.columnNames == ["name", "age"])
#expect(store.rowCount == 2)
#expect(store.cells(forRow: 0) == ["Alice", "30"])
}

@Test("Demoting after a column insert keeps the header row the full width")
func toggleHeaderAfterColumnInsert() {
let store = makeStore("1,2\n3,4\n")
store.toggleHeaderRow()
store.insertColumn(at: 2, name: "c")
store.toggleHeaderRow()
#expect(store.columnCount == 3)
#expect(store.columnNames == ["Column 1", "Column 2", "Column 3"])
#expect(store.cells(forRow: 0) == ["1", "2", "c"])
#expect(store.cells(forRow: 1).count == 3)
}
}

@Suite("CSVWriter round-trip")
Expand Down Expand Up @@ -426,4 +472,24 @@ struct CSVWriterRoundTripTests {
let written = try Data(contentsOf: outURL)
#expect(written.prefix(3) == Data([0xEF, 0xBB, 0xBF]))
}

@Test("A headerless file is written without a synthetic header row")
func roundTripHeaderless() throws {
let source = "1,2\n3,4\n"
let url = tempURL()
try source.data(using: .utf8)!.write(to: url)
defer { try? FileManager.default.removeItem(at: url) }

let data = try Data(contentsOf: url, options: .mappedIfSafe)
let dialect = CSVDialect.detect(from: data)
let store = CSVRowStore(data: data, dialect: dialect)
#expect(!store.hasHeaderRow)

let outURL = tempURL()
defer { try? FileManager.default.removeItem(at: outURL) }
try CSVWriter(dialect: dialect).write(store, to: outURL)

let written = try Data(contentsOf: outURL)
#expect(written == data)
}
}
4 changes: 4 additions & 0 deletions docs/features/csv-inspector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Cmd-click several column headers to select whole columns, and the menu reads **D

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.

## Header row

TablePro guesses whether the first row is a header when it opens the file. When it guesses wrong, choose **Edit > Switch First Row Between Header/Data** (`Cmd+Shift+H`) to flip it. Turning the header off moves the first row down into the data and names the columns `Column 1`, `Column 2`, and so on. Turning it back on promotes the first data row to the header. The change is undoable, and Save writes the file with or without a header row to match.

## Filter and sort

<Frame caption="Filter bar with multiple conditions (AND) above the data grid">
Expand Down
1 change: 1 addition & 0 deletions docs/features/keyboard-shortcuts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ With the panel open (`Cmd+Y`): `Return` loads the selected entry in the editor,
| Save | `Cmd+S` |
| Save As | `Cmd+Shift+S` |
| Toggle filter bar | `Cmd+F` |
| Switch first row between header and data | `Cmd+Shift+H` |
| Copy selected rows as TSV | `Cmd+C` |
| Paste TSV as new rows | `Cmd+V` |
| Add secondary sort column | `Shift+Click` column header |
Expand Down
Loading