Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 1 addition & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?`. 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.

Expand Down
91 changes: 20 additions & 71 deletions TablePro/Core/Services/Infrastructure/MainSplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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?) {
Expand All @@ -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 {
Expand All @@ -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 = ""
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
123 changes: 123 additions & 0 deletions TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
14 changes: 14 additions & 0 deletions TablePro/Extensions/String+Blank.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
27 changes: 8 additions & 19 deletions TablePro/Views/Main/Extensions/MainContentView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
Loading
Loading