Skip to content
103 changes: 52 additions & 51 deletions TablePro/AppDelegate+WindowConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.consumeOldestPendingConnectionId()
}

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)
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
89 changes: 46 additions & 43 deletions TablePro/Core/Services/Infrastructure/SessionStateFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
72 changes: 33 additions & 39 deletions TablePro/Core/Services/Infrastructure/WindowOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,55 +15,49 @@ 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?

/// 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?
/// 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.
/// Stores the connectionId so AppDelegate can set the correct tabbingIdentifier.
/// 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) {
pendingConnectionId = payload.connectionId
guard let openWindow else {
Self.logger.warning("openNativeTab called before openWindow was set — payload dropped")
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)
}

/// 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.removeAll { $0.id == id }
}

/// Consumes and returns the connectionId for the oldest pending payload.
/// 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
}
}

/// 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)"
}
}
Loading
Loading