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 @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- In the CSV editor, right-click a column header to rename, insert, delete, or change the type of that column. (#1913)
- 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)
- 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
61 changes: 61 additions & 0 deletions Plugins/CSVInspectorPlugin/CSVDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,67 @@ public final class CSVDocument: NSDocument, InspectorDocument {
onChange?()
}

public func splitColumn(at index: Int, separator: String, isRegex: Bool) {
guard index >= 0, index < store.columnCount, !separator.isEmpty else { return }
let spec: CSVRowStore.SplitSpec
if isRegex {
guard let regex = try? NSRegularExpression(pattern: separator) else { return }
spec = .regex(regex)
} else {
spec = .literal(separator)
}
performStructuralChange(name: String(localized: "Split Column")) {
store.splitColumn(at: index, spec: spec)
recomputeInferredTypes()
}
}

public func mergeColumns(at index: Int, separator: String) {
guard index >= 0, index + 1 < store.columnCount else { return }
performStructuralChange(name: String(localized: "Merge Columns")) {
store.mergeColumns(at: index, separator: separator)
recomputeInferredTypes()
}
}

private func recomputeInferredTypes() {
let sample = store.pageRows(offset: 0, limit: Self.typeInferenceSampleSize)
inferredTypes = CSVTypeInferrer.inferColumns(rows: sample, columnCount: store.columnCount)
typeOverrides = [:]

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 Preserve type overrides on untouched columns

A split or merge invokes this method and clears every manual type override, including overrides on columns unrelated to the operation. For example, splitting column A removes a user-selected type for column C and can change its subsequent sort behavior; retain unaffected overrides and shift their indices while replacing only the structurally affected ones.

Useful? React with 👍 / 👎.

}

private func performStructuralChange(name: String, _ mutate: () -> Void) {
let before = captureStructuralSnapshot()
mutate()
registerStructuralUndo(name: name, undoTo: before, redoTo: captureStructuralSnapshot())
onChange?()
}

private func registerStructuralUndo(name: String, undoTo: StructuralSnapshot, redoTo: StructuralSnapshot) {
undoManager?.registerUndo(withTarget: self) { document in
document.restoreStructuralSnapshot(undoTo)
document.registerStructuralUndo(name: name, undoTo: redoTo, redoTo: undoTo)
document.onChange?()
}
undoManager?.setActionName(name)
}

private func captureStructuralSnapshot() -> StructuralSnapshot {
StructuralSnapshot(store: store.captureState(), inferredTypes: inferredTypes, typeOverrides: typeOverrides)
}

private func restoreStructuralSnapshot(_ snapshot: StructuralSnapshot) {
store.restore(snapshot.store)
inferredTypes = snapshot.inferredTypes
typeOverrides = snapshot.typeOverrides
}

private struct StructuralSnapshot {
let store: CSVRowStore.StoreState
let inferredTypes: [InspectorColumnType]
let typeOverrides: [Int: InspectorColumnType]
}

private func registerUndo(_ action: @escaping (CSVDocument) -> Void) {
undoManager?.registerUndo(withTarget: self, handler: action)
}
Expand Down
100 changes: 100 additions & 0 deletions Plugins/CSVInspectorPlugin/CSVRowStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ final class CSVRowStore {
case remove(index: Int)
}

enum SplitSpec {
case literal(String)
case regex(NSRegularExpression)
}

struct StoreState {
let columnNames: [String]
let headerRef: RowRef
let logicalRows: [RowRef]
let columnTransforms: [ColumnTransform]
}

