Skip to content

Commit 7d3c0af

Browse files
committed
feat: persist column widths and order per table across sessions
1 parent 024a19b commit 7d3c0af

7 files changed

Lines changed: 130 additions & 5 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ struct MainEditorContentView: View {
473473
}
474474
DispatchQueue.main.async {
475475
coordinator.isUpdatingColumnLayout = false
476+
coordinator.saveColumnLayoutForTable()
476477
}
477478
}
478479
)
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/Results/DataGridView.swift

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,10 @@ struct DataGridView: NSViewRepresentable {
370370
column.isEditable = isEditable
371371
}
372372
}
373-
// Restore user-resized column widths after rebuild (only if user explicitly resized)
374-
if coordinator.hasUserResizedColumns, !columnLayout.columnWidths.isEmpty {
373+
let hasSavedLayout = !columnLayout.columnWidths.isEmpty
374+
375+
// Restore saved column widths after rebuild (from user resize or persisted layout)
376+
if hasSavedLayout {
375377
for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" {
376378
guard let colIndex = Self.columnIndex(from: column.identifier),
377379
colIndex < rowProvider.columns.count else { continue }
@@ -380,16 +382,19 @@ struct DataGridView: NSViewRepresentable {
380382
column.width = savedWidth
381383
}
382384
}
385+
coordinator.hasUserResizedColumns = true
383386
}
384387

385-
// Restore saved column order after rebuild (only if user explicitly reordered)
386-
if coordinator.hasUserResizedColumns, let savedOrder = columnLayout.columnOrder {
388+
// Restore saved column order after rebuild
389+
if let savedOrder = columnLayout.columnOrder {
387390
DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: rowProvider.columns)
391+
coordinator.hasUserResizedColumns = true
388392
}
389393

390394
// Persist calculated widths so subsequent tab switches reuse them
391395
// instead of calling the expensive calculateOptimalColumnWidth.
392-
if !coordinator.hasUserResizedColumns {
396+
// Skip when saved layout exists to avoid overwriting persisted values.
397+
if !coordinator.hasUserResizedColumns, !hasSavedLayout {
393398
var newWidths: [String: CGFloat] = [:]
394399
for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" {
395400
guard let colIndex = Self.columnIndex(from: column.identifier),
@@ -624,6 +629,7 @@ struct DataGridView: NSViewRepresentable {
624629

625630
static func dismantleNSView(_ nsView: NSScrollView, coordinator: TableViewCoordinator) {
626631
coordinator.overlayEditor?.dismiss(commit: false)
632+
coordinator.persistColumnLayoutToStorage()
627633
if let observer = coordinator.settingsObserver {
628634
NotificationCenter.default.removeObserver(observer)
629635
coordinator.settingsObserver = nil
@@ -692,6 +698,31 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
692698
changeManager.canRedo
693699
}
694700

701+
/// Capture current column widths and order from the live NSTableView
702+
/// and persist directly to ColumnLayoutStorage. Called from dismantleNSView
703+
/// to guarantee layout is saved even when the view is torn down without
704+
/// a SwiftUI render cycle (e.g., closing a tab).
705+
func persistColumnLayoutToStorage() {
706+
guard let tableView, let connectionId, let tableName, !tableName.isEmpty else { return }
707+
guard !rowProvider.columns.isEmpty else { return }
708+
709+
var widths: [String: CGFloat] = [:]
710+
var order: [String] = []
711+
for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" {
712+
guard let colIndex = DataGridView.columnIndex(from: column.identifier),
713+
colIndex < rowProvider.columns.count else { continue }
714+
let name = rowProvider.columns[colIndex]
715+
widths[name] = column.width
716+
order.append(name)
717+
}
718+
719+
guard !widths.isEmpty else { return }
720+
var layout = ColumnLayoutState()
721+
layout.columnWidths = widths
722+
layout.columnOrder = order
723+
ColumnLayoutStorage.shared.save(layout, for: tableName, connectionId: connectionId)
724+
}
725+
695726
weak var tableView: NSTableView?
696727
let cellFactory = DataGridCellFactory()
697728
var overlayEditor: CellOverlayEditor?

0 commit comments

Comments
 (0)