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
73 changes: 56 additions & 17 deletions TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions TablePro/AppDelegate+FileOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand All @@ -72,7 +74,7 @@ extension AppDelegate {
self.handleGenericDatabaseFile(url, type: dbType)
}
}
// Flag management is handled by endFileOpenSuppression() in each handler
self.endFileOpenSuppression()
}
}

Expand Down
17 changes: 16 additions & 1 deletion TablePro/AppDelegate+WindowConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID>()

/// Normalized param keys for URLs currently being connected.
/// Catches duplicates even before connectToSession creates the session.
var connectingURLParamKeys = Set<String>()

/// File paths currently being connected from file-open handlers.
/// Prevents duplicate connections when the same file is opened twice rapidly.
var connectingFilePaths = Set<String>()

// MARK: - NSApplicationDelegate

func application(_ application: NSApplication, open urls: [URL]) {
Expand Down
22 changes: 11 additions & 11 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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()
}
Expand Down
1 change: 0 additions & 1 deletion TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Connection/ConnectionFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Connection/WelcomeWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading