Skip to content

Commit fa398e6

Browse files
authored
feat(inspector): switch the CSV first row between header and data (#1942)
* feat(inspector): switch the CSV first row between header and data * fix(inspector): fix CSV header demote width, guard the no-op toggle, wire Cmd+Shift+H through shortcuts
1 parent 927fd49 commit fa398e6

11 files changed

Lines changed: 132 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- 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)
1515
- 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)
1616
- 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)
17+
- 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)
1718
- 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)
1819

1920
### Fixed

Plugins/CSVInspectorPlugin/CSVDocument.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,14 @@ public final class CSVDocument: NSDocument, InspectorDocument {
348348
}
349349
}
350350

351+
public func toggleHeaderRow() {
352+
guard store.hasHeaderRow || store.rowCount > 0 else { return }
353+
performStructuralChange(name: String(localized: "Switch Header Row")) {
354+
store.toggleHeaderRow()
355+
recomputeInferredTypes()
356+
}
357+
}
358+
351359
private func recomputeInferredTypes() {
352360
let sample = store.pageRows(offset: 0, limit: Self.typeInferenceSampleSize)
353361
inferredTypes = CSVTypeInferrer.inferColumns(rows: sample, columnCount: store.columnCount)

Plugins/CSVInspectorPlugin/CSVRowStore.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ final class CSVRowStore {
2727
let headerRef: RowRef
2828
let logicalRows: [RowRef]
2929
let columnTransforms: [ColumnTransform]
30+
let hasHeaderRow: Bool
3031
}
3132

3233
struct Snapshot: InspectorDataSnapshot {
@@ -72,6 +73,7 @@ final class CSVRowStore {
7273
let data: Data
7374
private let parser: CSVStreamingParser
7475
private(set) var columnNames: [String]
76+
private(set) var hasHeaderRow: Bool
7577
private var headerRef: RowRef
7678
private var logicalRows: [RowRef]
7779
private var columnTransforms: [ColumnTransform] = []
@@ -89,6 +91,7 @@ final class CSVRowStore {
8991

9092
var resolvedColumnNames: [String] = []
9193
var resolvedHeaderRef: RowRef = .materialized([])
94+
var resolvedHasHeaderRow = false
9295
if let first = ranges.first {
9396
let headerCells = data.withUnsafeBytes { raw -> [String] in
9497
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] }
@@ -97,6 +100,7 @@ final class CSVRowStore {
97100
if Self.isLikelyHeader(headerCells) {
98101
resolvedColumnNames = headerCells
99102
resolvedHeaderRef = .original(first)
103+
resolvedHasHeaderRow = true
100104
ranges.removeFirst()
101105
} else {
102106
let synthetic = (0..<headerCells.count).map { "Column \($0 + 1)" }
@@ -108,6 +112,7 @@ final class CSVRowStore {
108112
self.data = data
109113
self.parser = streamingParser
110114
self.columnNames = resolvedColumnNames
115+
self.hasHeaderRow = resolvedHasHeaderRow
111116
self.headerRef = resolvedHeaderRef
112117
self.logicalRows = ranges.map { .original($0) }
113118
}
@@ -324,12 +329,33 @@ final class CSVRowStore {
324329
finishStructuralRewrite()
325330
}
326331

332+
func toggleHeaderRow() {
333+
if hasHeaderRow {
334+
let headerCells = columnNames
335+
let synthetic = (0..<columnNames.count).map { "Column \($0 + 1)" }
336+
logicalRows.insert(.materialized(headerCells), at: 0)
337+
columnNames = synthetic
338+
headerRef = .materialized(synthetic)
339+
hasHeaderRow = false
340+
} else {
341+
guard !logicalRows.isEmpty else { return }
342+
let firstCells = cells(forRow: 0)
343+
logicalRows.removeFirst()
344+
columnNames = firstCells
345+
headerRef = .materialized(firstCells)
346+
hasHeaderRow = true
347+
}
348+
cache.removeAll()
349+
cacheOrder.removeAll()
350+
}
351+
327352
func captureState() -> StoreState {
328353
StoreState(
329354
columnNames: columnNames,
330355
headerRef: headerRef,
331356
logicalRows: logicalRows,
332-
columnTransforms: columnTransforms
357+
columnTransforms: columnTransforms,
358+
hasHeaderRow: hasHeaderRow
333359
)
334360
}
335361

@@ -338,6 +364,7 @@ final class CSVRowStore {
338364
headerRef = state.headerRef
339365
logicalRows = state.logicalRows
340366
columnTransforms = state.columnTransforms
367+
hasHeaderRow = state.hasHeaderRow
341368
cache.removeAll()
342369
cacheOrder.removeAll()
343370
}

Plugins/CSVInspectorPlugin/CSVWriter.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ struct CSVWriter {
3838
buffer.reserveCapacity(Self.flushThreshold + 4_096)
3939
buffer.append(contentsOf: dialect.bomBytes)
4040

41-
append(store.headerSource, from: store, into: &buffer)
42-
if buffer.count >= Self.flushThreshold {
43-
try handle.write(contentsOf: buffer)
44-
buffer.removeAll(keepingCapacity: true)
41+
if store.hasHeaderRow {
42+
append(store.headerSource, from: store, into: &buffer)
43+
if buffer.count >= Self.flushThreshold {
44+
try handle.write(contentsOf: buffer)
45+
buffer.removeAll(keepingCapacity: true)
46+
}
4547
}
4648

4749
for row in 0..<store.rowCount {

Plugins/TableProPluginKit/DocumentInspectorPlugin.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,13 @@ public protocol InspectorDocument: AnyObject {
6262
func renameColumn(at index: Int, to name: String)
6363
func splitColumn(at index: Int, separator: String, isRegex: Bool)
6464
func mergeColumns(at index: Int, separator: String)
65+
func toggleHeaderRow()
6566
func setTypeOverride(_ type: InspectorColumnType?, forColumn index: Int)
6667
var onChange: (() -> Void)? { get set }
6768
}
6869

6970
public extension InspectorDocument {
7071
func splitColumn(at index: Int, separator: String, isRegex: Bool) {}
7172
func mergeColumns(at index: Int, separator: String) {}
73+
func toggleHeaderRow() {}
7274
}

TablePro/Models/UI/KeyboardShortcutModels.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
9696
case addRow
9797
case duplicateRow
9898
case truncateTable
99+
case toggleHeaderRow
99100
case previewFKReference
100101
case saveAsFavorite
101102
case previousPage
@@ -138,7 +139,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
138139
return .editor
139140
case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson,
140141
.paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow,
141-
.truncateTable, .previewFKReference, .saveAsFavorite, .previousPage,
142+
.truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage,
142143
.nextPage, .firstPage, .lastPage, .refresh, .export, .importData:
143144
return .dataGrid
144145
case .newTab, .closeTab, .reopenClosedTab, .quickSwitcher, .toggleTableBrowser,
@@ -216,6 +217,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
216217
case .addRow: return String(localized: "Add Row")
217218
case .duplicateRow: return String(localized: "Duplicate Row")
218219
case .truncateTable: return String(localized: "Truncate Table")
220+
case .toggleHeaderRow: return String(localized: "Switch First Row Between Header/Data")
219221
case .previewFKReference: return String(localized: "Preview FK Reference")
220222
case .saveAsFavorite: return String(localized: "Save as Favorite")
221223
case .toggleTableBrowser: return String(localized: "Toggle Table Browser")
@@ -433,6 +435,7 @@ struct KeyboardSettings: Codable, Equatable {
433435
.addRow: .character("n", command: true, shift: true),
434436
.duplicateRow: .character("d", command: true, shift: true),
435437
.truncateTable: .special(.delete, option: true),
438+
.toggleHeaderRow: .character("h", command: true, shift: true),
436439
.previewFKReference: .special(.space),
437440
.saveAsFavorite: .character("d", command: true),
438441
.previousPage: .character("[", command: true),

TablePro/TableProApp.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,12 @@ struct AppMenuCommands: Commands {
645645
}
646646
.disabled(!keyWindowIsInspector)
647647

648+
Button("Switch First Row Between Header/Data") {
649+
NSApp.sendAction(#selector(InspectorViewController.inspectorToggleHeaderRow(_:)), to: nil, from: nil)
650+
}
651+
.optionalKeyboardShortcut(shortcut(for: .toggleHeaderRow))
652+
.disabled(!keyWindowIsInspector)
653+
648654
Divider()
649655

650656
// Table operations (work when tables selected in sidebar)

TablePro/Views/Inspector/InspectorViewController.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,10 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
217217
handleDeleteRows(state.selectedRowIndices)
218218
}
219219

220+
@objc func inspectorToggleHeaderRow(_ sender: Any?) {
221+
inspectorDocument?.toggleHeaderRow()
222+
}
223+
220224
@objc func inspectorInsertRowAbove(_ sender: Any?) {
221225
performInsertRow(anchoredBy: sender, below: false)
222226
}
@@ -588,7 +592,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
588592
#selector(toggleInspectorFilter(_:)), #selector(inspectorAddRow(_:)),
589593
#selector(inspectorInsertRowAbove(_:)), #selector(inspectorInsertRowBelow(_:)),
590594
#selector(inspectorInsertColumnLeft(_:)), #selector(inspectorInsertColumnRight(_:)),
591-
#selector(inspectorSplitColumn(_:)), #selector(inspectorMergeColumns(_:)):
595+
#selector(inspectorSplitColumn(_:)), #selector(inspectorMergeColumns(_:)),
596+
#selector(inspectorToggleHeaderRow(_:)):
592597
return nsDocument != nil
593598
case #selector(inspectorDeleteColumn(_:)):
594599
guard nsDocument != nil else { return false }

TableProTests/Plugins/CSVInspectorTests.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,52 @@ struct CSVRowStoreTests {
361361
#expect(store.columnNames == ["first", "last"])
362362
#expect(store.cells(forRow: 0) == ["Alice", "Smith"])
363363
}
364+
365+
@Test("toggleHeaderRow demotes the header into a data row")
366+
func toggleHeaderToData() {
367+
let store = makeStore("name,age\nAlice,30\n")
368+
#expect(store.hasHeaderRow)
369+
store.toggleHeaderRow()
370+
#expect(!store.hasHeaderRow)
371+
#expect(store.columnNames == ["Column 1", "Column 2"])
372+
#expect(store.rowCount == 2)
373+
#expect(store.cells(forRow: 0) == ["name", "age"])
374+
#expect(store.cells(forRow: 1) == ["Alice", "30"])
375+
}
376+
377+
@Test("toggleHeaderRow promotes the first data row into the header")
378+
func toggleDataToHeader() {
379+
let store = makeStore("1,2\n3,4\n")
380+
#expect(!store.hasHeaderRow)
381+
store.toggleHeaderRow()
382+
#expect(store.hasHeaderRow)
383+
#expect(store.columnNames == ["1", "2"])
384+
#expect(store.rowCount == 1)
385+
#expect(store.cells(forRow: 0) == ["3", "4"])
386+
}
387+
388+
@Test("toggleHeaderRow twice returns to the original state")
389+
func toggleHeaderRoundTrip() {
390+
let store = makeStore("name,age\nAlice,30\nBob,25\n")
391+
store.toggleHeaderRow()
392+
store.toggleHeaderRow()
393+
#expect(store.hasHeaderRow)
394+
#expect(store.columnNames == ["name", "age"])
395+
#expect(store.rowCount == 2)
396+
#expect(store.cells(forRow: 0) == ["Alice", "30"])
397+
}
398+
399+
@Test("Demoting after a column insert keeps the header row the full width")
400+
func toggleHeaderAfterColumnInsert() {
401+
let store = makeStore("1,2\n3,4\n")
402+
store.toggleHeaderRow()
403+
store.insertColumn(at: 2, name: "c")
404+
store.toggleHeaderRow()
405+
#expect(store.columnCount == 3)
406+
#expect(store.columnNames == ["Column 1", "Column 2", "Column 3"])
407+
#expect(store.cells(forRow: 0) == ["1", "2", "c"])
408+
#expect(store.cells(forRow: 1).count == 3)
409+
}
364410
}
365411

366412
@Suite("CSVWriter round-trip")
@@ -426,4 +472,24 @@ struct CSVWriterRoundTripTests {
426472
let written = try Data(contentsOf: outURL)
427473
#expect(written.prefix(3) == Data([0xEF, 0xBB, 0xBF]))
428474
}
475+
476+
@Test("A headerless file is written without a synthetic header row")
477+
func roundTripHeaderless() throws {
478+
let source = "1,2\n3,4\n"
479+
let url = tempURL()
480+
try source.data(using: .utf8)!.write(to: url)
481+
defer { try? FileManager.default.removeItem(at: url) }
482+
483+
let data = try Data(contentsOf: url, options: .mappedIfSafe)
484+
let dialect = CSVDialect.detect(from: data)
485+
let store = CSVRowStore(data: data, dialect: dialect)
486+
#expect(!store.hasHeaderRow)
487+
488+
let outURL = tempURL()
489+
defer { try? FileManager.default.removeItem(at: outURL) }
490+
try CSVWriter(dialect: dialect).write(store, to: outURL)
491+
492+
let written = try Data(contentsOf: outURL)
493+
#expect(written == data)
494+
}
429495
}

docs/features/csv-inspector.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ Cmd-click several column headers to select whole columns, and the menu reads **D
5959

6060
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.
6161

62+
## Header row
63+
64+
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.
65+
6266
## Filter and sort
6367

6468
<Frame caption="Filter bar with multiple conditions (AND) above the data grid">

0 commit comments

Comments
 (0)