From 24a7b50613d51e04cf10266d488dad91f700b8cb 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: Wed, 27 May 2026 12:21:58 +0700 Subject: [PATCH] feat(datagrid): open foreign key reference in a new tab on Cmd-click (#1421) --- CHANGELOG.md | 1 + .../Main/Child/DataTabGridDelegate.swift | 4 +- .../MainContentCoordinator+FKNavigation.swift | 51 ++++++---- .../Cells/DataGridCellAccessoryDelegate.swift | 2 +- .../Results/Cells/DataGridCellView.swift | 7 +- .../Views/Results/DataGridCoordinator.swift | 4 +- TablePro/Views/Results/DataGridRowView.swift | 74 +++++++++------ .../Views/Results/DataGridViewDelegate.swift | 4 +- .../Extensions/DataGridView+Click.swift | 4 +- .../Extensions/DataGridView+Popovers.swift | 2 +- .../Views/Main/FKNavigationTests.swift | 94 +++++++++++++++++++ docs/features/data-grid.mdx | 6 ++ 12 files changed, 196 insertions(+), 57 deletions(-) create mode 100644 TableProTests/Views/Main/FKNavigationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 413265fa2..cd8e13270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - OpenCode Zen as an AI provider. Add it from the provider list and paste an OpenCode key, or leave the key blank to use the free models; the model list loads automatically, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) +- Cmd-click a foreign key arrow to open the referenced table in a new tab instead of the current one. The right-click menu has the same Open in New Tab option. (#1421) ### Changed diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 68ba9774c..c51a90d7d 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -77,8 +77,8 @@ final class DataTabGridDelegate: DataGridViewDelegate { coordinator?.canClearActiveQueryResults ?? false } - func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) { - coordinator?.navigateToFKReference(value: value, fkInfo: fkInfo) + func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo, openInNewTab: Bool) { + coordinator?.navigateToFKReference(value: value, fkInfo: fkInfo, openInNewTab: openInNewTab) } func dataGridHideColumn(_ columnName: String) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 169ead9a4..5d64cb897 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -17,11 +17,11 @@ extension MainContentCoordinator { /// 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. - func navigateToFKReference(value: String, fkInfo: ForeignKeyInfo) { + func navigateToFKReference(value: String, fkInfo: ForeignKeyInfo, openInNewTab: Bool) { let referencedTable = fkInfo.referencedTable let referencedColumn = fkInfo.referencedColumn - fkNavigationLogger.debug("FK navigate: \(referencedTable).\(referencedColumn) = \(value)") + fkNavigationLogger.debug("FK navigate: \(referencedTable).\(referencedColumn) = \(value) newTab=\(openInNewTab)") let filter = TableFilter( columnName: referencedColumn, @@ -33,8 +33,8 @@ extension MainContentCoordinator { let targetSchema = fkInfo.referencedSchema ?? DatabaseManager.shared.session(for: connectionId)?.currentSchema - // Fast path: referenced table is already the active tab — just apply filter - if let current = tabManager.selectedTab, + if !openInNewTab, + let current = tabManager.selectedTab, current.tabType == .table, current.tableContext.tableName == referencedTable, current.tableContext.databaseName == currentDatabase, @@ -43,22 +43,12 @@ extension MainContentCoordinator { return } - // If current tab has unsaved changes, open in a new native tab instead of replacing - if changeManager.hasChanges { - let fkFilterState = TabFilterState( - filters: [filter], - appliedFilters: [filter], - isVisible: true, - filterLogicMode: .and - ) - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: referencedTable, + if openInNewTab || changeManager.hasChanges { + let payload = makeFKReferencePayload( + filter: filter, + referencedTable: referencedTable, databaseName: currentDatabase, - schemaName: targetSchema, - isView: false, - initialFilterState: fkFilterState + schemaName: targetSchema ) WindowManager.shared.openTab(payload: payload) return @@ -110,6 +100,29 @@ extension MainContentCoordinator { } } + func makeFKReferencePayload( + filter: TableFilter, + referencedTable: String, + databaseName: String?, + schemaName: String? + ) -> EditorTabPayload { + let fkFilterState = TabFilterState( + filters: [filter], + appliedFilters: [filter], + isVisible: true, + filterLogicMode: .and + ) + return EditorTabPayload( + connectionId: connection.id, + tabType: .table, + tableName: referencedTable, + databaseName: databaseName, + schemaName: schemaName, + isView: false, + initialFilterState: fkFilterState + ) + } + /// Toggle FK preview for the currently focused cell in the data grid. /// Called from the menu command system (Settings > Keyboard rebindable). func toggleFKPreviewForFocusedCell() { diff --git a/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift b/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift index cb700c25b..8ddaa3f4a 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift @@ -7,6 +7,6 @@ import Foundation @MainActor protocol DataGridCellAccessoryDelegate: AnyObject { - func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int) + func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int, openInNewTab: Bool) func dataGridCellDidClickChevron(row: Int, columnIndex: Int) } diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 626be8db0..8a6267203 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -337,7 +337,12 @@ final class DataGridCellView: NSView { return } if kind == .foreignKey { - accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex) + let openInNewTab = event.modifierFlags.contains(.command) + accessoryDelegate?.dataGridCellDidClickFKArrow( + row: cellRow, + columnIndex: cellColumnIndex, + openInNewTab: openInNewTab + ) return } if kind.showsChevron, !visualState.isDeleted { diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 6c1cbef92..6824bb736 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -695,8 +695,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // MARK: - DataGridCellAccessoryDelegate extension TableViewCoordinator: DataGridCellAccessoryDelegate { - func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int) { - handleFKArrowAction(row: row, columnIndex: columnIndex) + func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int, openInNewTab: Bool) { + handleFKArrowAction(row: row, columnIndex: columnIndex, openInNewTab: openInNewTab) } func dataGridCellDidClickChevron(row: Int, columnIndex: Int) { diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index b1173e669..958391bde 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -98,6 +98,43 @@ class DataGridRowView: NSTableRowView { } } + private func addForeignKeyMenuItems(to menu: NSMenu, dataColumnIndex: Int, tableRows: TableRows) { + guard let coordinator, dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[dataColumnIndex] + guard let fkInfo = tableRows.columnForeignKeys[columnName], + let cellValue = coordinator.cellValue(at: rowIndex, column: dataColumnIndex), + !cellValue.isEmpty else { return } + + menu.addItem(NSMenuItem.separator()) + + let previewItem = NSMenuItem( + title: String(localized: "Preview Referenced Row"), + action: #selector(previewForeignKey(_:)), + keyEquivalent: "" + ) + previewItem.representedObject = dataColumnIndex + previewItem.target = self + menu.addItem(previewItem) + + let navItem = NSMenuItem( + title: String(format: String(localized: "Open %@"), fkInfo.referencedTable), + action: #selector(navigateToForeignKey(_:)), + keyEquivalent: "" + ) + navItem.representedObject = dataColumnIndex + navItem.target = self + menu.addItem(navItem) + + let navInNewTabItem = NSMenuItem( + title: String(format: String(localized: "Open %@ in New Tab"), fkInfo.referencedTable), + action: #selector(navigateToForeignKeyInNewTab(_:)), + keyEquivalent: "" + ) + navInNewTabItem.representedObject = dataColumnIndex + navInNewTabItem.target = self + menu.addItem(navInNewTabItem) + } + override func menu(for event: NSEvent) -> NSMenu? { guard let coordinator = coordinator, let tableView = coordinator.tableView else { return nil } @@ -212,32 +249,7 @@ class DataGridRowView: NSTableRowView { } let tableRows = coordinator.tableRowsProvider() - if dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count { - let columnName = tableRows.columns[dataColumnIndex] - if let fkInfo = tableRows.columnForeignKeys[columnName], - let cellValue = coordinator.cellValue(at: rowIndex, column: dataColumnIndex), - !cellValue.isEmpty { - menu.addItem(NSMenuItem.separator()) - - let previewItem = NSMenuItem( - title: String(localized: "Preview Referenced Row"), - action: #selector(previewForeignKey(_:)), - keyEquivalent: "" - ) - previewItem.representedObject = dataColumnIndex - previewItem.target = self - menu.addItem(previewItem) - - let navItem = NSMenuItem( - title: String(format: String(localized: "Open %@"), fkInfo.referencedTable), - action: #selector(navigateToForeignKey(_:)), - keyEquivalent: "" - ) - navItem.representedObject = dataColumnIndex - navItem.target = self - menu.addItem(navItem) - } - } + addForeignKeyMenuItems(to: menu, dataColumnIndex: dataColumnIndex, tableRows: tableRows) if coordinator.isEditable { menu.addItem(NSMenuItem.separator()) @@ -465,6 +477,14 @@ class DataGridRowView: NSTableRowView { } @objc private func navigateToForeignKey(_ sender: NSMenuItem) { + performForeignKeyNavigation(from: sender, openInNewTab: false) + } + + @objc private func navigateToForeignKeyInNewTab(_ sender: NSMenuItem) { + performForeignKeyNavigation(from: sender, openInNewTab: true) + } + + private func performForeignKeyNavigation(from sender: NSMenuItem, openInNewTab: Bool) { guard let columnIndex = sender.representedObject as? Int, let coordinator else { return } let tableRows = coordinator.tableRowsProvider() @@ -472,7 +492,7 @@ class DataGridRowView: NSTableRowView { let columnName = tableRows.columns[columnIndex] guard let fkInfo = tableRows.columnForeignKeys[columnName], let value = coordinator.cellValue(at: rowIndex, column: columnIndex) else { return } - coordinator.delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) + coordinator.delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo, openInNewTab: openInNewTab) } } diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index 0b6b49b07..4ea326f5d 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -20,7 +20,7 @@ protocol DataGridViewDelegate: AnyObject { func dataGridMoveRow(from source: Int, to destination: Int) func dataGridSortStateChanged(_ state: SortState) func dataGridFilterColumn(_ columnName: String) - func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) + func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo, openInNewTab: Bool) func dataGridDuplicateRow() func dataGridExportResults() func dataGridClearResults() @@ -49,7 +49,7 @@ extension DataGridViewDelegate { func dataGridMoveRow(from source: Int, to destination: Int) {} func dataGridSortStateChanged(_ state: SortState) {} func dataGridFilterColumn(_ columnName: String) {} - func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) {} + func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo, openInNewTab: Bool) {} func dataGridDuplicateRow() {} func dataGridExportResults() {} func dataGridClearResults() {} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 0b90f03a7..fd07215a1 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -106,7 +106,7 @@ extension TableViewCoordinator { // MARK: - FK Navigation - func handleFKArrowAction(row: Int, columnIndex: Int) { + func handleFKArrowAction(row: Int, columnIndex: Int, openInNewTab: Bool) { let tableRows = tableRowsProvider() guard row >= 0 && row < cachedRowCount, columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } @@ -117,7 +117,7 @@ extension TableViewCoordinator { let value = cellValue(at: row, column: columnIndex) guard let value = value, !value.isEmpty else { return } - delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) + delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo, openInNewTab: openInNewTab) } // MARK: - Type Picker Popover diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index a6fd445b6..19c2546cf 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -56,7 +56,7 @@ extension TableViewCoordinator { onNavigate: { [weak self, model] in dismiss() guard let value = model.cellValue else { return } - self?.delegate?.dataGridNavigateFK(value: value, fkInfo: model.fkInfo) + self?.delegate?.dataGridNavigateFK(value: value, fkInfo: model.fkInfo, openInNewTab: false) }, onDismiss: dismiss ) diff --git a/TableProTests/Views/Main/FKNavigationTests.swift b/TableProTests/Views/Main/FKNavigationTests.swift new file mode 100644 index 000000000..bca1cbdf0 --- /dev/null +++ b/TableProTests/Views/Main/FKNavigationTests.swift @@ -0,0 +1,94 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("FKNavigation") +struct FKNavigationTests { + @Test("makeFKReferencePayload targets the referenced table and carries the FK filter") + @MainActor + func payloadCarriesFilterAndTarget() { + let connection = TestFixtures.makeConnection(database: "db") + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + let filter = TableFilter(columnName: "id", filterOperator: .equal, value: "42") + let payload = coordinator.makeFKReferencePayload( + filter: filter, + referencedTable: "users", + databaseName: "db", + schemaName: nil + ) + + #expect(payload.connectionId == connection.id) + #expect(payload.tabType == .table) + #expect(payload.tableName == "users") + #expect(payload.databaseName == "db") + #expect(payload.isView == false) + #expect(payload.initialFilterState?.filters == [filter]) + #expect(payload.initialFilterState?.appliedFilters == [filter]) + #expect(payload.initialFilterState?.isVisible == true) + } + + @Test("Plain click navigates the referenced table into the current tab") + @MainActor + func plainClickReplacesCurrentTab() 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 + ) + #expect(tabManager.tabs.count == 1) + + 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 == "users") + } + + @Test("Plain click on the already-open referenced table does not open a second tab") + @MainActor + func plainClickOnSameTableStaysInPlace() 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: "users", + databaseType: connection.type, + databaseName: coordinator.activeDatabaseName + ) + let tabId = tabManager.selectedTab?.id + #expect(tabManager.tabs.count == 1) + + 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 == tabId) + #expect(tabManager.selectedTab?.tableContext.tableName == "users") + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index a669d81d4..75db1bc73 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -31,6 +31,12 @@ Toggle between **Data**, **Structure**, and **JSON** in the status bar. Query ta JSON mode renders all rows as a JSON array. Toggle between Text and Tree views in the toolbar. To export only specific rows, select them in Data mode first, then switch to JSON. The Copy JSON button writes to the clipboard. View mode is remembered per tab. +### Foreign Key Navigation + +Foreign key cells show an arrow on the right edge. Click it to open the referenced table, filtered to the matching row. A plain click uses the current tab; `Cmd`-click opens a new tab. + +Right-click the cell for the same options plus **Preview Referenced Row**, which shows the row in a popover. + ## Column Features ### Resizing Columns