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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Holding Cmd+Return at safe-mode level `.silent` no longer stacks two confirmation sheets and runs the dangerous query twice. The `.silent` branch in `QueryExecutionCoordinator.dispatchStatements` and `dispatchParameterizedStatements` now sets the same `isShowingSafeModePrompt` re-entry flag synchronously that the `requiresConfirmation` branch already used; the flag is cleared in a `defer` inside the spawned `Task`.
- LSP `cancelRequest` no longer leaks a pending continuation when the underlying transport is mid-shutdown. The previous `try? writeMessage(data)` swallowed the failure, leaving the local handler stuck waiting for a response the LSP server would never produce. The new path logs the failure and resolves the pending entry with `CancellationError`, so AI inline-suggestion / Copilot LSP teardown no longer leaks completion handlers across the lifetime of the LSP process.
- Plugin auto-update no longer drops new rejection entries that arrive from concurrent operations (e.g., a manual install failure during the auto-update loop). `PluginManager.autoUpdateRejectedPlugins` previously snapshotted `rejectedPlugins` at entry, looped through awaits that could mutate it, then assigned the stale snapshot back at the end. The fix replaces only the entries it processed and preserves any concurrent additions.
- The schema provider for a connection no longer leaks when a SwiftUI body re-evaluation creates a throwaway `MainContentCoordinator` that is discarded before `markActivated` runs. `retain(for:)` moves from `init` to `markActivated` so it is paired with the matching `release` in `teardown` / `deinit`. Throwaway coordinators that never activated also no longer over-release a provider they never retained.
- Reconnecting then immediately disconnecting a session no longer writes the post-refresh table list into a tearing-down coordinator. `MainContentCommandActions.handleDatabaseDidConnect` captures `coordinator` weakly and rechecks `isTearingDown` before the initial work and again after the `await refreshTables()`, so the trailing `initRedisKeyTreeIfNeeded()` is skipped if the user disconnected mid-fetch.
- `SyncCoordinator.observeLocalChanges` no longer schedules a new debounce window before the previous (just-cancelled) sync task has unwound, so there are never two live sync tasks. `syncNow` also now guards against re-entrant invocation, returning early when a sync is already in progress instead of relying on the call-site `!isSyncing` check that could race with the actor hop.
- Built-in plugins now enforce the same `pluginKitVersion == currentPluginKitVersion` check that user-installed plugins did. Previously a built-in whose `Info.plist` `TableProPluginKitVersion` fell behind the host's `currentPluginKitVersion` would load anyway and crash on the first new protocol-witness method call. Built-ins ship together so this catches developer error before release.

- When saving the connections file fails (disk full, sandbox denied, encoding error), TablePro now aborts the dependent steps instead of continuing as if the save had succeeded. Previously a failed delete could remove the keychain password and queue a CloudKit tombstone for a record that was still on disk; on next sync the connection would be nuked from iCloud as well. Now: delete, add, update, duplicate, batch-delete, sync-pull, group-cleanup, and the plugin secure field migration all check the persistence result and skip the downstream side effects on failure. The connection form surfaces a localized error and keeps the form open instead of dismissing.
- iCloud sync no longer silently substitutes empty defaults when an SSH config, SSL config, jump-host list, or driver-specific field stored in a synced record fails to decode. A device on an older app build that pulled a record written by a newer build would previously end up with a connection missing its SSH/SSL config, then push that empty config back to iCloud and overwrite the authoritative copy. Decode failures now skip the record entirely and log which field failed; the cloud copy stays intact until the device is updated.
Expand Down
12 changes: 5 additions & 7 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,13 +375,11 @@ final class PluginManager {
}
}

if source == .userInstalled {
if pluginKitVersion < currentPluginKitVersion {
throw PluginError.pluginOutdated(
pluginVersion: pluginKitVersion,
requiredVersion: currentPluginKitVersion
)
}
if pluginKitVersion < currentPluginKitVersion {
throw PluginError.pluginOutdated(
pluginVersion: pluginKitVersion,
requiredVersion: currentPluginKitVersion
)
}
}

