Skip to content

Commit 50e778a

Browse files
authored
refactor(datagrid): replace NotificationCenter dispatch with delegate calls for row actions (#916)
* fix: use NSViewControllerRepresentable for proper view controller hierarchy, add CHANGELOG (#908) * refactor(datagrid): replace NotificationCenter dispatch with delegate calls for row actions * fix(perf): eliminate full NSTableView reload on delete, debounce lazy-load and inspector
1 parent 7e4f856 commit 50e778a

10 files changed

Lines changed: 93 additions & 98 deletions

TablePro/Views/Main/Child/DataTabGridDelegate.swift

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,28 @@ final class DataTabGridDelegate: DataGridViewDelegate {
5151
}
5252

5353
func dataGridDeleteRows(_ indices: Set<Int>) {
54-
NotificationCenter.default.post(
55-
name: .deleteSelectedRows,
56-
object: nil,
57-
userInfo: ["rowIndices": indices]
58-
)
54+
coordinator?.deleteSelectedRows(indices: indices)
5955
}
6056

6157
func dataGridCopyRows(_ indices: Set<Int>) {
62-
NotificationCenter.default.post(
63-
name: .copySelectedRows,
64-
object: nil,
65-
userInfo: ["rowIndices": indices]
66-
)
58+
coordinator?.copySelectedRowsToClipboard(indices: indices)
6759
}
6860

6961
func dataGridPasteRows() {
70-
NotificationCenter.default.post(name: .pasteRows, object: nil)
62+
var cell = editingCell?.wrappedValue
63+
coordinator?.pasteRows(editingCell: &cell)
64+
editingCell?.wrappedValue = cell
65+
}
66+
67+
func dataGridDuplicateRow() {
68+
guard let selectionState, let firstIndex = selectionState.indices.first else { return }
69+
var cell = editingCell?.wrappedValue
70+
coordinator?.duplicateSelectedRow(index: firstIndex, editingCell: &cell)
71+
editingCell?.wrappedValue = cell
72+
}
73+
74+
func dataGridExportResults() {
75+
NotificationCenter.default.post(name: .exportQueryResults, object: nil)
7176
}
7277

7378
func dataGridUndo() {

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,7 @@ struct MainEditorContentView: View {
807807

808808
private func statusBar(tab: QueryTab) -> some View {
809809
MainStatusBarView(
810-
tab: tab,
810+
snapshot: StatusBarSnapshot(tab: tab),
811811
filterStateManager: filterStateManager,
812812
columnVisibilityManager: columnVisibilityManager,
813813
allColumns: tab.resultColumns,

TablePro/Views/Main/Child/MainStatusBarView.swift

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,30 @@
77

88
import SwiftUI
99

10-
/// Status bar at the bottom of the results section
10+
struct StatusBarSnapshot: Equatable {
11+
let tabId: UUID?
12+
let tabType: TabType?
13+
let hasRows: Bool
14+
let hasColumns: Bool
15+
let rowCount: Int
16+
let hasTableName: Bool
17+
let pagination: PaginationState
18+
let statusMessage: String?
19+
20+
init(tab: QueryTab?) {
21+
self.tabId = tab?.id
22+
self.tabType = tab?.tabType
23+
self.hasRows = !(tab?.resultRows.isEmpty ?? true)
24+
self.hasColumns = !(tab?.resultColumns.isEmpty ?? true)
25+
self.rowCount = tab?.resultRows.count ?? 0
26+
self.hasTableName = tab?.tableContext.tableName != nil
27+
self.pagination = tab?.pagination ?? PaginationState()
28+
self.statusMessage = tab?.execution.statusMessage
29+
}
30+
}
31+
1132
struct MainStatusBarView: View {
12-
let tab: QueryTab?
33+
let snapshot: StatusBarSnapshot
1334
let filterStateManager: FilterStateManager
1435
let columnVisibilityManager: ColumnVisibilityManager
1536
let allColumns: [String]
@@ -33,9 +54,8 @@ struct MainStatusBarView: View {
3354

3455
var body: some View {
3556
HStack {
36-
// Left: View mode toggle
37-
if let tab = tab {
38-
if tab.tabType == .table, tab.tableContext.tableName != nil {
57+
if snapshot.tabId != nil {
58+
if snapshot.tabType == .table, snapshot.hasTableName {
3959
Picker(String(localized: "View Mode"), selection: $viewMode) {
4060
Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data)
4161
Label("Structure", systemImage: "list.bullet.rectangle").tag(ResultsViewMode.structure)
@@ -45,7 +65,7 @@ struct MainStatusBarView: View {
4565
.pickerStyle(.segmented)
4666
.frame(width: 260)
4767
.controlSize(.small)
48-
} else if !tab.resultColumns.isEmpty {
68+
} else if snapshot.hasColumns {
4969
Picker(String(localized: "View Mode"), selection: $viewMode) {
5070
Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data)
5171
Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json)
@@ -60,21 +80,21 @@ struct MainStatusBarView: View {
6080
Spacer()
6181

6282
// Center: Row info (selection or pagination summary) and status message
63-
if let tab = tab, !tab.resultRows.isEmpty {
83+
if snapshot.hasRows {
6484
HStack(spacing: 4) {
65-
if tab.pagination.isLoadingMore {
85+
if snapshot.pagination.isLoadingMore {
6686
ProgressView()
6787
.controlSize(.mini)
6888
Text("Loading…")
6989
.font(.caption)
7090
.foregroundStyle(.secondary)
7191
} else {
72-
Text(rowInfoText(for: tab))
92+
Text(rowInfoText)
7393
.font(.caption)
7494
.foregroundStyle(.secondary)
7595
}
7696

77-
if tab.tabType == .query && tab.pagination.hasMoreRows && !tab.pagination.isLoadingMore {
97+
if snapshot.tabType == .query && snapshot.pagination.hasMoreRows && !snapshot.pagination.isLoadingMore {
7898
Text("")
7999
.font(.caption)
80100
.foregroundStyle(.quaternary)
@@ -100,7 +120,7 @@ struct MainStatusBarView: View {
100120
.foregroundStyle(.secondary)
101121
}
102122

103-
if let statusMessage = tab.execution.statusMessage {
123+
if let statusMessage = snapshot.statusMessage {
104124
Text("·")
105125
.foregroundStyle(.tertiary)
106126
Text(statusMessage)
@@ -115,7 +135,7 @@ struct MainStatusBarView: View {
115135
// Right: Columns, Filters toggle and Pagination controls
116136
HStack(spacing: 8) {
117137
// Columns visibility button (works for both table and query tabs)
118-
if let tab = tab, !tab.resultColumns.isEmpty {
138+
if snapshot.hasColumns {
119139
Button {
120140
showColumnPopover.toggle()
121141
} label: {
@@ -141,7 +161,7 @@ struct MainStatusBarView: View {
141161
}
142162

143163
// Filters toggle button
144-
if let tab = tab, tab.tabType == .table, tab.tableContext.tableName != nil {
164+
if snapshot.tabType == .table, snapshot.hasTableName {
145165
Toggle(isOn: Binding(
146166
get: { filterStateManager.isVisible },
147167
set: { _ in filterStateManager.toggle() }
@@ -163,10 +183,10 @@ struct MainStatusBarView: View {
163183
}
164184

165185
// Pagination controls for table tabs
166-
if let tab = tab, tab.tabType == .table, tab.tableContext.tableName != nil,
167-
let total = tab.pagination.totalRowCount, total > 0 {
186+
if snapshot.tabType == .table, snapshot.hasTableName,
187+
let total = snapshot.pagination.totalRowCount, total > 0 {
168188
PaginationControlsView(
169-
pagination: tab.pagination,
189+
pagination: snapshot.pagination,
170190
onFirst: onFirstPage,
171191
onPrevious: onPreviousPage,
172192
onNext: onNextPage,
@@ -181,16 +201,16 @@ struct MainStatusBarView: View {
181201
.padding(.horizontal, 0)
182202
.padding(.vertical, 4)
183203
.background(Color(nsColor: .controlBackgroundColor))
184-
.onChange(of: tab?.id) { _, _ in
204+
.onChange(of: snapshot.tabId) { _, _ in
185205
showColumnPopover = false
186206
}
187207
}
188208

189209
/// Generate row info text based on selection and pagination state
190-
private func rowInfoText(for tab: QueryTab) -> String {
191-
let loadedCount = tab.resultRows.count
210+
private var rowInfoText: String {
211+
let loadedCount = snapshot.rowCount
192212
let selectedCount = selectedRowIndices.count
193-
let pagination = tab.pagination
213+
let pagination = snapshot.pagination
194214
let total = pagination.totalRowCount
195215

196216
if selectedCount > 0 {
@@ -199,15 +219,15 @@ struct MainStatusBarView: View {
199219
} else {
200220
return String(format: String(localized: "%d of %d rows selected"), selectedCount, loadedCount)
201221
}
202-
} else if tab.tabType == .query && pagination.hasMoreRows {
222+
} else if snapshot.tabType == .query && pagination.hasMoreRows {
203223
let formattedCount = loadedCount.formatted(.number.grouping(.automatic))
204224
if let total = total, total > 0 {
205225
let formattedTotal = total.formatted(.number.grouping(.automatic))
206226
let prefix = pagination.isApproximateRowCount ? "~" : ""
207227
return String(format: String(localized: "%@ of %@%@ rows"), formattedCount, prefix, formattedTotal)
208228
}
209229
return String(format: String(localized: "%@ rows (more available)"), formattedCount)
210-
} else if tab.tabType == .table, let total = total, total > 0 {
230+
} else if snapshot.tabType == .table, let total = total, total > 0 {
211231
let formattedTotal = total.formatted(.number.grouping(.automatic))
212232
let prefix = pagination.isApproximateRowCount ? "~" : ""
213233

TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,22 @@ extension MainContentView {
268268
}
269269
}
270270

271-
// Lazy-load full values for excluded columns when a single row is selected
271+
}
272+
273+
func lazyLoadExcludedColumnsIfNeeded() {
274+
guard let tab = coordinator.tabManager.selectedTab else { return }
275+
let selectedIndices = coordinator.selectionState.indices
276+
277+
let excludedNames: Set<String>
278+
if let tableName = tab.tableContext.tableName {
279+
excludedNames = Set(coordinator.columnExclusions(for: tableName).map(\.columnName))
280+
} else {
281+
excludedNames = []
282+
}
283+
284+
let capturedCoordinator = coordinator
285+
let capturedEditState = rightPanelState.editState
286+
272287
if !excludedNames.isEmpty,
273288
selectedIndices.count == 1,
274289
let tableName = tab.tableContext.tableName,

TablePro/Views/Main/Extensions/MainContentView+Helpers.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,16 @@ extension MainContentView {
6262

6363
// MARK: - Inspector Context
6464

65-
func scheduleInspectorUpdate() {
65+
func scheduleInspectorUpdate(lazyLoadExcludedColumns: Bool = false) {
6666
updateSidebarEditState()
6767
inspectorUpdateTask?.cancel()
6868
inspectorUpdateTask = Task { @MainActor in
6969
try? await Task.sleep(for: .milliseconds(50))
7070
guard !Task.isCancelled else { return }
7171
updateInspectorContext()
72+
if lazyLoadExcludedColumns {
73+
lazyLoadExcludedColumnsIfNeeded()
74+
}
7275
}
7376
}
7477

TablePro/Views/Main/MainContentView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ struct MainContentView: View {
425425
{
426426
coordinator.inspectorProxy?.showInspector()
427427
}
428-
scheduleInspectorUpdate()
428+
scheduleInspectorUpdate(lazyLoadExcludedColumns: true)
429429
},
430430
onFilterColumn: { columnName in
431431
filterStateManager.addFilterForColumn(columnName)

TablePro/Views/Results/DataGridView.swift

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,7 @@ struct DataGridView: NSViewRepresentable {
273273
// Only do full reload if row/column count changed, columns changed, or result version changed
274274
// For cell edits (versionChanged but same count), use granular reload
275275
let structureChanged = oldRowCount != newRowCount || oldColumnCount != newColumnCount
276-
let resultVersionChanged = previousIdentity.map { $0.resultVersion != resultVersion } ?? false
277-
let needsFullReload = structureChanged || resultVersionChanged
276+
let needsFullReload = structureChanged
278277

279278
coordinator.rowProvider = rowProvider
280279

@@ -564,19 +563,14 @@ struct DataGridView: NSViewRepresentable {
564563
}
565564
}
566565
} else if versionChanged {
567-
// Granular reload: only reload rows that changed
568566
let changedRows = changeManager.consumeChangedRowIndices()
569567
if changedRows.count > 500 {
570-
// Too many changed rows — full reload is faster than granular
571568
tableView.reloadData()
572569
} else if !changedRows.isEmpty {
573-
// Some rows changed → granular reload for performance
574570
let rowIndexSet = IndexSet(changedRows)
575571
let columnIndexSet = IndexSet(integersIn: 0..<tableView.numberOfColumns)
576572
tableView.reloadData(forRowIndexes: rowIndexSet, columnIndexes: columnIndexSet)
577-
} else {
578-
// Version changed but no specific rows tracked → full reload
579-
// Covers: undo/redo operations, cleared changes (refresh), etc.
573+
} else if !changeManager.hasChanges {
580574
tableView.reloadData()
581575
}
582576
}

TablePro/Views/Results/DataGridViewDelegate.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ protocol DataGridViewDelegate: AnyObject {
2121
func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool)
2222
func dataGridFilterColumn(_ columnName: String)
2323
func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo)
24+
func dataGridDuplicateRow()
25+
func dataGridExportResults()
2426
func dataGridHideColumn(_ columnName: String)
2527
func dataGridShowAllColumns()
2628
func dataGridRefresh()
@@ -42,6 +44,8 @@ extension DataGridViewDelegate {
4244
func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) {}
4345
func dataGridFilterColumn(_ columnName: String) {}
4446
func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) {}
47+
func dataGridDuplicateRow() {}
48+
func dataGridExportResults() {}
4549
func dataGridHideColumn(_ columnName: String) {}
4650
func dataGridShowAllColumns() {}
4751
func dataGridRefresh() {}

TablePro/Views/Results/KeyHandlingTableView.swift

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,8 @@ final class KeyHandlingTableView: NSTableView {
6666
let clickedRow = row(at: point)
6767
let clickedColumn = column(at: point)
6868

69-
// Double-click in empty area adds a new row
7069
if event.clickCount == 2 && clickedRow == -1 && coordinator?.isEditable == true {
71-
if let delegate = coordinator?.delegate {
72-
delegate.dataGridAddRow()
73-
} else {
74-
NotificationCenter.default.post(name: .addNewRow, object: nil)
75-
}
70+
coordinator?.delegate?.dataGridAddRow()
7671
return
7772
}
7873

@@ -106,30 +101,14 @@ final class KeyHandlingTableView: NSTableView {
106101

107102
// MARK: - Standard Edit Menu Actions
108103

109-
/// Delete selected rows - called from menu or keyboard shortcut
110104
@objc func delete(_ sender: Any?) {
111105
guard coordinator?.isEditable == true else { return }
112106
guard !selectedRowIndexes.isEmpty else { return }
113-
114-
if let delegate = coordinator?.delegate {
115-
delegate.dataGridDeleteRows(Set(selectedRowIndexes))
116-
} else {
117-
NotificationCenter.default.post(
118-
name: .deleteSelectedRows,
119-
object: nil,
120-
userInfo: ["rowIndices": Set(selectedRowIndexes)]
121-
)
122-
}
107+
coordinator?.delegate?.dataGridDeleteRows(Set(selectedRowIndexes))
123108
}
124109

125-
/// Copy selected rows to clipboard
126110
@objc func copy(_ sender: Any?) {
127-
let indices = Set(selectedRowIndexes)
128-
if let delegate = coordinator?.delegate {
129-
delegate.dataGridCopyRows(indices)
130-
} else {
131-
coordinator?.copyRows(at: indices)
132-
}
111+
coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes))
133112
}
134113

135114
/// Paste rows from clipboard

0 commit comments

Comments
 (0)