From 0c17e830f8ccefe133dc4471dcf8b521e7aa48d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 12:38:59 +0700 Subject: [PATCH] feat(tabs): show the open table's name and database in the window title (#1475) --- CHANGELOG.md | 4 + .../MainSplitViewController.swift | 57 +++++++- .../Infrastructure/TabWindowController.swift | 2 +- TablePro/Resources/Localizable.xcstrings | 22 ---- .../MainContentCoordinator+Navigation.swift | 9 -- .../MainContentView+Modifiers.swift | 1 + .../Extensions/MainContentView+Setup.swift | 9 +- TablePro/Views/Main/MainContentView.swift | 3 + .../MainSplitViewControllerTitleTests.swift | 123 ++++++++++++++++++ 9 files changed, 189 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d643cac0e..48c8881c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The window title bar shows the open table's name, with its database and schema below, so you can tell which table you're viewing without checking the sidebar. (#1475) + ## [0.46.0] - 2026-05-28 ### Added diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index a3ad26b47..978313e72 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -30,6 +30,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi didSet { view.window?.title = windowTitle } } + var windowSubtitle: String { + didSet { view.window?.subtitle = windowSubtitle } + } + // MARK: - Split View Items private var sidebarSplitItem: NSSplitViewItem! @@ -77,6 +81,38 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi return String(localized: "SQL Query") } + static func resolveDefaultSubtitle(tab: QueryTab?, connection: DatabaseConnection) -> String { + guard let tab, tab.tabType == .table, + let tableName = tab.tableContext.tableName, !tableName.isEmpty else { + return connection.name + } + return tableSubtitle( + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, + fallback: connection.name + ) + } + + static func resolveDefaultSubtitle(payload: EditorTabPayload?, connection: DatabaseConnection) -> String { + guard let payload, payload.tabType == .table, + let tableName = payload.tableName, !tableName.isEmpty else { + return connection.name + } + return tableSubtitle( + databaseName: payload.databaseName ?? "", + schemaName: payload.schemaName, + fallback: connection.name + ) + } + + private static func tableSubtitle(databaseName: String, schemaName: String?, fallback: String) -> String { + guard !databaseName.isEmpty else { return fallback } + if let schemaName, !schemaName.isEmpty { + return "\(databaseName) · \(schemaName)" + } + return databaseName + } + // MARK: - Init init(payload: EditorTabPayload?, sessionState: SessionStateFactory.SessionState?) { @@ -105,6 +141,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } self.currentSession = resolvedSession + let subtitleConnection = self.payloadConnection ?? resolvedSession?.connection + if let subtitleConnection { + self.windowSubtitle = Self.resolveDefaultSubtitle(payload: payload, connection: subtitleConnection) + } else { + self.windowSubtitle = "" + } + if let session = resolvedSession { self.rightPanelState = RightPanelState() let state: SessionStateFactory.SessionState @@ -191,9 +234,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi guard let window = view.window else { return } window.title = windowTitle - if let session = currentSession { - window.subtitle = session.connection.name - } + window.subtitle = windowSubtitle if let sessionState { sessionState.coordinator.inspectorProxy = self @@ -291,8 +332,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi if payload?.tableName == nil, windowTitle == String(localized: "SQL Query") || windowTitle.hasSuffix(" Query") { windowTitle = newSession.connection.name + windowSubtitle = newSession.connection.name } - view.window?.subtitle = newSession.connection.name if rightPanelState == nil { rightPanelState = RightPanelState() @@ -375,6 +416,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi connection: currentSession.connection, payload: payload, windowTitle: windowTitleBinding, + windowSubtitle: windowSubtitleBinding, sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, @@ -458,6 +500,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) } + private var windowSubtitleBinding: Binding { + Binding( + get: { [weak self] in self?.windowSubtitle ?? "" }, + set: { [weak self] in self?.windowSubtitle = $0 } + ) + } + // MARK: - InspectorVisibilityProxy var isInspectorVisible: Bool { diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 768e3d656..acbb4c479 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -55,7 +55,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.isRestorable = AppSettingsStorage.shared.loadGeneral().startupBehavior == .reopenLast window.restorationClass = TabWindowRestoration.self window.toolbarStyle = .unified - window.titleVisibility = .hidden + window.titleVisibility = .visible window.tabbingMode = .preferred window.tabbingIdentifier = WindowManager.tabbingIdentifier(for: payload.connectionId) window.collectionBehavior.insert([.fullScreenPrimary, .managed]) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 6158570e6..3c4a1f54f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -661,28 +661,6 @@ } } }, - "%@ - Preview" : { - "localizations" : { - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ - Önizleme" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ - Xem trước" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ - 预览" - } - } - } - }, "%@ (%@@%@)" : { "localizations" : { "en" : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 813158eff..447c89872 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -215,7 +215,6 @@ extension MainContentCoordinator { } toolbarState.isTableTab = true } - updatePreviewSubtitle(isPreview: createAsPreview) restoreLastHiddenColumnsForTable(tableName) restoreFiltersForTable(tableName) if isInPlace, let dbIndex = Int(currentDatabase) { @@ -260,7 +259,6 @@ extension MainContentCoordinator { } toolbarState.isTableTab = true } - updatePreviewSubtitle(isPreview: createAsPreview) restoreLastHiddenColumnsForTable(tableName) restoreFiltersForTable(tableName) runQuery() @@ -288,13 +286,6 @@ extension MainContentCoordinator { guard let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.isPreview else { return } tabManager.mutate(at: tabIndex) { $0.isPreview = false } - updatePreviewSubtitle(isPreview: false) - } - - private func updatePreviewSubtitle(isPreview: Bool) { - contentWindow?.subtitle = isPreview - ? String(format: String(localized: "%@ - Preview"), connection.name) - : connection.name } func showAllTablesMetadata() { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift index 89952b5c0..9957db9b2 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift @@ -54,6 +54,7 @@ struct FocusedCommandActionsModifier: ViewModifier { connection: DatabaseConnection.preview, payload: nil, windowTitle: .constant("SQL Query"), + windowSubtitle: .constant(""), sidebarState: SharedSidebarState(), pendingTruncates: .constant([]), pendingDeletes: .constant([]), diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index e2a193693..6b9f95d32 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -197,6 +197,10 @@ extension MainContentView { ?? selectedTab?.title ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) } + windowSubtitle = MainSplitViewController.resolveDefaultSubtitle( + tab: selectedTab, + connection: connection + ) viewWindow?.representedURL = selectedTab?.content.sourceFileURL viewWindow?.isDocumentEdited = selectedTab?.content.isFileDirty ?? false } @@ -208,11 +212,6 @@ extension MainContentView { "[open] configureWindow start windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public)" ) let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false - if isPreview { - window.subtitle = String(format: String(localized: "%@ - Preview"), connection.name) - } else { - window.subtitle = connection.name - } let resolvedId = WindowManager.tabbingIdentifier(for: connection.id) window.tabbingIdentifier = resolvedId diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index c246283f0..bee244815 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -30,6 +30,7 @@ struct MainContentView: View { // Shared state from parent @Binding var windowTitle: String + @Binding var windowSubtitle: String @Bindable var schemaService = SchemaService.shared var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @@ -68,6 +69,7 @@ struct MainContentView: View { connection: DatabaseConnection, payload: EditorTabPayload?, windowTitle: Binding, + windowSubtitle: Binding, sidebarState: SharedSidebarState, pendingTruncates: Binding>, pendingDeletes: Binding>, @@ -81,6 +83,7 @@ struct MainContentView: View { self.connection = connection self.payload = payload self._windowTitle = windowTitle + self._windowSubtitle = windowSubtitle self.sidebarState = sidebarState self._pendingTruncates = pendingTruncates self._pendingDeletes = pendingDeletes diff --git a/TableProTests/Services/MainSplitViewControllerTitleTests.swift b/TableProTests/Services/MainSplitViewControllerTitleTests.swift index d80d169c0..e22805164 100644 --- a/TableProTests/Services/MainSplitViewControllerTitleTests.swift +++ b/TableProTests/Services/MainSplitViewControllerTitleTests.swift @@ -109,6 +109,129 @@ struct MainSplitViewControllerTitleTests { } } +@Suite("MainSplitViewController.resolveDefaultSubtitle") +@MainActor +struct MainSplitViewControllerSubtitleTests { + private let connection = DatabaseConnection(name: "MyConnection") + + private func tableTab(database: String, schema: String?) -> QueryTab { + var tab = QueryTab(id: UUID(), title: "users", query: "SELECT 1", tabType: .table, tableName: "users") + tab.tableContext.databaseName = database + tab.tableContext.schemaName = schema + return tab + } + + @Test("Table tab with database and schema joins them with a middle dot") + func tableTabWithSchemaAndDatabase() { + let subtitle = MainSplitViewController.resolveDefaultSubtitle( + tab: tableTab(database: "myapp", schema: "public"), + connection: connection + ) + #expect(subtitle == "myapp · public") + } + + @Test("Table tab without schema shows the database alone") + func tableTabWithDatabaseNoSchema() { + let subtitle = MainSplitViewController.resolveDefaultSubtitle( + tab: tableTab(database: "myapp", schema: nil), + connection: connection + ) + #expect(subtitle == "myapp") + } + + @Test("Table tab with an empty schema shows the database alone") + func tableTabWithEmptySchema() { + let subtitle = MainSplitViewController.resolveDefaultSubtitle( + tab: tableTab(database: "myapp", schema: ""), + connection: connection + ) + #expect(subtitle == "myapp") + } + + @Test("Table tab with no database falls back to the connection name") + func tableTabWithEmptyDatabaseName() { + let subtitle = MainSplitViewController.resolveDefaultSubtitle( + tab: tableTab(database: "", schema: nil), + connection: connection + ) + #expect(subtitle == connection.name) + } + + @Test("Table tab with no table name falls back to the connection name") + func tableTabWithNilTableName() { + var tab = QueryTab(id: UUID(), title: "x", query: "SELECT 1", tabType: .table) + tab.tableContext.databaseName = "myapp" + let subtitle = MainSplitViewController.resolveDefaultSubtitle(tab: tab, connection: connection) + #expect(subtitle == connection.name) + } + + @Test("Query tab never shows a table subtitle even with a resolved table name") + func queryTabReturnsConnectionName() { + let tab = QueryTab(id: UUID(), title: "q", query: "SELECT 1", tabType: .query, tableName: "users") + let subtitle = MainSplitViewController.resolveDefaultSubtitle(tab: tab, connection: connection) + #expect(subtitle == connection.name) + } + + @Test("Nil tab falls back to the connection name") + func nilTabReturnsConnectionName() { + let subtitle = MainSplitViewController.resolveDefaultSubtitle(tab: nil, connection: connection) + #expect(subtitle == connection.name) + } + + @Test("Server dashboard tab falls back to the connection name") + func serverDashboardTabReturnsConnectionName() { + let tab = QueryTab(id: UUID(), title: "d", query: "", tabType: .serverDashboard) + let subtitle = MainSplitViewController.resolveDefaultSubtitle(tab: tab, connection: connection) + #expect(subtitle == connection.name) + } + + @Test("ER diagram tab falls back to the connection name") + func erDiagramTabReturnsConnectionName() { + let tab = QueryTab(id: UUID(), title: "e", query: "", tabType: .erDiagram) + let subtitle = MainSplitViewController.resolveDefaultSubtitle(tab: tab, connection: connection) + #expect(subtitle == connection.name) + } + + @Test("Table payload with database and schema joins them with a middle dot") + func tablePayloadWithSchemaAndDatabase() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .table, + tableName: "users", + databaseName: "myapp", + schemaName: "public" + ) + let subtitle = MainSplitViewController.resolveDefaultSubtitle(payload: payload, connection: connection) + #expect(subtitle == "myapp · public") + } + + @Test("Table payload without schema shows the database alone") + func tablePayloadWithDatabaseNoSchema() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .table, + tableName: "users", + databaseName: "myapp" + ) + let subtitle = MainSplitViewController.resolveDefaultSubtitle(payload: payload, connection: connection) + #expect(subtitle == "myapp") + } + + @Test("Table payload with no database falls back to the connection name") + func tablePayloadWithNilDatabase() { + let payload = EditorTabPayload(connectionId: UUID(), tabType: .table, tableName: "users") + let subtitle = MainSplitViewController.resolveDefaultSubtitle(payload: payload, connection: connection) + #expect(subtitle == connection.name) + } + + @Test("Query payload falls back to the connection name") + func queryPayloadReturnsConnectionName() { + let payload = EditorTabPayload(connectionId: UUID(), tabType: .query, tableName: "users") + let subtitle = MainSplitViewController.resolveDefaultSubtitle(payload: payload, connection: connection) + #expect(subtitle == connection.name) + } +} + @Suite("QueryTab.fileDisplayTitle") struct QueryTabFileDisplayTitleTests { @Test("Returns FileManager display name for the URL")