From 97c8a1e39fefdcbb2c9a95868975b164f328a2d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 13:12:23 +0700 Subject: [PATCH 1/7] debug: instrument tab focus path with Issue1313 logger for #1313 --- .../Infrastructure/TabWindowController.swift | 24 ++++++++++++++++++- .../Infrastructure/WindowManager.swift | 18 ++++++++++++++ TablePro/Models/Query/QueryTabManager.swift | 15 +++++++++++- .../Main/MainContentCommandActions.swift | 9 +++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 768e3d656..f97380d71 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -7,6 +7,8 @@ import AppKit import os import SwiftUI +private let issue1313Logger = Logger(subsystem: "com.TablePro", category: "Issue1313") + @MainActor private final class EditorWindow: NSWindow { override func performClose(_ sender: Any?) { @@ -19,8 +21,17 @@ private final class EditorWindow: NSWindow { } override func newWindowForTab(_ sender: Any?) { - guard let coordinator = MainContentCoordinator.coordinator(forWindow: self), + let winId = ObjectIdentifier(self).hashValue + let resolvedCoordinator = MainContentCoordinator.coordinator(forWindow: self) + let tabsCount = resolvedCoordinator?.tabManager.tabs.count ?? -1 + let selectedTabId = resolvedCoordinator?.tabManager.selectedTabId?.uuidString ?? "nil" + let selectedIsPreview = resolvedCoordinator?.tabManager.selectedTab?.isPreview ?? false + issue1313Logger.info( + "[1313] EditorWindow.newWindowForTab fired winId=\(winId) isKey=\(self.isKeyWindow) tabsInCoord=\(tabsCount) selectedTabId=\(selectedTabId, privacy: .public) selectedIsPreview=\(selectedIsPreview)" + ) + guard let coordinator = resolvedCoordinator, let actions = coordinator.commandActions else { + issue1313Logger.info("[1313] newWindowForTab fallback to super (no coordinator/actions) winId=\(winId)") super.newWindowForTab(sender) return } @@ -117,6 +128,10 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { guard let window = notification.object as? NSWindow, let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } + let winId = ObjectIdentifier(window).hashValue + issue1313Logger.info( + "[1313] windowDidBecomeKey seq=\(seq) winId=\(winId) controllerId=\(self.controllerId, privacy: .public) tabbedCount=\(window.tabbedWindows?.count ?? -1)" + ) Self.lifecycleLogger.debug( "[switch] windowDidBecomeKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public) connId=\(coordinator.connectionId, privacy: .public)" ) @@ -137,6 +152,10 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { guard let window = notification.object as? NSWindow, let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } + let winId = ObjectIdentifier(window).hashValue + issue1313Logger.info( + "[1313] windowDidResignKey seq=\(seq) winId=\(winId) controllerId=\(self.controllerId, privacy: .public)" + ) Self.lifecycleLogger.debug( "[switch] windowDidResignKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public)" ) @@ -153,6 +172,9 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { let seq = MainContentCoordinator.nextSwitchSeq() let t0 = Date() guard let window = notification.object as? NSWindow else { return } + issue1313Logger.info( + "[1313] windowWillClose seq=\(seq) winId=\(ObjectIdentifier(window).hashValue) controllerId=\(self.controllerId, privacy: .public)" + ) Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)") cancelPendingConnectionIfNeeded() diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index d720d7382..2dceca21e 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -10,6 +10,7 @@ import SwiftUI @MainActor internal final class WindowManager { private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") + private static let issue1313Logger = Logger(subsystem: "com.TablePro", category: "Issue1313") internal static let shared = WindowManager() @@ -22,6 +23,9 @@ internal final class WindowManager { internal func openTab(payload: EditorTabPayload) { let t0 = Date() + Self.issue1313Logger.info( + "[1313] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) isPreview=\(payload.isPreview)" + ) Self.lifecycleLogger.info( "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute)" ) @@ -65,14 +69,28 @@ internal final class WindowManager { } } let target = sibling.tabbedWindows?.last ?? sibling + let newWinId = ObjectIdentifier(window).hashValue + let targetWinId = ObjectIdentifier(target).hashValue + Self.issue1313Logger.info( + "[1313] WindowManager pre-addTabbedWindow newWinId=\(newWinId) targetWinId=\(targetWinId) targetIsKey=\(target.isKeyWindow) groupSize=\(target.tabbedWindows?.count ?? -1)" + ) target.addTabbedWindow(window, ordered: .above) + Self.issue1313Logger.info( + "[1313] WindowManager post-addTabbedWindow newWinId=\(newWinId) groupSize=\(window.tabbedWindows?.count ?? -1)" + ) window.makeKeyAndOrderFront(nil) + Self.issue1313Logger.info( + "[1313] WindowManager post-makeKeyAndOrderFront newWinId=\(newWinId) isKey=\(window.isKeyWindow)" + ) Self.lifecycleLogger.info( "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) } else { window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) + Self.issue1313Logger.info( + "[1313] WindowManager standalone window newWinId=\(ObjectIdentifier(window).hashValue) isKey=\(window.isKeyWindow)" + ) Self.lifecycleLogger.info( "[open] WindowManager standalone window payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index e8b9ea634..203665bf3 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -7,6 +7,8 @@ import Foundation import Observation import os +private let issue1313Logger = Logger(subsystem: "com.TablePro", category: "Issue1313") + /// Manager for query tabs @MainActor @Observable final class QueryTabManager { @@ -15,12 +17,23 @@ final class QueryTabManager { _tabIndexMapDirty = true if oldValue.map(\.id) != tabs.map(\.id) { tabStructureVersion += 1 + let oldIds = oldValue.map { String($0.id.uuidString.prefix(8)) }.joined(separator: ",") + let newIds = tabs.map { String($0.id.uuidString.prefix(8)) }.joined(separator: ",") + issue1313Logger.info("[1313] tabs changed count=\(self.tabs.count) old=[\(oldIds, privacy: .public)] new=[\(newIds, privacy: .public)]") } syncTabSessionRegistry(oldTabs: oldValue, newTabs: tabs) } } - var selectedTabId: UUID? + var selectedTabId: UUID? { + didSet { + if oldValue != selectedTabId { + issue1313Logger.info( + "[1313] selectedTabId changed from=\(oldValue?.uuidString ?? "nil", privacy: .public) to=\(self.selectedTabId?.uuidString ?? "nil", privacy: .public)" + ) + } + } + } var tabStructureVersion: Int = 0 diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index db4109c10..85eec2d1b 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -336,13 +336,22 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) func newTab(initialQuery: String? = nil) { + let issue1313 = Logger(subsystem: "com.TablePro", category: "Issue1313") + let tabsCount = coordinator?.tabManager.tabs.count ?? -1 + let selectedTabId = coordinator?.tabManager.selectedTabId?.uuidString ?? "nil" + let selectedIsPreview = coordinator?.tabManager.selectedTab?.isPreview ?? false + issue1313.info( + "[1313] MainContentCommandActions.newTab entry tabsCount=\(tabsCount) selectedTabId=\(selectedTabId, privacy: .public) selectedIsPreview=\(selectedIsPreview)" + ) if let coordinator, coordinator.tabManager.tabs.isEmpty { + issue1313.info("[1313] newTab branch=addToEmptyManager (no new window)") coordinator.tabManager.addTab( initialQuery: initialQuery, databaseName: coordinator.activeDatabaseName ) return } + issue1313.info("[1313] newTab branch=openNewWindow via WindowManager.openTab") let payload = EditorTabPayload( connectionId: connection.id, initialQuery: initialQuery, From 6417019aa2838bbaae20d3df638da06cbbda3a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 13:18:38 +0700 Subject: [PATCH 2/7] debug: add tabGroup.selectedWindow probe and experimental fix for #1313 --- .../xcshareddata/xcschemes/TablePro.xcscheme | 2 +- .../Infrastructure/TabWindowController.swift | 6 ++++-- .../Services/Infrastructure/WindowManager.swift | 16 ++++++++++++++-- TablePro/Resources/Localizable.xcstrings | 6 ++++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index f99c67cbd..a2d8da8aa 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -1,7 +1,7 @@ + version = "1.8"> Date: Mon, 18 May 2026 13:25:47 +0700 Subject: [PATCH 3/7] debug: add early-resign stack trace, lifecycle logs, delayed re-assert fix --- .../Infrastructure/TabWindowController.swift | 16 ++++++++++--- .../Infrastructure/WindowManager.swift | 23 +++++++++++++------ ...inContentCoordinator+WindowLifecycle.swift | 15 +++++++++++- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index d85f7186a..9e6ec0384 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -49,6 +49,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { internal let controllerId: UUID + private let createdAt: Date = Date() + private var activity: NSUserActivity? internal init(payload: EditorTabPayload, sessionState: SessionStateFactory.SessionState? = nil) { @@ -155,9 +157,17 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { else { return } let winId = ObjectIdentifier(window).hashValue let groupSelectedId = window.tabGroup?.selectedWindow.map { ObjectIdentifier($0).hashValue } ?? 0 - issue1313Logger.info( - "[1313] windowDidResignKey seq=\(seq) winId=\(winId) controllerId=\(self.controllerId, privacy: .public) tabGroupSelectedWinId=\(groupSelectedId)" - ) + let ageMs = Int(Date().timeIntervalSince(self.createdAt) * 1_000) + if ageMs < 2_000 { + let stack = Thread.callStackSymbols.prefix(15).joined(separator: " | ") + issue1313Logger.info( + "[1313] windowDidResignKey EARLY seq=\(seq) winId=\(winId) ageMs=\(ageMs) tabGroupSelectedWinId=\(groupSelectedId) stack=\(stack, privacy: .public)" + ) + } else { + issue1313Logger.info( + "[1313] windowDidResignKey seq=\(seq) winId=\(winId) controllerId=\(self.controllerId, privacy: .public) tabGroupSelectedWinId=\(groupSelectedId)" + ) + } Self.lifecycleLogger.debug( "[switch] windowDidResignKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public)" ) diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index b32207981..81c113f77 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -84,15 +84,24 @@ internal final class WindowManager { Self.issue1313Logger.info( "[1313] WindowManager post-makeKeyAndOrderFront newWinId=\(newWinId) isKey=\(window.isKeyWindow) tabGroupSelectedWinId=\(selectedIdAfterKey)" ) - // Experimental fix: explicitly select Y in the tab group. - // Hypothesis: addTabbedWindow + makeKey sets key but does not - // sync tabGroup.selectedWindow, so AppKit's tab resolution - // re-selects X (the previously selected tab) ~300ms later. - if window.tabGroup?.selectedWindow !== window { - window.tabGroup?.selectedWindow = window + // Experimental fix v2: delayed re-assert. The 300ms mystery flip + // happens after window mount. Re-assert key + selectedWindow at + // 800ms to test whether late re-focus can hold. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak window] in + guard let window else { return } + let wasKey = window.isKeyWindow + let groupSel = window.tabGroup?.selectedWindow.map { ObjectIdentifier($0).hashValue } ?? 0 + let nid = ObjectIdentifier(window).hashValue Self.issue1313Logger.info( - "[1313] WindowManager forced tabGroup.selectedWindow=newWinId=\(newWinId)" + "[1313] WindowManager delayed-reassert pre newWinId=\(nid) wasKey=\(wasKey) tabGroupSelectedWinId=\(groupSel)" ) + if !wasKey || window.tabGroup?.selectedWindow !== window { + window.tabGroup?.selectedWindow = window + window.makeKeyAndOrderFront(nil) + Self.issue1313Logger.info( + "[1313] WindowManager delayed-reassert FIRED newWinId=\(nid)" + ) + } } Self.lifecycleLogger.info( "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index ab882efe1..335b1b584 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -22,6 +22,11 @@ extension MainContentCoordinator { /// is owned by `MainEditorContentView`'s `.task(id:)` modifier. func handleWindowDidBecomeKey() { let t0 = Date() + let issue1313 = Logger(subsystem: "com.TablePro", category: "Issue1313") + let winId = self.contentWindow.map { ObjectIdentifier($0).hashValue } ?? 0 + issue1313.info( + "[1313] coord.handleWindowDidBecomeKey start winId=\(winId) connId=\(self.connectionId, privacy: .public) selectedTabId=\(self.tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" + ) Self.lifecycleLogger.debug( "[switch] coordinator.handleWindowDidBecomeKey connId=\(self.connectionId, privacy: .public) selectedTabId=\(self.tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" ) @@ -32,11 +37,19 @@ extension MainContentCoordinator { let isConnected = DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { - Task { await self.refreshTablesIfStale() } + issue1313.info("[1313] coord.handleWindowDidBecomeKey scheduled refreshTablesIfStale winId=\(winId)") + Task { + issue1313.info("[1313] refreshTablesIfStale start winId=\(winId)") + await self.refreshTablesIfStale() + issue1313.info("[1313] refreshTablesIfStale done winId=\(winId)") + } } syncSidebarToSelectedTab() + issue1313.info( + "[1313] coord.handleWindowDidBecomeKey done winId=\(winId) totalMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" + ) Self.lifecycleLogger.debug( "[switch] coordinator.handleWindowDidBecomeKey done connId=\(self.connectionId, privacy: .public) totalMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) From b604d6f087e2d8aa6915ad56e72984a3d1260944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 13:38:16 +0700 Subject: [PATCH 4/7] fix(coordinator): scope sidebar selection per-window to stop Cmd+T focus jump (#1313) --- CHANGELOG.md | 1 + .../MainSplitViewController.swift | 1 + .../Infrastructure/TabWindowController.swift | 36 +-------------- .../Infrastructure/WindowManager.swift | 39 ---------------- TablePro/Models/Query/QueryTabManager.swift | 15 +------ TablePro/Models/UI/SharedSidebarState.swift | 6 +-- TablePro/Models/UI/WindowSidebarState.swift | 2 + .../MainContentCoordinator+Navigation.swift | 44 ++++++++++++------- ...MainContentCoordinator+QuickSwitcher.swift | 4 +- ...inContentCoordinator+WindowLifecycle.swift | 22 ++-------- .../MainContentView+EventHandlers.swift | 8 ++-- .../Extensions/MainContentView+Setup.swift | 4 +- .../Main/MainContentCommandActions.swift | 9 ---- TablePro/Views/Main/MainContentView.swift | 10 ++--- TablePro/Views/Sidebar/SidebarView.swift | 16 ++++--- 15 files changed, 64 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afcb236b..bcb8b578f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- New query tab (Cmd+T) no longer jumps focus back to the previous table tab on SQLite and other file-based databases (#1313) - PostgreSQL connections to AWS RDS, Cloud SQL, Azure, and other hosted Postgres now succeed out of the box instead of failing with "no pg_hba.conf entry for host" (#1298) - Oracle: SSL/TCPS settings from the SSL pane are now respected; previously every Oracle connection was plain TCP regardless of SSL mode - Cassandra: SSL settings from the SSL pane are now respected; previously every Cassandra connection was plain TCP because the plugin read from a non-existent "sslMode" field diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 93b3b7c60..1ab93cc8d 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -330,6 +330,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) -> some View { SidebarView( sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), + windowState: sessionState.coordinator.windowSidebarState, onDoubleClick: { [weak self] table in guard let coordinator = self?.sessionState?.coordinator else { return } let connectionId = coordinator.connectionId diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 9e6ec0384..768e3d656 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -7,8 +7,6 @@ import AppKit import os import SwiftUI -private let issue1313Logger = Logger(subsystem: "com.TablePro", category: "Issue1313") - @MainActor private final class EditorWindow: NSWindow { override func performClose(_ sender: Any?) { @@ -21,17 +19,8 @@ private final class EditorWindow: NSWindow { } override func newWindowForTab(_ sender: Any?) { - let winId = ObjectIdentifier(self).hashValue - let resolvedCoordinator = MainContentCoordinator.coordinator(forWindow: self) - let tabsCount = resolvedCoordinator?.tabManager.tabs.count ?? -1 - let selectedTabId = resolvedCoordinator?.tabManager.selectedTabId?.uuidString ?? "nil" - let selectedIsPreview = resolvedCoordinator?.tabManager.selectedTab?.isPreview ?? false - issue1313Logger.info( - "[1313] EditorWindow.newWindowForTab fired winId=\(winId) isKey=\(self.isKeyWindow) tabsInCoord=\(tabsCount) selectedTabId=\(selectedTabId, privacy: .public) selectedIsPreview=\(selectedIsPreview)" - ) - guard let coordinator = resolvedCoordinator, + guard let coordinator = MainContentCoordinator.coordinator(forWindow: self), let actions = coordinator.commandActions else { - issue1313Logger.info("[1313] newWindowForTab fallback to super (no coordinator/actions) winId=\(winId)") super.newWindowForTab(sender) return } @@ -49,8 +38,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { internal let controllerId: UUID - private let createdAt: Date = Date() - private var activity: NSUserActivity? internal init(payload: EditorTabPayload, sessionState: SessionStateFactory.SessionState? = nil) { @@ -130,11 +117,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { guard let window = notification.object as? NSWindow, let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } - let winId = ObjectIdentifier(window).hashValue - let groupSelectedId = window.tabGroup?.selectedWindow.map { ObjectIdentifier($0).hashValue } ?? 0 - issue1313Logger.info( - "[1313] windowDidBecomeKey seq=\(seq) winId=\(winId) controllerId=\(self.controllerId, privacy: .public) tabbedCount=\(window.tabbedWindows?.count ?? -1) tabGroupSelectedWinId=\(groupSelectedId)" - ) Self.lifecycleLogger.debug( "[switch] windowDidBecomeKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public) connId=\(coordinator.connectionId, privacy: .public)" ) @@ -155,19 +137,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { guard let window = notification.object as? NSWindow, let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } - let winId = ObjectIdentifier(window).hashValue - let groupSelectedId = window.tabGroup?.selectedWindow.map { ObjectIdentifier($0).hashValue } ?? 0 - let ageMs = Int(Date().timeIntervalSince(self.createdAt) * 1_000) - if ageMs < 2_000 { - let stack = Thread.callStackSymbols.prefix(15).joined(separator: " | ") - issue1313Logger.info( - "[1313] windowDidResignKey EARLY seq=\(seq) winId=\(winId) ageMs=\(ageMs) tabGroupSelectedWinId=\(groupSelectedId) stack=\(stack, privacy: .public)" - ) - } else { - issue1313Logger.info( - "[1313] windowDidResignKey seq=\(seq) winId=\(winId) controllerId=\(self.controllerId, privacy: .public) tabGroupSelectedWinId=\(groupSelectedId)" - ) - } Self.lifecycleLogger.debug( "[switch] windowDidResignKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public)" ) @@ -184,9 +153,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { let seq = MainContentCoordinator.nextSwitchSeq() let t0 = Date() guard let window = notification.object as? NSWindow else { return } - issue1313Logger.info( - "[1313] windowWillClose seq=\(seq) winId=\(ObjectIdentifier(window).hashValue) controllerId=\(self.controllerId, privacy: .public)" - ) Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)") cancelPendingConnectionIfNeeded() diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 81c113f77..d720d7382 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -10,7 +10,6 @@ import SwiftUI @MainActor internal final class WindowManager { private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") - private static let issue1313Logger = Logger(subsystem: "com.TablePro", category: "Issue1313") internal static let shared = WindowManager() @@ -23,9 +22,6 @@ internal final class WindowManager { internal func openTab(payload: EditorTabPayload) { let t0 = Date() - Self.issue1313Logger.info( - "[1313] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) isPreview=\(payload.isPreview)" - ) Self.lifecycleLogger.info( "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute)" ) @@ -69,49 +65,14 @@ internal final class WindowManager { } } let target = sibling.tabbedWindows?.last ?? sibling - let newWinId = ObjectIdentifier(window).hashValue - let targetWinId = ObjectIdentifier(target).hashValue - Self.issue1313Logger.info( - "[1313] WindowManager pre-addTabbedWindow newWinId=\(newWinId) targetWinId=\(targetWinId) targetIsKey=\(target.isKeyWindow) groupSize=\(target.tabbedWindows?.count ?? -1)" - ) target.addTabbedWindow(window, ordered: .above) - let selectedIdAfterAdd = window.tabGroup?.selectedWindow.map { ObjectIdentifier($0).hashValue } ?? 0 - Self.issue1313Logger.info( - "[1313] WindowManager post-addTabbedWindow newWinId=\(newWinId) groupSize=\(window.tabbedWindows?.count ?? -1) tabGroupSelectedWinId=\(selectedIdAfterAdd)" - ) window.makeKeyAndOrderFront(nil) - let selectedIdAfterKey = window.tabGroup?.selectedWindow.map { ObjectIdentifier($0).hashValue } ?? 0 - Self.issue1313Logger.info( - "[1313] WindowManager post-makeKeyAndOrderFront newWinId=\(newWinId) isKey=\(window.isKeyWindow) tabGroupSelectedWinId=\(selectedIdAfterKey)" - ) - // Experimental fix v2: delayed re-assert. The 300ms mystery flip - // happens after window mount. Re-assert key + selectedWindow at - // 800ms to test whether late re-focus can hold. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak window] in - guard let window else { return } - let wasKey = window.isKeyWindow - let groupSel = window.tabGroup?.selectedWindow.map { ObjectIdentifier($0).hashValue } ?? 0 - let nid = ObjectIdentifier(window).hashValue - Self.issue1313Logger.info( - "[1313] WindowManager delayed-reassert pre newWinId=\(nid) wasKey=\(wasKey) tabGroupSelectedWinId=\(groupSel)" - ) - if !wasKey || window.tabGroup?.selectedWindow !== window { - window.tabGroup?.selectedWindow = window - window.makeKeyAndOrderFront(nil) - Self.issue1313Logger.info( - "[1313] WindowManager delayed-reassert FIRED newWinId=\(nid)" - ) - } - } Self.lifecycleLogger.info( "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) } else { window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - Self.issue1313Logger.info( - "[1313] WindowManager standalone window newWinId=\(ObjectIdentifier(window).hashValue) isKey=\(window.isKeyWindow)" - ) Self.lifecycleLogger.info( "[open] WindowManager standalone window payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 203665bf3..e8b9ea634 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -7,8 +7,6 @@ import Foundation import Observation import os -private let issue1313Logger = Logger(subsystem: "com.TablePro", category: "Issue1313") - /// Manager for query tabs @MainActor @Observable final class QueryTabManager { @@ -17,23 +15,12 @@ final class QueryTabManager { _tabIndexMapDirty = true if oldValue.map(\.id) != tabs.map(\.id) { tabStructureVersion += 1 - let oldIds = oldValue.map { String($0.id.uuidString.prefix(8)) }.joined(separator: ",") - let newIds = tabs.map { String($0.id.uuidString.prefix(8)) }.joined(separator: ",") - issue1313Logger.info("[1313] tabs changed count=\(self.tabs.count) old=[\(oldIds, privacy: .public)] new=[\(newIds, privacy: .public)]") } syncTabSessionRegistry(oldTabs: oldValue, newTabs: tabs) } } - var selectedTabId: UUID? { - didSet { - if oldValue != selectedTabId { - issue1313Logger.info( - "[1313] selectedTabId changed from=\(oldValue?.uuidString ?? "nil", privacy: .public) to=\(self.selectedTabId?.uuidString ?? "nil", privacy: .public)" - ) - } - } - } + var selectedTabId: UUID? var tabStructureVersion: Int = 0 diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index e0ea4d157..5869ce3e3 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -2,8 +2,9 @@ // SharedSidebarState.swift // TablePro // -// Shared sidebar state (selection + search + tab) for cross-tab synchronization. -// One instance per connection, shared across all native macOS tabs. +// Connection-scoped sidebar state shared across all windows of the same +// connection. Window-scoped state (table selection) lives in +// `WindowSidebarState`. // import Foundation @@ -16,7 +17,6 @@ internal enum SidebarTab: String, CaseIterable { @MainActor @Observable final class SharedSidebarState { - var selectedTables: Set = [] var searchText: String = "" var redisKeyTreeViewModel: RedisKeyTreeViewModel? diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index a22616560..0ff6019ae 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -5,9 +5,11 @@ import Foundation import Observation +import TableProPluginKit @MainActor @Observable internal final class WindowSidebarState { + var selectedTables: Set = [] var favoritesSearchText: String = "" } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 6fe48011e..97792f131 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -15,16 +15,23 @@ private let navigationLogger = Logger(subsystem: "com.TablePro", category: "Main extension MainContentCoordinator { // MARK: - Table Tab Opening - func openTableTab(_ table: TableInfo, showStructure: Bool = false) { + func openTableTab(_ table: TableInfo, showStructure: Bool = false, redirectToSibling: Bool = false) { openTableTab( table.name, schema: table.schema, showStructure: showStructure, - isView: table.type == .view + isView: table.type == .view, + redirectToSibling: redirectToSibling ) } - func openTableTab(_ tableName: String, schema: String? = nil, showStructure: Bool = false, isView: Bool = false) { + func openTableTab( + _ tableName: String, + schema: String? = nil, + showStructure: Bool = false, + isView: Bool = false, + redirectToSibling: Bool = false + ) { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.navigationModel ?? .standard @@ -71,20 +78,25 @@ extension MainContentCoordinator { return } - // Check if another native window tab already has this table open — switch to it - for sibling in MainContentCoordinator.allActiveCoordinators() - where sibling !== self && sibling.connectionId == connectionId { - let hasMatch = sibling.tabManager.tabs.contains { tab in - tab.tabType == .table - && tab.tableContext.tableName == tableName - && tab.tableContext.databaseName == currentDatabase - && tab.tableContext.schemaName == resolvedSchema + // Opt-in cross-window navigation: if requested (e.g. quick switcher), + // and another window already shows this table, focus that window. + // Default-off so sidebar clicks and other window-local actions stay + // window-local instead of stealing focus to a sibling. + if redirectToSibling { + for sibling in MainContentCoordinator.allActiveCoordinators() + where sibling !== self && sibling.connectionId == connectionId { + let hasMatch = sibling.tabManager.tabs.contains { tab in + tab.tabType == .table + && tab.tableContext.tableName == tableName + && tab.tableContext.databaseName == currentDatabase + && tab.tableContext.schemaName == resolvedSchema + } + guard hasMatch, + let windowId = sibling.windowId, + let window = WindowLifecycleMonitor.shared.window(for: windowId) else { continue } + window.makeKeyAndOrderFront(nil) + return } - guard hasMatch, - let windowId = sibling.windowId, - let window = WindowLifecycleMonitor.shared.window(for: windowId) else { continue } - window.makeKeyAndOrderFront(nil) - return } // If no tabs exist (empty state), add a table tab directly. diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 672b6af39..c3c7f1cca 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -15,10 +15,10 @@ extension MainContentCoordinator { func handleQuickSwitcherSelection(_ item: QuickSwitcherItem) { switch item.kind { case .table, .systemTable: - openTableTab(item.name) + openTableTab(item.name, redirectToSibling: true) case .view: - openTableTab(item.name, isView: true) + openTableTab(item.name, isView: true, redirectToSibling: true) case .database: Task { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 335b1b584..e7b774b6c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -22,11 +22,6 @@ extension MainContentCoordinator { /// is owned by `MainEditorContentView`'s `.task(id:)` modifier. func handleWindowDidBecomeKey() { let t0 = Date() - let issue1313 = Logger(subsystem: "com.TablePro", category: "Issue1313") - let winId = self.contentWindow.map { ObjectIdentifier($0).hashValue } ?? 0 - issue1313.info( - "[1313] coord.handleWindowDidBecomeKey start winId=\(winId) connId=\(self.connectionId, privacy: .public) selectedTabId=\(self.tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" - ) Self.lifecycleLogger.debug( "[switch] coordinator.handleWindowDidBecomeKey connId=\(self.connectionId, privacy: .public) selectedTabId=\(self.tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" ) @@ -37,19 +32,11 @@ extension MainContentCoordinator { let isConnected = DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { - issue1313.info("[1313] coord.handleWindowDidBecomeKey scheduled refreshTablesIfStale winId=\(winId)") - Task { - issue1313.info("[1313] refreshTablesIfStale start winId=\(winId)") - await self.refreshTablesIfStale() - issue1313.info("[1313] refreshTablesIfStale done winId=\(winId)") - } + Task { await self.refreshTablesIfStale() } } syncSidebarToSelectedTab() - issue1313.info( - "[1313] coord.handleWindowDidBecomeKey done winId=\(winId) totalMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" - ) Self.lifecycleLogger.debug( "[switch] coordinator.handleWindowDidBecomeKey done connId=\(self.connectionId, privacy: .public) totalMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) @@ -103,11 +90,10 @@ extension MainContentCoordinator { // MARK: - Sidebar Sync - /// Update the connection-scoped sidebar selection so the active table tab + /// Update the window-scoped sidebar selection so the active table tab /// is highlighted. Reads tables fresh from the DatabaseManager because the /// schema load is async and may complete after focus changes. func syncSidebarToSelectedTab() { - let sidebarState = SharedSidebarState.forConnection(connectionId) let liveTables = DatabaseManager.shared .session(for: connectionId)?.tables ?? [] let target: Set @@ -117,9 +103,9 @@ extension MainContentCoordinator { } else { target = [] } - if sidebarState.selectedTables != target { + if windowSidebarState.selectedTables != target { if target.isEmpty && liveTables.isEmpty { return } - sidebarState.selectedTables = target + windowSidebarState.selectedTables = target } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 2f5801633..e75137c70 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -133,8 +133,7 @@ extension MainContentView { /// Only writes when the value actually changes, preventing spurious onChange triggers. /// Navigation safety is guaranteed by `SidebarNavigationResult.resolve` returning `.skip` /// when the selected table matches the current tab. - /// Reads from DatabaseManager (authoritative source) instead of the `tables` binding, - /// and skips background windows to avoid overwriting shared sidebar state. + /// Reads from DatabaseManager (authoritative source) instead of the `tables` binding. func syncSidebarToCurrentTab() { guard coordinator.isKeyWindow else { return } let liveTables = DatabaseManager.shared.session(for: connection.id)?.tables ?? [] @@ -146,9 +145,10 @@ extension MainContentView { } else { target = [] } - if sidebarState.selectedTables != target { + let state = coordinator.windowSidebarState + if state.selectedTables != target { if target.isEmpty && liveTables.isEmpty { return } - sidebarState.selectedTables = target + state.selectedTables = target } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 450c00f36..33daba0b8 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -274,8 +274,8 @@ extension MainContentView { connection: connection, selectionState: coordinator.selectionState, selectedTables: Binding( - get: { sidebarState.selectedTables }, - set: { sidebarState.selectedTables = $0 } + get: { coordinator.windowSidebarState.selectedTables }, + set: { coordinator.windowSidebarState.selectedTables = $0 } ), pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 85eec2d1b..db4109c10 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -336,22 +336,13 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) func newTab(initialQuery: String? = nil) { - let issue1313 = Logger(subsystem: "com.TablePro", category: "Issue1313") - let tabsCount = coordinator?.tabManager.tabs.count ?? -1 - let selectedTabId = coordinator?.tabManager.selectedTabId?.uuidString ?? "nil" - let selectedIsPreview = coordinator?.tabManager.selectedTab?.isPreview ?? false - issue1313.info( - "[1313] MainContentCommandActions.newTab entry tabsCount=\(tabsCount) selectedTabId=\(selectedTabId, privacy: .public) selectedIsPreview=\(selectedIsPreview)" - ) if let coordinator, coordinator.tabManager.tabs.isEmpty { - issue1313.info("[1313] newTab branch=addToEmptyManager (no new window)") coordinator.tabManager.addTab( initialQuery: initialQuery, databaseName: coordinator.activeDatabaseName ) return } - issue1313.info("[1313] newTab branch=openNewWindow via WindowManager.openTab") let payload = EditorTabPayload( connectionId: connection.id, initialQuery: initialQuery, diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 864542161..2e1e51883 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -187,7 +187,7 @@ struct MainContentView: View { mode: .tables( connection: exportConnection, preselectedTables: coordinator.exportPreselectedTableNames - ?? Set(sidebarState.selectedTables.map(\.name)) + ?? Set(coordinator.windowSidebarState.selectedTables.map(\.name)) ), sidebarTables: tables ) @@ -379,9 +379,9 @@ struct MainContentView: View { handleConnectionStatusChange() } - .onChange(of: sidebarState.selectedTables) { oldTables, newTables in + .onChange(of: coordinator.windowSidebarState.selectedTables) { oldTables, newTables in guard !coordinator.isTearingDown else { - Self.lifecycleLogger.debug("[switch] sidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") + Self.lifecycleLogger.debug("[switch] windowSidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") return } handleTableSelectionChange(from: oldTables, to: newTables) @@ -389,13 +389,13 @@ struct MainContentView: View { .onChange(of: tables) { _, newTables in let syncAction = SidebarSyncAction.resolveOnTablesLoad( newTables: newTables, - selectedTables: sidebarState.selectedTables, + selectedTables: coordinator.windowSidebarState.selectedTables, currentTabTableName: tabManager.selectedTab?.tableContext.tableName ) if case .select(let tableName) = syncAction, let match = newTables.first(where: { $0.name == tableName }) { - sidebarState.selectedTables = [match] + coordinator.windowSidebarState.selectedTables = [match] } } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index d4f31ef45..34e4ff153 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -13,6 +13,7 @@ struct SidebarView: View { @Bindable private var schemaService = SchemaService.shared var sidebarState: SharedSidebarState + var windowState: WindowSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set @@ -44,13 +45,14 @@ struct SidebarView: View { private var selectedTablesBinding: Binding> { Binding( - get: { sidebarState.selectedTables }, - set: { sidebarState.selectedTables = $0 } + get: { windowState.selectedTables }, + set: { windowState.selectedTables = $0 } ) } init( sidebarState: SharedSidebarState, + windowState: WindowSidebarState, onDoubleClick: ((TableInfo) -> Void)? = nil, pendingTruncates: Binding>, pendingDeletes: Binding>, @@ -60,12 +62,13 @@ struct SidebarView: View { coordinator: MainContentCoordinator? = nil ) { self.sidebarState = sidebarState + self.windowState = windowState self.onDoubleClick = onDoubleClick _pendingTruncates = pendingTruncates _pendingDeletes = pendingDeletes let selectedBinding = Binding( - get: { sidebarState.selectedTables }, - set: { sidebarState.selectedTables = $0 } + get: { windowState.selectedTables }, + set: { windowState.selectedTables = $0 } ) let vm = SidebarViewModel( selectedTables: selectedBinding, @@ -228,7 +231,7 @@ struct SidebarView: View { onDoubleClick?(table) } .onExitCommand { - sidebarState.selectedTables.removeAll() + windowState.selectedTables.removeAll() } } @@ -277,7 +280,7 @@ struct SidebarView: View { .contextMenu { SidebarContextMenu( clickedTable: table, - selectedTables: sidebarState.selectedTables, + selectedTables: windowState.selectedTables, isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, @@ -343,6 +346,7 @@ struct SidebarView: View { #Preview { SidebarView( sidebarState: SharedSidebarState(), + windowState: WindowSidebarState(), pendingTruncates: .constant([]), pendingDeletes: .constant([]), tableOperationOptions: .constant([:]), From d2bf8fddb134a1c73ae54e99efb88a02e0d65e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 13:58:02 +0700 Subject: [PATCH 5/7] refactor(coordinator): stop polling schema on focus, trust file watcher --- CHANGELOG.md | 1 + .../Core/Services/Query/SchemaService.swift | 17 ----------------- ...MainContentCoordinator+WindowLifecycle.swift | 6 ------ .../Views/Main/MainContentCoordinator.swift | 13 ------------- 4 files changed, 1 insertion(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb8b578f..b56af253a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - New query tab (Cmd+T) no longer jumps focus back to the previous table tab on SQLite and other file-based databases (#1313) +- File-based databases (SQLite, DuckDB) no longer flash the sidebar table list every time a window becomes key; external file changes are picked up reactively via the file watcher instead of polling on focus - PostgreSQL connections to AWS RDS, Cloud SQL, Azure, and other hosted Postgres now succeed out of the box instead of failing with "no pg_hba.conf entry for host" (#1298) - Oracle: SSL/TCPS settings from the SSL pane are now respected; previously every Oracle connection was plain TCP regardless of SSL mode - Cassandra: SSL settings from the SSL pane are now respected; previously every Cassandra connection was plain TCP because the plugin read from a non-existent "sslMode" field diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 2131fd3d2..7badc73dc 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -17,7 +17,6 @@ final class SchemaService { private(set) var functions: [UUID: [RoutineInfo]] = [:] private(set) var schemasInOrder: [UUID: [String]] = [:] - @ObservationIgnored private var lastLoadDates: [UUID: Date] = [:] @ObservationIgnored private let loadDedup = OnceTask() @ObservationIgnored private let procedureDedup = OnceTask() @ObservationIgnored private let functionDedup = OnceTask() @@ -74,20 +73,6 @@ final class SchemaService { await runLoad(connectionId: connectionId, driver: driver, connection: connection) } - func reloadIfStale( - connectionId: UUID, - driver: DatabaseDriver, - connection: DatabaseConnection, - staleness: TimeInterval - ) async { - guard let lastLoad = lastLoadDates[connectionId] else { - await reload(connectionId: connectionId, driver: driver, connection: connection) - return - } - guard Date().timeIntervalSince(lastLoad) > staleness else { return } - await reload(connectionId: connectionId, driver: driver, connection: connection) - } - func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async { do { let routines = try await procedureDedup.execute(key: connectionId) { @@ -127,7 +112,6 @@ final class SchemaService { procedures.removeValue(forKey: connectionId) functions.removeValue(forKey: connectionId) schemasInOrder.removeValue(forKey: connectionId) - lastLoadDates.removeValue(forKey: connectionId) } func refresh(connectionId: UUID) async { @@ -176,7 +160,6 @@ final class SchemaService { states[connectionId] = .loaded(tables) procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions - lastLoadDates[connectionId] = Date() } catch is CancellationError { return } catch { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index e7b774b6c..a8ddd5076 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -29,12 +29,6 @@ extension MainContentCoordinator { evictionTask?.cancel() evictionTask = nil - let isConnected = - DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false - if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { - Task { await self.refreshTablesIfStale() } - } - syncSidebarToSelectedTab() Self.lifecycleLogger.debug( diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 01455e286..8c5ca9554 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -480,19 +480,6 @@ final class MainContentCoordinator { fileWatcher = watcher } - /// Refresh schema only if not recently refreshed (avoids redundant work - /// when both the file watcher and window focus trigger close together). - func refreshTablesIfStale() async { - guard let driver = services.databaseManager.driver(for: connectionId) else { return } - await services.schemaService.reloadIfStale( - connectionId: connectionId, - driver: driver, - connection: connection, - staleness: 2 - ) - await reconcilePostSchemaLoad() - } - func showAIChatPanel() { inspectorProxy?.showInspector() rightPanelState?.activeTab = .aiChat From 9522a95aba0d70bf5284a7c980a0979d35cd9054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 14:01:06 +0700 Subject: [PATCH 6/7] chore(file-watcher): log watch start and event fire for diagnostics --- TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift b/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift index f003d17de..824308a5f 100644 --- a/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift +++ b/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift @@ -85,9 +85,11 @@ final class DatabaseFileWatcher { activeSources[connectionId] = source source.resume() + Self.logger.info("watching connId=\(connectionId, privacy: .public) path=\(path, privacy: .public)") } private func handleEvent(connectionId: UUID) { + Self.logger.info("file event connId=\(connectionId, privacy: .public)") // Re-create the watcher to get a fresh file descriptor. // SQLite journaling (rename + recreate) can invalidate the old fd. startSource(connectionId: connectionId) From c1477a2614fa0621437225466996e6cdfb8ff7fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 14:15:20 +0700 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?scope=20searchText=20per-window,=20add=20tests,=20drop=20unrela?= =?UTF-8?q?ted=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/xcschemes/TablePro.xcscheme | 2 +- .../Infrastructure/DatabaseFileWatcher.swift | 2 +- .../SidebarContainerViewController.swift | 10 +- TablePro/Models/UI/SharedSidebarState.swift | 1 - TablePro/Models/UI/WindowSidebarState.swift | 1 + TablePro/Resources/Localizable.xcstrings | 6 -- .../MainContentView+EventHandlers.swift | 5 +- TablePro/Views/Sidebar/SidebarView.swift | 4 +- .../Query/QueryTabManagerTabTitleTests.swift | 1 + .../Models/SharedSidebarStateTests.swift | 96 ++----------------- .../ViewModels/WindowSidebarStateTests.swift | 50 ++++++++++ 11 files changed, 70 insertions(+), 108 deletions(-) create mode 100644 TableProTests/ViewModels/WindowSidebarStateTests.swift diff --git a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index a2d8da8aa..f99c67cbd 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> = 0 else { - Self.logger.warning("Cannot open database file for watching: \(path, privacy: .public)") + Self.logger.error("Cannot open database file for watching: \(path, privacy: .public) errno=\(errno)") return } diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 390066c4c..9b43e6fee 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -76,8 +76,8 @@ internal final class SidebarContainerViewController: NSViewController { generation: Int ) { withObservationTracking { - _ = state.searchText _ = state.selectedSidebarTab + _ = windowState.searchText _ = windowState.favoritesSearchText } onChange: { [weak self] in Task { @MainActor [weak self] in @@ -96,7 +96,7 @@ internal final class SidebarContainerViewController: NSViewController { let placeholder: String switch state.selectedSidebarTab { case .tables: - activeText = state.searchText + activeText = windowState.searchText placeholder = String(localized: "Filter") case .favorites: activeText = windowState.favoritesSearchText @@ -121,12 +121,12 @@ extension SidebarContainerViewController: NSSearchFieldDelegate { } private func writeSearchText(_ text: String) { - guard let sidebarState else { return } + guard let sidebarState, let windowState else { return } switch sidebarState.selectedSidebarTab { case .tables: - sidebarState.searchText = text + windowState.searchText = text case .favorites: - windowState?.favoritesSearchText = text + windowState.favoritesSearchText = text } } } diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 5869ce3e3..4c4817143 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -17,7 +17,6 @@ internal enum SidebarTab: String, CaseIterable { @MainActor @Observable final class SharedSidebarState { - var searchText: String = "" var redisKeyTreeViewModel: RedisKeyTreeViewModel? var selectedSidebarTab: SidebarTab { diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index 0ff6019ae..e09da4b3f 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -11,5 +11,6 @@ import TableProPluginKit @Observable internal final class WindowSidebarState { var selectedTables: Set = [] + var searchText: String = "" var favoritesSearchText: String = "" } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ce4fdef33..afebc68aa 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -35123,9 +35123,6 @@ } } } - }, - "Preferred connects in plain TCP for this driver. Use Required to enforce TCPS." : { - }, "Preferred performs a 2-pass connect: tries TLS first, falls back to plain only on SSL handshake errors. Required by Cloud SQL and Azure MySQL." : { @@ -47407,9 +47404,6 @@ }, "This driver does not expose routine DDL." : { - }, - "This driver has no TLS fallback. Preferred forces TLS, same as Required." : { - }, "This DROP query will permanently remove database objects. This action cannot be undone." : { "localizations" : { diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index e75137c70..6daa63dec 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -145,10 +145,9 @@ extension MainContentView { } else { target = [] } - let state = coordinator.windowSidebarState - if state.selectedTables != target { + if coordinator.windowSidebarState.selectedTables != target { if target.isEmpty && liveTables.isEmpty { return } - state.selectedTables = target + coordinator.windowSidebarState.selectedTables = target } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 34e4ff153..fb24fe9f2 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -78,7 +78,7 @@ struct SidebarView: View { databaseType: databaseType, connectionId: connectionId ) - vm.searchText = sidebarState.searchText + vm.searchText = windowState.searchText if databaseType == .redis, let existingVM = sidebarState.redisKeyTreeViewModel { vm.redisKeyTreeViewModel = existingVM } @@ -112,7 +112,7 @@ struct SidebarView: View { } } } - .onChange(of: sidebarState.searchText) { _, newValue in + .onChange(of: windowState.searchText) { _, newValue in viewModel.searchText = newValue } .onAppear { diff --git a/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift b/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift index 0988f3a57..c93905857 100644 --- a/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift +++ b/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @Suite("QueryTabManager.tabTitle") +@MainActor struct QueryTabManagerTabTitleTests { @Test("Returns plain name when schema is nil") func nilSchemaReturnsName() { diff --git a/TableProTests/Models/SharedSidebarStateTests.swift b/TableProTests/Models/SharedSidebarStateTests.swift index 793feddca..48226b15e 100644 --- a/TableProTests/Models/SharedSidebarStateTests.swift +++ b/TableProTests/Models/SharedSidebarStateTests.swift @@ -3,6 +3,8 @@ // TableProTests // // Tests for SharedSidebarState — per-connection shared sidebar state registry. +// Window-scoped state (selection, search) lives in WindowSidebarState; see +// WindowSidebarStateTests. // import Foundation @@ -54,100 +56,16 @@ struct SharedSidebarStateTests { SharedSidebarState.removeConnection(UUID()) } - // MARK: - Default State + // MARK: - Sidebar Tab Persistence - @Test("New instance has empty selectedTables") + @Test("selectedSidebarTab persists across registry lookups for same connection") @MainActor - func defaultSelectedTablesEmpty() { - let state = SharedSidebarState() - #expect(state.selectedTables.isEmpty) - } - - @Test("New instance has empty searchText") - @MainActor - func defaultSearchTextEmpty() { - let state = SharedSidebarState() - #expect(state.searchText.isEmpty) - } - - // MARK: - State Mutation - - @Test("Setting selectedTables persists") - @MainActor - func selectedTablesPersists() { - let state = SharedSidebarState() - let table = TestFixtures.makeTableInfo(name: "users") - state.selectedTables = [table] - #expect(state.selectedTables.count == 1) - #expect(state.selectedTables.first?.name == "users") - } - - @Test("Setting searchText persists") - @MainActor - func searchTextPersists() { - let state = SharedSidebarState() - state.searchText = "user" - #expect(state.searchText == "user") - } - - // MARK: - Shared Reference Semantics - - @Test("Changes via one reference are visible through another") - @MainActor - func sharedReferenceSemantics() { + func selectedSidebarTabPersists() { let id = UUID() let a = SharedSidebarState.forConnection(id) + a.selectedSidebarTab = .favorites let b = SharedSidebarState.forConnection(id) - let table = TestFixtures.makeTableInfo(name: "orders") - a.selectedTables = [table] - #expect(b.selectedTables.count == 1) - #expect(b.selectedTables.first?.name == "orders") - a.searchText = "ord" - #expect(b.searchText == "ord") + #expect(b.selectedSidebarTab == .favorites) SharedSidebarState.removeConnection(id) } - - @Test("Clearing selectedTables is visible through shared reference") - @MainActor - func clearingSelectionShared() { - let id = UUID() - let a = SharedSidebarState.forConnection(id) - let b = SharedSidebarState.forConnection(id) - a.selectedTables = [TestFixtures.makeTableInfo(name: "users")] - #expect(!b.selectedTables.isEmpty) - a.selectedTables = [] - #expect(b.selectedTables.isEmpty) - SharedSidebarState.removeConnection(id) - } - - // MARK: - Disconnect Cleanup - - @Test("removeConnection clears state for that connection") - @MainActor - func removeConnectionClearsState() { - let id = UUID() - let state = SharedSidebarState.forConnection(id) - state.selectedTables = [TestFixtures.makeTableInfo(name: "users")] - state.searchText = "us" - SharedSidebarState.removeConnection(id) - // New instance should have clean state - let fresh = SharedSidebarState.forConnection(id) - #expect(fresh.selectedTables.isEmpty) - #expect(fresh.searchText.isEmpty) - SharedSidebarState.removeConnection(id) - } - - @Test("removeConnection does not affect other connections") - @MainActor - func removeDoesNotAffectOthers() { - let id1 = UUID() - let id2 = UUID() - let state1 = SharedSidebarState.forConnection(id1) - let state2 = SharedSidebarState.forConnection(id2) - state1.selectedTables = [TestFixtures.makeTableInfo(name: "a")] - state2.selectedTables = [TestFixtures.makeTableInfo(name: "b")] - SharedSidebarState.removeConnection(id1) - #expect(state2.selectedTables.first?.name == "b") - SharedSidebarState.removeConnection(id2) - } } diff --git a/TableProTests/ViewModels/WindowSidebarStateTests.swift b/TableProTests/ViewModels/WindowSidebarStateTests.swift new file mode 100644 index 000000000..152d470bb --- /dev/null +++ b/TableProTests/ViewModels/WindowSidebarStateTests.swift @@ -0,0 +1,50 @@ +// +// WindowSidebarStateTests.swift +// TableProTests +// +// Pins per-window scoping of sidebar state. Regression guard for #1313 where +// selectedTables was shared across windows of the same connection, causing +// Cmd+T to jump focus back to a sibling window. +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@MainActor +struct WindowSidebarStateTests { + @Test + func twoInstancesHoldIndependentSelection() { + let windowA = WindowSidebarState() + let windowB = WindowSidebarState() + + let users = TestFixtures.makeTableInfo(name: "users") + windowA.selectedTables = [users] + + #expect(windowA.selectedTables == [users]) + #expect(windowB.selectedTables.isEmpty) + } + + @Test + func twoInstancesHoldIndependentSearchText() { + let windowA = WindowSidebarState() + let windowB = WindowSidebarState() + + windowA.searchText = "users" + + #expect(windowA.searchText == "users") + #expect(windowB.searchText.isEmpty) + } + + @Test + func twoInstancesHoldIndependentFavoritesSearch() { + let windowA = WindowSidebarState() + let windowB = WindowSidebarState() + + windowA.favoritesSearchText = "daily" + + #expect(windowA.favoritesSearchText == "daily") + #expect(windowB.favoritesSearchText.isEmpty) + } +}