Skip to content

Commit c23c4af

Browse files
authored
fix: deep link cold launch missing toolbar and duplicate windows (#467)
## Summary - **Connect before opening window** for deep link handlers so the session is already in `activeSessions` when `ContentView.init` runs — avoids SwiftUI toolbar drop bug on NavigationSplitView detail transition - **3-layer duplicate prevention** for deep link URLs: by session ID, by connection params (`findSessionByParams`), and by normalized param key (`connectingURLParamKeys`) — catches transient UUIDs, auto-reconnect races, and rapid duplicate opens - **File path dedup** (`connectingFilePaths`) for SQLite/DuckDB/generic file handlers - **Balanced `endFileOpenSuppression`** at batch level in both warm-launch and cold-launch paths — fixes permanently stuck `isHandlingFileOpen` flag - **Orphan window handling**: hide SwiftUI state-restored windows via `orderOut(nil)` instead of `close()` to break infinite close→restore loop - **`pendingConnectionId` set at all call sites** (welcome window, connection form, dock menu, auto-reconnect, connection switcher) — ensures only true SwiftUI restorations have `pendingId == nil` - **Window title update** on first session connect for cold-launch deep links - **`isRestorable = false`** on main windows to reduce restoration attempts
1 parent 58a9020 commit c23c4af

9 files changed

Lines changed: 106 additions & 32 deletions

TablePro/AppDelegate+ConnectionHandler.swift

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -55,24 +55,43 @@ extension AppDelegate {
5555
ConnectionStorage.shared.savePassword(parsed.password, for: connection.id)
5656
}
5757

58-
if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil {
59-
handlePostConnectionActions(parsed, connectionId: connection.id)
58+
// Check if already connected or connecting (by ID or by params).
59+
// This catches duplicates from URL handler, auto-reconnect, or any other source.
60+
if DatabaseManager.shared.activeSessions[connection.id] != nil {
61+
if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil {
62+
handlePostConnectionActions(parsed, connectionId: connection.id)
63+
}
6064
bringConnectionWindowToFront(connection.id)
6165
return
6266
}
6367

64-
if let activeId = findActiveSessionByParams(parsed) {
65-
handlePostConnectionActions(parsed, connectionId: activeId)
66-
bringConnectionWindowToFront(activeId)
68+
if let existingId = findSessionByParams(parsed) {
69+
if DatabaseManager.shared.activeSessions[existingId]?.driver != nil {
70+
handlePostConnectionActions(parsed, connectionId: existingId)
71+
}
72+
bringConnectionWindowToFront(existingId)
6773
return
6874
}
6975

70-
openNewConnectionWindow(for: connection)
76+
// Skip if already connecting this connection from a URL (prevents duplicates).
77+
// Use param key to catch transient connections with different UUIDs
78+
// even before connectToSession creates the session.
79+
let paramKey = Self.paramKey(for: parsed)
80+
guard !connectingURLConnectionIds.contains(connection.id),
81+
!connectingURLParamKeys.contains(paramKey) else {
82+
return
83+
}
84+
connectingURLConnectionIds.insert(connection.id)
85+
connectingURLParamKeys.insert(paramKey)
7186

7287
Task { @MainActor in
73-
defer { self.endFileOpenSuppression() }
88+
defer {
89+
self.connectingURLConnectionIds.remove(connection.id)
90+
self.connectingURLParamKeys.remove(paramKey)
91+
}
7492
do {
7593
try await DatabaseManager.shared.connectToSession(connection)
94+
self.openNewConnectionWindow(for: connection)
7695
for window in NSApp.windows where self.isWelcomeWindow(window) {
7796
window.close()
7897
}
@@ -114,12 +133,16 @@ extension AppDelegate {
114133
type: .sqlite
115134
)
116135

117-
openNewConnectionWindow(for: connection)
136+
guard !connectingFilePaths.contains(filePath) else { return }
137+
connectingFilePaths.insert(filePath)
118138

119139
Task { @MainActor in
120-
defer { self.endFileOpenSuppression() }
140+
defer {
141+
self.connectingFilePaths.remove(filePath)
142+
}
121143
do {
122144
try await DatabaseManager.shared.connectToSession(connection)
145+
self.openNewConnectionWindow(for: connection)
123146
for window in NSApp.windows where self.isWelcomeWindow(window) {
124147
window.close()
125148
}
@@ -160,12 +183,16 @@ extension AppDelegate {
160183
type: .duckdb
161184
)
162185

163-
openNewConnectionWindow(for: connection)
186+
guard !connectingFilePaths.contains(filePath) else { return }
187+
connectingFilePaths.insert(filePath)
164188

165189
Task { @MainActor in
166-
defer { self.endFileOpenSuppression() }
190+
defer {
191+
self.connectingFilePaths.remove(filePath)
192+
}
167193
do {
168194
try await DatabaseManager.shared.connectToSession(connection)
195+
self.openNewConnectionWindow(for: connection)
169196
for window in NSApp.windows where self.isWelcomeWindow(window) {
170197
window.close()
171198
}
@@ -206,12 +233,16 @@ extension AppDelegate {
206233
type: dbType
207234
)
208235

209-
openNewConnectionWindow(for: connection)
236+
guard !connectingFilePaths.contains(filePath) else { return }
237+
connectingFilePaths.insert(filePath)
210238

211239
Task { @MainActor in
212-
defer { self.endFileOpenSuppression() }
240+
defer {
241+
self.connectingFilePaths.remove(filePath)
242+
}
213243
do {
214244
try await DatabaseManager.shared.connectToSession(connection)
245+
self.openNewConnectionWindow(for: connection)
215246
for window in NSApp.windows where self.isWelcomeWindow(window) {
216247
window.close()
217248
}
@@ -225,7 +256,9 @@ extension AppDelegate {
225256
// MARK: - Unified Queue
226257

227258
func scheduleQueuedURLProcessing() {
228-
guard !isProcessingQueuedURLs else { return }
259+
guard !isProcessingQueuedURLs else {
260+
return
261+
}
229262
isProcessingQueuedURLs = true
230263

231264
Task { @MainActor [weak self] in
@@ -256,7 +289,7 @@ extension AppDelegate {
256289
case .genericDatabaseFile(let url, let dbType): self.handleGenericDatabaseFile(url, type: dbType)
257290
}
258291
}
259-
// Flag management is handled by endFileOpenSuppression() in each handler
292+
self.endFileOpenSuppression()
260293
}
261294
}
262295

@@ -363,9 +396,9 @@ extension AppDelegate {
363396

364397
// MARK: - Session Lookup
365398

366-
private func findActiveSessionByParams(_ parsed: ParsedConnectionURL) -> UUID? {
399+
/// Finds any session (connected or still connecting) matching the parsed URL params.
400+
private func findSessionByParams(_ parsed: ParsedConnectionURL) -> UUID? {
367401
for (id, session) in DatabaseManager.shared.activeSessions {
368-
guard session.driver != nil else { continue }
369402
let conn = session.connection
370403
if conn.type == parsed.type
371404
&& conn.host == parsed.host
@@ -379,6 +412,12 @@ extension AppDelegate {
379412
return nil
380413
}
381414

415+
/// Normalized key for deduplicating connection attempts by URL params.
416+
static func paramKey(for parsed: ParsedConnectionURL) -> String {
417+
let rdb = parsed.redisDatabase.map { "/redis:\($0)" } ?? ""
418+
return "\(parsed.type.rawValue):\(parsed.username)@\(parsed.host):\(parsed.port ?? 0)/\(parsed.database)\(rdb)"
419+
}
420+
382421
func bringConnectionWindowToFront(_ connectionId: UUID) {
383422
let windows = WindowLifecycleMonitor.shared.windows(for: connectionId)
384423
if let window = windows.first {

TablePro/AppDelegate+FileOpen.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ extension AppDelegate {
5353
suppressWelcomeWindow()
5454
Task { @MainActor in
5555
for url in databaseURLs { self.handleDatabaseURL(url) }
56-
// Flag management is handled by endFileOpenSuppression() in each handler
56+
// endFileOpenSuppression is called here to match suppressWelcomeWindow above.
57+
// Individual handlers no longer manage this flag.
58+
self.endFileOpenSuppression()
5759
}
5860
}
5961

@@ -72,7 +74,7 @@ extension AppDelegate {
7274
self.handleGenericDatabaseFile(url, type: dbType)
7375
}
7476
}
75-
// Flag management is handled by endFileOpenSuppression() in each handler
77+
self.endFileOpenSuppression()
7678
}
7779
}
7880

TablePro/AppDelegate+WindowConfig.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ extension AppDelegate {
6767
let connections = ConnectionStorage.shared.loadConnections()
6868
guard let connection = connections.first(where: { $0.id == connectionId }) else { return }
6969

70+
WindowOpener.shared.pendingConnectionId = connection.id
7071
NotificationCenter.default.post(name: .openMainWindow, object: connection.id)
7172

7273
Task { @MainActor in
@@ -244,7 +245,18 @@ extension AppDelegate {
244245

245246
if isMainWindow(window) && !configuredWindows.contains(windowId) {
246247
window.tabbingMode = .preferred
248+
window.isRestorable = false
247249
let pendingId = MainActor.assumeIsolated { WindowOpener.shared.consumePendingConnectionId() }
250+
251+
// If no code opened this window (pendingId is nil), this is a
252+
// SwiftUI WindowGroup state restoration — not a window we created.
253+
// Hide it (orderOut, not close) to break the close→restore loop.
254+
if pendingId == nil && !isAutoReconnecting {
255+
configuredWindows.insert(windowId)
256+
window.orderOut(nil)
257+
return
258+
}
259+
248260
let existingIdentifier = NSApp.windows
249261
.first { $0 !== window && isMainWindow($0) && $0.isVisible }?
250262
.tabbingIdentifier
@@ -311,12 +323,15 @@ extension AppDelegate {
311323
return
312324
}
313325

326+
isAutoReconnecting = true
327+
314328
DispatchQueue.main.async { [weak self] in
315329
guard let self else { return }
316-
330+
WindowOpener.shared.pendingConnectionId = connection.id
317331
NotificationCenter.default.post(name: .openMainWindow, object: connection.id)
318332

319333
Task { @MainActor in
334+
defer { self.isAutoReconnecting = false }
320335
do {
321336
try await DatabaseManager.shared.connectToSession(connection)
322337

TablePro/AppDelegate.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4444
/// True while a queued URL polling task is active — prevents duplicate pollers
4545
var isProcessingQueuedURLs = false
4646

47+
/// True while auto-reconnect is in progress at startup
48+
var isAutoReconnecting = false
49+
50+
/// ConnectionIds currently being connected from URL handlers.
51+
/// Prevents duplicate connections when the same URL is opened twice rapidly.
52+
var connectingURLConnectionIds = Set<UUID>()
53+
54+
/// Normalized param keys for URLs currently being connected.
55+
/// Catches duplicates even before connectToSession creates the session.
56+
var connectingURLParamKeys = Set<String>()
57+
58+
/// File paths currently being connected from file-open handlers.
59+
/// Prevents duplicate connections when the same file is opened twice rapidly.
60+
var connectingFilePaths = Set<String>()
61+
4762
// MARK: - NSApplicationDelegate
4863

4964
func application(_ application: NSApplication, open urls: [URL]) {

TablePro/ContentView.swift

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,6 @@ struct ContentView: View {
256256
.navigationSubtitle(currentSession?.connection.name ?? "")
257257
}
258258

259-
// Removed: newConnectionSheet and editConnectionSheet helpers
260-
// Connection forms are now handled by the separate connection-form window
261-
262259
// MARK: - Session State Bindings
263260

264261
/// Generic helper to create bindings that update session state
@@ -338,7 +335,9 @@ struct ContentView: View {
338335
// MARK: - Connection Status
339336

340337
private func handleConnectionStatusChange() {
341-
guard closingSessionId == nil else { return }
338+
guard closingSessionId == nil else {
339+
return
340+
}
342341
let sessions = DatabaseManager.shared.activeSessions
343342
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
344343
guard let sid = connectionId else {
@@ -360,13 +359,10 @@ struct ContentView: View {
360359
AppState.shared.currentDatabaseType = nil
361360
AppState.shared.supportsDatabaseSwitching = true
362361

363-
let tabbingId = "com.TablePro.main.\(sid.uuidString)"
364-
DispatchQueue.main.async {
365-
for window in NSApp.windows where window.tabbingIdentifier == tabbingId {
366-
window.isReleasedWhenClosed = true
367-
window.close()
368-
}
369-
}
362+
// Window cleanup is handled by windowWillClose (opens welcome)
363+
// and windowDidBecomeKey (hides restored orphan windows).
364+
// Do NOT close windows here — it triggers SwiftUI state
365+
// restoration which creates an infinite close→restore loop.
370366
}
371367
return
372368
}
@@ -375,6 +371,10 @@ struct ContentView: View {
375371
return
376372
}
377373
currentSession = newSession
374+
// Update window title on first session connect (fixes cold-launch stale title)
375+
if payload?.tableName == nil, windowTitle == "SQL Query" || windowTitle.hasSuffix(" Query") {
376+
windowTitle = newSession.connection.name
377+
}
378378
if rightPanelState == nil {
379379
rightPanelState = RightPanelState()
380380
}

TablePro/TableProApp.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,6 @@ private struct OpenWindowHandler: View {
544544
if let payload = notification.object as? EditorTabPayload {
545545
openWindow(id: "main", value: payload)
546546
} else if let connectionId = notification.object as? UUID {
547-
// Legacy: connection ID only — open default query tab
548547
openWindow(id: "main", value: EditorTabPayload(connectionId: connectionId))
549548
}
550549
}

TablePro/Views/Connection/ConnectionFormView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
13031303
}
13041304

13051305
private func connectToDatabase(_ connection: DatabaseConnection) {
1306+
WindowOpener.shared.pendingConnectionId = connection.id
13061307
openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id))
13071308
NSApplication.shared.closeWindows(withId: "welcome")
13081309

@@ -1335,6 +1336,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
13351336
}
13361337

13371338
private func connectAfterInstall(_ connection: DatabaseConnection) {
1339+
WindowOpener.shared.pendingConnectionId = connection.id
13381340
openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id))
13391341
NSApplication.shared.closeWindows(withId: "welcome")
13401342

TablePro/Views/Connection/WelcomeWindowView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,7 @@ struct WelcomeWindowView: View {
795795
}
796796

797797
private func connectAfterInstall(_ connection: DatabaseConnection) {
798+
WindowOpener.shared.pendingConnectionId = connection.id
798799
openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id))
799800
NSApplication.shared.closeWindows(withId: "welcome")
800801

TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ struct ConnectionSwitcherPopover: View {
346346
let currentWindow = NSApp.keyWindow
347347
let previousMode = currentWindow?.tabbingMode ?? .preferred
348348
currentWindow?.tabbingMode = .disallowed
349+
WindowOpener.shared.pendingConnectionId = payload.connectionId
349350
openWindow(id: "main", value: payload)
350351
// Restore after the next run loop to let window creation complete
351352
DispatchQueue.main.async {

0 commit comments

Comments
 (0)