diff --git a/CHANGELOG.md b/CHANGELOG.md index b822d45d8..85bbcd227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed a restored window tab sometimes showing a blank name in the tab bar until you switched to it. (#1894) +- Fixed the window title not updating right away after saving a query to a file or opening a favorite into the current tab. (#1894) - Fixed the SQL editor in a new tab losing keyboard focus after the first couple of characters, which stopped further typing until you clicked back into the editor. (#1885) - Cancelling a SQL Server connection now stops waiting right away instead of staying stuck until the attempt finished on its own. A connection attempt also gives up after the login timeout with a clear message rather than hanging, and for Windows Authentication the message points at the usual Kerberos causes (KDC unreachable, missing SPN, or clock skew). (#1889) - Fixed MySQL and MariaDB queries ending in ORDER BY that could show an empty result with no error. A failure part way through reading rows was treated as a normal end of results, so a real server error looked like a table with no rows. TablePro now shows the error the server reported. (#1884) diff --git a/CLAUDE.md b/CLAUDE.md index 3c34dcc2f..9160a1330 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,10 +155,7 @@ These have caused real bugs when violated: **Tab replacement guard**: `openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. Tabs with active work open a new native window tab instead. This check runs before the preview tab branch. -**Window tab titles**: Resolved in TWO places that must stay in sync: -1. `ContentView.init` (title resolution chain) — initial title from payload -2. `MainContentView+Setup.swift` `updateWindowTitleAndFileState()` — ongoing title updates -Missing a case produces a wrong "{Language} Query" title on the first frame. +**Window tab titles**: The native tab label follows `NSWindow.title`, and AppKit renders it for background tabs too, so the title must be correct from creation, not from first activation. Every title resolves through `WindowTitleResolver` (pure, AppKit-free): `MainSplitViewController.init` for the payload-driven initial title, `updateWindowTitleAndFileState()` in `MainContentView+Setup.swift` for ongoing tab-driven updates. The resolver treats a blank string as absent at every tier and always recomputes a `.table` tab's name from `tableName`+`schemaName` instead of trusting a carried-over title. `TabWindowController.init` pushes the resolved title onto `window.title`/`window.subtitle` right after assigning `contentViewController`, because a joined-but-never-activated tab window never runs `viewWillAppear` or its SwiftUI lifecycle. `MainSplitViewController.windowTitle`'s `didSet` is the single guarded sink and never lets an empty string reach `NSWindow.title`. Never write `window.title` or `NSApp.keyWindow?.title` directly; mutate `tab.title` and call `QueryTabManager.markTabRenamed(_:)` so the resolver re-runs. A restored tab whose persisted title decoded to "" shipped as a blank tab label that only healed on activation. **Schema loading**: `SQLSchemaProvider` (actor) stores an in-flight `loadTask: Task?`. Concurrent callers `await` the same Task instead of firing duplicate `fetchTables()` queries. Never use a boolean `isLoading` guard that returns without data — callers need to await the result. diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index f643067a0..3ab3eee92 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -27,7 +27,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var closingSessionId: UUID? var windowTitle: String { - didSet { view.window?.title = windowTitle } + didSet { + let sanitized = WindowTitleResolver.sanitizeTitle(previous: oldValue, candidate: windowTitle) + if sanitized != windowTitle { + windowTitle = sanitized + } + view.window?.title = windowTitle + } } var windowSubtitle: String { @@ -61,70 +67,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var connectionStatusCancellable: AnyCancellable? - // MARK: - Title Resolution - - static func resolveDefaultTitle(payload: EditorTabPayload?, queryLanguageName: String?) -> String { - switch payload?.tabType { - case .serverDashboard: - return String(localized: "Server Dashboard") - case .usersRoles: - return String(localized: "Users & Roles") - case .erDiagram: - return String(localized: "ER Diagram") - case .createTable: - return String(localized: "Create Table") - default: - break - } - if let tabTitle = payload?.tabTitle { - return tabTitle - } - if let sourceFileURL = payload?.sourceFileURL { - return QueryTab.fileDisplayTitle(for: sourceFileURL) - } - if let tableName = payload?.tableName { - return tableName - } - if let queryLanguageName { - return String(format: String(localized: "%@ Query"), queryLanguageName) - } - 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?) { @@ -143,7 +85,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } return PluginManager.shared.queryLanguageName(for: connection.type) }() - self.windowTitle = Self.resolveDefaultTitle(payload: payload, queryLanguageName: queryLanguageName) var resolvedSession: ConnectionSession? if let connectionId = payload?.connectionId { @@ -153,9 +94,14 @@ 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) + let titleConnection = self.payloadConnection ?? resolvedSession?.connection + self.windowTitle = WindowTitleResolver.resolveTitle( + payload: payload, + databaseType: titleConnection?.type, + queryLanguageName: queryLanguageName + ) + if let titleConnection { + self.windowSubtitle = WindowTitleResolver.resolveSubtitle(payload: payload, connection: titleConnection) } else { self.windowSubtitle = "" } @@ -174,7 +120,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } self.sessionState = state if payload?.intent == .newEmptyTab, - let tabTitle = state.coordinator.tabManager.selectedTab?.title { + let tabTitle = state.coordinator.tabManager.selectedTab?.title, + !tabTitle.isBlank { self.windowTitle = tabTitle } } @@ -326,7 +273,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi currentSession = newSession if payload?.tableName == nil, - windowTitle == String(localized: "SQL Query") || windowTitle.hasSuffix(" Query") { + windowTitle.isBlank + || windowTitle == WindowTitleResolver.fallbackTitle + || windowTitle.hasSuffix(" Query") { windowTitle = newSession.connection.name windowSubtitle = newSession.connection.name } diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 50b984cf9..c0772178d 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -61,6 +61,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { let splitVC = MainSplitViewController(payload: payload, sessionState: sessionState) window.contentViewController = splitVC + window.title = splitVC.windowTitle + window.subtitle = splitVC.windowSubtitle super.init(window: window) diff --git a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift new file mode 100644 index 000000000..c653bb023 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift @@ -0,0 +1,123 @@ +// +// WindowTitleResolver.swift +// TablePro +// +// Single source of truth for window and native tab titles. +// + +import Foundation + +@MainActor +enum WindowTitleResolver { + static var fallbackTitle: String { + String(localized: "SQL Query") + } + + static func resolveTitle( + payload: EditorTabPayload?, + databaseType: DatabaseType?, + queryLanguageName: String? + ) -> String { + resolveTitle( + tabType: payload?.tabType, + tableName: payload?.tableName, + schemaName: payload?.schemaName, + explicitTitle: payload?.tabTitle, + sourceFileURL: payload?.sourceFileURL, + databaseType: databaseType, + queryLanguageName: queryLanguageName + ) + } + + static func resolveTitle( + tab: QueryTab?, + connection: DatabaseConnection, + queryLanguageName: String? + ) -> String { + resolveTitle( + tabType: tab?.tabType, + tableName: tab?.tableContext.tableName, + schemaName: tab?.tableContext.schemaName, + explicitTitle: tab?.title, + sourceFileURL: tab?.content.sourceFileURL, + databaseType: connection.type, + queryLanguageName: queryLanguageName + ) + } + + static func resolveSubtitle(payload: EditorTabPayload?, connection: DatabaseConnection) -> String { + tableSubtitle( + isTable: payload?.tabType == .table, + tableName: payload?.tableName, + databaseName: payload?.databaseName ?? "", + schemaName: payload?.schemaName, + fallback: connection.name + ) + } + + static func resolveSubtitle(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 sanitizeTitle(previous: String, candidate: String) -> String { + guard candidate.isBlank else { return candidate } + return previous.isBlank ? fallbackTitle : previous + } + + private static func resolveTitle( + tabType: TabType?, + tableName: String?, + schemaName: String?, + explicitTitle: String?, + sourceFileURL: URL?, + databaseType: DatabaseType?, + queryLanguageName: String? + ) -> String { + switch tabType { + case .serverDashboard: + return String(localized: "Server Dashboard") + case .usersRoles: + return String(localized: "Users & Roles") + case .erDiagram: + return String(localized: "ER Diagram") + case .createTable: + return String(localized: "Create Table") + default: + break + } + if tabType == .table, let tableName, !tableName.isBlank { + guard let databaseType else { return tableName } + return QueryTabManager.tabTitle(name: tableName, schema: schemaName, databaseType: databaseType) + } + if let explicitTitle, !explicitTitle.isBlank { + return explicitTitle + } + if let sourceFileURL { + return QueryTab.fileDisplayTitle(for: sourceFileURL) + } + if let queryLanguageName, !queryLanguageName.isBlank { + return String(format: String(localized: "%@ Query"), queryLanguageName) + } + return fallbackTitle + } + + private static func tableSubtitle( + isTable: Bool, + tableName: String?, + databaseName: String, + schemaName: String?, + fallback: String + ) -> String { + guard isTable, let tableName, !tableName.isBlank, !databaseName.isBlank else { return fallback } + if let schemaName, !schemaName.isBlank { + return "\(databaseName) · \(schemaName)" + } + return databaseName + } +} diff --git a/TablePro/Extensions/String+Blank.swift b/TablePro/Extensions/String+Blank.swift new file mode 100644 index 000000000..2bf345ce9 --- /dev/null +++ b/TablePro/Extensions/String+Blank.swift @@ -0,0 +1,14 @@ +// +// String+Blank.swift +// TablePro +// +// Blank-string check that treats whitespace-only strings as empty. +// + +import Foundation + +extension String { + var isBlank: Bool { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 296a3ab03..c9cc00303 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -85,7 +85,7 @@ struct PersistedTab: Codable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) - title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "Query" query = try container.decodeIfPresent(String.self, forKey: .query) ?? "" tabType = try container.decode(TabType.self, forKey: .tabType) tableName = try container.decodeIfPresent(String.self, forKey: .tableName) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 8ab8504b2..606a68e00 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -78,8 +78,6 @@ extension MainContentCoordinator { } if needsQuery { - NSApp.keyWindow?.title = referencedTable - guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return } let tableRows = tabSessionRegistry.tableRows(for: tab.id) let filteredQuery = queryBuilder.buildFilteredQuery( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index de67dcd91..5b9f9b2c2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -71,6 +71,7 @@ extension MainContentCoordinator { tab.content.loadMtime = mtime tab.title = QueryTab.fileDisplayTitle(for: favorite.fileURL) } + tabManager.markTabRenamed(tab.id) registerWindowForSourceFile(favorite.fileURL) return } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 75f8b0ca4..ba8d3bbab 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -286,27 +286,16 @@ extension MainContentView { /// Update window title, proxy icon, and dirty dot based on the selected tab. func updateWindowTitleAndFileState() { let selectedTab = tabManager.selectedTab - if selectedTab?.tabType == .serverDashboard { - windowTitle = String(localized: "Server Dashboard") - } else if selectedTab?.tabType == .usersRoles { - windowTitle = String(localized: "Users & Roles") - } else if selectedTab?.tabType == .createTable { - windowTitle = String(localized: "Create Table") - } else if selectedTab?.tabType == .erDiagram { - windowTitle = String(localized: "ER Diagram") - } else if let fileURL = selectedTab?.content.sourceFileURL { - windowTitle = selectedTab?.title ?? QueryTab.fileDisplayTitle(for: fileURL) + if selectedTab == nil, tabManager.tabs.isEmpty { + windowTitle = connection.name } else { - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - let queryLabel = String(format: String(localized: "%@ Query"), langName) - windowTitle = (selectedTab?.tabType == .table ? selectedTab?.tableContext.tableName : nil) - ?? selectedTab?.title - ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) + windowTitle = WindowTitleResolver.resolveTitle( + tab: selectedTab, + connection: connection, + queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type) + ) } - windowSubtitle = MainSplitViewController.resolveDefaultSubtitle( - tab: selectedTab, - connection: connection - ) + windowSubtitle = WindowTitleResolver.resolveSubtitle(tab: selectedTab, connection: connection) coordinator.splitViewController?.updateDetailMinimumThickness(for: selectedTab?.tabType) viewWindow?.representedURL = selectedTab?.content.sourceFileURL viewWindow?.isDocumentEdited = selectedTab?.showsUnsavedIndicator ?? false diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 44b515238..a7e718438 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -687,6 +687,7 @@ final class MainContentCommandActions { mutTab.content.savedFileContent = content mutTab.title = url.deletingPathExtension().lastPathComponent } + coordinator?.tabManager.markTabRenamed(tabId) } catch { Self.logger.error("Failed to save file: \(error.localizedDescription)") } diff --git a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift index bcff3eb73..5fad730ee 100644 --- a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift +++ b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift @@ -7,9 +7,10 @@ import Foundation import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @Suite("PersistedTab round-trip") @MainActor struct PersistedTabRoundTripTests { @@ -161,4 +162,31 @@ struct PersistedTabRoundTripTests { #expect(decoded.windowGroupIndex == nil) #expect(decoded.isView == false) } + + @Test("A persisted tab without a title key decodes to the default title, never empty") + func missingTitleDecodesToDefault() throws { + let json = """ + {"id":"\(UUID().uuidString)","query":"SELECT 1","tabType":{"query":{}},"tableName":null} + """ + let decoded = try JSONDecoder().decode(PersistedTab.self, from: Data(json.utf8)) + #expect(decoded.title == "Query") + } + + @Test("A persisted tab with a null title decodes to the default title, never empty") + func nullTitleDecodesToDefault() throws { + let json = """ + {"id":"\(UUID().uuidString)","title":null,"query":"SELECT 1","tabType":{"query":{}},"tableName":null} + """ + let decoded = try JSONDecoder().decode(PersistedTab.self, from: Data(json.utf8)) + #expect(decoded.title == "Query") + } + + @Test("A persisted tab with a real title keeps it") + func realTitleIsPreserved() throws { + let json = """ + {"id":"\(UUID().uuidString)","title":"auth.users","query":"","tabType":{"query":{}},"tableName":null} + """ + let decoded = try JSONDecoder().decode(PersistedTab.self, from: Data(json.utf8)) + #expect(decoded.title == "auth.users") + } } diff --git a/TableProTests/Services/MainSplitViewControllerTitleTests.swift b/TableProTests/Services/MainSplitViewControllerTitleTests.swift deleted file mode 100644 index e22805164..000000000 --- a/TableProTests/Services/MainSplitViewControllerTitleTests.swift +++ /dev/null @@ -1,279 +0,0 @@ -import Foundation -@testable import TablePro -import Testing - -@Suite("MainSplitViewController.resolveDefaultTitle") -@MainActor -struct MainSplitViewControllerTitleTests { - @Test("Nil payload falls back to SQL Query") - func nilPayloadFallsBackToSQLQuery() { - let title = MainSplitViewController.resolveDefaultTitle(payload: nil, queryLanguageName: nil) - #expect(title == String(localized: "SQL Query")) - } - - @Test("Server dashboard payload returns Server Dashboard") - func serverDashboardLabel() { - let payload = EditorTabPayload(connectionId: UUID(), tabType: .serverDashboard) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == String(localized: "Server Dashboard")) - } - - @Test("ER diagram payload returns ER Diagram") - func erDiagramLabel() { - let payload = EditorTabPayload(connectionId: UUID(), tabType: .erDiagram) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == String(localized: "ER Diagram")) - } - - @Test("Create table payload returns Create Table") - func createTableLabel() { - let payload = EditorTabPayload(connectionId: UUID(), tabType: .createTable) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == String(localized: "Create Table")) - } - - @Test("Explicit tabTitle wins") - func explicitTabTitleWins() { - let payload = EditorTabPayload( - connectionId: UUID(), - tabType: .query, - tabTitle: "report" - ) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == "report") - } - - @Test("Source file URL resolves to file display name, not language fallback") - func sourceFileURLBeatsLanguageFallback() { - let url = URL(fileURLWithPath: "/tmp/report.sql") - let payload = EditorTabPayload( - connectionId: UUID(), - tabType: .query, - sourceFileURL: url - ) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == QueryTab.fileDisplayTitle(for: url)) - #expect(title != "PostgreSQL Query") - #expect(title != String(localized: "SQL Query")) - } - - @Test("Explicit tabTitle takes precedence over sourceFileURL") - func tabTitlePrecedesSourceFileURL() { - let url = URL(fileURLWithPath: "/tmp/report.sql") - let payload = EditorTabPayload( - connectionId: UUID(), - tabType: .query, - sourceFileURL: url, - tabTitle: "Renamed" - ) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == "Renamed") - } - - @Test("Source file URL takes precedence over tableName") - func sourceFileURLPrecedesTableName() { - let url = URL(fileURLWithPath: "/tmp/report.sql") - let payload = EditorTabPayload( - connectionId: UUID(), - tabType: .query, - tableName: "users", - sourceFileURL: url - ) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == QueryTab.fileDisplayTitle(for: url)) - } - - @Test("Table payload with tableName returns the table name") - func tableNameUsedForTablePayload() { - let payload = EditorTabPayload( - connectionId: UUID(), - tabType: .table, - tableName: "users" - ) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == "users") - } - - @Test("Query payload with language name uses localized language label") - func queryWithLanguageFallback() { - let payload = EditorTabPayload(connectionId: UUID(), tabType: .query) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: "PostgreSQL") - #expect(title == String(format: String(localized: "%@ Query"), "PostgreSQL")) - } - - @Test("Query payload with no language name falls back to SQL Query") - func queryWithoutLanguageFallback() { - let payload = EditorTabPayload(connectionId: UUID(), tabType: .query) - let title = MainSplitViewController.resolveDefaultTitle(payload: payload, queryLanguageName: nil) - #expect(title == String(localized: "SQL Query")) - } -} - -@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") - func returnsFileManagerDisplayName() { - let url = URL(fileURLWithPath: "/tmp/report.sql") - let title = QueryTab.fileDisplayTitle(for: url) - #expect(title == FileManager.default.displayName(atPath: url.path(percentEncoded: false))) - } - - @Test("Strips directory components") - func stripsDirectoryComponents() { - let url = URL(fileURLWithPath: "/var/folders/xyz/queries/report.sql") - let title = QueryTab.fileDisplayTitle(for: url) - #expect(!title.contains("/")) - } - - @Test("Non-empty result for a file URL") - func nonEmptyResult() { - let url = URL(fileURLWithPath: "/tmp/report.sql") - let title = QueryTab.fileDisplayTitle(for: url) - #expect(!title.isEmpty) - } -} - -@Suite("QueryTabManager.addTab with sourceFileURL") -@MainActor -struct QueryTabManagerAddTabSourceFileTests { - @Test("Tab title uses the shared file display title helper") - func tabTitleUsesSharedHelper() { - let tabManager = QueryTabManager() - let url = URL(fileURLWithPath: "/tmp/report.sql") - tabManager.addTab(sourceFileURL: url) - let tab = tabManager.tabs.first - #expect(tab?.title == QueryTab.fileDisplayTitle(for: url)) - } - - @Test("Explicit title argument wins over sourceFileURL") - func explicitTitleWinsOverSourceFileURL() { - let tabManager = QueryTabManager() - let url = URL(fileURLWithPath: "/tmp/report.sql") - tabManager.addTab(title: "favorite-name", sourceFileURL: url) - let tab = tabManager.tabs.first - #expect(tab?.title == "favorite-name") - } -} diff --git a/TableProTests/Services/WindowTitleResolverTests.swift b/TableProTests/Services/WindowTitleResolverTests.swift new file mode 100644 index 000000000..4e03d6f20 --- /dev/null +++ b/TableProTests/Services/WindowTitleResolverTests.swift @@ -0,0 +1,472 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("WindowTitleResolver.resolveTitle from payload") +@MainActor +struct WindowTitleResolverPayloadTitleTests { + @Test("Nil payload falls back to SQL Query") + func nilPayloadFallsBackToSQLQuery() { + let title = WindowTitleResolver.resolveTitle(payload: nil, databaseType: nil, queryLanguageName: nil) + #expect(title == String(localized: "SQL Query")) + } + + @Test("Server dashboard payload returns Server Dashboard") + func serverDashboardLabel() { + let payload = EditorTabPayload(connectionId: UUID(), tabType: .serverDashboard) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == String(localized: "Server Dashboard")) + } + + @Test("ER diagram payload returns ER Diagram") + func erDiagramLabel() { + let payload = EditorTabPayload(connectionId: UUID(), tabType: .erDiagram) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == String(localized: "ER Diagram")) + } + + @Test("Create table payload returns Create Table") + func createTableLabel() { + let payload = EditorTabPayload(connectionId: UUID(), tabType: .createTable) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == String(localized: "Create Table")) + } + + @Test("Explicit tabTitle wins for query payloads") + func explicitTabTitleWins() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + tabTitle: "report" + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == "report") + } + + @Test("Source file URL resolves to file display name, not language fallback") + func sourceFileURLBeatsLanguageFallback() { + let url = URL(fileURLWithPath: "/tmp/report.sql") + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + sourceFileURL: url + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == QueryTab.fileDisplayTitle(for: url)) + #expect(title != "PostgreSQL Query") + #expect(title != String(localized: "SQL Query")) + } + + @Test("Explicit tabTitle takes precedence over sourceFileURL") + func tabTitlePrecedesSourceFileURL() { + let url = URL(fileURLWithPath: "/tmp/report.sql") + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + sourceFileURL: url, + tabTitle: "Renamed" + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == "Renamed") + } + + @Test("Source file URL takes precedence over tableName on query payloads") + func sourceFileURLPrecedesTableName() { + let url = URL(fileURLWithPath: "/tmp/report.sql") + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + tableName: "users", + sourceFileURL: url + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == QueryTab.fileDisplayTitle(for: url)) + } + + @Test("Table payload with tableName returns the table name") + func tableNameUsedForTablePayload() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .table, + tableName: "users" + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == "users") + } + + @Test("Table payload without a database type still returns the table name") + func tableNameUsedWithoutDatabaseType() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .table, + tableName: "users" + ) + let title = WindowTitleResolver.resolveTitle(payload: payload, databaseType: nil, queryLanguageName: nil) + #expect(title == "users") + } + + @Test("Query payload with language name uses localized language label") + func queryWithLanguageFallback() { + let payload = EditorTabPayload(connectionId: UUID(), tabType: .query) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == String(format: String(localized: "%@ Query"), "PostgreSQL")) + } + + @Test("Query payload with no language name falls back to SQL Query") + func queryWithoutLanguageFallback() { + let payload = EditorTabPayload(connectionId: UUID(), tabType: .query) + let title = WindowTitleResolver.resolveTitle(payload: payload, databaseType: .postgresql, queryLanguageName: nil) + #expect(title == String(localized: "SQL Query")) + } + + @Test("Table payload with an empty tabTitle resolves the table name, never blank") + func tablePayloadWithEmptyTabTitleResolvesTableName() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .table, + tableName: "orders", + schemaName: "public", + tabTitle: "" + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == "orders") + } + + @Test("Table payload in a non-default schema resolves the qualified name") + func tablePayloadNonDefaultSchemaQualifies() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .table, + tableName: "audit_log_entries", + schemaName: "auth", + tabTitle: "" + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == "auth.audit_log_entries") + } + + @Test("Table payload recomputes the title even when a tabTitle is present") + func tablePayloadRecomputesOverExplicitTitle() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .table, + tableName: "orders", + schemaName: "auth", + tabTitle: "stale carried-over name" + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == "auth.orders") + } + + @Test("Table payload with a blank tableName falls back to the explicit title") + func tablePayloadBlankTableNameFallsBackToExplicitTitle() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .table, + tableName: "", + tabTitle: "auth.users" + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == "auth.users") + } + + @Test("Empty tabTitle falls through to the file display name") + func emptyTabTitleFallsThroughToFileName() { + let url = URL(fileURLWithPath: "/tmp/report.sql") + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + sourceFileURL: url, + tabTitle: "" + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == QueryTab.fileDisplayTitle(for: url)) + } + + @Test("Whitespace-only tabTitle is treated as absent") + func whitespaceTabTitleTreatedAsAbsent() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + tabTitle: " " + ) + let title = WindowTitleResolver.resolveTitle( + payload: payload, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) + #expect(title == String(format: String(localized: "%@ Query"), "PostgreSQL")) + } + + @Test("Empty tabTitle with no other signal resolves to the fallback, never blank") + func emptyTabTitleNeverResolvesBlank() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + tabTitle: "" + ) + let title = WindowTitleResolver.resolveTitle(payload: payload, databaseType: nil, queryLanguageName: nil) + #expect(title == WindowTitleResolver.fallbackTitle) + #expect(!title.isBlank) + } +} + +@Suite("WindowTitleResolver.resolveTitle from tab") +@MainActor +struct WindowTitleResolverTabTitleTests { + private let connection = DatabaseConnection(name: "MyConnection", type: .postgresql) + + @Test("Table tab with an empty title resolves the table name, never blank") + func tableTabWithEmptyTitleResolvesTableName() { + var tab = QueryTab(id: UUID(), title: "", query: "SELECT 1", tabType: .table, tableName: "orders") + tab.tableContext.schemaName = "public" + let title = WindowTitleResolver.resolveTitle(tab: tab, connection: connection, queryLanguageName: "PostgreSQL") + #expect(title == "orders") + } + + @Test("Table tab in a non-default schema resolves the qualified name") + func tableTabNonDefaultSchemaQualifies() { + var tab = QueryTab(id: UUID(), title: "", query: "SELECT 1", tabType: .table, tableName: "audit_log_entries") + tab.tableContext.schemaName = "auth" + let title = WindowTitleResolver.resolveTitle(tab: tab, connection: connection, queryLanguageName: "PostgreSQL") + #expect(title == "auth.audit_log_entries") + } + + @Test("Query tab keeps its own title") + func queryTabKeepsOwnTitle() { + let tab = QueryTab(id: UUID(), title: "Query 2", query: "SELECT 1", tabType: .query) + let title = WindowTitleResolver.resolveTitle(tab: tab, connection: connection, queryLanguageName: "PostgreSQL") + #expect(title == "Query 2") + } + + @Test("Nil tab resolves to the language label") + func nilTabResolvesLanguageLabel() { + let title = WindowTitleResolver.resolveTitle(tab: nil, connection: connection, queryLanguageName: "PostgreSQL") + #expect(title == String(format: String(localized: "%@ Query"), "PostgreSQL")) + } +} + +@Suite("WindowTitleResolver.sanitizeTitle") +@MainActor +struct WindowTitleResolverSanitizeTests { + @Test("Non-blank candidate passes through") + func nonBlankCandidatePasses() { + #expect(WindowTitleResolver.sanitizeTitle(previous: "old", candidate: "new") == "new") + } + + @Test("Blank candidate keeps the previous title") + func blankCandidateKeepsPrevious() { + #expect(WindowTitleResolver.sanitizeTitle(previous: "users", candidate: "") == "users") + } + + @Test("Whitespace-only candidate keeps the previous title") + func whitespaceCandidateKeepsPrevious() { + #expect(WindowTitleResolver.sanitizeTitle(previous: "users", candidate: " ") == "users") + } + + @Test("Blank candidate over a blank previous returns the fallback") + func blankOverBlankReturnsFallback() { + #expect(WindowTitleResolver.sanitizeTitle(previous: "", candidate: "") == WindowTitleResolver.fallbackTitle) + } +} + +@Suite("WindowTitleResolver.resolveSubtitle from tab") +@MainActor +struct WindowTitleResolverTabSubtitleTests { + 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 = WindowTitleResolver.resolveSubtitle( + 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 = WindowTitleResolver.resolveSubtitle( + 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 = WindowTitleResolver.resolveSubtitle( + 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 = WindowTitleResolver.resolveSubtitle( + 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 = WindowTitleResolver.resolveSubtitle(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 = WindowTitleResolver.resolveSubtitle(tab: tab, connection: connection) + #expect(subtitle == connection.name) + } + + @Test("Nil tab falls back to the connection name") + func nilTabReturnsConnectionName() { + let subtitle = WindowTitleResolver.resolveSubtitle(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 = WindowTitleResolver.resolveSubtitle(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 = WindowTitleResolver.resolveSubtitle(tab: tab, connection: connection) + #expect(subtitle == connection.name) + } +} + +@Suite("WindowTitleResolver.resolveSubtitle from payload") +@MainActor +struct WindowTitleResolverPayloadSubtitleTests { + private let connection = DatabaseConnection(name: "MyConnection") + + @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 = WindowTitleResolver.resolveSubtitle(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 = WindowTitleResolver.resolveSubtitle(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 = WindowTitleResolver.resolveSubtitle(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 = WindowTitleResolver.resolveSubtitle(payload: payload, connection: connection) + #expect(subtitle == connection.name) + } +} + +@Suite("QueryTab.fileDisplayTitle") +struct QueryTabFileDisplayTitleTests { + @Test("Returns FileManager display name for the URL") + func returnsFileManagerDisplayName() { + let url = URL(fileURLWithPath: "/tmp/report.sql") + let title = QueryTab.fileDisplayTitle(for: url) + #expect(title == FileManager.default.displayName(atPath: url.path(percentEncoded: false))) + } + + @Test("Strips directory components") + func stripsDirectoryComponents() { + let url = URL(fileURLWithPath: "/var/folders/xyz/queries/report.sql") + let title = QueryTab.fileDisplayTitle(for: url) + #expect(!title.contains("/")) + } + + @Test("Non-empty result for a file URL") + func nonEmptyResult() { + let url = URL(fileURLWithPath: "/tmp/report.sql") + let title = QueryTab.fileDisplayTitle(for: url) + #expect(!title.isEmpty) + } +} + +@Suite("QueryTabManager.addTab with sourceFileURL") +@MainActor +struct QueryTabManagerAddTabSourceFileTests { + @Test("Tab title uses the shared file display title helper") + func tabTitleUsesSharedHelper() { + let tabManager = QueryTabManager() + let url = URL(fileURLWithPath: "/tmp/report.sql") + tabManager.addTab(sourceFileURL: url) + let tab = tabManager.tabs.first + #expect(tab?.title == QueryTab.fileDisplayTitle(for: url)) + } + + @Test("Explicit title argument wins over sourceFileURL") + func explicitTitleWinsOverSourceFileURL() { + let tabManager = QueryTabManager() + let url = URL(fileURLWithPath: "/tmp/report.sql") + tabManager.addTab(title: "favorite-name", sourceFileURL: url) + let tab = tabManager.tabs.first + #expect(tab?.title == "favorite-name") + } +}