From fc24f97285afee1429f8e4309e7f7e09761f129a Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:36:26 -0700 Subject: [PATCH 1/5] fix(sidebar): remove blank space above table tree on selection change --- CHANGELOG.md | 1 + TablePro/Views/Sidebar/SidebarTreeView.swift | 2 ++ TablePro/Views/Sidebar/SidebarView.swift | 2 ++ 3 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d871bd8e..969a7dcee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Switching between sidebar tables no longer leaves extra blank space above the table tree. (#1675) - SSH tunnels no longer pin a CPU core after the connection drops. A dropped tunnel is now detected and torn down instead of spinning in its relay loop. (#1769) ## [0.53.0] - 2026-06-25 diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index b786365ef..ae62e44b3 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -65,6 +65,8 @@ struct SidebarTreeView: View { } .listStyle(.sidebar) .scrollContentBackground(.hidden) + .safeAreaPadding(.top, 0) + .environment(\.defaultMinListHeaderHeight, 0) .contextMenu(forSelectionType: TableInfo.self) { _ in EmptyView() } primaryAction: { selection in diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index a85359039..2b9ab4cad 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -378,6 +378,8 @@ struct SidebarView: View { } .listStyle(.sidebar) .scrollContentBackground(.hidden) + .safeAreaPadding(.top, 0) + .environment(\.defaultMinListHeaderHeight, 0) .contextMenu(forSelectionType: TableInfo.self) { selection in SidebarContextMenu( clickedTable: selection.first, From de251453efbac722e6f9358de3372ff8b62b5b27 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 28 Jun 2026 18:51:53 +0700 Subject: [PATCH 2/5] refactor(sidebar): extract shared list layout modifier and fix favorites tab --- CHANGELOG.md | 2 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 3 +-- TablePro/Views/Sidebar/SidebarListLayout.swift | 17 +++++++++++++++++ TablePro/Views/Sidebar/SidebarTreeView.swift | 5 +---- TablePro/Views/Sidebar/SidebarView.swift | 5 +---- 5 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 TablePro/Views/Sidebar/SidebarListLayout.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 969a7dcee..983388baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Switching between sidebar tables no longer leaves extra blank space above the table tree. (#1675) +- Switching between sidebar tables no longer leaves extra blank space above the list. (#1675) - SSH tunnels no longer pin a CPU core after the connection drops. A dropped tunnel is now detected and torn down instead of spinning in its relay loop. (#1769) ## [0.53.0] - 2026-06-25 diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 5d2901eb3..af587e7e1 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -183,8 +183,7 @@ internal struct FavoritesTabView: View { } } } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) + .sidebarListLayout() .onDeleteCommand { deleteSelectedNode() } diff --git a/TablePro/Views/Sidebar/SidebarListLayout.swift b/TablePro/Views/Sidebar/SidebarListLayout.swift new file mode 100644 index 000000000..87eedf818 --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarListLayout.swift @@ -0,0 +1,17 @@ +import SwiftUI + +private struct SidebarListLayout: ViewModifier { + func body(content: Content) -> some View { + content + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .safeAreaPadding(.top, 0) + .environment(\.defaultMinListHeaderHeight, 0) + } +} + +extension View { + func sidebarListLayout() -> some View { + modifier(SidebarListLayout()) + } +} diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index ae62e44b3..8655bacf3 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -63,10 +63,7 @@ struct SidebarTreeView: View { } } } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - .safeAreaPadding(.top, 0) - .environment(\.defaultMinListHeaderHeight, 0) + .sidebarListLayout() .contextMenu(forSelectionType: TableInfo.self) { _ in EmptyView() } primaryAction: { selection in diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 2b9ab4cad..fd2aaf99d 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -376,10 +376,7 @@ struct SidebarView: View { } } } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - .safeAreaPadding(.top, 0) - .environment(\.defaultMinListHeaderHeight, 0) + .sidebarListLayout() .contextMenu(forSelectionType: TableInfo.self) { selection in SidebarContextMenu( clickedTable: selection.first, From fc6e28f83be21a50ccda0446dc9453a0a6f0d35a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Jun 2026 10:10:43 +0700 Subject: [PATCH 3/5] fix(datagrid): restore table tabs using the live page size instead of the persisted query limit --- CHANGELOG.md | 1 + .../TabPersistenceCoordinator.swift | 3 +- TablePro/Models/Query/QueryTab.swift | 4 +- ...ainContentCoordinator+TableFirstLoad.swift | 7 +++- .../Services/PersistedTabRoundTripTests.swift | 20 +++++++--- TableProTests/Models/PreviewTabTests.swift | 2 +- .../Main/DefaultSortInitialQueryTests.swift | 40 +++++++++++++++++-- 7 files changed, 63 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983388baa..56f52c07d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Switching between sidebar tables no longer leaves extra blank space above the list. (#1675) - SSH tunnels no longer pin a CPU core after the connection drops. A dropped tunnel is now detected and torn down instead of spinning in its relay loop. (#1769) +- Restored table tabs now load with the current page size instead of the page size from the previous session. ## [0.53.0] - 2026-06-25 diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index ef4245914..5163b9d49 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -141,7 +141,8 @@ internal final class TabPersistenceCoordinator { return RestoreResult(tabs: [], selectedTabId: nil, source: .none) } - var restoredTabs = state.tabs.map { QueryTab(from: $0) } + let defaultPageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + var restoredTabs = state.tabs.map { QueryTab(from: $0, defaultPageSize: defaultPageSize) } for index in restoredTabs.indices { guard let url = restoredTabs[index].content.sourceFileURL else { continue } if let loaded = FileTextLoader.load(url) { diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 8d1aa14fe..a55ee4c50 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -72,7 +72,7 @@ struct QueryTab: Identifiable, Equatable { self.restoredCursorOffset = nil } - init(from persisted: PersistedTab) { + init(from persisted: PersistedTab, defaultPageSize: Int) { self.id = persisted.id self.title = persisted.title self.tabType = persisted.tabType @@ -96,7 +96,7 @@ struct QueryTab: Identifiable, Equatable { self.sortState = SortState() self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState(columnWidths: persisted.columnWidths ?? [:]) - self.pagination = PaginationState() + self.pagination = PaginationState(pageSize: defaultPageSize) self.hasUserInteraction = false self.schemaVersion = 0 self.metadataVersion = 0 diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index bcc985cb2..9b2a59f28 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -20,7 +20,12 @@ extension MainContentCoordinator { let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return false } let hint = PluginManager.shared.defaultSortHint(for: connection.type, table: tableName) - guard firstLoadNeedsSchemaColumns(for: tab, hint: hint) else { return true } + guard firstLoadNeedsSchemaColumns(for: tab, hint: hint) else { + if let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { + filterCoordinator.rebuildTableQuery(at: index) + } + return true + } await loadSchemaColumns(for: tableName, schema: tab.tableContext.schemaName) diff --git a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift index 53fbc0b5d..bcff3eb73 100644 --- a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift +++ b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift @@ -28,7 +28,7 @@ struct PersistedTabRoundTripTests { source: .user ) - let restored = QueryTab(from: tab.toPersistedTab()) + let restored = QueryTab(from: tab.toPersistedTab(), defaultPageSize: 1_000) #expect(restored.pendingRestoredSort?.count == 2) #expect(restored.pendingRestoredSort?[0].columnName == "created_at") @@ -52,7 +52,17 @@ struct PersistedTabRoundTripTests { let persisted = tab.toPersistedTab() #expect(persisted.restoredPage == 3) - #expect(QueryTab(from: persisted).restoredPage == 3) + #expect(QueryTab(from: persisted, defaultPageSize: 1_000).restoredPage == 3) + } + + @Test("Restored table tab seeds pagination from the live page size, not the persisted query") + func paginationSeedsFromLivePageSize() { + var tab = tableTab(query: "SELECT * FROM users LIMIT 500 OFFSET 0") + tab.pagination.pageSize = 500 + + let restored = QueryTab(from: tab.toPersistedTab(), defaultPageSize: 1_000) + + #expect(restored.pagination.pageSize == 1_000) } @Test("Page 1 is not persisted") @@ -70,7 +80,7 @@ struct PersistedTabRoundTripTests { let persisted = tab.toPersistedTab() #expect(persisted.cursorOffset == 7) - #expect(QueryTab(from: persisted).restoredCursorOffset == 7) + #expect(QueryTab(from: persisted, defaultPageSize: 1_000).restoredCursorOffset == 7) } @Test("Cursor offset is clamped to query length on restore") @@ -84,7 +94,7 @@ struct PersistedTabRoundTripTests { cursorOffset: 10_000 ) - #expect(QueryTab(from: persisted).restoredCursorOffset == ("SELECT" as NSString).length) + #expect(QueryTab(from: persisted, defaultPageSize: 1_000).restoredCursorOffset == ("SELECT" as NSString).length) } @Test("A truncated query drops the persisted cursor offset") @@ -107,7 +117,7 @@ struct PersistedTabRoundTripTests { var tab = tableTab() tab.columnLayout.columnWidths = ["id": 80, "name": 220.5] - let restored = QueryTab(from: tab.toPersistedTab()) + let restored = QueryTab(from: tab.toPersistedTab(), defaultPageSize: 1_000) #expect(restored.columnLayout.columnWidths == ["id": 80, "name": 220.5]) } diff --git a/TableProTests/Models/PreviewTabTests.swift b/TableProTests/Models/PreviewTabTests.swift index 58889d411..66edb74b1 100644 --- a/TableProTests/Models/PreviewTabTests.swift +++ b/TableProTests/Models/PreviewTabTests.swift @@ -28,7 +28,7 @@ struct PreviewTabTests { tabType: .table, tableName: "users" ) - let tab = QueryTab(from: persisted) + let tab = QueryTab(from: persisted, defaultPageSize: 1_000) #expect(tab.isPreview == false) } diff --git a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift index 2c2e75b1a..9c5e00259 100644 --- a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift +++ b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift @@ -103,20 +103,52 @@ struct DefaultSortInitialQueryTests { #expect(tabManager.tabs[index].content.query == originalQuery) } - @Test("None behavior takes the fast path without touching the query") - func noneBehaviorSkipsSchemaWait() async { + @Test("None behavior takes the fast path and regenerates the browse query from current state") + func noneBehaviorRegeneratesQueryOnFastPath() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") - let originalQuery = tabManager.tabs[index].content.query + let pageSize = tabManager.tabs[index].pagination.pageSize await withDefaultSortBehavior(DefaultSortBehavior.none) { let ready = await coordinator.prepareTableTabFirstLoad(tabId: tabManager.tabs[index].id) #expect(ready) } - #expect(tabManager.tabs[index].content.query == originalQuery) + let query = tabManager.tabs[index].content.query + #expect(query.contains("LIMIT \(pageSize)")) + #expect(!query.localizedCaseInsensitiveContains("ORDER BY")) #expect(!tabManager.tabs[index].sortState.isSorting) } + @Test("A restored table tab loads with the live page size, not the persisted query LIMIT") + func restoredTableTabUsesLivePageSize() async { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + let persisted = PersistedTab( + id: UUID(), + title: "users", + query: "SELECT * FROM `users` LIMIT 500 OFFSET 0", + tabType: .table, + tableName: "users" + ) + let tab = QueryTab(from: persisted, defaultPageSize: 1_000) + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + await withDefaultSortBehavior(DefaultSortBehavior.none) { + let ready = await coordinator.prepareTableTabFirstLoad(tabId: tab.id) + #expect(ready) + } + + let query = tabManager.tabs[0].content.query + #expect(query.contains("LIMIT 1000")) + #expect(!query.contains("500")) + } + @Test("A restored user sort is never overwritten by the default sort") func userSortSurvivesFirstLoad() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") From 8eba09e61e176e53a4e892817c0f9e4eaf51bbb2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Jun 2026 14:07:54 +0700 Subject: [PATCH 4/5] fix(datagrid): clamp restored page size to a positive value --- .../Services/Infrastructure/TabPersistenceCoordinator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 5163b9d49..8dd4e41c3 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -141,7 +141,7 @@ internal final class TabPersistenceCoordinator { return RestoreResult(tabs: [], selectedTabId: nil, source: .none) } - let defaultPageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + let defaultPageSize = max(1, AppSettingsManager.shared.dataGrid.defaultPageSize) var restoredTabs = state.tabs.map { QueryTab(from: $0, defaultPageSize: defaultPageSize) } for index in restoredTabs.indices { guard let url = restoredTabs[index].content.sourceFileURL else { continue } From 683397aa1f12672066502608adc272ad75a55494 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Jun 2026 14:14:28 +0700 Subject: [PATCH 5/5] refactor(settings): validate default page size at the decode boundary --- .../TabPersistenceCoordinator.swift | 2 +- TablePro/Models/Settings/AppSettings.swift | 3 ++- .../Inspector/InspectorViewController.swift | 2 +- ...ainContentCoordinator+TableFirstLoad.swift | 2 +- .../Validation/SettingsValidationTests.swift | 23 +++++++++++++++++++ 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 8dd4e41c3..5163b9d49 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -141,7 +141,7 @@ internal final class TabPersistenceCoordinator { return RestoreResult(tabs: [], selectedTabId: nil, source: .none) } - let defaultPageSize = max(1, AppSettingsManager.shared.dataGrid.defaultPageSize) + let defaultPageSize = AppSettingsManager.shared.dataGrid.defaultPageSize var restoredTabs = state.tabs.map { QueryTab(from: $0, defaultPageSize: defaultPageSize) } for index in restoredTabs.indices { guard let url = restoredTabs[index].content.sourceFileURL else { continue } diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 56f97e46c..2628a3744 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -205,7 +205,8 @@ struct DataGridSettings: Codable, Equatable { rowHeight = try container.decodeIfPresent(DataGridRowHeight.self, forKey: .rowHeight) ?? .normal dateFormat = try container.decodeIfPresent(DateFormatOption.self, forKey: .dateFormat) ?? .iso8601 nullDisplay = try container.decodeIfPresent(String.self, forKey: .nullDisplay) ?? "NULL" - defaultPageSize = try container.decodeIfPresent(Int.self, forKey: .defaultPageSize) ?? 1_000 + defaultPageSize = (try container.decodeIfPresent(Int.self, forKey: .defaultPageSize) ?? 1_000) + .clamped(to: SettingsValidationRules.defaultPageSizeRange) showAlternateRows = try container.decodeIfPresent(Bool.self, forKey: .showAlternateRows) ?? true showRowNumbers = try container.decodeIfPresent(Bool.self, forKey: .showRowNumbers) ?? true autoShowInspector = try container.decodeIfPresent(Bool.self, forKey: .autoShowInspector) ?? false diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index 37814f77b..1945bdea6 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -40,7 +40,7 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation self.gridDelegate = InspectorGridDelegate() super.init(nibName: nil, bundle: nil) gridDelegate.owner = self - state.pageSize = max(1, AppSettingsManager.shared.dataGrid.defaultPageSize) + state.pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize inspectorDocument.onChange = { [weak self] in guard let self else { return } if self.isApplyingGridCellEdit, self.displayIndices == nil { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index 9b2a59f28..c05d06ad4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -57,7 +57,7 @@ extension MainContentCoordinator { tab.pendingRestoredSort ?? [], in: effectiveResultColumns(for: tab) ) - let pageSize = max(1, AppSettingsManager.shared.dataGrid.defaultPageSize) + let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize let page = max(1, tab.restoredPage ?? 1) tabManager.mutate(at: index) { tab in diff --git a/TableProTests/Core/Validation/SettingsValidationTests.swift b/TableProTests/Core/Validation/SettingsValidationTests.swift index 51a26aa3e..dfcd2daeb 100644 --- a/TableProTests/Core/Validation/SettingsValidationTests.swift +++ b/TableProTests/Core/Validation/SettingsValidationTests.swift @@ -267,6 +267,29 @@ struct SettingsValidationTests { #expect(range.upperBound == 100_000) } + @Test("Decoding clamps an out-of-range stored page size to the valid range") + func decodingClampsDefaultPageSize() throws { + let range = SettingsValidationRules.defaultPageSizeRange + + let zero = try JSONDecoder().decode( + DataGridSettings.self, + from: Data(#"{"defaultPageSize":0}"#.utf8) + ) + #expect(zero.defaultPageSize == range.lowerBound) + + let tooLarge = try JSONDecoder().decode( + DataGridSettings.self, + from: Data(#"{"defaultPageSize":9999999}"#.utf8) + ) + #expect(tooLarge.defaultPageSize == range.upperBound) + + let valid = try JSONDecoder().decode( + DataGridSettings.self, + from: Data(#"{"defaultPageSize":1000}"#.utf8) + ) + #expect(valid.defaultPageSize == 1_000) + } + @Test("Validation rules min non-negative is correct") func validationRulesMinNonNegative() { #expect(SettingsValidationRules.minNonNegative == 0)