diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 408050d5..253fcba5 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -55,24 +55,43 @@ extension AppDelegate { ConnectionStorage.shared.savePassword(parsed.password, for: connection.id) } - if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { - handlePostConnectionActions(parsed, connectionId: connection.id) + // Check if already connected or connecting (by ID or by params). + // This catches duplicates from URL handler, auto-reconnect, or any other source. + if DatabaseManager.shared.activeSessions[connection.id] != nil { + if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { + handlePostConnectionActions(parsed, connectionId: connection.id) + } bringConnectionWindowToFront(connection.id) return } - if let activeId = findActiveSessionByParams(parsed) { - handlePostConnectionActions(parsed, connectionId: activeId) - bringConnectionWindowToFront(activeId) + if let existingId = findSessionByParams(parsed) { + if DatabaseManager.shared.activeSessions[existingId]?.driver != nil { + handlePostConnectionActions(parsed, connectionId: existingId) + } + bringConnectionWindowToFront(existingId) return } - openNewConnectionWindow(for: connection) + // Skip if already connecting this connection from a URL (prevents duplicates). + // Use param key to catch transient connections with different UUIDs + // even before connectToSession creates the session. + let paramKey = Self.paramKey(for: parsed) + guard !connectingURLConnectionIds.contains(connection.id), + !connectingURLParamKeys.contains(paramKey) else { + return + } + connectingURLConnectionIds.insert(connection.id) + connectingURLParamKeys.insert(paramKey) Task { @MainActor in - defer { self.endFileOpenSuppression() } + defer { + self.connectingURLConnectionIds.remove(connection.id) + self.connectingURLParamKeys.remove(paramKey) + } do { try await DatabaseManager.shared.connectToSession(connection) + self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } @@ -114,12 +133,16 @@ extension AppDelegate { type: .sqlite ) - openNewConnectionWindow(for: connection) + guard !connectingFilePaths.contains(filePath) else { return } + connectingFilePaths.insert(filePath) Task { @MainActor in - defer { self.endFileOpenSuppression() } + defer { + self.connectingFilePaths.remove(filePath) + } do { try await DatabaseManager.shared.connectToSession(connection) + self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } @@ -160,12 +183,16 @@ extension AppDelegate { type: .duckdb ) - openNewConnectionWindow(for: connection) + guard !connectingFilePaths.contains(filePath) else { return } + connectingFilePaths.insert(filePath) Task { @MainActor in - defer { self.endFileOpenSuppression() } + defer { + self.connectingFilePaths.remove(filePath) + } do { try await DatabaseManager.shared.connectToSession(connection) + self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } @@ -206,12 +233,16 @@ extension AppDelegate { type: dbType ) - openNewConnectionWindow(for: connection) + guard !connectingFilePaths.contains(filePath) else { return } + connectingFilePaths.insert(filePath) Task { @MainActor in - defer { self.endFileOpenSuppression() } + defer { + self.connectingFilePaths.remove(filePath) + } do { try await DatabaseManager.shared.connectToSession(connection) + self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } @@ -225,7 +256,9 @@ extension AppDelegate { // MARK: - Unified Queue func scheduleQueuedURLProcessing() { - guard !isProcessingQueuedURLs else { return } + guard !isProcessingQueuedURLs else { + return + } isProcessingQueuedURLs = true Task { @MainActor [weak self] in @@ -256,7 +289,7 @@ extension AppDelegate { case .genericDatabaseFile(let url, let dbType): self.handleGenericDatabaseFile(url, type: dbType) } } - // Flag management is handled by endFileOpenSuppression() in each handler + self.endFileOpenSuppression() } } @@ -363,9 +396,9 @@ extension AppDelegate { // MARK: - Session Lookup - private func findActiveSessionByParams(_ parsed: ParsedConnectionURL) -> UUID? { + /// Finds any session (connected or still connecting) matching the parsed URL params. + private func findSessionByParams(_ parsed: ParsedConnectionURL) -> UUID? { for (id, session) in DatabaseManager.shared.activeSessions { - guard session.driver != nil else { continue } let conn = session.connection if conn.type == parsed.type && conn.host == parsed.host @@ -379,6 +412,12 @@ extension AppDelegate { return nil } + /// Normalized key for deduplicating connection attempts by URL params. + static func paramKey(for parsed: ParsedConnectionURL) -> String { + let rdb = parsed.redisDatabase.map { "/redis:\($0)" } ?? "" + return "\(parsed.type.rawValue):\(parsed.username)@\(parsed.host):\(parsed.port ?? 0)/\(parsed.database)\(rdb)" + } + func bringConnectionWindowToFront(_ connectionId: UUID) { let windows = WindowLifecycleMonitor.shared.windows(for: connectionId) if let window = windows.first { diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index ce60f22b..2532d781 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -53,7 +53,9 @@ extension AppDelegate { suppressWelcomeWindow() Task { @MainActor in for url in databaseURLs { self.handleDatabaseURL(url) } - // Flag management is handled by endFileOpenSuppression() in each handler + // endFileOpenSuppression is called here to match suppressWelcomeWindow above. + // Individual handlers no longer manage this flag. + self.endFileOpenSuppression() } } @@ -72,7 +74,7 @@ extension AppDelegate { self.handleGenericDatabaseFile(url, type: dbType) } } - // Flag management is handled by endFileOpenSuppression() in each handler + self.endFileOpenSuppression() } } diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index 8884e675..dfe35b33 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -67,6 +67,7 @@ extension AppDelegate { let connections = ConnectionStorage.shared.loadConnections() guard let connection = connections.first(where: { $0.id == connectionId }) else { return } + WindowOpener.shared.pendingConnectionId = connection.id NotificationCenter.default.post(name: .openMainWindow, object: connection.id) Task { @MainActor in @@ -244,7 +245,18 @@ extension AppDelegate { if isMainWindow(window) && !configuredWindows.contains(windowId) { window.tabbingMode = .preferred + window.isRestorable = false let pendingId = MainActor.assumeIsolated { WindowOpener.shared.consumePendingConnectionId() } + + // If no code opened this window (pendingId is nil), this is a + // SwiftUI WindowGroup state restoration — not a window we created. + // Hide it (orderOut, not close) to break the close→restore loop. + if pendingId == nil && !isAutoReconnecting { + configuredWindows.insert(windowId) + window.orderOut(nil) + return + } + let existingIdentifier = NSApp.windows .first { $0 !== window && isMainWindow($0) && $0.isVisible }? .tabbingIdentifier @@ -311,12 +323,15 @@ extension AppDelegate { return } + isAutoReconnecting = true + DispatchQueue.main.async { [weak self] in guard let self else { return } - + WindowOpener.shared.pendingConnectionId = connection.id NotificationCenter.default.post(name: .openMainWindow, object: connection.id) Task { @MainActor in + defer { self.isAutoReconnecting = false } do { try await DatabaseManager.shared.connectToSession(connection) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index efb654f2..42a1be68 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -44,6 +44,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// True while a queued URL polling task is active — prevents duplicate pollers var isProcessingQueuedURLs = false + /// True while auto-reconnect is in progress at startup + var isAutoReconnecting = false + + /// ConnectionIds currently being connected from URL handlers. + /// Prevents duplicate connections when the same URL is opened twice rapidly. + var connectingURLConnectionIds = Set() + + /// Normalized param keys for URLs currently being connected. + /// Catches duplicates even before connectToSession creates the session. + var connectingURLParamKeys = Set() + + /// File paths currently being connected from file-open handlers. + /// Prevents duplicate connections when the same file is opened twice rapidly. + var connectingFilePaths = Set() + // MARK: - NSApplicationDelegate func application(_ application: NSApplication, open urls: [URL]) { diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 14b5df98..a346e380 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -256,9 +256,6 @@ struct ContentView: View { .navigationSubtitle(currentSession?.connection.name ?? "") } - // Removed: newConnectionSheet and editConnectionSheet helpers - // Connection forms are now handled by the separate connection-form window - // MARK: - Session State Bindings /// Generic helper to create bindings that update session state @@ -338,7 +335,9 @@ struct ContentView: View { // MARK: - Connection Status private func handleConnectionStatusChange() { - guard closingSessionId == nil else { return } + guard closingSessionId == nil else { + return + } let sessions = DatabaseManager.shared.activeSessions let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId guard let sid = connectionId else { @@ -360,13 +359,10 @@ struct ContentView: View { AppState.shared.currentDatabaseType = nil AppState.shared.supportsDatabaseSwitching = true - let tabbingId = "com.TablePro.main.\(sid.uuidString)" - DispatchQueue.main.async { - for window in NSApp.windows where window.tabbingIdentifier == tabbingId { - window.isReleasedWhenClosed = true - window.close() - } - } + // Window cleanup is handled by windowWillClose (opens welcome) + // and windowDidBecomeKey (hides restored orphan windows). + // Do NOT close windows here — it triggers SwiftUI state + // restoration which creates an infinite close→restore loop. } return } @@ -375,6 +371,10 @@ struct ContentView: View { return } currentSession = newSession + // Update window title on first session connect (fixes cold-launch stale title) + if payload?.tableName == nil, windowTitle == "SQL Query" || windowTitle.hasSuffix(" Query") { + windowTitle = newSession.connection.name + } if rightPanelState == nil { rightPanelState = RightPanelState() } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 49b6a2ee..547140dc 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -544,7 +544,6 @@ private struct OpenWindowHandler: View { if let payload = notification.object as? EditorTabPayload { openWindow(id: "main", value: payload) } else if let connectionId = notification.object as? UUID { - // Legacy: connection ID only — open default query tab openWindow(id: "main", value: EditorTabPayload(connectionId: connectionId)) } } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index a6e8afe9..929317a2 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -1303,6 +1303,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } private func connectToDatabase(_ connection: DatabaseConnection) { + WindowOpener.shared.pendingConnectionId = connection.id openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) NSApplication.shared.closeWindows(withId: "welcome") @@ -1335,6 +1336,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } private func connectAfterInstall(_ connection: DatabaseConnection) { + WindowOpener.shared.pendingConnectionId = connection.id openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) NSApplication.shared.closeWindows(withId: "welcome") diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index a3a34c56..95f4ebd3 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -795,6 +795,7 @@ struct WelcomeWindowView: View { } private func connectAfterInstall(_ connection: DatabaseConnection) { + WindowOpener.shared.pendingConnectionId = connection.id openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) NSApplication.shared.closeWindows(withId: "welcome") diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index da37d061..c0ab8a61 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -346,6 +346,7 @@ struct ConnectionSwitcherPopover: View { let currentWindow = NSApp.keyWindow let previousMode = currentWindow?.tabbingMode ?? .preferred currentWindow?.tabbingMode = .disallowed + WindowOpener.shared.pendingConnectionId = payload.connectionId openWindow(id: "main", value: payload) // Restore after the next run loop to let window creation complete DispatchQueue.main.async {