struct Snapshot: InspectorDataSnapshot {
let data: Data
let parser: CSVStreamingParser
Expand Down Expand Up @@ -269,6 +281,94 @@ final class CSVRowStore {
return previous
}

func splitColumn(at index: Int, spec: SplitSpec) {
guard index >= 0, index < columnNames.count else { return }
let baseName = columnNames[index]
var rowCells: [[String]] = []
var pieceRows: [[String]] = []
rowCells.reserveCapacity(logicalRows.count)
pieceRows.reserveCapacity(logicalRows.count)
var pieceCount = 1
for row in logicalRows.indices {
let cells = cells(forRow: row)
let value = index < cells.count ? cells[index] : ""
let pieces = Self.split(value, spec: spec)
pieceCount = max(pieceCount, pieces.count)
rowCells.append(cells)
pieceRows.append(pieces)
}
let newNames = (0..<pieceCount).map { "\(baseName) \($0 + 1)" }
for row in logicalRows.indices {
var cells = rowCells[row]
let pieces = pieceRows[row]
let padded = (0..<pieceCount).map { $0 < pieces.count ? pieces[$0] : "" }
if index < cells.count { cells.remove(at: index) }
cells.insert(contentsOf: padded, at: min(index, cells.count))
logicalRows[row] = .materialized(cells)
}
columnNames.remove(at: index)
columnNames.insert(contentsOf: newNames, at: index)
finishStructuralRewrite()
}

func mergeColumns(at index: Int, separator: String) {
guard index >= 0, index + 1 < columnNames.count else { return }
for row in logicalRows.indices {
var cells = cells(forRow: row)
while cells.count <= index + 1 { cells.append("") }
cells[index] = cells[index] + separator + cells[index + 1]
cells.remove(at: index + 1)
logicalRows[row] = .materialized(cells)
}
columnNames.remove(at: index + 1)
finishStructuralRewrite()
}

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

func restore(_ state: StoreState) {
columnNames = state.columnNames
headerRef = state.headerRef
logicalRows = state.logicalRows
columnTransforms = state.columnTransforms
cache.removeAll()
cacheOrder.removeAll()
}

static func split(_ value: String, spec: SplitSpec) -> [String] {
switch spec {
case .literal(let separator):
guard !separator.isEmpty else { return [value] }
return value.components(separatedBy: separator)
case .regex(let regex):
let text = value as NSString
guard text.length > 0 else { return [value] }
var pieces: [String] = []
var lastEnd = 0
regex.enumerateMatches(in: value, range: NSRange(location: 0, length: text.length)) { match, _, _ in
guard let match, match.range.length > 0 else { return }
Comment on lines +355 to +356

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 Handle zero-width regex separators

When users choose Regex mode with a valid zero-width separator such as (?=,), ^, or a lookahead/lookbehind, this guard skips every match and returns the original value as one piece. The UI promises to split at every regex match, so these commonly useful boundary patterns silently produce no split; process zero-length matches while advancing safely instead of discarding them.

Useful? React with 👍 / 👎.

pieces.append(text.substring(with: NSRange(location: lastEnd, length: match.range.location - lastEnd)))
lastEnd = match.range.location + match.range.length
}
pieces.append(text.substring(from: lastEnd))
return pieces
}
}

private func finishStructuralRewrite() {
headerRef = .materialized(columnNames)
columnTransforms = []
cache.removeAll()
cacheOrder.removeAll()
}

private func applyColumnTransforms(_ cells: [String]) -> [String] {
Self.applyColumnTransforms(cells, transforms: columnTransforms)
}
Expand Down
7 changes: 7 additions & 0 deletions Plugins/TableProPluginKit/DocumentInspectorPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ public protocol InspectorDocument: AnyObject {
func insertColumn(at index: Int, name: String)
func removeColumn(at index: Int)
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 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) {}
}
10 changes: 10 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,16 @@ struct AppMenuCommands: Commands {
}
.disabled(!keyWindowIsInspector)

Button("Split Column…") {
NSApp.sendAction(#selector(InspectorViewController.inspectorSplitColumn(_:)), to: nil, from: nil)
}
.disabled(!keyWindowIsInspector)

Button("Merge Columns…") {
NSApp.sendAction(#selector(InspectorViewController.inspectorMergeColumns(_:)), to: nil, from: nil)
}
.disabled(!keyWindowIsInspector)

Button("Delete Column") {
NSApp.sendAction(#selector(InspectorViewController.inspectorDeleteColumn(_:)), to: nil, from: nil)
}
Expand Down
20 changes: 18 additions & 2 deletions TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ enum InspectorColumnMenuBuilder {
static func structureItems(
forColumn index: Int,
currentType: InspectorColumnType,
deleteColumns: [Int]
deleteColumns: [Int],
canMerge: Bool
) -> [NSMenuItem] {
let rename = actionItem(
title: String(localized: "Rename Column…"),
Expand All @@ -28,6 +29,11 @@ enum InspectorColumnMenuBuilder {
action: #selector(InspectorViewController.inspectorInsertColumnRight(_:)),
column: index
)
let split = actionItem(
title: String(localized: "Split Column…"),
action: #selector(InspectorViewController.inspectorSplitColumn(_:)),
column: index
)
let changeType = NSMenuItem(title: String(localized: "Change Type"), action: nil, keyEquivalent: "")
changeType.submenu = typeSubmenu(forColumn: index, currentType: currentType)
let delete = actionItem(
Expand All @@ -38,7 +44,17 @@ enum InspectorColumnMenuBuilder {
column: index
)
delete.representedObject = deleteColumns
return [rename, insertLeft, insertRight, changeType, .separator(), delete]

var items = [rename, insertLeft, insertRight, split]
if canMerge {
items.append(actionItem(
title: String(localized: "Merge Columns…"),
action: #selector(InspectorViewController.inspectorMergeColumns(_:)),
column: index
))
}
items.append(contentsOf: [changeType, .separator(), delete])
return items
}

static func typeSubmenu(forColumn index: Int, currentType: InspectorColumnType) -> NSMenu {
Expand Down
Loading
Loading