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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304)

## [0.44.0] - 2026-05-23

### Added
Expand Down
55 changes: 31 additions & 24 deletions TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,39 @@ extension TableViewCoordinator {
}

func commitTypedCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) {
cellCommitLogger.debug("commitTypedCellEdit(row: \(row, privacy: .public), columnIndex: \(columnIndex, privacy: .public)) isCommitting=\(self.isCommittingCellEdit, privacy: .public) delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)")
guard !isCommittingCellEdit else { return }
guard let tableView else { return }
guard let delta = recordCellEdit(row: row, columnIndex: columnIndex, newValue: typedNewValue) else { return }

invalidateDisplayCache()
visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs)

guard let tableColumnIndex = DataGridView.tableColumnIndex(
for: columnIndex,
in: tableView,
schema: identitySchema
) else { return }
if case .cellChanged = delta {
tableRowsController.apply(.cellChanged(row: row, column: tableColumnIndex))
} else {
tableView.reloadData(
forRowIndexes: IndexSet(integer: row),
columnIndexes: IndexSet(integer: tableColumnIndex)
)
}
}

@discardableResult
func recordCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) -> Delta? {
cellCommitLogger.debug("recordCellEdit(row: \(row, privacy: .public), columnIndex: \(columnIndex, privacy: .public)) isCommitting=\(self.isCommittingCellEdit, privacy: .public) delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)")
guard !isCommittingCellEdit else { return nil }
let tableRows = tableRowsProvider()
guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return }
guard let displayRowValues = displayRow(at: row) else { return }
guard columnIndex < displayRowValues.values.count else { return }
guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return nil }
guard let displayRowValues = displayRow(at: row) else { return nil }
guard columnIndex < displayRowValues.values.count else { return nil }
let oldValue = displayRowValues.values[columnIndex]
guard oldValue != typedNewValue else {
cellCommitLogger.debug("commitTypedCellEdit - value unchanged, guard returned")
return
cellCommitLogger.debug("recordCellEdit - value unchanged, guard returned")
return nil
}

isCommittingCellEdit = true
Expand All @@ -49,23 +71,8 @@ extension TableViewCoordinator {
delta = tableRows.edit(row: storageRow, column: columnIndex, value: typedNewValue)
}
}
cellCommitLogger.debug("commitTypedCellEdit - about to call delegate.dataGridDidEditCell, delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)")
cellCommitLogger.debug("recordCellEdit - about to call delegate.dataGridDidEditCell, delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)")
delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: typedNewValue.asText)
invalidateDisplayCache()
visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs)

guard let tableColumnIndex = DataGridView.tableColumnIndex(
for: columnIndex,
in: tableView,
schema: identitySchema
) else { return }
if storageRow != nil, case .cellChanged = delta {
tableRowsController.apply(.cellChanged(row: row, column: tableColumnIndex))
} else {
tableView.reloadData(
forRowIndexes: IndexSet(integer: row),
columnIndexes: IndexSet(integer: tableColumnIndex)
)
}
return delta
}
}
133 changes: 133 additions & 0 deletions TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//
// DataGridView+FillColumn.swift
// TablePro
//

import AppKit
import TableProPluginKit

extension TableViewCoordinator {
@objc func fillColumn(_ sender: NSMenuItem) {
guard let columnIndex = sender.representedObject as? Int else { return }
presentFillColumnDialog(columnIndex: columnIndex)
}

func presentFillColumnDialog(columnIndex: Int) {
guard isEditable, let window = tableView?.window else { return }

let tableRows = tableRowsProvider()
guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return }

let rowCount = Self.fillTargetRows(
rowCount: cachedRowCount,
isEditable: isEditable,
isRowDeleted: changeManager.isRowDeleted
).count
guard rowCount > 0 else { return }

let columnName = tableRows.columns[columnIndex]
let allowsNull = tableRows.columnNullable[columnName] ?? true
let accessory = FillColumnAccessoryView(allowsNull: allowsNull)

let alert = NSAlert()
alert.messageText = String(format: String(localized: "Fill column \"%@\""), columnName)
alert.informativeText = Self.fillImpactDescription(rowCount: rowCount)
alert.accessoryView = accessory
alert.addButton(withTitle: String(localized: "Fill"))
alert.addButton(withTitle: String(localized: "Cancel"))

alert.beginSheetModal(for: window) { [weak self] response in
guard response == .alertFirstButtonReturn else { return }
self?.applyFillColumn(columnIndex: columnIndex, value: accessory.resolvedValue)
}

DispatchQueue.main.async {
alert.window.makeFirstResponder(accessory.firstResponderView)
}
}

