Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
705e351
fix: Cmd+W clears last tab instead of closing connection window
datlechin Apr 17, 2026
cd58a60
chore: add lifecycle logging to WindowLifecycleMonitor
datlechin Apr 17, 2026
b8fb4c6
refactor: replace SwiftUI WindowGroup with NSWindowController for mai…
datlechin Apr 17, 2026
921e8c8
fix: publish command actions on configureWindow so Cmd+T works after …
datlechin Apr 17, 2026
734ad92
fix: address ultrareview findings on window/toolbar refactor
datlechin Apr 17, 2026
7bd15be
fix: open ER Diagram and Server Dashboard in new window tab instead o…
datlechin Apr 17, 2026
73d8aaf
docs: simplify CHANGELOG entry for ER Diagram/Server Dashboard fix
datlechin Apr 17, 2026
c5a47e4
docs: simplify unreleased CHANGELOG entries
datlechin Apr 17, 2026
7b9ad3d
refactor: address code review findings
datlechin Apr 18, 2026
4ec9d84
fix: add debug logging for rapid tab switch/close delay investigation
datlechin Apr 18, 2026
9655022
fix: defer body content and coalesce schema loads for rapid tab opera…
datlechin Apr 18, 2026
22e7d30
fix: add debug logging for lifecycle guards, schema coalescing, and d…
datlechin Apr 18, 2026
62610b7
fix: show 'ER Diagram' as window tab title instead of 'SQL Query'
datlechin Apr 18, 2026
a99e2a9
refactor: fix spinner flash, toolbar retention, localization, tab tit…
datlechin Apr 18, 2026
ccfa0ee
refactor: downgrade hot-path logging to debug level
datlechin Apr 18, 2026
5ae9a82
fix: Cmd+W on first connect now clears to empty state instead of clos…
datlechin Apr 18, 2026
8e021ca
fix: route Cmd+W through closeTab via EditorWindow.performClose override
datlechin Apr 18, 2026
c17933b
docs: simplify unreleased CHANGELOG entries
datlechin Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Main editor window rewritten on AppKit (`NSWindowController` + `NSToolbar`) for faster tab opens and correct lifecycle
- Toolbar layout follows Apple HIG (sidebar left, connection center, view actions right)

### Fixed

- Cmd+W closing the connection window instead of clearing to empty state
- ER Diagram and Server Dashboard replacing the current tab instead of opening a new one
- Welcome window stealing focus on connect, disabling Cmd+T until manual click
- Toolbar empty on second tab, menu shortcuts disabled after toolbar click

### Added

- Handoff via NSUserActivity

## [0.32.1] - 2026-04-17

### Changed
Expand Down
4 changes: 2 additions & 2 deletions TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ extension AppDelegate {
NSWindow.allowsAutomaticWindowTabbing = false
}
let payload = EditorTabPayload(connectionId: connection.id)
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)
}

// MARK: - Post-Connect Actions
Expand All @@ -350,7 +350,7 @@ extension AppDelegate {
tableName: tableName,
isView: parsed.isView
)
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)

