From a6d55222e0f6a8f8de44957cc16924e23a0de8e7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 4 Aug 2026 13:45:43 +0700 Subject: [PATCH] fix(datagrid): keep the query tab when navigating to a foreign key reference --- CHANGELOG.md | 2 + .../MainContentCoordinator+FKNavigation.swift | 164 ++++++--- .../MainContentCoordinator+Navigation.swift | 18 +- .../Views/Main/MainContentCoordinator.swift | 4 + .../Views/Main/FKNavigationTests.swift | 345 ++++++++++++++++++ .../Views/Main/OpenTableTabTests.swift | 68 ++++ docs/features/data-grid.mdx | 2 +- 7 files changed, 549 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 830f5a9a9..197bb9930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Clicking a foreign key arrow in a query tab's results no longer replaces that tab and loses the query and its results. The referenced table opens in its own tab, and clicking the same reference again returns to that tab instead of opening a duplicate. A tab with unsaved cell edits is kept the same way. +- A foreign key jump between table tabs now keeps the filters you saved for the table you left and applies the hidden columns you saved for the table you land on. - Saving a table structure change with more than one connection open no longer applies the change to a different connection or jumps the view back to it. The save now runs against the connection, database, and schema the edited table belongs to, and stops with an error instead of writing if it cannot reach them. (#2015) - Saving on a MySQL or MariaDB server that starts sessions read-only no longer fails with "Cannot execute statement in a READ ONLY transaction". TablePro marks a transaction read-write before it writes instead of inheriting the server default. Same for PostgreSQL, CockroachDB, and Redshift. (#2009) - Changing Safe Mode in the connection form now applies to an open connection instead of waiting for a reconnect. (#2009) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 606a68e00..49011d573 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -15,8 +15,8 @@ extension MainContentCoordinator { // MARK: - Foreign Key Navigation /// Navigate to the referenced table filtered by the FK value. - /// Opens or switches to the referenced table tab with a pre-applied filter - /// so only the matching row is shown. + /// Reuses the current tab when it holds nothing the user authored, and otherwise opens the + /// reference in its own tab so the originating query or edits survive. func navigateToFKReference(value: String, fkInfo: ForeignKeyInfo, openInNewTab: Bool) { let referencedTable = fkInfo.referencedTable let referencedColumn = fkInfo.referencedColumn @@ -35,67 +35,40 @@ extension MainContentCoordinator { if !openInNewTab, let current = tabManager.selectedTab, - current.tabType == .table, - current.tableContext.tableName == referencedTable, - current.tableContext.databaseName == currentDatabase, - current.tableContext.schemaName == targetSchema { + matchesFKTarget(current, table: referencedTable, database: currentDatabase, schema: targetSchema) { applyFKFilter(filter, for: referencedTable) return } - if openInNewTab || changeManager.hasChanges { - let payload = makeFKReferencePayload( - filter: filter, + guard openInNewTab || selectedTabHoldsProtectedContent else { + replaceSelectedTabWithFKTarget( referencedTable: referencedTable, + filter: filter, databaseName: currentDatabase, schemaName: targetSchema ) - WindowManager.shared.openTab(payload: payload) return } - let needsQuery: Bool - do { - needsQuery = try tabManager.replaceTabContent( - tableName: referencedTable, - databaseType: connection.type, - isView: false, - databaseName: currentDatabase, - schemaName: targetSchema - ) - } catch { - fkNavigationLogger.error("navigateToFKReference replaceTabContent failed: \(error.localizedDescription, privacy: .public)") + if !openInNewTab, + let existing = openFKTargetTab( + table: referencedTable, + database: currentDatabase, + schema: targetSchema, + filter: filter + ) { + existing.coordinator.selectTabAndFocusWindow(existing.tabId) return } - if needsQuery, let (tab, tabIndex) = tabManager.selectedTabAndIndex { - setActiveTableRows(TableRows(), for: tab.id) - tabManager.mutate(at: tabIndex) { $0.pagination.reset() } - } - - if let (tab, _) = tabManager.selectedTabAndIndex { - toolbarState.isTableTab = tab.tabType == .table - } - - if needsQuery { - guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return } - let tableRows = tabSessionRegistry.tableRows(for: tab.id) - let filteredQuery = queryBuilder.buildFilteredQuery( - tableName: referencedTable, - schemaName: targetSchema, - filters: [filter], - columns: tableRows.columns, - limit: tab.pagination.pageSize, - offset: tab.pagination.currentOffset - ) - tabManager.mutate(at: tabIndex) { $0.content.query = filteredQuery } - - updateFilterState(filter, for: referencedTable) - - runQuery() - } else { - applyFKFilter(filter, for: referencedTable) - } + promotePreviewTab() + let payload = makeFKReferencePayload( + filter: filter, + referencedTable: referencedTable, + databaseName: currentDatabase, + schemaName: targetSchema + ) + openTabInNewWindow(payload) } func makeFKReferencePayload( @@ -137,6 +110,99 @@ extension MainContentCoordinator { ) } + private func matchesFKTarget(_ tab: QueryTab, table: String, database: String, schema: String?) -> Bool { + tab.tabType == .table + && tab.tableContext.tableName == table + && tab.tableContext.databaseName == database + && tab.tableContext.schemaName == schema + } + + private func isSameFKPredicate(_ lhs: TableFilter, _ rhs: TableFilter) -> Bool { + lhs.columnName == rhs.columnName + && lhs.filterOperator == rhs.filterOperator + && lhs.value == rhs.value + } + + /// A tab already showing exactly this reference. Matching the filter too keeps a click on a + /// different row from re-filtering a tab the user opened for another one. + private func openFKTargetTab( + table: String, + database: String, + schema: String?, + filter: TableFilter + ) -> (coordinator: MainContentCoordinator, tabId: UUID)? { + func matches(_ tab: QueryTab) -> Bool { + guard matchesFKTarget(tab, table: table, database: database, schema: schema) else { return false } + let applied = tab.filterState.appliedFilters + guard applied.count == 1 else { return false } + return isSameFKPredicate(applied[0], filter) + } + + if let match = tabManager.tabs.first(where: matches) { + return (self, match.id) + } + + for sibling in MainContentCoordinator.allActiveCoordinators() + where sibling !== self && sibling.connectionId == connectionId { + guard let match = sibling.tabManager.tabs.first(where: matches) else { continue } + return (sibling, match.id) + } + return nil + } + + private func replaceSelectedTabWithFKTarget( + referencedTable: String, + filter: TableFilter, + databaseName: String, + schemaName: String? + ) { + if let outgoingTable = tabManager.selectedTab?.tableContext.tableName { + saveLastFilters(for: outgoingTable) + } + + let replaced: Bool + do { + replaced = try tabManager.replaceTabContent( + tableName: referencedTable, + databaseType: connection.type, + isView: false, + databaseName: databaseName, + schemaName: schemaName + ) + } catch { + fkNavigationLogger.error("navigateToFKReference replaceTabContent failed: \(error.localizedDescription, privacy: .public)") + return + } + + guard replaced, let (replacedTab, tabIndex) = tabManager.selectedTabAndIndex else { + applyFKFilter(filter, for: referencedTable) + return + } + + let tabId = replacedTab.id + cancelTableLoad(for: tabId) + toolbarState.isTableTab = true + setActiveTableRows(TableRows(), for: tabId) + tabManager.mutate(at: tabIndex) { $0.pagination.reset() } + restoreLastHiddenColumnsForTable() + + guard let pagination = tabManager.selectedTab?.pagination else { return } + let tableRows = tabSessionRegistry.tableRows(for: tabId) + let filteredQuery = queryBuilder.buildFilteredQuery( + tableName: referencedTable, + schemaName: schemaName, + filters: [filter], + columns: tableRows.columns, + limit: pagination.pageSize, + offset: pagination.currentOffset + ) + tabManager.mutate(at: tabIndex) { $0.content.query = filteredQuery } + + updateFilterState(filter, for: referencedTable) + + runQuery() + } + private func applyFKFilter(_ filter: TableFilter, for tableName: String) { applyFilters([filter]) updateFilterState(filter, for: tableName) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index c6f90f057..f5e62bff4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -302,17 +302,27 @@ extension MainContentCoordinator { // MARK: - Preview Tabs + /// Content the user authored that lives nowhere else, so replacing the tab in place would + /// destroy it. Any navigation that reuses the selected tab must consult this first. + var selectedTabHoldsProtectedContent: Bool { + guard let tab = tabManager.selectedTab else { return false } + if changeManager.hasChanges { return true } + if tab.holdsQueryWork { return true } + if tab.tabType == .createTable { return toolbarState.hasCreateTablePending } + return false + } + var isActiveTabReusable: Bool { guard let tab = tabManager.selectedTab else { return false } - if changeManager.hasChanges - || selectedTabFilterState.hasAppliedFilters + if selectedTabHoldsProtectedContent { return false } + if selectedTabFilterState.hasAppliedFilters || tab.hasUserActiveSort || tab.display.hasPinnedResults { return false } - if tab.tabType == .createTable { return !toolbarState.hasCreateTablePending } + if tab.tabType == .createTable { return true } if tab.isPreview { return true } - if tab.tabType == .query, !tab.holdsQueryWork { return true } + if tab.tabType == .query { return true } return false } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 76988f660..30de82b44 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -190,6 +190,10 @@ final class MainContentCoordinator { @ObservationIgnored var pendingScrollToTopAfterReplace: Set = [] + @ObservationIgnored var openTabInNewWindow: (EditorTabPayload) -> Void = { + WindowManager.shared.openTab(payload: $0) + } + // MARK: - Internal State @ObservationIgnored internal var queryGeneration: Int = 0 diff --git a/TableProTests/Views/Main/FKNavigationTests.swift b/TableProTests/Views/Main/FKNavigationTests.swift index f69b9a141..a4cc36a06 100644 --- a/TableProTests/Views/Main/FKNavigationTests.swift +++ b/TableProTests/Views/Main/FKNavigationTests.swift @@ -123,6 +123,351 @@ struct FKNavigationTests { #expect(tabManager.selectedTab?.tableContext.schemaName == "sales") } + @Test("Plain click from an executed query tab opens a new tab and leaves the query tab intact") + @MainActor + func plainClickFromExecutedQueryTabOpensNewTab() { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + tabManager.addTab(initialQuery: "SELECT * FROM orders", databaseName: coordinator.activeDatabaseName) + tabManager.mutate(at: 0) { $0.execution.lastExecutedAt = Date() } + let originalTabId = tabManager.selectedTab?.id + + var opened: [EditorTabPayload] = [] + coordinator.openTabInNewWindow = { opened.append($0) } + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") + coordinator.navigateToFKReference(value: "42", fkInfo: fkInfo, openInNewTab: false) + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.id == originalTabId) + #expect(tabManager.selectedTab?.tabType == .query) + #expect(tabManager.selectedTab?.content.query == "SELECT * FROM orders") + #expect(tabManager.selectedTab?.execution.lastExecutedAt != nil) + #expect(opened.count == 1) + #expect(opened.first?.tableName == "users") + #expect(opened.first?.initialFilterState?.appliedFilters.first?.value == "42") + } + + @Test("Plain click from a query tab with unexecuted SQL opens a new tab") + @MainActor + func plainClickFromTypedQueryTabOpensNewTab() { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + tabManager.addTab(initialQuery: "SELECT 1", databaseName: coordinator.activeDatabaseName) + let originalTabId = tabManager.selectedTab?.id + + var opened: [EditorTabPayload] = [] + coordinator.openTabInNewWindow = { opened.append($0) } + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") + coordinator.navigateToFKReference(value: "42", fkInfo: fkInfo, openInNewTab: false) + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.id == originalTabId) + #expect(tabManager.selectedTab?.tabType == .query) + #expect(tabManager.selectedTab?.content.query == "SELECT 1") + #expect(opened.count == 1) + } + + @Test("Plain click from a table tab with pending edits leaves the edits in place") + @MainActor + func plainClickWithPendingEditsOpensNewTab() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try tabManager.addTableTab( + tableName: "orders", + databaseType: connection.type, + databaseName: coordinator.activeDatabaseName + ) + coordinator.changeManager.hasChanges = true + + var opened: [EditorTabPayload] = [] + coordinator.openTabInNewWindow = { opened.append($0) } + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") + coordinator.navigateToFKReference(value: "42", fkInfo: fkInfo, openInNewTab: false) + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.tableContext.tableName == "orders") + #expect(opened.count == 1) + } + + @Test("Clicking the same reference again returns to the tab it already opened") + @MainActor + func repeatedPlainClickActivatesExistingTargetTab() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + + let originTabManager = QueryTabManager() + let originCoordinator = MainContentCoordinator( + connection: connection, + tabManager: originTabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + originCoordinator.registerEagerly() + + let targetTabManager = QueryTabManager() + let targetCoordinator = MainContentCoordinator( + connection: connection, + tabManager: targetTabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + targetCoordinator.registerEagerly() + + defer { + originCoordinator.teardown() + targetCoordinator.teardown() + } + + originTabManager.addTab( + initialQuery: "SELECT * FROM orders", + databaseName: originCoordinator.activeDatabaseName + ) + originTabManager.mutate(at: 0) { $0.execution.lastExecutedAt = Date() } + + try targetTabManager.addTableTab( + tableName: "users", + databaseType: connection.type, + databaseName: targetCoordinator.activeDatabaseName + ) + targetTabManager.mutate(at: 0) { + $0.filterState.filters = [TableFilter(columnName: "id", filterOperator: .equal, value: "42")] + $0.filterState.commit = .all + } + let existingTargetTabId = targetTabManager.selectedTab?.id + + var opened: [EditorTabPayload] = [] + originCoordinator.openTabInNewWindow = { opened.append($0) } + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") + originCoordinator.navigateToFKReference(value: "42", fkInfo: fkInfo, openInNewTab: false) + + #expect(opened.isEmpty) + #expect(originTabManager.tabs.count == 1) + #expect(targetTabManager.tabs.count == 1) + #expect(targetTabManager.selectedTab?.id == existingTargetTabId) + } + + @Test("A reference to a different row does not re-filter a tab opened for another row") + @MainActor + func differentReferencedRowOpensItsOwnTab() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + + let originTabManager = QueryTabManager() + let originCoordinator = MainContentCoordinator( + connection: connection, + tabManager: originTabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + originCoordinator.registerEagerly() + + let targetTabManager = QueryTabManager() + let targetCoordinator = MainContentCoordinator( + connection: connection, + tabManager: targetTabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + targetCoordinator.registerEagerly() + + defer { + originCoordinator.teardown() + targetCoordinator.teardown() + } + + originTabManager.addTab( + initialQuery: "SELECT * FROM orders", + databaseName: originCoordinator.activeDatabaseName + ) + originTabManager.mutate(at: 0) { $0.execution.lastExecutedAt = Date() } + + try targetTabManager.addTableTab( + tableName: "users", + databaseType: connection.type, + databaseName: targetCoordinator.activeDatabaseName + ) + targetTabManager.mutate(at: 0) { + $0.filterState.filters = [TableFilter(columnName: "id", filterOperator: .equal, value: "42")] + $0.filterState.commit = .all + } + + var opened: [EditorTabPayload] = [] + originCoordinator.openTabInNewWindow = { opened.append($0) } + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") + originCoordinator.navigateToFKReference(value: "99", fkInfo: fkInfo, openInNewTab: false) + + #expect(opened.count == 1) + #expect(opened.first?.initialFilterState?.appliedFilters.first?.value == "99") + #expect(targetTabManager.selectedTab?.filterState.appliedFilters.first?.value == "42") + } + + @Test("Cmd-click opens a new tab even when the same reference is already open") + @MainActor + func explicitNewTabSkipsReuse() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + + let originTabManager = QueryTabManager() + let originCoordinator = MainContentCoordinator( + connection: connection, + tabManager: originTabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + originCoordinator.registerEagerly() + defer { originCoordinator.teardown() } + + try originTabManager.addTableTab( + tableName: "users", + databaseType: connection.type, + databaseName: originCoordinator.activeDatabaseName + ) + originTabManager.mutate(at: 0) { + $0.filterState.filters = [TableFilter(columnName: "id", filterOperator: .equal, value: "42")] + $0.filterState.commit = .all + } + try originTabManager.addTableTab( + tableName: "orders", + databaseType: connection.type, + databaseName: originCoordinator.activeDatabaseName + ) + + var opened: [EditorTabPayload] = [] + originCoordinator.openTabInNewWindow = { opened.append($0) } + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") + originCoordinator.navigateToFKReference(value: "42", fkInfo: fkInfo, openInNewTab: true) + + #expect(opened.count == 1) + #expect(originTabManager.selectedTab?.tableContext.tableName == "orders") + } + + @Test("An in-place hop saves the outgoing table's filters and restores the target's hidden columns") + @MainActor + func inPlaceHopKeepsPerTableSettings() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + let ordersKey = ColumnLayoutTableKey( + connectionId: connection.id, + databaseName: coordinator.activeDatabaseName, + schemaName: nil, + tableName: "orders" + ) + let usersKey = ColumnLayoutTableKey( + connectionId: connection.id, + databaseName: coordinator.activeDatabaseName, + schemaName: nil, + tableName: "users" + ) + defer { + FileColumnLayoutPersister.shared.clear(for: ordersKey) + FileColumnLayoutPersister.shared.clear(for: usersKey) + FilterSettingsStorage.shared.clearLastFilters( + for: "orders", + connectionId: connection.id, + databaseName: coordinator.activeDatabaseName, + schemaName: nil + ) + } + FileColumnLayoutPersister.shared.saveHiddenColumns(["ssn"], for: usersKey) + + try tabManager.addTableTab( + tableName: "orders", + databaseType: connection.type, + databaseName: coordinator.activeDatabaseName + ) + let outgoingFilter = TableFilter(columnName: "status", filterOperator: .equal, value: "open") + tabManager.mutate(at: 0) { + $0.filterState.filters = [outgoingFilter] + $0.filterState.commit = .all + } + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") + coordinator.navigateToFKReference(value: "42", fkInfo: fkInfo, openInNewTab: false) + + #expect(tabManager.selectedTab?.tableContext.tableName == "users") + #expect(tabManager.selectedTab?.columnLayout.hiddenColumns == ["ssn"]) + + let savedForOrders = FilterSettingsStorage.shared.loadLastFilters( + for: "orders", + connectionId: connection.id, + databaseName: coordinator.activeDatabaseName, + schemaName: nil + ) + #expect(savedForOrders.contains { $0.columnName == "status" && $0.value == "open" }) + } + + @Test("An in-place hop cancels the outgoing tab's in-flight load") + @MainActor + func inPlaceHopCancelsInFlightLoad() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try tabManager.addTableTab( + tableName: "orders", + databaseType: connection.type, + databaseName: coordinator.activeDatabaseName + ) + guard let tabId = tabManager.selectedTab?.id else { + Issue.record("expected a selected tab") + return + } + + let inFlight = Task { + while !Task.isCancelled { + await Task.yield() + } + } + coordinator.tableLoadTasks[tabId] = (token: UUID(), task: inFlight) + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") + coordinator.navigateToFKReference(value: "42", fkInfo: fkInfo, openInNewTab: false) + + #expect(inFlight.isCancelled) + #expect(coordinator.tableLoadTasks[tabId] == nil) + } + @Test("Metadata is not cached until foreign keys were fetched") @MainActor func metadataCacheRequiresFetchedForeignKeys() throws { diff --git a/TableProTests/Views/Main/OpenTableTabTests.swift b/TableProTests/Views/Main/OpenTableTabTests.swift index e40fd5717..6b327e9ef 100644 --- a/TableProTests/Views/Main/OpenTableTabTests.swift +++ b/TableProTests/Views/Main/OpenTableTabTests.swift @@ -388,6 +388,74 @@ struct OpenTableTabTests { #expect(tabManager.selectedTab?.display.resultsViewMode == .structure) } + // MARK: - Protected content + + @Test("An executed query tab holds protected content") + @MainActor + func executedQueryTabIsProtected() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addTab(databaseName: "db") + coordinator.tabManager.mutate(at: 0) { $0.execution.lastExecutedAt = Date() } + #expect(coordinator.selectedTabHoldsProtectedContent) + } + + @Test("A query tab with typed but unexecuted SQL holds protected content") + @MainActor + func typedQueryTabIsProtected() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db") + #expect(coordinator.selectedTabHoldsProtectedContent) + } + + @Test("A blank never-executed query tab holds no protected content") + @MainActor + func blankQueryTabIsNotProtected() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addTab(databaseName: "db") + #expect(coordinator.selectedTabHoldsProtectedContent == false) + } + + @Test("A table tab with pending cell edits holds protected content") + @MainActor + func tableTabWithPendingEditsIsProtected() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + try coordinator.tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db") + coordinator.changeManager.hasChanges = true + #expect(coordinator.selectedTabHoldsProtectedContent) + } + + @Test("An ordinary table tab holds no protected content") + @MainActor + func ordinaryTableTabIsNotProtected() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + try coordinator.tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db") + #expect(coordinator.selectedTabHoldsProtectedContent == false) + } + + @Test("A createTable tab with a committable design holds protected content") + @MainActor + func createTableTabWithPendingDesignIsProtected() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addCreateTableTab(databaseName: "db") + coordinator.toolbarState.hasCreateTablePending = true + #expect(coordinator.selectedTabHoldsProtectedContent) + } + + @Test("A createTable tab without a committable design holds no protected content") + @MainActor + func createTableTabWithoutPendingDesignIsNotProtected() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addCreateTableTab(databaseName: "db") + #expect(coordinator.selectedTabHoldsProtectedContent == false) + } + @MainActor private static func makeCoordinator() -> MainContentCoordinator { MainContentCoordinator( diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 9d77cbfa6..dace6a8c4 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -82,7 +82,7 @@ Right-click an editable cell and open **Set Value** for common values without ty ### Foreign Keys -Foreign key cells show an arrow on the right edge. Click it to open the referenced table filtered to the matching row; `Cmd`-click opens a new tab. Right-click for **Preview Referenced Row**, which shows the row in a popover. +Foreign key cells show an arrow on the right edge. Click it to open the referenced table filtered to the matching row. If the current tab holds a query or unsaved edits, the reference opens in its own tab instead of replacing it, and clicking the same reference again returns to that tab. `Cmd`-click always opens a new tab. Right-click for **Preview Referenced Row**, which shows the row in a popover. Foreign key lookup