func applyFillColumn(columnIndex: Int, value: PluginCellValue) {
let targetRows = Self.fillTargetRows(
rowCount: cachedRowCount,
isEditable: isEditable,
isRowDeleted: changeManager.isRowDeleted
)
guard !targetRows.isEmpty else { return }

let undoManager = tableView?.window?.undoManager
undoManager?.beginUndoGrouping()
undoManager?.setActionName(String(localized: "Fill Column"))

var didEdit = false
for row in targetRows where recordCellEdit(row: row, columnIndex: columnIndex, newValue: value) != nil {
didEdit = true
}

undoManager?.endUndoGrouping()

guard didEdit else { return }
invalidateAllDisplayCaches()
tableView?.reloadData()
}

static func fillTargetRows(rowCount: Int, isEditable: Bool, isRowDeleted: (Int) -> Bool) -> [Int] {
guard isEditable, rowCount > 0 else { return [] }
return (0..<rowCount).filter { !isRowDeleted($0) }
}

static func fillColumnValue(text: String, setNull: Bool) -> PluginCellValue {
setNull ? .null : .text(text)
}

static func fillImpactDescription(rowCount: Int) -> String {
if rowCount == 1 {
return String(localized: "This sets 1 loaded row. Review and Save to apply.")
}
return String(
format: String(localized: "This sets %lld loaded rows. Review and Save to apply."),
Int64(rowCount)
)
}
}

@MainActor
private final class FillColumnAccessoryView: NSView {
private let valueField = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
private let nullCheckbox: NSButton?

init(allowsNull: Bool) {
nullCheckbox = allowsNull
? NSButton(checkboxWithTitle: String(localized: "Set to NULL"), target: nil, action: nil)
: nil
super.init(frame: NSRect(x: 0, y: 0, width: 260, height: allowsNull ? 50 : 24))

valueField.usesSingleLineMode = true
valueField.placeholderString = String(localized: "Value")
valueField.frame = NSRect(x: 0, y: allowsNull ? 26 : 0, width: 260, height: 24)
addSubview(valueField)

guard let nullCheckbox else { return }
nullCheckbox.frame = NSRect(x: 0, y: 0, width: 260, height: 18)
nullCheckbox.target = self
nullCheckbox.action = #selector(nullStateChanged)
addSubview(nullCheckbox)
}

required init?(coder: NSCoder) {
nullCheckbox = nil
super.init(coder: coder)
}

var resolvedValue: PluginCellValue {
TableViewCoordinator.fillColumnValue(
text: valueField.stringValue,
setNull: nullCheckbox?.state == .on
)
}

var firstResponderView: NSView { valueField }

@objc private func nullStateChanged() {
valueField.isEnabled = nullCheckbox?.state != .on
}
}
14 changes: 14 additions & 0 deletions TablePro/Views/Results/Extensions/DataGridView+Sort.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ extension TableViewCoordinator {
menu.addItem(copyValuesItem)
}

if let dataColumnIndex = dataColumnIndex(from: column.identifier),
isEditable,
cachedRowCount > 0,
!primaryKeyColumns.contains(baseName) {
let fillItem = NSMenuItem(
title: String(localized: "Fill Column…"),
action: #selector(fillColumn(_:)),
keyEquivalent: ""
)
fillItem.representedObject = dataColumnIndex
fillItem.target = self
menu.addItem(fillItem)
}

let filterItem = NSMenuItem(title: String(localized: "Filter with column"), action: #selector(filterWithColumn(_:)), keyEquivalent: "")
filterItem.representedObject = baseName
filterItem.target = self
Expand Down
Loading
Loading