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
16 changes: 9 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Full-text search across all columns in iOS data browser
- 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)
- Server Dashboard: active sessions, metrics, slow queries (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)
- Handoff support between iOS and macOS
- iOS: full-text search in data browser, state restoration, iPad keyboard shortcuts

### Changed

- Sidebar table loading refactored: single source of truth, explicit loading states, no race conditions on database switch

### Fixed

- Create Database dialog showing MySQL charset/collation options for all database types; now shows database-specific options (encoding/LC_COLLATE for PostgreSQL, hidden for Redis/etcd)
- SSH Tunnel not working with `~/.ssh/config` profiles (#672): added `Include` directive support, SSH token expansion (`%d`, `%h`, `%u`, `%r`), multi-word `Host` filtering, and detailed handshake error messages
- Create Database dialog now shows correct options per database type (encoding/LC_COLLATE for PostgreSQL, hidden for Redis/etcd)
- SSH tunnel with `~/.ssh/config` profiles (#672): `Include` directives, token expansion, multi-word `Host` filtering

## [0.30.1] - 2026-04-10

Expand Down
1 change: 0 additions & 1 deletion TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,6 @@ struct ContentView: View {
tableOperationOptions: sessionTableOperationOptionsBinding,
databaseType: currentSession.connection.type,
connectionId: currentSession.connection.id,
schemaProvider: SchemaProviderRegistry.shared.provider(for: currentSession.connection.id),
coordinator: sessionState.coordinator
)
}
Expand Down
24 changes: 7 additions & 17 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,27 +107,17 @@ actor SQLSchemaProvider {
isLoading
}

/// Invalidate cache and reload
func invalidateCache() {
tables.removeAll()
columnCache.removeAll()
columnAccessOrder.removeAll()
cachedDriver = nil
}

func invalidateTables() {
tables.removeAll()
}

func updateTables(_ newTables: [TableInfo]) {
tables = newTables
}

func fetchFreshTables() async throws -> [TableInfo]? {
guard let driver = cachedDriver else { return nil }
let fresh = try await driver.fetchTables()
tables = fresh
return fresh
func resetForDatabase(_ database: String?, tables newTables: [TableInfo], driver: DatabaseDriver) {
self.tables = newTables
self.columnCache.removeAll()
self.columnAccessOrder.removeAll()
self.cachedDriver = driver
self.isLoading = false
self.lastLoadError = nil
}

/// Find table name from alias
Expand Down
146 changes: 2 additions & 144 deletions TablePro/ViewModels/SidebarViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,18 @@
// TablePro
//
// ViewModel for SidebarView.
// Handles table loading, search filtering, and batch operations.
// Handles search filtering and batch operations.
//

import Observation
import os
import SwiftUI

// MARK: - TableFetcher Protocol

/// Abstraction over table fetching for testability
protocol TableFetcher: Sendable {
func fetchTables(force: Bool) async throws -> [TableInfo]
}

private let sidebarLogger = Logger(subsystem: "com.TablePro", category: "SidebarViewModel")

/// Production implementation that uses DatabaseManager, with optional schema provider cache
struct LiveTableFetcher: TableFetcher {
let connectionId: UUID
let schemaProvider: SQLSchemaProvider?

init(connectionId: UUID, schemaProvider: SQLSchemaProvider? = nil) {
self.connectionId = connectionId
self.schemaProvider = schemaProvider
}

func fetchTables(force: Bool) async throws -> [TableInfo] {
if let provider = schemaProvider {
if force {
if let fresh = try await provider.fetchFreshTables() { return fresh }
} else {
let cached = await provider.getTables()
if !cached.isEmpty { return cached }
}
}
guard let driver = await DatabaseManager.shared.driver(for: connectionId) else {
sidebarLogger.warning("Driver is nil for connection \(connectionId)")
return []
}
let fetched = try await driver.fetchTables()
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
sidebarLogger.debug("Fetched \(fetched.count) tables")
if let provider = schemaProvider {
await provider.updateTables(fetched)
}
return fetched
}
}

// MARK: - SidebarViewModel

@MainActor @Observable
final class SidebarViewModel {
// MARK: - Published State

var isLoading = false
var errorMessage: String?
var debouncedSearchText = ""
var isTablesExpanded: Bool = {
let key = "sidebar.isTablesExpanded"
Expand All @@ -84,11 +39,6 @@ final class SidebarViewModel {
var pendingOperationType: TableOperationType?
var pendingOperationTables: [String] = []

// MARK: - Internal State

/// Prevents selection callback during programmatic updates (e.g., refresh)
var isRestoringSelection = false

// MARK: - Binding Storage

private var tablesBinding: Binding<[TableInfo]>
Expand All @@ -101,8 +51,6 @@ final class SidebarViewModel {
// MARK: - Dependencies

private let connectionId: UUID
private let tableFetcher: TableFetcher
private var loadTask: Task<Void, Never>?

// MARK: - Convenience Accessors

Expand Down Expand Up @@ -140,9 +88,7 @@ final class SidebarViewModel {
pendingDeletes: Binding<Set<String>>,
tableOperationOptions: Binding<[String: TableOperationOptions]>,
databaseType: DatabaseType,
connectionId: UUID,
schemaProvider: SQLSchemaProvider? = nil,
tableFetcher: TableFetcher? = nil
connectionId: UUID
) {
self.tablesBinding = tables
self.selectedTablesBinding = selectedTables
Expand All @@ -151,94 +97,6 @@ final class SidebarViewModel {
self.tableOperationOptionsBinding = tableOperationOptions
self.databaseType = databaseType
self.connectionId = connectionId
self.tableFetcher = tableFetcher ?? LiveTableFetcher(connectionId: connectionId, schemaProvider: schemaProvider)
}

// MARK: - Lifecycle

func onAppear() {
guard tables.isEmpty else {
sidebarLogger.debug("onAppear: tables not empty (\(self.tables.count)), skipping")
return
}
if DatabaseManager.shared.driver(for: connectionId) != nil {
sidebarLogger.debug("onAppear: loading tables")
loadTables()
} else {
sidebarLogger.warning("onAppear: driver is nil for \(self.connectionId)")
}
}

// MARK: - Table Loading

func loadTables(force: Bool = false) {
loadTask?.cancel()
guard !isLoading else { return }
isLoading = true
errorMessage = nil
loadTask = Task {
await loadTablesAsync(force: force)
}
}

func forceLoadTables() {
loadTask?.cancel()
loadTask = nil
isLoading = false
loadTables(force: true)
}

private func loadTablesAsync(force: Bool = false) async {
let previousSelectedName: String? = tables.isEmpty ? nil : selectedTables.first?.name

do {
let fetchedTables = try await tableFetcher.fetchTables(force: force)
tables = fetchedTables

// Clean up stale entries for tables that no longer exist
let fetchedNames = Set(fetchedTables.map(\.name))

let staleSelections = selectedTables.filter { !fetchedNames.contains($0.name) }
if !staleSelections.isEmpty {
isRestoringSelection = true
selectedTables.subtract(staleSelections)
isRestoringSelection = false
}

let stalePendingDeletes = pendingDeletes.subtracting(fetchedNames)
let stalePendingTruncates = pendingTruncates.subtracting(fetchedNames)
if !stalePendingDeletes.isEmpty {
pendingDeletes.subtract(stalePendingDeletes)
for name in stalePendingDeletes {
tableOperationOptions.removeValue(forKey: name)
}
}
if !stalePendingTruncates.isEmpty {
pendingTruncates.subtract(stalePendingTruncates)
for name in stalePendingTruncates {
tableOperationOptions.removeValue(forKey: name)
}
}

// Only restore selection if it was cleared (prevent reopening tabs)
if let name = previousSelectedName {
let currentNames = Set(selectedTables.map { $0.name })
if !currentNames.contains(name) {
// Selection was cleared, restore it without triggering callback
isRestoringSelection = true
if let restored = fetchedTables.first(where: { $0.name == name }) {
selectedTables = [restored]
}
isRestoringSelection = false
}
}
isLoading = false
} catch is CancellationError {
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}

// MARK: - Batch Operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,6 @@ extension MainContentCoordinator {
tabManager.tabs[index].pendingChanges = TabPendingChanges()
}

reloadSidebar()
Task { await refreshTables() }
}
}
Loading
Loading