diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a03d95a..03c159841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Foreign key preview: press Cmd+Enter on a FK cell to see the referenced row in a popover + ## [0.27.2] - 2026-04-02 ### Added diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 99f5ad0b4..e24a43872 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -540,6 +540,16 @@ } } }, + "%@ → %@.%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ → %2$@.%3$@" + } + } + } + }, "%@ cannot be empty" : { "localizations" : { "tr" : { @@ -13194,6 +13204,9 @@ } } } + }, + "Failed to load referenced row" : { + }, "Failed to load schemas" : { "localizations" : { @@ -19763,7 +19776,6 @@ } }, "No database connection" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21140,6 +21152,9 @@ } } } + }, + "NULL — no referenced row" : { + }, "NULL display cannot be empty" : { "localizations" : { @@ -21434,6 +21449,9 @@ } } } + }, + "Open %@" : { + }, "Open %@ Editor" : { "localizations" : { @@ -24703,6 +24721,9 @@ } } } + }, + "Referenced row not found" : { + }, "Referenced table" : { "extractionState" : "stale", diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index c4a5b97cd..75b6ae572 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -74,6 +74,35 @@ extension TableViewCoordinator { } } + func showForeignKeyPreview(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { + guard columnIndex >= 0, columnIndex < rowProvider.columns.count else { return } + let columnName = rowProvider.columns[columnIndex] + guard let fkInfo = rowProvider.columnForeignKeys[columnName] else { return } + let cellValue = rowProvider.value(atRow: row, column: columnIndex) + guard let databaseType, let connectionId else { return } + guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } + + let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) + PopoverPresenter.show( + relativeTo: cellRect, + of: tableView, + contentSize: NSSize(width: 380, height: 400) + ) { [weak self] dismiss in + ForeignKeyPreviewView( + cellValue: cellValue, + fkInfo: fkInfo, + connectionId: connectionId, + databaseType: databaseType, + onNavigate: { + dismiss() + guard let value = cellValue else { return } + self?.onNavigateFK?(value, fkInfo) + }, + onDismiss: dismiss + ) + } + } + func showJSONEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { let currentValue = rowProvider.value(atRow: row, column: columnIndex) diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift new file mode 100644 index 000000000..e0c7e687a --- /dev/null +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -0,0 +1,175 @@ +// +// ForeignKeyPreviewView.swift +// TablePro +// +// Read-only popover showing the referenced row for a foreign key cell. +// + +import os +import SwiftUI +import TableProPluginKit + +struct ForeignKeyPreviewView: View { + let cellValue: String? + let fkInfo: ForeignKeyInfo + let connectionId: UUID + let databaseType: DatabaseType + let onNavigate: () -> Void + let onDismiss: () -> Void + + @State private var columns: [String] = [] + @State private var values: [String?] = [] + @State private var isLoading = true + @State private var errorMessage: String? + + private static let logger = Logger(subsystem: "com.TablePro", category: "FKPreview") + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + Divider() + footer + } + .frame(width: 380) + .fixedSize(horizontal: false, vertical: true) + .task { await fetchReferencedRow() } + } + + // MARK: - Header + + private var header: some View { + HStack { + Text("\(fkInfo.column) → \(fkInfo.referencedTable).\(fkInfo.referencedColumn)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + if cellValue == nil { + Text("NULL — no referenced row") + .foregroundStyle(.secondary) + .font(.system(size: 12)) + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 60) + } else if isLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 60) + } else if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .font(.system(size: 12)) + .padding(10) + } else if values.isEmpty { + Text("Referenced row not found") + .foregroundStyle(.secondary) + .font(.system(size: 12)) + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 60) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(zip(columns, values).enumerated()), id: \.offset) { _, pair in + let (col, value) = pair + HStack(alignment: .top, spacing: 8) { + Text(col) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 120, alignment: .trailing) + .lineLimit(1) + + if let val = value { + Text(val) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(3) + .textSelection(.enabled) + } else { + Text("NULL") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.tertiary) + .italic() + } + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + } + } + } + .frame(maxHeight: 300) + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Spacer() + Button { + onNavigate() + } label: { + Label( + String(format: String(localized: "Open %@"), fkInfo.referencedTable), + systemImage: "arrow.right" + ) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(cellValue == nil || isLoading || values.isEmpty) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + // MARK: - Data Fetching + + private func fetchReferencedRow() async { + guard let value = cellValue else { + isLoading = false + return + } + + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + Self.logger.error("No active driver for FK preview") + errorMessage = String(localized: "No database connection") + isLoading = false + return + } + + let quotedTable = driver.quoteIdentifier(fkInfo.referencedTable) + let quotedColumn = driver.quoteIdentifier(fkInfo.referencedColumn) + let escapedValue = driver.escapeStringLiteral(value) + + let limitClause: String + switch PluginManager.shared.paginationStyle(for: databaseType) { + case .offsetFetch: + limitClause = "OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY" + case .limit: + limitClause = "LIMIT 1" + } + + let query = "SELECT * FROM \(quotedTable) WHERE \(quotedColumn) = '\(escapedValue)' \(limitClause)" + + do { + let result = try await driver.execute(query: query) + if let firstRow = result.rows.first { + columns = result.columns + values = firstRow + } + } catch { + Self.logger.error("FK preview query failed: \(error.localizedDescription)") + errorMessage = String(localized: "Failed to load referenced row") + } + + isLoading = false + } +} diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index efe21e378..31dec487c 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -243,6 +243,14 @@ final class KeyHandlingTableView: NSTableView { break } + // Cmd+Return: preview referenced FK row + if key == .return && modifiers.contains(.command) && selectedRow >= 0 && focusedColumn >= 1 { + coordinator?.showForeignKeyPreview( + tableView: self, row: selectedRow, column: focusedColumn, columnIndex: focusedColumn - 1 + ) + return + } + // For all other keys, use interpretKeyEvents to map to standard selectors // This handles Return → insertNewline(_:), Delete → deleteBackward(_:), ESC → cancelOperation(_:) interpretKeyEvents([event]) diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 7fa4f42d6..2cd4efe6c 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -106,6 +106,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Action | Shortcut | |--------|----------| | Edit cell | `Enter` or `F2` | +| Preview FK reference | `Cmd+Enter` | | Cancel edit | `Escape` | | Delete row | `Delete` or `Backspace` | | Commit changes | `Cmd+S` |