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 @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- In the CSV editor, set the delimiter, quote character, encoding, and line ending by hand from Edit > Set CSV Properties…, then Reload to re-read the file with those settings. (#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
14 changes: 13 additions & 1 deletion Plugins/CSVInspectorPlugin/CSVDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import AppKit
import TableProPluginKit
import os

public final class CSVDocument: NSDocument, InspectorDocument {
public final class CSVDocument: NSDocument, CSVConfigurableDocument {
static let logger = Logger(subsystem: "com.TablePro", category: "CSVInspector")

private static let typeInferenceSampleSize = 200
Expand Down Expand Up @@ -356,6 +356,18 @@ public final class CSVDocument: NSDocument, InspectorDocument {
}
}

public var csvDialect: CSVDialect { dialect }

public func reload(with newDialect: CSVDialect) {
let data = store.data
dialect = newDialect
store = CSVRowStore(data: data, dialect: newDialect)
Comment on lines +362 to +364

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 Re-encode all rows using the selected dialect

After any property change, this recreates the store with every row still backed by the original byte ranges. On Save, CSVRowStore.rowSource(at:) returns those ranges as .rawBytes, and CSVWriter.append copies them verbatim instead of serializing them with the newly selected dialect. Thus changing encoding or line ending and saving leaves existing rows in the old format (and editing only one row produces a mixed-format file); with an encoding change, the writer can even prepend a new BOM to payload bytes still in the prior encoding. Materialize/rewrite the original rows when the dialect changes so the Save action promised by this flow actually writes the selected settings.

Useful? React with 👍 / 👎.

recomputeInferredTypes()
undoManager?.removeAllActions()
updateChangeCount(.changeDone)
onChange?()
}

private func recomputeInferredTypes() {
let sample = store.pageRows(offset: 0, limit: Self.typeInferenceSampleSize)
inferredTypes = CSVTypeInferrer.inferColumns(rows: sample, columnCount: store.columnCount)
Expand Down
6 changes: 6 additions & 0 deletions Plugins/TableProPluginKit/DocumentInspectorPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,9 @@ public extension InspectorDocument {
func mergeColumns(at index: Int, separator: String) {}
func toggleHeaderRow() {}
}

@MainActor
public protocol CSVConfigurableDocument: InspectorDocument {
var csvDialect: CSVDialect { get }
func reload(with dialect: CSVDialect)
}
5 changes: 5 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,11 @@ struct AppMenuCommands: Commands {
.optionalKeyboardShortcut(shortcut(for: .toggleHeaderRow))
.disabled(!keyWindowIsInspector)

Button(String(localized: "Set CSV Properties…")) {
NSApp.sendAction(#selector(InspectorViewController.inspectorSetCSVProperties(_:)), to: nil, from: nil)
}
.disabled(!keyWindowIsInspector)

Divider()

// Table operations (work when tables selected in sidebar)
Expand Down
84 changes: 84 additions & 0 deletions TablePro/Views/Inspector/CSVPropertiesSheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// CSVPropertiesSheet.swift
// TablePro
//

import SwiftUI
import TableProPluginKit

struct CSVPropertiesSheet: View {
private let baseDialect: CSVDialect
private let onReload: (CSVDialect) -> Void
private let onCancel: () -> Void

@State private var delimiterIndex: Int
@State private var quoteIndex: Int
@State private var encodingIndex: Int
@State private var lineEndingIndex: Int

init(
dialect: CSVDialect,
onReload: @escaping (CSVDialect) -> Void,
onCancel: @escaping () -> Void
) {
self.baseDialect = dialect
self.onReload = onReload
self.onCancel = onCancel
_delimiterIndex = State(initialValue: CSVPropertyOptions.delimiterIndex(for: dialect.delimiter))
_quoteIndex = State(initialValue: CSVPropertyOptions.quoteIndex(for: dialect.quoteChar))
_encodingIndex = State(initialValue: CSVPropertyOptions.encodingIndex(for: dialect.encoding))
_lineEndingIndex = State(initialValue: CSVPropertyOptions.lineEndingIndex(for: dialect.lineEnding))
}

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("CSV Properties").font(.headline)
Text("Re-read the file with these settings. This discards unsaved changes.")
.font(.subheadline)
.foregroundStyle(.secondary)

Form {
Picker("Delimiter", selection: $delimiterIndex) {
ForEach(CSVPropertyOptions.delimiters.indices, id: \.self) { index in
Text(CSVPropertyOptions.delimiters[index].label).tag(index)
}
}
Picker("Quote character", selection: $quoteIndex) {
ForEach(CSVPropertyOptions.quotes.indices, id: \.self) { index in
Text(CSVPropertyOptions.quotes[index].label).tag(index)
}
}
Picker("Encoding", selection: $encodingIndex) {
ForEach(CSVPropertyOptions.encodings.indices, id: \.self) { index in
Text(CSVPropertyOptions.encodings[index].label).tag(index)
}
}
Picker("Line ending", selection: $lineEndingIndex) {
ForEach(CSVPropertyOptions.lineEndings.indices, id: \.self) { index in
Text(CSVPropertyOptions.lineEndings[index].label).tag(index)
}
}
}

HStack {
Spacer()
Button("Cancel", role: .cancel, action: onCancel)
.keyboardShortcut(.cancelAction)
Button("Reload") { onReload(selectedDialect) }
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 340)
}

private var selectedDialect: CSVDialect {
CSVPropertyOptions.dialect(
base: baseDialect,
delimiterIndex: delimiterIndex,
quoteIndex: quoteIndex,
encodingIndex: encodingIndex,
lineEndingIndex: lineEndingIndex
)
}
}
69 changes: 69 additions & 0 deletions TablePro/Views/Inspector/CSVPropertyOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// CSVPropertyOptions.swift
// TablePro
//

import Foundation
import TableProPluginKit

enum CSVPropertyOptions {
static let delimiters: [(label: String, byte: UInt8)] = [
(String(localized: "Comma ,"), 0x2C),
(String(localized: "Semicolon ;"), 0x3B),
(String(localized: "Tab"), 0x09),
(String(localized: "Pipe |"), 0x7C),
(String(localized: "Colon :"), 0x3A),
(String(localized: "Space"), 0x20),
]

static let quotes: [(label: String, byte: UInt8)] = [
(String(localized: "Double Quote \""), 0x22),
(String(localized: "Single Quote '"), 0x27),
]

static let encodings: [(label: String, encoding: String.Encoding)] = [
("UTF-8", .utf8),
("UTF-16 LE", .utf16LittleEndian),
("UTF-16 BE", .utf16BigEndian),
Comment on lines +26 to +27

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 Support UTF-16 parsing before offering it as an override

For a BOM-less UTF-16 CSV, selecting either option here cannot correctly repair detection: CSVStreamingParser scans and splits one byte at a time, so after consuming an ASCII delimiter or newline byte it leaves the adjacent UTF-16 NUL byte at the start of the next field/row. That yields malformed fields and, for CRLF input, spurious rows. BOM-less UTF-16 is precisely a case where the manual encoding picker is needed, so decode into code-unit-aligned text (or make the parser encoding-aware) before exposing these choices.

Useful? React with 👍 / 👎.

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 Reject unrepresentable rows for legacy encoding selections

When a user selects Latin-1 or Windows-1252 and then edits a row containing an unrepresentable character (for example, an emoji from an originally UTF-8 file), CSVWriter.append gets nil from data(using:) but still appends the line ending. The save therefore silently replaces the entire edited record with a blank row instead of reporting its existing encodingFailed error; reject the save or otherwise handle the failed conversion.

Useful? React with 👍 / 👎.

("Latin-1", .isoLatin1),
("Windows-1252", .windowsCP1252),
]

static let lineEndings: [(label: String, value: CSVDialect.LineEnding)] = [
("LF", .lf),
("CRLF", .crlf),
("CR", .cr),
]

static func delimiterIndex(for byte: UInt8) -> Int {
delimiters.firstIndex { $0.byte == byte } ?? 0
}

static func quoteIndex(for byte: UInt8) -> Int {
quotes.firstIndex { $0.byte == byte } ?? 0
}

static func encodingIndex(for encoding: String.Encoding) -> Int {
encodings.firstIndex { $0.encoding == encoding } ?? 0
}

static func lineEndingIndex(for value: CSVDialect.LineEnding) -> Int {
lineEndings.firstIndex { $0.value == value } ?? 0
}

static func dialect(
base: CSVDialect,
delimiterIndex: Int,
quoteIndex: Int,
encodingIndex: Int,
lineEndingIndex: Int
) -> CSVDialect {
CSVDialect(
delimiter: delimiters.indices.contains(delimiterIndex) ? delimiters[delimiterIndex].byte : base.delimiter,
quoteChar: quotes.indices.contains(quoteIndex) ? quotes[quoteIndex].byte : base.quoteChar,
Comment on lines +61 to +63

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 Prevent non-tab delimiters for TSV documents

When a user selects (for example) Comma for a .tsv document and saves, the file is written using that delimiter, but reopening it calls read(from:ofType:), which forcibly resets every public.tab-separated-values-text dialect to tab. The reopened document is therefore parsed incorrectly, so this newly exposed selection cannot persist for TSV files. Either constrain TSV documents to tab in this dialog or stop overriding their selected delimiter on read.

Useful? React with 👍 / 👎.

encoding: encodings.indices.contains(encodingIndex) ? encodings[encodingIndex].encoding : base.encoding,
lineEnding: lineEndings.indices.contains(lineEndingIndex) ? lineEndings[lineEndingIndex].value : base.lineEnding,
hasBom: base.hasBom

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 Skip an existing BOM when changing the encoding

When a UTF-8-BOM CSV is reloaded after selecting Latin-1 or Windows-1252, this retains hasBom == true but changes encoding; CSVStreamingParser.bomSkip returns zero for those encodings, so the three BOM bytes are parsed as part of the first header/field (for Latin-1, name). Preserve the source BOM length independently of the chosen encoding, or clear/normalize it when building the override dialect.

Useful? React with 👍 / 👎.

)
}
}
48 changes: 48 additions & 0 deletions TablePro/Views/Inspector/InspectorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
private var lastFilterClauses: [FilterClause] = []
private var lastSortSpecs: [SortSpec] = []
private var pendingPostRefresh: PostRefreshAction?
private var propertiesSheetController: NSViewController?

private enum PostRefreshAction {
case selectClamped(displayRow: Int)
Expand Down Expand Up @@ -221,6 +222,51 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
inspectorDocument?.toggleHeaderRow()
}

@objc func inspectorSetCSVProperties(_ sender: Any?) {
guard let configurable = inspectorDocument as? CSVConfigurableDocument else { return }
presentCSVProperties(configurable)
}

private func presentCSVProperties(_ document: CSVConfigurableDocument) {
guard propertiesSheetController == nil else { return }
let sheet = CSVPropertiesSheet(
dialect: document.csvDialect,
onReload: { [weak self, weak document] dialect in
self?.dismissPropertiesSheet()
guard let document else { return }
DispatchQueue.main.async { self?.reloadCSV(document, with: dialect) }
},
onCancel: { [weak self] in self?.dismissPropertiesSheet() }
)
let hosting = NSHostingController(rootView: sheet)
propertiesSheetController = hosting
presentAsSheet(hosting)
}

private func dismissPropertiesSheet() {
guard let controller = propertiesSheetController else { return }
dismiss(controller)
propertiesSheetController = nil
}

private func reloadCSV(_ document: CSVConfigurableDocument, with dialect: CSVDialect) {
guard nsDocument?.isDocumentEdited == true, let window = view.window else {
document.reload(with: dialect)
return
}
let alert = NSAlert()
alert.messageText = String(localized: "Reload with new properties?")
alert.informativeText = String(localized: "This discards your unsaved changes and re-reads the file with the chosen settings.")
alert.alertStyle = .warning
let reloadButton = alert.addButton(withTitle: String(localized: "Reload"))
reloadButton.hasDestructiveAction = true
alert.addButton(withTitle: String(localized: "Cancel"))
alert.beginSheetModal(for: window) { [weak document] response in
guard response == .alertFirstButtonReturn, let document else { return }
document.reload(with: dialect)
}
}

@objc func inspectorInsertRowAbove(_ sender: Any?) {
performInsertRow(anchoredBy: sender, below: false)
}
Expand Down Expand Up @@ -595,6 +641,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
#selector(inspectorSplitColumn(_:)), #selector(inspectorMergeColumns(_:)),
#selector(inspectorToggleHeaderRow(_:)):
return nsDocument != nil
case #selector(inspectorSetCSVProperties(_:)):
return inspectorDocument is CSVConfigurableDocument
case #selector(inspectorDeleteColumn(_:)):
guard nsDocument != nil else { return false }
if let menuItem = item as? NSMenuItem, menuItem.representedObject is [Int] { return true }
Expand Down
41 changes: 41 additions & 0 deletions TableProTests/Views/CSVPropertyOptionsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// CSVPropertyOptionsTests.swift
// TableProTests
//

@testable import TablePro
import TableProPluginKit
import Testing

@Suite("CSVPropertyOptions")
struct CSVPropertyOptionsTests {
@Test("Indices map back from a dialect's bytes and values")
func indicesFromDialect() {
#expect(CSVPropertyOptions.delimiterIndex(for: 0x3B) == 1)
#expect(CSVPropertyOptions.quoteIndex(for: 0x27) == 1)
#expect(CSVPropertyOptions.encodingIndex(for: .windowsCP1252) == 4)

Check failure on line 16 in TableProTests/Views/CSVPropertyOptionsTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

static property 'windowsCP1252' is not available due to missing import of defining module 'Foundation'
#expect(CSVPropertyOptions.lineEndingIndex(for: .crlf) == 1)
}

@Test("An unknown delimiter falls back to the first option")
func unknownFallsBack() {
#expect(CSVPropertyOptions.delimiterIndex(for: 0x5E) == 0)
}

@Test("Building a dialect from indices sets the four properties and keeps the BOM")
func dialectRoundTrip() {
let base = CSVDialect(delimiter: 0x2C, hasBom: true)
let dialect = CSVPropertyOptions.dialect(
base: base,
delimiterIndex: CSVPropertyOptions.delimiterIndex(for: 0x09),
quoteIndex: CSVPropertyOptions.quoteIndex(for: 0x27),
encodingIndex: CSVPropertyOptions.encodingIndex(for: .utf16LittleEndian),

Check failure on line 32 in TableProTests/Views/CSVPropertyOptionsTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

static property 'utf16LittleEndian' is not available due to missing import of defining module 'Foundation'

Check failure on line 32 in TableProTests/Views/CSVPropertyOptionsTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

static property 'utf16LittleEndian' is not available due to missing import of defining module 'Foundation'
lineEndingIndex: CSVPropertyOptions.lineEndingIndex(for: .cr)
)
#expect(dialect.delimiter == 0x09)
#expect(dialect.quoteChar == 0x27)
#expect(dialect.encoding == .utf16LittleEndian)

Check failure on line 37 in TableProTests/Views/CSVPropertyOptionsTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

static property 'utf16LittleEndian' is not available due to missing import of defining module 'Foundation'

Check failure on line 37 in TableProTests/Views/CSVPropertyOptionsTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

static property 'utf16LittleEndian' is not available due to missing import of defining module 'Foundation'
#expect(dialect.lineEnding == .cr)
#expect(dialect.hasBom)
}
}
2 changes: 2 additions & 0 deletions docs/features/csv-inspector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ When a file opens, TablePro detects:
- **Line ending**: CRLF, LF, or CR, whichever appears first in the first 64 KB.
- **Header row**: row one is treated as headers when at least half of its cells are non-empty and not numbers. Otherwise TablePro generates `Column 1`, `Column 2`, ... and treats every row as data.

When a guess is wrong, choose **Edit > Set CSV Properties…** to pick the delimiter, quote character, encoding, and line ending by hand, then **Reload** to re-read the file with those settings. Reload discards unsaved edits and asks first if you have any.

## Pagination

Rows load in pages. The page size comes from the Default page size setting in [Settings > Data Grid](/customization/settings), 1,000 rows by default. The status bar shows row and column counts, and previous/next page controls when the file spans more than one page.
Expand Down
Loading