Skip to content

Commit e71869b

Browse files
authored
feat: add option to group all connection tabs in one window (#546)
* feat: add option to group all connection tabs in one window * fix: scope auto-reconnect failure cleanup to connection and document runtime toggle
1 parent 92be443 commit e71869b

17 files changed

Lines changed: 195 additions & 42 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Option to group all connection tabs in one window instead of separate windows per connection
13+
1014
## [0.27.1] - 2026-04-01
1115

1216
### Fixed

TablePro/AppDelegate+ConnectionHandler.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ extension AppDelegate {
316316

317317
private func openNewConnectionWindow(for connection: DatabaseConnection) {
318318
let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
319-
if hadExistingMain {
319+
if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs {
320320
NSWindow.allowsAutomaticWindowTabbing = false
321321
}
322322
let payload = EditorTabPayload(connectionId: connection.id)

TablePro/AppDelegate+FileOpen.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ extension AppDelegate {
176176
}
177177

178178
let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
179-
if hadExistingMain {
179+
if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs {
180180
NSWindow.allowsAutomaticWindowTabbing = false
181181
}
182182

TablePro/AppDelegate+WindowConfig.swift

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,12 @@ extension AppDelegate {
8080
} catch {
8181
windowLogger.error("Dock connection failed for '\(connection.name)': \(error.localizedDescription)")
8282

83-
for window in NSApp.windows where self.isMainWindow(window) {
83+
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
8484
window.close()
8585
}
86-
self.openWelcomeWindow()
86+
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
87+
self.openWelcomeWindow()
88+
}
8789
}
8890
}
8991
}
@@ -251,18 +253,27 @@ extension AppDelegate {
251253
// If no code opened this window (pendingId is nil), this is a
252254
// SwiftUI WindowGroup state restoration — not a window we created.
253255
// Hide it (orderOut, not close) to break the close→restore loop.
256+
// Exception: if the window is already part of a tab group, it was
257+
// attached by our addTabbedWindow call — not a restoration orphan.
258+
// Ordering it out would crash NSWindowStackController.
254259
if pendingId == nil && !isAutoReconnecting {
255260
configuredWindows.insert(windowId)
261+
if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 {
262+
// Already in a tab group — leave it alone
263+
return
264+
}
256265
window.orderOut(nil)
257266
return
258267
}
259268

260269
let existingIdentifier = NSApp.windows
261270
.first { $0 !== window && isMainWindow($0) && $0.isVisible }?
262271
.tabbingIdentifier
272+
let groupAll = MainActor.assumeIsolated { AppSettingsManager.shared.tabs.groupAllConnectionTabs }
263273
let resolvedIdentifier = TabbingIdentifierResolver.resolve(
264274
pendingConnectionId: pendingId,
265-
existingIdentifier: existingIdentifier
275+
existingIdentifier: existingIdentifier,
276+
groupAllConnections: groupAll
266277
)
267278
window.tabbingIdentifier = resolvedIdentifier
268279
configuredWindows.insert(windowId)
@@ -273,10 +284,25 @@ extension AppDelegate {
273284

274285
// Explicitly attach to existing tab group — automatic tabbing
275286
// doesn't work when tabbingIdentifier is set after window creation.
276-
if let existingWindow = NSApp.windows.first(where: {
277-
$0 !== window && isMainWindow($0) && $0.isVisible
278-
&& $0.tabbingIdentifier == resolvedIdentifier
279-
}) {
287+
let matchingWindow: NSWindow?
288+
if groupAll {
289+
// When grouping all connections, attach to any visible main window
290+
// and normalize all existing windows' tabbingIdentifiers so future
291+
// windows also match (not just the first one found).
292+
let existingMainWindows = NSApp.windows.filter {
293+
$0 !== window && isMainWindow($0) && $0.isVisible
294+
}
295+
for existing in existingMainWindows {
296+
existing.tabbingIdentifier = resolvedIdentifier
297+
}
298+
matchingWindow = existingMainWindows.first
299+
} else {
300+
matchingWindow = NSApp.windows.first {
301+
$0 !== window && isMainWindow($0) && $0.isVisible
302+
&& $0.tabbingIdentifier == resolvedIdentifier
303+
}
304+
}
305+
if let existingWindow = matchingWindow {
280306
let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow
281307
targetWindow.addTabbedWindow(window, ordered: .above)
282308
window.makeKeyAndOrderFront(nil)
@@ -339,18 +365,21 @@ extension AppDelegate {
339365
window.close()
340366
}
341367
} catch is CancellationError {
342-
for window in NSApp.windows where self.isMainWindow(window) {
368+
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
343369
window.close()
344370
}
345-
self.openWelcomeWindow()
371+
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
372+
self.openWelcomeWindow()
373+
}
346374
} catch {
347375
windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)")
348376

349-
for window in NSApp.windows where self.isMainWindow(window) {
377+
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
350378
window.close()
351379
}
352-
353-
self.openWelcomeWindow()
380+
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
381+
self.openWelcomeWindow()
382+
}
354383
}
355384
}
356385
}

