diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6201c48..f84b0518e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) - iOS: open DuckDB database files and in-memory DuckDB databases, matching the Mac app. (#1526) - Save the current query as a favorite from a star button in the SQL editor toolbar. - Field names and types in the row Details panel can now be selected and copied. diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 3943aee1c..cbb3d0638 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,40 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi return String(localized: "SQL Query") } + static func resolveDefaultSubtitle(tab: QueryTab?, connection: DatabaseConnection) -> String { + tableSubtitle( + isTable: tab?.tabType == .table, + tableName: tab?.tableContext.tableName, + databaseName: tab?.tableContext.databaseName ?? "", + schemaName: tab?.tableContext.schemaName, + fallback: connection.name + ) + } + + static func resolveDefaultSubtitle(payload: EditorTabPayload?, connection: DatabaseConnection) -> String { + tableSubtitle( + isTable: payload?.tabType == .table, + tableName: payload?.tableName, + databaseName: payload?.databaseName ?? "", + schemaName: payload?.schemaName, + fallback: connection.name + ) + } + + private static func tableSubtitle( + isTable: Bool, + tableName: String?, + databaseName: String, + schemaName: String?, + fallback: String + ) -> String { + guard isTable, let tableName, !tableName.isEmpty, !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 +143,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 @@ -196,9 +241,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 @@ -298,8 +341,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() @@ -383,6 +426,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, @@ -466,6 +510,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 5487c98be..5a366ddb3 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 040c6c341..5e31cdefc 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -228,7 +228,6 @@ extension MainContentCoordinator { } toolbarState.isTableTab = true } - updatePreviewSubtitle(isPreview: createAsPreview) restoreLastHiddenColumnsForTable(tableName) restoreFiltersForTable(tableName) if isInPlace, let dbIndex = Int(currentDatabase) { @@ -273,7 +272,6 @@ extension MainContentCoordinator { } toolbarState.isTableTab = true } - updatePreviewSubtitle(isPreview: createAsPreview) restoreLastHiddenColumnsForTable(tableName) restoreFiltersForTable(tableName) executeSelectedTableTabQuery() @@ -301,13 +299,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 19c6ed457..49307e58d 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -200,6 +200,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 } @@ -211,11 +215,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")