Skip to content

Commit fcf0115

Browse files
authored
feat: persist column widths and order per table across sessions (#413)
* feat: persist column widths and order per table across sessions * fix: address code review issues for column layout persistence
1 parent 024a19b commit fcf0115

9 files changed

Lines changed: 164 additions & 23 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Show/hide row numbers column in data grid (Settings > Data Grid)
13+
- Persist column widths and order per table across tab switches, view toggles, and app restarts
1314

1415
## [0.22.0] - 2026-03-21
1516

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// ColumnLayoutStorage.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
@MainActor
9+
internal final class ColumnLayoutStorage {
10+
static let shared = ColumnLayoutStorage()
11+
12+
private init() {}
13+
14+
// MARK: - Types
15+
16+
private struct PersistedColumnLayout: Codable {
17+
var columnWidths: [String: CGFloat]
18+
var columnOrder: [String]?
19+
}
20+
21+
// MARK: - Public API
22+
23+
func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {
24+
guard !layout.columnWidths.isEmpty else { return }
25+
26+
let persisted = PersistedColumnLayout(
27+
columnWidths: layout.columnWidths,
28+
columnOrder: layout.columnOrder
29+
)
30+
let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId)
31+
if let data = try? JSONEncoder().encode(persisted) {
32+
UserDefaults.standard.set(data, forKey: key)
33+
}
34+
}
35+
36+
func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? {
37+
let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId)
38+
guard let data = UserDefaults.standard.data(forKey: key),
39+
let persisted = try? JSONDecoder().decode(PersistedColumnLayout.self, from: data)
40+
else {
41+
return nil
42+
}
43+
var state = ColumnLayoutState()
44+
state.columnWidths = persisted.columnWidths
45+
state.columnOrder = persisted.columnOrder
46+
return state
47+
}
48+
49+
func clear(for tableName: String, connectionId: UUID) {
50+
let key = Self.userDefaultsKey(tableName: tableName, connectionId: connectionId)
51+
UserDefaults.standard.removeObject(forKey: key)
52+
}
53+
54+
// MARK: - Private
55+
56+
private static func userDefaultsKey(tableName: String, connectionId: UUID) -> String {
57+
"com.TablePro.columns.layout.\(connectionId.uuidString).\(tableName)"
58+
}
59+
}

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ struct MainEditorContentView: View {
329329
databaseType: connection.type,
330330
tableName: tab.tableName,
331331
primaryKeyColumn: changeManager.primaryKeyColumn,
332+
tabType: tab.tabType,
332333
showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers,
333334
hiddenColumns: columnVisibilityManager.hiddenColumns,
334335
onHideColumn: { [coordinator] columnName in
@@ -473,6 +474,7 @@ struct MainEditorContentView: View {
473474
}
474475
DispatchQueue.main.async {
475476
coordinator.isUpdatingColumnLayout = false
477+
coordinator.saveColumnLayoutForTable()
476478
}
477479
}
478480
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// MainContentCoordinator+ColumnLayout.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
extension MainContentCoordinator {
9+
func saveColumnLayoutForTable() {
10+
guard let index = tabManager.selectedTabIndex else { return }
11+
let tab = tabManager.tabs[index]
12+
guard tab.tabType == .table, let tableName = tab.tableName, !tableName.isEmpty else { return }
13+
14+
ColumnLayoutStorage.shared.save(tab.columnLayout, for: tableName, connectionId: connectionId)
15+
columnVisibilityManager.saveLastHiddenColumns(for: tableName, connectionId: connectionId)
16+
}
17+
18+
func restoreColumnLayoutForTable(_ tableName: String) {
19+
guard let index = tabManager.selectedTabIndex else { return }
20+
21+
if let savedLayout = ColumnLayoutStorage.shared.load(for: tableName, connectionId: connectionId) {
22+
tabManager.tabs[index].columnLayout.columnWidths = savedLayout.columnWidths
23+
tabManager.tabs[index].columnLayout.columnOrder = savedLayout.columnOrder
24+
}
25+
restoreLastHiddenColumnsForTable(tableName)
26+
}
27+
}

TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ extension MainContentCoordinator {
9797
}
9898
// In-place navigation needs selectRedisDatabaseAndQuery to ensure the correct
9999
// database is SELECTed and session state is updated before querying.
100+
restoreColumnLayoutForTable(tableName)
100101
if navigationModel == .inPlace, let dbIndex = Int(currentDatabase) {
101102
selectRedisDatabaseAndQuery(dbIndex)
102103
} else {
@@ -119,6 +120,7 @@ extension MainContentCoordinator {
119120
toolbarState.isTableTab = true
120121
AppState.shared.isTableTab = true
121122
}
123+
restoreColumnLayoutForTable(tableName)
122124
if let dbIndex = Int(currentDatabase) {
123125
selectRedisDatabaseAndQuery(dbIndex)
124126
}
@@ -190,6 +192,7 @@ extension MainContentCoordinator {
190192
AppState.shared.isTableTab = true
191193
}
192194
preview.window.makeKeyAndOrderFront(nil)
195+
previewCoordinator.restoreColumnLayoutForTable(tableName)
193196
previewCoordinator.runQuery()
194197
return
195198
}
@@ -216,6 +219,7 @@ extension MainContentCoordinator {
216219
toolbarState.isTableTab = true
217220
AppState.shared.isTableTab = true
218221
}
222+
restoreColumnLayoutForTable(tableName)
219223
runQuery()
220224
return
221225
}

TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ extension MainContentCoordinator {
2626
tabManager.tabs[oldIndex].pendingChanges = changeManager.saveState()
2727
}
2828
tabManager.tabs[oldIndex].filterState = filterStateManager.saveToTabState()
29+
saveColumnVisibilityToTab()
30+
saveColumnLayoutForTable()
2931
}
3032

3133
if tabManager.tabs.count > 2 {

TablePro/Views/Main/MainContentView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,9 @@ struct MainContentView: View {
501501
)
502502
tabManager.tabs[tabIndex].query = filteredQuery
503503
}
504+
if let tableName = selectedTab.tableName {
505+
coordinator.restoreColumnLayoutForTable(tableName)
506+
}
504507
coordinator.executeTableTabQueryDirectly()
505508
}
506509
} else {
@@ -577,6 +580,9 @@ struct MainContentView: View {
577580
{
578581
Task { await coordinator.switchDatabase(to: selectedTab.databaseName) }
579582
} else {
583+
if let tableName = selectedTab.tableName {
584+
coordinator.restoreColumnLayoutForTable(tableName)
585+
}
580586
coordinator.executeTableTabQueryDirectly()
581587
}
582588
} else {

TablePro/Views/Results/DataGridView.swift

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ struct DataGridView: NSViewRepresentable {
6161
var databaseType: DatabaseType?
6262
var tableName: String?
6363
var primaryKeyColumn: String?
64+
var tabType: TabType?
6465
var showRowNumbers: Bool = true
6566
var hiddenColumns: Set<String> = []
6667
var onHideColumn: ((String) -> Void)?
@@ -271,6 +272,7 @@ struct DataGridView: NSViewRepresentable {
271272
coordinator.databaseType = databaseType
272273
coordinator.tableName = tableName
273274
coordinator.primaryKeyColumn = primaryKeyColumn
275+
coordinator.tabType = tabType
274276

275277
coordinator.rebuildVisualStateCache()
276278

@@ -336,15 +338,11 @@ struct DataGridView: NSViewRepresentable {
336338
column.headerCell.setAccessibilityLabel(
337339
String(localized: "Column: \(columnName)")
338340
)
339-
if let savedWidth = columnLayout.columnWidths[columnName] {
340-
column.width = savedWidth
341-
} else {
342-
column.width = coordinator.cellFactory.calculateOptimalColumnWidth(
343-
for: columnName,
344-
columnIndex: index,
345-
rowProvider: rowProvider
346-
)
347-
}
341+
column.width = coordinator.cellFactory.calculateOptimalColumnWidth(
342+
for: columnName,
343+
columnIndex: index,
344+
rowProvider: rowProvider
345+
)
348346
column.minWidth = 30
349347
column.resizingMask = .userResizingMask
350348
column.isEditable = isEditable
@@ -358,20 +356,18 @@ struct DataGridView: NSViewRepresentable {
358356
colIndex < rowProvider.columns.count else { continue }
359357
let columnName = rowProvider.columns[colIndex]
360358
column.title = columnName
361-
if let savedWidth = columnLayout.columnWidths[columnName] {
362-
column.width = savedWidth
363-
} else {
364-
column.width = coordinator.cellFactory.calculateOptimalColumnWidth(
365-
for: columnName,
366-
columnIndex: colIndex,
367-
rowProvider: rowProvider
368-
)
369-
}
359+
column.width = coordinator.cellFactory.calculateOptimalColumnWidth(
360+
for: columnName,
361+
columnIndex: colIndex,
362+
rowProvider: rowProvider
363+
)
370364
column.isEditable = isEditable
371365
}
372366
}
373-
// Restore user-resized column widths after rebuild (only if user explicitly resized)
374-
if coordinator.hasUserResizedColumns, !columnLayout.columnWidths.isEmpty {
367+
let hasSavedLayout = !columnLayout.columnWidths.isEmpty
368+
369+
// Restore saved column widths after rebuild (from user resize or persisted layout)
370+
if hasSavedLayout {
375371
for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" {
376372
guard let colIndex = Self.columnIndex(from: column.identifier),
377373
colIndex < rowProvider.columns.count else { continue }
@@ -380,16 +376,19 @@ struct DataGridView: NSViewRepresentable {
380376
column.width = savedWidth
381377
}
382378
}
379+
coordinator.hasUserResizedColumns = true
383380
}
384381

385-
// Restore saved column order after rebuild (only if user explicitly reordered)
386-
if coordinator.hasUserResizedColumns, let savedOrder = columnLayout.columnOrder {
382+
// Restore saved column order after rebuild
383+
if let savedOrder = columnLayout.columnOrder {
387384
DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: rowProvider.columns)
385+
coordinator.hasUserResizedColumns = true
388386
}
389387

390388
// Persist calculated widths so subsequent tab switches reuse them
391389
// instead of calling the expensive calculateOptimalColumnWidth.
392-
if !coordinator.hasUserResizedColumns {
390+
// Skip when saved layout exists to avoid overwriting persisted values.
391+
if !coordinator.hasUserResizedColumns, !hasSavedLayout {
393392
var newWidths: [String: CGFloat] = [:]
394393
for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" {
395394
guard let colIndex = Self.columnIndex(from: column.identifier),
@@ -624,6 +623,7 @@ struct DataGridView: NSViewRepresentable {
624623

625624
static func dismantleNSView(_ nsView: NSScrollView, coordinator: TableViewCoordinator) {
626625
coordinator.overlayEditor?.dismiss(commit: false)
626+
coordinator.persistColumnLayoutToStorage()
627627
if let observer = coordinator.settingsObserver {
628628
NotificationCenter.default.removeObserver(observer)
629629
coordinator.settingsObserver = nil
@@ -681,6 +681,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
681681
var databaseType: DatabaseType?
682682
var tableName: String?
683683
var primaryKeyColumn: String?
684+
var tabType: TabType?
684685

685686
/// Check if undo is available
686687
func canUndo() -> Bool {
@@ -692,6 +693,32 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
692693
changeManager.canRedo
693694
}
694695

696+
/// Capture current column widths and order from the live NSTableView
697+
/// and persist directly to ColumnLayoutStorage. Called from dismantleNSView
698+
/// to guarantee layout is saved even when the view is torn down without
699+
/// a SwiftUI render cycle (e.g., closing a tab).
700+
func persistColumnLayoutToStorage() {
701+
guard tabType == .table else { return }
702+
guard let tableView, let connectionId, let tableName, !tableName.isEmpty else { return }
703+
guard !rowProvider.columns.isEmpty else { return }
704+
705+
var widths: [String: CGFloat] = [:]
706+
var order: [String] = []
707+
for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" {
708+
guard let colIndex = DataGridView.columnIndex(from: column.identifier),
709+
colIndex < rowProvider.columns.count else { continue }
710+
let name = rowProvider.columns[colIndex]
711+
widths[name] = column.width
712+
order.append(name)
713+
}
714+
715+
guard !widths.isEmpty else { return }
716+
var layout = ColumnLayoutState()
717+
layout.columnWidths = widths
718+
layout.columnOrder = order
719+
ColumnLayoutStorage.shared.save(layout, for: tableName, connectionId: connectionId)
720+
}
721+
695722
weak var tableView: NSTableView?
696723
let cellFactory = DataGridCellFactory()
697724
var overlayEditor: CellOverlayEditor?
@@ -717,6 +744,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
717744
var hasUserResizedColumns: Bool = false
718745
/// Guards against two-frame bounce when async column layout write-back triggers updateNSView
719746
var isWritingColumnLayout: Bool = false
747+
/// Debounced work item for persisting column layout after resize/reorder
748+
var layoutPersistWorkItem: DispatchWorkItem?
720749

721750
private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell")
722751
static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView")

TablePro/Views/Results/Extensions/DataGridView+Selection.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,22 @@ extension TableViewCoordinator {
1111
// Only track user-initiated resizes, not programmatic ones during column rebuilds
1212
guard !isRebuildingColumns else { return }
1313
hasUserResizedColumns = true
14+
scheduleLayoutPersist()
1415
}
1516

1617
func tableViewColumnDidMove(_ notification: Notification) {
1718
guard !isRebuildingColumns else { return }
1819
hasUserResizedColumns = true
20+
scheduleLayoutPersist()
21+
}
22+
23+
private func scheduleLayoutPersist() {
24+
layoutPersistWorkItem?.cancel()
25+
let workItem = DispatchWorkItem { [weak self] in
26+
self?.persistColumnLayoutToStorage()
27+
}
28+
layoutPersistWorkItem = workItem
29+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
1930
}
2031

2132
func tableViewSelectionDidChange(_ notification: Notification) {

0 commit comments

Comments
 (0)