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
20 changes: 20 additions & 0 deletions apple/TablePro/Core/DataFiles/DataFileController+Editing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,31 @@ extension DataFileController {
let original = table.cell(key: key, column: tabularColumn)
let cell = text.map { editedCell($0, replacing: original, column: id) } ?? nullCell
guard cell != original else { return }
if pendingCellEdits != nil {
pendingCellEdits?.append((key: key, columnID: id, cell: cell))
return
}
var updated = table
updated.setCells([(key: key, columnID: id, cell: cell)])
commit(updated, actionName: String(localized: "Edit Cell"))
}

/// The cell edits made inside `work` are one undo step and one page refresh, rather than a whole page rebuilt
/// for every cell of a paste.
func batchCellEdits(actionName: String, _ work: () -> Void) {
guard pendingCellEdits == nil else {
work()
return
}
pendingCellEdits = []
work()
let edits = pendingCellEdits ?? []
pendingCellEdits = nil
guard !edits.isEmpty, var updated = table else { return }
updated.setCells(edits)
commit(updated, actionName: actionName)
}

func blankRow() -> [TabularCell] {
guard let table else { return [] }
return Array(repeating: table.source.absentCell, count: table.columnCount)
Expand Down
25 changes: 19 additions & 6 deletions apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,29 @@ extension DataFileController {
}
}

