Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Safe mode level changes in the toolbar persist as the connection default across reconnects.
- Toolbar customizations persist after closing and reopening a session window. (#1455)
- Pasting rows with commas in a cell keeps each value in its own column and preserves NULL vs the literal text "NULL".
- BigQuery: switching to another table loads its data immediately instead of leaving the grid empty.
Expand Down
16 changes: 14 additions & 2 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import TableProPluginKit
// MARK: - Session Management

extension DatabaseManager {
func connectToSession(_ connection: DatabaseConnection) async throws {
func connectToSession(_ requestedConnection: DatabaseConnection) async throws {
let connection = resolvedConnectionDefinition(for: requestedConnection)

if let existing = activeSessions[connection.id], existing.driver != nil {
switchToSession(connection.id)
return
Expand Down Expand Up @@ -178,6 +180,13 @@ extension DatabaseManager {
}
}

internal func resolvedConnectionDefinition(for connection: DatabaseConnection) -> DatabaseConnection {
guard let stored = connectionStorage.loadConnection(id: connection.id) else { return connection }
var resolved = connection
resolved.safeModeLevel = stored.safeModeLevel
return resolved
}

internal func finalizeConnectionFailure(for connectionId: UUID, cancelled: Bool) {
guard !cancelled else { return }
removeSessionEntry(for: connectionId)
Expand Down Expand Up @@ -391,9 +400,12 @@ extension DatabaseManager {
}

func setSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) {
guard var session = activeSessions[connectionId], session.safeModeLevel != level else { return }
guard var session = activeSessions[connectionId] else { return }
guard session.safeModeLevel != level || session.connection.safeModeLevel != level else { return }
session.safeModeLevel = level
session.connection.safeModeLevel = level
setSession(session, for: connectionId)
_ = connectionStorage.updateSafeModeLevel(level, for: connectionId)
}

internal func setSession(_ session: ConnectionSession, for connectionId: UUID) {
Expand Down
32 changes: 32 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ final class ConnectionStorage {
}
}

func loadConnection(id: UUID) -> DatabaseConnection? {
loadConnections().first { $0.id == id }
}

/// Save all connections. Returns `true` if persisted, `false` if encoding or
/// the atomic write failed. Callers that mutate dependent state (sync tracker,
/// keychain entries) MUST check the return value and abort on `false`.
Expand Down Expand Up @@ -196,6 +200,34 @@ final class ConnectionStorage {
return true
}

@discardableResult
func updateSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) -> Bool {
var connections = loadConnections()
guard let index = connections.firstIndex(where: { $0.id == connectionId }) else {
Self.logger.notice(
"Skipped updateSafeModeLevel: connection not found for \(connectionId, privacy: .public)"
)
return false
}

guard connections[index].safeModeLevel != level else { return true }

connections[index].safeModeLevel = level
guard saveConnections(connections) else {
Self.logger.error(
"Aborted updateSafeModeLevel: persistence failed for \(connectionId, privacy: .public)"
)
return false
}

let updatedConnection = connections[index]
if !updatedConnection.localOnly && !updatedConnection.isSample {
syncTracker.markDirty(.connection, id: updatedConnection.id.uuidString)
}

return true
}

/// Delete a connection
func deleteConnection(_ connection: DatabaseConnection) {
var connections = loadConnections()
Expand Down
34 changes: 30 additions & 4 deletions TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
//

import Foundation
@testable import TablePro
import TableProPluginKit
import Testing
@testable import TablePro

@Suite("ConnectionStorage Persistence")
@MainActor
Expand All @@ -26,8 +26,12 @@ struct ConnectionStoragePersistenceTests {
withIntermediateDirectories: true
)
let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)"
self.defaults = UserDefaults(suiteName: suiteName)!
let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")!
guard let defaults = UserDefaults(suiteName: suiteName),
let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")
else {
fatalError("Failed to create isolated test user defaults")
}
self.defaults = defaults
let metadata = SyncMetadataStorage(userDefaults: syncDefaults)
self.syncTracker = SyncChangeTracker(metadataStorage: metadata)
self.storage = ConnectionStorage(
Expand All @@ -49,12 +53,34 @@ struct ConnectionStoragePersistenceTests {
#expect(reloaded.contains { $0.id == connection.id })
}

@Test("updateSafeModeLevel writes the new level through to disk")
func updateSafeModeLevelWritesThrough() {
let connection = DatabaseConnection(
name: "Write Through",
host: "127.0.0.1",
port: 3_306,
type: .mysql,
safeModeLevel: .silent
)

storage.addConnection(connection)
storage.invalidateCache()
#expect(storage.loadConnections().first { $0.id == connection.id }?.safeModeLevel == .silent)

let updated = storage.updateSafeModeLevel(.readOnly, for: connection.id)
#expect(updated)

storage.invalidateCache()
let reloaded = storage.loadConnections().first { $0.id == connection.id }
#expect(reloaded?.safeModeLevel == .readOnly)
}

@Test("round-trip save and load preserves connections")
func roundTripSaveLoad() {
let connection = DatabaseConnection(
name: "Round Trip Test",
host: "127.0.0.1",
port: 5432,
port: 5_432,
type: .postgresql
)

Expand Down
Loading
Loading