Expand Down
12 changes: 10 additions & 2 deletions TablePro/Core/Sync/SyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ final class SyncCoordinator {
Self.logger.info("syncNow: canSync() returned false, skipping")
return
}
guard !syncStatus.isSyncing else {
Self.logger.info("syncNow: another sync is already in progress, skipping")
return
}

syncStatus = .syncing

Expand Down Expand Up @@ -614,9 +618,13 @@ final class SyncCoordinator {
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
guard syncStatus.isEnabled, !syncStatus.isSyncing else { return }
syncTask?.cancel()
guard syncStatus.isEnabled else { return }
let previousTask = syncTask
previousTask?.cancel()
syncTask = Task {
// Wait for the cancelled previous task to unwind before scheduling
// the new debounce window, so we never have two sync tasks live.
_ = await previousTask?.value
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
await self.syncNow()
Expand Down
1 change: 0 additions & 1 deletion TablePro/Models/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
// Application settings models - pure data structures
//

import AppKit
import Foundation
import SwiftUI

Expand Down
17 changes: 10 additions & 7 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -859,16 +859,19 @@ final class MainContentCommandActions {
}

private func handleDatabaseDidConnect() {
Task {
if let driver = DatabaseManager.shared.driver(for: self.connection.id) {
coordinator?.toolbarState.databaseVersion = driver.serverVersion
Task { [weak coordinator] in
guard let coordinator, !coordinator.isTearingDown else { return }
if let driver = DatabaseManager.shared.driver(for: coordinator.connection.id) {
coordinator.toolbarState.databaseVersion = driver.serverVersion
}
if case .loading = SchemaService.shared.state(for: self.connection.id) {
coordinator?.initRedisKeyTreeIfNeeded()
if case .loading = SchemaService.shared.state(for: coordinator.connection.id) {
coordinator.initRedisKeyTreeIfNeeded()
return
}
await coordinator?.refreshTables()
coordinator?.initRedisKeyTreeIfNeeded()
await coordinator.refreshTables()
// Re-check after await: the user may have disconnected mid-fetch.
guard !coordinator.isTearingDown else { return }
coordinator.initRedisKeyTreeIfNeeded()
}
}

Expand Down
20 changes: 11 additions & 9 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,6 @@ final class MainContentCoordinator {
self.persistence = TabPersistenceCoordinator(connectionId: connection.id)

_ = services.schemaProviderRegistry.getOrCreate(for: connection.id)
services.schemaProviderRegistry.retain(for: connection.id)
ConnectionDataCache.shared(for: connection.id).ensureLoaded()
changeManager.undoManagerProvider = { [weak self] in self?.contentWindow?.undoManager }
changeManager.onUndoApplied = { [weak self] result in self?.handleUndoResult(result) }
Expand Down Expand Up @@ -427,7 +426,14 @@ final class MainContentCoordinator {

func markActivated() {
let start = Date()
_didActivate.withLock { $0 = true }
let wasAlreadyActive = _didActivate.withLock { current -> Bool in
let prior = current
current = true
return prior
}
if !wasAlreadyActive {
services.schemaProviderRegistry.retain(for: connection.id)
}
registerForPersistence()
setupPluginDriver()
startFileWatcherIfNeeded()
Expand Down Expand Up @@ -602,18 +608,14 @@ final class MainContentCoordinator {
let alreadyHandled = _didTeardown.withLock { $0 } || _teardownScheduled.withLock { $0 }

// Never-activated coordinators are throwaway instances created by SwiftUI
// during body re-evaluation — @State only keeps the first, rest are discarded
// during body re-evaluation — @State only keeps the first, rest are discarded.
// Retain is paired with `markActivated`, so a never-activated coordinator
// never retained the schema provider and must not release it here.
guard _didActivate.withLock({ $0 }) else {
let id = instanceId
Task { @MainActor in
Self.activeCoordinators.removeValue(forKey: id)
}
if !alreadyHandled {
Task { @MainActor in
services.schemaProviderRegistry.release(for: connectionId)
services.schemaProviderRegistry.purgeUnused()
}
}
return
}

Expand Down
Loading