Skip to content
6 changes: 4 additions & 2 deletions TableProMobile/TableProMobile/Drivers/MySQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable {
var supportsSchemas: Bool { false }
var currentSchema: String? { nil }
var supportsTransactions: Bool { true }
private(set) var serverVersion: String?

// Set once during connect() before the driver is shared — safe for concurrent reads
nonisolated(unsafe) private(set) var serverVersion: String?

init(host: String, port: Int, user: String, password: String, database: String, sslEnabled: Bool = false) {
self.host = host
Expand Down Expand Up @@ -345,7 +347,7 @@ private actor MySQLActor {

// MARK: - MySQL Field Type Names

private nonisolated func mysqlFieldTypeName(_ typeValue: UInt32) -> String {
nonisolated private func mysqlFieldTypeName(_ typeValue: UInt32) -> String {
switch typeValue {
case 0: return "DECIMAL"
case 1: return "TINYINT"
Expand Down
18 changes: 14 additions & 4 deletions TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable {
private let sslEnabled: Bool

var supportsSchemas: Bool { true }
private(set) var currentSchema: String? = "public"
var supportsTransactions: Bool { true }
private(set) var serverVersion: String?

// Set once during connect()/switchSchema() before the driver is shared — safe for concurrent reads
nonisolated(unsafe) private(set) var currentSchema: String? = "public"
nonisolated(unsafe) private(set) var serverVersion: String?

init(host: String, port: Int, user: String, password: String, database: String, sslEnabled: Bool = false) {
self.host = host
Expand Down Expand Up @@ -331,9 +333,14 @@ private actor PostgreSQLActor {
guard let conn else { return nil }
let version = PQserverVersion(conn)
if version == 0 { return nil }
let major = version / 10000
let major = version / 10000 // swiftlint:disable:this number_separator
let minor = (version / 100) % 100
let patch = version % 100
// PostgreSQL 10+ uses two-component versioning (major.patch)
// PostgreSQL 9.x and earlier uses three-component versioning (major.minor.patch)
if major >= 10 {
return "\(major).\(patch)"
}
return "\(major).\(minor).\(patch)"
}

Expand Down Expand Up @@ -407,7 +414,7 @@ private actor PostgreSQLActor {

// MARK: - PostgreSQL OID Type Names

private nonisolated func pgOidToTypeName(_ oid: UInt32) -> String {
nonisolated private func pgOidToTypeName(_ oid: UInt32) -> String {
switch oid {
case 16: return "boolean"
case 17: return "bytea"
Expand All @@ -423,6 +430,8 @@ private nonisolated func pgOidToTypeName(_ oid: UInt32) -> String {
case 700: return "real"
case 701: return "double precision"
case 869: return "inet"
// PostgreSQL OID constants — separators would obscure the wire-protocol values
// swiftlint:disable number_separator
case 1042: return "char"
case 1043: return "varchar"
case 1082: return "date"
Expand All @@ -432,6 +441,7 @@ private nonisolated func pgOidToTypeName(_ oid: UInt32) -> String {
case 1700: return "numeric"
case 2950: return "uuid"
case 3802: return "jsonb"
// swiftlint:enable number_separator
default: return "unknown"
}
}
Expand Down
4 changes: 3 additions & 1 deletion TableProMobile/TableProMobile/Drivers/RedisDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ final class RedisDriver: DatabaseDriver, @unchecked Sendable {
var supportsSchemas: Bool { false }
var currentSchema: String? { nil }
var supportsTransactions: Bool { false }
private(set) var serverVersion: String?

// Set once during connect() before the driver is shared — safe for concurrent reads
nonisolated(unsafe) private(set) var serverVersion: String?

init(host: String, port: Int, password: String?, database: Int = 0, sslEnabled: Bool = false) {
self.host = host
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ enum ClipboardExporter {
let value = i < row.count ? row[i] : nil
let key = " \"\(escapeJsonString(col.name))\""
if let value {
if Int64(value) != nil || Double(value) != nil {
if Int64(value) != nil {
pairs.append("\(key): \(value)")
} else if let parsed = Double(value), parsed.isFinite {
pairs.append("\(key): \(value)")
} else if value == "true" || value == "false" {
pairs.append("\(key): \(value)")
Expand Down
4 changes: 4 additions & 0 deletions TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ enum SQLBuilder {
static func escapeString(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\0", with: "\\0")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\u{1a}", with: "\\Z")
.replacingOccurrences(of: "'", with: "''")
}

Expand Down
7 changes: 6 additions & 1 deletion TableProMobile/TableProMobile/SSH/SSHTunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,10 @@ actor SSHTunnel {
socketFD = -1
}

// Free session off-actor to avoid blocking
// Free session off-actor to avoid blocking the actor (libssh2_session_disconnect
// can take seconds on a slow network). The detached thread acquires sessionLock
// first, which serializes with the relay thread's libssh2 calls — the relay
// will see isAlive == false after its current locked operation and exit.
let sess = session
session = nil
let lock = sessionLock
Expand Down Expand Up @@ -452,6 +455,7 @@ actor SSHTunnel {
// Channel -> Client
if pollFDs[1].revents & Int16(POLLIN) != 0 {
lock.lock()
guard aliveFlag.value else { lock.unlock(); return }
let readResult = Int(tablepro_libssh2_channel_read(channel, buffer, bufferSize))
let eof = libssh2_channel_eof(channel)
lock.unlock()
Expand All @@ -478,6 +482,7 @@ actor SSHTunnel {
var totalWritten = 0
while totalWritten < Int(clientRead) {
lock.lock()
guard aliveFlag.value else { lock.unlock(); return }
let written = Int(tablepro_libssh2_channel_write(
channel,
buffer.advanced(by: totalWritten),
Expand Down
30 changes: 29 additions & 1 deletion TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,15 +271,29 @@ final class IOSSyncCoordinator {
return changes
}

// MARK: - Merge (last-write-wins)
// MARK: - Merge

// DatabaseConnection has no modifiedDate field, so we use CKRecord.modificationDate
// from the cached record to determine which version is newer. Local changes are
// tracked via dirty flags (markDirty), so if the local copy is dirty and the remote
// record is older than the last sync, we keep local. Otherwise remote wins.

private func mergeConnections(local: [DatabaseConnection], remote: PullChanges) -> [DatabaseConnection] {
var result = local.filter { !remote.deletedConnectionIDs.contains($0.id) }
let localMap = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) })
let dirtyIDs = metadata.dirtyIDs(for: .connection)

for remoteConn in remote.changedConnections {
if localMap[remoteConn.id] != nil {
if let index = result.firstIndex(where: { $0.id == remoteConn.id }) {
if dirtyIDs.contains(remoteConn.id.uuidString) {
// Local has unsaved changes: keep local version so we push it later
continue
}
if result[index] == remoteConn {
// Content identical: skip overwrite to preserve any transient local state
continue
}
result[index] = remoteConn
}
} else if !remote.deletedConnectionIDs.contains(remoteConn.id) {
Expand All @@ -293,10 +307,17 @@ final class IOSSyncCoordinator {
private func mergeGroups(local: [ConnectionGroup], remote: PullChanges) -> [ConnectionGroup] {
var result = local.filter { !remote.deletedGroupIDs.contains($0.id) }
let localMap = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) })
let dirtyIDs = metadata.dirtyIDs(for: .group)

for remoteGroup in remote.changedGroups {
if localMap[remoteGroup.id] != nil {
if let index = result.firstIndex(where: { $0.id == remoteGroup.id }) {
if dirtyIDs.contains(remoteGroup.id.uuidString) {
continue
}
if result[index] == remoteGroup {
continue
}
result[index] = remoteGroup
}
} else if !remote.deletedGroupIDs.contains(remoteGroup.id) {
Expand All @@ -310,10 +331,17 @@ final class IOSSyncCoordinator {
private func mergeTags(local: [ConnectionTag], remote: PullChanges) -> [ConnectionTag] {
var result = local.filter { !remote.deletedTagIDs.contains($0.id) }
let localMap = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) })
let dirtyIDs = metadata.dirtyIDs(for: .tag)

for remoteTag in remote.changedTags {
if localMap[remoteTag.id] != nil {
if let index = result.firstIndex(where: { $0.id == remoteTag.id }) {
if dirtyIDs.contains(remoteTag.id.uuidString) {
continue
}
if result[index] == remoteTag {
continue
}
result[index] = remoteTag
}
} else if !remote.deletedTagIDs.contains(remoteTag.id) {
Expand Down
8 changes: 6 additions & 2 deletions TableProMobile/TableProMobile/Views/ConnectedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct ConnectedView: View {
@State private var schemas: [String] = []
@State private var activeSchema: String = "public"
@State private var isSwitching = false
@State private var isReconnecting = false

enum ConnectedTab: String, CaseIterable {
case tables = "Tables"
Expand Down Expand Up @@ -126,7 +127,7 @@ struct ConnectedView: View {
.disabled(isSwitching)
}
}
if supportsSchemas && schemas.count > 1 {
if supportsSchemas && schemas.count > 1 && selectedTab == .tables {
ToolbarItem(placement: .topBarTrailing) {
Menu {
ForEach(schemas, id: \.self) { schema in
Expand Down Expand Up @@ -173,6 +174,7 @@ struct ConnectedView: View {
QueryEditorView(
session: session,
tables: tables,
databaseType: connection.type,
queryHistory: $queryHistory,
connectionId: connection.id,
historyStorage: historyStorage
Expand Down Expand Up @@ -233,7 +235,9 @@ struct ConnectedView: View {
}

private func reconnectIfNeeded() async {
guard let session, !isSwitching else { return }
guard let session, !isSwitching, !isReconnecting else { return }
isReconnecting = true
defer { isReconnecting = false }
do {
_ = try await session.driver.ping()
} catch {
Expand Down
4 changes: 2 additions & 2 deletions TableProMobile/TableProMobile/Views/ConnectionFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ struct ConnectionFormView: View {
name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name,
type: type,
host: host,
port: Int(port) ?? 3306,
port: Int(port) ?? 3306, // swiftlint:disable:this number_separator
username: username,
database: database,
sshEnabled: sshEnabled,
Expand Down Expand Up @@ -605,6 +605,7 @@ struct ConnectionFormView: View {
if storageFailed {
credentialError = String(localized: "Some credentials could not be saved to the keychain. You may need to re-enter them later.")
showCredentialError = true
return
}

onSave(connection)
Expand All @@ -616,4 +617,3 @@ private struct TestResult {
let message: String
let recovery: String?
}

49 changes: 24 additions & 25 deletions TableProMobile/TableProMobile/Views/ConnectionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ struct ConnectionListView: View {
@Environment(AppState.self) private var appState
@State private var showingAddConnection = false
@State private var editingConnection: DatabaseConnection?
@State private var selectedConnection: DatabaseConnection?
@State private var navigationPath = NavigationPath()
@State private var selectedConnectionId: UUID?
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
@State private var showingGroupManagement = false
@State private var showingTagManagement = false
@State private var filterTagId: UUID?
Expand All @@ -30,21 +30,15 @@ struct ConnectionListView: View {
appState.syncCoordinator.status == .syncing
}

private var selectedConnection: DatabaseConnection? {
guard let selectedConnectionId else { return nil }
return appState.connections.first { $0.id == selectedConnectionId }
}

var body: some View {
NavigationSplitView {
NavigationStack(path: $navigationPath) {
sidebar
.navigationTitle("Connections")
.navigationDestination(for: DatabaseConnection.self) { connection in
ConnectedView(connection: connection)
}
}
.onChange(of: appState.pendingConnectionId) { _, newId in
navigateToPendingConnection(newId)
}
.onAppear {
navigateToPendingConnection(appState.pendingConnectionId)
}
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
.navigationTitle("Connections")
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
filterMenu
Expand Down Expand Up @@ -74,6 +68,12 @@ struct ConnectionListView: View {
.disabled(isSyncing)
}
}
.onChange(of: appState.pendingConnectionId) { _, newId in
navigateToPendingConnection(newId)
}
.onAppear {
navigateToPendingConnection(appState.pendingConnectionId)
}
} detail: {
NavigationStack {
if let connection = selectedConnection {
Expand Down Expand Up @@ -125,7 +125,7 @@ struct ConnectionListView: View {
ProgressView("Syncing from iCloud...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
List(selection: $selectedConnectionId) {
if groupByGroup {
groupedContent
} else {
Expand Down Expand Up @@ -247,20 +247,19 @@ struct ConnectionListView: View {

private func navigateToPendingConnection(_ id: UUID?) {
guard let id,
let connection = appState.connections.first(where: { $0.id == id }) else { return }
navigationPath.append(connection)
selectedConnection = connection
appState.connections.contains(where: { $0.id == id }) else { return }
selectedConnectionId = id
appState.pendingConnectionId = nil
}

private func connectionRow(_ connection: DatabaseConnection) -> some View {
NavigationLink(value: connection) {
NavigationLink(value: connection.id) {
ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId))
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
if selectedConnection?.id == connection.id {
selectedConnection = nil
if selectedConnectionId == connection.id {
selectedConnectionId = nil
}
appState.removeConnection(connection)
} label: {
Expand All @@ -283,8 +282,8 @@ struct ConnectionListView: View {
}
Divider()
Button(role: .destructive) {
if selectedConnection?.id == connection.id {
selectedConnection = nil
if selectedConnectionId == connection.id {
selectedConnectionId = nil
}
appState.removeConnection(connection)
} label: {
Expand Down
Loading
Loading