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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- iPad keyboard shortcuts (Cmd+N new connection, Cmd+Return execute query, Cmd+1/2 switch tabs) and trackpad hover effects on list rows
- Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)
- Handoff support for cross-device continuity between iOS and macOS
- State restoration across app lifecycle on iOS (selected connection, active tab, query text, database/schema selection)

## [0.30.1] - 2026-04-10

Expand Down
63 changes: 49 additions & 14 deletions TableProMobile/TableProMobile/Views/ConnectedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ struct ConnectedView: View {
@State private var session: ConnectionSession?
@State private var tables: [TableInfo] = []
@State private var isConnecting = true
@State private var isConnectInProgress = false
@State private var appError: AppError?
@State private var failureAlertMessage: String?
@State private var showFailureAlert = false
@State private var selectedTab = ConnectedTab.tables
@AppStorage("lastSelectedTab") private var selectedTabRaw: String = ConnectedTab.tables.rawValue
@State private var queryHistory: [QueryHistoryItem] = []
@State private var historyStorage = QueryHistoryStorage()
@State private var databases: [String] = []
Expand All @@ -40,6 +41,18 @@ struct ConnectedView: View {
case query = "Query"
}

private var selectedTab: ConnectedTab {
get { ConnectedTab(rawValue: selectedTabRaw) ?? .tables }
set { selectedTabRaw = newValue.rawValue }
}

private var selectedTabBinding: Binding<ConnectedTab> {
Binding(
get: { ConnectedTab(rawValue: selectedTabRaw) ?? .tables },
set: { selectedTabRaw = $0.rawValue }
)
}

private var displayName: String {
connection.name.isEmpty ? connection.host : connection.name
}
Expand Down Expand Up @@ -119,7 +132,7 @@ struct ConnectedView: View {
.navigationTitle(supportsDatabaseSwitching && databases.count > 1 ? "" : displayName)
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .top) {
Picker("Tab", selection: $selectedTab) {
Picker("Tab", selection: selectedTabBinding) {
Text("Tables").tag(ConnectedTab.tables)
Text("Query").tag(ConnectedTab.query)
}
Expand All @@ -128,10 +141,10 @@ struct ConnectedView: View {
.padding(.vertical, 8)
}
.background {
Button("") { selectedTab = .tables }
Button("") { selectedTabRaw = ConnectedTab.tables.rawValue }
.keyboardShortcut("1", modifiers: .command)
.hidden()
Button("") { selectedTab = .query }
Button("") { selectedTabRaw = ConnectedTab.query.rawValue }
.keyboardShortcut("2", modifiers: .command)
.hidden()
}
Expand Down Expand Up @@ -203,12 +216,22 @@ struct ConnectedView: View {
}
}
.onAppear {
let key = connection.id.uuidString
activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? ""
activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public"

let hasDriver = appState.connectionManager.session(for: connection.id)?.driver != nil
if !hasDriver, !isConnecting {
appError = nil
if !hasDriver, !isConnecting, appError == nil {
Task { await connect() }
}
}
.onChange(of: activeDatabase) { _, newValue in
guard !newValue.isEmpty else { return }
UserDefaults.standard.set(newValue, forKey: "lastDB.\(connection.id.uuidString)")
}
.onChange(of: activeSchema) { _, newValue in
UserDefaults.standard.set(newValue, forKey: "lastSchema.\(connection.id.uuidString)")
}
.onChange(of: scenePhase) { _, phase in
if phase == .active, session != nil {
Task { await reconnectIfNeeded() }
Expand Down Expand Up @@ -241,11 +264,15 @@ struct ConnectedView: View {
}

private func connect() async {
guard !isConnectInProgress else { return }
guard session == nil else {
isConnecting = false
return
}

isConnectInProgress = true
defer { isConnectInProgress = false }

if let existing = appState.connectionManager.session(for: connection.id) {
self.session = existing
do {
Expand All @@ -271,18 +298,13 @@ struct ConnectedView: View {

do {
let session = try await appState.connectionManager.connect(connection)
guard !Task.isCancelled else {
await appState.connectionManager.disconnect(connection.id)
return
}
self.session = session
self.tables = try await session.driver.fetchTables(schema: nil)
isConnecting = false
hapticSuccess.toggle()
await loadDatabases()
await loadSchemas()
} catch {
guard !Task.isCancelled else { return }
let context = ErrorContext(
operation: "connect",
databaseType: connection.type,
Expand Down Expand Up @@ -324,8 +346,14 @@ struct ConnectedView: View {
guard let session, supportsDatabaseSwitching else { return }
do {
databases = try await session.driver.fetchDatabases()
// Use session's active database (may differ from connection.database after a switch)
if let stored = appState.connectionManager.session(for: connection.id) {
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
Expand All @@ -339,7 +367,14 @@ struct ConnectedView: View {
guard let session, supportsSchemas else { return }
do {
schemas = try await session.driver.fetchSchemas()
activeSchema = session.driver.currentSchema ?? "public"
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 {
// Silently fail — don't show picker
}
Expand Down
39 changes: 27 additions & 12 deletions TableProMobile/TableProMobile/Views/ConnectionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ struct ConnectionListView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var showingAddConnection = false
@State private var editingConnection: DatabaseConnection?
@State private var selectedConnectionId: UUID?
@AppStorage("lastConnectionId") private var selectedConnectionIdString: String?
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
@State private var showingGroupManagement = false
@State private var showingTagManagement = false
@State private var filterTagId: UUID?
@State private var groupByGroup = false
@AppStorage("lastFilterTagId") private var filterTagIdString: String?
@AppStorage("groupByGroup") private var groupByGroup = false
@State private var editMode: EditMode = .inactive
@State private var connectionToDelete: DatabaseConnection?

Expand All @@ -28,6 +28,21 @@ struct ConnectionListView: View {
)
}

private var selectedConnectionId: Binding<UUID?> {
Binding(
get: { selectedConnectionIdString.flatMap { UUID(uuidString: $0) } },
set: { selectedConnectionIdString = $0?.uuidString }
)
}

private var selectedConnectionUUID: UUID? {
selectedConnectionIdString.flatMap { UUID(uuidString: $0) }
}

private var filterTagId: UUID? {
filterTagIdString.flatMap { UUID(uuidString: $0) }
}

private var displayedConnections: [DatabaseConnection] {
var result = appState.connections
if let filterTagId {
Expand All @@ -44,8 +59,8 @@ struct ConnectionListView: View {
}

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

var body: some View {
Expand Down Expand Up @@ -90,7 +105,7 @@ struct ConnectionListView: View {
.onChange(of: appState.pendingConnectionId) { _, newId in
navigateToPendingConnection(newId)
}
.onChange(of: filterTagId) {
.onChange(of: filterTagIdString) {
editMode = .inactive
}
.onChange(of: groupByGroup) {
Expand Down Expand Up @@ -135,7 +150,7 @@ struct ConnectionListView: View {

@ViewBuilder
private var connectionList: some View {
let list = List(selection: $selectedConnectionId) {
let list = List(selection: selectedConnectionId) {
if groupByGroup {
groupedContent
} else {
Expand Down Expand Up @@ -201,8 +216,8 @@ struct ConnectionListView: View {
) {
Button(String(localized: "Delete"), role: .destructive) {
if let connection = connectionToDelete {
if selectedConnectionId == connection.id {
selectedConnectionId = nil
if selectedConnectionUUID == connection.id {
selectedConnectionIdString = nil
}
appState.removeConnection(connection)
}
Expand All @@ -222,7 +237,7 @@ struct ConnectionListView: View {
if !appState.tags.isEmpty {
Section("Filter by Tag") {
Button {
filterTagId = nil
filterTagIdString = nil
} label: {
HStack {
Text("All")
Expand All @@ -233,7 +248,7 @@ struct ConnectionListView: View {
}
ForEach(appState.tags) { tag in
Button {
filterTagId = tag.id
filterTagIdString = tag.id.uuidString
} label: {
HStack {
Image(systemName: "circle.fill")
Expand Down Expand Up @@ -339,7 +354,7 @@ struct ConnectionListView: View {
private func navigateToPendingConnection(_ id: UUID?) {
guard let id,
appState.connections.contains(where: { $0.id == id }) else { return }
selectedConnectionId = id
selectedConnectionIdString = id.uuidString
appState.pendingConnectionId = nil
}

Expand Down
9 changes: 8 additions & 1 deletion TableProMobile/TableProMobile/Views/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ struct QueryEditorView: View {
}
.toolbar { toolbarContent }
.onAppear {
if !initialQuery.isEmpty { query = initialQuery }
if !initialQuery.isEmpty {
query = initialQuery
} else if query.isEmpty {
query = UserDefaults.standard.string(forKey: "lastQuery.\(connectionId.uuidString)") ?? ""
}
}
.onChange(of: query) { _, newValue in
UserDefaults.standard.set(newValue, forKey: "lastQuery.\(connectionId.uuidString)")
}
.alert("Write Query Blocked", isPresented: $showWriteBlockedAlert) {
Button("OK", role: .cancel) {}
Expand Down
Loading