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

### Changed

- Reduce memory: eliminate dedicated ping driver (~30-50 MB per connection), use main driver for health checks
- Reduce memory: evict inactive native window-tab row data after 5s, re-fetch on focus
- Reduce memory: lazy-load plugin bundles on first use instead of at startup (~20-30 MB saved)
- Reduce memory: remove duplicate sourceQuery string from RowBuffer
- Reduce memory: InMemoryRowProvider references RowBuffer directly instead of copying rows (~3-10 MB per tab)
- Reduce memory: eliminate metadata driver entirely, multiplex all queries on main driver (~30-50 MB per connection)
- Reduce memory: lazy AIChatViewModel initialization (deferred until AI panel is first opened)
- Reduce memory: remove duplicate connections array from ContentView (use ConnectionStorage.shared directly)
- Reduce CPU: consolidate per-editor NSEvent monitors into shared EditorEventRouter singleton (O(n) → O(1) per event)
- Fix tab persistence: aggregate tabs from all windows at quit time instead of last-write-wins per-coordinator save
- Split DatabaseManager.sessionVersion into fine-grained connectionListVersion and connectionStatusVersion to reduce cascade re-renders
- Extract AppState property reads into local lets in view bodies for explicit granular observation tracking
- Reorganized project directory structure: Services, Utilities, Models split into domain-specific subdirectories
Expand Down
12 changes: 6 additions & 6 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,6 @@
5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5A868000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; };
5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; };
5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; };
5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; };
5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000C /* MarkdownUI */; };
5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; };
5A86A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86A000100000000 /* CSVExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5A86B000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
Expand All @@ -40,6 +34,12 @@
5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; };
5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; };
5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; };
5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; };
5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000C /* MarkdownUI */; };
5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down
20 changes: 0 additions & 20 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,12 @@ struct ContentView: View {
let payload: EditorTabPayload?

@State private var currentSession: ConnectionSession?
@State private var connections: [DatabaseConnection] = []
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showNewConnectionSheet = false
@State private var showEditConnectionSheet = false
@State private var connectionToEdit: DatabaseConnection?
@State private var connectionToDelete: DatabaseConnection?
@State private var showDeleteConfirmation = false
@State private var hasLoaded = false
@State private var rightPanelState: RightPanelState?
@State private var sessionState: SessionStateFactory.SessionState?
@State private var inspectorContext = InspectorContext.empty
Expand Down Expand Up @@ -68,9 +66,6 @@ struct ContentView: View {
} message: { connection in
Text("Are you sure you want to delete \"\(connection.name)\"?")
}
.onAppear {
loadConnections()
}
.onReceive(NotificationCenter.default.publisher(for: .newConnection)) { _ in
openWindow(id: "connection-form", value: nil as UUID?)
}
Expand Down Expand Up @@ -364,29 +359,14 @@ struct ContentView: View {

// MARK: - Persistence

private func loadConnections() {
guard !hasLoaded else { return }

let saved = storage.loadConnections()
if saved.isEmpty {
connections = DatabaseConnection.sampleConnections
storage.saveConnections(connections)
} else {
connections = saved
}
hasLoaded = true
}

private func deleteConnection(_ connection: DatabaseConnection) {
if DatabaseManager.shared.activeSessions[connection.id] != nil {
Task {
await DatabaseManager.shared.disconnectSession(connection.id)
}
}

connections.removeAll { $0.id == connection.id }
storage.deleteConnection(connection)
storage.saveConnections(connections)
}

private func showAllTablesMetadata() {
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ extension DatabaseDriver {
enum DatabaseDriverFactory {
static func createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver {
let pluginId = connection.type.pluginTypeId
if PluginManager.shared.driverPlugins[pluginId] == nil {
PluginManager.shared.loadPendingPlugins()
}
guard let plugin = PluginManager.shared.driverPlugins[pluginId] else {
throw DatabaseError.connectionFailed(
"\(pluginId) driver plugin not loaded. The plugin may be disabled or missing from the PlugIns directory."
Expand Down
139 changes: 2 additions & 137 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,6 @@ final class DatabaseManager {
/// Health monitors for active connections (MySQL/PostgreSQL only)
private var healthMonitors: [UUID: ConnectionHealthMonitor] = [:]

/// Dedicated lightweight drivers used exclusively for health-check pings.
/// Separate from the main driver so pings never queue behind long-running user queries.
private var pingDrivers: [UUID: DatabaseDriver] = [:]

private var metadataCreationTasks: [UUID: Task<Void, Never>] = [:]

/// Current session (computed from currentSessionId)
var currentSession: ConnectionSession? {
guard let sessionId = currentSessionId else { return nil }
Expand All @@ -57,22 +51,11 @@ final class DatabaseManager {
currentSession?.driver
}

/// Dedicated driver for metadata queries (columns, FKs, count).
/// Runs on a separate serial queue so metadata fetches don't block the main query.
var activeMetadataDriver: DatabaseDriver? {
currentSession?.metadataDriver
}

/// Resolve the driver for a specific connection (session-scoped, no global state)
func driver(for connectionId: UUID) -> DatabaseDriver? {
activeSessions[connectionId]?.driver
}

/// Resolve the metadata driver for a specific connection
func metadataDriver(for connectionId: UUID) -> DatabaseDriver? {
activeSessions[connectionId]?.metadataDriver
}

/// Resolve a session by explicit connection ID
func session(for connectionId: UUID) -> ConnectionSession? {
activeSessions[connectionId]
Expand Down Expand Up @@ -211,33 +194,6 @@ final class DatabaseManager {
await startHealthMonitor(for: connection.id)
}

// Create a dedicated metadata connection in the background so Phase 2
// metadata queries (columns, FKs, count) run in parallel with main queries.
let metaConnection = effectiveConnection
let metaConnectionId = connection.id
let metaTimeout = AppSettingsManager.shared.general.queryTimeoutSeconds
metadataCreationTasks[metaConnectionId] = Task { [weak self] in
guard let self else { return }
defer { self.metadataCreationTasks.removeValue(forKey: metaConnectionId) }
do {
let metaDriver = try DatabaseDriverFactory.createDriver(for: metaConnection)
try await metaDriver.connect()
if metaTimeout > 0 {
try? await metaDriver.applyQueryTimeout(metaTimeout)
}
await self.executeStartupCommands(
connection.startupCommands, on: metaDriver, connectionName: connection.name
)
if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema,
let schemaMetaDriver = metaDriver as? SchemaSwitchable {
try? await schemaMetaDriver.switchSchema(to: savedSchema)
}
activeSessions[metaConnectionId]?.metadataDriver = metaDriver
} catch {
// Non-fatal: Phase 2 falls back to main driver if metadata driver unavailable
Self.logger.warning("Metadata connection failed: \(error.localizedDescription)")
}
}
} catch {
// Close tunnel if connection failed
if connection.sshConfig.enabled {
Expand Down Expand Up @@ -282,14 +238,9 @@ final class DatabaseManager {
try? await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id)
}

// Cancel any in-flight metadata driver creation
metadataCreationTasks[sessionId]?.cancel()
metadataCreationTasks.removeValue(forKey: sessionId)

// Stop health monitoring
await stopHealthMonitor(for: sessionId)

session.metadataDriver?.disconnect()
session.driver?.disconnect()
activeSessions.removeValue(forKey: sessionId)

Expand Down Expand Up @@ -318,9 +269,6 @@ final class DatabaseManager {
await stopHealthMonitor(for: sessionId)
}

for task in metadataCreationTasks.values { task.cancel() }
metadataCreationTasks.removeAll()

let sessionIds = Array(activeSessions.keys)
for sessionId in sessionIds {
await disconnectSession(sessionId)
Expand Down Expand Up @@ -475,43 +423,15 @@ final class DatabaseManager {
// Stop any existing monitor
await stopHealthMonitor(for: connectionId)

// Create a dedicated lightweight driver for pings so they never
// queue behind long-running user queries on the main driver.
if let session = activeSessions[connectionId] {
let connectionForPing = session.effectiveConnection ?? session.connection
let dedicatedPingDriver: DatabaseDriver
do {
dedicatedPingDriver = try DatabaseDriverFactory.createDriver(for: connectionForPing)
} catch {
Self.logger.warning("Failed to create ping driver for \(connectionId): \(error.localizedDescription)")
return
}
do {
try await dedicatedPingDriver.connect()
pingDrivers[connectionId] = dedicatedPingDriver
} catch {
Self.logger.warning(
"Failed to create dedicated ping driver, will fall back to main driver")
}
}

let monitor = ConnectionHealthMonitor(
connectionId: connectionId,
pingHandler: { [weak self] in
guard let self else { return false }
// Prefer the dedicated ping driver so pings are never blocked
// by long-running user queries on the main driver.
let pingDriver = await self.pingDrivers[connectionId]
let driver: DatabaseDriver
if let pingDriver {
driver = pingDriver
} else if let mainDriver = await self.activeSessions[connectionId]?.driver {
driver = mainDriver
} else {
guard let mainDriver = await self.activeSessions[connectionId]?.driver else {
return false
}
do {
_ = try await driver.execute(query: "SELECT 1")
_ = try await mainDriver.execute(query: "SELECT 1")
return true
} catch {
return false
Expand All @@ -527,15 +447,6 @@ final class DatabaseManager {
session.status = .connected
}

// Also reconnect the dedicated ping driver so future pings
// don't fail immediately after a successful main reconnect.
let connectionForPing = session.effectiveConnection ?? session.connection
let newPingDriver = try await MainActor.run {
try DatabaseDriverFactory.createDriver(for: connectionForPing)
}
try await newPingDriver.connect()
await self.replacePingDriver(newPingDriver, for: connectionId)

return true
} catch {
return false
Expand Down Expand Up @@ -610,22 +521,11 @@ final class DatabaseManager {
return driver
}

/// Replace the dedicated ping driver for a connection, disconnecting the old one.
private func replacePingDriver(_ newDriver: DatabaseDriver, for connectionId: UUID) {
pingDrivers[connectionId]?.disconnect()
pingDrivers[connectionId] = newDriver
}

/// Stop health monitoring for a connection
private func stopHealthMonitor(for connectionId: UUID) async {
if let monitor = healthMonitors.removeValue(forKey: connectionId) {
await monitor.stopMonitoring()
}

// Disconnect and remove the dedicated ping driver
if let pingDriver = pingDrivers.removeValue(forKey: connectionId) {
pingDriver.disconnect()
}
}

/// Reconnect the current session (called from toolbar Reconnect button)
Expand All @@ -650,7 +550,6 @@ final class DatabaseManager {

do {
// Disconnect existing drivers
session.metadataDriver?.disconnect()
session.driver?.disconnect()

// Recreate SSH tunnel if needed and build effective connection
Expand Down Expand Up @@ -688,40 +587,6 @@ final class DatabaseManager {
session.effectiveConnection = effectiveConnection
}

// Recreate metadata connection in background
let metaConnection = effectiveConnection
let metaConnectionId = sessionId
let metaTimeout = AppSettingsManager.shared.general.queryTimeoutSeconds
let startupCmds = session.connection.startupCommands
let connName = session.connection.name
metadataCreationTasks[metaConnectionId] = Task { [weak self] in
guard let self else { return }
defer { self.metadataCreationTasks.removeValue(forKey: metaConnectionId) }
do {
let metaDriver = try DatabaseDriverFactory.createDriver(for: metaConnection)
try await metaDriver.connect()
if metaTimeout > 0 {
try? await metaDriver.applyQueryTimeout(metaTimeout)
}
await self.executeStartupCommands(
startupCmds, on: metaDriver, connectionName: connName
)
if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema,
let schemaMetaDriver = metaDriver as? SchemaSwitchable {
try? await schemaMetaDriver.switchSchema(to: savedSchema)
}
// Restore database on metadata driver too for MSSQL
if let savedDatabase = self.activeSessions[metaConnectionId]?.currentDatabase,
let adapter = metaDriver as? PluginDriverAdapter {
try? await adapter.switchDatabase(to: savedDatabase)
}
activeSessions[metaConnectionId]?.metadataDriver = metaDriver
} catch {
Self.logger.warning(
"Metadata reconnection failed: \(error.localizedDescription)")
}
}

// Restart health monitoring
if session.connection.type != .sqlite {
await startHealthMonitor(for: sessionId)
Expand Down
Loading