Skip to content

Commit b2d0d05

Browse files
authored
fix(coordinator): make the window title a function of the pane it is showing (#2054)
1 parent 2f9f5be commit b2d0d05

6 files changed

Lines changed: 248 additions & 48 deletions

File tree

CHANGELOG.md

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

3333
### Fixed
3434

35+
- A window being connected is now named after the connection instead of "SQL Query", which named a tab it did not have yet.
36+
- A window that loses its connection no longer keeps the name of the table it stopped showing. Its name and its native tab label now follow what the window is actually displaying.
37+
- The titlebar no longer repeats itself, so a window with no tabs open reads "My Database" rather than "My Database - My Database".
38+
- A tab you named yourself is no longer renamed behind your back when the window reconnects. Any title ending in "Query" used to be treated as a placeholder and replaced.
39+
- Window naming now works in translated builds. The rule that decided whether a title was a placeholder compared it against English text, so it never matched outside English.
40+
- Renaming a connection now updates the name of any window already open on it.
3541
- A dropped SSH tunnel that ran out of reconnect attempts left the window spinning forever with no way back. It now reports what happened and offers to try again.
3642
- A brief tunnel reconnect no longer closes your tabs. The window used to tear down the whole session on the blip, taking unsaved query edits with it.
3743
- The sidebar's filter field no longer sits over an empty sidebar while a connection is still being established.

TablePro/Core/Services/Infrastructure/MainSplitViewController.swift

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
2020
// MARK: - Payload & Session
2121

2222
let payload: EditorTabPayload?
23-
let payloadConnection: DatabaseConnection?
23+
/// Re-read when the connection record changes, so a rename reaches the window's name and
24+
/// the connecting screen instead of freezing whatever the record said at creation.
25+
private(set) var payloadConnection: DatabaseConnection?
2426
private var currentSession: ConnectionSession?
2527
private var sessionState: SessionStateFactory.SessionState?
2628
private var rightPanelState: RightPanelState?
@@ -82,6 +84,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
8284

8385
private var connectionStatusCancellable: AnyCancellable?
8486
private var railVisibilityCancellable: AnyCancellable?
87+
private var connectionUpdatedCancellable: AnyCancellable?
8588

8689
// MARK: - Init
8790

@@ -95,14 +98,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
9598
self.payloadConnection = nil
9699
}
97100

98-
let queryLanguageName: String? = {
99-
guard let connectionId = payload?.connectionId,
100-
let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection else {
101-
return nil
102-
}
103-
return PluginManager.shared.queryLanguageName(for: connection.type)
104-
}()
105-
106101
var resolvedSession: ConnectionSession?
107102
if let connectionId = payload?.connectionId {
108103
resolvedSession = DatabaseManager.shared.activeSessions[connectionId]
@@ -111,17 +106,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
111106
}
112107
self.currentSession = resolvedSession
113108

114-
let titleConnection = self.payloadConnection ?? resolvedSession?.connection
115-
self.windowTitle = WindowTitleResolver.resolveTitle(
116-
payload: payload,
117-
databaseType: titleConnection?.type,
118-
queryLanguageName: queryLanguageName
119-
)
120-
if let titleConnection {
121-
self.windowSubtitle = WindowTitleResolver.resolveSubtitle(payload: payload, connection: titleConnection)
122-
} else {
123-
self.windowSubtitle = ""
124-
}
109+
self.windowTitle = ""
110+
self.windowSubtitle = ""
125111

126112
if let session = resolvedSession {
127113
self.rightPanelState = RightPanelState(connectionId: session.connection.id)
@@ -136,11 +122,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
136122
state = SessionStateFactory.create(connection: session.connection, payload: payload)
137123
}
138124
self.sessionState = state
139-
if payload?.intent == .newEmptyTab,
140-
let tabTitle = state.coordinator.tabManager.selectedTab?.title,
141-
!tabTitle.isBlank {
142-
self.windowTitle = tabTitle
143-
}
144125
}
145126

146127
if resolvedSession?.driver != nil {
@@ -152,6 +133,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
152133
}
153134

154135
super.init(nibName: nil, bundle: nil)
136+
137+
/// AppKit renders a native tab's label even for a tab that is never activated, so the
138+
/// title has to be right at creation rather than at first appearance.
139+
applyWindowTitle()
155140
}
156141

157142
@available(*, unavailable)
@@ -260,13 +245,30 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
260245
.sink { [weak self] _ in
261246
self?.applyRailVisibility(workspaceCount: WorkspaceRailStore.entries.count)
262247
}
248+
connectionUpdatedCancellable = AppEvents.shared.connectionUpdated
249+
.receive(on: RunLoop.main)
250+
.sink { [weak self] changedId in
251+
self?.handleConnectionRecordChange(changedId)
252+
}
263253
handleConnectionStatusChange()
264254
applyRailVisibility(workspaceCount: WorkspaceRailStore.entries.count)
265255
}
266256

267257
private func removeObservers() {
268258
connectionStatusCancellable = nil
269259
railVisibilityCancellable = nil
260+
connectionUpdatedCancellable = nil
261+
}
262+
263+
/// `nil` is the documented bulk-update payload, so it has to repaint too.
264+
private func handleConnectionRecordChange(_ changedId: UUID?) {
265+
guard let connectionId = payload?.connectionId ?? currentSession?.connection.id else { return }
266+
guard changedId == nil || changedId == connectionId else { return }
267+
guard let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId })
268+
?? DatabaseManager.shared.activeSessions[connectionId]?.connection else { return }
269+
payloadConnection = stored
270+
applyWindowTitle()
271+
rebuildPanes()
270272
}
271273

272274
// MARK: - Toolbar
@@ -330,14 +332,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
330332
private func adoptSession(_ session: ConnectionSession) {
331333
currentSession = session
332334

333-
if payload?.tableName == nil,
334-
windowTitle.isBlank
335-
|| windowTitle == WindowTitleResolver.fallbackTitle
336-
|| windowTitle.hasSuffix(" Query") {
337-
windowTitle = session.connection.name
338-
windowSubtitle = session.connection.name
339-
}
340-
341335
if rightPanelState == nil {
342336
rightPanelState = RightPanelState(connectionId: session.connection.id)
343337
}
@@ -368,9 +362,25 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
368362
private func applyPhase() {
369363
rebuildPanes()
370364
applyPaneChrome()
365+
applyWindowTitle()
371366
SessionRecoveryTracker.sync()
372367
}
373368

369+
/// Repainted on every phase change for the same reason the panes are. Leaving it out is
370+
/// what let a window keep the name of a table it had stopped showing after the session
371+
/// underneath it went away.
372+
internal func applyWindowTitle() {
373+
let resolved = WindowTitleResolver.resolveWindow(
374+
pane: currentPane,
375+
connection: paneConnection,
376+
tab: sessionState?.tabManager.selectedTab,
377+
hasTabs: !(sessionState?.tabManager.tabs.isEmpty ?? true),
378+
queryLanguageName: paneConnection.map { PluginManager.shared.queryLanguageName(for: $0.type) } ?? nil
379+
)
380+
windowTitle = resolved.title
381+
windowSubtitle = resolved.subtitle
382+
}
383+
374384
internal func transition(to next: ConnectionWindowPhase) {
375385
phase = next
376386
}

TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,50 @@
77

88
import Foundation
99

10+
/// Title and subtitle decided together. Resolving them apart is how a window ended up
11+
/// announcing "TablePro - TablePro": two callers each picked the connection name without
12+
/// knowing the other had.
13+
struct ResolvedWindowTitle: Equatable {
14+
let title: String
15+
let subtitle: String
16+
}
17+
1018
@MainActor
1119
enum WindowTitleResolver {
1220
static var fallbackTitle: String {
1321
String(localized: "SQL Query")
1422
}
1523

24+
/// The window's name is a function of the pane it is showing, exactly like its content and
25+
/// its chrome. A window that is not showing content is not showing a document, so naming it
26+
/// after a tab is naming something that is not there: that is how a connecting window came
27+
/// to be called "SQL Query", and how a window that lost its session kept the name of the
28+
/// table it had stopped displaying.
29+
static func resolveWindow(
30+
pane: ConnectionWindowPane,
31+
connection: DatabaseConnection?,
32+
tab: QueryTab?,
33+
hasTabs: Bool,
34+
queryLanguageName: String?
35+
) -> ResolvedWindowTitle {
36+
let connectionName = connection?.name ?? ""
37+
38+
guard pane == .content else {
39+
return connectionTitle(connectionName)
40+
}
41+
guard hasTabs, let connection else {
42+
return connectionTitle(connectionName)
43+
}
44+
45+
let title = resolveTitle(tab: tab, connection: connection, queryLanguageName: queryLanguageName)
46+
let subtitle = resolveSubtitle(tab: tab, connection: connection)
47+
return ResolvedWindowTitle(title: title, subtitle: subtitle == title ? "" : subtitle)
48+
}
49+
50+
private static func connectionTitle(_ name: String) -> ResolvedWindowTitle {
51+
ResolvedWindowTitle(title: name.isBlank ? fallbackTitle : name, subtitle: "")
52+
}
53+
1654
static func resolveTitle(
1755
payload: EditorTabPayload?,
1856
databaseType: DatabaseType?,

TablePro/Views/Main/Extensions/MainContentView+Setup.swift

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -278,16 +278,15 @@ extension MainContentView {
278278
/// Update window title, proxy icon, and dirty dot based on the selected tab.
279279
func updateWindowTitleAndFileState() {
280280
let selectedTab = tabManager.selectedTab
281-
if selectedTab == nil, tabManager.tabs.isEmpty {
282-
windowTitle = connection.name
283-
} else {
284-
windowTitle = WindowTitleResolver.resolveTitle(
285-
tab: selectedTab,
286-
connection: connection,
287-
queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type)
288-
)
289-
}
290-
windowSubtitle = WindowTitleResolver.resolveSubtitle(tab: selectedTab, connection: connection)
281+
let resolved = WindowTitleResolver.resolveWindow(
282+
pane: .content,
283+
connection: connection,
284+
tab: selectedTab,
285+
hasTabs: !tabManager.tabs.isEmpty,
286+
queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type)
287+
)
288+
windowTitle = resolved.title
289+
windowSubtitle = resolved.subtitle
291290
coordinator.splitViewController?.updateDetailMinimumThickness(for: selectedTab?.tabType)
292291
viewWindow?.representedURL = selectedTab?.content.sourceFileURL
293292
viewWindow?.isDocumentEdited = selectedTab?.showsUnsavedIndicator ?? false

TablePro/Views/Main/MainContentView.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,6 @@ struct MainContentView: View {
323323
"[open] MainContentView.onAppear start windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public) tabs=\(tabManager.tabs.count)"
324324
)
325325
coordinator.markActivated()
326-
327-
// Set window title for empty state (no tabs restored)
328-
if tabManager.tabs.isEmpty {
329-
windowTitle = connection.name
330-
}
331326
setupCommandActions()
332327
updateToolbarPendingState()
333328
updateInspectorContext()

0 commit comments

Comments
 (0)