Skip to content

Commit bad2466

Browse files
authored
refactor: replace native window tabs with in-app tab bar (#763)
* refactor: replace native window tabs with in-app tab bar for instant switching Native macOS window tabs used addTabbedWindow() which took 600-900ms per call, causing severe lag on Cmd+T and tab restoration. This refactor moves to a single-window-per-connection architecture with a custom SwiftUI tab bar, eliminating the N² view lifecycle cascades during tab restore. * fix: resolve multiple in-app tab bar bugs and clean up stale references - Fix closeInAppTab save button no-op (data loss) - Add unsaved changes check to Close Others / Close All - Fix switchSchema not persisting tabs before clearing - Fix double-tap rename gesture conflict (exclusively before) - Fix Vim :q closing entire window instead of current tab - Show dirty indicator for pending database changes - Fix draggedTabId not cleared on cancelled drag - Fix rename TextField stuck on focus loss - Register SQL files for duplicate detection - Protect preview tabs with pending changes from replacement - Fix promotePreviewTab not clearing WindowLifecycleMonitor flag - Query hasPreview from tabManager instead of stale window monitor - Remove dead code (aggregatedTabs, closeSiblingNativeWindows) - Update 15 stale "native window tab" comments across 9 files - Add debug logging for tab navigation flow * fix: remove PERF debug logging, fix stale native tab references - Remove all 45 [PERF] log statements and timing infrastructure - Remove unused OSSignposter (windowPerfLog) and 5 PERF-only loggers - Fix tabbingMode = .preferred → .disallowed in windowDidBecomeKey - Remove cross-window previewWindow() lookup in sidebar double-click - Replace WindowLifecycleMonitor.setPreview() with direct subtitle update - Remove dead isFirstCoordinatorForConnection() multi-window code - Simplify ConnectionSwitcherPopover tabbingMode manipulation * fix: defer SessionState creation from ContentView.init to view lifecycle SessionStateFactory.create() was called eagerly in ContentView.init, which SwiftUI invokes speculatively during body evaluation. Each call allocated 7 heavy objects (QueryTabManager, MainContentCoordinator, etc.) that were immediately discarded, causing "QueryTabManager deallocated" spam and wasted resources. Consolidated 3 duplicate creation sites into a single ensureSessionState() method, called only from reactive handlers (onChange, onReceive) after the view is committed to the hierarchy. * fix: restore onDisappear grace period, remove race-prone disconnectSession SwiftUI fires onDisappear transiently when the view hierarchy is reconstructed (e.g., sessionState changing from nil to a value causes if-let branches to rebuild). Without a grace period, the immediate coordinator.teardown() + disconnectSession killed the SSH tunnel while the connection was still being established, causing auto-reconnect to fail repeatedly. Fix: restore 200ms grace period with window re-registration check, and remove disconnectSession from onDisappear entirely — WindowLifecycleMonitor .handleWindowClose already handles disconnect on actual NSWindow close. * refactor: move coordinator teardown from onDisappear to NSWindow willCloseNotification SwiftUI's onDisappear fires transiently during view hierarchy reconstruction (e.g., sessionState nil→value causes if-let branches to rebuild). Using it for coordinator teardown caused race conditions where SSH tunnels were killed during auto-reconnect. Apple's recommended pattern for macOS: use NSWindow.willCloseNotification for deterministic resource cleanup, not SwiftUI view lifecycle callbacks. Changes: - WindowLifecycleMonitor: add onWindowClose closure to Entry, called in handleWindowClose before disconnect — deterministic teardown - MainContentView+Setup: pass coordinator/rightPanelState teardown closure to WindowLifecycleMonitor.register() - MainContentView: remove onDisappear teardown (grace period hack gone) - MainContentCoordinator: remove markTeardownScheduled/clearTeardownScheduled and _teardownScheduled lock (no longer needed) - Remove all [RESTORE] and [TAB-NAV] debug logging * fix: persist open connection IDs incrementally per Apple guidelines Apple's documentation: "save data progressively and not rely solely on user actions to save important information." applicationWillTerminate does not fire on SIGKILL (Xcode Cmd+R, Force Quit, memory pressure). Now saves active connection IDs to UserDefaults immediately on connect and disconnect, so auto-reconnect works correctly after any termination. The applicationWillTerminate save is kept as a belt-and-suspenders fallback. * feat: add reopen closed tab, MRU selection, and pinned tabs Reopen Closed Tab (Cmd+Shift+T): - Closed tabs stored in per-window history stack (capped at 20) - Reopened tabs get fresh RowBuffer, data re-fetched on demand MRU Tab Selection: - Track tab activation order in QueryTabManager - On tab close, select the most recently active tab (not adjacent) - Matches browser behavior (Chrome, Safari) Pinned Tabs: - Right-click → Pin/Unpin Tab - Pinned tabs show pin icon, no close button - Always at left side of tab bar, separated by divider - Survive Close Others and Close All - Persisted across sessions via isPinned in PersistedTab * fix: scroll tab bar to active tab on initial load * fix: remove unnecessary Task.yield delay from tab switching Tab switch handler used await Task.yield() to debounce rapid clicks, but this deferred execution until after SwiftUI's body re-evaluation (~100-200ms). The actual handleTabChange work is only 2ms. Switched to synchronous onChange handler — tab switches are now instant. * fix: remove tab-switch row eviction that caused re-fetch delays Row data was evicted on every tab switch (when >2 tabs), then re-fetched when switching back — causing visible delays while waiting for the query. Other DB clients (Beekeeper, DataGrip, TablePlus) keep tab data in memory and only evict under memory pressure. Eviction now only happens: - When the window loses focus (didResignKeyNotification, 5s delay) - Under system memory pressure (MemoryPressureAdvisor) Also removed Task.yield() from tab switch handler — the actual work is 2ms, no debounce needed. * fix: remove window-resign row eviction that caused re-fetch on tab switch Row data was evicted 5s after window resigned key (didResignKeyNotification), then re-fetched when switching back to the tab — causing visible delays. Other DB clients (Beekeeper, DataGrip, TablePlus) keep all tab data in memory until explicit close. Eviction now only happens under system memory pressure via MemoryPressureAdvisor — not on window resign or tab switch. Also removed [TAB-DBG] diagnostic logging. * fix: skip redundant display format detection on cached tab switch cacheRowProvider() always called makeRowProvider() → applyDisplayFormats() even when the cached entry was still valid. This caused synchronous UserDefaults I/O (5-50ms) and format detection (1-5ms) on every tab switch, multiplied by 3 redundant onChange handlers. Now checks cache validity first — if resultVersion, metadataVersion, and sortState match, skips the expensive makeRowProvider entirely. * perf: keep tab views alive across switches (NSTabViewController pattern) SwiftUI's conditional rendering (if let tab { tabContent(for: tab) }) destroyed and recreated the entire DataGridView (NSTableView) and SourceEditor (TreeSitterClient) on every tab switch — ~200ms cost. Replaced with ZStack + ForEach + opacity pattern: all tab views stay alive in the hierarchy, only the active tab is visible. Matches Apple's NSTabViewController behavior where child view controllers are kept alive and only swapped in/out of the visible hierarchy. Tab switch is now instant — no view destruction/recreation, no NSTableView column rebuild, no TreeSitter language parser reinitialization. * perf: eliminate redundant reloadVersion bump and cascading re-evals Two fixes for tab switch performance with ZStack keep-alive: 1. Removed unconditional changeManager.reloadVersion += 1 on tab switch. With ZStack, each tab's DataGridView already has its data — the forced reload caused a redundant 200ms+ NSTableView.reloadData(). 2. Added isHandlingTabSwitch guard to handleTabsChange. Saving outgoing tab state mutates tabManager.tabs, which triggered handleTabsChange → persistence.saveNow → more cascading body re-evaluations. Tab switch reduced from ~680ms (9 body re-evals) to ~90ms (5 re-evals). * perf: two-phase tab switch — instant visual, deferred state restore handleTabChange was mutating 5 @observable objects synchronously (filterStateManager, columnVisibilityManager, toolbarState, changeManager, tabManager.tabs), each triggering a separate SwiftUI body re-evaluation. With ZStack keep-alive, all tab views (active + hidden) re-evaluated on each mutation — 5 cascading passes blocking the visual switch. Split into two phases: - Phase 1 (sync, ~1ms): selectedRowIndices + toolbarState.isTableTab only. SwiftUI flips opacity immediately — user sees instant switch. - Phase 2 (deferred, next frame): save outgoing state + restore shared managers. Invisible to user — 16ms later, managers catch up. Also batch outgoing tab state save into single array write (1 didSet) instead of 2 separate element mutations (2 didSet calls). * fix: cancel previous deferred tab switch on rapid Cmd+1/Cmd+2 spam The deferred Phase 2 tasks were queuing up — each keypress created a new Task that executed in order even after the user stopped pressing. Now cancels the previous tabSwitchTask before creating a new one, so only the final tab switch commits its state restoration. * perf: remove incoming state restoration from tab switch entirely The deferred Phase 2 was still restoring 5 @observable managers (filterStateManager, columnVisibilityManager, changeManager, etc.) causing 16 body re-evaluations over ~960ms after the user stops pressing Cmd+1/Cmd+2. With ZStack keep-alive, each tab's view maintains its own correct state — shared manager reconfiguration is unnecessary. Phase 2 now only saves outgoing tab state (for persistence) and checks for lazy query needs. No @observable mutations on the incoming tab. * debug: add [DBG] logging to trace tab switch body re-evaluation cascade * perf: defer title/sidebar/persist to Phase 2, skip .task during rapid switch Three fixes for tab switch responsiveness: 1. Move updateWindowTitleAndFileState, syncSidebarToCurrentTab, and persistence.saveNow from synchronous handleTabSelectionChange to the deferred Phase 2 task via onTabSwitchSettled callback. These were triggering onChange(selectedTables) → handleTableSelectionChange → another full body eval chain per switch. 2. Guard .task(id: currentTab?.tableName) with isHandlingTabSwitch — during rapid Cmd+1/2/3 switching, 30+ metadata tasks queued up and all executed when the user stopped, causing ~1 second of trailing body re-evaluations. 3. handleTabSelectionChange now only calls handleTabChange (Phase 1) — all other work deferred to Phase 2 which cancels on next switch. * perf: remove .task(id: tableName) that queued 28+ tasks during rapid switch .task(id: currentTab?.tableName) created a new SwiftUI-managed task for every tab switch. During rapid Cmd+1/2/3 spam, 28+ tasks queued up and all executed when the user stopped — each triggering loadTableMetadata. Moved metadata loading to Phase 2's onTabSwitchSettled callback, which is cancellable and only runs for the final settled tab. * perf: zero synchronous mutations on tab switch — fully deferred Removed ALL synchronous @observable mutations from onChange(selectedTabId). The ZStack opacity flip is driven by selectedTabId binding alone — no handleTabChange Phase 1 needed. MRU tracking (lightweight array append) stays synchronous. Everything else (toolbarState, selectedRowIndices, outgoing save, title, sidebar, persist, metadata) is deferred to Phase 2 Task which coalesces rapid Cmd+1/2/3 spam via tabSwitchTask cancellation. During rapid keyboard repeat, the main thread only processes the onChange callback (~0ms) + SwiftUI body eval for opacity change. No @observable mutations means no cascading body re-evaluations. * perf: guard all onChange handlers with isHandlingTabSwitch Each tab switch triggered 6+ onChange handlers (resultColumns, inspectorTrigger, pendingChangeTrigger, EC.resultVersion, EC.metadataVersion, EC.activeResultSetId) which cascaded into 4+ extra body re-evaluations per switch cycle. Now all onChange handlers check isHandlingTabSwitch and return early during tab switching. Also onTabSwitchSettled only runs if the settled tab is still the currently selected tab — prevents stale sidebar sync from triggering onChange(selectedTables) body eval cascade. Target: reduce per-switch cycle from ~280ms to ~80ms (body eval + AppKit opacity layout only, no onChange cascades). * perf: throttle keyboard tab switch commands during active switch selectTab/selectPreviousTab/selectNextTab now check isHandlingTabSwitch and return early if a switch is still being processed. This prevents macOS keyboard repeat events (30ms interval) from queuing 20+ tab switches that continue executing after the user releases the keys. The isHandlingTabSwitch flag is set synchronously in scheduleTabSwitch and cleared in the deferred Phase 2 Task, providing a natural throttle window of ~100ms per switch. * fix: replace aggressive throttle with same-tab dedup for keyboard repeat The isHandlingTabSwitch throttle blocked ALL keyboard events during Phase 2 (~500ms), making the app feel unresponsive. Replaced with: 1. selectTab: skip only if already on target tab (Cmd+1 repeat → skip) 2. selectPreviousTab/selectNextTab: no throttle (always responsive) 3. isHandlingTabSwitch now synchronous-only (defer in scheduleTabSwitch) — true only during the onChange handler, not during Phase 2 Task * refactor: replace ZStack+opacity with AppKit tab container for instant switching Replace SwiftUI ZStack+ForEach+opacity pattern with NSViewRepresentable (TabContentContainerView) that manages one NSHostingView per tab. Tab switching toggles isHidden instead of SwiftUI opacity, eliminating body re-evaluation cascade for all inactive tabs. Key changes: - TabContentContainerView: NSViewRepresentable with per-tab NSHostingView - Tab click via .onTapGesture (removed NSView overlay that blocked close/drag) - Cmd+W intercepted via NSEvent monitor (not window.delegate overwrite) - Phase 1 synchronous outgoing save (no state loss during rapid switching) - Shared manager restore guarded to skip unchanged values - Version-gated rootView rebuild (contentVersion includes error/executing) - Rename moved to context menu, deprecated onCommit replaced with onSubmit - teardown resumes saveCompletionContinuation to prevent Task leak - Dead code removed (evictInactiveTabs, handleTabSelectionChange, DBG logs) * fix: resolve remaining critical/high tab bar bugs (round 2) - Guard background tab close against pendingChanges (data loss) - Add changeManager.hasChanges to quit-time unsaved check - Guard saveCompletionContinuation against concurrent access - Clear pendingChanges on reopened closed tab - Set isEditable on preview tab creation path - Show dirty indicator for active tab via isActiveTabDirty prop - Add persistTabs() helper to consistently exclude preview tabs * fix: resolve medium/low tab bar issues (round 3) - Move closeTabsToRight to coordinator with aggregated unsaved-changes check - Enforce pinned/unpinned boundary in drag-and-drop reorder - Commit rename text on focus loss instead of discarding - Add reopenClosedTab to ShortcutAction for remappable Cmd+Shift+T - Add accessibility labels to tab bar items and close button * fix: contentVersion hash collision causing empty DataGrid after query contentVersion used simple addition (resultVersion + metadataVersion + isExecuting?2:0), causing collisions like 0+0+2 == 1+1+0. The TabContentContainerView saw identical versions and skipped rebuilding the rootView, leaving the DataGrid showing only the "#" column. Fix: use prime multipliers (97, 31, 13) to avoid collisions. Also: - Defer query from addTableTabInApp to tab switch Phase 2 - Guard lazy query launch against stale selectedTabId - Add debug logging across query/apply/container pipeline * fix: route deeplinks and Handoff to in-app tabs instead of new windows When a connection is already open, deeplinks (tablepro://), Handoff, and database URL schemes (mysql://, postgres://) now add content as in-app tabs via the existing coordinator instead of creating duplicate NSWindows. Falls back to openNativeTab only when no coordinator exists. Add routeToExistingWindow helper that dispatches to openTableTab or tabManager.addTab based on payload type, then brings the window to front. * fix: prevent duplicate windows on cold launch via deeplink - Suppress auto-reconnect when app is launched by URL/deeplink - Defer auto-reconnect to next run loop so URL handler can set flag - Add connectingURLConnectionIds guard to connectViaDeeplink - Check pendingPayloads for race with auto-reconnect window creation - Fix closeRestoredMainWindows killing deeplink window * refactor: clean up debug logging and extract AppDelegate helpers - Remove ~35 [DEEPLINK], [TAB-NAV], [TAB-SWITCH], [QUERY], [APPLY], [CONTAINER] debug log statements added during tab bar refactor - Extract closeAllWelcomeWindows() helper (replaces 8 inline patterns) - Merge attemptAutoReconnectAll + attemptAutoReconnect into single method - Fix closeRestoredMainWindows: remove unnecessary DispatchQueue.main.async - Clean connectViaDeeplink guard chain (remove redundant variables) - Add timing comment to suppressAutoReconnect mechanism Net reduction: -178 lines * docs: update CHANGELOG for in-app tab bar refactor * docs: simplify CHANGELOG entries * docs: update tabs, keyboard shortcuts, and deeplinks for in-app tab bar - Add Cmd+Shift+T (Reopen Closed Tab) to keyboard shortcuts - Add Close Tabs to Right, Close All, Reopen Closed Tab to tabs docs - Document MRU tab selection, reopening history (20 tabs), remappable shortcut - Expand pinned tabs: left position, divider, drag boundary enforcement - Add Rename and full context menu items - Note preview tab promotion on data changes - Update deeplinks: opens as in-app tab in existing window * fix: resolve shared state not triggering NSHostingView rebuild The TabContentContainerView only rebuilt rootView when contentVersion changed, but several shared manager states that affect rendering were not included — toggling them had no visual effect. - Add filterStateManager.hasAppliedFilters to activeTabContentVersion - Add columnVisibilityManager.hiddenColumns hash to version - Add safeModeLevel.blocksAllWrites to version - Add showRowNumbers setting to version - Fix builtVersions init to use activeTabContentVersion - Replace NSApp.keyWindow with contentWindow in 5 confirmation dialogs - Set window subtitle for preview tabs created via "no reusable tab" path - Clear selectedRowIndices on tab switch to prevent stale selection - Remove dead didBecomeKey handler in ContentView
1 parent 65802d6 commit bad2466

39 files changed

Lines changed: 1603 additions & 754 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- In-app tab bar with instant switching, drag reorder, pinned tabs, and dirty indicators
13+
- Reopen closed tab (Cmd+Shift+T), MRU tab selection on close
14+
- Deeplinks and Handoff route to in-app tabs instead of creating duplicate windows
15+
16+
### Changed
17+
18+
- Replace native macOS window tabs with in-app tab bar (600ms+ → instant)
19+
- Tab content preserved across switches (no view destruction/recreation)
20+
- Connection state persisted incrementally (survives force quit)
21+
1022
### Fixed
1123

1224
- Raw SQL injection via external URL scheme deeplinks — now requires user confirmation

TablePro/AppDelegate+ConnectionHandler.swift

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,7 @@ extension AppDelegate {
9292
do {
9393
try await DatabaseManager.shared.connectToSession(connection)
9494
self.openNewConnectionWindow(for: connection)
95-
for window in NSApp.windows where self.isWelcomeWindow(window) {
96-
window.close()
97-
}
95+
self.closeAllWelcomeWindows()
9896
self.handlePostConnectionActions(parsed, connectionId: connection.id)
9997
} catch {
10098
connectionLogger.error("Database URL connect failed: \(error.localizedDescription)")
@@ -143,9 +141,7 @@ extension AppDelegate {
143141
do {
144142
try await DatabaseManager.shared.connectToSession(connection)
145143
self.openNewConnectionWindow(for: connection)
146-
for window in NSApp.windows where self.isWelcomeWindow(window) {
147-
window.close()
148-
}
144+
self.closeAllWelcomeWindows()
149145
} catch {
150146
connectionLogger.error("SQLite file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)")
151147
await self.handleConnectionFailure(error)
@@ -193,9 +189,7 @@ extension AppDelegate {
193189
do {
194190
try await DatabaseManager.shared.connectToSession(connection)
195191
self.openNewConnectionWindow(for: connection)
196-
for window in NSApp.windows where self.isWelcomeWindow(window) {
197-
window.close()
198-
}
192+
self.closeAllWelcomeWindows()
199193
} catch {
200194
connectionLogger.error("DuckDB file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)")
201195
await self.handleConnectionFailure(error)
@@ -243,9 +237,7 @@ extension AppDelegate {
243237
do {
244238
try await DatabaseManager.shared.connectToSession(connection)
245239
self.openNewConnectionWindow(for: connection)
246-
for window in NSApp.windows where self.isWelcomeWindow(window) {
247-
window.close()
248-
}
240+
self.closeAllWelcomeWindows()
249241
} catch {
250242
connectionLogger.error("File open failed for '\(filePath, privacy: .public)' (\(dbType.rawValue)): \(error.localizedDescription)")
251243
await self.handleConnectionFailure(error)
@@ -350,7 +342,9 @@ extension AppDelegate {
350342
tableName: tableName,
351343
isView: parsed.isView
352344
)
353-
WindowOpener.shared.openNativeTab(payload)
345+
if !routeToExistingWindow(connectionId: connectionId, payload: payload) {
346+
WindowOpener.shared.openNativeTab(payload)
347+
}
354348

355349
if parsed.filterColumn != nil || parsed.filterCondition != nil {
356350
await waitForNotification(.refreshData, timeout: .seconds(3))
@@ -475,6 +469,12 @@ extension AppDelegate {
475469
return "\(parsed.type.rawValue):\(parsed.username)@\(parsed.host):\(parsed.port ?? 0)/\(parsed.database)\(rdb)"
476470
}
477471

472+
func closeAllWelcomeWindows() {
473+
for window in NSApp.windows where isWelcomeWindow(window) {
474+
window.close()
475+
}
476+
}
477+
478478
func bringConnectionWindowToFront(_ connectionId: UUID) {
479479
let windows = WindowLifecycleMonitor.shared.windows(for: connectionId)
480480
if let window = windows.first {

TablePro/AppDelegate+FileOpen.swift

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,31 +32,39 @@ extension AppDelegate {
3232

3333
let tableName = activity.userInfo?["tableName"] as? String
3434

35+
// Already connected — route to existing window's in-app tab bar
3536
if DatabaseManager.shared.activeSessions[connectionId]?.driver != nil {
3637
if let tableName {
3738
let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName)
38-
WindowOpener.shared.openNativeTab(payload)
39-
} else {
40-
for window in NSApp.windows where isMainWindow(window) {
41-
window.makeKeyAndOrderFront(nil)
42-
return
39+
if !routeToExistingWindow(connectionId: connectionId, payload: payload) {
40+
WindowOpener.shared.openNativeTab(payload)
4341
}
42+
} else {
43+
bringConnectionWindowToFront(connectionId)
4444
}
4545
return
4646
}
4747

48-
let initialPayload = EditorTabPayload(connectionId: connectionId)
48+
// Window already pending (e.g., auto-reconnect in progress) — just bring to front
49+
let hasPending = WindowOpener.shared.pendingPayloads.contains { $0.connectionId == connectionId }
50+
if hasPending {
51+
bringConnectionWindowToFront(connectionId)
52+
return
53+
}
54+
55+
// Not connected — create window, connect, then route content as in-app tab
56+
let initialPayload = EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault)
4957
WindowOpener.shared.openNativeTab(initialPayload)
5058

5159
Task { @MainActor in
5260
do {
5361
try await DatabaseManager.shared.connectToSession(connection)
54-
for window in NSApp.windows where self.isWelcomeWindow(window) {
55-
window.close()
56-
}
62+
self.closeAllWelcomeWindows()
5763
if let tableName {
5864
let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName)
59-
WindowOpener.shared.openNativeTab(payload)
65+
if !routeToExistingWindow(connectionId: connectionId, payload: payload) {
66+
WindowOpener.shared.openNativeTab(payload)
67+
}
6068
}
6169
} catch {
6270
fileOpenLogger.error("Handoff connect failed: \(error.localizedDescription)")
@@ -86,6 +94,11 @@ extension AppDelegate {
8694
// MARK: - Main Dispatch
8795

8896
func handleOpenURLs(_ urls: [URL]) {
97+
// application(_:open:) fires in the same run loop pass as applicationDidFinishLaunching
98+
// on cold launch from URL. The deferred auto-reconnect Task yields to the next run loop,
99+
// so this flag is guaranteed to be set before the Task checks it.
100+
suppressAutoReconnect = true
101+
89102
let deeplinks = urls.filter { $0.scheme == "tablepro" }
90103
if !deeplinks.isEmpty {
91104
Task { @MainActor in
@@ -143,9 +156,7 @@ extension AppDelegate {
143156
for window in NSApp.windows where isMainWindow(window) {
144157
window.makeKeyAndOrderFront(nil)
145158
}
146-
for window in NSApp.windows where isWelcomeWindow(window) {
147-
window.close()
148-
}
159+
closeAllWelcomeWindows()
149160
NotificationCenter.default.post(name: .openSQLFiles, object: sqlFiles)
150161
endFileOpenSuppression()
151162
} else {
@@ -155,6 +166,36 @@ extension AppDelegate {
155166
}
156167
}
157168

169+
// MARK: - In-App Tab Routing
170+
171+
/// Route content to an existing connection window's in-app tab bar when possible.
172+
/// Returns true if the content was routed to an existing window.
173+
/// Falls back gracefully (returns false) when no coordinator exists for the connection.
174+
@discardableResult
175+
func routeToExistingWindow(
176+
connectionId: UUID,
177+
payload: EditorTabPayload
178+
) -> Bool {
179+
guard let coordinator = MainContentCoordinator.firstCoordinator(for: connectionId) else {
180+
return false
181+
}
182+
switch payload.tabType {
183+
case .table:
184+
if let tableName = payload.tableName {
185+
coordinator.openTableTab(tableName, showStructure: payload.showStructure, isView: payload.isView)
186+
}
187+
case .query:
188+
coordinator.tabManager.addTab(
189+
initialQuery: payload.initialQuery,
190+
databaseName: payload.databaseName ?? coordinator.connection.database
191+
)
192+
default:
193+
coordinator.addNewQueryTab()
194+
}
195+
coordinator.contentWindow?.makeKeyAndOrderFront(nil)
196+
return true
197+
}
198+
158199
// MARK: - Welcome Window Suppression
159200

160201
func suppressWelcomeWindow() {
@@ -216,7 +257,7 @@ extension AppDelegate {
216257
makePayload: (@Sendable (UUID) -> EditorTabPayload)? = nil
217258
) {
218259
guard let connection = DeeplinkHandler.resolveConnection(named: connectionName) else {
219-
fileOpenLogger.error("Deep link: no connection named '\(connectionName, privacy: .public)'")
260+
fileOpenLogger.error("No connection named '\(connectionName, privacy: .public)'")
220261
AlertHelper.showErrorSheet(
221262
title: String(localized: "Connection Not Found"),
222263
message: String(format: String(localized: "No saved connection named \"%@\"."), connectionName),
@@ -225,27 +266,47 @@ extension AppDelegate {
225266
return
226267
}
227268

228-
if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil {
269+
let hasDriver = DatabaseManager.shared.activeSessions[connection.id]?.driver != nil
270+
let hasCoordinator = MainContentCoordinator.firstCoordinator(for: connection.id) != nil
271+
272+
// Already connected — route to existing window's in-app tab bar
273+
if hasDriver {
229274
if let payload = makePayload?(connection.id) {
230-
WindowOpener.shared.openNativeTab(payload)
231-
} else {
232-
for window in NSApp.windows where isMainWindow(window) {
233-
window.makeKeyAndOrderFront(nil)
234-
return
275+
if !routeToExistingWindow(connectionId: connection.id, payload: payload) {
276+
WindowOpener.shared.openNativeTab(payload)
235277
}
278+
} else {
279+
bringConnectionWindowToFront(connection.id)
236280
}
237281
return
238282
}
239283

284+
// Prevent duplicate connections from rapid deeplink invocations
285+
let hasPendingWindow = WindowOpener.shared.pendingPayloads.contains { $0.connectionId == connection.id }
286+
let isAlreadyConnecting = connectingURLConnectionIds.contains(connection.id)
287+
guard !isAlreadyConnecting, !hasPendingWindow else {
288+
bringConnectionWindowToFront(connection.id)
289+
return
290+
}
291+
292+
// Has coordinator but no driver — window exists, connection may be in progress
293+
if hasCoordinator {
294+
bringConnectionWindowToFront(connection.id)
295+
return
296+
}
297+
240298
let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
241299
if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs {
242300
NSWindow.allowsAutomaticWindowTabbing = false
243301
}
244302

245-
let deeplinkPayload = EditorTabPayload(connectionId: connection.id)
303+
connectingURLConnectionIds.insert(connection.id)
304+
305+
let deeplinkPayload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)
246306
WindowOpener.shared.openNativeTab(deeplinkPayload)
247307

248308
Task { @MainActor in
309+
defer { self.connectingURLConnectionIds.remove(connection.id) }
249310
do {
250311
// Confirm pre-connect script if present (deep links are external, so always confirm)
251312
if let script = connection.preConnectScript,
@@ -262,14 +323,14 @@ extension AppDelegate {
262323
}
263324

264325
try await DatabaseManager.shared.connectToSession(connection)
265-
for window in NSApp.windows where self.isWelcomeWindow(window) {
266-
window.close()
267-
}
326+
self.closeAllWelcomeWindows()
268327
if let payload = makePayload?(connection.id) {
269-
WindowOpener.shared.openNativeTab(payload)
328+
if !self.routeToExistingWindow(connectionId: connection.id, payload: payload) {
329+
WindowOpener.shared.openNativeTab(payload)
330+
}
270331
}
271332
} catch {
272-
fileOpenLogger.error("Deep link connect failed: \(error.localizedDescription)")
333+
fileOpenLogger.error("Deeplink connect failed for \"\(connectionName, privacy: .public)\": \(error.localizedDescription, privacy: .public)")
273334
await self.handleConnectionFailure(error)
274335
}
275336
}

0 commit comments

Comments
 (0)