diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eab73e7c..7de26595d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/CSVInspectorPlugin/CSVDocument.swift b/Plugins/CSVInspectorPlugin/CSVDocument.swift index abe651f55..3d818f645 100644 --- a/Plugins/CSVInspectorPlugin/CSVDocument.swift +++ b/Plugins/CSVInspectorPlugin/CSVDocument.swift @@ -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) diff --git a/Plugins/CSVInspectorPlugin/CSVRowStore.swift b/Plugins/CSVInspectorPlugin/CSVRowStore.swift index bde5d91df..735edbf32 100644 --- a/Plugins/CSVInspectorPlugin/CSVRowStore.swift +++ b/Plugins/CSVInspectorPlugin/CSVRowStore.swift @@ -27,6 +27,7 @@ final class CSVRowStore { let headerRef: RowRef let logicalRows: [RowRef] let columnTransforms: [ColumnTransform] + let hasHeaderRow: Bool } struct Snapshot: InspectorDataSnapshot { @@ -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] = [] @@ -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 [] } @@ -97,6 +100,7 @@ final class CSVRowStore { if Self.isLikelyHeader(headerCells) { resolvedColumnNames = headerCells resolvedHeaderRef = .original(first) + resolvedHasHeaderRow = true ranges.removeFirst() } else { let synthetic = (0.. StoreState { StoreState( columnNames: columnNames, headerRef: headerRef, logicalRows: logicalRows, - columnTransforms: columnTransforms + columnTransforms: columnTransforms, + hasHeaderRow: hasHeaderRow ) } @@ -338,6 +364,7 @@ final class CSVRowStore { headerRef = state.headerRef logicalRows = state.logicalRows columnTransforms = state.columnTransforms + hasHeaderRow = state.hasHeaderRow cache.removeAll() cacheOrder.removeAll() } diff --git a/Plugins/CSVInspectorPlugin/CSVWriter.swift b/Plugins/CSVInspectorPlugin/CSVWriter.swift index 06153172a..3b49426df 100644 --- a/Plugins/CSVInspectorPlugin/CSVWriter.swift +++ b/Plugins/CSVInspectorPlugin/CSVWriter.swift @@ -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.. Void)? { get set } } @@ -69,4 +70,5 @@ public protocol InspectorDocument: AnyObject { public extension InspectorDocument { func splitColumn(at index: Int, separator: String, isRegex: Bool) {} func mergeColumns(at index: Int, separator: String) {} + func toggleHeaderRow() {} } diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index d84d63984..c161e2d1c 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -96,6 +96,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case addRow case duplicateRow case truncateTable + case toggleHeaderRow case previewFKReference case saveAsFavorite case previousPage @@ -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, @@ -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") @@ -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), diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 5574ba052..c8b91ca2b 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -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) diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index 009e971b8..5ada05114 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -217,6 +217,10 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation handleDeleteRows(state.selectedRowIndices) } + @objc func inspectorToggleHeaderRow(_ sender: Any?) { + inspectorDocument?.toggleHeaderRow() + } + @objc func inspectorInsertRowAbove(_ sender: Any?) { performInsertRow(anchoredBy: sender, below: false) } @@ -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 } diff --git a/TableProTests/Plugins/CSVInspectorTests.swift b/TableProTests/Plugins/CSVInspectorTests.swift index 737f3221f..abef10d9e 100644 --- a/TableProTests/Plugins/CSVInspectorTests.swift +++ b/TableProTests/Plugins/CSVInspectorTests.swift @@ -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") @@ -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) + } } diff --git a/docs/features/csv-inspector.mdx b/docs/features/csv-inspector.mdx index 7f9450d37..cf0de90d4 100644 --- a/docs/features/csv-inspector.mdx +++ b/docs/features/csv-inspector.mdx @@ -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 diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 32349bc41..fcc7e6f88 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -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 |