Skip to content

Commit fea651f

Browse files
committed
fix: prevent welcome window freeze when opening database files from Finder
1 parent a63c497 commit fea651f

3 files changed

Lines changed: 294 additions & 27 deletions

File tree

TablePro/AppDelegate+WindowConfig.swift

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,24 +192,32 @@ extension AppDelegate {
192192

193193
func scheduleWelcomeWindowSuppression() {
194194
Task { @MainActor [weak self] in
195-
try? await Task.sleep(for: .milliseconds(200))
196-
self?.closeWelcomeWindowIfMainExists()
197-
try? await Task.sleep(for: .milliseconds(500))
195+
// Keep trying to close the welcome window until a main window is visible.
196+
// DuckDB and other slow-connecting databases may take several seconds,
197+
// so we poll repeatedly rather than giving up after 700ms.
198+
for attempt in 0 ..< 30 {
199+
try? await Task.sleep(for: .milliseconds(attempt < 4 ? 200 : 500))
200+
guard let self else { return }
201+
if self.closeWelcomeWindowIfMainExists() {
202+
break
203+
}
204+
}
198205
guard let self else { return }
199-
self.closeWelcomeWindowIfMainExists()
200206
self.fileOpenSuppressionCount = max(0, self.fileOpenSuppressionCount - 1)
201207
if self.fileOpenSuppressionCount == 0 {
202208
self.isHandlingFileOpen = false
203209
}
204210
}
205211
}
206212

207-
private func closeWelcomeWindowIfMainExists() {
213+
@discardableResult
214+
private func closeWelcomeWindowIfMainExists() -> Bool {
208215
let hasMainWindow = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
209-
guard hasMainWindow else { return }
216+
guard hasMainWindow else { return false }
210217
for window in NSApp.windows where isWelcomeWindow(window) {
211218
window.close()
212219
}
220+
return true
213221
}
214222

215223
// MARK: - Window Notifications
@@ -219,9 +227,13 @@ extension AppDelegate {
219227
let windowId = ObjectIdentifier(window)
220228

221229
if isWelcomeWindow(window) && isHandlingFileOpen {
222-
window.close()
223-
for mainWin in NSApp.windows where isMainWindow(mainWin) {
230+
// Only close welcome if a main window exists to take its place;
231+
// otherwise just hide it so the user doesn't see a flash.
232+
if let mainWin = NSApp.windows.first(where: { isMainWindow($0) }) {
233+
window.close()
224234
mainWin.makeKeyAndOrderFront(nil)
235+
} else {
236+
window.orderOut(nil)
225237
}
226238
return
227239
}

TablePro/ContentView.swift

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,9 @@ struct ContentView: View {
157157

158158
@ViewBuilder
159159
private var mainContent: some View {
160-
if let currentSession = currentSession, let rightPanelState, let sessionState {
161-
NavigationSplitView(columnVisibility: $columnVisibility) {
162-
// MARK: - Sidebar (Left) - Table Browser
160+
NavigationSplitView(columnVisibility: $columnVisibility) {
161+
// MARK: - Sidebar (Left) - Table Browser
162+
if let currentSession = currentSession, let sessionState {
163163
VStack(spacing: 0) {
164164
SidebarView(
165165
tables: sessionTablesBinding,
@@ -197,9 +197,12 @@ struct ContentView: View {
197197
placement: .sidebar,
198198
prompt: sidebarSearchPrompt(for: currentSession.connection.id)
199199
)
200-
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600)
201-
} detail: {
202-
// MARK: - Detail (Main workspace with optional right sidebar)
200+
} else {
201+
Color.clear
202+
}
203+
} detail: {
204+
// MARK: - Detail (Main workspace with optional right sidebar)
205+
if let currentSession = currentSession, let rightPanelState, let sessionState {
203206
HStack(spacing: 0) {
204207
MainContentView(
205208
connection: currentSession.connection,
@@ -235,21 +238,21 @@ struct ContentView: View {
235238
}
236239
}
237240
.animation(.easeInOut(duration: 0.2), value: rightPanelState.isPresented)
241+
} else {
242+
VStack(spacing: 16) {
243+
ProgressView()
244+
.scaleEffect(1.5)
245+
246+
Text("Connecting...")
247+
.font(.headline)
248+
.foregroundStyle(.secondary)
249+
}
250+
.frame(maxWidth: .infinity, maxHeight: .infinity)
238251
}
239-
.navigationTitle(windowTitle)
240-
.navigationSubtitle(currentSession.connection.name)
241-
} else {
242-
VStack(spacing: 16) {
243-
ProgressView()
244-
.scaleEffect(1.5)
245-
246-
Text("Connecting...")
247-
.font(.headline)
248-
.foregroundStyle(.secondary)
249-
}
250-
.frame(maxWidth: .infinity, maxHeight: .infinity)
251-
.navigationTitle("TablePro")
252252
}
253+
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600)
254+
.navigationTitle(windowTitle)
255+
.navigationSubtitle(currentSession?.connection.name ?? "")
253256
}
254257

255258
// Removed: newConnectionSheet and editConnectionSheet helpers
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
//
2+
// WelcomeWindowSuppressionTests.swift
3+
// TableProTests
4+
//
5+
// Regression tests for the welcome window suppression logic in AppDelegate+WindowConfig.
6+
// Covers the fix where double-clicking .duckdb files from Finder caused the app to freeze
7+
// because suppression gave up too early and welcome was closed instead of hidden.
8+
//
9+
10+
import AppKit
11+
import Foundation
12+
import Testing
13+
@testable import TablePro
14+
15+
@Suite("Welcome Window Suppression")
16+
@MainActor
17+
struct WelcomeWindowSuppressionTests {
18+
/// Create a fresh AppDelegate for each test — avoids relying on NSApp.delegate
19+
/// which may not be our AppDelegate in parallel test runner processes.
20+
private func makeAppDelegate() -> AppDelegate {
21+
AppDelegate()
22+
}
23+
24+
private func makeWindow(identifier: String) -> NSWindow {
25+
let window = NSWindow()
26+
window.identifier = NSUserInterfaceItemIdentifier(identifier)
27+
return window
28+
}
29+
30+
// MARK: - Window Identification
31+
32+
@Test("isMainWindow — exact identifier 'main'")
33+
func isMainWindowExact() {
34+
let delegate = makeAppDelegate()
35+
let window = makeWindow(identifier: "main")
36+
#expect(delegate.isMainWindow(window))
37+
}
38+
39+
@Test("isMainWindow — prefixed identifier 'main-123'")
40+
func isMainWindowPrefixed() {
41+
let delegate = makeAppDelegate()
42+
let window = makeWindow(identifier: "main-123")
43+
#expect(delegate.isMainWindow(window))
44+
}
45+
46+
@Test("isMainWindow — returns false for nil identifier")
47+
func isMainWindowNilIdentifier() {
48+
let delegate = makeAppDelegate()
49+
let window = NSWindow()
50+
window.identifier = nil
51+
#expect(!delegate.isMainWindow(window))
52+
}
53+
54+
@Test("isMainWindow — returns false for 'welcome'")
55+
func isMainWindowUnrelated() {
56+
let delegate = makeAppDelegate()
57+
let window = makeWindow(identifier: "welcome")
58+
#expect(!delegate.isMainWindow(window))
59+
}
60+
61+
@Test("isMainWindow — returns false for 'mainExtra' (no dash separator)")
62+
func isMainWindowNoDash() {
63+
let delegate = makeAppDelegate()
64+
let window = makeWindow(identifier: "mainExtra")
65+
#expect(!delegate.isMainWindow(window))
66+
}
67+
68+
@Test("isWelcomeWindow — exact identifier 'welcome'")
69+
func isWelcomeWindowExact() {
70+
let delegate = makeAppDelegate()
71+
let window = makeWindow(identifier: "welcome")
72+
#expect(delegate.isWelcomeWindow(window))
73+
}
74+
75+
@Test("isWelcomeWindow — prefixed identifier 'welcome-abc'")
76+
func isWelcomeWindowPrefixed() {
77+
let delegate = makeAppDelegate()
78+
let window = makeWindow(identifier: "welcome-abc")
79+
#expect(delegate.isWelcomeWindow(window))
80+
}
81+
82+
@Test("isWelcomeWindow — returns false for nil identifier")
83+
func isWelcomeWindowNilIdentifier() {
84+
let delegate = makeAppDelegate()
85+
let window = NSWindow()
86+
window.identifier = nil
87+
#expect(!delegate.isWelcomeWindow(window))
88+
}
89+
90+
@Test("isWelcomeWindow — returns false for 'main'")
91+
func isWelcomeWindowNotMain() {
92+
let delegate = makeAppDelegate()
93+
let window = makeWindow(identifier: "main")
94+
#expect(!delegate.isWelcomeWindow(window))
95+
}
96+
97+
@Test("isWelcomeWindow — returns false for 'welcomeExtra' (no dash separator)")
98+
func isWelcomeWindowNoDash() {
99+
let delegate = makeAppDelegate()
100+
let window = makeWindow(identifier: "welcomeExtra")
101+
#expect(!delegate.isWelcomeWindow(window))
102+
}
103+
104+
// MARK: - suppressWelcomeWindow State
105+
106+
@Test("suppressWelcomeWindow — sets isHandlingFileOpen to true")
107+
func suppressSetsFlag() {
108+
let delegate = makeAppDelegate()
109+
delegate.suppressWelcomeWindow()
110+
#expect(delegate.isHandlingFileOpen == true)
111+
}
112+
113+
@Test("suppressWelcomeWindow — increments fileOpenSuppressionCount")
114+
func suppressIncrementsCount() {
115+
let delegate = makeAppDelegate()
116+
delegate.suppressWelcomeWindow()
117+
#expect(delegate.fileOpenSuppressionCount == 1)
118+
119+
delegate.suppressWelcomeWindow()
120+
#expect(delegate.fileOpenSuppressionCount == 2)
121+
}
122+
123+
@Test("suppressWelcomeWindow — hides existing welcome windows via orderOut")
124+
func suppressHidesWelcomeWindows() {
125+
let delegate = makeAppDelegate()
126+
127+
let welcome = makeWindow(identifier: "welcome")
128+
welcome.orderFront(nil)
129+
defer { welcome.close() }
130+
131+
#expect(welcome.isVisible)
132+
133+
delegate.suppressWelcomeWindow()
134+
135+
#expect(!welcome.isVisible)
136+
}
137+
138+
// MARK: - windowDidBecomeKey Suppression Behavior
139+
140+
@Test("windowDidBecomeKey — welcome hides (orderOut) when file open and no main window")
141+
func windowDidBecomeKeyHidesWelcomeWhenNoMain() {
142+
let delegate = makeAppDelegate()
143+
delegate.isHandlingFileOpen = true
144+
145+
let welcome = makeWindow(identifier: "welcome")
146+
welcome.orderFront(nil)
147+
defer { welcome.close() }
148+
149+
#expect(welcome.isVisible)
150+
151+
let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome)
152+
delegate.windowDidBecomeKey(notification)
153+
154+
// Key regression fix: welcome should be hidden (not closed) so it can reappear
155+
// when the main window is ready — prevents "no visible windows" freeze
156+
#expect(!welcome.isVisible)
157+
}
158+
159+
@Test("windowDidBecomeKey — welcome closes when file open and main window exists")
160+
func windowDidBecomeKeyClosesWelcomeWhenMainExists() {
161+
let delegate = makeAppDelegate()
162+
delegate.isHandlingFileOpen = true
163+
164+
let mainWin = makeWindow(identifier: "main")
165+
mainWin.orderFront(nil)
166+
defer { mainWin.close() }
167+
168+
let welcome = makeWindow(identifier: "welcome")
169+
welcome.orderFront(nil)
170+
defer { welcome.close() }
171+
172+
let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome)
173+
delegate.windowDidBecomeKey(notification)
174+
175+
#expect(!welcome.isVisible)
176+
}
177+
178+
@Test("windowDidBecomeKey — welcome not suppressed when isHandlingFileOpen is false")
179+
func windowDidBecomeKeyNoSuppressionWhenNotHandlingFile() {
180+
let delegate = makeAppDelegate()
181+
delegate.isHandlingFileOpen = false
182+
183+
let welcome = makeWindow(identifier: "welcome")
184+
welcome.orderFront(nil)
185+
defer { welcome.close() }
186+
187+
let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome)
188+
delegate.windowDidBecomeKey(notification)
189+
190+
#expect(welcome.isVisible)
191+
}
192+
193+
@Test("windowDidBecomeKey — non-welcome window is not affected by suppression")
194+
func windowDidBecomeKeyIgnoresNonWelcome() {
195+
let delegate = makeAppDelegate()
196+
delegate.isHandlingFileOpen = true
197+
198+
let other = makeWindow(identifier: "settings")
199+
other.orderFront(nil)
200+
defer { other.close() }
201+
202+
let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: other)
203+
delegate.windowDidBecomeKey(notification)
204+
205+
#expect(other.isVisible)
206+
}
207+
208+
// MARK: - Suppression Count State
209+
210+
@Test("Multiple suppress calls — count increments independently")
211+
func multipleSuppressionCountsStack() {
212+
let delegate = makeAppDelegate()
213+
delegate.suppressWelcomeWindow()
214+
delegate.suppressWelcomeWindow()
215+
delegate.suppressWelcomeWindow()
216+
217+
#expect(delegate.fileOpenSuppressionCount == 3)
218+
#expect(delegate.isHandlingFileOpen == true)
219+
}
220+
221+
@Test("fileOpenSuppressionCount decrement to zero resets isHandlingFileOpen")
222+
func countZeroResetsFlag() {
223+
let delegate = makeAppDelegate()
224+
delegate.isHandlingFileOpen = true
225+
delegate.fileOpenSuppressionCount = 1
226+
227+
// Simulate what scheduleWelcomeWindowSuppression does at the end
228+
delegate.fileOpenSuppressionCount = max(0, delegate.fileOpenSuppressionCount - 1)
229+
if delegate.fileOpenSuppressionCount == 0 {
230+
delegate.isHandlingFileOpen = false
231+
}
232+
233+
#expect(delegate.fileOpenSuppressionCount == 0)
234+
#expect(delegate.isHandlingFileOpen == false)
235+
}
236+
237+
@Test("fileOpenSuppressionCount decrement keeps flag true while count > 0")
238+
func countPositiveKeepsFlag() {
239+
let delegate = makeAppDelegate()
240+
delegate.isHandlingFileOpen = true
241+
delegate.fileOpenSuppressionCount = 2
242+
243+
// Simulate one decrement
244+
delegate.fileOpenSuppressionCount = max(0, delegate.fileOpenSuppressionCount - 1)
245+
if delegate.fileOpenSuppressionCount == 0 {
246+
delegate.isHandlingFileOpen = false
247+
}
248+
249+
#expect(delegate.fileOpenSuppressionCount == 1)
250+
#expect(delegate.isHandlingFileOpen == true)
251+
}
252+
}

0 commit comments

Comments
 (0)