From b837a32587edc830a8632367843e1cf4a8bc4245 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Feb 2026 15:41:17 +0700 Subject: [PATCH] Add connection health monitoring with auto-reconnect Periodic 30-second health checks (SELECT 1) for MySQL/MariaDB and PostgreSQL connections with automatic exponential backoff reconnection (3 retries at 2s/4s/8s intervals). Adds a toolbar Reconnect button for manual recovery when auto-reconnect fails. --- CHANGELOG.md | 2 + TRACKING.md | 4 +- .../Database/ConnectionHealthMonitor.swift | 229 ++++++++++++++++++ TablePro/Core/Database/DatabaseManager.swift | 193 ++++++++++++++- TablePro/Models/ConnectionSession.swift | 2 + TablePro/OpenTableApp.swift | 3 + .../Main/MainContentNotificationHandler.swift | 18 ++ .../Views/Toolbar/ToolbarController.swift | 10 + .../Views/Toolbar/ToolbarItemFactory.swift | 33 +++ .../Views/Toolbar/ToolbarItemIdentifier.swift | 9 + 10 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 TablePro/Core/Database/ConnectionHealthMonitor.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 64146a07f..2df1a8847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PostgreSQL user-defined enum type support via `pg_enum` catalog lookup - SQLite CHECK constraint pseudo-enum detection (e.g., `CHECK(col IN ('a','b','c'))`) - Language setting in General preferences (System, English, Vietnamese) with full Vietnamese localization (637 strings) +- Connection health monitoring with automatic reconnection for MySQL/MariaDB and PostgreSQL — pings every 30 seconds, retries 3 times with exponential backoff (2s/4s/8s) on failure +- Manual "Reconnect" toolbar button appears when connection is lost or in error state ### Changed diff --git a/TRACKING.md b/TRACKING.md index fc325ac68..5acd64cab 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -170,7 +170,7 @@ | User/Role Management | MEDIUM | Large | No sidebar section for Users/Roles | | SQLite Table Recreation for ALTER | MEDIUM | Medium | Throws `unsupportedOperation` for most ALTER TABLE | | Keyboard Shortcut Customization | MEDIUM | Medium | All shortcuts hardcoded | -| Connection Health Monitoring | MEDIUM | Medium | No ping/keepalive or auto-reconnect | +| ~~Connection Health Monitoring~~ | ~~MEDIUM~~ | ~~Medium~~ | **DONE** — 30s periodic ping (SELECT 1) for MySQL/PostgreSQL, 3-retry exponential backoff (2s/4s/8s), toolbar Reconnect button | ### Tier 3 — Nice-to-Have @@ -338,7 +338,7 @@ The following v0.2.0 features are documented on feature pages but missing from c - [ ] Schema compare/diff - [ ] ER diagram visualization - [ ] Keyboard shortcut customization -- [ ] Connection health monitoring + auto-reconnect +- [x] Connection health monitoring + auto-reconnect - [x] Localization infrastructure ### Immediate Actions (This Week) diff --git a/TablePro/Core/Database/ConnectionHealthMonitor.swift b/TablePro/Core/Database/ConnectionHealthMonitor.swift new file mode 100644 index 000000000..885af87b6 --- /dev/null +++ b/TablePro/Core/Database/ConnectionHealthMonitor.swift @@ -0,0 +1,229 @@ +// +// ConnectionHealthMonitor.swift +// TablePro +// +// Actor that monitors database connection health with periodic pings +// and automatic reconnection with exponential backoff. +// + +import Foundation +import os + +// MARK: - Health State + +extension ConnectionHealthMonitor { + /// Represents the current health state of a monitored connection. + enum HealthState: Sendable, Equatable { + case healthy + case checking + case reconnecting(attempt: Int) // 1-based attempt number + case failed + } +} + +// MARK: - Notification + +extension Notification.Name { + /// Posted when a connection's health state changes. + /// userInfo: ["connectionId": UUID, "state": ConnectionHealthMonitor.HealthState] + static let connectionHealthStateChanged = Notification.Name("connectionHealthStateChanged") +} + +// MARK: - ConnectionHealthMonitor + +/// Monitors a single database connection's health via periodic pings and +/// automatically attempts reconnection with exponential backoff on failure. +/// +/// Uses closure-based dependency injection so it does not directly reference +/// `DatabaseDriver` (which is not `Sendable`). The caller provides `pingHandler` +/// and `reconnectHandler` closures. +actor ConnectionHealthMonitor { + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionHealthMonitor") + + // MARK: - Configuration + + private static let pingInterval: TimeInterval = 30.0 + private static let maxRetries = 3 + private static let backoffDelays: [TimeInterval] = [2.0, 4.0, 8.0] + + // MARK: - Dependencies + + private let connectionId: UUID + private let pingHandler: @Sendable () async -> Bool + private let reconnectHandler: @Sendable () async -> Bool + private let onStateChanged: @Sendable (UUID, HealthState) async -> Void + + // MARK: - State + + private var state: HealthState = .healthy + private var monitoringTask: Task? + + // MARK: - Initialization + + /// Creates a new health monitor for a database connection. + /// + /// - Parameters: + /// - connectionId: The unique identifier of the connection to monitor. + /// - pingHandler: Closure that executes a lightweight query (e.g., `SELECT 1`) + /// and returns `true` if the connection is alive. + /// - reconnectHandler: Closure that attempts to re-establish the connection + /// and returns `true` on success. + /// - onStateChanged: Closure invoked whenever the health state transitions. + init( + connectionId: UUID, + pingHandler: @escaping @Sendable () async -> Bool, + reconnectHandler: @escaping @Sendable () async -> Bool, + onStateChanged: @escaping @Sendable (UUID, HealthState) async -> Void + ) { + self.connectionId = connectionId + self.pingHandler = pingHandler + self.reconnectHandler = reconnectHandler + self.onStateChanged = onStateChanged + } + + // MARK: - Public API + + /// The current health state of the monitored connection. + var currentState: HealthState { + state + } + + /// Starts periodic health monitoring. + /// + /// Creates a long-running task that pings the connection every 30 seconds. + /// If monitoring is already active, this method does nothing. + func startMonitoring() { + guard monitoringTask == nil else { + Self.logger.debug("Monitoring already active for connection \(self.connectionId)") + return + } + + Self.logger.info("Starting health monitoring for connection \(self.connectionId)") + + monitoringTask = Task { [weak self] in + guard let self else { return } + + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(Self.pingInterval)) + + guard !Task.isCancelled else { break } + + await self.performHealthCheck() + } + + Self.logger.debug("Monitoring loop exited for connection \(self.connectionId)") + } + } + + /// Stops periodic health monitoring and cancels any in-flight reconnect attempts. + func stopMonitoring() { + Self.logger.info("Stopping health monitoring for connection \(self.connectionId)") + monitoringTask?.cancel() + monitoringTask = nil + } + + /// Resets the monitor to `.healthy` after the user manually reconnects. + /// + /// Call this when an external reconnection succeeds so the monitor resumes + /// normal periodic pings instead of staying in `.failed` state. + func resetAfterManualReconnect() async { + Self.logger.info("Manual reconnect succeeded, resetting to healthy for connection \(self.connectionId)") + await transitionTo(.healthy) + } + + // MARK: - Health Check + + /// Performs a single health check cycle. + /// + /// Skips the check if the monitor is already in a non-healthy state + /// (e.g., mid-reconnect). On ping failure, triggers the reconnect sequence. + private func performHealthCheck() async { + guard state == .healthy else { + Self.logger.debug("Skipping health check — state is \(String(describing: self.state)) for connection \(self.connectionId)") + return + } + + await transitionTo(.checking) + + let isAlive = await pingHandler() + + if isAlive { + Self.logger.debug("Ping succeeded for connection \(self.connectionId)") + await transitionTo(.healthy) + } else { + Self.logger.warning("Ping failed for connection \(self.connectionId), starting reconnect sequence") + await attemptReconnect() + } + } + + // MARK: - Reconnection + + /// Attempts to reconnect with exponential backoff. + /// + /// Tries up to `maxRetries` times (3), waiting 2s, 4s, and 8s between attempts. + /// On success, transitions back to `.healthy`. After all retries are exhausted, + /// transitions to `.failed`. + private func attemptReconnect() async { + for attempt in 1...Self.maxRetries { + guard !Task.isCancelled else { + Self.logger.debug("Reconnect cancelled for connection \(self.connectionId)") + return + } + + let delay = Self.backoffDelays[attempt - 1] + + Self.logger.warning("Reconnect attempt \(attempt)/\(Self.maxRetries) for connection \(self.connectionId) — waiting \(delay)s") + await transitionTo(.reconnecting(attempt: attempt)) + + try? await Task.sleep(for: .seconds(delay)) + + guard !Task.isCancelled else { + Self.logger.debug("Reconnect cancelled during backoff for connection \(self.connectionId)") + return + } + + let success = await reconnectHandler() + + if success { + Self.logger.info("Reconnect succeeded on attempt \(attempt) for connection \(self.connectionId)") + await transitionTo(.healthy) + return + } + + Self.logger.warning("Reconnect attempt \(attempt) failed for connection \(self.connectionId)") + } + + // All retries exhausted + Self.logger.error("All \(Self.maxRetries) reconnect attempts failed for connection \(self.connectionId)") + await transitionTo(.failed) + } + + // MARK: - State Transitions + + /// Transitions to a new health state, logging the change and notifying observers. + private func transitionTo(_ newState: HealthState) async { + let oldState = state + state = newState + + if oldState != newState { + Self.logger.log( + level: logLevel(for: newState), + "Connection \(self.connectionId) health state: \(String(describing: oldState)) -> \(String(describing: newState))" + ) + + await onStateChanged(connectionId, newState) + } + } + + /// Returns the appropriate log level for a given health state. + private func logLevel(for state: HealthState) -> OSLogType { + switch state { + case .healthy, .checking: + return .debug + case .reconnecting: + return .default + case .failed: + return .error + } + } +} diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index c21b90d38..7a643f347 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -25,6 +25,9 @@ final class DatabaseManager: ObservableObject { /// Currently selected session ID (displayed in UI) @Published private(set) var currentSessionId: UUID? + /// Health monitors for active connections (MySQL/PostgreSQL only) + private var healthMonitors: [UUID: ConnectionHealthMonitor] = [:] + /// Current session (computed from currentSessionId) var currentSession: ConnectionSession? { guard let sessionId = currentSessionId else { return nil } @@ -128,6 +131,7 @@ final class DatabaseManager: ObservableObject { // Update session with successful connection session.driver = driver session.status = driver.status + session.effectiveConnection = effectiveConnection activeSessions[connection.id] = session // Restore tab state if it exists @@ -142,6 +146,11 @@ final class DatabaseManager: ObservableObject { // Post notification for reliable delivery NotificationCenter.default.post(name: .databaseDidConnect, object: nil) + + // Start health monitoring for network databases (skip SQLite) + if connection.type != .sqlite { + await startHealthMonitor(for: connection.id) + } } catch { // Close tunnel if connection failed if connection.sshConfig.enabled { @@ -186,6 +195,9 @@ final class DatabaseManager: ObservableObject { try? await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id) } + // Stop health monitoring + await stopHealthMonitor(for: sessionId) + session.driver?.disconnect() activeSessions.removeValue(forKey: sessionId) @@ -203,6 +215,11 @@ final class DatabaseManager: ObservableObject { /// Disconnect all sessions func disconnectAll() async { + // Stop all health monitors + for sessionId in healthMonitors.keys { + await stopHealthMonitor(for: sessionId) + } + for sessionId in activeSessions.keys { await disconnectSession(sessionId) } @@ -297,7 +314,181 @@ final class DatabaseManager: ObservableObject { return try await driver.testConnection() } - // MARK: - Schema Changes + // MARK: - Health Monitoring + + /// Start health monitoring for a connection + private func startHealthMonitor(for connectionId: UUID) async { + // Stop any existing monitor + await stopHealthMonitor(for: connectionId) + + let monitor = ConnectionHealthMonitor( + connectionId: connectionId, + pingHandler: { [weak self] in + guard let self else { return false } + guard let session = await self.activeSessions[connectionId], + let driver = session.driver else { return false } + do { + _ = try await driver.execute(query: "SELECT 1") + return true + } catch { + return false + } + }, + reconnectHandler: { [weak self] in + guard let self else { return false } + guard let session = await self.activeSessions[connectionId] else { return false } + do { + let driver = try await self.reconnectDriver(for: session) + await self.updateSession(connectionId) { session in + session.driver = driver + session.status = .connected + } + return true + } catch { + return false + } + }, + onStateChanged: { [weak self] id, state in + guard let self else { return } + await MainActor.run { + switch state { + case .healthy: + self.updateSession(id) { session in + session.status = .connected + } + case .reconnecting(let attempt): + Self.logger.info("Reconnecting session \(id) (attempt \(attempt)/3)") + self.updateSession(id) { session in + session.status = .connecting + } + case .failed: + Self.logger.error("Health monitoring failed for session \(id) after 3 retries") + self.updateSession(id) { session in + session.status = .error(String(localized: "Connection lost")) + } + case .checking: + break // No UI update needed + } + } + } + ) + + healthMonitors[connectionId] = monitor + await monitor.startMonitoring() + } + + /// Creates a fresh driver, connects, and applies timeout for the given session. + /// Uses the session's effective connection (SSH-tunneled if applicable). + private func reconnectDriver(for session: ConnectionSession) async throws -> DatabaseDriver { + // Disconnect existing driver + session.driver?.disconnect() + + // Use effective connection (tunneled) if available, otherwise original + let connectionForDriver = session.effectiveConnection ?? session.connection + let driver = DatabaseDriverFactory.createDriver(for: connectionForDriver) + try await driver.connect() + + // Apply timeout + let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds + if timeoutSeconds > 0 { + try await driver.applyQueryTimeout(timeoutSeconds) + } + + return driver + } + + /// Stop health monitoring for a connection + private func stopHealthMonitor(for connectionId: UUID) async { + if let monitor = healthMonitors.removeValue(forKey: connectionId) { + await monitor.stopMonitoring() + } + } + + /// Reconnect the current session (called from toolbar Reconnect button) + func reconnectCurrentSession() async { + guard let sessionId = currentSessionId, + let session = activeSessions[sessionId] else { return } + + Self.logger.info("Manual reconnect requested for: \(session.connection.name)") + + // Update status to connecting + updateSession(sessionId) { session in + session.status = .connecting + } + + // Stop existing health monitor + await stopHealthMonitor(for: sessionId) + + do { + // Disconnect existing driver + session.driver?.disconnect() + + // Recreate SSH tunnel if needed + var effectiveConnection = session.connection + if session.connection.sshConfig.enabled { + let sshPassword = ConnectionStorage.shared.loadSSHPassword(for: session.connection.id) + let keyPassphrase = ConnectionStorage.shared.loadKeyPassphrase(for: session.connection.id) + + let tunnelPort = try await SSHTunnelManager.shared.createTunnel( + connectionId: session.connection.id, + sshHost: session.connection.sshConfig.host, + sshPort: session.connection.sshConfig.port, + sshUsername: session.connection.sshConfig.username, + authMethod: session.connection.sshConfig.authMethod, + privateKeyPath: session.connection.sshConfig.privateKeyPath, + keyPassphrase: keyPassphrase, + sshPassword: sshPassword, + remoteHost: session.connection.host, + remotePort: session.connection.port + ) + + effectiveConnection = DatabaseConnection( + id: session.connection.id, + name: session.connection.name, + host: "127.0.0.1", + port: tunnelPort, + database: session.connection.database, + username: session.connection.username, + type: session.connection.type, + sshConfig: SSHConfiguration() + ) + } + + // Create new driver and connect + let driver = DatabaseDriverFactory.createDriver(for: effectiveConnection) + try await driver.connect() + + // Apply timeout + let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds + if timeoutSeconds > 0 { + try await driver.applyQueryTimeout(timeoutSeconds) + } + + // Update session + updateSession(sessionId) { session in + session.driver = driver + session.status = .connected + session.effectiveConnection = effectiveConnection + } + + // Restart health monitoring + if session.connection.type != .sqlite { + await startHealthMonitor(for: sessionId) + } + + // Post connection notification for schema reload + NotificationCenter.default.post(name: .databaseDidConnect, object: nil) + + Self.logger.info("Manual reconnect succeeded for: \(session.connection.name)") + } catch { + Self.logger.error("Manual reconnect failed: \(error.localizedDescription)") + updateSession(sessionId) { session in + session.status = .error(String(localized: "Reconnect failed: \(error.localizedDescription)")) + } + } + } + + // MARK: - SSH Tunnel Recovery /// Handle SSH tunnel death by attempting reconnection private func handleSSHTunnelDied(connectionId: UUID) async { diff --git a/TablePro/Models/ConnectionSession.swift b/TablePro/Models/ConnectionSession.swift index 1209551e8..a53b96cc0 100644 --- a/TablePro/Models/ConnectionSession.swift +++ b/TablePro/Models/ConnectionSession.swift @@ -11,6 +11,8 @@ import Foundation struct ConnectionSession: Identifiable { let id: UUID // Same as connection.id var connection: DatabaseConnection // Made var to allow database switching + /// The connection used to create the driver (may differ from `connection` for SSH tunneled connections) + var effectiveConnection: DatabaseConnection? var driver: DatabaseDriver? var status: ConnectionStatus = .disconnected var lastError: String? diff --git a/TablePro/OpenTableApp.swift b/TablePro/OpenTableApp.swift index cc96797c3..9140589d0 100644 --- a/TablePro/OpenTableApp.swift +++ b/TablePro/OpenTableApp.swift @@ -453,6 +453,9 @@ extension Notification.Name { // Database switcher notifications static let openDatabaseSwitcher = Notification.Name("openDatabaseSwitcher") + // Reconnect notifications + static let reconnectDatabase = Notification.Name("reconnectDatabase") + // Table creation notifications static let createTable = Notification.Name("createTable") diff --git a/TablePro/Views/Main/MainContentNotificationHandler.swift b/TablePro/Views/Main/MainContentNotificationHandler.swift index 732ee2e6c..f4f0839a0 100644 --- a/TablePro/Views/Main/MainContentNotificationHandler.swift +++ b/TablePro/Views/Main/MainContentNotificationHandler.swift @@ -79,6 +79,7 @@ final class MainContentNotificationHandler: ObservableObject { setupUndoRedoObservers() setupWindowObservers() setupFileOpenObservers() + setupReconnectObservers() } // MARK: - Row Operations @@ -695,4 +696,21 @@ final class MainContentNotificationHandler: ObservableObject { } .store(in: &cancellables) } + + // MARK: - Reconnect Operations + + private func setupReconnectObservers() { + NotificationCenter.default.publisher(for: .reconnectDatabase) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleReconnect() + } + .store(in: &cancellables) + } + + private func handleReconnect() { + Task { + await DatabaseManager.shared.reconnectCurrentSession() + } + } } diff --git a/TablePro/Views/Toolbar/ToolbarController.swift b/TablePro/Views/Toolbar/ToolbarController.swift index 215c20f88..110c0154b 100644 --- a/TablePro/Views/Toolbar/ToolbarController.swift +++ b/TablePro/Views/Toolbar/ToolbarController.swift @@ -103,6 +103,7 @@ final class ToolbarController: NSObject, NSToolbarDelegate { ToolbarItemIdentifier.databaseSwitcher.nsIdentifier, ToolbarItemIdentifier.newQueryTab.nsIdentifier, ToolbarItemIdentifier.refresh.nsIdentifier, + ToolbarItemIdentifier.reconnect.nsIdentifier, .flexibleSpace, ToolbarItemIdentifier.connectionStatus.nsIdentifier, .flexibleSpace, @@ -158,6 +159,15 @@ final class ToolbarController: NSObject, NSToolbarDelegate { // Only enabled when connected return state.connectionState == .connected + case .reconnect: + // Only enabled when in error or disconnected state + switch state.connectionState { + case .error, .disconnected: + return true + default: + return false + } + case .connectionStatus: // Always visible (shows status even when disconnected) return true diff --git a/TablePro/Views/Toolbar/ToolbarItemFactory.swift b/TablePro/Views/Toolbar/ToolbarItemFactory.swift index f036d4bba..498d53fb1 100644 --- a/TablePro/Views/Toolbar/ToolbarItemFactory.swift +++ b/TablePro/Views/Toolbar/ToolbarItemFactory.swift @@ -54,6 +54,8 @@ final class DefaultToolbarItemFactory: ToolbarItemFactory { return makeNewQueryTabItem() case .refresh: return makeRefreshItem(state: state) + case .reconnect: + return makeReconnectItem(state: state) case .connectionStatus: return makeConnectionStatusItem(state: state) case .filterToggle: @@ -153,6 +155,33 @@ final class DefaultToolbarItemFactory: ToolbarItemFactory { return item } + private func makeReconnectItem(state: ConnectionToolbarState) -> NSToolbarItem { + let item = NSToolbarItem(itemIdentifier: ToolbarItemIdentifier.reconnect.nsIdentifier) + item.label = ToolbarItemIdentifier.reconnect.label + item.paletteLabel = ToolbarItemIdentifier.reconnect.paletteLabel + item.toolTip = ToolbarItemIdentifier.reconnect.toolTip + + let button = NSButton( + image: systemImage(named: ToolbarItemIdentifier.reconnect.iconName, description: "Reconnect"), + target: ToolbarActionProxy.shared, + action: #selector(ToolbarActionProxy.reconnectAction) + ) + button.bezelStyle = .texturedRounded + button.isBordered = true + + item.view = button + + // Enable only when in error or disconnected state (and session exists) + switch state.connectionState { + case .error, .disconnected: + item.isEnabled = true + default: + item.isEnabled = false + } + + return item + } + private func makeConnectionStatusItem(state: ConnectionToolbarState) -> NSToolbarItem { let item = NSToolbarItem(itemIdentifier: ToolbarItemIdentifier.connectionStatus.nsIdentifier) item.label = "" // Don't show label (connection name already in window title) @@ -318,6 +347,10 @@ final class DefaultToolbarItemFactory: ToolbarItemFactory { NotificationCenter.default.post(name: .refreshData, object: nil) } + @objc func reconnectAction() { + NotificationCenter.default.post(name: .reconnectDatabase, object: nil) + } + @objc func filterToggleAction() { NotificationCenter.default.post(name: .toggleFilterPanel, object: nil) } diff --git a/TablePro/Views/Toolbar/ToolbarItemIdentifier.swift b/TablePro/Views/Toolbar/ToolbarItemIdentifier.swift index 622224299..9c8e80aa1 100644 --- a/TablePro/Views/Toolbar/ToolbarItemIdentifier.swift +++ b/TablePro/Views/Toolbar/ToolbarItemIdentifier.swift @@ -24,6 +24,9 @@ enum ToolbarItemIdentifier: String, CaseIterable { /// Refresh current view/query case refresh = "com.TablePro.toolbar.refresh" + /// Reconnect to database when connection is lost + case reconnect = "com.TablePro.toolbar.reconnect" + // MARK: - Center Section (Principal) /// Connection status display (tag + connection info + execution indicator) @@ -63,6 +66,7 @@ enum ToolbarItemIdentifier: String, CaseIterable { case .databaseSwitcher: return String(localized: "Database") case .newQueryTab: return String(localized: "SQL") case .refresh: return String(localized: "Refresh") + case .reconnect: return String(localized: "Reconnect") case .connectionStatus: return "" // Set dynamically in ToolbarItemFactory case .filterToggle: return String(localized: "Filters") case .historyToggle: return String(localized: "History") @@ -79,6 +83,7 @@ enum ToolbarItemIdentifier: String, CaseIterable { case .databaseSwitcher: return String(localized: "Database Switcher") case .newQueryTab: return String(localized: "New Query Tab") case .refresh: return String(localized: "Refresh") + case .reconnect: return String(localized: "Reconnect to Database") case .connectionStatus: return String(localized: "Connection Status") case .filterToggle: return String(localized: "Toggle Filters") case .historyToggle: return String(localized: "Toggle History") @@ -99,6 +104,8 @@ enum ToolbarItemIdentifier: String, CaseIterable { return String(localized: "New Query Tab (⌘T)") case .refresh: return String(localized: "Refresh (⌘R)") + case .reconnect: + return String(localized: "Reconnect to Database") case .connectionStatus: return String(localized: "Connection Status") case .filterToggle: @@ -125,6 +132,8 @@ enum ToolbarItemIdentifier: String, CaseIterable { return "doc.text" case .refresh: return "arrow.clockwise" + case .reconnect: + return "arrow.triangle.2.circlepath" case .connectionStatus: return "info.circle" // Not used (custom view) case .filterToggle: