From 08eb10e198b6b828bf979cad91a3e369210e0b29 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: Thu, 28 May 2026 12:02:53 +0700 Subject: [PATCH] fix(tabs): name the tab after the SQL file when opening from disk (#1220) --- CHANGELOG.md | 1 + .../MainSplitViewController.swift | 55 +++--- TablePro/Models/Query/QueryTab.swift | 4 + TablePro/Models/Query/QueryTabManager.swift | 2 +- .../MainContentCoordinator+Favorites.swift | 6 +- .../Extensions/MainContentView+Setup.swift | 2 +- .../Models/EditorTabPayloadTests.swift | 15 ++ .../MainSplitViewControllerTitleTests.swift | 156 ++++++++++++++++++ 8 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 TableProTests/Services/MainSplitViewControllerTitleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b2f6533..a171b2f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: running a query that returns a very large result no longer crashes the app. The query editor keeps the first rows it loads, stops before memory runs low, and tells you to add LIMIT to fetch more. - iOS: Safe Mode "Confirm Writes" now prompts before saving a row edit or inserting a row, matching the query editor. Previously grid edits and inserts saved with no confirmation. - Redshift: schema switching now works, along with the contains, starts with, and ends with filters and table search. All previously failed with a SQL syntax error. (#1439) +- Opening a `.sql` file now names the tab after the file instead of showing "SQL Query". (#1220) ## [0.45.0] - 2026-05-26 diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 40ca11e9c..a3ad26b47 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -49,6 +49,34 @@ 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 .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") + } + // MARK: - Init init(payload: EditorTabPayload?, sessionState: SessionStateFactory.SessionState?) { @@ -60,25 +88,14 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi self.payloadConnection = nil } - let defaultTitle: String - if payload?.tabType == .serverDashboard { - defaultTitle = String(localized: "Server Dashboard") - } else if payload?.tabType == .erDiagram { - defaultTitle = String(localized: "ER Diagram") - } else if payload?.tabType == .createTable { - defaultTitle = String(localized: "Create Table") - } else if let tabTitle = payload?.tabTitle { - defaultTitle = tabTitle - } else if let tableName = payload?.tableName { - defaultTitle = tableName - } else if let connectionId = payload?.connectionId, - let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection { - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - defaultTitle = "\(langName) Query" - } else { - defaultTitle = String(localized: "SQL Query") - } - self.windowTitle = defaultTitle + let queryLanguageName: String? = { + guard let connectionId = payload?.connectionId, + let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection else { + return nil + } + return PluginManager.shared.queryLanguageName(for: connection.type) + }() + self.windowTitle = Self.resolveDefaultTitle(payload: payload, queryLanguageName: queryLanguageName) var resolvedSession: ConnectionSession? if let connectionId = payload?.connectionId { diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 13abe699d..59b46b42b 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -130,6 +130,10 @@ struct QueryTab: Identifiable, Equatable { } } + static func fileDisplayTitle(for url: URL) -> String { + FileManager.default.displayName(atPath: url.path(percentEncoded: false)) + } + var hasUserActiveSort: Bool { sortState.isSorting && sortState.source == .user } diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 58477b946..bc5d4bed0 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -117,7 +117,7 @@ final class QueryTabManager { if let title { tabTitle = title } else if let sourceFileURL { - tabTitle = sourceFileURL.deletingPathExtension().lastPathComponent + tabTitle = QueryTab.fileDisplayTitle(for: sourceFileURL) } else { tabTitle = nextTitle() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index 94f46ea40..de67dcd91 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -53,7 +53,6 @@ extension MainContentCoordinator { if tabManager.tabs.isEmpty { tabManager.addTab( initialQuery: loaded.content, - title: favorite.name, sourceFileURL: favorite.fileURL ) registerWindowForSourceFile(favorite.fileURL) @@ -70,7 +69,7 @@ extension MainContentCoordinator { tab.content.query = loaded.content tab.content.savedFileContent = loaded.content tab.content.loadMtime = mtime - tab.title = favorite.name + tab.title = QueryTab.fileDisplayTitle(for: favorite.fileURL) } registerWindowForSourceFile(favorite.fileURL) return @@ -81,8 +80,7 @@ extension MainContentCoordinator { tabType: .query, databaseName: activeDatabaseName, initialQuery: loaded.content, - sourceFileURL: favorite.fileURL, - tabTitle: favorite.name + sourceFileURL: favorite.fileURL ) WindowManager.shared.openTab(payload: payload) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index de36cca30..baf76a780 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -189,7 +189,7 @@ extension MainContentView { } else if selectedTab?.tabType == .erDiagram { windowTitle = String(localized: "ER Diagram") } else if let fileURL = selectedTab?.content.sourceFileURL { - windowTitle = selectedTab?.title ?? fileURL.deletingPathExtension().lastPathComponent + windowTitle = selectedTab?.title ?? QueryTab.fileDisplayTitle(for: fileURL) } else { let langName = PluginManager.shared.queryLanguageName(for: connection.type) let queryLabel = String(format: String(localized: "%@ Query"), langName) diff --git a/TableProTests/Models/EditorTabPayloadTests.swift b/TableProTests/Models/EditorTabPayloadTests.swift index d95705a75..c903d695e 100644 --- a/TableProTests/Models/EditorTabPayloadTests.swift +++ b/TableProTests/Models/EditorTabPayloadTests.swift @@ -135,10 +135,25 @@ struct EditorTabPayloadTests { let payload = EditorTabPayload(from: tab, connectionId: connectionId) #expect(payload.connectionId == connectionId) #expect(payload.tabType == tab.tabType) + #expect(payload.tabTitle == tab.title) #expect(payload.tableName == tab.tableContext.tableName) #expect(payload.databaseName == tab.tableContext.databaseName) #expect(payload.initialQuery == tab.content.query) #expect(payload.isView == tab.tableContext.isView) #expect(payload.showStructure == (tab.display.resultsViewMode == .structure)) } + + @Test("Init from a file-backed query tab preserves the file title") + @MainActor + func initFromFileBackedQueryTab() throws { + let tabManager = QueryTabManager() + let url = URL(fileURLWithPath: "/tmp/report.sql") + tabManager.addTab(initialQuery: "SELECT 1", sourceFileURL: url) + let tab = try #require(tabManager.tabs.first) + let connectionId = UUID() + let payload = EditorTabPayload(from: tab, connectionId: connectionId) + #expect(payload.sourceFileURL == url) + #expect(payload.tabTitle == tab.title) + #expect(payload.tabTitle == QueryTab.fileDisplayTitle(for: url)) + } } diff --git a/TableProTests/Services/MainSplitViewControllerTitleTests.swift b/TableProTests/Services/MainSplitViewControllerTitleTests.swift new file mode 100644 index 000000000..d80d169c0 --- /dev/null +++ b/TableProTests/Services/MainSplitViewControllerTitleTests.swift @@ -0,0 +1,156 @@ +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("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") + } +}