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

## [Unreleased]

### Changed

- iOS: TabView navigation with ConnectionCoordinator extraction and coordinator caching

## [0.35.0] - 2026-04-25

### Added
Expand Down
8 changes: 4 additions & 4 deletions TableProMobile/TableProMobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1803,7 +1803,7 @@
INFOPLIST_FILE = TableProWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TableProWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand Down Expand Up @@ -1834,7 +1834,7 @@
INFOPLIST_FILE = TableProWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TableProWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand Down Expand Up @@ -1906,7 +1906,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
Expand Down Expand Up @@ -1964,7 +1964,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
Expand Down
1 change: 1 addition & 0 deletions TableProMobile/TableProMobile/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ final class AppState {
var groups: [ConnectionGroup] = []
var tags: [ConnectionTag] = []
var pendingConnectionId: UUID?
var pendingTableName: String?
let connectionManager: ConnectionManager
let syncCoordinator = IOSSyncCoordinator()
let sshProvider: IOSSSHProvider
Expand Down
323 changes: 323 additions & 0 deletions TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
//
// ConnectionCoordinator.swift
// TableProMobile
//

import os
import Foundation
import Observation
import SwiftUI
import TableProDatabase
import TableProModels

@MainActor @Observable
final class ConnectionCoordinator {
let connection: DatabaseConnection

private(set) var session: ConnectionSession?
private(set) var phase: ConnectionPhase = .connecting
private(set) var tables: [TableInfo] = []
private(set) var databases: [String] = []
private(set) var schemas: [String] = []
private(set) var activeDatabase: String = ""
private(set) var activeSchema: String = "public"

private(set) var isSwitching = false
private(set) var isReconnecting = false
var failureAlertMessage: String?
var showFailureAlert = false

var selectedTab: ConnectedTab = .tables {
didSet {
UserDefaults.standard.set(selectedTab.rawValue, forKey: "lastTab.\(connection.id.uuidString)")
}
}
var pendingQuery: String?
var tablesPath = NavigationPath()

private(set) var queryHistory: [QueryHistoryItem] = []
private let historyStorage = QueryHistoryStorage()

private let appState: AppState
private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionCoordinator")

enum ConnectionPhase: Sendable {
case connecting
case connected
case error(AppError)
}

var displayName: String {
connection.name.isEmpty ? connection.host : connection.name
}

var supportsDatabaseSwitching: Bool {
connection.type == .mysql || connection.type == .mariadb ||
connection.type == .postgresql || connection.type == .redshift
}

var supportsSchemas: Bool {
connection.type == .postgresql || connection.type == .redshift
}

init(connection: DatabaseConnection, appState: AppState) {
self.connection = connection
self.appState = appState
}

// MARK: - Persisted State

func restorePersistedState() {
let key = connection.id.uuidString
if let savedTab = UserDefaults.standard.string(forKey: "lastTab.\(key)"),
let tab = ConnectedTab(rawValue: savedTab) {
selectedTab = tab
}
activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? ""
activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public"
}

// MARK: - Connection Lifecycle

private var isConnecting = false

func connect() async {
guard !isConnecting, session == nil else {
if session != nil { phase = .connected }
return
}

isConnecting = true
defer { isConnecting = false }
phase = .connecting

if let existing = appState.connectionManager.session(for: connection.id) {
self.session = existing
do {
self.tables = try await existing.driver.fetchTables(schema: nil)
await loadDatabases()
await loadSchemas()
phase = .connected
} catch {
self.session = nil
await appState.connectionManager.disconnect(connection.id)
await connectFresh()
}
return
}

await connectFresh()
}

private func connectFresh() async {
await appState.sshProvider.setPendingConnectionId(connection.id)

do {
let newSession = try await appState.connectionManager.connect(connection)
self.session = newSession
self.tables = try await newSession.driver.fetchTables(schema: nil)
await loadDatabases()
await loadSchemas()
phase = .connected
navigateToPendingTable()
} catch {
let context = ErrorContext(
operation: "connect",
databaseType: connection.type,
host: connection.host,
sshEnabled: connection.sshEnabled
)
phase = .error(ErrorClassifier.classify(error, context: context))
}
}

func reconnectIfNeeded() async {
guard let session, !isSwitching, !isReconnecting else { return }
isReconnecting = true
defer { isReconnecting = false }
do {
_ = try await session.driver.ping()
} catch {
do {
await appState.sshProvider.setPendingConnectionId(connection.id)
let newSession = try await appState.connectionManager.connect(connection)
self.session = newSession
} catch {
let context = ErrorContext(
operation: "reconnect",
databaseType: connection.type,
host: connection.host,
sshEnabled: connection.sshEnabled
)
phase = .error(ErrorClassifier.classify(error, context: context))
self.session = nil
}
}
}

// MARK: - Database / Schema Switching

func switchDatabase(to name: String) async {
guard let session, name != activeDatabase, !isSwitching else { return }
isSwitching = true
defer { isSwitching = false }

if connection.type == .postgresql || connection.type == .redshift {
await reconnectWithDatabase(name)
} else {
do {
try await appState.connectionManager.switchDatabase(connection.id, to: name)
if let freshSession = appState.connectionManager.session(for: connection.id) {
self.session = freshSession
}
activeDatabase = name
UserDefaults.standard.set(name, forKey: "lastDB.\(connection.id.uuidString)")
if let current = self.session {
self.tables = try await current.driver.fetchTables(schema: nil)
}
} catch {
failureAlertMessage = String(localized: "Failed to switch database")
showFailureAlert = true
}
}
}

private func reconnectWithDatabase(_ database: String) async {
await appState.connectionManager.disconnect(connection.id)
self.session = nil

var newConnection = connection
newConnection.database = database

await appState.sshProvider.setPendingConnectionId(connection.id)

do {
let newSession = try await appState.connectionManager.connect(newConnection)
self.session = newSession
self.tables = try await newSession.driver.fetchTables(schema: nil)
activeDatabase = database
UserDefaults.standard.set(database, forKey: "lastDB.\(connection.id.uuidString)")
await loadSchemas()
} catch {
Self.logger.error("Failed to switch to database \(database, privacy: .public): \(error.localizedDescription, privacy: .public)")
await appState.sshProvider.setPendingConnectionId(connection.id)
do {
let fallbackSession = try await appState.connectionManager.connect(connection)
self.session = fallbackSession
self.tables = try await fallbackSession.driver.fetchTables(schema: nil)
failureAlertMessage = String(localized: "Failed to switch database")
showFailureAlert = true
} catch {
let context = ErrorContext(
operation: "switchDatabase",
databaseType: connection.type,
host: connection.host,
sshEnabled: connection.sshEnabled
)
phase = .error(ErrorClassifier.classify(error, context: context))
self.session = nil
}
}
}

func switchSchema(to name: String) async {
guard let session, name != activeSchema, !isSwitching else { return }
isSwitching = true
defer { isSwitching = false }

do {
try await session.driver.switchSchema(to: name)
activeSchema = name
UserDefaults.standard.set(name, forKey: "lastSchema.\(connection.id.uuidString)")
self.tables = try await session.driver.fetchTables(schema: name)
} catch {
failureAlertMessage = String(localized: "Failed to switch schema")
showFailureAlert = true
}
}

// MARK: - Tables

func refreshTables() async {
guard let session else { return }
do {
let schema = supportsSchemas ? activeSchema : nil
self.tables = try await session.driver.fetchTables(schema: schema)
} catch {
Self.logger.warning("Failed to refresh tables: \(error.localizedDescription, privacy: .public)")
failureAlertMessage = String(localized: "Failed to refresh tables")
showFailureAlert = true
}
}

// MARK: - Query History

func loadHistory() {
queryHistory = historyStorage.load(for: connection.id)
}

func addHistoryItem(_ item: QueryHistoryItem) {
historyStorage.save(item)
queryHistory.append(item)
}

func deleteHistoryItem(_ id: UUID) {
historyStorage.delete(id)
queryHistory.removeAll { $0.id == id }
}

func clearHistory() {
historyStorage.clearAll(for: connection.id)
queryHistory = []
}

func navigateToPendingTable() {
guard let tableName = appState.pendingTableName,
let table = tables.first(where: { $0.name == tableName }) else { return }
appState.pendingTableName = nil
selectedTab = .tables
Task { @MainActor in
tablesPath.append(table)
}
}

// MARK: - Private Helpers

private func loadDatabases() async {
guard let session, supportsDatabaseSwitching else { return }
do {
databases = try await session.driver.fetchDatabases()
if !activeDatabase.isEmpty, databases.contains(activeDatabase) {
let sessionDB = appState.connectionManager.session(for: connection.id)?.activeDatabase ?? connection.database
if activeDatabase != sessionDB {
let target = activeDatabase
activeDatabase = sessionDB
await switchDatabase(to: target)
}
} else if let stored = appState.connectionManager.session(for: connection.id) {
activeDatabase = stored.activeDatabase
} else {
activeDatabase = connection.database
}
} catch {
Self.logger.warning("Failed to load databases: \(error.localizedDescription, privacy: .public)")
}
}

private func loadSchemas() async {
guard let session, supportsSchemas else { return }
do {
schemas = try await session.driver.fetchSchemas()
let currentSchema = session.driver.currentSchema ?? "public"
if schemas.contains(activeSchema), activeSchema != currentSchema {
let target = activeSchema
activeSchema = currentSchema
await switchSchema(to: target)
} else if !schemas.contains(activeSchema) {
activeSchema = currentSchema
}
} catch {
Self.logger.warning("Failed to load schemas: \(error.localizedDescription, privacy: .public)")
}
}
}
11 changes: 11 additions & 0 deletions TableProMobile/TableProMobile/Models/ConnectedTab.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// ConnectedTab.swift
// TableProMobile
//

enum ConnectedTab: String, CaseIterable {
case tables
case query
case history
case settings
}
1 change: 1 addition & 0 deletions TableProMobile/TableProMobile/TableProMobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ struct TableProMobileApp: App {
guard let connectionId = activity.userInfo?["connectionId"] as? String,
let uuid = UUID(uuidString: connectionId) else { return }
appState.pendingConnectionId = uuid
appState.pendingTableName = activity.userInfo?["tableName"] as? String
}
}
.onChange(of: scenePhase) { _, phase in
Expand Down
Loading
Loading