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 @@ -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)
Expand Down
164 changes: 115 additions & 49 deletions TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ final class MainContentCoordinator {

@ObservationIgnored var pendingScrollToTopAfterReplace: Set<UUID> = []

@ObservationIgnored var openTabInNewWindow: (EditorTabPayload) -> Void = {
WindowManager.shared.openTab(payload: $0)
}

// MARK: - Internal State

@ObservationIgnored internal var queryGeneration: Int = 0
Expand Down
Loading
Loading