diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0cf8c6a..5a1be5ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - AWS IAM authentication for PostgreSQL and MySQL connections to RDS and Aurora. Pick AWS IAM in the connection's Authentication field and use an access key, a named AWS profile, or SSO. TablePro generates a fresh login token on every connect and reconnect, so you never paste an expiring token, and SSL is required automatically. (#1291) +- Pagination bar for table tabs with a rows-per-page menu (5, 10, 20, 100, 500, 1,000, All rows, or a custom size) and First, Previous, Next, and Last page buttons. (#1364) +- Click the page indicator in the pagination bar to jump to a specific page. (#1364) +- Pagination now appears for filtered tables whose total row count is unknown, so you can page through them instead of seeing only the first page. (#1364) +- First Page and Last Page keyboard actions, unbound by default and assignable in Settings > Keyboard. (#1364) ## [0.44.0] - 2026-05-23 diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 90de70f4b..ef4ab868e 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -21,7 +21,10 @@ final class PaginationCoordinator { // MARK: - Pagination func goToNextPage() { - paginateIfPossible(where: \.hasNextPage) { $0.goToNextPage() } + guard let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex else { return } + let loadedRowCount = parent.tabSessionRegistry.tableRows(for: tab.id).rows.count + guard tab.pagination.canGoToNextPage(loadedRowCount: loadedRowCount) else { return } + paginateAfterConfirmation(tabIndex: tabIndex) { $0.goToNextPage(loadedRowCount: loadedRowCount) } } func goToPreviousPage() { @@ -33,7 +36,11 @@ final class PaginationCoordinator { } func goToLastPage() { - paginateIfPossible(where: { $0.currentPage != $0.totalPages }) { $0.goToLastPage() } + paginateIfPossible(where: { $0.isLastPageKnown && $0.currentPage != $0.totalPages }) { $0.goToLastPage() } + } + + func goToPage(_ page: Int) { + paginateIfPossible(where: { $0.isLastPageKnown && page > 0 && page <= $0.totalPages }) { $0.goToPage(page) } } func updatePageSize(_ newSize: Int) { @@ -41,13 +48,38 @@ final class PaginationCoordinator { paginateIfPossible { $0.updatePageSize(newSize) } } - func updateOffset(_ newOffset: Int) { - guard newOffset >= 0 else { return } - paginateIfPossible { $0.updateOffset(newOffset) } - } + func showAllRows() { + guard let (tab, _) = parent.tabManager.selectedTabAndIndex, + let total = tab.pagination.totalRowCount, total > 0 else { return } - func applyPaginationSettings() { - reloadCurrentPage() + let tabId = tab.id + let alert = NSAlert() + alert.messageText = String(localized: "Show All Rows") + alert.informativeText = String( + format: String(localized: "This will load all %@ rows on a single page. Large result sets use significant memory. Continue?"), + total.formatted() + ) + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "Show All")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let apply: () -> Void = { [weak self] in + guard let self, + let tabIndex = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + paginateAfterConfirmation(tabIndex: tabIndex) { pagination in + pagination.updatePageSize(max(total, 1)) + pagination.goToFirstPage() + } + } + + if let window = parent.contentWindow ?? NSApp.keyWindow { + alert.beginSheetModal(for: window) { response in + guard response == .alertFirstButtonReturn else { return } + apply() + } + } else if alert.runModal() == .alertFirstButtonReturn { + apply() + } } private func paginateIfPossible( diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 34c5e36f2..2d1e72742 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -142,6 +142,15 @@ struct PaginationState: Equatable { currentPage < totalPages } + var isLastPageKnown: Bool { + totalRowCount != nil + } + + func canGoToNextPage(loadedRowCount: Int) -> Bool { + if hasNextPage { return true } + return totalRowCount == nil && loadedRowCount >= pageSize + } + /// Whether there is a previous page available var hasPreviousPage: Bool { currentPage > 1 @@ -169,6 +178,12 @@ struct PaginationState: Equatable { currentOffset = (currentPage - 1) * pageSize } + mutating func goToNextPage(loadedRowCount: Int) { + guard canGoToNextPage(loadedRowCount: loadedRowCount) else { return } + currentPage += 1 + currentOffset = (currentPage - 1) * pageSize + } + /// Navigate to previous page mutating func goToPreviousPage() { guard hasPreviousPage else { return } diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index edf54033e..5abc8b0e4 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -58,6 +58,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { // Navigation case previousPage case nextPage + case firstPage + case lastPage // Edit case undo @@ -103,7 +105,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .saveChanges, .saveAs, .previewSQL, .closeTab, .refresh, .executeQuery, .executeAllStatements, .cancelQuery, .explainQuery, .formatQuery, .export, .importData, .quickSwitcher, - .previousPage, .nextPage, .saveAsFavorite: + .previousPage, .nextPage, .firstPage, .lastPage, .saveAsFavorite: return .file case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .paste, .delete, .selectAll, .clearSelection, .addRow, @@ -150,6 +152,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .quickSwitcher: return String(localized: "Quick Switcher") case .previousPage: return String(localized: "Previous Page") case .nextPage: return String(localized: "Next Page") + case .firstPage: return String(localized: "First Page") + case .lastPage: return String(localized: "Last Page") case .undo: return String(localized: "Undo") case .redo: return String(localized: "Redo") case .cut: return String(localized: "Cut") diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index b0c53bc36..0bffd2a49 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -2,208 +2,237 @@ // PaginationControlsView.swift // TablePro // -// Pagination controls for navigating large datasets (TablePlus-style) -// import SwiftUI -/// Pagination controls displayed in the status bar (TablePlus design) struct PaginationControlsView: View { let pagination: PaginationState + let loadedRowCount: Int let onFirst: () -> Void let onPrevious: () -> Void let onNext: () -> Void let onLast: () -> Void - let onLimitChange: (Int) -> Void - let onOffsetChange: (Int) -> Void - let onGo: () -> Void + let onPageSizeChange: (Int) -> Void + let onShowAll: () -> Void + let onGoToPage: (Int) -> Void + + @State private var showJumpPopover = false + @State private var showCustomPopover = false + @State private var jumpText = "" + @State private var customText = "" + @FocusState private var isJumpFocused: Bool + @FocusState private var isCustomFocused: Bool - @State private var limitText: String = "" - @State private var offsetText: String = "" - @State private var showSettings = false - @FocusState private var isLimitFocused: Bool - @FocusState private var isOffsetFocused: Bool + private static let pageSizePresets = [5, 10, 20, 100, 500, 1_000] var body: some View { HStack(spacing: 8) { - // Navigation buttons - navigationButtons + pageSizeMenu + navigationCluster + } + .popover(isPresented: $showCustomPopover, arrowEdge: .top) { + customPageSizePopover + } + } + + // MARK: - Page Size Menu - // Settings button (gear icon) - opens popover - Button(action: { showSettings.toggle() }) { - Image(systemName: "gearshape") - .frame(width: 24, height: 24) + private var pageSizeMenu: some View { + Menu { + Picker(String(localized: "Rows per page"), selection: pageSizeBinding) { + ForEach(Self.pageSizePresets, id: \.self) { size in + Text(size.formatted()).tag(size) + } } - .buttonStyle(.borderless) - .help(String(localized: "Pagination Settings")) - .popover(isPresented: $showSettings, arrowEdge: .top) { - settingsPopover + .pickerStyle(.inline) + + Divider() + + Button(String(localized: "All rows…")) { onShowAll() } + Button(String(localized: "Custom…")) { + customText = "\(pagination.pageSize)" + showCustomPopover = true } + } label: { + Text(pageSizeLabel) } - .onAppear { - limitText = "\(pagination.pageSize)" - offsetText = "\(pagination.currentOffset)" - } - .onChange(of: pagination.pageSize) { _, newValue in - limitText = "\(newValue)" - } - .onChange(of: pagination.currentOffset) { _, newValue in - offsetText = "\(newValue)" - } + .menuStyle(.borderlessButton) + .fixedSize() + .controlSize(.small) + .help(String(localized: "Rows per page")) + .accessibilityLabel(String(localized: "Rows per page")) } - // MARK: - Navigation Buttons + private var pageSizeBinding: Binding { + Binding(get: { pagination.pageSize }, set: { onPageSizeChange($0) }) + } - private var navigationButtons: some View { - HStack(spacing: 4) { - // Previous page button - Button(action: onPrevious) { - Image(systemName: "chevron.left") - .imageScale(.small) - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .disabled(!pagination.hasPreviousPage || pagination.isLoading) - .help(String(localized: "Previous Page (⌘[)")) - .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: .previousPage)) + private var pageSizeLabel: String { + pagination.pageSize.formatted() + } - // Page indicator: "1 of 25" - Text("\(pagination.currentPage) of \(pagination.totalPages)") - .font(.caption) - .foregroundStyle(.secondary) - .frame(minWidth: 60) + // MARK: - Navigation + + private var navigationCluster: some View { + HStack(spacing: 2) { + navButton( + "chevron.backward.to.line", + label: String(localized: "First page"), + enabled: pagination.hasPreviousPage, + action: onFirst, + shortcut: .firstPage + ) + navButton( + "chevron.backward", + label: String(localized: "Previous page"), + enabled: pagination.hasPreviousPage, + action: onPrevious, + shortcut: .previousPage + ) + + pageIndicator if pagination.isLoading { ProgressView() .controlSize(.small) } - // Next page button - Button(action: onNext) { - Image(systemName: "chevron.right") - .imageScale(.small) - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .disabled(!pagination.hasNextPage || pagination.isLoading) - .help(String(localized: "Next Page (⌘])")) - .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: .nextPage)) + navButton( + "chevron.forward", + label: String(localized: "Next page"), + enabled: pagination.canGoToNextPage(loadedRowCount: loadedRowCount), + action: onNext, + shortcut: .nextPage + ) + navButton( + "chevron.forward.to.line", + label: String(localized: "Last page"), + enabled: pagination.isLastPageKnown && pagination.currentPage != pagination.totalPages, + action: onLast, + shortcut: .lastPage + ) } } - // MARK: - Settings Popover - - private var settingsPopover: some View { - VStack(spacing: 0) { - Form { - Section { - LabeledContent(String(localized: "Limit")) { - TextField("", text: $limitText) - .multilineTextAlignment(.trailing) - .focused($isLimitFocused) - .onSubmit { applyLimitChange() } - } - LabeledContent(String(localized: "Offset")) { - TextField("", text: $offsetText) - .multilineTextAlignment(.trailing) - .focused($isOffsetFocused) - .onSubmit { applyOffsetChange() } - } - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + private func navButton( + _ symbol: String, + label: String, + enabled: Bool, + action: @escaping () -> Void, + shortcut: ShortcutAction + ) -> some View { + Button(action: action) { + Image(systemName: symbol) + .imageScale(.small) + .frame(width: 22, height: 22) + } + .buttonStyle(.borderless) + .disabled(!enabled || pagination.isLoading) + .help(label) + .accessibilityLabel(label) + .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: shortcut)) + } - Divider() + private var pageIndicator: some View { + Button { + jumpText = "\(pagination.currentPage)" + showJumpPopover = true + } label: { + Text(pageIndicatorText) + .font(.caption) + .foregroundStyle(.secondary) + .frame(minWidth: 44) + } + .buttonStyle(.plain) + .disabled(!pagination.isLastPageKnown) + .help(String(localized: "Go to page")) + .accessibilityLabel(pageIndicatorAccessibilityLabel) + .popover(isPresented: $showJumpPopover, arrowEdge: .top) { + jumpPopover + } + } + + private var pageIndicatorText: String { + guard pagination.isLastPageKnown else { return "\(pagination.currentPage)" } + return "\(pagination.currentPage) / \(pagination.totalPages)" + } + + private var pageIndicatorAccessibilityLabel: String { + guard pagination.isLastPageKnown else { + return String(format: String(localized: "Page %d"), pagination.currentPage) + } + return String(format: String(localized: "Page %d of %d"), pagination.currentPage, pagination.totalPages) + } + + // MARK: - Popovers - Button { - applyLimitChange() - applyOffsetChange() - showSettings = false - } label: { - Text("Go").frame(maxWidth: .infinity) + private var jumpPopover: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Go to page") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("", text: $jumpText) + .frame(width: 70) + .focused($isJumpFocused) + .onSubmit(submitJump) + Button("Go", action: submitJump) + .keyboardShortcut(.defaultAction) } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - .keyboardShortcut(.defaultAction) - .padding(12) } - .frame(width: 220) + .padding(12) + .onAppear { isJumpFocused = true } } - // MARK: - Helpers + private var customPageSizePopover: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Rows per page") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("", text: $customText) + .frame(width: 80) + .focused($isCustomFocused) + .onSubmit(submitCustom) + Button("Apply", action: submitCustom) + .keyboardShortcut(.defaultAction) + } + } + .padding(12) + .onAppear { isCustomFocused = true } + } + + // MARK: - Actions - private func applyLimitChange() { - if let limit = Int(limitText), limit > 0 { - onLimitChange(limit) - } else { - limitText = "\(pagination.pageSize)" + private func submitJump() { + if let page = Int(jumpText), page > 0, page <= pagination.totalPages { + onGoToPage(page) } + showJumpPopover = false } - private func applyOffsetChange() { - if let offset = Int(offsetText), offset >= 0 { - onOffsetChange(offset) - } else { - offsetText = "\(pagination.currentOffset)" + private func submitCustom() { + if let size = Int(customText), size > 0 { + onPageSizeChange(size) } + showCustomPopover = false } } #Preview { VStack(spacing: 20) { - // Preview with multiple pages - PaginationControlsView( - pagination: PaginationState( - totalRowCount: 5_000, - pageSize: 200, - currentPage: 3, - currentOffset: 400, - isLoading: false - ), - onFirst: {}, - onPrevious: {}, - onNext: {}, - onLast: {}, - onLimitChange: { _ in }, - onOffsetChange: { _ in }, - onGo: {} - ) - - // Preview on first page PaginationControlsView( - pagination: PaginationState( - totalRowCount: 1_000, - pageSize: 200, - currentPage: 1, - currentOffset: 0, - isLoading: false - ), - onFirst: {}, - onPrevious: {}, - onNext: {}, - onLast: {}, - onLimitChange: { _ in }, - onOffsetChange: { _ in }, - onGo: {} + pagination: PaginationState(totalRowCount: 5_000, pageSize: 1_000, currentPage: 3, currentOffset: 2_000), + loadedRowCount: 1_000, + onFirst: {}, onPrevious: {}, onNext: {}, onLast: {}, + onPageSizeChange: { _ in }, onShowAll: {}, onGoToPage: { _ in } ) - // Preview loading state PaginationControlsView( - pagination: PaginationState( - totalRowCount: 5_000, - pageSize: 200, - currentPage: 2, - currentOffset: 200, - isLoading: true - ), - onFirst: {}, - onPrevious: {}, - onNext: {}, - onLast: {}, - onLimitChange: { _ in }, - onOffsetChange: { _ in }, - onGo: {} + pagination: PaginationState(totalRowCount: nil, pageSize: 1_000, currentPage: 2, currentOffset: 1_000), + loadedRowCount: 1_000, + onFirst: {}, onPrevious: {}, onNext: {}, onLast: {}, + onPageSizeChange: { _ in }, onShowAll: {}, onGoToPage: { _ in } ) } .padding() diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 58f0cc848..b02ccfed0 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -51,9 +51,9 @@ struct MainEditorContentView: View { let onPreviousPage: () -> Void let onNextPage: () -> Void let onLastPage: () -> Void - let onLimitChange: (Int) -> Void - let onOffsetChange: (Int) -> Void - let onPaginationGo: () -> Void + let onPageSizeChange: (Int) -> Void + let onShowAll: () -> Void + let onGoToPage: (Int) -> Void @State private var cachedChangeManager: AnyChangeManager? @State private var erDiagramViewModels: [UUID: ERDiagramViewModel] = [:] @@ -751,9 +751,9 @@ struct MainEditorContentView: View { onPreviousPage: onPreviousPage, onNextPage: onNextPage, onLastPage: onLastPage, - onLimitChange: onLimitChange, - onOffsetChange: onOffsetChange, - onPaginationGo: onPaginationGo, + onPageSizeChange: onPageSizeChange, + onShowAll: onShowAll, + onGoToPage: onGoToPage, onToggleColumn: { coordinator.toggleColumnVisibility($0) }, onShowAllColumns: { coordinator.showAllColumns() }, onHideAllColumns: { coordinator.hideAllColumns($0) }, diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 34b816cd0..1900ea98a 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -44,9 +44,9 @@ struct MainStatusBarView: View { let onPreviousPage: () -> Void let onNextPage: () -> Void let onLastPage: () -> Void - let onLimitChange: (Int) -> Void - let onOffsetChange: (Int) -> Void - let onPaginationGo: () -> Void + let onPageSizeChange: (Int) -> Void + let onShowAll: () -> Void + let onGoToPage: (Int) -> Void // Column visibility callbacks let onToggleColumn: (String) -> Void @@ -186,17 +186,17 @@ struct MainStatusBarView: View { } // Pagination controls for table tabs - if snapshot.tabType == .table, snapshot.hasTableName, - let total = snapshot.pagination.totalRowCount, total > 0 { + if snapshot.tabType == .table, snapshot.hasTableName, showsPaginationControls { PaginationControlsView( pagination: snapshot.pagination, + loadedRowCount: snapshot.rowCount, onFirst: onFirstPage, onPrevious: onPreviousPage, onNext: onNextPage, onLast: onLastPage, - onLimitChange: onLimitChange, - onOffsetChange: onOffsetChange, - onGo: onPaginationGo + onPageSizeChange: onPageSizeChange, + onShowAll: onShowAll, + onGoToPage: onGoToPage ) } } @@ -209,6 +209,12 @@ struct MainStatusBarView: View { } } + private var showsPaginationControls: Bool { + let pagination = snapshot.pagination + if let total = pagination.totalRowCount, total > 0 { return true } + return pagination.currentPage > 1 || snapshot.rowCount >= pagination.pageSize + } + /// Generate row info text based on selection and pagination state private var rowInfoText: String { let loadedCount = snapshot.rowCount @@ -230,6 +236,9 @@ struct MainStatusBarView: View { let prefix = pagination.isApproximateRowCount ? "~" : "" return String(format: String(localized: "%d-%d of %@%@ rows"), pagination.rangeStart, pagination.rangeEnd, prefix, formattedTotal) + } else if snapshot.tabType == .table, pagination.currentPage > 1 || loadedCount >= pagination.pageSize { + let rangeEnd = pagination.currentOffset + loadedCount + return String(format: String(localized: "%d-%d of ? rows"), pagination.rangeStart, rangeEnd) } else if loadedCount > 0 { let formattedCount = loadedCount.formatted(.number.grouping(.automatic)) return String(format: String(localized: "%@ rows"), formattedCount) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift index 2ff31aa8a..92f328a77 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift @@ -22,15 +22,15 @@ extension MainContentCoordinator { paginationCoordinator.goToLastPage() } - func updatePageSize(_ newSize: Int) { - paginationCoordinator.updatePageSize(newSize) + func goToPage(_ page: Int) { + paginationCoordinator.goToPage(page) } - func updateOffset(_ newOffset: Int) { - paginationCoordinator.updateOffset(newOffset) + func updatePageSize(_ newSize: Int) { + paginationCoordinator.updatePageSize(newSize) } - func applyPaginationSettings() { - paginationCoordinator.applyPaginationSettings() + func showAllRows() { + paginationCoordinator.showAllRows() } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index ae2d9c426..c246283f0 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -456,14 +456,14 @@ struct MainContentView: View { onLastPage: { coordinator.goToLastPage() }, - onLimitChange: { newLimit in - coordinator.updatePageSize(newLimit) + onPageSizeChange: { newSize in + coordinator.updatePageSize(newSize) }, - onOffsetChange: { newOffset in - coordinator.updateOffset(newOffset) + onShowAll: { + coordinator.showAllRows() }, - onPaginationGo: { - coordinator.applyPaginationSettings() + onGoToPage: { page in + coordinator.goToPage(page) } ) } diff --git a/TableProTests/Models/PaginationStateTests.swift b/TableProTests/Models/PaginationStateTests.swift index 844579d1f..e555a73af 100644 --- a/TableProTests/Models/PaginationStateTests.swift +++ b/TableProTests/Models/PaginationStateTests.swift @@ -197,4 +197,70 @@ struct PaginationStateTests { #expect(state.rangeStart == 1) #expect(state.rangeEnd == 5) } + + @Test("Last page is known when total row count is set") + func isLastPageKnownWithTotal() { + let state = PaginationState(totalRowCount: 100, pageSize: 10) + #expect(state.isLastPageKnown == true) + } + + @Test("Last page is unknown when total row count is nil") + func isLastPageUnknownWithNilTotal() { + let state = PaginationState(totalRowCount: nil, pageSize: 10) + #expect(state.isLastPageKnown == false) + } + + @Test("Range end with unknown total uses offset plus page size") + func rangeEndWithNilTotal() { + let state = PaginationState(totalRowCount: nil, pageSize: 10, currentPage: 2, currentOffset: 10) + #expect(state.rangeEnd == 20) + } + + @Test("Can go to next page with a known total mid-range") + func canGoToNextPageKnownTotal() { + let state = PaginationState(totalRowCount: 100, pageSize: 10, currentPage: 1) + #expect(state.canGoToNextPage(loadedRowCount: 10) == true) + } + + @Test("Cannot go to next page on the last known page") + func cannotGoToNextPageOnLastKnownPage() { + let state = PaginationState(totalRowCount: 100, pageSize: 10, currentPage: 10) + #expect(state.canGoToNextPage(loadedRowCount: 10) == false) + } + + @Test("Can go to next page with unknown total and a full page loaded") + func canGoToNextPageUnknownTotalFullPage() { + let state = PaginationState(totalRowCount: nil, pageSize: 10, currentPage: 1) + #expect(state.canGoToNextPage(loadedRowCount: 10) == true) + } + + @Test("Cannot go to next page with unknown total and a partial page loaded") + func cannotGoToNextPageUnknownTotalPartialPage() { + let state = PaginationState(totalRowCount: nil, pageSize: 10, currentPage: 1) + #expect(state.canGoToNextPage(loadedRowCount: 7) == false) + } + + @Test("Go to next page with loaded count advances when total is unknown") + func goToNextPageLoadedCountAdvancesUnknownTotal() { + var state = PaginationState(totalRowCount: nil, pageSize: 10, currentPage: 1) + state.goToNextPage(loadedRowCount: 10) + #expect(state.currentPage == 2) + #expect(state.currentOffset == 10) + } + + @Test("Go to next page with loaded count does nothing on a partial unknown-total page") + func goToNextPageLoadedCountNoOpOnPartialPage() { + var state = PaginationState(totalRowCount: nil, pageSize: 10, currentPage: 1) + state.goToNextPage(loadedRowCount: 4) + #expect(state.currentPage == 1) + #expect(state.currentOffset == 0) + } + + @Test("Go to page does nothing when total is unknown") + func goToPageNoOpWithNilTotal() { + var state = PaginationState(totalRowCount: nil, pageSize: 10, currentPage: 1) + state.goToPage(3) + #expect(state.currentPage == 1) + #expect(state.currentOffset == 0) + } } diff --git a/TableProTests/Views/Main/PaginationCoordinatorTests.swift b/TableProTests/Views/Main/PaginationCoordinatorTests.swift new file mode 100644 index 000000000..b0746def8 --- /dev/null +++ b/TableProTests/Views/Main/PaginationCoordinatorTests.swift @@ -0,0 +1,101 @@ +// +// PaginationCoordinatorTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("PaginationCoordinator navigation") +@MainActor +struct PaginationCoordinatorTests { + private func makeCoordinator( + pagination: PaginationState, + loadedRowCount: Int + ) -> (MainContentCoordinator, QueryTabManager, UUID) { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + var tab = QueryTab(title: "users", query: "SELECT * FROM users", tabType: .table) + tab.tableContext.tableName = "users" + tab.pagination = pagination + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + let columns = ["id", "name"] + let rows = (0.. PaginationState? { + tabManager.tabs.first { $0.id == tabId }?.pagination + } + + @Test("Go to page jumps to the requested page when total is known") + func goToPageWithKnownTotal() { + let (coordinator, tabManager, tabId) = makeCoordinator( + pagination: PaginationState(totalRowCount: 100, pageSize: 10, currentPage: 1), + loadedRowCount: 10 + ) + coordinator.goToPage(3) + #expect(pagination(tabManager, tabId)?.currentPage == 3) + #expect(pagination(tabManager, tabId)?.currentOffset == 20) + } + + @Test("Go to page is ignored when total is unknown") + func goToPageIgnoredWhenTotalUnknown() { + let (coordinator, tabManager, tabId) = makeCoordinator( + pagination: PaginationState(totalRowCount: nil, pageSize: 10, currentPage: 1), + loadedRowCount: 10 + ) + coordinator.goToPage(3) + #expect(pagination(tabManager, tabId)?.currentPage == 1) + } + + @Test("Next page advances on an unknown total when a full page is loaded") + func nextPageAdvancesUnknownTotalFullPage() { + let (coordinator, tabManager, tabId) = makeCoordinator( + pagination: PaginationState(totalRowCount: nil, pageSize: 5, currentPage: 1), + loadedRowCount: 5 + ) + coordinator.goToNextPage() + #expect(pagination(tabManager, tabId)?.currentPage == 2) + #expect(pagination(tabManager, tabId)?.currentOffset == 5) + } + + @Test("Next page does nothing on an unknown total when a partial page is loaded") + func nextPageNoOpUnknownTotalPartialPage() { + let (coordinator, tabManager, tabId) = makeCoordinator( + pagination: PaginationState(totalRowCount: nil, pageSize: 10, currentPage: 1), + loadedRowCount: 4 + ) + coordinator.goToNextPage() + #expect(pagination(tabManager, tabId)?.currentPage == 1) + } + + @Test("Last page is ignored when total is unknown") + func lastPageIgnoredWhenTotalUnknown() { + let (coordinator, tabManager, tabId) = makeCoordinator( + pagination: PaginationState(totalRowCount: nil, pageSize: 5, currentPage: 2, currentOffset: 5), + loadedRowCount: 5 + ) + coordinator.goToLastPage() + #expect(pagination(tabManager, tabId)?.currentPage == 2) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 50d3e6127..a3913b0d1 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -369,7 +369,13 @@ Copying rows reflects the grid as shown: hidden columns are left out and the col ## Pagination -Table tabs paginate large result sets. Set page size in **Settings** > **Editor** (Small: 100, Medium: 500, Large: 1,000, Custom: any value from 10 to 100,000). Smaller pages load faster. +Table tabs paginate large result sets. The status bar shows a rows-per-page menu and First / Previous / Next / Last buttons, with a page indicator between them. + +- **Rows per page**: pick a preset (5, 10, 20, 100, 500, 1,000), enter a custom size, or choose **All rows** to load the whole table on one page. Loading all rows asks for confirmation first, since large tables use a lot of memory. +- **Page navigation**: jump to the first or last page, step one page at a time, or click the page indicator to go to a specific page. +- The bar also appears for filtered tables whose total row count is unknown. There the Next button stays available while a full page keeps loading, and the indicator shows the page number without a total. + +Set the default page size in **Settings** > **Editor** (Small: 100, Medium: 500, Large: 1,000, Custom: any value from 10 to 100,000). Smaller pages load faster. {/* Screenshot: Pagination controls with page navigation */} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index b9dd3efe6..8bc831a4f 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -97,6 +97,15 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Page up | `Page Up` | | Page down | `Page Down` | +### Pagination + +| Action | Shortcut | +|--------|----------| +| Previous page | `Cmd+[` | +| Next page | `Cmd+]` | +| First page | Unbound (set in **Settings** > **Keyboard**) | +| Last page | Unbound (set in **Settings** > **Keyboard**) | + ### Editing | Action | Shortcut |