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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/Main/Child/DataTabGridDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
7 changes: 6 additions & 1 deletion TablePro/Views/Results/Cells/DataGridCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
74 changes: 47 additions & 27 deletions TablePro/Views/Results/DataGridRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -465,14 +477,22 @@ 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()
guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return }
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)
}
}

Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/Results/DataGridViewDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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() {}
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/Results/Extensions/DataGridView+Click.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
94 changes: 94 additions & 0 deletions TableProTests/Views/Main/FKNavigationTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
6 changes: 6 additions & 0 deletions docs/features/data-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading