From e297a346d92881afab786291b820bc5e786432d7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 11 Jun 2026 17:38:46 +0700 Subject: [PATCH 1/5] fix(coordinator): stop Cmd+R refresh from cancelling its own table reload --- CHANGELOG.md | 2 + .../Coordinators/PaginationCoordinator.swift | 3 +- .../Database/DatabaseManager+Schema.swift | 2 +- TablePro/Core/Events/AppCommands.swift | 2 +- .../MainWindowToolbar+Actions.swift | 2 +- .../MainWindowToolbar+Buttons.swift | 2 +- .../Query/SchemaProviderRegistry.swift | 12 +-- TablePro/TableProApp.swift | 4 +- .../MainContentCoordinator+Refresh.swift | 58 +++++++------ ...inContentCoordinator+SessionContexts.swift | 2 +- .../Main/MainContentCommandActions.swift | 49 ++++++----- .../Views/Structure/ClickHousePartsView.swift | 6 +- .../Views/Structure/CreateTableView.swift | 2 +- .../StructureViewActionHandler.swift | 1 + .../TableStructureView+DataLoading.swift | 1 + .../Views/Structure/TableStructureView.swift | 15 +++- .../Autocomplete/SQLSchemaProviderTests.swift | 3 +- .../MainContentCoordinatorRefreshTests.swift | 87 ++++++++++++++++++- 18 files changed, 176 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 612401059..04a161c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509) +- Refresh (Cmd+R) now acts only on the focused window's connection, instead of also reloading views and clearing autocomplete caches for every other open connection. ### Fixed - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. - Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633) - Refreshing a table now reloads its data even when the previous load is still running; before, the refresh was silently dropped and the grid kept stale rows. (#1637) +- Cmd+R on a table now reloads its rows instead of failing with a query error; the refresh was sending the database a stray cancel that aborted its own freshly-issued reload. - SQL autocomplete now suggests tables after JOIN. It detects the clause at the cursor across multi-join and multi-clause queries, so columns no longer appear where a table is expected, and tables lead the list. (#1646) ### Security diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 498dfc170..8f274b3fb 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -129,10 +129,11 @@ final class PaginationCoordinator { // MARK: - Cancel Current Query func cancelCurrentQuery() { + let hadInFlightTask = parent.currentQueryTask != nil parent.currentQueryTask?.cancel() parent.currentQueryTask = nil parent.queryGeneration += 1 - if let driver = DatabaseManager.shared.driver(for: parent.connectionId) { + if hadInFlightTask, let driver = DatabaseManager.shared.driver(for: parent.connectionId) { try? driver.cancelQuery() } parent.toolbarState.setExecuting(false) diff --git a/TablePro/Core/Database/DatabaseManager+Schema.swift b/TablePro/Core/Database/DatabaseManager+Schema.swift index 45cde76b5..76c365d13 100644 --- a/TablePro/Core/Database/DatabaseManager+Schema.swift +++ b/TablePro/Core/Database/DatabaseManager+Schema.swift @@ -112,7 +112,7 @@ extension DatabaseManager { } await MainActor.run { - AppCommands.shared.refreshData.send(nil) + AppCommands.shared.refreshData.send(connectionId) } } catch { if useTransaction { diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index 995e7e291..d78f826bc 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -12,7 +12,7 @@ final class AppCommands { // MARK: - Refresh - let refreshData = PassthroughSubject() + let refreshData = PassthroughSubject() // MARK: - File / Connection Import-Export diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift index ac1b71e18..468ca8bb1 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift @@ -16,7 +16,7 @@ extension MainWindowToolbar { } @objc func performRefresh(_ sender: Any?) { - AppCommands.shared.refreshData.send(nil) + coordinator?.commandActions?.refresh() } @objc func performSaveChanges(_ sender: Any?) { diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift index 036d38693..b063bbb6b 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift @@ -85,7 +85,7 @@ struct RefreshToolbarButton: View { var body: some View { let state = coordinator.toolbarState Button { - AppCommands.shared.refreshData.send(nil) + coordinator.commandActions?.refresh() } label: { Label("Refresh", systemImage: "arrow.clockwise") } diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index f7f0dfe70..e99bc93fd 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -40,15 +40,9 @@ final class SchemaProviderRegistry { .store(in: &cancellables) } - func invalidateColumnCache(for connectionId: UUID?) { - if let id = connectionId { - guard let provider = providers[id] else { return } - Task { await provider.clearColumnCache() } - return - } - for provider in providers.values { - Task { await provider.clearColumnCache() } - } + func invalidateColumnCache(for connectionId: UUID) { + guard let provider = providers[connectionId] else { return } + Task { await provider.clearColumnCache() } } func provider(for connectionId: UUID) -> SQLSchemaProvider? { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 628413cde..907e0e4e0 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -237,7 +237,7 @@ struct AppMenuCommands: Commands { // - Clean method calls, no global event bus // // 3. **NotificationCenter** (Multi-listener broadcasts only): - // - refreshData (Sidebar + Coordinator + StructureView) + // - refreshData: targeted per-connection data-changed signal // - Legitimate broadcasts where multiple views respond // File menu @@ -423,7 +423,7 @@ struct AppMenuCommands: Commands { .disabled(!(actions?.isQueryExecuting ?? false)) Button("Refresh") { - AppCommands.shared.refreshData.send(nil) + actions?.refresh() } .optionalKeyboardShortcut(shortcut(for: .refresh)) .disabled(!(actions?.isConnected ?? false)) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index 343f0e485..369dad44d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -15,37 +15,41 @@ extension MainContentCoordinator { hasPendingTableOps: Bool, onDiscard: @escaping () -> Void ) { - // If showing structure view, let it handle refresh notifications - if let (tab, _) = tabManager.selectedTabAndIndex, - tab.display.resultsViewMode == .structure { + guard let (tab, _) = tabManager.selectedTabAndIndex else { return } + if tab.display.resultsViewMode == .structure { + structureActions?.refresh?() return } + reloadActiveTableData(hasPendingTableOps: hasPendingTableOps, onDiscard: onDiscard) + } - let hasEditedCells = changeManager.hasChanges + func reloadActiveTableData( + hasPendingTableOps: Bool, + onDiscard: @escaping () -> Void + ) { + guard let (tab, tabIndex) = tabManager.selectedTabAndIndex, + tab.tabType == .table, + tab.display.resultsViewMode != .structure else { return } - if hasEditedCells || hasPendingTableOps { - Task { - let window = NSApp.keyWindow - let confirmed = await confirmDiscardChanges(action: .refresh, window: window) - if confirmed { - onDiscard() - changeManager.clearChangesAndUndoHistory() - // Query tabs should not auto-execute on refresh (use Cmd+Enter to execute) - if let (tab, tabIndex) = tabManager.selectedTabAndIndex, - tab.tabType == .table { - cancelCurrentQuery() - rebuildTableQuery(at: tabIndex) - runQuery() - } - } - } - } else { - if let (tab, tabIndex) = tabManager.selectedTabAndIndex, - tab.tabType == .table { - cancelCurrentQuery() - rebuildTableQuery(at: tabIndex) - runQuery() - } + guard changeManager.hasChanges || hasPendingTableOps else { + reloadTableTab(at: tabIndex) + return } + + Task { + let confirmed = await confirmDiscardChanges(action: .refresh, window: NSApp.keyWindow) + guard confirmed else { return } + onDiscard() + changeManager.clearChangesAndUndoHistory() + guard let (tab, tabIndex) = tabManager.selectedTabAndIndex, + tab.tabType == .table else { return } + reloadTableTab(at: tabIndex) + } + } + + private func reloadTableTab(at tabIndex: Int) { + cancelCurrentQuery() + rebuildTableQuery(at: tabIndex) + runQuery() } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift index 23e41e202..38ed6ce12 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift @@ -28,7 +28,7 @@ extension MainContentCoordinator { do { try await driver.switchSessionContext(id: id, to: value) await loadSessionContexts() - AppCommands.shared.refreshData.send(nil) + AppCommands.shared.refreshData.send(connectionId) } catch { AlertHelper.showErrorSheet( title: String(localized: "Switch Failed"), diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 1f11efa63..88ed30922 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -893,36 +893,39 @@ final class MainContentCommandActions { // MARK: Data Broadcasts + func refresh() { + guard let coordinator else { return } + coordinator.handleRefresh( + hasPendingTableOps: hasPendingTableOps, + onDiscard: { [weak self] in self?.clearPendingTableOps() } + ) + Task { await coordinator.refreshTables() } + } + + private var hasPendingTableOps: Bool { + !pendingTruncates.wrappedValue.isEmpty || !pendingDeletes.wrappedValue.isEmpty + } + + private func clearPendingTableOps() { + pendingTruncates.wrappedValue.removeAll() + pendingDeletes.wrappedValue.removeAll() + } + private func setupDataBroadcastObservers() { AppCommands.shared.refreshData .receive(on: RunLoop.main) - .sink { [weak self] target in - guard let self else { return } - if let target, target != self.connection.id { - return - } - if target == nil && !self.isKeyWindow() { - return - } - self.handleRefreshData() + .sink { [weak self] changedConnectionId in + guard let self, changedConnectionId == self.connection.id, + let coordinator = self.coordinator else { return } + coordinator.reloadActiveTableData( + hasPendingTableOps: self.hasPendingTableOps, + onDiscard: { [weak self] in self?.clearPendingTableOps() } + ) + Task { await coordinator.refreshTables() } } .store(in: &eventCancellables) } - private func handleRefreshData() { - let hasPendingTableOps = !pendingTruncates.wrappedValue.isEmpty || !pendingDeletes.wrappedValue.isEmpty - coordinator?.handleRefresh( - hasPendingTableOps: hasPendingTableOps, - onDiscard: { [weak self] in - self?.pendingTruncates.wrappedValue.removeAll() - self?.pendingDeletes.wrappedValue.removeAll() - } - ) - if let coordinator { - Task { await coordinator.refreshTables() } - } - } - // MARK: Tab Broadcasts private func setupTabBroadcastObservers() { diff --git a/TablePro/Views/Structure/ClickHousePartsView.swift b/TablePro/Views/Structure/ClickHousePartsView.swift index 6e5d35c49..1197ff6b7 100644 --- a/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/TablePro/Views/Structure/ClickHousePartsView.swift @@ -14,6 +14,7 @@ struct ClickHousePartsView: View { let tableName: String let connectionId: UUID + let reloadToken: Int @State private var parts: [ClickHousePartInfo] = [] @State private var isLoading = true @@ -50,10 +51,7 @@ struct ClickHousePartsView: View { partsTable } } - .task { await loadParts() } - .onReceive(AppCommands.shared.refreshData) { _ in - Task { await loadParts() } - } + .task(id: reloadToken) { await loadParts() } } private var partsToolbar: some View { diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 0fb4d793a..60002ed2c 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -389,7 +389,7 @@ struct CreateTableView: View { wasSuccessful: true ) - AppCommands.shared.refreshData.send(nil) + AppCommands.shared.refreshData.send(connection.id) if let coordinator { coordinator.openTableTab(tableName) diff --git a/TablePro/Views/Structure/StructureViewActionHandler.swift b/TablePro/Views/Structure/StructureViewActionHandler.swift index f29936029..d73c4bead 100644 --- a/TablePro/Views/Structure/StructureViewActionHandler.swift +++ b/TablePro/Views/Structure/StructureViewActionHandler.swift @@ -15,4 +15,5 @@ final class StructureViewActionHandler { var redo: (() -> Void)? var addRow: (() -> Void)? var removeRow: (() -> Void)? + var refresh: (() -> Void)? } diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift index 8d5342743..0244b122f 100644 --- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift @@ -168,6 +168,7 @@ extension TableStructureView { private func reloadAllTabs() async { loadedTabs.removeAll() + partsReloadToken += 1 await loadColumns() await fetchTabData(.indexes) if connection.type.supportsForeignKeys { diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index b75aed694..459db63cc 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -35,6 +35,7 @@ struct TableStructureView: View { @State var isInitialLoading = true @State var errorMessage: String? @State var loadedTabs: Set = [] + @State var partsReloadToken = 0 @State var isReloadingAfterSave = false // Prevent onChange loops during save reload @State var lastSaveTime: Date? // Track when we last saved @AppStorage("skipSchemaPreview") var skipSchemaPreview = false @@ -126,6 +127,7 @@ struct TableStructureView: View { actionHandler.redo = { self.gridDelegate.dataGridRedo() } actionHandler.addRow = { self.gridDelegate.dataGridAddRow() } actionHandler.removeRow = { self.gridDelegate.dataGridDeleteRows(self.selectedRows) } + actionHandler.refresh = { self.onRefreshData() } coordinator?.structureActions = actionHandler publishFooterState() } @@ -148,7 +150,10 @@ struct TableStructureView: View { // manager but the grid never displays it. displayVersion += 1 } - .onReceive(AppCommands.shared.refreshData) { _ in onRefreshData() } + .onReceive(AppCommands.shared.refreshData) { changedConnectionId in + guard changedConnectionId == connection.id else { return } + onRefreshData() + } } // MARK: - Toolbar @@ -283,7 +288,11 @@ struct TableStructureView: View { case .ddl: ddlView case .parts: - ClickHousePartsView(tableName: tableName, connectionId: connection.id) + ClickHousePartsView( + tableName: tableName, + connectionId: connection.id, + reloadToken: partsReloadToken + ) } } @@ -358,7 +367,7 @@ struct TableStructureView: View { loadSchemaForEditing() isReloadingAfterSave = false columnLayoutPersister.clear(for: tableName, connectionId: connection.id) - AppCommands.shared.refreshData.send(nil) + AppCommands.shared.refreshData.send(connection.id) } catch { AlertHelper.showErrorSheet( title: String(localized: "Column Reorder Failed"), diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index 7011b2088..71ff47db8 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -24,6 +24,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var fetchColumnsCalls: [String] = [] var fetchSchemaTablesCalls: [String] = [] var applyQueryTimeoutValues: [Int] = [] + var cancelQueryCallCount = 0 init(connection: DatabaseConnection = TestFixtures.makeConnection()) { self.connection = connection @@ -94,7 +95,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { } func createDatabase(name: String, charset: String, collation: String?) async throws {} - func cancelQuery() throws {} + func cancelQuery() throws { cancelQueryCallCount += 1 } func beginTransaction() async throws {} func commitTransaction() async throws {} func rollbackTransaction() async throws {} diff --git a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift index d287bb5f5..009b303c3 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift @@ -17,9 +17,15 @@ import Testing @MainActor struct MainContentCoordinatorRefreshTests { private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { + makeCoordinator(connection: TestFixtures.makeConnection()) + } + + private func makeCoordinator( + connection: DatabaseConnection + ) -> (MainContentCoordinator, QueryTabManager) { let tabManager = QueryTabManager() let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), + connection: connection, tabManager: tabManager, changeManager: DataChangeManager(), toolbarState: ConnectionToolbarState() @@ -27,6 +33,19 @@ struct MainContentCoordinatorRefreshTests { return (coordinator, tabManager) } + private func withInjectedDriver( + _ body: (DatabaseConnection, MockDatabaseDriver) -> Void + ) { + let connection = TestFixtures.makeConnection() + let driver = MockDatabaseDriver(connection: connection) + DatabaseManager.shared.injectSession( + ConnectionSession(connection: connection, driver: driver), + for: connection.id + ) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + body(connection, driver) + } + private func addTableTab( to tabManager: QueryTabManager, tableName: String = "users", @@ -163,4 +182,70 @@ struct MainContentCoordinatorRefreshTests { #expect(coordinator.queryGeneration == initialGeneration) #expect(tabManager.tabs[idx].execution.isExecuting == true) } + + @Test("cancelCurrentQuery leaves the driver alone when no query is in flight") + func cancelWithoutInFlightDoesNotTouchDriver() { + withInjectedDriver { connection, driver in + let (coordinator, _) = makeCoordinator(connection: connection) + + coordinator.cancelCurrentQuery() + + #expect(driver.cancelQueryCallCount == 0) + } + } + + @Test("cancelCurrentQuery cancels the driver when a query is in flight") + func cancelWithInFlightCancelsDriver() { + withInjectedDriver { connection, driver in + let (coordinator, _) = makeCoordinator(connection: connection) + let inFlight = Task { _ = try? await Task.sleep(for: .seconds(60)) } + defer { inFlight.cancel() } + coordinator.currentQueryTask = inFlight + + coordinator.cancelCurrentQuery() + + #expect(driver.cancelQueryCallCount == 1) + } + } + + @Test("Refresh on an idle table tab does not issue a stray driver cancel") + func idleRefreshDoesNotCancelDriver() { + withInjectedDriver { connection, driver in + let (coordinator, tabManager) = makeCoordinator(connection: connection) + let tabId = addTableTab(to: tabManager) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("expected tab to exist") + return + } + tabManager.tabs[idx].execution.lastExecutedAt = Date() + + coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) + defer { coordinator.currentQueryTask?.cancel() } + + #expect(driver.cancelQueryCallCount == 0) + } + } + + @Test("Refresh in structure view dispatches to the structure refresh handler") + func refreshInStructureViewDispatchesToHandler() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("expected tab to exist") + return + } + tabManager.tabs[idx].display.resultsViewMode = .structure + + let handler = StructureViewActionHandler() + var refreshCalled = false + handler.refresh = { refreshCalled = true } + coordinator.structureActions = handler + + let initialGeneration = coordinator.queryGeneration + coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) + + #expect(refreshCalled == true) + #expect(coordinator.queryGeneration == initialGeneration) + #expect(coordinator.currentQueryTask == nil) + } } From 855be154157ca1dee32a4ad1a5090e7624e8f2e6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 11 Jun 2026 21:15:59 +0700 Subject: [PATCH 2/5] perf(coordinator): coalesce rapid Cmd+R refreshes and ignore key auto-repeat --- CHANGELOG.md | 1 + TablePro/AppDelegate.swift | 1 + .../KeyboardHandling/KeyRepeatFilter.swift | 36 +++++++++++++++++++ .../MainContentCoordinator+Refresh.swift | 25 +++++++++++++ .../Main/MainContentCommandActions.swift | 3 +- .../Views/Main/MainContentCoordinator.swift | 5 +++ 6 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 TablePro/Core/KeyboardHandling/KeyRepeatFilter.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a161c14..3cbafd913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509) - Refresh (Cmd+R) now acts only on the focused window's connection, instead of also reloading views and clearing autocomplete caches for every other open connection. +- Holding Cmd+R no longer queues a backlog of refreshes that kept running after the key was released; refresh fires once per key press, and rapid presses collapse into a single reload. ### Fixed diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 744957125..54fe47fc0 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -60,6 +60,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) NSWindow.allowsAutomaticWindowTabbing = true + KeyRepeatFilter.shared.install() let syncSettings = AppSettingsStorage.shared.loadSync() let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords UserDefaults.standard.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey) diff --git a/TablePro/Core/KeyboardHandling/KeyRepeatFilter.swift b/TablePro/Core/KeyboardHandling/KeyRepeatFilter.swift new file mode 100644 index 000000000..131e2fff6 --- /dev/null +++ b/TablePro/Core/KeyboardHandling/KeyRepeatFilter.swift @@ -0,0 +1,36 @@ +// +// KeyRepeatFilter.swift +// TablePro +// +// Drops OS key auto-repeat for actions that must fire once per physical press. +// SwiftUI `.commands` key-equivalents auto-repeat while held, but menu actions +// like Refresh should fire once per press, matching standard macOS behaviour. +// + +import AppKit + +@MainActor +final class KeyRepeatFilter { + static let shared = KeyRepeatFilter() + + private static let nonRepeatingActions: [ShortcutAction] = [.refresh] + + private var monitor: Any? + + private init() {} + + func install() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { nsEvent in + nonisolated(unsafe) let event = nsEvent + return MainActor.assumeIsolated { + guard event.isARepeat else { return event } + let keyboard = AppSettingsManager.shared.keyboard + let suppress = Self.nonRepeatingActions.contains { + keyboard.shortcut(for: $0)?.matches(event) == true + } + return suppress ? nil : event + } + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index 369dad44d..30defffb3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -11,6 +11,31 @@ import Foundation extension MainContentCoordinator { // MARK: - Refresh Handling + private static let refreshCoalesceInterval: Duration = .milliseconds(250) + + func requestRefresh(hasPendingTableOps: Bool, onDiscard: @escaping () -> Void) { + if refreshCoalesceTask == nil { + fireRefresh(hasPendingTableOps: hasPendingTableOps, onDiscard: onDiscard) + } else { + refreshPendingTrailing = true + } + refreshCoalesceTask?.cancel() + refreshCoalesceTask = Task { [weak self] in + try? await Task.sleep(for: Self.refreshCoalesceInterval) + guard let self, !Task.isCancelled else { return } + self.refreshCoalesceTask = nil + if self.refreshPendingTrailing { + self.refreshPendingTrailing = false + self.fireRefresh(hasPendingTableOps: hasPendingTableOps, onDiscard: onDiscard) + } + } + } + + private func fireRefresh(hasPendingTableOps: Bool, onDiscard: @escaping () -> Void) { + handleRefresh(hasPendingTableOps: hasPendingTableOps, onDiscard: onDiscard) + Task { await refreshTables() } + } + func handleRefresh( hasPendingTableOps: Bool, onDiscard: @escaping () -> Void diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 88ed30922..065165c3e 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -895,11 +895,10 @@ final class MainContentCommandActions { func refresh() { guard let coordinator else { return } - coordinator.handleRefresh( + coordinator.requestRefresh( hasPendingTableOps: hasPendingTableOps, onDiscard: { [weak self] in self?.clearPendingTableOps() } ) - Task { await coordinator.refreshTables() } } private var hasPendingTableOps: Bool { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f470dbb64..a04516d35 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -231,6 +231,9 @@ final class MainContentCoordinator { /// Eviction task scheduled in `handleWindowDidResignKey` (fires 5s later). @ObservationIgnored var evictionTask: Task? + @ObservationIgnored var refreshCoalesceTask: Task? + @ObservationIgnored var refreshPendingTrailing = false + /// True once the coordinator's view has appeared (onAppear fired). /// Coordinators that SwiftUI creates during body re-evaluation but never /// adopts into @State are silently discarded — no teardown warning needed. @@ -652,6 +655,8 @@ final class MainContentCoordinator { fileWatcher = nil currentQueryTask?.cancel() currentQueryTask = nil + refreshCoalesceTask?.cancel() + refreshCoalesceTask = nil for entry in tableLoadTasks.values { entry.task.cancel() } tableLoadTasks.removeAll() changeManagerUpdateTask?.cancel() From a052ea9388292a3d37576a9a9e5943f9503e1863 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 11 Jun 2026 21:48:31 +0700 Subject: [PATCH 3/5] refactor(coordinator): dedupe concurrent schema reloads with single-flight refreshTables --- TablePro/Views/Main/MainContentCoordinator.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index a04516d35..9aa34e5b0 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -233,6 +233,7 @@ final class MainContentCoordinator { @ObservationIgnored var refreshCoalesceTask: Task? @ObservationIgnored var refreshPendingTrailing = false + @ObservationIgnored private var schemaReloadTask: Task? /// True once the coordinator's view has appeared (onAppear fired). /// Coordinators that SwiftUI creates during body re-evaluation but never @@ -529,6 +530,19 @@ final class MainContentCoordinator { } func refreshTables() async { + if let existing = schemaReloadTask { + await existing.value + return + } + let task = Task { [weak self] in + await self?.reloadSchema() + } + schemaReloadTask = task + await task.value + schemaReloadTask = nil + } + + private func reloadSchema() async { schemaColumns.removeAll() let schemaService = services.schemaService let connectionId = connectionId @@ -657,6 +671,8 @@ final class MainContentCoordinator { currentQueryTask = nil refreshCoalesceTask?.cancel() refreshCoalesceTask = nil + schemaReloadTask?.cancel() + schemaReloadTask = nil for entry in tableLoadTasks.values { entry.task.cancel() } tableLoadTasks.removeAll() changeManagerUpdateTask?.cancel() From 1d4a1c6a57b46b5a245a477356129281419a4a27 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 11 Jun 2026 21:48:32 +0700 Subject: [PATCH 4/5] test(coordinator): cover Cmd+R refresh coalescing --- .../MainContentCoordinatorRefreshTests.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift index 009b303c3..000791570 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift @@ -248,4 +248,51 @@ struct MainContentCoordinatorRefreshTests { #expect(coordinator.queryGeneration == initialGeneration) #expect(coordinator.currentQueryTask == nil) } + + @Test("requestRefresh fires immediately and coalesces a rapid second call") + func requestRefreshCoalescesRapidCalls() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("expected tab to exist") + return + } + tabManager.tabs[idx].execution.lastExecutedAt = Date() + defer { + coordinator.refreshCoalesceTask?.cancel() + coordinator.currentQueryTask?.cancel() + } + let initialGeneration = coordinator.queryGeneration + + coordinator.requestRefresh(hasPendingTableOps: false, onDiscard: {}) + let generationAfterLeading = coordinator.queryGeneration + coordinator.requestRefresh(hasPendingTableOps: false, onDiscard: {}) + let generationAfterSecond = coordinator.queryGeneration + + #expect(generationAfterLeading == initialGeneration + 2) + #expect(generationAfterSecond == generationAfterLeading) + #expect(coordinator.refreshPendingTrailing == true) + #expect(coordinator.refreshCoalesceTask != nil) + } + + @Test("A single requestRefresh fires once and schedules no trailing refresh") + func singleRequestRefreshHasNoTrailing() async { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("expected tab to exist") + return + } + tabManager.tabs[idx].execution.lastExecutedAt = Date() + defer { coordinator.currentQueryTask?.cancel() } + + coordinator.requestRefresh(hasPendingTableOps: false, onDiscard: {}) + let generationAfterLeading = coordinator.queryGeneration + + try? await Task.sleep(for: .milliseconds(400)) + + #expect(coordinator.queryGeneration == generationAfterLeading) + #expect(coordinator.refreshPendingTrailing == false) + #expect(coordinator.refreshCoalesceTask == nil) + } } From 60a7e7d120fa51a5629bba23017a359b21cbb828 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 11 Jun 2026 22:33:47 +0700 Subject: [PATCH 5/5] fix(coordinator): return Void from refreshTables single-flight task --- TablePro/Views/Main/MainContentCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 9aa34e5b0..d18e00e7b 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -535,7 +535,8 @@ final class MainContentCoordinator { return } let task = Task { [weak self] in - await self?.reloadSchema() + guard let self else { return } + await self.reloadSchema() } schemaReloadTask = task await task.value