From 68c7ffe157dcfe3308b98c2939d57bddc7f326e4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 22 Jul 2026 01:15:46 +0700 Subject: [PATCH 1/3] feat(inspector): split and merge columns in the CSV editor --- CHANGELOG.md | 1 + Plugins/CSVInspectorPlugin/CSVDocument.swift | 61 +++++++++ Plugins/CSVInspectorPlugin/CSVRowStore.swift | 97 +++++++++++++++ .../DocumentInspectorPlugin.swift | 7 ++ TablePro/TableProApp.swift | 10 ++ .../InspectorColumnMenuBuilder.swift | 20 ++- .../Inspector/InspectorViewController.swift | 116 +++++++++++++++++- .../Inspector/InspectorWindowController.swift | 4 +- TableProTests/Plugins/CSVInspectorTests.swift | 42 +++++++ .../InspectorColumnMenuBuilderTests.swift | 42 +++++-- docs/features/csv-inspector.mdx | 4 +- 11 files changed, 387 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abe459bd0..9eab73e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/CSVInspectorPlugin/CSVDocument.swift b/Plugins/CSVInspectorPlugin/CSVDocument.swift index 71849eb20..abe651f55 100644 --- a/Plugins/CSVInspectorPlugin/CSVDocument.swift +++ b/Plugins/CSVInspectorPlugin/CSVDocument.swift @@ -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 = [:] + } + + 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) } diff --git a/Plugins/CSVInspectorPlugin/CSVRowStore.swift b/Plugins/CSVInspectorPlugin/CSVRowStore.swift index e33845a7a..585da1f77 100644 --- a/Plugins/CSVInspectorPlugin/CSVRowStore.swift +++ b/Plugins/CSVInspectorPlugin/CSVRowStore.swift @@ -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 @@ -269,6 +281,91 @@ 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 pieceRows: [[String]] = [] + 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) + pieceRows.append(pieces) + } + let newNames = (0..= 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 } + 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) } diff --git a/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift b/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift index b094ed0b0..22e0bc79e 100644 --- a/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift +++ b/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift @@ -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) {} +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 548699bdb..5574ba052 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -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) } diff --git a/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift b/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift index 82b4c84d0..eac782300 100644 --- a/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift +++ b/TablePro/Views/Inspector/InspectorColumnMenuBuilder.swift @@ -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…"), @@ -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( @@ -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 { diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index ff1bed7a2..5582f77e2 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -359,6 +359,116 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation return false } + @objc func inspectorSplitColumn(_ sender: Any?) { + guard let column = structuralTargetColumn(from: sender) else { return } + promptSplitColumn(column) + } + + @objc func inspectorMergeColumns(_ sender: Any?) { + guard let inspector = inspectorDocument, + let column = structuralTargetColumn(from: sender), + column + 1 < inspector.columnNames.count else { return } + promptMergeColumns(column) + } + + private func structuralTargetColumn(from sender: Any?) -> Int? { + guard let inspector = inspectorDocument, !inspector.columnNames.isEmpty else { return nil } + let count = inspector.columnNames.count + if let menuItem = sender as? NSMenuItem, menuItem.tag >= 0, menuItem.tag < count { + return menuItem.tag + } + if let first = gridDelegate.coordinator?.selectionController.selection.affectedColumns.min(), + first >= 0, first < count { + return first + } + return nil + } + + private func promptSplitColumn(_ column: Int) { + guard let inspector = inspectorDocument, let window = view.window, + column >= 0, column < inspector.columnNames.count else { return } + let alert = NSAlert() + alert.messageText = String(format: String(localized: "Split “%@”"), inspector.columnNames[column]) + alert.informativeText = String(localized: "Split each value into new columns at every match.") + alert.addButton(withTitle: String(localized: "Split")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + field.placeholderString = String(localized: "Separator or pattern") + field.usesSingleLineMode = true + let mode = NSSegmentedControl( + labels: [String(localized: "Delimiter"), String(localized: "Regex")], + trackingMode: .selectOne, + target: nil, + action: nil + ) + mode.selectedSegment = 0 + let stack = accessoryStack(with: [field, mode]) + alert.accessoryView = stack + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + self?.applySplit(column: column, separator: field.stringValue, isRegex: mode.selectedSegment == 1) + } + DispatchQueue.main.async { alert.window.makeFirstResponder(field) } + } + + private func promptMergeColumns(_ column: Int) { + guard let inspector = inspectorDocument, let window = view.window, + column + 1 < inspector.columnNames.count else { return } + let alert = NSAlert() + alert.messageText = String( + format: String(localized: "Merge “%@” with “%@”"), + inspector.columnNames[column], + inspector.columnNames[column + 1] + ) + alert.informativeText = String(localized: "Join the two columns into one, placing this text between the values.") + alert.addButton(withTitle: String(localized: "Merge")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + field.placeholderString = String(localized: "Separator (optional)") + field.usesSingleLineMode = true + alert.accessoryView = accessoryStack(with: [field]) + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + self?.inspectorDocument?.mergeColumns(at: column, separator: field.stringValue) + } + DispatchQueue.main.async { alert.window.makeFirstResponder(field) } + } + + private func applySplit(column: Int, separator: String, isRegex: Bool) { + guard !separator.isEmpty else { return } + if isRegex, (try? NSRegularExpression(pattern: separator)) == nil { + presentInvalidPattern() + return + } + inspectorDocument?.splitColumn(at: column, separator: separator, isRegex: isRegex) + } + + private func presentInvalidPattern() { + guard let window = view.window else { return } + let alert = NSAlert() + alert.messageText = String(localized: "Invalid pattern") + alert.informativeText = String(localized: "That regular expression could not be read. Check the syntax and try again.") + alert.addButton(withTitle: String(localized: "OK")) + alert.beginSheetModal(for: window) + } + + private func accessoryStack(with views: [NSView]) -> NSStackView { + let stack = NSStackView(views: views) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + for view in views { + view.widthAnchor.constraint(equalToConstant: 260).isActive = true + } + stack.frame = NSRect(x: 0, y: 0, width: 260, height: CGFloat(views.count) * 32) + return stack + } + @objc func inspectorSetColumnType(_ sender: Any?) { guard let menuItem = sender as? NSMenuItem, let assignment = menuItem.representedObject as? ColumnTypeAssignment else { return } @@ -370,7 +480,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation return InspectorColumnMenuBuilder.structureItems( forColumn: index, currentType: inspector.displayedType(forColumn: index), - deleteColumns: columnDeleteSelection(clicked: index) + deleteColumns: columnDeleteSelection(clicked: index), + canMerge: index + 1 < inspector.columnNames.count ) } @@ -457,7 +568,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation case #selector(saveDocument(_:)), #selector(saveDocumentAs(_:)), #selector(toggleInspectorFilter(_:)), #selector(inspectorAddRow(_:)), #selector(inspectorInsertRowAbove(_:)), #selector(inspectorInsertRowBelow(_:)), - #selector(inspectorInsertColumnLeft(_:)), #selector(inspectorInsertColumnRight(_:)): + #selector(inspectorInsertColumnLeft(_:)), #selector(inspectorInsertColumnRight(_:)), + #selector(inspectorSplitColumn(_:)), #selector(inspectorMergeColumns(_:)): return nsDocument != nil case #selector(inspectorDeleteColumn(_:)): guard nsDocument != nil else { return false } diff --git a/TablePro/Views/Inspector/InspectorWindowController.swift b/TablePro/Views/Inspector/InspectorWindowController.swift index f45985792..61d0dea23 100644 --- a/TablePro/Views/Inspector/InspectorWindowController.swift +++ b/TablePro/Views/Inspector/InspectorWindowController.swift @@ -155,10 +155,12 @@ final class InspectorWindowController: NSWindowController, NSWindowDelegate, NST private func columnSubmenu(forColumn index: Int, currentType: InspectorColumnType) -> NSMenu { let submenu = NSMenu() + let columnCount = (documentRef as? (any InspectorDocument))?.columnNames.count ?? 0 let items = InspectorColumnMenuBuilder.structureItems( forColumn: index, currentType: currentType, - deleteColumns: [index] + deleteColumns: [index], + canMerge: index + 1 < columnCount ) for item in items { submenu.addItem(item) diff --git a/TableProTests/Plugins/CSVInspectorTests.swift b/TableProTests/Plugins/CSVInspectorTests.swift index 0b282c647..737f3221f 100644 --- a/TableProTests/Plugins/CSVInspectorTests.swift +++ b/TableProTests/Plugins/CSVInspectorTests.swift @@ -319,6 +319,48 @@ struct CSVRowStoreTests { #expect(store.columnNames == ["a", "c"]) #expect(store.cells(forRow: 0) == ["1", "3"]) } + + @Test("split(_:spec:) splits by a literal separator") + func splitLiteral() { + #expect(CSVRowStore.split("a-b-c", spec: .literal("-")) == ["a", "b", "c"]) + #expect(CSVRowStore.split("nodash", spec: .literal("-")) == ["nodash"]) + #expect(CSVRowStore.split("x", spec: .literal("")) == ["x"]) + } + + @Test("split(_:spec:) splits by a regular expression") + func splitRegex() throws { + let regex = try NSRegularExpression(pattern: "[0-9]+") + #expect(CSVRowStore.split("a12b3c", spec: .regex(regex)) == ["a", "b", "c"]) + } + + @Test("splitColumn replaces the column with padded split pieces") + func splitColumnReplaces() { + let store = makeStore("full\nAlice Smith\nBob\n") + store.splitColumn(at: 0, spec: .literal(" ")) + #expect(store.columnNames == ["full 1", "full 2"]) + #expect(store.cells(forRow: 0) == ["Alice", "Smith"]) + #expect(store.cells(forRow: 1) == ["Bob", ""]) + } + + @Test("mergeColumns joins a column with the next using the separator") + func mergeColumnsJoins() { + let store = makeStore("first,last\nAlice,Smith\nBob,Jones\n") + store.mergeColumns(at: 0, separator: " ") + #expect(store.columnNames == ["first"]) + #expect(store.cells(forRow: 0) == ["Alice Smith"]) + #expect(store.cells(forRow: 1) == ["Bob Jones"]) + } + + @Test("captureState and restore round-trip a structural change") + func captureRestoreRoundTrip() { + let store = makeStore("first,last\nAlice,Smith\n") + let before = store.captureState() + store.mergeColumns(at: 0, separator: " ") + #expect(store.columnCount == 1) + store.restore(before) + #expect(store.columnNames == ["first", "last"]) + #expect(store.cells(forRow: 0) == ["Alice", "Smith"]) + } } @Suite("CSVWriter round-trip") diff --git a/TableProTests/Views/InspectorColumnMenuBuilderTests.swift b/TableProTests/Views/InspectorColumnMenuBuilderTests.swift index d04a23b7b..38d99e371 100644 --- a/TableProTests/Views/InspectorColumnMenuBuilderTests.swift +++ b/TableProTests/Views/InspectorColumnMenuBuilderTests.swift @@ -13,25 +13,41 @@ import Testing struct InspectorColumnMenuBuilderTests { @Test("Structure items route each action to the clicked column") func structureItemsWiring() { - let items = InspectorColumnMenuBuilder.structureItems(forColumn: 3, currentType: .text, deleteColumns: [3]) + let items = InspectorColumnMenuBuilder.structureItems( + forColumn: 3, currentType: .text, deleteColumns: [3], canMerge: true + ) - #expect(items.count == 6) #expect(items[0].action == #selector(InspectorViewController.inspectorRenameColumn(_:))) #expect(items[1].action == #selector(InspectorViewController.inspectorInsertColumnLeft(_:))) #expect(items[2].action == #selector(InspectorViewController.inspectorInsertColumnRight(_:))) - #expect(items[3].submenu != nil) - #expect(items[4].isSeparatorItem) - #expect(items[5].action == #selector(InspectorViewController.inspectorDeleteColumn(_:))) + #expect(items[3].action == #selector(InspectorViewController.inspectorSplitColumn(_:))) + #expect(items[4].action == #selector(InspectorViewController.inspectorMergeColumns(_:))) + #expect(items.last?.action == #selector(InspectorViewController.inspectorDeleteColumn(_:))) - for index in [0, 1, 2, 5] { - #expect(items[index].tag == 3) - #expect(items[index].target == nil) + for item in items where item.action != nil && !item.isSeparatorItem { + #expect(item.tag == 3) + #expect(item.target == nil) } } + @Test("Merge Columns is omitted for the last column") + func mergeOmittedWhenNoNextColumn() { + let withMerge = InspectorColumnMenuBuilder.structureItems( + forColumn: 1, currentType: .text, deleteColumns: [1], canMerge: true + ) + let withoutMerge = InspectorColumnMenuBuilder.structureItems( + forColumn: 1, currentType: .text, deleteColumns: [1], canMerge: false + ) + + #expect(withMerge.contains { $0.action == #selector(InspectorViewController.inspectorMergeColumns(_:)) }) + #expect(!withoutMerge.contains { $0.action == #selector(InspectorViewController.inspectorMergeColumns(_:)) }) + } + @Test("Delete Column is the last item, in its own trailing group") func deleteIsLastAfterSeparator() { - let items = InspectorColumnMenuBuilder.structureItems(forColumn: 0, currentType: .integer, deleteColumns: [0]) + let items = InspectorColumnMenuBuilder.structureItems( + forColumn: 0, currentType: .integer, deleteColumns: [0], canMerge: false + ) #expect(items.last?.action == #selector(InspectorViewController.inspectorDeleteColumn(_:))) #expect(items[items.count - 2].isSeparatorItem) @@ -39,11 +55,15 @@ struct InspectorColumnMenuBuilderTests { @Test("Delete item carries the target columns and pluralizes its title") func deleteTargetsAndTitle() { - let single = InspectorColumnMenuBuilder.structureItems(forColumn: 1, currentType: .text, deleteColumns: [1]) + let single = InspectorColumnMenuBuilder.structureItems( + forColumn: 1, currentType: .text, deleteColumns: [1], canMerge: true + ) #expect(single.last?.representedObject as? [Int] == [1]) #expect(single.last?.title == "Delete Column") - let multi = InspectorColumnMenuBuilder.structureItems(forColumn: 1, currentType: .text, deleteColumns: [1, 2, 3]) + let multi = InspectorColumnMenuBuilder.structureItems( + forColumn: 1, currentType: .text, deleteColumns: [1, 2, 3], canMerge: true + ) #expect(multi.last?.representedObject as? [Int] == [1, 2, 3]) #expect(multi.last?.title == "Delete Columns") } diff --git a/docs/features/csv-inspector.mdx b/docs/features/csv-inspector.mdx index 759da4d4d..7f9450d37 100644 --- a/docs/features/csv-inspector.mdx +++ b/docs/features/csv-inspector.mdx @@ -50,10 +50,12 @@ Right-click a column header, or open the toolbar `Columns` menu, to reach the sa - **Rename Column…** prompts for a new name; the column stays in place. - **Insert Column Left / Right** inserts a new column next to the one you clicked, with a name you supply. +- **Split Column…** splits each value into new columns at every match of a delimiter or a regular expression. The original column is replaced by the pieces; rows with fewer pieces get empty cells. +- **Merge Columns…** joins the column with the one to its right into a single column, placing a separator you choose between the two values. - **Change Type ▸** overrides the inferred type as Text, Integer, Real, Boolean, or Date. **Reset to Inferred** drops the override. - **Delete Column** removes the column. It sits last in the menu and is undoable with Cmd+Z. Deleting a column that holds data asks you to confirm first. -Cmd-click several column headers to select whole columns, and the menu reads **Delete Columns**, removing all of them in one undoable step. Insert Column Left / Right, and Delete Column, also appear in the Edit menu, where they act on the selected columns. +Cmd-click several column headers to select whole columns, and the menu reads **Delete Columns**, removing all of them in one undoable step. Insert Column Left / Right, Split Column…, Merge Columns…, and Delete Column also appear in the Edit menu, where they act on the selected columns. 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. From d22b41d309db99e4db45c111a09b01a11ca3d175 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 22 Jul 2026 10:31:46 +0700 Subject: [PATCH 2/3] fix(inspector): split CSV columns in one pass and keep column layout in sync --- Plugins/CSVInspectorPlugin/CSVRowStore.swift | 5 +++- .../Inspector/InspectorViewController.swift | 25 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Plugins/CSVInspectorPlugin/CSVRowStore.swift b/Plugins/CSVInspectorPlugin/CSVRowStore.swift index 585da1f77..bde5d91df 100644 --- a/Plugins/CSVInspectorPlugin/CSVRowStore.swift +++ b/Plugins/CSVInspectorPlugin/CSVRowStore.swift @@ -284,7 +284,9 @@ final class CSVRowStore { 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 { @@ -292,11 +294,12 @@ final class CSVRowStore { 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.. Date: Wed, 22 Jul 2026 13:51:48 +0700 Subject: [PATCH 3/3] test(inspector): exclude the submenu item from the CSV column-menu wiring check --- TableProTests/Views/InspectorColumnMenuBuilderTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TableProTests/Views/InspectorColumnMenuBuilderTests.swift b/TableProTests/Views/InspectorColumnMenuBuilderTests.swift index 38d99e371..fa3b1c808 100644 --- a/TableProTests/Views/InspectorColumnMenuBuilderTests.swift +++ b/TableProTests/Views/InspectorColumnMenuBuilderTests.swift @@ -24,7 +24,7 @@ struct InspectorColumnMenuBuilderTests { #expect(items[4].action == #selector(InspectorViewController.inspectorMergeColumns(_:))) #expect(items.last?.action == #selector(InspectorViewController.inspectorDeleteColumn(_:))) - for item in items where item.action != nil && !item.isSeparatorItem { + for item in items where !item.isSeparatorItem && item.submenu == nil { #expect(item.tag == 3) #expect(item.target == nil) }