Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b0f6e27
refactor: replace native window tabs with in-app tab bar for instant …
datlechin Apr 15, 2026
71708c0
fix: resolve multiple in-app tab bar bugs and clean up stale references
datlechin Apr 16, 2026
2043e05
fix: remove PERF debug logging, fix stale native tab references
datlechin Apr 16, 2026
73f4741
fix: defer SessionState creation from ContentView.init to view lifecycle
datlechin Apr 16, 2026
745675a
fix: restore onDisappear grace period, remove race-prone disconnectSe…
datlechin Apr 16, 2026
e312398
refactor: move coordinator teardown from onDisappear to NSWindow will…
datlechin Apr 16, 2026
61af26a
fix: persist open connection IDs incrementally per Apple guidelines
datlechin Apr 16, 2026
aeb41eb
feat: add reopen closed tab, MRU selection, and pinned tabs
datlechin Apr 16, 2026
e03d2da
fix: scroll tab bar to active tab on initial load
datlechin Apr 16, 2026
042da88
fix: remove unnecessary Task.yield delay from tab switching
datlechin Apr 16, 2026
f7c6de3
fix: remove tab-switch row eviction that caused re-fetch delays
datlechin Apr 16, 2026
208655a
fix: remove window-resign row eviction that caused re-fetch on tab sw…
datlechin Apr 16, 2026
405c37a
fix: skip redundant display format detection on cached tab switch
datlechin Apr 16, 2026
725c22c
perf: keep tab views alive across switches (NSTabViewController pattern)
datlechin Apr 16, 2026
cc81892
perf: eliminate redundant reloadVersion bump and cascading re-evals
datlechin Apr 16, 2026
1793bd3
perf: two-phase tab switch — instant visual, deferred state restore
datlechin Apr 16, 2026
b0b1862
fix: cancel previous deferred tab switch on rapid Cmd+1/Cmd+2 spam
datlechin Apr 16, 2026
59e3114
perf: remove incoming state restoration from tab switch entirely
datlechin Apr 16, 2026
a52f95c
debug: add [DBG] logging to trace tab switch body re-evaluation cascade
datlechin Apr 16, 2026
e58a8d3
perf: defer title/sidebar/persist to Phase 2, skip .task during rapid…
datlechin Apr 16, 2026
5ff6efe
perf: remove .task(id: tableName) that queued 28+ tasks during rapid …
datlechin Apr 16, 2026
199f5f7
perf: zero synchronous mutations on tab switch — fully deferred
datlechin Apr 16, 2026
073151a
perf: guard all onChange handlers with isHandlingTabSwitch
datlechin Apr 16, 2026
5f1a434
perf: throttle keyboard tab switch commands during active switch
datlechin Apr 16, 2026
056b32b
fix: replace aggressive throttle with same-tab dedup for keyboard repeat
datlechin Apr 16, 2026
ca5e05c
refactor: replace ZStack+opacity with AppKit tab container for instan…
datlechin Apr 16, 2026
9edf780
fix: resolve remaining critical/high tab bar bugs (round 2)
datlechin Apr 16, 2026
ce538b8
fix: resolve medium/low tab bar issues (round 3)
datlechin Apr 16, 2026
c8f146e
fix: contentVersion hash collision causing empty DataGrid after query
datlechin Apr 16, 2026
96d0e9f
fix: route deeplinks and Handoff to in-app tabs instead of new windows
datlechin Apr 16, 2026
bffcdbd
fix: prevent duplicate windows on cold launch via deeplink
datlechin Apr 16, 2026
e07733a
refactor: clean up debug logging and extract AppDelegate helpers
datlechin Apr 16, 2026
0d2f940
docs: update CHANGELOG for in-app tab bar refactor
datlechin Apr 16, 2026
7733556
docs: simplify CHANGELOG entries
datlechin Apr 16, 2026
dac05df
docs: update tabs, keyboard shortcuts, and deeplinks for in-app tab bar
datlechin Apr 16, 2026
6fd3988
fix: resolve shared state not triggering NSHostingView rebuild
datlechin Apr 16, 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- In-app tab bar with instant switching, drag reorder, pinned tabs, and dirty indicators
- Reopen closed tab (Cmd+Shift+T), MRU tab selection on close
- Deeplinks and Handoff route to in-app tabs instead of creating duplicate windows

### Changed

- Replace native macOS window tabs with in-app tab bar (600ms+ → instant)
- Tab content preserved across switches (no view destruction/recreation)
- Connection state persisted incrementally (survives force quit)

