Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Replace native macOS window tabs with in-app tab bar for instant tab switching (was 600ms+ per tab)
- Tab restoration now loads all tabs in a single window instead of opening N separate windows

### Fixed

- Raw SQL injection via external URL scheme deeplinks — now requires user confirmation
Expand Down
28 changes: 23 additions & 5 deletions TablePro/AppDelegate+WindowConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import os
import SwiftUI

private let windowLogger = Logger(subsystem: "com.TablePro", category: "WindowConfig")
private let windowPerfLog = OSSignposter(subsystem: "com.TablePro", category: "WindowPerf")

extension AppDelegate {
// MARK: - Dock Menu
Expand Down Expand Up @@ -63,19 +64,20 @@ extension AppDelegate {
}

@objc func newWindowForTab(_ sender: Any?) {
let start = ContinuousClock.now
guard let keyWindow = NSApp.keyWindow,
let connectionId = MainActor.assumeIsolated({
WindowLifecycleMonitor.shared.connectionId(fromWindow: keyWindow)
})
else { return }

let payload = EditorTabPayload(
connectionId: connectionId,
intent: .newEmptyTab
)
// Add an in-app tab to the active coordinator instead of creating a new native window
MainActor.assumeIsolated {
WindowOpener.shared.openNativeTab(payload)
if let coordinator = MainContentCoordinator.firstCoordinator(for: connectionId) {
coordinator.addNewQueryTab()
}
}
windowLogger.info("[PERF] newWindowForTab: \(ContinuousClock.now - start)")
}

@objc func connectFromDock(_ sender: NSMenuItem) {
Expand Down Expand Up @@ -223,6 +225,7 @@ extension AppDelegate {
// MARK: - Window Notifications

@objc func windowDidBecomeKey(_ notification: Notification) {
let becomeKeyStart = ContinuousClock.now
guard let window = notification.object as? NSWindow else { return }
let windowId = ObjectIdentifier(window)

Expand Down Expand Up @@ -253,19 +256,23 @@ extension AppDelegate {
}

if isMainWindow(window) && !configuredWindows.contains(windowId) {
windowLogger.info("[PERF] windowDidBecomeKey: configuring new main window (elapsed so far: \(ContinuousClock.now - becomeKeyStart))")
window.tabbingMode = .preferred
window.isRestorable = false
configuredWindows.insert(windowId)

let pendingConnectionId = MainActor.assumeIsolated {
WindowOpener.shared.consumeOldestPendingConnectionId()
}
windowLogger.info("[PERF] windowDidBecomeKey: consumeOldestPending=\(String(describing: pendingConnectionId)), isAutoReconnecting=\(self.isAutoReconnecting) (elapsed: \(ContinuousClock.now - becomeKeyStart))")

if pendingConnectionId == nil && !isAutoReconnecting {
if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 {
windowLogger.info("[PERF] windowDidBecomeKey: orphan window already tabbed, returning (total: \(ContinuousClock.now - becomeKeyStart))")
return
}
window.orderOut(nil)
windowLogger.info("[PERF] windowDidBecomeKey: orphan window hidden (total: \(ContinuousClock.now - becomeKeyStart))")
return
}

Expand All @@ -278,6 +285,7 @@ extension AppDelegate {
NSWindow.allowsAutomaticWindowTabbing = true
}

let windowLookupStart = ContinuousClock.now
let matchingWindow: NSWindow?
if groupAll {
let existingMainWindows = NSApp.windows.filter {
Expand All @@ -293,16 +301,24 @@ extension AppDelegate {
&& $0.tabbingIdentifier == resolvedIdentifier
}
}
windowLogger.info("[PERF] windowDidBecomeKey: window lookup took \(ContinuousClock.now - windowLookupStart), totalWindows=\(NSApp.windows.count), groupAll=\(groupAll)")

if let existingWindow = matchingWindow {
let mergeStart = ContinuousClock.now
let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow
targetWindow.addTabbedWindow(window, ordered: .above)
window.makeKeyAndOrderFront(nil)
windowLogger.info("[PERF] windowDidBecomeKey: addTabbedWindow took \(ContinuousClock.now - mergeStart)")
}
}
windowLogger.info("[PERF] windowDidBecomeKey: main window config TOTAL=\(ContinuousClock.now - becomeKeyStart)")
} else {
windowLogger.info("[PERF] windowDidBecomeKey: non-main or already configured (total: \(ContinuousClock.now - becomeKeyStart))")
}
}

@objc func windowWillClose(_ notification: Notification) {
let closeStart = ContinuousClock.now
guard let window = notification.object as? NSWindow else { return }

configuredWindows.remove(ObjectIdentifier(window))
Expand All @@ -311,12 +327,14 @@ extension AppDelegate {
let remainingMainWindows = NSApp.windows.filter {
$0 !== window && isMainWindow($0) && $0.isVisible
}.count
windowLogger.info("[PERF] windowWillClose: isMainWindow=true, remainingMainWindows=\(remainingMainWindows), totalWindows=\(NSApp.windows.count)")

if remainingMainWindows == 0 {
NotificationCenter.default.post(name: .mainWindowWillClose, object: nil)
openWelcomeWindow()
}
}
windowLogger.info("[PERF] windowWillClose: total=\(ContinuousClock.now - closeStart)")
}

@objc func windowDidChangeOcclusionState(_ notification: Notification) {
Expand Down
3 changes: 3 additions & 0 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct ContentView: View {
private let storage = ConnectionStorage.shared

init(payload: EditorTabPayload?) {
let initStart = ContinuousClock.now
self.payload = payload
let defaultTitle: String
if payload?.tabType == .serverDashboard {
Expand Down Expand Up @@ -62,6 +63,7 @@ struct ContentView: View {
resolvedSession = DatabaseManager.shared.activeSessions[currentId]
}
_currentSession = State(initialValue: resolvedSession)
let sessionResolved = ContinuousClock.now

if let session = resolvedSession {
_rightPanelState = State(initialValue: RightPanelState())
Expand All @@ -77,6 +79,7 @@ struct ContentView: View {
_rightPanelState = State(initialValue: nil)
_sessionState = State(initialValue: nil)
}
Self.logger.info("[PERF] ContentView.init: total=\(ContinuousClock.now - initStart), sessionResolve=\(sessionResolved - initStart), stateFactory=\(ContinuousClock.now - sessionResolved)")
}

var body: some View {
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,21 +241,29 @@ extension DatabaseManager {

/// Disconnect a specific session
func disconnectSession(_ sessionId: UUID) async {
let disconnStart = ContinuousClock.now
guard let session = activeSessions[sessionId] else { return }

// Close SSH tunnel if exists
if session.connection.resolvedSSHConfig.enabled {
let sshStart = ContinuousClock.now
do {
try await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id)
} catch {
Self.logger.warning("SSH tunnel cleanup failed for \(session.connection.name): \(error.localizedDescription)")
}
Self.logger.info("[PERF] disconnectSession: SSH tunnel close=\(ContinuousClock.now - sshStart)")
}

// Stop health monitoring
let healthStart = ContinuousClock.now
await stopHealthMonitor(for: sessionId)
Self.logger.info("[PERF] disconnectSession: stopHealthMonitor=\(ContinuousClock.now - healthStart)")

let driverStart = ContinuousClock.now
session.driver?.disconnect()
Self.logger.info("[PERF] disconnectSession: driver.disconnect=\(ContinuousClock.now - driverStart)")

removeSessionEntry(for: sessionId)

// Clean up shared schema cache for this connection
Expand All @@ -274,6 +282,7 @@ extension DatabaseManager {
AppSettingsStorage.shared.saveLastConnectionId(nil)
}
}
Self.logger.info("[PERF] disconnectSession: TOTAL=\(ContinuousClock.now - disconnStart)")
}

/// Disconnect all sessions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
//

import Foundation
import os

private let sessionFactoryLogger = Logger(subsystem: "com.TablePro", category: "SessionStateFactory")

@MainActor
enum SessionStateFactory {
Expand All @@ -23,6 +26,7 @@ enum SessionStateFactory {
connection: DatabaseConnection,
payload: EditorTabPayload?
) -> SessionState {
let factoryStart = ContinuousClock.now
let tabMgr = QueryTabManager()
let changeMgr = DataChangeManager()
changeMgr.databaseType = connection.type
Expand Down Expand Up @@ -111,6 +115,7 @@ enum SessionStateFactory {
}
}

let preCoordTime = ContinuousClock.now
let coord = MainContentCoordinator(
connection: connection,
tabManager: tabMgr,
Expand All @@ -119,6 +124,9 @@ enum SessionStateFactory {
columnVisibilityManager: colVisMgr,
toolbarState: toolbarSt
)
let coordTime = ContinuousClock.now

sessionFactoryLogger.info("[PERF] SessionStateFactory.create total=\(ContinuousClock.now - factoryStart), coordinator.init=\(coordTime - preCoordTime), tabSetup=\(preCoordTime - factoryStart)")

return SessionState(
tabManager: tabMgr,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

import Foundation
import Observation
import os

private let persistLogger = Logger(subsystem: "com.TablePro", category: "TabPersistence")

/// Result of tab restoration from disk
internal struct RestoreResult {
Expand Down Expand Up @@ -99,15 +102,20 @@ internal final class TabPersistenceCoordinator {

/// Restore tabs from disk. Called once at window creation.
internal func restoreFromDisk() async -> RestoreResult {
let start = ContinuousClock.now
guard let state = await TabDiskActor.shared.load(connectionId: connectionId) else {
persistLogger.info("[PERF] restoreFromDisk: no saved state (\(ContinuousClock.now - start))")
return RestoreResult(tabs: [], selectedTabId: nil, source: .none)
}

guard !state.tabs.isEmpty else {
persistLogger.info("[PERF] restoreFromDisk: empty tabs (\(ContinuousClock.now - start))")
return RestoreResult(tabs: [], selectedTabId: nil, source: .none)
}

let mapStart = ContinuousClock.now
let restoredTabs = state.tabs.map { QueryTab(from: $0) }
persistLogger.info("[PERF] restoreFromDisk: diskLoad=\(mapStart - start), tabMapping=\(ContinuousClock.now - mapStart), totalTabs=\(restoredTabs.count), total=\(ContinuousClock.now - start)")
return RestoreResult(
tabs: restoredTabs,
selectedTabId: state.selectedTabId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ internal final class WindowLifecycleMonitor {
}

private func handleWindowClose(_ closedWindow: NSWindow) {
let closeStart = ContinuousClock.now
guard let (windowId, entry) = entries.first(where: { $0.value.window === closedWindow }) else {
Self.logger.info("[PERF] handleWindowClose: window not found in entries")
return
}

Expand All @@ -214,9 +216,12 @@ internal final class WindowLifecycleMonitor {
let hasRemainingWindows = entries.values.contains {
$0.connectionId == closedConnectionId && $0.window != nil
}
Self.logger.info("[PERF] handleWindowClose: cleanup took \(ContinuousClock.now - closeStart), hasRemainingWindows=\(hasRemainingWindows), remainingEntries=\(self.entries.count)")
if !hasRemainingWindows {
Task {
let disconnectStart = ContinuousClock.now
await DatabaseManager.shared.disconnectSession(closedConnectionId)
Self.logger.info("[PERF] handleWindowClose: disconnectSession took \(ContinuousClock.now - disconnectStart)")
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Services/Infrastructure/WindowOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ internal final class WindowOpener {
/// Falls back to .openMainWindow notification if openWindow is not yet available
/// (cold launch from Dock menu before any SwiftUI view has appeared).
internal func openNativeTab(_ payload: EditorTabPayload) {
let start = ContinuousClock.now
pendingPayloads.append((id: payload.id, connectionId: payload.connectionId))
if let openWindow {
openWindow(id: "main", value: payload)
} else {
Self.logger.info("openWindow not set — falling back to .openMainWindow notification")
NotificationCenter.default.post(name: .openMainWindow, object: payload)
}
let elapsed = ContinuousClock.now - start
Self.logger.info("[PERF] openNativeTab: \(elapsed) (intent=\(String(describing: payload.intent)), pendingCount=\(self.pendingPayloads.count))")
}

/// Called by MainContentView.configureWindow after the window is fully set up.
Expand Down
8 changes: 4 additions & 4 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -457,16 +457,16 @@ struct AppMenuCommands: Commands {

Divider()

// Previous tab (Cmd+Shift+[) — delegate to native macOS tab switching
// Previous tab (Cmd+Shift+[) — in-app tab switching
Button("Show Previous Tab") {
NSApp.sendAction(#selector(NSWindow.selectPreviousTab(_:)), to: nil, from: nil)
actions?.selectPreviousTab()
}
.optionalKeyboardShortcut(shortcut(for: .showPreviousTab))
.disabled(!(actions?.isConnected ?? false))

// Next tab (Cmd+Shift+]) — delegate to native macOS tab switching
// Next tab (Cmd+Shift+]) — in-app tab switching
Button("Show Next Tab") {
NSApp.sendAction(#selector(NSWindow.selectNextTab(_:)), to: nil, from: nil)
actions?.selectNextTab()
}
.optionalKeyboardShortcut(shortcut(for: .showNextTab))
.disabled(!(actions?.isConnected ?? false))
Expand Down
21 changes: 19 additions & 2 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,25 @@ struct MainEditorContentView: View {
let isHistoryVisible = coordinator.toolbarState.isHistoryPanelVisible

VStack(spacing: 0) {
// Native macOS window tabs replace the custom tab bar.
// Each window-tab contains a single tab — no ZStack keep-alive needed.
if tabManager.tabs.count > 1 || !tabManager.tabs.isEmpty {
EditorTabBar(
tabs: tabManager.tabs,
selectedTabId: Binding(
get: { tabManager.selectedTabId },
set: { tabManager.selectedTabId = $0 }
),
databaseType: connection.type,
onClose: { id in coordinator.closeInAppTab(id) },
onCloseOthers: { id in coordinator.closeOtherTabs(excluding: id) },
onCloseAll: { coordinator.closeAllTabs() },
onReorder: { tabs in coordinator.reorderTabs(tabs) },
onRename: { id, name in coordinator.renameTab(id, to: name) },
onAddTab: { coordinator.addNewQueryTab() },
onDuplicate: { id in coordinator.duplicateTab(id) }
)
Divider()
}

if let tab = tabManager.selectedTab {
tabContent(for: tab)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,24 +53,26 @@ extension MainContentCoordinator {
return
}

// If current tab has unsaved changes, open in a new native tab instead of replacing
// If current tab has unsaved changes, open in a new in-app tab instead of replacing
if changeManager.hasChanges {
let fkFilterState = TabFilterState(
filters: [filter],
appliedFilters: [filter],
isVisible: true,
filterLogicMode: .and
)
let payload = EditorTabPayload(
connectionId: connection.id,
tabType: .table,
tabManager.addTableTab(
tableName: referencedTable,
databaseName: currentDatabase,
schemaName: targetSchema,
isView: false,
initialFilterState: fkFilterState
databaseType: connection.type,
databaseName: currentDatabase
)
WindowOpener.shared.openNativeTab(payload)
if let tabIndex = tabManager.selectedTabIndex {
tabManager.tabs[tabIndex].schemaName = targetSchema
tabManager.tabs[tabIndex].filterState = fkFilterState
filterStateManager.restoreFromTabState(fkFilterState)
}
restoreColumnLayoutForTable(referencedTable)
runQuery()
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@ extension MainContentCoordinator {
return
}

let payload = EditorTabPayload(
connectionId: connection.id,
tabType: .query,
databaseName: connection.database,
initialQuery: favorite.query
)
WindowOpener.shared.openNativeTab(payload)
tabManager.addTab(initialQuery: favorite.query, databaseName: connection.database)
}
}
Loading
Loading