From 71604e462f450049bc5937a3b8eb3668645039b6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 23:13:36 +0700 Subject: [PATCH 1/7] refactor: replace pendingConnectionId with TabIntent enum and pendingPayloads registry - Add TabIntent enum (.openContent, .newEmptyTab, .restoreOrDefault) to EditorTabPayload - Replace race-prone pendingConnectionId singleton with pendingPayloads dictionary - Add connectionId(fromWindow:) reverse lookup to WindowLifecycleMonitor - Add WindowOpener.tabbingIdentifier(for:) static helper --- .../WindowLifecycleMonitor.swift | 6 ++ .../Infrastructure/WindowOpener.swift | 58 ++++++++----------- TablePro/Models/Query/EditorTabPayload.swift | 56 ++++++++++++++---- 3 files changed, 76 insertions(+), 44 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index cbd6e6c52..fdc6adf78 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -119,6 +119,12 @@ internal final class WindowLifecycleMonitor { return entries[windowId]?.connectionId } + /// Returns the connectionId associated with the given NSWindow, if registered. + internal func connectionId(fromWindow window: NSWindow) -> UUID? { + purgeStaleEntries() + return entries.values.first(where: { $0.window === window })?.connectionId + } + /// Check if any windows are registered for a connection. internal func hasWindows(for connectionId: UUID) -> Bool { purgeStaleEntries() diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index 926aa83c9..75ac895e2 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -18,52 +18,44 @@ internal final class WindowOpener { /// Set by ContentView when it appears. Safe to store — OpenWindowAction is app-scoped, not view-scoped. internal var openWindow: OpenWindowAction? - /// The connectionId for the next window about to be opened. - /// Set by `openNativeTab` before calling `openWindow`, consumed by - /// `AppDelegate.windowDidBecomeKey` to set the correct `tabbingIdentifier`. - internal var pendingConnectionId: UUID? + /// Payloads for windows that have been requested but not yet acknowledged + /// by MainContentView.configureWindow. Keyed by payload.id. + /// Stores connectionId so windowDidBecomeKey can compute tabbingIdentifier + /// synchronously (before SwiftUI renders) to avoid flicker. + internal private(set) var pendingPayloads: [UUID: UUID] = [:] // [payloadId: connectionId] + + /// Whether any payloads are pending — used for orphan detection in windowDidBecomeKey. + internal var hasPendingPayloads: Bool { !pendingPayloads.isEmpty } /// Opens a new native window tab with the given payload. - /// Stores the connectionId so AppDelegate can set the correct tabbingIdentifier. internal func openNativeTab(_ payload: EditorTabPayload) { - pendingConnectionId = payload.connectionId + pendingPayloads[payload.id] = payload.connectionId guard let openWindow else { Self.logger.warning("openNativeTab called before openWindow was set — payload dropped") + pendingPayloads.removeValue(forKey: payload.id) return } openWindow(id: "main", value: payload) } - /// Returns and clears the pending connectionId (consume-once pattern). - internal func consumePendingConnectionId() -> UUID? { - defer { pendingConnectionId = nil } - return pendingConnectionId + /// Called by MainContentView.configureWindow after the window is fully set up. + internal func acknowledgePayload(_ id: UUID) { + pendingPayloads.removeValue(forKey: id) + } + + /// Consumes and returns the connectionId for the oldest pending payload. + /// Removes the entry so subsequent calls don't return stale data. + internal func consumeAnyPendingConnectionId() -> UUID? { + guard let first = pendingPayloads.first else { return nil } + pendingPayloads.removeValue(forKey: first.key) + return first.value } -} -/// Pure logic for resolving the tabbingIdentifier for a new main window. -/// Extracted for testability — no AppKit dependencies. -internal enum TabbingIdentifierResolver { - /// Resolve the tabbingIdentifier for a new main window. - /// - Parameters: - /// - pendingConnectionId: The connectionId from WindowOpener (if a tab was just opened) - /// - existingIdentifier: The tabbingIdentifier from an existing visible main window (if any) - /// - groupAllConnections: When true, all windows share one tab group regardless of connection - /// - Returns: The tabbingIdentifier to assign to the new window - internal static func resolve( - pendingConnectionId: UUID?, - existingIdentifier: String?, - groupAllConnections: Bool = false - ) -> String { - if groupAllConnections { + /// Returns the tabbingIdentifier for a connection. + internal static func tabbingIdentifier(for connectionId: UUID) -> String { + if AppSettingsManager.shared.tabs.groupAllConnectionTabs { return "com.TablePro.main" } - if let connectionId = pendingConnectionId { - return "com.TablePro.main.\(connectionId.uuidString)" - } - if let existing = existingIdentifier { - return existing - } - return "com.TablePro.main" + return "com.TablePro.main.\(connectionId.uuidString)" } } diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index fdd7888db..f7891e473 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -8,6 +8,16 @@ import Foundation +/// Declares the intent behind creating a new window tab. +internal enum TabIntent: String, Codable, Hashable { + /// Open a specific tab with content (table, query with SQL, create-table, etc.) + case openContent + /// Create a new empty query tab (Cmd+T, native "+" button, toolbar "+") + case newEmptyTab + /// First window for a connection — restore tabs from disk or create default + case restoreOrDefault +} + /// Payload passed to each native window tab to identify what content it should display. /// Each window-tab receives this at creation time via `openWindow(id:value:)`. internal struct EditorTabPayload: Codable, Hashable { @@ -37,8 +47,16 @@ internal struct EditorTabPayload: Codable, Hashable { internal let initialFilterState: TabFilterState? /// Source file URL for .sql files opened from disk (used for deduplication) internal let sourceFileURL: URL? - /// Whether this is a Cmd+T new tab (creates default tab eagerly, skips disk restoration) - internal let isNewTab: Bool + /// The intent behind creating this tab + internal let intent: TabIntent + + private enum CodingKeys: String, CodingKey { + case id, connectionId, tabType, tableName, databaseName, schemaName + case initialQuery, isView, showStructure, skipAutoExecute, isPreview + case initialFilterState, sourceFileURL, intent + // Legacy key for backward decoding only + case isNewTab + } internal init( id: UUID = UUID(), @@ -54,7 +72,7 @@ internal struct EditorTabPayload: Codable, Hashable { isPreview: Bool = false, initialFilterState: TabFilterState? = nil, sourceFileURL: URL? = nil, - isNewTab: Bool = false + intent: TabIntent = .openContent ) { self.id = id self.connectionId = connectionId @@ -69,7 +87,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.isPreview = isPreview self.initialFilterState = initialFilterState self.sourceFileURL = sourceFileURL - self.isNewTab = isNewTab + self.intent = intent } internal init(from decoder: Decoder) throws { @@ -87,14 +105,30 @@ internal struct EditorTabPayload: Codable, Hashable { isPreview = try container.decodeIfPresent(Bool.self, forKey: .isPreview) ?? false initialFilterState = try container.decodeIfPresent(TabFilterState.self, forKey: .initialFilterState) sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL) - isNewTab = try container.decodeIfPresent(Bool.self, forKey: .isNewTab) ?? false + if let decodedIntent = try container.decodeIfPresent(TabIntent.self, forKey: .intent) { + intent = decodedIntent + } else { + let legacyNewTab = try container.decodeIfPresent(Bool.self, forKey: .isNewTab) ?? false + intent = legacyNewTab ? .newEmptyTab : .openContent + } } - /// Whether this payload is a "connection-only" payload — just a connectionId - /// with no specific tab content. Used by MainContentView to decide whether - /// to create a default tab or restore tabs from storage. - internal var isConnectionOnly: Bool { - tabType == .query && tableName == nil && initialQuery == nil + internal func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(connectionId, forKey: .connectionId) + try container.encode(tabType, forKey: .tabType) + try container.encodeIfPresent(tableName, forKey: .tableName) + try container.encodeIfPresent(databaseName, forKey: .databaseName) + try container.encodeIfPresent(schemaName, forKey: .schemaName) + try container.encodeIfPresent(initialQuery, forKey: .initialQuery) + try container.encode(isView, forKey: .isView) + try container.encode(showStructure, forKey: .showStructure) + try container.encode(skipAutoExecute, forKey: .skipAutoExecute) + try container.encode(isPreview, forKey: .isPreview) + try container.encodeIfPresent(initialFilterState, forKey: .initialFilterState) + try container.encodeIfPresent(sourceFileURL, forKey: .sourceFileURL) + try container.encode(intent, forKey: .intent) } /// Create a payload from a persisted QueryTab for restoration @@ -112,6 +146,6 @@ internal struct EditorTabPayload: Codable, Hashable { self.isPreview = false self.initialFilterState = nil self.sourceFileURL = tab.sourceFileURL - self.isNewTab = false + self.intent = .openContent } } From 0c050a1bf87132f878190870025da7ca787614d3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 23:13:43 +0700 Subject: [PATCH 2/7] refactor: simplify windowDidBecomeKey, add newWindowForTab for native tab bar + --- TablePro/AppDelegate+WindowConfig.swift | 103 ++++++++++++------------ 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index c39d8210b..102406272 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -62,13 +62,29 @@ extension AppDelegate { openWelcomeWindow() } + @objc func newWindowForTab(_ sender: Any?) { + guard let keyWindow = NSApp.keyWindow, + let connectionId = MainActor.assumeIsolated({ + WindowLifecycleMonitor.shared.connectionId(fromWindow: keyWindow) + }) + else { return } + + let payload = EditorTabPayload( + connectionId: connectionId, + intent: .newEmptyTab + ) + MainActor.assumeIsolated { + WindowOpener.shared.openNativeTab(payload) + } + } + @objc func connectFromDock(_ sender: NSMenuItem) { guard let connectionId = sender.representedObject as? UUID else { return } 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) + let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + WindowOpener.shared.openNativeTab(payload) Task { @MainActor in do { @@ -248,65 +264,50 @@ 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. - // Exception: if the window is already part of a tab group, it was - // attached by our addTabbedWindow call — not a restoration orphan. - // Ordering it out would crash NSWindowStackController. - if pendingId == nil && !isAutoReconnecting { - configuredWindows.insert(windowId) + configuredWindows.insert(windowId) + + let pendingConnectionId = MainActor.assumeIsolated { + WindowOpener.shared.consumeAnyPendingConnectionId() + } + + if pendingConnectionId == nil && !isAutoReconnecting { if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 { - // Already in a tab group — leave it alone return } window.orderOut(nil) return } - let existingIdentifier = NSApp.windows - .first { $0 !== window && isMainWindow($0) && $0.isVisible }? - .tabbingIdentifier - let groupAll = MainActor.assumeIsolated { AppSettingsManager.shared.tabs.groupAllConnectionTabs } - let resolvedIdentifier = TabbingIdentifierResolver.resolve( - pendingConnectionId: pendingId, - existingIdentifier: existingIdentifier, - groupAllConnections: groupAll - ) - window.tabbingIdentifier = resolvedIdentifier - configuredWindows.insert(windowId) + if let connectionId = pendingConnectionId { + let groupAll = MainActor.assumeIsolated { AppSettingsManager.shared.tabs.groupAllConnectionTabs } + let resolvedIdentifier = WindowOpener.tabbingIdentifier(for: connectionId) + window.tabbingIdentifier = resolvedIdentifier - if !NSWindow.allowsAutomaticWindowTabbing { - NSWindow.allowsAutomaticWindowTabbing = true - } - - // Explicitly attach to existing tab group — automatic tabbing - // doesn't work when tabbingIdentifier is set after window creation. - let matchingWindow: NSWindow? - if groupAll { - // When grouping all connections, attach to any visible main window - // and normalize all existing windows' tabbingIdentifiers so future - // windows also match (not just the first one found). - let existingMainWindows = NSApp.windows.filter { - $0 !== window && isMainWindow($0) && $0.isVisible + if !NSWindow.allowsAutomaticWindowTabbing { + NSWindow.allowsAutomaticWindowTabbing = true } - for existing in existingMainWindows { - existing.tabbingIdentifier = resolvedIdentifier + + let matchingWindow: NSWindow? + if groupAll { + let existingMainWindows = NSApp.windows.filter { + $0 !== window && isMainWindow($0) && $0.isVisible + } + for existing in existingMainWindows { + existing.tabbingIdentifier = resolvedIdentifier + } + matchingWindow = existingMainWindows.first + } else { + matchingWindow = NSApp.windows.first { + $0 !== window && isMainWindow($0) && $0.isVisible + && $0.tabbingIdentifier == resolvedIdentifier + } } - matchingWindow = existingMainWindows.first - } else { - matchingWindow = NSApp.windows.first { - $0 !== window && isMainWindow($0) && $0.isVisible - && $0.tabbingIdentifier == resolvedIdentifier + if let existingWindow = matchingWindow { + let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow + targetWindow.addTabbedWindow(window, ordered: .above) + window.makeKeyAndOrderFront(nil) } } - if let existingWindow = matchingWindow { - let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow - targetWindow.addTabbedWindow(window, ordered: .above) - window.makeKeyAndOrderFront(nil) - } } } @@ -354,8 +355,8 @@ extension AppDelegate { Task { @MainActor [weak self] in guard let self else { return } - WindowOpener.shared.pendingConnectionId = connection.id - NotificationCenter.default.post(name: .openMainWindow, object: connection.id) + let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + WindowOpener.shared.openNativeTab(payload) defer { self.isAutoReconnecting = false } do { From 9e22c3b12c51d681dae5450cd853ecbc1ee9241b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 23:13:50 +0700 Subject: [PATCH 3/7] refactor: unify all openWindow call sites through WindowOpener.openNativeTab --- TablePro/TableProApp.swift | 4 ++-- TablePro/ViewModels/WelcomeViewModel.swift | 12 ++++++++---- TablePro/Views/Connection/ConnectionFormView.swift | 12 ++++++++---- TablePro/Views/Main/MainContentCommandActions.swift | 2 +- .../Views/Toolbar/ConnectionSwitcherPopover.swift | 5 +---- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index cb942712e..90c9e40d6 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -605,9 +605,9 @@ private struct OpenWindowHandler: View { } .onReceive(NotificationCenter.default.publisher(for: .openMainWindow)) { notification in if let payload = notification.object as? EditorTabPayload { - openWindow(id: "main", value: payload) + WindowOpener.shared.openNativeTab(payload) } else if let connectionId = notification.object as? UUID { - openWindow(id: "main", value: EditorTabPayload(connectionId: connectionId)) + WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connectionId)) } } .onReceive(NotificationCenter.default.publisher(for: .openSettingsWindow)) { _ in diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index ec7ef7573..d21c0a27d 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -208,8 +208,10 @@ final class WelcomeViewModel { func connectToDatabase(_ connection: DatabaseConnection) { guard let openWindow else { return } - WindowOpener.shared.pendingConnectionId = connection.id - openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) + if WindowOpener.shared.openWindow == nil { + WindowOpener.shared.openWindow = openWindow + } + WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) NSApplication.shared.closeWindows(withId: "welcome") Task { @@ -234,8 +236,10 @@ final class WelcomeViewModel { func connectAfterInstall(_ connection: DatabaseConnection) { guard let openWindow else { return } - WindowOpener.shared.pendingConnectionId = connection.id - openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) + if WindowOpener.shared.openWindow == nil { + WindowOpener.shared.openWindow = openWindow + } + WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) NSApplication.shared.closeWindows(withId: "welcome") Task { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index a3a54305e..f0d7d84b4 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -949,8 +949,10 @@ struct ConnectionFormView: View { } private func connectToDatabase(_ connection: DatabaseConnection) { - WindowOpener.shared.pendingConnectionId = connection.id - openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) + if WindowOpener.shared.openWindow == nil { + WindowOpener.shared.openWindow = openWindow + } + WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) NSApplication.shared.closeWindows(withId: "welcome") Task { @@ -990,8 +992,10 @@ struct ConnectionFormView: View { } private func connectAfterInstall(_ connection: DatabaseConnection) { - WindowOpener.shared.pendingConnectionId = connection.id - openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) + if WindowOpener.shared.openWindow == nil { + WindowOpener.shared.openWindow = openWindow + } + WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) NSApplication.shared.closeWindows(withId: "welcome") Task { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 1eaa26173..bc00dd865 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -297,7 +297,7 @@ final class MainContentCommandActions { connectionId: connection.id, tabType: .query, initialQuery: initialQuery, - isNewTab: true + intent: .newEmptyTab ) WindowOpener.shared.openNativeTab(payload) } diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index 4d7a71a24..e7f2438d8 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -344,16 +344,13 @@ struct ConnectionSwitcherPopover: View { /// (unless the user opted to group all connections in one window). private func openWindowForDifferentConnection(_ payload: EditorTabPayload) { if AppSettingsManager.shared.tabs.groupAllConnectionTabs { - // Let the window merge into the existing tab group WindowOpener.shared.openNativeTab(payload) } else { // Temporarily disable tab merging so the new window opens independently 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 + WindowOpener.shared.openNativeTab(payload) DispatchQueue.main.async { currentWindow?.tabbingMode = previousMode } From 4dd51c0d4908d755fbaef3df5b451cbc86dddb42 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 23:13:57 +0700 Subject: [PATCH 4/7] refactor: intent-based tab creation, eliminate timing hacks in tab restore --- TablePro/ContentView.swift | 7 +- .../Infrastructure/SessionStateFactory.swift | 89 ++++++++++--------- TablePro/Views/Main/MainContentView.swift | 57 +++++------- 3 files changed, 75 insertions(+), 78 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 11d5425ba..ac8772991 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -49,11 +49,14 @@ struct ContentView: View { } _windowTitle = State(initialValue: defaultTitle) - // For Cmd+T (new tab), the session already exists. Resolve synchronously - // to avoid the "Connecting..." flash while waiting for async onChange. + // Resolve session synchronously to avoid "Connecting..." flash. + // For payload with connectionId: look up that specific session. + // For nil payload (native tab bar "+"): fall back to current session. var resolvedSession: ConnectionSession? if let connectionId = payload?.connectionId { resolvedSession = DatabaseManager.shared.activeSessions[connectionId] + } else if let currentId = DatabaseManager.shared.currentSessionId { + resolvedSession = DatabaseManager.shared.activeSessions[currentId] } _currentSession = State(initialValue: resolvedSession) diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 053dda09c..9738a05e3 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -48,54 +48,57 @@ enum SessionStateFactory { toolbarSt.databaseName = String(dbIndex) } - // Initialize single tab based on payload. - // For isConnectionOnly (Cmd+T new tab), create a default query tab eagerly - // so MainContentView doesn't flash "No tabs open" before initializeAndRestoreTabs runs. - if let payload, !payload.isConnectionOnly { - switch payload.tabType { - case .table: - if let tableName = payload.tableName { - if payload.isPreview { - tabMgr.addPreviewTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? connection.database - ) - } else { - tabMgr.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? connection.database - ) - } - if let index = tabMgr.selectedTabIndex { - tabMgr.tabs[index].isView = payload.isView - tabMgr.tabs[index].isEditable = !payload.isView - tabMgr.tabs[index].schemaName = payload.schemaName - if payload.showStructure { - tabMgr.tabs[index].showStructure = true + if let payload { + switch payload.intent { + case .openContent: + switch payload.tabType { + case .table: + toolbarSt.isTableTab = true + if let tableName = payload.tableName { + if payload.isPreview { + tabMgr.addPreviewTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) + } else { + tabMgr.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) } - if let initialFilter = payload.initialFilterState { - tabMgr.tabs[index].filterState = initialFilter - filterMgr.restoreFromTabState(initialFilter) + if let index = tabMgr.selectedTabIndex { + tabMgr.tabs[index].isView = payload.isView + tabMgr.tabs[index].isEditable = !payload.isView + tabMgr.tabs[index].schemaName = payload.schemaName + if payload.showStructure { + tabMgr.tabs[index].showStructure = true + } + if let initialFilter = payload.initialFilterState { + tabMgr.tabs[index].filterState = initialFilter + filterMgr.restoreFromTabState(initialFilter) + } } + } else { + tabMgr.addTab(databaseName: payload.databaseName ?? connection.database) } - } else { - tabMgr.addTab(databaseName: payload.databaseName ?? connection.database) + case .query: + tabMgr.addTab( + initialQuery: payload.initialQuery, + databaseName: payload.databaseName ?? connection.database, + sourceFileURL: payload.sourceFileURL + ) + case .createTable: + tabMgr.addCreateTableTab( + databaseName: payload.databaseName ?? connection.database + ) } - case .query: - tabMgr.addTab( - initialQuery: payload.initialQuery, - databaseName: payload.databaseName ?? connection.database, - sourceFileURL: payload.sourceFileURL - ) - case .createTable: - tabMgr.addCreateTableTab( - databaseName: payload.databaseName ?? connection.database - ) + case .newEmptyTab: + tabMgr.addTab(databaseName: payload.databaseName ?? connection.database) + case .restoreOrDefault: + break } - } else if payload?.isNewTab == true { - tabMgr.addTab(databaseName: payload?.databaseName ?? connection.database) } let coord = MainContentCoordinator( diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 5543ff3d2..f13187b05 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -452,18 +452,18 @@ struct MainContentView: View { hasInitialized = true Task { await coordinator.loadSchemaIfNeeded() } - // If payload provided a specific tab (not connection-only), execute its query immediately - if let payload, !payload.isConnectionOnly { - if payload.skipAutoExecute { - // Don't execute now — query will fire when user clicks this tab - // (handled by didBecomeKeyNotification) - return - } + guard let payload else { + await handleRestoreOrDefault() + return + } + + switch payload.intent { + case .openContent: + if payload.skipAutoExecute { return } if let selectedTab = tabManager.selectedTab, selectedTab.tabType == .table, !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - // Fast path: connection already ready if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { @@ -476,7 +476,6 @@ struct MainContentView: View { let tableName = selectedTab.tableName, let tabIndex = tabManager.selectedTabIndex { - // columns is [] on initial load — buildFilteredQuery uses SELECT * let filteredQuery = coordinator.queryBuilder.buildFilteredQuery( tableName: tableName, filters: selectedTab.filterState.appliedFilters, @@ -492,19 +491,22 @@ struct MainContentView: View { coordinator.executeTableTabQueryDirectly() } } else { - // Reactive path: fires via onChange(of: sessionVersion) when connection is ready coordinator.needsLazyLoad = true } } if let sourceURL = payload.sourceFileURL { WindowLifecycleMonitor.shared.registerSourceFile(sourceURL, windowId: windowId) } + + case .newEmptyTab: return + + case .restoreOrDefault: + await handleRestoreOrDefault() } + } - // Connection-only payload or nil payload -- restore tabs from storage - // If other windows already exist for this connection, this is a "new tab" - // from the native macOS "+" button -- just add a single empty query tab. + private func handleRestoreOrDefault() async { if WindowLifecycleMonitor.shared.hasOtherWindows(for: connection.id, excluding: windowId) { if tabManager.tabs.isEmpty { tabManager.addTab(databaseName: connection.database) @@ -512,12 +514,8 @@ struct MainContentView: View { return } - // No existing windows -- restore tabs from storage (first window on connection) let result = await coordinator.persistence.restoreFromDisk() if !result.tabs.isEmpty { - // Rebuild base queries for table tabs to strip stale filter/sort WHERE clauses. - // Filter state is not persisted, so the stored query may contain orphaned conditions - // that reference columns from a different schema — causing errors on restore. var restoredTabs = result.tabs for i in restoredTabs.indices where restoredTabs[i].tabType == .table { if let tableName = restoredTabs[i].tableName { @@ -529,41 +527,31 @@ struct MainContentView: View { } } - // Find the selected tab, or use the first one let selectedId = result.selectedTabId let selectedIndex = restoredTabs.firstIndex(where: { $0.id == selectedId }) ?? 0 - // Keep only the selected tab for this window let selectedTab = restoredTabs[selectedIndex] tabManager.tabs = [selectedTab] tabManager.selectedTabId = selectedTab.id - // Open remaining tabs as new native window-tabs let remainingTabs = restoredTabs.enumerated() .filter { $0.offset != selectedIndex } .map(\.element) if !remainingTabs.isEmpty { - // Delay to let the first window finish setup Task { @MainActor in - try? await Task.sleep(nanoseconds: 100_000_000) for tab in remainingTabs { - let payload = EditorTabPayload( + let restorePayload = EditorTabPayload( from: tab, connectionId: connection.id, skipAutoExecute: true) - WindowOpener.shared.openNativeTab(payload) - // Small delay between opens to avoid overwhelming AppKit - try? await Task.sleep(nanoseconds: 50_000_000) + WindowOpener.shared.openNativeTab(restorePayload) } - // Re-activate the selected tab's window so it stays in front viewWindow?.makeKeyAndOrderFront(nil) } } - // Execute query for the selected tab if it's a table tab if selectedTab.tabType == .table, !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - // Fast path: connection already ready if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { @@ -578,7 +566,6 @@ struct MainContentView: View { coordinator.executeTableTabQueryDirectly() } } else { - // Reactive path: fires via onChange(of: sessionVersion) when connection is ready coordinator.needsLazyLoad = true } } @@ -623,9 +610,9 @@ struct MainContentView: View { } else { window.subtitle = connection.name } - window.tabbingIdentifier = AppSettingsManager.shared.tabs.groupAllConnectionTabs - ? "com.TablePro.main" - : "com.TablePro.main.\(connection.id.uuidString)" + + let resolvedId = WindowOpener.tabbingIdentifier(for: connection.id) + window.tabbingIdentifier = resolvedId window.tabbingMode = .preferred coordinator.windowId = windowId @@ -639,6 +626,10 @@ struct MainContentView: View { coordinator.contentWindow = window isKeyWindow = window.isKeyWindow + if let payloadId = payload?.id { + WindowOpener.shared.acknowledgePayload(payloadId) + } + // Native proxy icon (Cmd+click shows path in Finder) and dirty dot window.representedURL = tabManager.selectedTab?.sourceFileURL window.isDocumentEdited = tabManager.selectedTab?.isFileDirty ?? false From f0ca66e035818bb9a16ff6d988c1d44b9e5234be Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 23:14:05 +0700 Subject: [PATCH 5/7] fix: show loading spinner instead of empty state while sidebar tables load --- TablePro/ViewModels/SidebarViewModel.swift | 12 +++++------- TablePro/Views/Sidebar/SidebarView.swift | 6 +++++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 00b449c76..893c09e65 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -158,13 +158,11 @@ final class SidebarViewModel { NSLog("[SidebarVM] onAppear: tables not empty (%d), skipping", tables.count) return } - Task { @MainActor in - if DatabaseManager.shared.driver(for: connectionId) != nil { - NSLog("[SidebarVM] onAppear: driver found, loading tables") - loadTables() - } else { - NSLog("[SidebarVM] onAppear: driver is nil for %@", connectionId.uuidString) - } + if DatabaseManager.shared.driver(for: connectionId) != nil { + NSLog("[SidebarVM] onAppear: driver found, loading tables") + loadTables() + } else { + NSLog("[SidebarVM] onAppear: driver is nil for %@", connectionId.uuidString) } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 1fe59a74d..adf202b20 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -149,7 +149,7 @@ struct SidebarView: View { private var tablesContent: some View { if let error = viewModel.errorMessage { errorState(message: error) - } else if tables.isEmpty && viewModel.isLoading { + } else if tables.isEmpty && hasActiveConnection { loadingState } else if tables.isEmpty { emptyState @@ -158,6 +158,10 @@ struct SidebarView: View { } } + private var hasActiveConnection: Bool { + viewModel.isLoading || DatabaseManager.shared.driver(for: connectionId) != nil + } + private var loadingState: some View { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) From 8c222c58d6fbbebc33d93d528ba4f7bd65ff6711 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 23:14:10 +0700 Subject: [PATCH 6/7] test: update tests for TabIntent enum and pendingPayloads API --- .../Services/WindowTabGroupingTests.swift | 132 +++--------------- .../Models/SQLFileDeduplicationTests.swift | 6 +- .../Views/Main/SessionStateFactoryTests.swift | 2 +- 3 files changed, 27 insertions(+), 113 deletions(-) diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift index 09a7e2cc4..d6fe94828 100644 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ b/TableProTests/Core/Services/WindowTabGroupingTests.swift @@ -5,7 +5,7 @@ // Tests for correct window tab grouping behavior: // - Same-connection tabs merge into the same window // - Different-connection tabs stay in separate windows -// - WindowOpener tracks pending connectionId for AppDelegate +// - WindowOpener tracks pending payloads for tab-group attachment // import Foundation @@ -16,86 +16,47 @@ import Testing @Suite("WindowTabGrouping") @MainActor struct WindowTabGroupingTests { - // MARK: - WindowOpener pending connectionId + // MARK: - WindowOpener pending payload tracking - @Test("openNativeTab sets pendingConnectionId from payload") - func openNativeTabSetsPendingConnectionId() { + @Test("openNativeTab without openWindow action drops payload and removes from pending") + func openNativeTabWithoutOpenWindowDropsPayload() { let connectionId = UUID() let opener = WindowOpener.shared - // No openWindow action set, so it won't actually open — but pendingConnectionId should be set opener.openWindow = nil let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: "users") opener.openNativeTab(payload) - #expect(opener.pendingConnectionId == connectionId) + #expect(opener.pendingPayloads[payload.id] == nil) } - @Test("pendingConnectionId is nil initially") - func pendingConnectionIdNilInitially() { + @Test("pendingPayloads is empty initially") + func pendingPayloadsEmptyInitially() { let opener = WindowOpener.shared - opener.pendingConnectionId = nil + for id in opener.pendingPayloads.keys { + opener.acknowledgePayload(id) + } - #expect(opener.pendingConnectionId == nil) + #expect(opener.pendingPayloads.isEmpty) } - @Test("consumePendingConnectionId returns and clears the value") - func consumePendingConnectionIdReturnsAndClears() { - let connectionId = UUID() - let opener = WindowOpener.shared - opener.pendingConnectionId = connectionId - - let consumed = opener.consumePendingConnectionId() - - #expect(consumed == connectionId) - #expect(opener.pendingConnectionId == nil) - } - - @Test("consumePendingConnectionId returns nil when nothing pending") - func consumePendingConnectionIdReturnsNilWhenEmpty() { + @Test("acknowledgePayload removes the id from pending") + func acknowledgePayloadRemovesId() { let opener = WindowOpener.shared - opener.pendingConnectionId = nil - - let consumed = opener.consumePendingConnectionId() + let payloadId = UUID() - #expect(consumed == nil) + opener.acknowledgePayload(payloadId) + #expect(opener.pendingPayloads[payloadId] == nil) } // MARK: - TabbingIdentifier resolution - @Test("tabbingIdentifier uses pending connectionId when available") - func tabbingIdentifierUsesPendingConnectionId() { - let connectionId = UUID() - let expected = "com.TablePro.main.\(connectionId.uuidString)" - - let result = TabbingIdentifierResolver.resolve(pendingConnectionId: connectionId, existingIdentifier: nil) - - #expect(result == expected) - } - - @Test("tabbingIdentifier falls back to existing window identifier when no pending") - func tabbingIdentifierFallsBackToExistingWindow() { - let existingId = "com.TablePro.main.AAAA-BBBB" - - let result = TabbingIdentifierResolver.resolve(pendingConnectionId: nil, existingIdentifier: existingId) - - #expect(result == existingId) - } - - @Test("tabbingIdentifier uses generic default when no pending and no existing window") - func tabbingIdentifierUsesGenericDefault() { - let result = TabbingIdentifierResolver.resolve(pendingConnectionId: nil, existingIdentifier: nil) - - #expect(result == "com.TablePro.main") - } - - @Test("tabbingIdentifier prefers pending connectionId over existing window") - func tabbingIdentifierPrefersPendingOverExisting() { + @Test("tabbingIdentifier produces connection-specific identifier") + func tabbingIdentifierUsesConnectionId() { let connectionId = UUID() let expected = "com.TablePro.main.\(connectionId.uuidString)" - let existingId = "com.TablePro.main.DIFFERENT" - let result = TabbingIdentifierResolver.resolve(pendingConnectionId: connectionId, existingIdentifier: existingId) + let result = WindowOpener.tabbingIdentifier(for: connectionId) #expect(result == expected) } @@ -107,8 +68,8 @@ struct WindowTabGroupingTests { let connectionA = UUID() let connectionB = UUID() - let idA = TabbingIdentifierResolver.resolve(pendingConnectionId: connectionA, existingIdentifier: nil) - let idB = TabbingIdentifierResolver.resolve(pendingConnectionId: connectionB, existingIdentifier: nil) + let idA = WindowOpener.tabbingIdentifier(for: connectionA) + let idB = WindowOpener.tabbingIdentifier(for: connectionB) #expect(idA != idB) #expect(idA.contains(connectionA.uuidString)) @@ -119,56 +80,9 @@ struct WindowTabGroupingTests { func sameConnectionProducesSameIdentifier() { let connectionId = UUID() - let id1 = TabbingIdentifierResolver.resolve(pendingConnectionId: connectionId, existingIdentifier: nil) - let id2 = TabbingIdentifierResolver.resolve(pendingConnectionId: connectionId, existingIdentifier: nil) + let id1 = WindowOpener.tabbingIdentifier(for: connectionId) + let id2 = WindowOpener.tabbingIdentifier(for: connectionId) #expect(id1 == id2) } - - @Test("Opening table tab for connection B while connection A window exists uses B's identifier") - func openingTabForConnectionBUsesCorrectIdentifier() { - let connectionA = UUID() - let connectionB = UUID() - let existingWindowIdentifier = "com.TablePro.main.\(connectionA.uuidString)" - - // When opening a tab for connection B, the pending connectionId should be B - // This should produce B's identifier, NOT copy A's identifier - let result = TabbingIdentifierResolver.resolve( - pendingConnectionId: connectionB, - existingIdentifier: existingWindowIdentifier - ) - - #expect(result == "com.TablePro.main.\(connectionB.uuidString)") - #expect(result != existingWindowIdentifier) - } - - // MARK: - groupAllConnections - - @Test("groupAllConnections returns shared identifier regardless of connectionId") - func groupAllConnectionsReturnsSharedIdentifier() { - let connectionA = UUID() - let connectionB = UUID() - - let idA = TabbingIdentifierResolver.resolve( - pendingConnectionId: connectionA, existingIdentifier: nil, groupAllConnections: true - ) - let idB = TabbingIdentifierResolver.resolve( - pendingConnectionId: connectionB, existingIdentifier: nil, groupAllConnections: true - ) - - #expect(idA == "com.TablePro.main") - #expect(idB == "com.TablePro.main") - #expect(idA == idB) - } - - @Test("groupAllConnections ignores existingIdentifier") - func groupAllConnectionsIgnoresExistingIdentifier() { - let existing = "com.TablePro.main.SOME-UUID" - - let result = TabbingIdentifierResolver.resolve( - pendingConnectionId: nil, existingIdentifier: existing, groupAllConnections: true - ) - - #expect(result == "com.TablePro.main") - } } diff --git a/TableProTests/Models/SQLFileDeduplicationTests.swift b/TableProTests/Models/SQLFileDeduplicationTests.swift index 3890bc8aa..cdca446a9 100644 --- a/TableProTests/Models/SQLFileDeduplicationTests.swift +++ b/TableProTests/Models/SQLFileDeduplicationTests.swift @@ -117,8 +117,8 @@ struct EditorTabPayloadSourceFileURLTests { #expect(payload.sourceFileURL == url) } - @Test("EditorTabPayload isConnectionOnly is true even with sourceFileURL") - func isConnectionOnlyUnaffectedBySourceFileURL() { + @Test("EditorTabPayload with sourceFileURL still has openContent intent by default") + func sourceFileURLDoesNotChangeIntent() { let url = URL(fileURLWithPath: "/tmp/test.sql") let payload = EditorTabPayload( connectionId: UUID(), @@ -126,7 +126,7 @@ struct EditorTabPayloadSourceFileURLTests { sourceFileURL: url ) - #expect(payload.isConnectionOnly == true) + #expect(payload.intent == .openContent) } } diff --git a/TableProTests/Views/Main/SessionStateFactoryTests.swift b/TableProTests/Views/Main/SessionStateFactoryTests.swift index 208121318..aaf2ecc62 100644 --- a/TableProTests/Views/Main/SessionStateFactoryTests.swift +++ b/TableProTests/Views/Main/SessionStateFactoryTests.swift @@ -137,7 +137,7 @@ struct SessionStateFactoryTests { @MainActor func connectionOnlyPayload_isNewTab_createsDefaultTab() { let conn = TestFixtures.makeConnection() - let payload = EditorTabPayload(connectionId: conn.id, tabType: .query, isNewTab: true) + let payload = EditorTabPayload(connectionId: conn.id, tabType: .query, intent: .newEmptyTab) let state = SessionStateFactory.create(connection: conn, payload: payload) From d69f475d73a262caeb74e0af60aaf1a399d2a84c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 23:35:14 +0700 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?ordered=20FIFO=20queue,=20cold-launch=20fallback,=20restore=20c?= =?UTF-8?q?omments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/AppDelegate+WindowConfig.swift | 2 +- .../Infrastructure/WindowOpener.swift | 40 ++++++++++--------- TablePro/Views/Main/MainContentView.swift | 2 + .../Services/WindowTabGroupingTests.swift | 38 +++++++++++++++--- 4 files changed, 56 insertions(+), 26 deletions(-) diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index 102406272..07e77da59 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -267,7 +267,7 @@ extension AppDelegate { configuredWindows.insert(windowId) let pendingConnectionId = MainActor.assumeIsolated { - WindowOpener.shared.consumeAnyPendingConnectionId() + WindowOpener.shared.consumeOldestPendingConnectionId() } if pendingConnectionId == nil && !isAutoReconnecting { diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index 75ac895e2..d85bfafd4 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -3,7 +3,7 @@ // TablePro // // Bridges SwiftUI's openWindow environment action to imperative code. -// Stored by ContentView on appear so MainContentCommandActions can open native tabs. +// Stored on appear by ContentView, WelcomeViewModel, or ConnectionFormView. // import os @@ -15,40 +15,42 @@ internal final class WindowOpener { internal static let shared = WindowOpener() - /// Set by ContentView when it appears. Safe to store — OpenWindowAction is app-scoped, not view-scoped. + /// Set on appear by ContentView, WelcomeViewModel, or ConnectionFormView. + /// Safe to store — OpenWindowAction is app-scoped, not view-scoped. internal var openWindow: OpenWindowAction? - /// Payloads for windows that have been requested but not yet acknowledged - /// by MainContentView.configureWindow. Keyed by payload.id. - /// Stores connectionId so windowDidBecomeKey can compute tabbingIdentifier - /// synchronously (before SwiftUI renders) to avoid flicker. - internal private(set) var pendingPayloads: [UUID: UUID] = [:] // [payloadId: connectionId] + /// Ordered queue of pending payloads — windows requested via openNativeTab + /// but not yet acknowledged by MainContentView.configureWindow. + /// Ordered so consumeOldestPendingConnectionId returns the correct entry + /// when multiple windows open in quick succession (e.g., tab restore). + internal private(set) var pendingPayloads: [(id: UUID, connectionId: UUID)] = [] /// Whether any payloads are pending — used for orphan detection in windowDidBecomeKey. internal var hasPendingPayloads: Bool { !pendingPayloads.isEmpty } /// Opens a new native window tab with the given payload. + /// Falls back to .openMainWindow notification if openWindow is not yet available + /// (cold launch from Dock menu before any SwiftUI view has appeared). internal func openNativeTab(_ payload: EditorTabPayload) { - pendingPayloads[payload.id] = payload.connectionId - guard let openWindow else { - Self.logger.warning("openNativeTab called before openWindow was set — payload dropped") - pendingPayloads.removeValue(forKey: payload.id) - return + pendingPayloads.append((id: payload.id, connectionId: payload.connectionId)) + if let openWindow { + openWindow(id: "main", value: payload) + } else { + Self.logger.info("openWindow not set — falling back to .openMainWindow notification") + NotificationCenter.default.post(name: .openMainWindow, object: payload) } - openWindow(id: "main", value: payload) } /// Called by MainContentView.configureWindow after the window is fully set up. internal func acknowledgePayload(_ id: UUID) { - pendingPayloads.removeValue(forKey: id) + pendingPayloads.removeAll { $0.id == id } } /// Consumes and returns the connectionId for the oldest pending payload. - /// Removes the entry so subsequent calls don't return stale data. - internal func consumeAnyPendingConnectionId() -> UUID? { - guard let first = pendingPayloads.first else { return nil } - pendingPayloads.removeValue(forKey: first.key) - return first.value + /// Removes the entry so subsequent calls return the next payload in order. + internal func consumeOldestPendingConnectionId() -> UUID? { + guard !pendingPayloads.isEmpty else { return nil } + return pendingPayloads.removeFirst().connectionId } /// Returns the tabbingIdentifier for a connection. diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index f13187b05..0ad94ed28 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -472,6 +472,7 @@ struct MainContentView: View { { Task { await coordinator.switchDatabase(to: selectedTab.databaseName) } } else { + // columns is [] on initial load — buildFilteredQuery uses SELECT * if !selectedTab.filterState.appliedFilters.isEmpty, let tableName = selectedTab.tableName, let tabIndex = tabManager.selectedTabIndex @@ -491,6 +492,7 @@ struct MainContentView: View { coordinator.executeTableTabQueryDirectly() } } else { + // Reactive path: fires via onChange(of: sessionVersion) when connection is ready coordinator.needsLazyLoad = true } } diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift index d6fe94828..cec244e2f 100644 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ b/TableProTests/Core/Services/WindowTabGroupingTests.swift @@ -18,8 +18,8 @@ import Testing struct WindowTabGroupingTests { // MARK: - WindowOpener pending payload tracking - @Test("openNativeTab without openWindow action drops payload and removes from pending") - func openNativeTabWithoutOpenWindowDropsPayload() { + @Test("openNativeTab without openWindow falls back to notification and keeps pending") + func openNativeTabWithoutOpenWindowFallsBack() { let connectionId = UUID() let opener = WindowOpener.shared @@ -27,14 +27,17 @@ struct WindowTabGroupingTests { let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: "users") opener.openNativeTab(payload) - #expect(opener.pendingPayloads[payload.id] == nil) + // Payload stays pending (notification handler will create the window) + #expect(opener.pendingPayloads.contains { $0.id == payload.id }) + // Clean up + opener.acknowledgePayload(payload.id) } @Test("pendingPayloads is empty initially") func pendingPayloadsEmptyInitially() { let opener = WindowOpener.shared - for id in opener.pendingPayloads.keys { - opener.acknowledgePayload(id) + for entry in opener.pendingPayloads { + opener.acknowledgePayload(entry.id) } #expect(opener.pendingPayloads.isEmpty) @@ -46,7 +49,30 @@ struct WindowTabGroupingTests { let payloadId = UUID() opener.acknowledgePayload(payloadId) - #expect(opener.pendingPayloads[payloadId] == nil) + #expect(!opener.pendingPayloads.contains { $0.id == payloadId }) + } + + @Test("consumeOldestPendingConnectionId returns in FIFO order") + func consumeOldestReturnsFIFO() { + let opener = WindowOpener.shared + // Clear any stale state + while opener.consumeOldestPendingConnectionId() != nil {} + + let idA = UUID() + let idB = UUID() + let payloadA = EditorTabPayload(connectionId: idA, tabType: .query) + let payloadB = EditorTabPayload(connectionId: idB, tabType: .query) + + opener.openWindow = nil + opener.openNativeTab(payloadA) + opener.openNativeTab(payloadB) + + let first = opener.consumeOldestPendingConnectionId() + let second = opener.consumeOldestPendingConnectionId() + + #expect(first == idA) + #expect(second == idB) + #expect(opener.consumeOldestPendingConnectionId() == nil) } // MARK: - TabbingIdentifier resolution