TablePro/Core/Services/Infrastructure/WindowOpener.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,16 @@ internal enum TabbingIdentifierResolver {
4848
/// - Parameters:
4949
/// - pendingConnectionId: The connectionId from WindowOpener (if a tab was just opened)
5050
/// - existingIdentifier: The tabbingIdentifier from an existing visible main window (if any)
51+
/// - groupAllConnections: When true, all windows share one tab group regardless of connection
5152
/// - Returns: The tabbingIdentifier to assign to the new window
52-
internal static func resolve(pendingConnectionId: UUID?, existingIdentifier: String?) -> String {
53+
internal static func resolve(
54+
pendingConnectionId: UUID?,
55+
existingIdentifier: String?,
56+
groupAllConnections: Bool = false
57+
) -> String {
58+
if groupAllConnections {
59+
return "com.TablePro.main"
60+
}
5361
if let connectionId = pendingConnectionId {
5462
return "com.TablePro.main.\(connectionId.uuidString)"
5563
}

TablePro/Models/Settings/AppSettings.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,14 +451,17 @@ struct HistorySettings: Codable, Equatable {
451451
/// Tab behavior settings
452452
struct TabSettings: Codable, Equatable {
453453
var enablePreviewTabs: Bool = true
454+
var groupAllConnectionTabs: Bool = false
454455
static let `default` = TabSettings()
455456

456-
init(enablePreviewTabs: Bool = true) {
457+
init(enablePreviewTabs: Bool = true, groupAllConnectionTabs: Bool = false) {
457458
self.enablePreviewTabs = enablePreviewTabs
459+
self.groupAllConnectionTabs = groupAllConnectionTabs
458460
}
459461

460462
init(from decoder: Decoder) throws {
461463
let container = try decoder.container(keyedBy: CodingKeys.self)
462464
enablePreviewTabs = try container.decodeIfPresent(Bool.self, forKey: .enablePreviewTabs) ?? true
465+
groupAllConnectionTabs = try container.decodeIfPresent(Bool.self, forKey: .groupAllConnectionTabs) ?? false
463466
}
464467
}

TablePro/Resources/Localizable.xcstrings

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14626,6 +14626,9 @@
1462614626
}
1462714627
}
1462814628
}
14629+
},
14630+
"Group all connections in one window" : {
14631+
1462914632
},
1463014633
"Group name" : {
1463114634
"localizations" : {
@@ -33971,6 +33974,9 @@
3397133974
}
3397233975
}
3397333976
}
33977+
},
33978+
"When enabled, tabs from different connections share the same window instead of opening separate windows." : {
33979+
3397433980
},
3397533981
"When enabled, this favorite is visible in all connections" : {
3397633982
"localizations" : {

TablePro/ViewModels/WelcomeViewModel.swift

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ final class WelcomeViewModel {
217217
try await dbManager.connectToSession(connection)
218218
} catch is CancellationError {
219219
// User cancelled password prompt — return to welcome
220-
NSApplication.shared.closeWindows(withId: "main")
220+
closeConnectionWindows(for: connection.id)
221221
self.openWindow?(id: "welcome")
222222
} catch {
223223
if case PluginError.pluginNotInstalled = error {
@@ -226,7 +226,7 @@ final class WelcomeViewModel {
226226
} else {
227227
Self.logger.error(
228228
"Failed to connect: \(error.localizedDescription, privacy: .public)")
229-
handleConnectionFailure(error: error)
229+
handleConnectionFailure(error: error, connectionId: connection.id)
230230
}
231231
}
232232
}
@@ -242,12 +242,12 @@ final class WelcomeViewModel {
242242
do {
243243
try await dbManager.connectToSession(connection)
244244
} catch is CancellationError {
245-
NSApplication.shared.closeWindows(withId: "main")
245+
closeConnectionWindows(for: connection.id)
246246
self.openWindow?(id: "welcome")
247247
} catch {
248248
Self.logger.error(
249249
"Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)")
250-
handleConnectionFailure(error: error)
250+
handleConnectionFailure(error: error, connectionId: connection.id)
251251
}
252252
}
253253
}
@@ -511,9 +511,9 @@ final class WelcomeViewModel {
511511

512512
// MARK: - Private Helpers
513513

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

519519
AlertHelper.showErrorSheet(
@@ -525,8 +525,15 @@ final class WelcomeViewModel {
525525

526526
private func handleMissingPlugin(connection: DatabaseConnection) {
527527
guard let openWindow else { return }
528-
NSApplication.shared.closeWindows(withId: "main")
528+
closeConnectionWindows(for: connection.id)
529529
openWindow(id: "welcome")
530530
pluginInstallConnection = connection
531531
}
532+
533+
/// Close windows for a specific connection only, preserving other connections' windows.
534+
private func closeConnectionWindows(for connectionId: UUID) {
535+
for window in WindowLifecycleMonitor.shared.windows(for: connectionId) {
536+
window.close()
537+
}
538+
}
532539
}

TablePro/Views/Connection/ConnectionFormView.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -966,7 +966,7 @@ struct ConnectionFormView: View {
966966
handleMissingPlugin(connection: connection)
967967
return
968968
}
969-
NSApplication.shared.closeWindows(withId: "main")
969+
closeConnectionWindows(for: connection.id)
970970
openWindow(id: "welcome")
971971
guard !(error is CancellationError) else { return }
972972
Self.logger.error("Failed to connect: \(error.localizedDescription, privacy: .public)")
@@ -977,11 +977,17 @@ struct ConnectionFormView: View {
977977
}
978978

979979
private func handleMissingPlugin(connection: DatabaseConnection) {
980-
NSApplication.shared.closeWindows(withId: "main")
980+
closeConnectionWindows(for: connection.id)
981981
openWindow(id: "welcome")
982982
pluginInstallConnection = connection
983983
}
984984

985+
private func closeConnectionWindows(for connectionId: UUID) {
986+
for window in WindowLifecycleMonitor.shared.windows(for: connectionId) {
987+
window.close()
988+
}
989+
}
990+
985991
private func connectAfterInstall(_ connection: DatabaseConnection) {
986992
WindowOpener.shared.pendingConnectionId = connection.id
987993
openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id))

TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,13 @@ extension MainContentCoordinator {
202202
let safeRows = selectResult.rows.map { row in
203203
row.map { $0.map { String($0) } }
204204
}
205-
let tableName = lastSelectSQL.flatMap {
206-
extractTableName(from: $0)
205+
// For table tabs, preserve existing tableName instead of re-extracting
206+
// from SQL — extractTableName can fail on schema-qualified/quoted names
207+
let tableName: String?
208+
if updatedTab.tabType == .table, let existing = updatedTab.tableName {
209+
tableName = existing
210+
} else {
211+
tableName = lastSelectSQL.flatMap { extractTableName(from: $0) }
207212
}
208213

209214
updatedTab.resultColumns = safeColumns
@@ -215,7 +220,10 @@ extension MainContentCoordinator {
215220
updatedTab.resultColumns = []
216221
updatedTab.columnTypes = []
217222
updatedTab.resultRows = []
218-
updatedTab.tableName = nil
223+
// Preserve tableName for table tabs even when no SELECT result
224+
if updatedTab.tabType != .table {
225+
updatedTab.tableName = nil
226+
}
219227
updatedTab.isEditable = false
220228
}
221229

0 commit comments

Comments
 (0)