From 36289a6a53b75bf31ba29d6aa121e0ec843133f7 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: Fri, 3 Apr 2026 08:32:09 +0700 Subject: [PATCH 1/3] feat: add FK preview popover on Cmd+Enter in data grid --- CHANGELOG.md | 4 + TablePro/Resources/Localizable.xcstrings | 23 ++- .../Extensions/DataGridView+Popovers.swift | 30 +++ .../Views/Results/ForeignKeyPreviewView.swift | 175 ++++++++++++++++++ .../Views/Results/KeyHandlingTableView.swift | 6 + docs/features/keyboard-shortcuts.mdx | 1 + 6 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 TablePro/Views/Results/ForeignKeyPreviewView.swift 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..aa2040801 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -74,6 +74,36 @@ extension TableViewCoordinator { } } + func showForeignKeyPreview(tableView: NSTableView, row: Int, column: Int) { + let columnIndex = column - 1 + 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..d9c0345a8 --- /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..04c2e9d2d 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -243,6 +243,12 @@ final class KeyHandlingTableView: NSTableView { break } + // Cmd+Return: preview referenced FK row + if key == .return && modifiers.contains(.command) { + coordinator?.showForeignKeyPreview(tableView: self, row: focusedRow, column: focusedColumn) + 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` | From cf8734b2068c0b9f03e4dd988e3bfff6e9eb8f21 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: Fri, 3 Apr 2026 08:37:26 +0700 Subject: [PATCH 2/3] fix: improve FK preview UI/UX and address code review issues --- .../Extensions/DataGridView+Popovers.swift | 3 +- .../Views/Results/ForeignKeyPreviewView.swift | 83 +++++++++++-------- .../Views/Results/KeyHandlingTableView.swift | 6 +- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index aa2040801..75b6ae572 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -74,8 +74,7 @@ extension TableViewCoordinator { } } - func showForeignKeyPreview(tableView: NSTableView, row: Int, column: Int) { - let columnIndex = column - 1 + 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 } diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index d9c0345a8..59d43fcc4 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -32,7 +32,7 @@ struct ForeignKeyPreviewView: View { Divider() footer } - .frame(width: 380) + .frame(width: 400) .fixedSize(horizontal: false, vertical: true) .task { await fetchReferencedRow() } } @@ -40,13 +40,19 @@ struct ForeignKeyPreviewView: View { // MARK: - Header private var header: some View { - HStack { + VStack(alignment: .leading, spacing: 2) { Text("\(fkInfo.column) → \(fkInfo.referencedTable).\(fkInfo.referencedColumn)") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) - Spacer() + if let cellValue { + Text(cellValue) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(1) + } } - .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) .padding(.vertical, 8) } @@ -68,7 +74,8 @@ struct ForeignKeyPreviewView: View { Text(errorMessage) .foregroundStyle(.red) .font(.system(size: 12)) - .padding(10) + .frame(maxWidth: .infinity, alignment: .center) + .padding(12) } else if values.isEmpty { Text("Referenced row not found") .foregroundStyle(.secondary) @@ -76,37 +83,39 @@ struct ForeignKeyPreviewView: View { .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) + rowList + } + } + + private var rowList: some View { + ScrollView { + VStack(spacing: 0) { + ForEach(Array(zip(columns, values).enumerated()), id: \.offset) { index, pair in + let (col, value) = pair + HStack(alignment: .firstTextBaseline, spacing: 0) { + Text(col) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(1) + .layoutPriority(-1) + + Text(valueText(value)) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(value != nil ? .primary : .tertiary) + .italic(value == nil) + .lineLimit(3) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(index.isMultiple(of: 2) ? Color.clear : Color.primary.opacity(0.03)) } } - .frame(maxHeight: 300) } + .frame(maxHeight: 300) } // MARK: - Footer @@ -124,12 +133,18 @@ struct ForeignKeyPreviewView: View { } .buttonStyle(.borderedProminent) .controlSize(.small) - .disabled(cellValue == nil || (!isLoading && values.isEmpty)) + .disabled(cellValue == nil || isLoading || values.isEmpty) } - .padding(.horizontal, 10) + .padding(.horizontal, 12) .padding(.vertical, 8) } + // MARK: - Helpers + + private func valueText(_ value: String?) -> String { + value ?? "NULL" + } + // MARK: - Data Fetching private func fetchReferencedRow() async { diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 04c2e9d2d..31dec487c 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -244,8 +244,10 @@ final class KeyHandlingTableView: NSTableView { } // Cmd+Return: preview referenced FK row - if key == .return && modifiers.contains(.command) { - coordinator?.showForeignKeyPreview(tableView: self, row: focusedRow, column: focusedColumn) + if key == .return && modifiers.contains(.command) && selectedRow >= 0 && focusedColumn >= 1 { + coordinator?.showForeignKeyPreview( + tableView: self, row: selectedRow, column: focusedColumn, columnIndex: focusedColumn - 1 + ) return } From 7bcfe6ce639c159a5bfe8348884bdf58b060763f 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: Fri, 3 Apr 2026 08:38:47 +0700 Subject: [PATCH 3/3] fix: revert FK preview layout to original design --- .../Views/Results/ForeignKeyPreviewView.swift | 81 ++++++++----------- 1 file changed, 33 insertions(+), 48 deletions(-) diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index 59d43fcc4..e0c7e687a 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -32,7 +32,7 @@ struct ForeignKeyPreviewView: View { Divider() footer } - .frame(width: 400) + .frame(width: 380) .fixedSize(horizontal: false, vertical: true) .task { await fetchReferencedRow() } } @@ -40,19 +40,13 @@ struct ForeignKeyPreviewView: View { // MARK: - Header private var header: some View { - VStack(alignment: .leading, spacing: 2) { + HStack { Text("\(fkInfo.column) → \(fkInfo.referencedTable).\(fkInfo.referencedColumn)") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) - if let cellValue { - Text(cellValue) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.primary) - .lineLimit(1) - } + Spacer() } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) + .padding(.horizontal, 10) .padding(.vertical, 8) } @@ -74,8 +68,7 @@ struct ForeignKeyPreviewView: View { Text(errorMessage) .foregroundStyle(.red) .font(.system(size: 12)) - .frame(maxWidth: .infinity, alignment: .center) - .padding(12) + .padding(10) } else if values.isEmpty { Text("Referenced row not found") .foregroundStyle(.secondary) @@ -83,39 +76,37 @@ struct ForeignKeyPreviewView: View { .frame(maxWidth: .infinity, alignment: .center) .frame(height: 60) } else { - rowList - } - } - - private var rowList: some View { - ScrollView { - VStack(spacing: 0) { - ForEach(Array(zip(columns, values).enumerated()), id: \.offset) { index, pair in - let (col, value) = pair - HStack(alignment: .firstTextBaseline, spacing: 0) { - Text(col) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .trailing) - .lineLimit(1) - .layoutPriority(-1) - - Text(valueText(value)) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(value != nil ? .primary : .tertiary) - .italic(value == nil) - .lineLimit(3) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .layoutPriority(1) + 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) } - .padding(.horizontal, 12) - .padding(.vertical, 5) - .background(index.isMultiple(of: 2) ? Color.clear : Color.primary.opacity(0.03)) } } + .frame(maxHeight: 300) } - .frame(maxHeight: 300) } // MARK: - Footer @@ -135,16 +126,10 @@ struct ForeignKeyPreviewView: View { .controlSize(.small) .disabled(cellValue == nil || isLoading || values.isEmpty) } - .padding(.horizontal, 12) + .padding(.horizontal, 10) .padding(.vertical, 8) } - // MARK: - Helpers - - private func valueText(_ value: String?) -> String { - value ?? "NULL" - } - // MARK: - Data Fetching private func fetchReferencedRow() async {