if parsed.filterColumn != nil || parsed.filterCondition != nil {
await waitForNotification(.refreshData, timeout: .seconds(3))
Expand Down
12 changes: 6 additions & 6 deletions TablePro/AppDelegate+FileOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ extension AppDelegate {
if DatabaseManager.shared.activeSessions[connectionId]?.driver != nil {
if let tableName {
let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName)
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)
} else {
for window in NSApp.windows where isMainWindow(window) {
window.makeKeyAndOrderFront(nil)
Expand All @@ -46,7 +46,7 @@ extension AppDelegate {
}

let initialPayload = EditorTabPayload(connectionId: connectionId)
WindowOpener.shared.openNativeTab(initialPayload)
WindowManager.shared.openTab(payload: initialPayload)

Task { @MainActor in
do {
Expand All @@ -56,7 +56,7 @@ extension AppDelegate {
}
if let tableName {
let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName)
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)
}
} catch {
fileOpenLogger.error("Handoff connect failed: \(error.localizedDescription)")
Expand Down Expand Up @@ -227,7 +227,7 @@ extension AppDelegate {

if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil {
if let payload = makePayload?(connection.id) {
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)
} else {
for window in NSApp.windows where isMainWindow(window) {
window.makeKeyAndOrderFront(nil)
Expand All @@ -243,7 +243,7 @@ extension AppDelegate {
}

let deeplinkPayload = EditorTabPayload(connectionId: connection.id)
WindowOpener.shared.openNativeTab(deeplinkPayload)
WindowManager.shared.openTab(payload: deeplinkPayload)

Task { @MainActor in
do {
Expand All @@ -266,7 +266,7 @@ extension AppDelegate {
window.close()
}
if let payload = makePayload?(connection.id) {
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)
}
} catch {
fileOpenLogger.error("Deep link connect failed: \(error.localizedDescription)")
Expand Down
71 changes: 17 additions & 54 deletions TablePro/AppDelegate+WindowConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ extension AppDelegate {
@objc func newWindowForTab(_ sender: Any?) {
guard let keyWindow = NSApp.keyWindow,
let connectionId = MainActor.assumeIsolated({
WindowLifecycleMonitor.shared.connectionId(fromWindow: keyWindow)
WindowLifecycleMonitor.shared.connectionId(forWindow: keyWindow)
})
else { return }

Expand All @@ -74,7 +74,7 @@ extension AppDelegate {
intent: .newEmptyTab
)
MainActor.assumeIsolated {
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)
}
}

Expand All @@ -84,7 +84,7 @@ extension AppDelegate {
guard let connection = connections.first(where: { $0.id == connectionId }) else { return }

let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)

Task { @MainActor in
do {
Expand Down Expand Up @@ -252,71 +252,34 @@ extension AppDelegate {
closeWelcomeWindowIfMainExists()
}

if isMainWindow(window) && !configuredWindows.contains(windowId) {
window.tabbingMode = .preferred
window.isRestorable = false
configuredWindows.insert(windowId)

let pendingConnectionId = MainActor.assumeIsolated {
WindowOpener.shared.consumeOldestPendingConnectionId()
}

if pendingConnectionId == nil && !isAutoReconnecting {
if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 {
return
}
window.orderOut(nil)
return
}

if let connectionId = pendingConnectionId {
let groupAll = MainActor.assumeIsolated { AppSettingsManager.shared.tabs.groupAllConnectionTabs }
let resolvedIdentifier = WindowOpener.tabbingIdentifier(for: connectionId)
window.tabbingIdentifier = resolvedIdentifier

if !NSWindow.allowsAutomaticWindowTabbing {
NSWindow.allowsAutomaticWindowTabbing = true
}

let matchingWindow: NSWindow?
if groupAll {
let existingMainWindows = NSApp.windows.filter {
$0 !== window && isMainWindow($0) && $0.isVisible
}
for existing in existingMainWindows {
existing.tabbingIdentifier = resolvedIdentifier
}
matchingWindow = existingMainWindows.first
} else {
matchingWindow = NSApp.windows.first {
$0 !== window && isMainWindow($0) && $0.isVisible
&& $0.tabbingIdentifier == resolvedIdentifier
}
}
if let existingWindow = matchingWindow {
let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow
targetWindow.addTabbedWindow(window, ordered: .above)
window.makeKeyAndOrderFront(nil)
}
}
}
// Phase 5: removed legacy main-window tabbing block. `WindowManager.openTab`
// now performs the tab-group merge at creation time with the correct
// ordering, and pre-marks `configuredWindows` so this method is a no-op
// for main windows. The old block consumed `WindowOpener.pendingPayloads`
// and called `addTabbedWindow` mid-`windowDidBecomeKey`, which produced
// the 200–7000 ms grace-period delay we removed in Phase 2.
}

@objc func windowWillClose(_ notification: Notification) {
let seq = MainContentCoordinator.nextSwitchSeq()
let t0 = Date()
guard let window = notification.object as? NSWindow else { return }
let isMain = isMainWindow(window)

configuredWindows.remove(ObjectIdentifier(window))

if isMainWindow(window) {
if isMain {
let remainingMainWindows = NSApp.windows.filter {
$0 !== window && isMainWindow($0) && $0.isVisible
}.count
windowLogger.info("[close] AppDelegate.windowWillClose seq=\(seq) isMain=true remaining=\(remainingMainWindows)")

if remainingMainWindows == 0 {
NotificationCenter.default.post(name: .mainWindowWillClose, object: nil)
openWelcomeWindow()
}
}
windowLogger.info("[close] AppDelegate.windowWillClose seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))")
}

@objc func windowDidChangeOcclusionState(_ notification: Notification) {
Expand Down Expand Up @@ -355,7 +318,7 @@ extension AppDelegate {

for connection in validConnections {
let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)

do {
try await DatabaseManager.shared.connectToSession(connection)
Expand Down Expand Up @@ -400,7 +363,7 @@ extension AppDelegate {
Task { @MainActor [weak self] in
guard let self else { return }
let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)
WindowOpener.shared.openNativeTab(payload)
WindowManager.shared.openTab(payload: payload)

defer { self.isAutoReconnecting = false }
do {
Expand Down
1 change: 1 addition & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ internal extension URL {
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private static let logger = Logger(subsystem: "com.TablePro", category: "AppDelegate")
static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle")

/// Track windows that have been configured to avoid re-applying styles
var configuredWindows = Set<ObjectIdentifier>()
Expand Down
68 changes: 42 additions & 26 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import TableProPluginKit

struct ContentView: View {
private static let logger = Logger(subsystem: "com.TablePro", category: "ContentView")
private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle")

/// Payload identifying what this native window-tab should display.
/// nil = default empty query tab (first window on connection).
Expand All @@ -35,10 +36,18 @@ struct ContentView: View {
private let storage = ConnectionStorage.shared

init(payload: EditorTabPayload?) {
let initStart = Date()
Self.lifecycleLogger.info(
"[open] ContentView.init start payloadId=\(payload?.id.uuidString ?? "nil", privacy: .public) connId=\(payload?.connectionId.uuidString ?? "nil", privacy: .public) tabType=\(String(describing: payload?.tabType), privacy: .public)"
)
self.payload = payload
let defaultTitle: String
if payload?.tabType == .serverDashboard {
defaultTitle = String(localized: "Server Dashboard")
} else if payload?.tabType == .erDiagram {
defaultTitle = String(localized: "ER Diagram")
} else if payload?.tabType == .createTable {
defaultTitle = String(localized: "Create Table")
} else if let tabTitle = payload?.tabTitle {
defaultTitle = tabTitle
} else if let tableName = payload?.tableName {
Expand All @@ -65,9 +74,27 @@ struct ContentView: View {

if let session = resolvedSession {
_rightPanelState = State(initialValue: RightPanelState())
let state = SessionStateFactory.create(
connection: session.connection, payload: payload
)
let factoryStart = Date()
// Prefer the SessionState that `WindowManager.openTab` created
// eagerly (so the NSToolbar could be installed in
// `TabWindowController.init` without a flash). Fall back to
// creating one here for code paths that bypass WindowManager
// (currently none in production — kept defensively).
let state: SessionStateFactory.SessionState
if let payloadId = payload?.id,
let pending = SessionStateFactory.consumePending(for: payloadId) {
state = pending
Self.lifecycleLogger.info(
"[open] ContentView.init SessionStateFactory consumed pending payloadId=\(payloadId, privacy: .public) connId=\(session.connection.id, privacy: .public)"
)
} else {
state = SessionStateFactory.create(
connection: session.connection, payload: payload
)
Self.lifecycleLogger.info(
"[open] ContentView.init SessionStateFactory.create elapsedMs=\(Int(Date().timeIntervalSince(factoryStart) * 1_000)) connId=\(session.connection.id, privacy: .public)"
)
}
_sessionState = State(initialValue: state)
if payload?.intent == .newEmptyTab,
let tabTitle = state.coordinator.tabManager.selectedTab?.title {
Expand All @@ -77,6 +104,9 @@ struct ContentView: View {
_rightPanelState = State(initialValue: nil)
_sessionState = State(initialValue: nil)
}
Self.lifecycleLogger.info(
"[open] ContentView.init done payloadId=\(payload?.id.uuidString ?? "nil", privacy: .public) hasSession=\(resolvedSession != nil) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1_000))"
)
}

var body: some View {
Expand Down Expand Up @@ -117,10 +147,12 @@ struct ContentView: View {
rightPanelState = RightPanelState()
}
if sessionState == nil {
let t0 = Date()
sessionState = SessionStateFactory.create(
connection: session.connection,
payload: payload
)
Self.lifecycleLogger.info("[open] ContentView.onChange(currentSessionId) created SessionState connId=\(session.connection.id, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))")
}
}
} else {
Expand All @@ -132,29 +164,10 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .connectionStatusDidChange)) { _ in
handleConnectionStatusChange()
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in
// Only process notifications for our own window to avoid every
// ContentView instance re-rendering on every window focus change.
// Match by checking if the window is registered for our connectionId
// in WindowLifecycleMonitor (subtitle may not be set yet on first appear).
guard let notificationWindow = notification.object as? NSWindow,
let windowId = notificationWindow.identifier?.rawValue,
windowId == "main" || windowId.hasPrefix("main-"),
let connectionId = payload?.connectionId
else { return }

// Verify this notification is for our window. Check WindowLifecycleMonitor
// first (reliable after onAppear registers), fall back to subtitle match
// for the brief window before registration completes.
let isOurWindow = WindowLifecycleMonitor.shared.windows(for: connectionId)
.contains(where: { $0 === notificationWindow })
|| {
guard let name = currentSession?.connection.name, !name.isEmpty else { return false }
return notificationWindow.subtitle == name
|| notificationWindow.subtitle == "\(name) — Preview"
}()
guard isOurWindow else { return }
}
// Phase 2: removed global `NSWindow.didBecomeKeyNotification` observer.
// Window focus is now routed via `TabWindowController` NSWindowDelegate
// directly into `MainContentCoordinator.handleWindowDidBecomeKey`,
// eliminating the per-ContentView-instance fan-out.
}

// MARK: - View Components
Expand Down Expand Up @@ -326,6 +339,7 @@ struct ContentView: View {
}
guard let newSession = sessions[sid] else {
if currentSession?.id == sid {
Self.lifecycleLogger.info("[close] ContentView.handleConnectionStatusChange session removed connId=\(sid, privacy: .public)")
closingSessionId = sid
rightPanelState?.teardown()
rightPanelState = nil
Expand Down Expand Up @@ -353,10 +367,12 @@ struct ContentView: View {
rightPanelState = RightPanelState()
}
if sessionState == nil {
let t0 = Date()
sessionState = SessionStateFactory.create(
connection: newSession.connection,
payload: payload
)
Self.lifecycleLogger.info("[open] ContentView.handleConnectionStatusChange created SessionState connId=\(newSession.connection.id, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))")
}
}

Expand Down
Loading
Loading