Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Option to group all connection tabs in one window instead of separate windows per connection

## [0.27.1] - 2026-04-01

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ extension AppDelegate {

private func openNewConnectionWindow(for connection: DatabaseConnection) {
let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
if hadExistingMain {
if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs {
NSWindow.allowsAutomaticWindowTabbing = false
}
let payload = EditorTabPayload(connectionId: connection.id)
Expand Down
2 changes: 1 addition & 1 deletion TablePro/AppDelegate+FileOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ extension AppDelegate {
}

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

Expand Down
53 changes: 41 additions & 12 deletions TablePro/AppDelegate+WindowConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,12 @@ extension AppDelegate {
} catch {
windowLogger.error("Dock connection failed for '\(connection.name)': \(error.localizedDescription)")

for window in NSApp.windows where self.isMainWindow(window) {
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
window.close()
}
self.openWelcomeWindow()
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
self.openWelcomeWindow()
}
}
}
}
Expand Down Expand Up @@ -251,18 +253,27 @@ extension AppDelegate {
// If no code opened this window (pendingId is nil), this is a
// SwiftUI WindowGroup state restoration — not a window we created.
// Hide it (orderOut, not close) to break the close→restore loop.
// Exception: if the window is already part of a tab group, it was
// attached by our addTabbedWindow call — not a restoration orphan.
// Ordering it out would crash NSWindowStackController.
if pendingId == nil && !isAutoReconnecting {
configuredWindows.insert(windowId)
if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 {
// Already in a tab group — leave it alone
return
}
window.orderOut(nil)
return
}

let existingIdentifier = NSApp.windows
.first { $0 !== window && isMainWindow($0) && $0.isVisible }?
.tabbingIdentifier
let groupAll = MainActor.assumeIsolated { AppSettingsManager.shared.tabs.groupAllConnectionTabs }
let resolvedIdentifier = TabbingIdentifierResolver.resolve(
pendingConnectionId: pendingId,
existingIdentifier: existingIdentifier
existingIdentifier: existingIdentifier,
groupAllConnections: groupAll
)
window.tabbingIdentifier = resolvedIdentifier
configuredWindows.insert(windowId)
Expand All @@ -273,10 +284,25 @@ extension AppDelegate {

// Explicitly attach to existing tab group — automatic tabbing
// doesn't work when tabbingIdentifier is set after window creation.
if let existingWindow = NSApp.windows.first(where: {
$0 !== window && isMainWindow($0) && $0.isVisible
&& $0.tabbingIdentifier == resolvedIdentifier
}) {
let matchingWindow: NSWindow?
if groupAll {
// When grouping all connections, attach to any visible main window
// and normalize all existing windows' tabbingIdentifiers so future
// windows also match (not just the first one found).
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)
Expand Down Expand Up @@ -339,18 +365,21 @@ extension AppDelegate {
window.close()
}
} catch is CancellationError {
for window in NSApp.windows where self.isMainWindow(window) {
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
window.close()
}
self.openWelcomeWindow()
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
self.openWelcomeWindow()
}
} catch {
windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)")

for window in NSApp.windows where self.isMainWindow(window) {
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
window.close()
}

self.openWelcomeWindow()
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
self.openWelcomeWindow()
}
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion TablePro/Core/Services/Infrastructure/WindowOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,16 @@ internal enum TabbingIdentifierResolver {
/// - Parameters:
/// - pendingConnectionId: The connectionId from WindowOpener (if a tab was just opened)
/// - existingIdentifier: The tabbingIdentifier from an existing visible main window (if any)
/// - groupAllConnections: When true, all windows share one tab group regardless of connection
/// - Returns: The tabbingIdentifier to assign to the new window
internal static func resolve(pendingConnectionId: UUID?, existingIdentifier: String?) -> String {
internal static func resolve(
pendingConnectionId: UUID?,
existingIdentifier: String?,
groupAllConnections: Bool = false
) -> String {
if groupAllConnections {
return "com.TablePro.main"
}
if let connectionId = pendingConnectionId {
return "com.TablePro.main.\(connectionId.uuidString)"
}
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Models/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -451,14 +451,17 @@ struct HistorySettings: Codable, Equatable {
/// Tab behavior settings
struct TabSettings: Codable, Equatable {
var enablePreviewTabs: Bool = true
var groupAllConnectionTabs: Bool = false
static let `default` = TabSettings()

init(enablePreviewTabs: Bool = true) {
init(enablePreviewTabs: Bool = true, groupAllConnectionTabs: Bool = false) {
self.enablePreviewTabs = enablePreviewTabs
self.groupAllConnectionTabs = groupAllConnectionTabs
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
enablePreviewTabs = try container.decodeIfPresent(Bool.self, forKey: .enablePreviewTabs) ?? true
groupAllConnectionTabs = try container.decodeIfPresent(Bool.self, forKey: .groupAllConnectionTabs) ?? false
}
}
6 changes: 6 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -14626,6 +14626,9 @@
}
}
}
},
"Group all connections in one window" : {

},
"Group name" : {
"localizations" : {
Expand Down Expand Up @@ -33971,6 +33974,9 @@
}
}
}
},
"When enabled, tabs from different connections share the same window instead of opening separate windows." : {

},
"When enabled, this favorite is visible in all connections" : {
"localizations" : {
Expand Down
21 changes: 14 additions & 7 deletions TablePro/ViewModels/WelcomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ final class WelcomeViewModel {
try await dbManager.connectToSession(connection)
} catch is CancellationError {
// User cancelled password prompt — return to welcome
NSApplication.shared.closeWindows(withId: "main")
closeConnectionWindows(for: connection.id)
self.openWindow?(id: "welcome")
} catch {
if case PluginError.pluginNotInstalled = error {
Expand All @@ -226,7 +226,7 @@ final class WelcomeViewModel {
} else {
Self.logger.error(
"Failed to connect: \(error.localizedDescription, privacy: .public)")
handleConnectionFailure(error: error)
handleConnectionFailure(error: error, connectionId: connection.id)
}
}
}
Expand All @@ -242,12 +242,12 @@ final class WelcomeViewModel {
do {
try await dbManager.connectToSession(connection)
} catch is CancellationError {
NSApplication.shared.closeWindows(withId: "main")
closeConnectionWindows(for: connection.id)
self.openWindow?(id: "welcome")
} catch {
Self.logger.error(
"Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)")
handleConnectionFailure(error: error)
handleConnectionFailure(error: error, connectionId: connection.id)
}
}
}
Expand Down Expand Up @@ -511,9 +511,9 @@ final class WelcomeViewModel {

// MARK: - Private Helpers

private func handleConnectionFailure(error: Error) {
private func handleConnectionFailure(error: Error, connectionId: UUID) {
guard let openWindow else { return }
NSApplication.shared.closeWindows(withId: "main")
closeConnectionWindows(for: connectionId)
openWindow(id: "welcome")

AlertHelper.showErrorSheet(
Expand All @@ -525,8 +525,15 @@ final class WelcomeViewModel {

private func handleMissingPlugin(connection: DatabaseConnection) {
guard let openWindow else { return }
NSApplication.shared.closeWindows(withId: "main")
closeConnectionWindows(for: connection.id)
openWindow(id: "welcome")
pluginInstallConnection = connection
}

/// Close windows for a specific connection only, preserving other connections' windows.
private func closeConnectionWindows(for connectionId: UUID) {
for window in WindowLifecycleMonitor.shared.windows(for: connectionId) {
window.close()
}
}
}
10 changes: 8 additions & 2 deletions TablePro/Views/Connection/ConnectionFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,7 @@ struct ConnectionFormView: View {
handleMissingPlugin(connection: connection)
return
}
NSApplication.shared.closeWindows(withId: "main")
closeConnectionWindows(for: connection.id)
openWindow(id: "welcome")
guard !(error is CancellationError) else { return }
Self.logger.error("Failed to connect: \(error.localizedDescription, privacy: .public)")
Expand All @@ -977,11 +977,17 @@ struct ConnectionFormView: View {
}

private func handleMissingPlugin(connection: DatabaseConnection) {
NSApplication.shared.closeWindows(withId: "main")
closeConnectionWindows(for: connection.id)
openWindow(id: "welcome")
pluginInstallConnection = connection
}

private func closeConnectionWindows(for connectionId: UUID) {
for window in WindowLifecycleMonitor.shared.windows(for: connectionId) {
window.close()
}
}

private func connectAfterInstall(_ connection: DatabaseConnection) {
WindowOpener.shared.pendingConnectionId = connection.id
openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,13 @@ extension MainContentCoordinator {
let safeRows = selectResult.rows.map { row in
row.map { $0.map { String($0) } }
}
let tableName = lastSelectSQL.flatMap {
extractTableName(from: $0)
// For table tabs, preserve existing tableName instead of re-extracting
// from SQL — extractTableName can fail on schema-qualified/quoted names
let tableName: String?
if updatedTab.tabType == .table, let existing = updatedTab.tableName {
tableName = existing
} else {
tableName = lastSelectSQL.flatMap { extractTableName(from: $0) }
}

updatedTab.resultColumns = safeColumns
Expand All @@ -215,7 +220,10 @@ extension MainContentCoordinator {
updatedTab.resultColumns = []
updatedTab.columnTypes = []
updatedTab.resultRows = []
updatedTab.tableName = nil
// Preserve tableName for table tabs even when no SELECT result
if updatedTab.tabType != .table {
updatedTab.tableName = nil
}
updatedTab.isEditable = false
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ extension MainContentCoordinator {

// Check if another native window tab already has this table open — switch to it
if let keyWindow = NSApp.keyWindow {
let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) })
let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow]
for window in tabbedWindows where window.title == tableName {
for window in tabbedWindows
where window.title == tableName && ownWindows.contains(ObjectIdentifier(window)) {
window.makeKeyAndOrderFront(nil)
return
}
Expand Down Expand Up @@ -219,8 +221,25 @@ extension MainContentCoordinator {
}
}

// No preview window exists but current tab is already a preview: replace in-place
if let selectedTab = tabManager.selectedTab, selectedTab.isPreview {
// No preview window exists but current tab can be reused: replace in-place.
// This covers: preview tabs, non-preview table tabs with no active work,
// and empty/default query tabs (no user-entered content).
let isReusableTab: Bool = {
guard let tab = tabManager.selectedTab else { return false }
if tab.isPreview { return true }
// Table tab with no active work
if tab.tabType == .table && !changeManager.hasChanges
&& !filterStateManager.hasAppliedFilters && !tab.sortState.isSorting {
return true
}
// Empty/default query tab (no user content, no results, never executed)
if tab.tabType == .query && tab.lastExecutedAt == nil
&& tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return true
}
return false
}()
if let selectedTab = tabManager.selectedTab, isReusableTab {
// Skip if already showing this table
if selectedTab.tableName == tableName, selectedTab.databaseName == databaseName {
return
Expand Down Expand Up @@ -346,7 +365,11 @@ extension MainContentCoordinator {
private func closeSiblingNativeWindows() {
guard let keyWindow = NSApp.keyWindow else { return }
let siblings = keyWindow.tabbedWindows ?? []
let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) })
for sibling in siblings where sibling !== keyWindow {
// Only close windows belonging to this connection to avoid
// destroying tabs from other connections when groupAllConnectionTabs is ON
guard ownWindows.contains(ObjectIdentifier(sibling)) else { continue }
sibling.close()
}
}
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,11 @@ final class MainContentCoordinator {
if usesNoSQLBrowsing {
tableName = tabManager.selectedTab?.tableName
isEditable = tableName != nil
} else if tab.tabType == .table, let existingName = tab.tableName {
// Table tabs already know their table name — don't re-extract from SQL
// which can fail for schema-qualified or quoted identifiers
tableName = existingName
isEditable = true
} else {
tableName = extractTableName(from: effectiveSQL)
isEditable = tableName != nil
Expand Down
4 changes: 3 additions & 1 deletion TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,9 @@ struct MainContentView: View {
} else {
window.subtitle = connection.name
}
window.tabbingIdentifier = "com.TablePro.main.\(connection.id.uuidString)"
window.tabbingIdentifier = AppSettingsManager.shared.tabs.groupAllConnectionTabs
? "com.TablePro.main"
: "com.TablePro.main.\(connection.id.uuidString)"
window.tabbingMode = .preferred
coordinator.windowId = windowId

Expand Down
6 changes: 6 additions & 0 deletions TablePro/Views/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ struct GeneralSettingsView: View {
Text("Single-clicking a table opens a temporary tab that gets replaced on next click.")
.font(.caption)
.foregroundStyle(.secondary)

Toggle("Group all connections in one window", isOn: $settingsManager.tabs.groupAllConnectionTabs)

Text("When enabled, tabs from different connections share the same window instead of opening separate windows.")
.font(.caption)
.foregroundStyle(.secondary)
}

Section {
Expand Down
Loading
Loading