### Fixed

- Raw SQL injection via external URL scheme deeplinks — now requires user confirmation
Expand Down
26 changes: 13 additions & 13 deletions TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,7 @@ extension AppDelegate {
do {
try await DatabaseManager.shared.connectToSession(connection)
self.openNewConnectionWindow(for: connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
self.closeAllWelcomeWindows()
self.handlePostConnectionActions(parsed, connectionId: connection.id)
} catch {
connectionLogger.error("Database URL connect failed: \(error.localizedDescription)")
Expand Down Expand Up @@ -143,9 +141,7 @@ extension AppDelegate {
do {
try await DatabaseManager.shared.connectToSession(connection)
self.openNewConnectionWindow(for: connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
self.closeAllWelcomeWindows()
} catch {
connectionLogger.error("SQLite file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)")
await self.handleConnectionFailure(error)
Expand Down Expand Up @@ -193,9 +189,7 @@ extension AppDelegate {
do {
try await DatabaseManager.shared.connectToSession(connection)
self.openNewConnectionWindow(for: connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
self.closeAllWelcomeWindows()
} catch {
connectionLogger.error("DuckDB file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)")
await self.handleConnectionFailure(error)
Expand Down Expand Up @@ -243,9 +237,7 @@ extension AppDelegate {
do {
try await DatabaseManager.shared.connectToSession(connection)
self.openNewConnectionWindow(for: connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
self.closeAllWelcomeWindows()
} catch {
connectionLogger.error("File open failed for '\(filePath, privacy: .public)' (\(dbType.rawValue)): \(error.localizedDescription)")
await self.handleConnectionFailure(error)
Expand Down Expand Up @@ -350,7 +342,9 @@ extension AppDelegate {
tableName: tableName,
isView: parsed.isView
)
WindowOpener.shared.openNativeTab(payload)
if !routeToExistingWindow(connectionId: connectionId, payload: payload) {
WindowOpener.shared.openNativeTab(payload)
}

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

func closeAllWelcomeWindows() {
for window in NSApp.windows where isWelcomeWindow(window) {
window.close()
}
}

func bringConnectionWindowToFront(_ connectionId: UUID) {
let windows = WindowLifecycleMonitor.shared.windows(for: connectionId)
if let window = windows.first {
Expand Down
113 changes: 87 additions & 26 deletions TablePro/AppDelegate+FileOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,31 +32,39 @@ extension AppDelegate {

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

// Already connected — route to existing window's in-app tab bar
if DatabaseManager.shared.activeSessions[connectionId]?.driver != nil {
if let tableName {
let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName)
WindowOpener.shared.openNativeTab(payload)
} else {
for window in NSApp.windows where isMainWindow(window) {
window.makeKeyAndOrderFront(nil)
return
if !routeToExistingWindow(connectionId: connectionId, payload: payload) {
WindowOpener.shared.openNativeTab(payload)
}
} else {
bringConnectionWindowToFront(connectionId)
}
return
}

let initialPayload = EditorTabPayload(connectionId: connectionId)
// Window already pending (e.g., auto-reconnect in progress) — just bring to front
let hasPending = WindowOpener.shared.pendingPayloads.contains { $0.connectionId == connectionId }
if hasPending {
bringConnectionWindowToFront(connectionId)
return
}

// Not connected — create window, connect, then route content as in-app tab
let initialPayload = EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault)
WindowOpener.shared.openNativeTab(initialPayload)

Task { @MainActor in
do {
try await DatabaseManager.shared.connectToSession(connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
self.closeAllWelcomeWindows()
if let tableName {
let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName)
WindowOpener.shared.openNativeTab(payload)
if !routeToExistingWindow(connectionId: connectionId, payload: payload) {
WindowOpener.shared.openNativeTab(payload)
}
}
} catch {
fileOpenLogger.error("Handoff connect failed: \(error.localizedDescription)")
Expand Down Expand Up @@ -86,6 +94,11 @@ extension AppDelegate {
// MARK: - Main Dispatch

func handleOpenURLs(_ urls: [URL]) {
// application(_:open:) fires in the same run loop pass as applicationDidFinishLaunching
// on cold launch from URL. The deferred auto-reconnect Task yields to the next run loop,
// so this flag is guaranteed to be set before the Task checks it.
suppressAutoReconnect = true

let deeplinks = urls.filter { $0.scheme == "tablepro" }
if !deeplinks.isEmpty {
Task { @MainActor in
Expand Down Expand Up @@ -143,9 +156,7 @@ extension AppDelegate {
for window in NSApp.windows where isMainWindow(window) {
window.makeKeyAndOrderFront(nil)
}
for window in NSApp.windows where isWelcomeWindow(window) {
window.close()
}
closeAllWelcomeWindows()
NotificationCenter.default.post(name: .openSQLFiles, object: sqlFiles)
endFileOpenSuppression()
} else {
Expand All @@ -155,6 +166,36 @@ extension AppDelegate {
}
}

// MARK: - In-App Tab Routing

/// Route content to an existing connection window's in-app tab bar when possible.
/// Returns true if the content was routed to an existing window.
/// Falls back gracefully (returns false) when no coordinator exists for the connection.
@discardableResult
func routeToExistingWindow(
connectionId: UUID,
payload: EditorTabPayload
) -> Bool {
guard let coordinator = MainContentCoordinator.firstCoordinator(for: connectionId) else {
return false
}
switch payload.tabType {
case .table:
if let tableName = payload.tableName {
coordinator.openTableTab(tableName, showStructure: payload.showStructure, isView: payload.isView)
}
case .query:
coordinator.tabManager.addTab(
initialQuery: payload.initialQuery,
databaseName: payload.databaseName ?? coordinator.connection.database
)
default:
coordinator.addNewQueryTab()
}
coordinator.contentWindow?.makeKeyAndOrderFront(nil)
return true
}

// MARK: - Welcome Window Suppression

func suppressWelcomeWindow() {
Expand Down Expand Up @@ -216,7 +257,7 @@ extension AppDelegate {
makePayload: (@Sendable (UUID) -> EditorTabPayload)? = nil
) {
guard let connection = DeeplinkHandler.resolveConnection(named: connectionName) else {
fileOpenLogger.error("Deep link: no connection named '\(connectionName, privacy: .public)'")
fileOpenLogger.error("No connection named '\(connectionName, privacy: .public)'")
AlertHelper.showErrorSheet(
title: String(localized: "Connection Not Found"),
message: String(format: String(localized: "No saved connection named \"%@\"."), connectionName),
Expand All @@ -225,27 +266,47 @@ extension AppDelegate {
return
}

if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil {
let hasDriver = DatabaseManager.shared.activeSessions[connection.id]?.driver != nil
let hasCoordinator = MainContentCoordinator.firstCoordinator(for: connection.id) != nil

// Already connected — route to existing window's in-app tab bar
if hasDriver {
if let payload = makePayload?(connection.id) {
WindowOpener.shared.openNativeTab(payload)
} else {
for window in NSApp.windows where isMainWindow(window) {
window.makeKeyAndOrderFront(nil)
return
if !routeToExistingWindow(connectionId: connection.id, payload: payload) {
WindowOpener.shared.openNativeTab(payload)
}
} else {
bringConnectionWindowToFront(connection.id)
}
return
}

// Prevent duplicate connections from rapid deeplink invocations
let hasPendingWindow = WindowOpener.shared.pendingPayloads.contains { $0.connectionId == connection.id }
let isAlreadyConnecting = connectingURLConnectionIds.contains(connection.id)
guard !isAlreadyConnecting, !hasPendingWindow else {
bringConnectionWindowToFront(connection.id)
return
}

// Has coordinator but no driver — window exists, connection may be in progress
if hasCoordinator {
bringConnectionWindowToFront(connection.id)
return
}

let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs {
NSWindow.allowsAutomaticWindowTabbing = false
}

let deeplinkPayload = EditorTabPayload(connectionId: connection.id)
connectingURLConnectionIds.insert(connection.id)

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

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

try await DatabaseManager.shared.connectToSession(connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
self.closeAllWelcomeWindows()
if let payload = makePayload?(connection.id) {
WindowOpener.shared.openNativeTab(payload)
if !self.routeToExistingWindow(connectionId: connection.id, payload: payload) {
WindowOpener.shared.openNativeTab(payload)
}
}
} catch {
fileOpenLogger.error("Deep link connect failed: \(error.localizedDescription)")
fileOpenLogger.error("Deeplink connect failed for \"\(connectionName, privacy: .public)\": \(error.localizedDescription, privacy: .public)")
await self.handleConnectionFailure(error)
}
}
Expand Down
Loading
Loading