Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ 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.
- 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

- 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
Expand Down
1 change: 1 addition & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Core/Coordinators/PaginationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Database/DatabaseManager+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ extension DatabaseManager {
}

await MainActor.run {
AppCommands.shared.refreshData.send(nil)
AppCommands.shared.refreshData.send(connectionId)
}
} catch {
if useTransaction {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Events/AppCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ final class AppCommands {

// MARK: - Refresh

let refreshData = PassthroughSubject<UUID?, Never>()
let refreshData = PassthroughSubject<UUID, Never>()

// MARK: - File / Connection Import-Export

Expand Down
36 changes: 36 additions & 0 deletions TablePro/Core/KeyboardHandling/KeyRepeatFilter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ extension MainWindowToolbar {
}

@objc func performRefresh(_ sender: Any?) {
AppCommands.shared.refreshData.send(nil)
coordinator?.commandActions?.refresh()
}

@objc func performSaveChanges(_ sender: Any?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
12 changes: 3 additions & 9 deletions TablePro/Core/Services/Query/SchemaProviderRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
4 changes: 2 additions & 2 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
85 changes: 57 additions & 28 deletions TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,41 +11,70 @@ 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
) {
// 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

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()
}
func reloadActiveTableData(
hasPendingTableOps: Bool,
onDiscard: @escaping () -> Void
) {
guard let (tab, tabIndex) = tabManager.selectedTabAndIndex,
tab.tabType == .table,
tab.display.resultsViewMode != .structure else { return }

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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
48 changes: 25 additions & 23 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -893,36 +893,38 @@ final class MainContentCommandActions {

// MARK: Data Broadcasts

func refresh() {
guard let coordinator else { return }
coordinator.requestRefresh(
hasPendingTableOps: hasPendingTableOps,
onDiscard: { [weak self] in self?.clearPendingTableOps() }
)
Comment on lines +898 to +901

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate autocomplete cache on manual refresh

When Cmd+R or the toolbar calls this new direct refresh path, it no longer publishes AppCommands.refreshData; the only place that clears autocomplete column metadata is SchemaProviderRegistry.subscribeToRefreshSignal() via that publisher. In the scenario where a table's columns changed outside this window and the user presses Refresh to pick up current schema, the table/sidebar reload runs but SQL autocomplete keeps serving the stale cached columns for this connection, whereas the previous refreshData.send(nil) path cleared the cache.

Useful? React with 👍 / 👎.

}

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() {
Expand Down
22 changes: 22 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ final class MainContentCoordinator {
/// Eviction task scheduled in `handleWindowDidResignKey` (fires 5s later).
@ObservationIgnored var evictionTask: Task<Void, Never>?

@ObservationIgnored var refreshCoalesceTask: Task<Void, Never>?
@ObservationIgnored var refreshPendingTrailing = false
@ObservationIgnored private var schemaReloadTask: Task<Void, Never>?

/// 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.
Expand Down Expand Up @@ -526,6 +530,20 @@ final class MainContentCoordinator {
}

func refreshTables() async {
if let existing = schemaReloadTask {
await existing.value
return
}
let task = Task { [weak self] in
guard let self else { return }
await self.reloadSchema()
}
schemaReloadTask = task
await task.value
schemaReloadTask = nil
}

private func reloadSchema() async {
schemaColumns.removeAll()
let schemaService = services.schemaService
let connectionId = connectionId
Expand Down Expand Up @@ -652,6 +670,10 @@ final class MainContentCoordinator {
fileWatcher = nil
currentQueryTask?.cancel()
currentQueryTask = nil
refreshCoalesceTask?.cancel()
refreshCoalesceTask = nil
schemaReloadTask?.cancel()
schemaReloadTask = nil
for entry in tableLoadTasks.values { entry.task.cancel() }
tableLoadTasks.removeAll()
changeManagerUpdateTask?.cancel()
Expand Down
6 changes: 2 additions & 4 deletions TablePro/Views/Structure/ClickHousePartsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Structure/CreateTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Structure/StructureViewActionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ final class StructureViewActionHandler {
var redo: (() -> Void)?
var addRow: (() -> Void)?
var removeRow: (() -> Void)?
var refresh: (() -> Void)?
}
Loading
Loading