From 7419f9ac81a8d3a91e590204136ac8c3f361a9c8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 02:36:57 +0700 Subject: [PATCH 1/3] feat(datagrid): write one typed value into every selected cell --- .../DataFileController+Editing.swift | 20 + .../DataFileController+Mutations.swift | 25 +- .../DataFileController+Selection.swift | 67 ++- .../Core/DataFiles/DataFileController.swift | 2 + .../TablePro/Core/Menu/EditMenuBuilder.swift | 11 +- .../Core/Menu/SetValueCommandResponding.swift | 1 + .../Models/UI/KeyboardShortcutModels.swift | 6 +- .../TablePro/Resources/Localizable.xcstrings | 9 + .../DataFiles/DataFileGridDelegate.swift | 12 + .../Views/DataFiles/DataFilePrompts.swift | 13 - .../DataFileSplitViewController+Actions.swift | 30 +- .../Views/Results/DataGridRowView.swift | 23 +- .../Views/Results/DataGridViewDelegate.swift | 7 + .../Extensions/DataGridView+CellPaste.swift | 110 +++-- .../DataGridView+SetCellsToValue.swift | 167 +++++++ .../Views/Results/KeyHandlingTableView.swift | 9 + .../Views/Results/SetCellsToValuePrompt.swift | 27 ++ .../DataFiles/DataFileControllerTests.swift | 47 ++ .../Core/Menu/MainMenuBuilderTests.swift | 32 +- .../Models/ShortcutUniquenessTests.swift | 4 +- .../DataGridRowViewSetValueTests.swift | 23 +- .../DataGridSetCellsToValueTests.swift | 420 ++++++++++++++++++ .../KeyHandlingTableViewSetValueTests.swift | 7 +- 23 files changed, 968 insertions(+), 104 deletions(-) create mode 100644 apple/TablePro/Views/Results/Extensions/DataGridView+SetCellsToValue.swift create mode 100644 apple/TablePro/Views/Results/SetCellsToValuePrompt.swift create mode 100644 apple/TableProTests/Views/Results/Extensions/DataGridSetCellsToValueTests.swift diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Editing.swift b/apple/TablePro/Core/DataFiles/DataFileController+Editing.swift index 4168ce6027..d31806303a 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Editing.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Editing.swift @@ -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) diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift b/apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift index 17c12f02dc..25f2298f97 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift @@ -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) ) } } diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Selection.swift b/apple/TablePro/Core/DataFiles/DataFileController+Selection.swift index 5f687cc4a2..ec0897ad61 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Selection.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Selection.swift @@ -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 { @@ -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] = [:] + 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 ) } diff --git a/apple/TablePro/Core/DataFiles/DataFileController.swift b/apple/TablePro/Core/DataFiles/DataFileController.swift index db33ad9552..fd23f77560 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController.swift @@ -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? diff --git a/apple/TablePro/Core/Menu/EditMenuBuilder.swift b/apple/TablePro/Core/Menu/EditMenuBuilder.swift index 48da255e55..d524a75eb9 100644 --- a/apple/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/EditMenuBuilder.swift @@ -177,6 +177,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 ) ]) } @@ -271,10 +278,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(_:)) diff --git a/apple/TablePro/Core/Menu/SetValueCommandResponding.swift b/apple/TablePro/Core/Menu/SetValueCommandResponding.swift index 2518bbdc5c..e7a90f17cc 100644 --- a/apple/TablePro/Core/Menu/SetValueCommandResponding.swift +++ b/apple/TablePro/Core/Menu/SetValueCommandResponding.swift @@ -14,4 +14,5 @@ import AppKit func setValueDefault(_ sender: Any?) func generateUUIDv4(_ sender: Any?) func generateUUIDv7(_ sender: Any?) + func setCellsToValue(_ sender: Any?) } diff --git a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift index c29ce3eac6..e9bca79868 100644 --- a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -112,6 +112,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case setValueDefault case generateUUIDv4 case generateUUIDv7 + case setCellsToValue case restorePreviousValues case truncateTable case toggleHeaderRow @@ -185,7 +186,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { return .editor case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .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: @@ -219,7 +220,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, /// Named rather than left to the `default:` below. It reverses a row the grid is @@ -309,6 +310,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") diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index b34b1dc845..3a9ab0870a 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -195366,6 +195366,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" : { @@ -195406,6 +195412,9 @@ } } } + }, + "This sets 1 cell." : { + }, "This sets 1 loaded row. Review and Save to apply." : { "localizations" : { diff --git a/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift b/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift index ee1e45023b..5f3df6bf79 100644 --- a/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift +++ b/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift @@ -110,6 +110,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)) }, diff --git a/apple/TablePro/Views/DataFiles/DataFilePrompts.swift b/apple/TablePro/Views/DataFiles/DataFilePrompts.swift index bed99c4fa8..8a95ffa15f 100644 --- a/apple/TablePro/Views/DataFiles/DataFilePrompts.swift +++ b/apple/TablePro/Views/DataFiles/DataFilePrompts.swift @@ -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) diff --git a/apple/TablePro/Views/DataFiles/DataFileSplitViewController+Actions.swift b/apple/TablePro/Views/DataFiles/DataFileSplitViewController+Actions.swift index c771ecffd0..a90aadc642 100644 --- a/apple/TablePro/Views/DataFiles/DataFileSplitViewController+Actions.swift +++ b/apple/TablePro/Views/DataFiles/DataFileSplitViewController+Actions.swift @@ -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?) { diff --git a/apple/TablePro/Views/Results/DataGridRowView.swift b/apple/TablePro/Views/Results/DataGridRowView.swift index 6e11b954ce..4d267a0acb 100644 --- a/apple/TablePro/Views/Results/DataGridRowView.swift +++ b/apple/TablePro/Views/Results/DataGridRowView.swift @@ -698,10 +698,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 @@ -754,6 +768,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)) diff --git a/apple/TablePro/Views/Results/DataGridViewDelegate.swift b/apple/TablePro/Views/Results/DataGridViewDelegate.swift index bf1fcd6453..3faee2cfa0 100644 --- a/apple/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/apple/TablePro/Views/Results/DataGridViewDelegate.swift @@ -64,6 +64,11 @@ protocol DataGridViewDelegate: AnyObject { func dataGridTextTone(row displayRow: Int, column: Int) -> NSColor? /// Nil lets the grid derive the rules from its column types and display formats. func dataGridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? + /// True when the window ran its own Set Cells to Value over these cells, so the grid's items and the window's do + /// the same thing. + func dataGridSetCellsToValue(_ targets: [SetValueTarget]) -> Bool + /// Runs the cell edits of one command, so an owner that writes each edit through can write them once. + func dataGridBatchEdits(actionName: String, _ edits: () -> Void) } extension DataGridViewDelegate { @@ -76,6 +81,8 @@ extension DataGridViewDelegate { func dataGridAccessibilityText(row displayRow: Int, column: Int) -> String? { nil } func dataGridTextTone(row displayRow: Int, column: Int) -> NSColor? { nil } func dataGridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? { nil } + func dataGridSetCellsToValue(_ targets: [SetValueTarget]) -> Bool { false } + func dataGridBatchEdits(actionName: String, _ edits: () -> Void) { edits() } func dataGridDidEditCell(row: Int, column: Int, newValue: String?) {} func dataGridDeleteRows(_ indices: Set) {} func dataGridCopyRows(_ indices: Set) {} diff --git a/apple/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift b/apple/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift index ab742dee68..519a8a1a1a 100644 --- a/apple/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift +++ b/apple/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift @@ -39,33 +39,29 @@ extension TableViewCoordinator { let undoManager = tableView?.window?.undoManager undoManager?.beginUndoGrouping() - let tableRows = tableRowsProvider() var refusedCell = false - for (gridRow, fields) in block.lines.enumerated() { - let targetRow = anchorRow + gridRow - guard targetRow < maxRow else { break } - guard !isRowDeleted(displayRow: targetRow) else { continue } - - for (gridCol, field) in fields.enumerated() { - let targetPosition = anchorPosition + gridCol - guard targetPosition < maxCol else { break } - guard let targetCol = dataColumnIndex(atDisplayPosition: targetPosition) else { continue } - let isCommitted: Bool - if block.isGridCopy { - isCommitted = commitCopiedField( + batchEdits(actionName: String(localized: "Paste Cells")) { + for (gridRow, fields) in block.lines.enumerated() { + let targetRow = anchorRow + gridRow + guard targetRow < maxRow else { break } + guard !isRowDeleted(displayRow: targetRow) else { continue } + + for (gridCol, field) in fields.enumerated() { + let targetPosition = anchorPosition + gridCol + guard targetPosition < maxCol else { break } + guard let targetCol = dataColumnIndex(atDisplayPosition: targetPosition) else { continue } + guard let entry = pastedEntry( field, + of: block, isExpression: block.isExpression(line: gridRow, field: gridCol), - row: targetRow, columnIndex: targetCol - ) - } else { - let text = field.flatMap { - Self.pastedText($0, columnType: tableRows.editingColumnType(at: targetCol)) + ) else { + refusedCell = true + continue } - isCommitted = commitEnteredText(text, row: targetRow, columnIndex: targetCol) - } - if !isCommitted { - refusedCell = true + commitTypedCellEdit( + row: targetRow, columnIndex: targetCol, newValue: entry.value, isExpression: entry.isExpression + ) } } } @@ -97,6 +93,33 @@ extension TableViewCoordinator { } } + /// Answered without mutating anything so menu validation can ask it. + func canPasteValueIntoSelectedCells() -> Bool { + selectionFill() != nil + } + + /// One value on the clipboard goes into every cell of a multi-cell selection, the way a spreadsheet pastes it. + /// False for anything else, which keeps the anchor paste: a block, one selected cell, or no cell selection. + func pasteValueIntoSelectedCells() -> Bool { + guard let fill = selectionFill() else { return false } + let isExpression = fill.block.isExpression(line: 0, field: 0) + stageEntry(into: fill.targets, actionName: String(localized: "Paste Cells")) { columnIndex in + pastedEntry(fill.field, of: fill.block, isExpression: isExpression, columnIndex: columnIndex) + } + return true + } + + private func selectionFill() -> (block: CellPasteBlock, field: String?, targets: [SetValueTarget])? { + guard !selectionController.selection.isEmpty, + let block = clipboardCellBlock(), + block.lines.count == 1, + let line = block.lines.first, + line.count == 1 else { return nil } + let targets = setValueTargets(clicked: nil) + guard targets.count > 1 else { return nil } + return (block: block, field: line[0], targets: targets) + } + /// The clipboard as a cell block, or nil when it belongs to the row-paste path instead. /// /// A clipboard holding one value fills the focused cell, which is what copying a cell and @@ -105,14 +128,7 @@ extension TableViewCoordinator { /// table is still whole rows and stays with row paste, and so is anything TablePro's own row /// copy wrote, which carries its own pasteboard type. private func cellPasteBlock(anchorRow: Int, anchorColumn: Int) -> CellPasteBlock? { - guard isEditable else { return nil } - if ClipboardService.shared.hasGridRows { return nil } - guard let text = ClipboardService.shared.readText(), !text.isEmpty else { return nil } - guard (text as NSString).length <= Self.maxCellPasteLength else { return nil } - - let block = Self.gridCopiedBlock(matching: text) - ?? CellPasteBlock(lines: Self.pastedLines(text).map { $0.components(separatedBy: "\t") }, isGridCopy: false) - guard let firstLine = block.lines.first else { return nil } + guard let block = clipboardCellBlock(), let firstLine = block.lines.first else { return nil } /// Whole rows are routed away by the result's own column count, not the presented one. /// `TSVRowParser` fills from data index 0 and knows nothing about which columns are on @@ -136,6 +152,16 @@ extension TableViewCoordinator { return block } + /// The clipboard's fields, before anything decides where they land. + private func clipboardCellBlock() -> CellPasteBlock? { + guard isEditable else { return nil } + if ClipboardService.shared.hasGridRows { return nil } + guard let text = ClipboardService.shared.readText(), !text.isEmpty else { return nil } + guard (text as NSString).length <= Self.maxCellPasteLength else { return nil } + return Self.gridCopiedBlock(matching: text) + ?? CellPasteBlock(lines: Self.pastedLines(text).map { $0.components(separatedBy: "\t") }, isGridCopy: false) + } + /// The fields of the grid's own copy, while the clipboard still holds the text it wrote. They are /// taken as they are rather than split from the text, where a value with a tab or a line break /// in it would become several fields and a NULL would read as the text `NULL`. @@ -144,16 +170,26 @@ extension TableViewCoordinator { return CellPasteBlock(lines: copied.fields, isGridCopy: true, expressionFields: copied.expressionFields ?? [:]) } + /// What a pasted field stages in a column, or nil when the column holds neither it nor NULL in its place. + /// /// The grid's copy says which fields were SQL, so a field it marked stages as SQL for the save to check against - /// this engine, and any other field is text however it reads: a stored `CURRENT_TIMESTAMP` is not promoted. - private func commitCopiedField(_ field: String?, isExpression: Bool, row: Int, columnIndex: Int) -> Bool { + /// this engine, and any other field is text however it reads: a stored `CURRENT_TIMESTAMP` is not promoted. Text + /// from another app is read the way a typed cell is. + private func pastedEntry( + _ field: String?, + of block: CellPasteBlock, + isExpression: Bool, + columnIndex: Int + ) -> StagedCellEntry? { + let tableRows = tableRowsProvider() + guard block.isGridCopy else { + let text = field.flatMap { Self.pastedText($0, columnType: tableRows.editingColumnType(at: columnIndex)) } + return enteredCell(text, columnIndex: columnIndex) + } if isExpression, let field { - commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: .text(field), isExpression: true) - return true + return (value: .text(field), isExpression: true) } - guard let value = tableRowsProvider().enteredValue(field, forColumn: columnIndex) else { return false } - commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: value) - return true + return tableRows.enteredValue(field, forColumn: columnIndex).map { (value: $0, isExpression: false) } } /// Text from another app has no NULL of its own. A date, number or boolean cannot hold the diff --git a/apple/TablePro/Views/Results/Extensions/DataGridView+SetCellsToValue.swift b/apple/TablePro/Views/Results/Extensions/DataGridView+SetCellsToValue.swift new file mode 100644 index 0000000000..1a4a67ae34 --- /dev/null +++ b/apple/TablePro/Views/Results/Extensions/DataGridView+SetCellsToValue.swift @@ -0,0 +1,167 @@ +// +// DataGridView+SetCellsToValue.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +/// What one cell stages: a value, and whether it is SQL the engine runs. +typealias StagedCellEntry = (value: PluginCellValue, isExpression: Bool) + +/// The records a Set Cells to Value sheet was opened over. A load that lands while the sheet is up can put other +/// records at the same display positions, so the write finds these records again or is dropped. +@MainActor +struct SetCellsToValueRecords { + private let cells: [(rowID: RowID, columnIndex: Int)] + private let rows: [RowID: Row] + + init(_ targets: [SetValueTarget], in coordinator: TableViewCoordinator) { + let tableRows = coordinator.tableRowsProvider() + var cells: [(rowID: RowID, columnIndex: Int)] = [] + var rows: [RowID: Row] = [:] + for target in targets { + guard let row = coordinator.displayRow(at: target.displayRow, in: tableRows) else { continue } + cells.append((rowID: row.id, columnIndex: target.columnIndex)) + rows[row.id] = row + } + self.cells = cells + self.rows = rows + } + + /// Where the records are now, or nil when one of them is gone or no longer holds what it held. + func targets(in coordinator: TableViewCoordinator) -> [SetValueTarget]? { + let tableRows = coordinator.tableRowsProvider() + var filteredPositions: [RowID: Int] = [:] + if let displayIDs = coordinator.displayIDs { + for (position, id) in displayIDs.enumerated() { + filteredPositions[id] = position + } + } + var targets: [SetValueTarget] = [] + targets.reserveCapacity(cells.count) + for cell in cells { + guard let row = tableRows.row(withID: cell.rowID), row == rows[cell.rowID] else { return nil } + let position = coordinator.displayIDs == nil ? tableRows.index(of: cell.rowID) : filteredPositions[cell.rowID] + guard let position else { return nil } + targets.append(SetValueTarget(displayRow: position, columnIndex: cell.columnIndex)) + } + return targets + } +} + +extension TableViewCoordinator { + /// The targets are the ones every Set Value item takes. A window with its own command runs that instead. + func setCellsToValue(_ targets: [SetValueTarget]) { + if delegate?.dataGridSetCellsToValue(targets) == true { return } + let cells = setCellsToValueCells(targets) + guard !cells.isEmpty, let window = tableView?.window else { return } + let records = SetCellsToValueRecords(cells, in: self) + SetCellsToValuePrompt.present( + informative: Self.setCellsToValueImpact(cells: cells.count, targets: targets.count), + window: window + ) { [weak self] text in + guard let self, let text else { return } + guard let current = records.targets(in: self) else { + NSSound.beep() + return + } + self.applyCellsToValue(text, to: self.setCellsToValueCells(current)) + } + } + + func isSetCellsToValueOffered(for targets: [SetValueTarget]) -> Bool { + targets.contains { canStartInlineEdit(row: $0.displayRow, columnIndex: $0.columnIndex) } + } + + /// The cells typing could edit, so a read-only or server-owned column, a deleted row and a binary value stay out. + func setCellsToValueCells(_ targets: [SetValueTarget]) -> [SetValueTarget] { + targets.filter { canStartInlineEdit(row: $0.displayRow, columnIndex: $0.columnIndex) } + } + + /// The text is read for each column the way a typed cell is: an empty date is NULL, a function the engine runs + /// there is SQL. + func applyCellsToValue(_ text: String, to cells: [SetValueTarget]) { + stageEntry(into: cells, actionName: String(localized: "Set Cells to Value")) { columnIndex in + enteredCell(text, columnIndex: columnIndex) + } + } + + /// One undo step for every cell. The entry is read once per column; a column that holds neither it nor NULL in + /// its place keeps its cells, and the beep says the value was refused. + @discardableResult + func stageEntry( + into cells: [SetValueTarget], + actionName: String, + entry: (Int) -> StagedCellEntry? + ) -> Int { + guard !cells.isEmpty else { return 0 } + let undoManager = tableView?.window?.undoManager + undoManager?.beginUndoGrouping() + + var entries: [Int: StagedCellEntry?] = [:] + var edited: [SetValueTarget] = [] + var refused = false + batchEdits(actionName: actionName) { + for cell in cells where !isRowDeleted(displayRow: cell.displayRow) { + let resolved: StagedCellEntry? + if let cached = entries[cell.columnIndex] { + resolved = cached + } else { + resolved = entry(cell.columnIndex) + entries[cell.columnIndex] = .some(resolved) + } + guard let resolved else { + refused = true + continue + } + guard recordCellEdit( + row: cell.displayRow, + columnIndex: cell.columnIndex, + newValue: resolved.value, + isExpression: resolved.isExpression + ) != nil else { continue } + edited.append(cell) + } + } + + // After the loop: each cell's own step names the open group "Edit Cell". + if !edited.isEmpty { + undoManager?.setActionName(actionName) + } + undoManager?.endUndoGrouping() + + if edited.count == 1, let only = edited.first { + repaintEditedCell(row: only.displayRow, columnIndex: only.columnIndex) + } else if !edited.isEmpty { + invalidateAllDisplayCaches() + tableView?.reloadData() + } + if refused { + NSSound.beep() + } + return edited.count + } + + func batchEdits(actionName: String, _ edits: () -> Void) { + guard let delegate else { + edits() + return + } + delegate.dataGridBatchEdits(actionName: actionName, edits) + } + + static func setCellsToValueImpact(cells: Int, targets: Int) -> String { + if cells < targets { + return String( + format: String(localized: "This sets %1$lld of %2$lld cells. The others cannot be edited."), + Int64(cells), + Int64(targets) + ) + } + if cells == 1 { + return String(localized: "This sets 1 cell.") + } + return String(format: String(localized: "This sets %lld cells."), Int64(cells)) + } +} diff --git a/apple/TablePro/Views/Results/KeyHandlingTableView.swift b/apple/TablePro/Views/Results/KeyHandlingTableView.swift index f9b7885840..6e43913a5c 100644 --- a/apple/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/apple/TablePro/Views/Results/KeyHandlingTableView.swift @@ -392,6 +392,7 @@ final class KeyHandlingTableView: NSTableView { @objc func paste(_ sender: Any?) { guard coordinator?.isEditable == true else { return } + if coordinator?.pasteValueIntoSelectedCells() == true { return } if let anchor = pasteAnchorCell(), coordinator?.pasteCellsFromClipboard(anchorRow: anchor.row, anchorColumn: anchor.column) == true { return @@ -419,6 +420,7 @@ final class KeyHandlingTableView: NSTableView { private var canPaste: Bool { guard let coordinator, coordinator.isEditable else { return false } if coordinator.delegate?.dataGridCanPasteRows() == true { return true } + if coordinator.canPasteValueIntoSelectedCells() { return true } guard let anchor = pasteAnchorCell() else { return false } return coordinator.canPasteCellsFromClipboard(anchorRow: anchor.row, anchorColumn: anchor.column) } @@ -427,6 +429,9 @@ final class KeyHandlingTableView: NSTableView { if let command = Self.setValueCommand(for: item.action) { return canApplySetValue(command) } + if item.action == #selector(SetValueCommandResponding.setCellsToValue(_:)) { + return coordinator?.isSetCellsToValueOffered(for: setValueTargets()) == true + } switch item.action { case #selector(delete(_:)), #selector(deleteBackward(_:)): let hasGridSelection = gridSelection?.isEmpty == false @@ -875,6 +880,10 @@ extension KeyHandlingTableView: SetValueCommandResponding { applySetValue(.uuid(.v7)) } + func setCellsToValue(_ sender: Any?) { + coordinator?.setCellsToValue(setValueTargets()) + } + static func setValueCommand(for action: Selector?) -> SetValueCommand? { switch action { case #selector(SetValueCommandResponding.setValueEmpty(_:)): diff --git a/apple/TablePro/Views/Results/SetCellsToValuePrompt.swift b/apple/TablePro/Views/Results/SetCellsToValuePrompt.swift new file mode 100644 index 0000000000..b94ce3c7e9 --- /dev/null +++ b/apple/TablePro/Views/Results/SetCellsToValuePrompt.swift @@ -0,0 +1,27 @@ +// +// SetCellsToValuePrompt.swift +// TablePro +// + +import AppKit + +@MainActor +enum SetCellsToValuePrompt { + static func present(informative: String, window: NSWindow?, completion: @escaping (String?) -> Void) { + let field = NSTextField(string: "") + field.placeholderString = String(localized: "Value") + field.setAccessibilityLabel(String(localized: "Value")) + field.frame = NSRect(x: 0, y: 0, width: 260, height: 22) + + let alert = NSAlert() + alert.messageText = String(localized: "Set Cells to Value") + alert.informativeText = informative + alert.addButton(withTitle: String(localized: "Set")) + alert.addButton(withTitle: String(localized: "Cancel")) + alert.accessoryView = field + alert.window.initialFirstResponder = field + AlertHelper.present(alert, in: window) { response in + completion(response == .alertFirstButtonReturn ? field.stringValue : nil) + } + } +} diff --git a/apple/TableProTests/Core/DataFiles/DataFileControllerTests.swift b/apple/TableProTests/Core/DataFiles/DataFileControllerTests.swift index ff966159a9..d436598ae2 100644 --- a/apple/TableProTests/Core/DataFiles/DataFileControllerTests.swift +++ b/apple/TableProTests/Core/DataFiles/DataFileControllerTests.swift @@ -122,6 +122,53 @@ struct DataFileControllerTests { #expect(column(controller, 0) == ["y"]) } + @Test("Set Cells to Value from the grid writes the cells it resolved, not the focused column") + func setCellsToValueWritesTheGridCells() async throws { + let (controller, _) = try await loaded("name,city\nA,Paris\nB,London\nC,Rome\n") + let target = try #require(controller.cellTarget(for: [ + SetValueTarget(displayRow: 0, columnIndex: 1), + SetValueTarget(displayRow: 2, columnIndex: 1) + ])) + #expect(target.cells.map { $0.column } == [controller.columnNames.ids[1]]) + #expect(!target.coversWholeColumns) + + controller.applyCleanup(.setValue("Oslo"), target: target, actionName: "Set Cells to Value") + await controller.waitForPendingWork() + + #expect(column(controller, 0) == ["A", "B", "C"]) + #expect(column(controller, 1) == ["Oslo", "London", "Oslo"]) + } + + @Test("Cell edits made as one batch are one commit and one undo step") + func batchedCellEditsAreOneUndoStep() async throws { + let (controller, _) = try await loaded("name,city\nA,Paris\nB,London\nC,Rome\n") + + controller.batchCellEdits(actionName: "Paste Cells") { + controller.setCell(pageRow: 0, column: 1, text: "Oslo") + controller.setCell(pageRow: 2, column: 1, text: "Oslo") + } + + #expect(column(controller, 1) == ["Oslo", "London", "Oslo"]) + #expect(undoManager.undoActionName == "Paste Cells") + undoManager.undo() + #expect(column(controller, 1) == ["Paris", "London", "Rome"]) + } + + @Test("Separate cells in two columns change only those cells, not the rows and columns between them") + func separateCellsStaySeparate() async throws { + let (controller, _) = try await loaded("name,city\nA,Paris\nB,London\n") + let target = try #require(controller.cellTarget(for: [ + SetValueTarget(displayRow: 0, columnIndex: 0), + SetValueTarget(displayRow: 1, columnIndex: 1) + ])) + + controller.applyCleanup(.setValue("x"), target: target, actionName: "Set Cells to Value") + await controller.waitForPendingWork() + + #expect(column(controller, 0) == ["x", "B"]) + #expect(column(controller, 1) == ["Paris", "x"]) + } + @Test("Changing the query goes back to the first page") func queryChangeResetsPage() async throws { let rows = (1...30).map { "row\($0)" }.joined(separator: "\n") diff --git a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 6b9c08e830..b6bc34ac76 100644 --- a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -1204,29 +1204,42 @@ struct MainMenuBuilderTests { @MainActor struct EditSetValueMenuTests { private struct Leaf { - let command: SetValueCommand + let title: String let action: Selector let shortcut: ShortcutAction } private static let leaves: [Leaf] = [ - Leaf(command: .empty, action: #selector(SetValueCommandResponding.setValueEmpty(_:)), shortcut: .setValueEmpty), - Leaf(command: .null, action: #selector(SetValueCommandResponding.setValueNull(_:)), shortcut: .setValueNull), Leaf( - command: .defaultValue, + title: SetValueOptions.title(for: .empty), + action: #selector(SetValueCommandResponding.setValueEmpty(_:)), + shortcut: .setValueEmpty + ), + Leaf( + title: SetValueOptions.title(for: .null), + action: #selector(SetValueCommandResponding.setValueNull(_:)), + shortcut: .setValueNull + ), + Leaf( + title: SetValueOptions.title(for: .defaultValue), action: #selector(SetValueCommandResponding.setValueDefault(_:)), shortcut: .setValueDefault ), Leaf( - command: .uuid(.v4), + title: SetValueOptions.title(for: .uuid(.v4)), action: #selector(SetValueCommandResponding.generateUUIDv4(_:)), shortcut: .generateUUIDv4 ), Leaf( - command: .uuid(.v7), + title: SetValueOptions.title(for: .uuid(.v7)), action: #selector(SetValueCommandResponding.generateUUIDv7(_:)), shortcut: .generateUUIDv7 ), + Leaf( + title: String(localized: "Set Cells to Value…"), + action: #selector(SetValueCommandResponding.setCellsToValue(_:)), + shortcut: .setCellsToValue + ), ] private func editMenu() throws -> NSMenu { @@ -1258,13 +1271,13 @@ struct EditSetValueMenuTests { let setValue = try setValueItem(in: editMenu()) let items = try #require(setValue.submenu).items let separators = items.map { $0.isSeparatorItem } - #expect(separators == [false, false, false, true, false, false]) + #expect(separators == [false, false, false, true, false, false, true, false]) let commands = items.filter { !$0.isSeparatorItem } let titles = commands.map { $0.title } let actions = commands.map { $0.action } let identifiers = commands.map { $0.identifier } - let expectedTitles = Self.leaves.map { SetValueOptions.title(for: $0.command) } + let expectedTitles = Self.leaves.map { $0.title } let expectedActions: [Selector?] = Self.leaves.map { $0.action } let expectedIdentifiers: [NSUserInterfaceItemIdentifier?] = Self.leaves.map { MenuItemFactory.identifier(for: $0.shortcut) @@ -1293,7 +1306,7 @@ struct EditSetValueMenuTests { let edit = try editMenu() let setValue = try setValueItem(in: edit) let children = setValue.submenu?.items ?? [] - #expect(children.count == 6) + #expect(children.count == 8) for shown in [true, false] { EditDataMenuGroup.setShown(shown, in: edit) #expect(!setValue.isHidden, "data group shown: \(shown)") @@ -1313,6 +1326,7 @@ struct EditSetValueMenuTests { } #expect(ShortcutAction.generateUUIDv4.displayName == SetValueOptions.title(for: .uuid(.v4))) #expect(ShortcutAction.generateUUIDv7.displayName == SetValueOptions.title(for: .uuid(.v7))) + #expect(ShortcutAction.setCellsToValue.displayName == String(localized: "Set Cells to Value")) } @Test("A key the user gives a Set Value command reaches its menu item") diff --git a/apple/TableProTests/Models/ShortcutUniquenessTests.swift b/apple/TableProTests/Models/ShortcutUniquenessTests.swift index ac00283776..d08f2f1150 100644 --- a/apple/TableProTests/Models/ShortcutUniquenessTests.swift +++ b/apple/TableProTests/Models/ShortcutUniquenessTests.swift @@ -50,10 +50,10 @@ struct ShortcutUniquenessTests { } // Counted, not listed: a default for any of these takes a combo already in use, so it has to be on purpose. - @Test("Twenty-one actions ship unbound") + @Test("Twenty-two actions ship unbound") func unboundActionsAreCounted() { let unbound = ShortcutAction.allCases.filter { KeyboardSettings.defaultShortcuts[$0] == nil } - #expect(unbound.count == 21, "Unbound: \(unbound.map(\.rawValue).sorted())") + #expect(unbound.count == 22, "Unbound: \(unbound.map(\.rawValue).sorted())") for action in unbound { #expect(KeyboardSettings.default.shortcut(for: action) == nil, "\(action.rawValue)") } diff --git a/apple/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift b/apple/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift index 9e4fcf772e..bb63f5d77d 100644 --- a/apple/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift +++ b/apple/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift @@ -182,7 +182,28 @@ struct DataGridRowViewSetValueTests { let menu = try #require(rowView.contextMenu(target: .cell(dataColumn: 0))) let setValue = try #require(menu.items.first { $0.title == "Set Value" }?.submenu) - #expect(titles(setValue) == ["Generate UUID", "Generate UUIDv7"]) + #expect(titles(setValue) == ["Generate UUID", "Generate UUIDv7", "-", "Set Cells to Value…"]) + } + + @Test("A NOT NULL number with no default still offers Set Cells to Value, alone") + func typedValueIsTheOnlyItemOnANotNullNumber() throws { + let fixture = makeFixture( + columns: ["id", "qty"], + columnTypes: [.integer(rawType: "int4"), .integer(rawType: "int4")], + rows: [[.text("1"), .text("5")]], + columnNullable: ["id": false, "qty": false] + ) + let tableView = KeyHandlingTableView() + tableView.coordinator = fixture.coordinator + fixture.coordinator.tableView = tableView + let rowView = DataGridRowView() + rowView.coordinator = fixture.coordinator + rowView.rowIndex = 0 + + let menu = try #require(rowView.contextMenu(target: .cell(dataColumn: 1))) + let setValue = try #require(menu.items.first { $0.title == "Set Value" }?.submenu) + + #expect(titles(setValue) == ["Set Cells to Value…"]) } @Test("A read-only grid has no Set Value at all") diff --git a/apple/TableProTests/Views/Results/Extensions/DataGridSetCellsToValueTests.swift b/apple/TableProTests/Views/Results/Extensions/DataGridSetCellsToValueTests.swift new file mode 100644 index 0000000000..77c969e4ca --- /dev/null +++ b/apple/TableProTests/Views/Results/Extensions/DataGridSetCellsToValueTests.swift @@ -0,0 +1,420 @@ +// +// DataGridSetCellsToValueTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class CellsToValueLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class CellsToValueRowStore { + var tableRows: TableRows + + init(_ tableRows: TableRows) { + self.tableRows = tableRows + } +} + +@MainActor +private final class CellsToValueUndoWindow: NSWindow { + let undo = UndoManager() + + override var undoManager: UndoManager? { undo } +} + +@MainActor +private final class OwnCommandDelegate: DataGridViewDelegate { + private(set) var handedTargets: [[SetValueTarget]] = [] + + func dataGridSetCellsToValue(_ targets: [SetValueTarget]) -> Bool { + handedTargets.append(targets) + return true + } +} + +@MainActor +private struct CellsToValueFixture { + static let columns = ["id", "note", "label", "seen", "born", "calc", "payload"] + static let id = 0 + static let note = 1 + static let label = 2 + static let seen = 3 + static let born = 4 + static let calc = 5 + static let payload = 6 + + let manager: DataChangeManager + let coordinator: TableViewCoordinator + let store: CellsToValueRowStore + + init(rowCount: Int = 4, selectedRows: Set = []) { + manager = DataChangeManager() + manager.configureForTable( + tableName: "things", + columns: Self.columns, + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: ["calc"] + ) + coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(manager), + isEditable: true, + selectedRowIndices: .constant(selectedRows), + delegate: nil, + layoutPersister: CellsToValueLayoutPersister() + ) + let rows: [[PluginCellValue]] = (0.. PluginCellValue? { + manager.pending.change(forRow: .existing(row), type: .update)? + .cellChanges.first { $0.columnIndex == column }?.newValue + } + + func select(rows: ClosedRange, columns: ClosedRange) { + coordinator.selectionController.update(.single( + GridRect(rows: rows, columns: columns), + anchor: GridCoord(row: rows.lowerBound, displayColumn: columns.lowerBound), + active: GridCoord(row: rows.upperBound, displayColumn: columns.upperBound) + )) + } + + func cell(_ row: Int, _ column: Int) -> SetValueTarget { + SetValueTarget(displayRow: row, columnIndex: column) + } + + func selectedTargets() -> [SetValueTarget] { + coordinator.setValueTargets(clicked: nil) + } + + func setCellsToValue(_ text: String) { + coordinator.applyCellsToValue(text, to: coordinator.setCellsToValueCells(selectedTargets())) + } + + func deleteRow(_ row: Int) { + manager.recordRowDeletion(rowID: .existing(row), originalRow: Array(store.tableRows.rows[row].values)) + } + + func attachUndoWindow() -> CellsToValueUndoWindow { + let window = CellsToValueUndoWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: true + ) + window.isReleasedWhenClosed = false + window.undo.groupsByEvent = false + let tableView = KeyHandlingTableView() + window.contentView?.addSubview(tableView) + tableView.coordinator = coordinator + tableView.addTableColumn(DataGridView.makeRowNumberColumn()) + coordinator.tableView = tableView + // Built through the column pool, so the selection's display positions resolve to the grid's own columns. + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: [], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 100 } + ) + coordinator.invalidateColumnIndexCache() + let undo = window.undo + manager.undoManagerProvider = { undo } + return window + } +} + +@MainActor +struct DataGridSetCellsToValueTests { + @Test("The typed value goes into every selected cell and no other") + func writesEverySelectedCell() { + let fixture = CellsToValueFixture() + fixture.select(rows: 0...2, columns: CellsToValueFixture.note...CellsToValueFixture.note) + + fixture.setCellsToValue("archived") + + for row in 0...2 { + #expect(fixture.staged(row: row, column: CellsToValueFixture.note) == .text("archived")) + #expect(fixture.store.tableRows.rows[row].values[CellsToValueFixture.note] == .text("archived")) + } + #expect(fixture.staged(row: 3, column: CellsToValueFixture.note) == nil) + #expect(fixture.store.tableRows.rows[3].values[CellsToValueFixture.note] == .text("n3")) + #expect(fixture.staged(row: 0, column: CellsToValueFixture.label) == nil) + } + + @Test("A server-owned column, a binary column and a deleted row are left out of the cells it writes") + func leavesOutCellsTypingCannotEdit() { + let fixture = CellsToValueFixture() + fixture.deleteRow(1) + fixture.select(rows: 0...1, columns: CellsToValueFixture.born...CellsToValueFixture.payload) + let targets = fixture.selectedTargets() + + let cells = fixture.coordinator.setCellsToValueCells(targets) + + #expect(targets.count == 6) + #expect(cells == [fixture.cell(0, CellsToValueFixture.born)]) + #expect(fixture.coordinator.isSetCellsToValueOffered(for: targets)) + } + + @Test("Nothing is offered where typing could edit no cell") + func notOfferedWithoutAnEditableCell() { + let fixture = CellsToValueFixture() + let readOnly = [fixture.cell(0, CellsToValueFixture.calc), fixture.cell(1, CellsToValueFixture.payload)] + + #expect(!fixture.coordinator.isSetCellsToValueOffered(for: readOnly)) + #expect(!fixture.coordinator.isSetCellsToValueOffered(for: [])) + + fixture.coordinator.isEditable = false + #expect(!fixture.coordinator.isSetCellsToValueOffered(for: [fixture.cell(0, CellsToValueFixture.note)])) + } + + @Test("The sheet says how many cells take the value, and how many it skips") + func impactCountsTheCells() { + #expect(TableViewCoordinator.setCellsToValueImpact(cells: 1, targets: 1) == "This sets 1 cell.") + #expect(TableViewCoordinator.setCellsToValueImpact(cells: 40, targets: 40) == "This sets 40 cells.") + #expect( + TableViewCoordinator.setCellsToValueImpact(cells: 38, targets: 40) + == "This sets 38 of 40 cells. The others cannot be edited." + ) + } + + @Test("Every cell it writes is one undo step, named Set Cells to Value") + func oneUndoStep() { + let fixture = CellsToValueFixture() + let window = fixture.attachUndoWindow() + defer { window.orderOut(nil) } + fixture.select(rows: 0...3, columns: CellsToValueFixture.note...CellsToValueFixture.label) + + fixture.setCellsToValue("archived") + + #expect(window.undo.undoActionName == "Set Cells to Value") + window.undo.undo() + #expect(!window.undo.canUndo) + #expect(!fixture.manager.hasChanges) + } + + @Test("A column that holds neither the text nor NULL keeps its cells, and the others take it") + func refusingColumnKeepsItsCells() { + let fixture = CellsToValueFixture() + fixture.select(rows: 0...1, columns: CellsToValueFixture.note...CellsToValueFixture.born) + + fixture.setCellsToValue("") + + #expect(fixture.staged(row: 0, column: CellsToValueFixture.note) == .text("")) + #expect(fixture.staged(row: 1, column: CellsToValueFixture.seen) == .null) + #expect(fixture.staged(row: 0, column: CellsToValueFixture.born) == nil) + #expect(fixture.store.tableRows.rows[1].values[CellsToValueFixture.born] == .text("2024-01-02")) + } + + @Test("A function name stages as SQL on a timestamp and as text on a text column, as typing it does") + func functionIsReadPerColumn() { + let fixture = CellsToValueFixture() + fixture.select(rows: 0...0, columns: CellsToValueFixture.note...CellsToValueFixture.seen) + + fixture.setCellsToValue("now()") + + #expect(fixture.staged(row: 0, column: CellsToValueFixture.seen) == .text("now()")) + #expect(fixture.manager.isExpression(rowID: .existing(0), columnIndex: CellsToValueFixture.seen)) + #expect(fixture.staged(row: 0, column: CellsToValueFixture.note) == .text("now()")) + #expect(!fixture.manager.isExpression(rowID: .existing(0), columnIndex: CellsToValueFixture.note)) + } + + @Test("With whole rows selected the clicked column is written on each of them") + func clickedColumnOnSelectedRows() { + let fixture = CellsToValueFixture(selectedRows: [0, 2]) + let targets = fixture.coordinator.setValueTargets(clicked: fixture.cell(2, CellsToValueFixture.note)) + + fixture.coordinator.applyCellsToValue("archived", to: fixture.coordinator.setCellsToValueCells(targets)) + + #expect(fixture.staged(row: 0, column: CellsToValueFixture.note) == .text("archived")) + #expect(fixture.staged(row: 1, column: CellsToValueFixture.note) == nil) + #expect(fixture.staged(row: 2, column: CellsToValueFixture.note) == .text("archived")) + } + + @Test("A window with its own Set Cells to Value runs it, and the grid stages nothing") + func windowCommandTakesOver() { + let fixture = CellsToValueFixture() + let delegate = OwnCommandDelegate() + fixture.coordinator.delegate = delegate + fixture.select(rows: 0...1, columns: CellsToValueFixture.note...CellsToValueFixture.note) + + fixture.coordinator.setCellsToValue(fixture.selectedTargets()) + + #expect(delegate.handedTargets == [[fixture.cell(0, CellsToValueFixture.note), fixture.cell(1, CellsToValueFixture.note)]]) + #expect(!fixture.manager.hasChanges) + } + + @Test("The records under the sheet are found again after a sort or a value filter moves them") + func recordsFollowTheirRows() throws { + let fixture = CellsToValueFixture() + let records = SetCellsToValueRecords( + [fixture.cell(1, CellsToValueFixture.note), fixture.cell(3, CellsToValueFixture.label)], + in: fixture.coordinator + ) + + fixture.coordinator.valueFilteredIDs = [.existing(3), .existing(2), .existing(1)] + + let moved = try #require(records.targets(in: fixture.coordinator)) + #expect(moved == [fixture.cell(2, CellsToValueFixture.note), fixture.cell(0, CellsToValueFixture.label)]) + } + + @Test("A load that replaces the records under the sheet drops the write") + func replacedRecordsDropTheWrite() { + let fixture = CellsToValueFixture() + let records = SetCellsToValueRecords([fixture.cell(1, CellsToValueFixture.note)], in: fixture.coordinator) + + _ = fixture.store.tableRows.edit(row: 1, column: CellsToValueFixture.label, value: .text("reloaded")) + + #expect(records.targets(in: fixture.coordinator) == nil) + } + + @Test("A record a value filter now hides drops the write") + func hiddenRecordDropsTheWrite() { + let fixture = CellsToValueFixture() + let records = SetCellsToValueRecords([fixture.cell(1, CellsToValueFixture.note)], in: fixture.coordinator) + + fixture.coordinator.valueFilteredIDs = [.existing(0), .existing(2)] + + #expect(records.targets(in: fixture.coordinator) == nil) + } +} + +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct DataGridPasteIntoSelectionTests { + @Test("One pasted value goes into every selected cell, as one undo step") + func singleValueFillsTheSelection() throws { + let clipboard = try #require(RecordingClipboard.current) + clipboard.text = "archived" + let fixture = CellsToValueFixture() + let window = fixture.attachUndoWindow() + defer { window.orderOut(nil) } + fixture.select(rows: 0...2, columns: CellsToValueFixture.note...CellsToValueFixture.label) + + #expect(fixture.coordinator.pasteValueIntoSelectedCells()) + + for row in 0...2 { + #expect(fixture.staged(row: row, column: CellsToValueFixture.note) == .text("archived")) + #expect(fixture.staged(row: row, column: CellsToValueFixture.label) == .text("archived")) + } + #expect(fixture.staged(row: 3, column: CellsToValueFixture.note) == nil) + #expect(window.undo.undoActionName == "Paste Cells") + window.undo.undo() + #expect(!fixture.manager.hasChanges) + } + + @Test("Paste in the grid fills a cell selection rather than only the focused cell") + func pasteCommandFillsTheSelection() throws { + try #require(RecordingClipboard.current).text = "archived" + let fixture = CellsToValueFixture() + let window = fixture.attachUndoWindow() + defer { window.orderOut(nil) } + let tableView = try #require(fixture.coordinator.tableView as? KeyHandlingTableView) + fixture.select(rows: 1...2, columns: CellsToValueFixture.note...CellsToValueFixture.note) + + tableView.paste(nil) + + #expect(fixture.staged(row: 1, column: CellsToValueFixture.note) == .text("archived")) + #expect(fixture.staged(row: 2, column: CellsToValueFixture.note) == .text("archived")) + #expect(fixture.staged(row: 0, column: CellsToValueFixture.note) == nil) + } + + @Test("A NULL copied from the grid fills nullable cells and leaves a NOT NULL column alone") + func copiedNullFillsWhereItFits() throws { + let fixture = CellsToValueFixture() + fixture.coordinator.copyCellValue(at: 0, columnIndex: CellsToValueFixture.payload) + #expect(try #require(RecordingClipboard.current).gridCells?.fields == [[nil]]) + fixture.select(rows: 1...2, columns: CellsToValueFixture.note...CellsToValueFixture.label) + + #expect(fixture.coordinator.pasteValueIntoSelectedCells()) + + #expect(fixture.staged(row: 1, column: CellsToValueFixture.note) == .null) + #expect(fixture.staged(row: 2, column: CellsToValueFixture.note) == .null) + #expect(fixture.staged(row: 1, column: CellsToValueFixture.label) == nil) + #expect(fixture.store.tableRows.rows[2].values[CellsToValueFixture.label] == .text("l2")) + } + + @Test("A block, or a single selected cell, keeps the paste anchored at the focused cell") + func blockOrOneCellKeepsTheAnchor() throws { + let clipboard = try #require(RecordingClipboard.current) + let fixture = CellsToValueFixture() + + clipboard.text = "a\tb" + fixture.select(rows: 0...1, columns: CellsToValueFixture.note...CellsToValueFixture.label) + #expect(!fixture.coordinator.canPasteValueIntoSelectedCells()) + #expect(!fixture.coordinator.pasteValueIntoSelectedCells()) + + clipboard.text = "a" + fixture.select(rows: 0...0, columns: CellsToValueFixture.note...CellsToValueFixture.note) + #expect(!fixture.coordinator.canPasteValueIntoSelectedCells()) + #expect(!fixture.coordinator.pasteValueIntoSelectedCells()) + + fixture.coordinator.selectionController.clear() + #expect(!fixture.coordinator.pasteValueIntoSelectedCells()) + #expect(!fixture.manager.hasChanges) + } + + @Test("Paste is enabled over a cell selection with no focused cell when one value is on the clipboard") + func pasteIsEnabledWithoutAFocusedCell() throws { + try #require(RecordingClipboard.current).text = "archived" + let fixture = CellsToValueFixture() + let window = fixture.attachUndoWindow() + defer { window.orderOut(nil) } + let tableView = try #require(fixture.coordinator.tableView as? KeyHandlingTableView) + fixture.select(rows: 0...3, columns: CellsToValueFixture.note...CellsToValueFixture.note) + tableView.focusedRow = -1 + tableView.focusedColumn = -1 + let paste = NSMenuItem(title: "", action: #selector(KeyHandlingTableView.paste(_:)), keyEquivalent: "") + + #expect(fixture.coordinator.canPasteValueIntoSelectedCells()) + #expect(tableView.validateUserInterfaceItem(paste)) + } + } +} diff --git a/apple/TableProTests/Views/Results/KeyHandlingTableViewSetValueTests.swift b/apple/TableProTests/Views/Results/KeyHandlingTableViewSetValueTests.swift index b23bcd984d..ca76e067ca 100644 --- a/apple/TableProTests/Views/Results/KeyHandlingTableViewSetValueTests.swift +++ b/apple/TableProTests/Views/Results/KeyHandlingTableViewSetValueTests.swift @@ -99,6 +99,7 @@ struct KeyHandlingTableViewSetValueTests { private static let defaultValue = #selector(SetValueCommandResponding.setValueDefault(_:)) private static let uuidV4 = #selector(SetValueCommandResponding.generateUUIDv4(_:)) private static let uuidV7 = #selector(SetValueCommandResponding.generateUUIDv7(_:)) + private static let cellsToValue = #selector(SetValueCommandResponding.setCellsToValue(_:)) @Test("On the focused uuid key only the UUID items are enabled") func focusedUUIDKey() { @@ -112,13 +113,14 @@ struct KeyHandlingTableViewSetValueTests { #expect(!isEnabled(Self.defaultValue, in: sut)) } - @Test("On a focused nullable text cell Empty and NULL are enabled and the UUIDs are not") + @Test("On a focused nullable text cell Empty, NULL and Set Cells to Value are enabled and the UUIDs are not") func focusedTextCell() { let sut = makeSUT() focus(sut, row: 0, dataColumn: Self.note) #expect(isEnabled(Self.empty, in: sut)) #expect(isEnabled(Self.null, in: sut)) + #expect(isEnabled(Self.cellsToValue, in: sut)) #expect(!isEnabled(Self.uuidV4, in: sut)) } @@ -126,7 +128,7 @@ struct KeyHandlingTableViewSetValueTests { func nothingFocused() { let sut = makeSUT() - for selector in [Self.empty, Self.null, Self.defaultValue, Self.uuidV4, Self.uuidV7] { + for selector in [Self.empty, Self.null, Self.defaultValue, Self.uuidV4, Self.uuidV7, Self.cellsToValue] { #expect(!isEnabled(selector, in: sut)) } } @@ -138,6 +140,7 @@ struct KeyHandlingTableViewSetValueTests { #expect(!isEnabled(Self.null, in: sut)) #expect(!isEnabled(Self.empty, in: sut)) + #expect(!isEnabled(Self.cellsToValue, in: sut)) } @Test("A selection across both columns enables what either one takes, and writes each only where it fits") From 320e76cb441ab53c44023bb5e41eed411d818b39 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 02:39:56 +0700 Subject: [PATCH 2/3] docs(changelog): add the fragments for #3717 --- apple/changelog.d/3717.added.md | 1 + apple/changelog.d/3717.fixed.1.md | 1 + apple/changelog.d/3717.fixed.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 apple/changelog.d/3717.added.md create mode 100644 apple/changelog.d/3717.fixed.1.md create mode 100644 apple/changelog.d/3717.fixed.md diff --git a/apple/changelog.d/3717.added.md b/apple/changelog.d/3717.added.md new file mode 100644 index 0000000000..7b4ae56e39 --- /dev/null +++ b/apple/changelog.d/3717.added.md @@ -0,0 +1 @@ +Set Cells to Value… writes one typed value into every selected cell, and pasting one value fills them all. diff --git a/apple/changelog.d/3717.fixed.1.md b/apple/changelog.d/3717.fixed.1.md new file mode 100644 index 0000000000..c4db10c034 --- /dev/null +++ b/apple/changelog.d/3717.fixed.1.md @@ -0,0 +1 @@ +Pasting into many cells of a data file rebuilt the page once for every cell. diff --git a/apple/changelog.d/3717.fixed.md b/apple/changelog.d/3717.fixed.md new file mode 100644 index 0000000000..c9cf568e56 --- /dev/null +++ b/apple/changelog.d/3717.fixed.md @@ -0,0 +1 @@ +Data file cleanup commands changed the cells between separately selected cells, not only the selected ones. From 6d7f4c2b36ab0ec68ba25272d577667ae92ada44 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 05:07:52 +0700 Subject: [PATCH 3/3] test(shortcuts): count Set Cells to Value among the unbound actions --- apple/TableProTests/Models/ShortcutUniquenessTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apple/TableProTests/Models/ShortcutUniquenessTests.swift b/apple/TableProTests/Models/ShortcutUniquenessTests.swift index 9150cabcd9..b199a01bff 100644 --- a/apple/TableProTests/Models/ShortcutUniquenessTests.swift +++ b/apple/TableProTests/Models/ShortcutUniquenessTests.swift @@ -50,10 +50,10 @@ struct ShortcutUniquenessTests { } // Counted, not listed: a default for any of these takes a combo already in use, so it has to be on purpose. - @Test("Twenty-two actions ship unbound") + @Test("Twenty-three actions ship unbound") func unboundActionsAreCounted() { let unbound = ShortcutAction.allCases.filter { KeyboardSettings.defaultShortcuts[$0] == nil } - #expect(unbound.count == 22, "Unbound: \(unbound.map(\.rawValue).sorted())") + #expect(unbound.count == 23, "Unbound: \(unbound.map(\.rawValue).sorted())") for action in unbound { #expect(KeyboardSettings.default.shortcut(for: action) == nil, "\(action.rawValue)") } @@ -63,7 +63,7 @@ struct ShortcutUniquenessTests { @Test("Each newly rebindable command is listed under a category with a name", arguments: [ ShortcutAction.showTablesList, .showFavoritesList, .restorePreviousValues, .newAgentSession, .openAgentSession, .closeAgentSession, .deleteAgentSession, .newAIConversation, - .copyPath, .copyRelativePath, + .copyPath, .copyRelativePath, .setCellsToValue, ]) func newlyRebindableCommandsAreListable(action: ShortcutAction) { #expect(!action.displayName.isEmpty)