diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 6c696afc..408050d5 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -70,6 +70,7 @@ extension AppDelegate { openNewConnectionWindow(for: connection) Task { @MainActor in + defer { self.endFileOpenSuppression() } do { try await DatabaseManager.shared.connectToSession(connection) for window in NSApp.windows where self.isWelcomeWindow(window) { @@ -116,6 +117,7 @@ extension AppDelegate { openNewConnectionWindow(for: connection) Task { @MainActor in + defer { self.endFileOpenSuppression() } do { try await DatabaseManager.shared.connectToSession(connection) for window in NSApp.windows where self.isWelcomeWindow(window) { @@ -161,6 +163,7 @@ extension AppDelegate { openNewConnectionWindow(for: connection) Task { @MainActor in + defer { self.endFileOpenSuppression() } do { try await DatabaseManager.shared.connectToSession(connection) for window in NSApp.windows where self.isWelcomeWindow(window) { @@ -206,6 +209,7 @@ extension AppDelegate { openNewConnectionWindow(for: connection) Task { @MainActor in + defer { self.endFileOpenSuppression() } do { try await DatabaseManager.shared.connectToSession(connection) for window in NSApp.windows where self.isWelcomeWindow(window) { @@ -252,7 +256,7 @@ extension AppDelegate { case .genericDatabaseFile(let url, let dbType): self.handleGenericDatabaseFile(url, type: dbType) } } - self.scheduleWelcomeWindowSuppression() + // Flag management is handled by endFileOpenSuppression() in each handler } } diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index 25c66fdb..ce60f22b 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -53,7 +53,7 @@ extension AppDelegate { suppressWelcomeWindow() Task { @MainActor in for url in databaseURLs { self.handleDatabaseURL(url) } - self.scheduleWelcomeWindowSuppression() + // Flag management is handled by endFileOpenSuppression() in each handler } } @@ -72,7 +72,7 @@ extension AppDelegate { self.handleGenericDatabaseFile(url, type: dbType) } } - self.scheduleWelcomeWindowSuppression() + // Flag management is handled by endFileOpenSuppression() in each handler } } @@ -87,7 +87,7 @@ extension AppDelegate { window.close() } NotificationCenter.default.post(name: .openSQLFiles, object: sqlFiles) - scheduleWelcomeWindowSuppression() + endFileOpenSuppression() } else { queuedFileURLs.append(contentsOf: sqlFiles) openWelcomeWindow() diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index f443d2bf..8884e675 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -190,26 +190,24 @@ extension AppDelegate { // MARK: - Welcome Window Suppression - func scheduleWelcomeWindowSuppression() { - Task { @MainActor [weak self] in - try? await Task.sleep(for: .milliseconds(200)) - self?.closeWelcomeWindowIfMainExists() - try? await Task.sleep(for: .milliseconds(500)) - guard let self else { return } - self.closeWelcomeWindowIfMainExists() - self.fileOpenSuppressionCount = max(0, self.fileOpenSuppressionCount - 1) - if self.fileOpenSuppressionCount == 0 { - self.isHandlingFileOpen = false - } + /// Called by connection handlers when the file-open connection attempt finishes + /// (success or failure). Decrements the suppression counter and resets the flag + /// when all outstanding file opens have completed. + func endFileOpenSuppression() { + fileOpenSuppressionCount = max(0, fileOpenSuppressionCount - 1) + if fileOpenSuppressionCount == 0 { + isHandlingFileOpen = false } } - private func closeWelcomeWindowIfMainExists() { + @discardableResult + private func closeWelcomeWindowIfMainExists() -> Bool { let hasMainWindow = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } - guard hasMainWindow else { return } + guard hasMainWindow else { return false } for window in NSApp.windows where isWelcomeWindow(window) { window.close() } + return true } // MARK: - Window Notifications @@ -219,9 +217,13 @@ extension AppDelegate { let windowId = ObjectIdentifier(window) if isWelcomeWindow(window) && isHandlingFileOpen { - window.close() - for mainWin in NSApp.windows where isMainWindow(mainWin) { + // Only close welcome if a main window exists to take its place; + // otherwise just hide it so the user doesn't see a flash. + if let mainWin = NSApp.windows.first(where: { isMainWindow($0) }) { + window.close() mainWin.makeKeyAndOrderFront(nil) + } else { + window.orderOut(nil) } return } @@ -236,6 +238,10 @@ extension AppDelegate { configuredWindows.insert(windowId) } + if isMainWindow(window) && isHandlingFileOpen { + closeWelcomeWindowIfMainExists() + } + if isMainWindow(window) && !configuredWindows.contains(windowId) { window.tabbingMode = .preferred let pendingId = MainActor.assumeIsolated { WindowOpener.shared.consumePendingConnectionId() } diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 1bbf967f..14b5df98 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -157,9 +157,9 @@ struct ContentView: View { @ViewBuilder private var mainContent: some View { - if let currentSession = currentSession, let rightPanelState, let sessionState { - NavigationSplitView(columnVisibility: $columnVisibility) { - // MARK: - Sidebar (Left) - Table Browser + NavigationSplitView(columnVisibility: $columnVisibility) { + // MARK: - Sidebar (Left) - Table Browser + if let currentSession = currentSession, let sessionState { VStack(spacing: 0) { SidebarView( tables: sessionTablesBinding, @@ -198,8 +198,13 @@ struct ContentView: View { prompt: sidebarSearchPrompt(for: currentSession.connection.id) ) .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600) - } detail: { - // MARK: - Detail (Main workspace with optional right sidebar) + } else { + Color.clear + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600) + } + } detail: { + // MARK: - Detail (Main workspace with optional right sidebar) + if let currentSession = currentSession, let rightPanelState, let sessionState { HStack(spacing: 0) { MainContentView( connection: currentSession.connection, @@ -235,21 +240,20 @@ struct ContentView: View { } } .animation(.easeInOut(duration: 0.2), value: rightPanelState.isPresented) + } else { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + + Text("Connecting...") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .navigationTitle(windowTitle) - .navigationSubtitle(currentSession.connection.name) - } else { - VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.5) - - Text("Connecting...") - .font(.headline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationTitle("TablePro") } + .navigationTitle(windowTitle) + .navigationSubtitle(currentSession?.connection.name ?? "") } // Removed: newConnectionSheet and editConnectionSheet helpers diff --git a/TableProTests/Core/Services/WelcomeWindowSuppressionTests.swift b/TableProTests/Core/Services/WelcomeWindowSuppressionTests.swift new file mode 100644 index 00000000..513cc8a7 --- /dev/null +++ b/TableProTests/Core/Services/WelcomeWindowSuppressionTests.swift @@ -0,0 +1,286 @@ +// +// WelcomeWindowSuppressionTests.swift +// TableProTests +// +// Regression tests for the welcome window suppression logic in AppDelegate+WindowConfig. +// Covers the fix where double-clicking .duckdb files from Finder caused the app to freeze +// because suppression gave up too early and welcome was closed instead of hidden. +// + +import AppKit +import Foundation +import Testing +@testable import TablePro + +@Suite("Welcome Window Suppression") +@MainActor +struct WelcomeWindowSuppressionTests { + /// Create a fresh AppDelegate for each test — avoids relying on NSApp.delegate + /// which may not be our AppDelegate in parallel test runner processes. + private func makeAppDelegate() -> AppDelegate { + AppDelegate() + } + + private func makeWindow(identifier: String) -> NSWindow { + let window = NSWindow() + window.identifier = NSUserInterfaceItemIdentifier(identifier) + return window + } + + // MARK: - Window Identification + + @Test("isMainWindow — exact identifier 'main'") + func isMainWindowExact() { + let delegate = makeAppDelegate() + let window = makeWindow(identifier: "main") + #expect(delegate.isMainWindow(window)) + } + + @Test("isMainWindow — prefixed identifier 'main-123'") + func isMainWindowPrefixed() { + let delegate = makeAppDelegate() + let window = makeWindow(identifier: "main-123") + #expect(delegate.isMainWindow(window)) + } + + @Test("isMainWindow — returns false for nil identifier") + func isMainWindowNilIdentifier() { + let delegate = makeAppDelegate() + let window = NSWindow() + window.identifier = nil + #expect(!delegate.isMainWindow(window)) + } + + @Test("isMainWindow — returns false for 'welcome'") + func isMainWindowUnrelated() { + let delegate = makeAppDelegate() + let window = makeWindow(identifier: "welcome") + #expect(!delegate.isMainWindow(window)) + } + + @Test("isMainWindow — returns false for 'mainExtra' (no dash separator)") + func isMainWindowNoDash() { + let delegate = makeAppDelegate() + let window = makeWindow(identifier: "mainExtra") + #expect(!delegate.isMainWindow(window)) + } + + @Test("isWelcomeWindow — exact identifier 'welcome'") + func isWelcomeWindowExact() { + let delegate = makeAppDelegate() + let window = makeWindow(identifier: "welcome") + #expect(delegate.isWelcomeWindow(window)) + } + + @Test("isWelcomeWindow — prefixed identifier 'welcome-abc'") + func isWelcomeWindowPrefixed() { + let delegate = makeAppDelegate() + let window = makeWindow(identifier: "welcome-abc") + #expect(delegate.isWelcomeWindow(window)) + } + + @Test("isWelcomeWindow — returns false for nil identifier") + func isWelcomeWindowNilIdentifier() { + let delegate = makeAppDelegate() + let window = NSWindow() + window.identifier = nil + #expect(!delegate.isWelcomeWindow(window)) + } + + @Test("isWelcomeWindow — returns false for 'main'") + func isWelcomeWindowNotMain() { + let delegate = makeAppDelegate() + let window = makeWindow(identifier: "main") + #expect(!delegate.isWelcomeWindow(window)) + } + + @Test("isWelcomeWindow — returns false for 'welcomeExtra' (no dash separator)") + func isWelcomeWindowNoDash() { + let delegate = makeAppDelegate() + let window = makeWindow(identifier: "welcomeExtra") + #expect(!delegate.isWelcomeWindow(window)) + } + + // MARK: - suppressWelcomeWindow State + + @Test("suppressWelcomeWindow — sets isHandlingFileOpen to true") + func suppressSetsFlag() { + let delegate = makeAppDelegate() + delegate.suppressWelcomeWindow() + #expect(delegate.isHandlingFileOpen == true) + } + + @Test("suppressWelcomeWindow — increments fileOpenSuppressionCount") + func suppressIncrementsCount() { + let delegate = makeAppDelegate() + delegate.suppressWelcomeWindow() + #expect(delegate.fileOpenSuppressionCount == 1) + + delegate.suppressWelcomeWindow() + #expect(delegate.fileOpenSuppressionCount == 2) + } + + @Test("suppressWelcomeWindow — hides existing welcome windows via orderOut") + func suppressHidesWelcomeWindows() { + let delegate = makeAppDelegate() + + let welcome = makeWindow(identifier: "welcome") + welcome.orderFront(nil) + defer { welcome.close() } + + #expect(welcome.isVisible) + + delegate.suppressWelcomeWindow() + + #expect(!welcome.isVisible) + } + + // MARK: - windowDidBecomeKey Suppression Behavior + + @Test("windowDidBecomeKey — welcome hides (orderOut) when file open and no main window") + func windowDidBecomeKeyHidesWelcomeWhenNoMain() { + let delegate = makeAppDelegate() + delegate.isHandlingFileOpen = true + + let welcome = makeWindow(identifier: "welcome") + welcome.orderFront(nil) + defer { welcome.close() } + + #expect(welcome.isVisible) + + let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome) + delegate.windowDidBecomeKey(notification) + + // Key regression fix: welcome should be hidden (not closed) so it can reappear + // when the main window is ready — prevents "no visible windows" freeze + #expect(!welcome.isVisible) + } + + @Test("windowDidBecomeKey — welcome closes when file open and main window exists") + func windowDidBecomeKeyClosesWelcomeWhenMainExists() { + let delegate = makeAppDelegate() + delegate.isHandlingFileOpen = true + + let mainWin = makeWindow(identifier: "main") + mainWin.orderFront(nil) + defer { mainWin.close() } + + let welcome = makeWindow(identifier: "welcome") + welcome.orderFront(nil) + defer { welcome.close() } + + let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome) + delegate.windowDidBecomeKey(notification) + + #expect(!welcome.isVisible) + } + + @Test("windowDidBecomeKey — welcome not suppressed when isHandlingFileOpen is false") + func windowDidBecomeKeyNoSuppressionWhenNotHandlingFile() { + let delegate = makeAppDelegate() + delegate.isHandlingFileOpen = false + + let welcome = makeWindow(identifier: "welcome") + welcome.orderFront(nil) + defer { welcome.close() } + + let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome) + delegate.windowDidBecomeKey(notification) + + #expect(welcome.isVisible) + } + + @Test("windowDidBecomeKey — non-welcome window is not affected by suppression") + func windowDidBecomeKeyIgnoresNonWelcome() { + let delegate = makeAppDelegate() + delegate.isHandlingFileOpen = true + + let other = makeWindow(identifier: "settings") + other.orderFront(nil) + defer { other.close() } + + let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: other) + delegate.windowDidBecomeKey(notification) + + #expect(other.isVisible) + } + + // MARK: - Suppression Count State + + @Test("Multiple suppress calls — count increments independently") + func multipleSuppressionCountsStack() { + let delegate = makeAppDelegate() + delegate.suppressWelcomeWindow() + delegate.suppressWelcomeWindow() + delegate.suppressWelcomeWindow() + + #expect(delegate.fileOpenSuppressionCount == 3) + #expect(delegate.isHandlingFileOpen == true) + } + + @Test("endFileOpenSuppression — decrement to zero resets isHandlingFileOpen") + func endSuppressionResetsFlag() { + let delegate = makeAppDelegate() + delegate.isHandlingFileOpen = true + delegate.fileOpenSuppressionCount = 1 + + delegate.endFileOpenSuppression() + + #expect(delegate.fileOpenSuppressionCount == 0) + #expect(delegate.isHandlingFileOpen == false) + } + + @Test("endFileOpenSuppression — keeps flag true while count > 0") + func endSuppressionKeepsFlagWhilePositive() { + let delegate = makeAppDelegate() + delegate.isHandlingFileOpen = true + delegate.fileOpenSuppressionCount = 2 + + delegate.endFileOpenSuppression() + + #expect(delegate.fileOpenSuppressionCount == 1) + #expect(delegate.isHandlingFileOpen == true) + } + + // MARK: - Main Window Becomes Key + + @Test("windowDidBecomeKey — main window appearing closes welcome during file open") + func windowDidBecomeKeyMainWindowClosesWelcome() { + let delegate = makeAppDelegate() + delegate.isHandlingFileOpen = true + + let welcome = makeWindow(identifier: "welcome") + welcome.orderFront(nil) + defer { welcome.close() } + + let mainWin = makeWindow(identifier: "main") + mainWin.orderFront(nil) + defer { mainWin.close() } + + // Simulate main window becoming key — should close welcome + let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: mainWin) + delegate.windowDidBecomeKey(notification) + + #expect(!welcome.isVisible) + } + + @Test("windowDidBecomeKey — main window does not close welcome when not handling file open") + func windowDidBecomeKeyMainWindowNoEffectWhenNotHandling() { + let delegate = makeAppDelegate() + delegate.isHandlingFileOpen = false + + let welcome = makeWindow(identifier: "welcome") + welcome.orderFront(nil) + defer { welcome.close() } + + let mainWin = makeWindow(identifier: "main") + mainWin.orderFront(nil) + defer { mainWin.close() } + + let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: mainWin) + delegate.windowDidBecomeKey(notification) + + // Welcome should remain visible — no suppression active + #expect(welcome.isVisible) + } +}