func applyCleanup(_ operation: TabularCleanupOperation, columns: [TabularColumnID], keys: [Int], actionName: String) {
guard !columns.isEmpty, !keys.isEmpty else { return }
func applyCleanup(_ operation: TabularCleanupOperation, target: DataFileCellTarget, actionName: String) {
let cells = target.cells.filter { !$0.keys.isEmpty }
guard !cells.isEmpty else { return }
runMutation(title: String(localized: "Updating Cells…"), actionName: actionName) { table, progress in
let result = try await TabularCleanup.apply(operation, columns: columns, keys: keys, in: table, progress: progress)
guard result.changedCells > 0 else {
var values: [TabularColumnID: ColumnValues] = [:]
var changed = 0
let share = 1 / Double(cells.count)
for (index, column) in cells.enumerated() {
let result = try await TabularCleanup.apply(
operation,
columns: [column.column],
keys: column.keys,
in: table
) { progress((Double(index) + $0) * share) }
values.merge(result.values) { _, latest in latest }
changed += result.changedCells
}
guard changed > 0 else {
return DataFileMutationOutcome(table: nil, message: String(localized: "No cells changed."))
}
return DataFileMutationOutcome(
table: Self.applying(result.values, to: table),
message: Self.changedCellsMessage(result.changedCells)
table: Self.applying(values, to: table),
message: Self.changedCellsMessage(changed)
)
}
}
Expand Down
67 changes: 56 additions & 11 deletions apple/TablePro/Core/DataFiles/DataFileController+Selection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,21 @@
import Foundation
import TableProTabular

struct DataFileCellTarget: Equatable {
/// One column and the rows a command changes in it.
struct DataFileColumnCells: Equatable, Sendable {
let column: TabularColumnID
let keys: [Int]
let columns: [TabularColumnID]
}

/// Each column keeps its own rows, so a selection of separate cells changes those cells and not the rows and columns
/// between them.
struct DataFileCellTarget: Equatable {
let cells: [DataFileColumnCells]
let coversWholeColumns: Bool

static func wholeColumns(_ columns: [TabularColumnID], keys: [Int]) -> DataFileCellTarget {
DataFileCellTarget(cells: columns.map { DataFileColumnCells(column: $0, keys: keys) }, coversWholeColumns: true)
}
}

extension DataFileController {
Expand Down Expand Up @@ -40,27 +51,61 @@ extension DataFileController {

func cellTarget(explicitColumn: TabularColumnID? = nil) -> DataFileCellTarget? {
if let explicitColumn {
return DataFileCellTarget(keys: visibleKeys(), columns: [explicitColumn], coversWholeColumns: true)
return .wholeColumns([explicitColumn], keys: visibleKeys())
}
let columns = selectedColumnIDs()
if !columns.isEmpty {
return DataFileCellTarget(keys: visibleKeys(), columns: columns, coversWholeColumns: true)
return .wholeColumns(columns, keys: visibleKeys())
}
guard let coordinator = gridCoordinator else { return nil }
guard !gridSelection.isEmpty else { return focusedCellTarget() }
let dataColumns = coordinator.dataColumnIndices(in: gridSelection.affectedColumns)
let ids = dataColumns.compactMap { columnNames.ids.indices.contains($0) ? columnNames.ids[$0] : nil }
let keys = gridSelection.affectedRows.compactMap { key(forPageRow: $0) }
guard !ids.isEmpty, !keys.isEmpty else { return nil }
return DataFileCellTarget(keys: keys, columns: ids, coversWholeColumns: false)
var pairs: [(column: Int, pageRow: Int)] = []
for row in gridSelection.affectedRows {
for column in coordinator.dataColumnIndices(in: gridSelection.columns(in: row)) {
pairs.append((column: column, pageRow: row))
}
}
return cellTarget(pairs: pairs)
}

/// The cells the grid resolved, read the way `cellTarget()` reads a selection: whole selected columns cover every
/// visible row, and anything else covers those cells only.
func cellTarget(for targets: [SetValueTarget]) -> DataFileCellTarget? {
guard let target = cellTarget(pairs: targets.map { (column: $0.columnIndex, pageRow: $0.displayRow) }) else {
return nil
}
let wholeColumns = selectedColumnIDs()
let columns = target.cells.map(\.column)
guard !wholeColumns.isEmpty, Set(columns).isSubset(of: Set(wholeColumns)) else { return target }
return .wholeColumns(columns, keys: visibleKeys())
}

private func cellTarget(pairs: [(column: Int, pageRow: Int)]) -> DataFileCellTarget? {
var order: [Int] = []
var keysByColumn: [Int: [Int]] = [:]
var seen: [Int: Set<Int>] = [:]
for pair in pairs where columnNames.ids.indices.contains(pair.column) {
guard let key = key(forPageRow: pair.pageRow), seen[pair.column, default: []].insert(key).inserted else {
continue
}
if keysByColumn[pair.column] == nil {
order.append(pair.column)
}
keysByColumn[pair.column, default: []].append(key)
}
guard !order.isEmpty else { return nil }
let cells = order.map { DataFileColumnCells(column: columnNames.ids[$0], keys: keysByColumn[$0] ?? []) }
return DataFileCellTarget(cells: cells, coversWholeColumns: false)
}

private func focusedCellTarget() -> DataFileCellTarget? {
guard let focused = activeCell() else { return nil }
let selectedKeys = selectedRowIndices.sorted().compactMap { key(forPageRow: $0) }
return DataFileCellTarget(
keys: selectedKeys.contains(focused.key) ? selectedKeys : [focused.key],
columns: [focused.column],
cells: [DataFileColumnCells(
column: focused.column,
keys: selectedKeys.contains(focused.key) ? selectedKeys : [focused.key]
)],
coversWholeColumns: false
)
}
Expand Down
2 changes: 2 additions & 0 deletions apple/TablePro/Core/DataFiles/DataFileController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ final class DataFileController: ObservableObject {
weak var undoManager: UndoManager?
weak var gridCoordinator: TableViewCoordinator?
var onEdited: (() -> Void)?
/// Set while `batchCellEdits` runs, so the cell edits made inside it land as one commit.
var pendingCellEdits: [(key: Int, columnID: TabularColumnID, cell: TabularCell)]?

private(set) var kind: DataFileKind?
private(set) var content: DataFileContent?
Expand Down
11 changes: 7 additions & 4 deletions apple/TablePro/Core/Menu/EditMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,13 @@ enum EditMenuBuilder {
action: #selector(SetValueCommandResponding.generateUUIDv7(_:)),
shortcut: .generateUUIDv7,
keyboard: keyboard
),
MenuItemFactory.separator,
MenuItemFactory.item(
String(localized: "Set Cells to Value…"),
action: #selector(SetValueCommandResponding.setCellsToValue(_:)),
shortcut: .setCellsToValue,
keyboard: keyboard
)
])
}
Expand Down Expand Up @@ -315,10 +322,6 @@ enum EditMenuBuilder {
String(localized: "Fill Down"),
action: #selector(DataFileSplitViewController.dataFileFillDown(_:))
),
MenuItemFactory.item(
String(localized: "Set Cells to Value…"),
action: #selector(DataFileSplitViewController.dataFileSetCellsToValue(_:))
),
MenuItemFactory.item(
String(localized: "Trim Whitespace"),
action: #selector(DataFileSplitViewController.dataFileTrimWhitespace(_:))
Expand Down
1 change: 1 addition & 0 deletions apple/TablePro/Core/Menu/SetValueCommandResponding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ import AppKit
func setValueDefault(_ sender: Any?)
func generateUUIDv4(_ sender: Any?)
func generateUUIDv7(_ sender: Any?)
func setCellsToValue(_ sender: Any?)
}
6 changes: 4 additions & 2 deletions apple/TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case setValueDefault
case generateUUIDv4
case generateUUIDv7
case setCellsToValue
case restorePreviousValues
case truncateTable
case toggleHeaderRow
Expand Down Expand Up @@ -195,7 +196,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .undo, .redo, .cut, .copy, .copyCellValue, .copyRowsExplicit, .copyWithHeaders, .copyAsJson,
.copyAsCsv, .copyAsCsvWithHeaders, .copyAsMarkdown, .copyAsInClause, .copyAsInsert, .copyAsUpdate,
.paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow,
.setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7,
.setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7, .setCellsToValue,
.restorePreviousValues,
.truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage,
.nextPage, .firstPage, .lastPage, .refresh, .export, .importData, .jumpToColumn:
Expand Down Expand Up @@ -229,7 +230,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
.previewSQL, .aiReviewQuery, .aiExplainQuery, .aiOptimizeQuery:
return .editor
case .previousPage, .nextPage, .firstPage, .lastPage, .addRow, .duplicateRow,
.setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7,
.setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7, .setCellsToValue,
.delete, .truncateTable, .previewFKReference, .saveAsFavorite,
.copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters, .jumpToColumn,
.copyCellValue, .copyAsCsv, .copyAsCsvWithHeaders, .copyAsMarkdown, .copyAsInClause,
Expand Down Expand Up @@ -330,6 +331,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .setValueDefault: return String(localized: "Set Value to Default")
case .generateUUIDv4: return SetValueOptions.title(for: .uuid(.v4))
case .generateUUIDv7: return SetValueOptions.title(for: .uuid(.v7))
case .setCellsToValue: return String(localized: "Set Cells to Value")
case .restorePreviousValues: return String(localized: "Restore Previous Values")
case .truncateTable: return String(localized: "Truncate Table")
case .toggleHeaderRow: return String(localized: "Switch First Row Between Header/Data")
Expand Down
9 changes: 9 additions & 0 deletions apple/TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -195905,6 +195905,12 @@
}
}
}
},
"This sets %1$lld of %2$lld cells. The others cannot be edited." : {

},
"This sets %lld cells." : {

},
"This sets %lld loaded rows. Review and Save to apply." : {
"localizations" : {
Expand Down Expand Up @@ -195945,6 +195951,9 @@
}
}
}
},
"This sets 1 cell." : {

},
"This sets 1 loaded row. Review and Save to apply." : {
"localizations" : {
Expand Down
12 changes: 12 additions & 0 deletions apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ final class DataFileGridDelegate: DataGridViewDelegate {
return DataFileColumnMenuBuilder.rowItems(forPageRow: displayRow)
}

func dataGridSetCellsToValue(_ targets: [SetValueTarget]) -> Bool {
guard let owner else { return false }
if let target = controller.cellTarget(for: targets) {
owner.presentSetCellsToValue(target)
}
return true
}

func dataGridBatchEdits(actionName: String, _ edits: () -> Void) {
controller.batchCellEdits(actionName: actionName, edits)
}

func dataGridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? {
SelectionSummaryColumnPolicy(
rules: controller.columnNames.ids.map { DataFileColumnTypes.summaryRule(for: controller.kind(of: $0)) },
Expand Down
13 changes: 0 additions & 13 deletions apple/TablePro/Views/DataFiles/DataFilePrompts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,6 @@ enum DataFilePrompts {
}
}

static func value(window: NSWindow?, completion: @escaping (String?) -> Void) {
let field = textField(initial: "", placeholder: String(localized: "Value"))
present(
message: String(localized: "Set Cells to Value"),
informative: String(localized: "Every targeted cell gets this value."),
confirm: String(localized: "Set"),
accessory: field,
window: window
) { confirmed in
completion(confirmed ? field.stringValue : nil)
}
}

static func splitSeparator(window: NSWindow?, completion: @escaping (TabularSplitSeparator?) -> Void) {
let field = textField(initial: ",", placeholder: String(localized: "Separator"))
let regexToggle = NSButton(checkboxWithTitle: String(localized: "Regular Expression"), target: nil, action: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,37 +159,35 @@ extension DataFileSplitViewController {
}

@objc func dataFileFillDown(_ sender: Any?) {
guard let target = controller.cellTarget(explicitColumn: clickedColumn(from: sender)), target.keys.count > 1 else { return }
controller.applyCleanup(.fillDown, columns: target.columns, keys: target.keys, actionName: String(localized: "Fill Down"))
guard let target = controller.cellTarget(explicitColumn: clickedColumn(from: sender)),
target.cells.contains(where: { $0.keys.count > 1 }) else { return }
controller.applyCleanup(.fillDown, target: target, actionName: String(localized: "Fill Down"))
}

@objc func dataFileSetCellsToValue(_ sender: Any?) {
guard let target = controller.cellTarget(explicitColumn: clickedColumn(from: sender)) else { return }
DataFilePrompts.value(window: view.window) { [weak self] value in
presentSetCellsToValue(target)
}

func presentSetCellsToValue(_ target: DataFileCellTarget) {
SetCellsToValuePrompt.present(
informative: String(localized: "Every targeted cell gets this value."),
window: view.window
) { [weak self] value in
guard let value else { return }
self?.controller.applyCleanup(
.setValue(value),
columns: target.columns,
keys: target.keys,
actionName: String(localized: "Set Cells to Value")
)
self?.controller.applyCleanup(.setValue(value), target: target, actionName: String(localized: "Set Cells to Value"))
}
}

@objc func dataFileTrimWhitespace(_ sender: Any?) {
guard let target = controller.cellTarget(explicitColumn: clickedColumn(from: sender)) else { return }
controller.applyCleanup(.trimWhitespace, columns: target.columns, keys: target.keys, actionName: String(localized: "Trim Whitespace"))
controller.applyCleanup(.trimWhitespace, target: target, actionName: String(localized: "Trim Whitespace"))
}

@objc func dataFileChangeCase(_ sender: Any?) {
guard let style = (sender as? NSMenuItem)?.representedObject as? DataFileCaseAssignment,
let target = controller.cellTarget(explicitColumn: style.column) else { return }
controller.applyCleanup(
.changeCase(style.style),
columns: target.columns,
keys: target.keys,
actionName: String(localized: "Change Case")
)
controller.applyCleanup(.changeCase(style.style), target: target, actionName: String(localized: "Change Case"))
}

@objc func dataFileReplaceInColumn(_ sender: Any?) {
Expand Down
23 changes: 22 additions & 1 deletion apple/TablePro/Views/Results/DataGridRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -703,10 +703,24 @@ class DataGridRowView: NSTableRowView {
guard coordinator.isEditable, dataColumnIndex >= 0 else { return }

let clicked = SetValueTarget(displayRow: rowIndex, columnIndex: dataColumnIndex)
let targets = coordinator.setValueTargets(clicked: clicked)
let setValueMenu = buildSetValueMenu(
commands: coordinator.setValueCommands(for: coordinator.setValueTargets(clicked: clicked)),
commands: coordinator.setValueCommands(for: targets),
clickedColumn: dataColumnIndex
)
if coordinator.isSetCellsToValueOffered(for: targets) {
if !setValueMenu.items.isEmpty {
setValueMenu.addItem(.separator())
}
let item = NSMenuItem(
title: String(localized: "Set Cells to Value…"),
action: #selector(setCellsToValueFromContextMenu(_:)),
keyEquivalent: ""
)
item.representedObject = dataColumnIndex
item.target = self
setValueMenu.addItem(item)
}
if !setValueMenu.items.isEmpty {
let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "")
setValueItem.submenu = setValueMenu
Expand Down Expand Up @@ -759,6 +773,13 @@ class DataGridRowView: NSTableRowView {
coordinator.applySetValue(choice.command, to: coordinator.setValueTargets(clicked: clicked))
}

@objc private func setCellsToValueFromContextMenu(_ sender: NSMenuItem) {
guard let coordinator, let column = sender.representedObject as? Int else { return }
coordinator.setCellsToValue(
coordinator.setValueTargets(clicked: SetValueTarget(displayRow: rowIndex, columnIndex: column))
)
}

@objc private func deleteRow() {
guard let coordinator else { return }
coordinator.delegate?.dataGridDeleteRows(coordinator.currentRowSelection(fallbackRow: rowIndex))
Expand Down
Loading
Loading