Skip to content

Commit 98f9160

Browse files
authored
feat(inspector): set CSV delimiter, quote, encoding, and line ending with reload (#1943)
1 parent fa398e6 commit 98f9160

9 files changed

Lines changed: 269 additions & 1 deletion

File tree

CHANGELOG.md

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

2021
### Fixed

Plugins/CSVInspectorPlugin/CSVDocument.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import AppKit
22
import TableProPluginKit
33
import os
44

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

88
private static let typeInferenceSampleSize = 200
@@ -356,6 +356,18 @@ public final class CSVDocument: NSDocument, InspectorDocument {
356356
}
357357
}
358358

359+
public var csvDialect: CSVDialect { dialect }
360+
361+
public func reload(with newDialect: CSVDialect) {
362+
let data = store.data
363+
dialect = newDialect
364+
store = CSVRowStore(data: data, dialect: newDialect)
365+
recomputeInferredTypes()
366+
undoManager?.removeAllActions()
367+
updateChangeCount(.changeDone)
368+
onChange?()
369+
}
370+
359371
private func recomputeInferredTypes() {
360372
let sample = store.pageRows(offset: 0, limit: Self.typeInferenceSampleSize)
361373
inferredTypes = CSVTypeInferrer.inferColumns(rows: sample, columnCount: store.columnCount)

Plugins/TableProPluginKit/DocumentInspectorPlugin.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,9 @@ public extension InspectorDocument {
7272
func mergeColumns(at index: Int, separator: String) {}
7373
func toggleHeaderRow() {}
7474
}
75+
76+
@MainActor
77+
public protocol CSVConfigurableDocument: InspectorDocument {
78+
var csvDialect: CSVDialect { get }
79+
func reload(with dialect: CSVDialect)
80+
}

TablePro/TableProApp.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,11 @@ struct AppMenuCommands: Commands {
651651
.optionalKeyboardShortcut(shortcut(for: .toggleHeaderRow))
652652
.disabled(!keyWindowIsInspector)
653653

654+
Button(String(localized: "Set CSV Properties…")) {
655+
NSApp.sendAction(#selector(InspectorViewController.inspectorSetCSVProperties(_:)), to: nil, from: nil)
656+
}
657+
.disabled(!keyWindowIsInspector)
658+
654659
Divider()
655660

656661
// Table operations (work when tables selected in sidebar)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// CSVPropertiesSheet.swift
3+
// TablePro
4+
//
5+
6+
import SwiftUI
7+
import TableProPluginKit
8+
9+
struct CSVPropertiesSheet: View {
10+
private let baseDialect: CSVDialect
11+
private let onReload: (CSVDialect) -> Void
12+
private let onCancel: () -> Void
13+
14+
@State private var delimiterIndex: Int
15+
@State private var quoteIndex: Int
16+
@State private var encodingIndex: Int
17+
@State private var lineEndingIndex: Int
18+
19+
init(
20+
dialect: CSVDialect,
21+
onReload: @escaping (CSVDialect) -> Void,
22+
onCancel: @escaping () -> Void
23+
) {
24+
self.baseDialect = dialect
25+
self.onReload = onReload
26+
self.onCancel = onCancel
27+
_delimiterIndex = State(initialValue: CSVPropertyOptions.delimiterIndex(for: dialect.delimiter))
28+
_quoteIndex = State(initialValue: CSVPropertyOptions.quoteIndex(for: dialect.quoteChar))
29+
_encodingIndex = State(initialValue: CSVPropertyOptions.encodingIndex(for: dialect.encoding))
30+
_lineEndingIndex = State(initialValue: CSVPropertyOptions.lineEndingIndex(for: dialect.lineEnding))
31+
}
32+
33+
var body: some View {
34+
VStack(alignment: .leading, spacing: 16) {
35+
Text("CSV Properties").font(.headline)
36+
Text("Re-read the file with these settings. This discards unsaved changes.")
37+
.font(.subheadline)
38+
.foregroundStyle(.secondary)
39+
40+
Form {
41+
Picker("Delimiter", selection: $delimiterIndex) {
42+
ForEach(CSVPropertyOptions.delimiters.indices, id: \.self) { index in
43+
Text(CSVPropertyOptions.delimiters[index].label).tag(index)
44+
}
45+
}
46+
Picker("Quote character", selection: $quoteIndex) {
47+
ForEach(CSVPropertyOptions.quotes.indices, id: \.self) { index in
48+
Text(CSVPropertyOptions.quotes[index].label).tag(index)
49+
}
50+
}
51+
Picker("Encoding", selection: $encodingIndex) {
52+
ForEach(CSVPropertyOptions.encodings.indices, id: \.self) { index in
53+
Text(CSVPropertyOptions.encodings[index].label).tag(index)
54+
}
55+
}
56+
Picker("Line ending", selection: $lineEndingIndex) {
57+
ForEach(CSVPropertyOptions.lineEndings.indices, id: \.self) { index in
58+
Text(CSVPropertyOptions.lineEndings[index].label).tag(index)
59+
}
60+
}
61+
}
62+
63+
HStack {
64+
Spacer()
65+
Button("Cancel", role: .cancel, action: onCancel)
66+
.keyboardShortcut(.cancelAction)
67+
Button("Reload") { onReload(selectedDialect) }
68+
.keyboardShortcut(.defaultAction)
69+
}
70+
}
71+
.padding(20)
72+
.frame(width: 340)
73+
}
74+
75+
private var selectedDialect: CSVDialect {
76+
CSVPropertyOptions.dialect(
77+
base: baseDialect,
78+
delimiterIndex: delimiterIndex,
79+
quoteIndex: quoteIndex,
80+
encodingIndex: encodingIndex,
81+
lineEndingIndex: lineEndingIndex
82+
)
83+
}
84+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// CSVPropertyOptions.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
enum CSVPropertyOptions {
10+
static let delimiters: [(label: String, byte: UInt8)] = [
11+
(String(localized: "Comma ,"), 0x2C),
12+
(String(localized: "Semicolon ;"), 0x3B),
13+
(String(localized: "Tab"), 0x09),
14+
(String(localized: "Pipe |"), 0x7C),
15+
(String(localized: "Colon :"), 0x3A),
16+
(String(localized: "Space"), 0x20),
17+
]
18+
19+
static let quotes: [(label: String, byte: UInt8)] = [
20+
(String(localized: "Double Quote \""), 0x22),
21+
(String(localized: "Single Quote '"), 0x27),
22+
]
23+
24+
static let encodings: [(label: String, encoding: String.Encoding)] = [
25+
("UTF-8", .utf8),
26+
("UTF-16 LE", .utf16LittleEndian),
27+
("UTF-16 BE", .utf16BigEndian),
28+
("Latin-1", .isoLatin1),
29+
("Windows-1252", .windowsCP1252),
30+
]
31+
32+
static let lineEndings: [(label: String, value: CSVDialect.LineEnding)] = [
33+
("LF", .lf),
34+
("CRLF", .crlf),
35+
("CR", .cr),
36+
]
37+
38+
static func delimiterIndex(for byte: UInt8) -> Int {
39+
delimiters.firstIndex { $0.byte == byte } ?? 0
40+
}
41+
42+
static func quoteIndex(for byte: UInt8) -> Int {
43+
quotes.firstIndex { $0.byte == byte } ?? 0
44+
}
45+
46+
static func encodingIndex(for encoding: String.Encoding) -> Int {
47+
encodings.firstIndex { $0.encoding == encoding } ?? 0
48+
}
49+
50+
static func lineEndingIndex(for value: CSVDialect.LineEnding) -> Int {
51+
lineEndings.firstIndex { $0.value == value } ?? 0
52+
}
53+
54+
static func dialect(
55+
base: CSVDialect,
56+
delimiterIndex: Int,
57+
quoteIndex: Int,
58+
encodingIndex: Int,
59+
lineEndingIndex: Int
60+
) -> CSVDialect {
61+
CSVDialect(
62+
delimiter: delimiters.indices.contains(delimiterIndex) ? delimiters[delimiterIndex].byte : base.delimiter,
63+
quoteChar: quotes.indices.contains(quoteIndex) ? quotes[quoteIndex].byte : base.quoteChar,
64+
encoding: encodings.indices.contains(encodingIndex) ? encodings[encodingIndex].encoding : base.encoding,
65+
lineEnding: lineEndings.indices.contains(lineEndingIndex) ? lineEndings[lineEndingIndex].value : base.lineEnding,
66+
hasBom: base.hasBom
67+
)
68+
}
69+
}

TablePro/Views/Inspector/InspectorViewController.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
2525
private var lastFilterClauses: [FilterClause] = []
2626
private var lastSortSpecs: [SortSpec] = []
2727
private var pendingPostRefresh: PostRefreshAction?
28+
private var propertiesSheetController: NSViewController?
2829

2930
private enum PostRefreshAction {
3031
case selectClamped(displayRow: Int)
@@ -221,6 +222,51 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
221222
inspectorDocument?.toggleHeaderRow()
222223
}
223224

225+
@objc func inspectorSetCSVProperties(_ sender: Any?) {
226+
guard let configurable = inspectorDocument as? CSVConfigurableDocument else { return }
227+
presentCSVProperties(configurable)
228+
}
229+
230+
private func presentCSVProperties(_ document: CSVConfigurableDocument) {
231+
guard propertiesSheetController == nil else { return }
232+
let sheet = CSVPropertiesSheet(
233+
dialect: document.csvDialect,
234+
onReload: { [weak self, weak document] dialect in
235+
self?.dismissPropertiesSheet()
236+
guard let document else { return }
237+
DispatchQueue.main.async { self?.reloadCSV(document, with: dialect) }
238+
},
239+
onCancel: { [weak self] in self?.dismissPropertiesSheet() }
240+
)
241+
let hosting = NSHostingController(rootView: sheet)
242+
propertiesSheetController = hosting
243+
presentAsSheet(hosting)
244+
}
245+
246+
private func dismissPropertiesSheet() {
247+
guard let controller = propertiesSheetController else { return }
248+
dismiss(controller)
249+
propertiesSheetController = nil
250+
}
251+
252+
private func reloadCSV(_ document: CSVConfigurableDocument, with dialect: CSVDialect) {
253+
guard nsDocument?.isDocumentEdited == true, let window = view.window else {
254+
document.reload(with: dialect)
255+
return
256+
}
257+
let alert = NSAlert()
258+
alert.messageText = String(localized: "Reload with new properties?")
259+
alert.informativeText = String(localized: "This discards your unsaved changes and re-reads the file with the chosen settings.")
260+
alert.alertStyle = .warning
261+
let reloadButton = alert.addButton(withTitle: String(localized: "Reload"))
262+
reloadButton.hasDestructiveAction = true
263+
alert.addButton(withTitle: String(localized: "Cancel"))
264+
alert.beginSheetModal(for: window) { [weak document] response in
265+
guard response == .alertFirstButtonReturn, let document else { return }
266+
document.reload(with: dialect)
267+
}
268+
}
269+
224270
@objc func inspectorInsertRowAbove(_ sender: Any?) {
225271
performInsertRow(anchoredBy: sender, below: false)
226272
}
@@ -595,6 +641,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation
595641
#selector(inspectorSplitColumn(_:)), #selector(inspectorMergeColumns(_:)),
596642
#selector(inspectorToggleHeaderRow(_:)):
597643
return nsDocument != nil
644+
case #selector(inspectorSetCSVProperties(_:)):
645+
return inspectorDocument is CSVConfigurableDocument
598646
case #selector(inspectorDeleteColumn(_:)):
599647
guard nsDocument != nil else { return false }
600648
if let menuItem = item as? NSMenuItem, menuItem.representedObject is [Int] { return true }
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// CSVPropertyOptionsTests.swift
3+
// TableProTests
4+
//
5+
6+
@testable import TablePro
7+
import TableProPluginKit
8+
import Testing
9+
10+
@Suite("CSVPropertyOptions")
11+
struct CSVPropertyOptionsTests {
12+
@Test("Indices map back from a dialect's bytes and values")
13+
func indicesFromDialect() {
14+
#expect(CSVPropertyOptions.delimiterIndex(for: 0x3B) == 1)
15+
#expect(CSVPropertyOptions.quoteIndex(for: 0x27) == 1)
16+
#expect(CSVPropertyOptions.encodingIndex(for: .windowsCP1252) == 4)
17+
#expect(CSVPropertyOptions.lineEndingIndex(for: .crlf) == 1)
18+
}
19+
20+
@Test("An unknown delimiter falls back to the first option")
21+
func unknownFallsBack() {
22+
#expect(CSVPropertyOptions.delimiterIndex(for: 0x5E) == 0)
23+
}
24+
25+
@Test("Building a dialect from indices sets the four properties and keeps the BOM")
26+
func dialectRoundTrip() {
27+
let base = CSVDialect(delimiter: 0x2C, hasBom: true)
28+
let dialect = CSVPropertyOptions.dialect(
29+
base: base,
30+
delimiterIndex: CSVPropertyOptions.delimiterIndex(for: 0x09),
31+
quoteIndex: CSVPropertyOptions.quoteIndex(for: 0x27),
32+
encodingIndex: CSVPropertyOptions.encodingIndex(for: .utf16LittleEndian),
33+
lineEndingIndex: CSVPropertyOptions.lineEndingIndex(for: .cr)
34+
)
35+
#expect(dialect.delimiter == 0x09)
36+
#expect(dialect.quoteChar == 0x27)
37+
#expect(dialect.encoding == .utf16LittleEndian)
38+
#expect(dialect.lineEnding == .cr)
39+
#expect(dialect.hasBom)
40+
}
41+
}

docs/features/csv-inspector.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ When a file opens, TablePro detects:
2626
- **Line ending**: CRLF, LF, or CR, whichever appears first in the first 64 KB.
2727
- **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.
2828

29+
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.
30+
2931
## Pagination
3032

3133
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.

0 commit comments

Comments
 (0)