diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c159841..cca74e938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Structure tab context menu with Copy Name, Copy Definition (SQL), Duplicate, and Delete for columns, indexes, and foreign keys - Foreign key preview: press Cmd+Enter on a FK cell to see the referenced row in a popover ## [0.27.2] - 2026-04-02 diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index bcffd5f61..e618604d0 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -740,6 +740,20 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return def } + // MARK: - Definition SQL (clipboard copy) + + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { + buildColumnDefinitionSQL(column) + } + + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { + buildIndexDefinitionSQL(index) + } + + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { + buildForeignKeyDefinitionSQL(fk) + } + // MARK: - Column Reorder DDL func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 972e984fa..6d0a893e1 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -868,6 +868,21 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return def } + // MARK: - Definition SQL (clipboard copy) + + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { + pgColumnDefinition(column, inlinePK: false) + } + + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { + let qualifiedTable = tableName.map { quoteIdentifier($0) } ?? "\"table\"" + return pgIndexDefinition(index, qualifiedTable: qualifiedTable) + } + + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { + pgForeignKeyDefinition(fk) + } + // MARK: - Helpers private func stripLimitOffset(from query: String) -> String { diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 39aeb3fed..ff907be02 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -101,6 +101,11 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? + // Definition SQL for clipboard copy (optional — return nil if not supported) + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? + // Table operations (optional — return nil to use app-level fallback) func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? @@ -230,6 +235,10 @@ public extension PluginDatabaseDriver { func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { nil } func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { nil } + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { nil } + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { nil } + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { nil } + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { nil } func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { nil } func foreignKeyDisableStatements() -> [String]? { nil } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 8e7ff51af..2c8dbbff1 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -153,6 +153,11 @@ protocol DatabaseDriver: AnyObject { func foreignKeyDisableStatements() -> [String]? func foreignKeyEnableStatements() -> [String]? + + // Definition SQL for clipboard copy + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? } // MARK: - Schema Switching @@ -193,6 +198,10 @@ extension DatabaseDriver { func foreignKeyDisableStatements() -> [String]? { nil } func foreignKeyEnableStatements() -> [String]? { nil } + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { nil } + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { nil } + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { nil } + func testConnection() async throws -> Bool { try await connect() disconnect() diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 09df73218..86ac48d2f 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -349,6 +349,20 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.generateCreateTableSQL(definition: definition) } + // MARK: - Definition SQL (clipboard copy) + + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { + pluginDriver.generateColumnDefinitionSQL(column: column) + } + + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { + pluginDriver.generateIndexDefinitionSQL(index: index, tableName: tableName) + } + + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { + pluginDriver.generateForeignKeyDefinitionSQL(fk: fk) + } + // MARK: - Table Operations func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String] { diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 3ceae1cc4..991e0ba91 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -133,7 +133,7 @@ struct SchemaStatementGenerator { // MARK: - Column Operations private func generateAddColumn(_ column: EditableColumnDefinition) -> SchemaStatement? { - guard let sql = pluginDriver.generateAddColumnSQL(table: tableName, column: toPluginColumnDefinition(column)) else { + guard let sql = pluginDriver.generateAddColumnSQL(table: tableName, column: column.toPlugin()) else { return nil } return SchemaStatement(sql: sql, description: "Add column '\(column.name)'", isDestructive: false) @@ -142,8 +142,8 @@ struct SchemaStatementGenerator { private func generateModifyColumn(old: EditableColumnDefinition, new: EditableColumnDefinition) -> SchemaStatement? { guard let sql = pluginDriver.generateModifyColumnSQL( table: tableName, - oldColumn: toPluginColumnDefinition(old), - newColumn: toPluginColumnDefinition(new) + oldColumn: old.toPlugin(), + newColumn: new.toPlugin() ) else { return nil } @@ -164,7 +164,7 @@ struct SchemaStatementGenerator { // MARK: - Index Operations private func generateAddIndex(_ index: EditableIndexDefinition) -> SchemaStatement? { - guard let sql = pluginDriver.generateAddIndexSQL(table: tableName, index: toPluginIndexDefinition(index)) else { + guard let sql = pluginDriver.generateAddIndexSQL(table: tableName, index: index.toPlugin()) else { return nil } return SchemaStatement(sql: sql, description: "Add index '\(index.name)'", isDestructive: false) @@ -172,7 +172,7 @@ struct SchemaStatementGenerator { private func generateModifyIndex(old: EditableIndexDefinition, new: EditableIndexDefinition) -> SchemaStatement? { guard let dropSql = pluginDriver.generateDropIndexSQL(table: tableName, indexName: old.name), - let addSql = pluginDriver.generateAddIndexSQL(table: tableName, index: toPluginIndexDefinition(new)) else { + let addSql = pluginDriver.generateAddIndexSQL(table: tableName, index: new.toPlugin()) else { return nil } let sql = "\(dropSql);\n\(addSql);" @@ -195,7 +195,7 @@ struct SchemaStatementGenerator { private func generateAddForeignKey(_ fk: EditableForeignKeyDefinition) -> SchemaStatement? { guard let sql = pluginDriver.generateAddForeignKeySQL( table: tableName, - fk: toPluginForeignKeyDefinition(fk) + fk: fk.toPlugin() ) else { return nil } @@ -204,7 +204,7 @@ struct SchemaStatementGenerator { private func generateModifyForeignKey(old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) -> SchemaStatement? { guard let dropSql = pluginDriver.generateDropForeignKeySQL(table: tableName, constraintName: old.name), - let addSql = pluginDriver.generateAddForeignKeySQL(table: tableName, fk: toPluginForeignKeyDefinition(new)) else { + let addSql = pluginDriver.generateAddForeignKeySQL(table: tableName, fk: new.toPlugin()) else { return nil } let sql = "\(dropSql);\n\(addSql);" @@ -238,39 +238,4 @@ struct SchemaStatementGenerator { ) } - // MARK: - Plugin Type Converters - - private func toPluginColumnDefinition(_ col: EditableColumnDefinition) -> PluginColumnDefinition { - PluginColumnDefinition( - name: col.name, - dataType: col.dataType, - isNullable: col.isNullable, - defaultValue: col.defaultValue, - isPrimaryKey: col.isPrimaryKey, - autoIncrement: col.autoIncrement, - comment: col.comment, - unsigned: col.unsigned, - onUpdate: col.onUpdate - ) - } - - private func toPluginIndexDefinition(_ index: EditableIndexDefinition) -> PluginIndexDefinition { - PluginIndexDefinition( - name: index.name, - columns: index.columns, - isUnique: index.isUnique, - indexType: index.type.rawValue - ) - } - - private func toPluginForeignKeyDefinition(_ fk: EditableForeignKeyDefinition) -> PluginForeignKeyDefinition { - PluginForeignKeyDefinition( - name: fk.name, - columns: fk.columns, - referencedTable: fk.referencedTable, - referencedColumns: fk.referencedColumns, - onDelete: fk.onDelete.rawValue, - onUpdate: fk.onUpdate.rawValue - ) - } } diff --git a/TablePro/Models/Schema/ColumnDefinition.swift b/TablePro/Models/Schema/ColumnDefinition.swift index 7ee0cbc88..a3a3b66f2 100644 --- a/TablePro/Models/Schema/ColumnDefinition.swift +++ b/TablePro/Models/Schema/ColumnDefinition.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit /// Column definition for schema modification (editable structure tab) struct EditableColumnDefinition: Hashable, Codable, Identifiable { @@ -69,6 +70,14 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { ) } + func toPlugin() -> PluginColumnDefinition { + PluginColumnDefinition( + name: name, dataType: dataType, isNullable: isNullable, defaultValue: defaultValue, + isPrimaryKey: isPrimaryKey, autoIncrement: autoIncrement, comment: comment, + unsigned: unsigned, onUpdate: onUpdate + ) + } + /// Convert back to ColumnInfo func toColumnInfo() -> ColumnInfo { ColumnInfo( diff --git a/TablePro/Models/Schema/ForeignKeyDefinition.swift b/TablePro/Models/Schema/ForeignKeyDefinition.swift index 5aa9d3a49..844d821b3 100644 --- a/TablePro/Models/Schema/ForeignKeyDefinition.swift +++ b/TablePro/Models/Schema/ForeignKeyDefinition.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit /// Foreign key definition for schema modification (editable structure tab) struct EditableForeignKeyDefinition: Hashable, Codable, Identifiable { @@ -59,6 +60,13 @@ struct EditableForeignKeyDefinition: Hashable, Codable, Identifiable { ) } + func toPlugin() -> PluginForeignKeyDefinition { + PluginForeignKeyDefinition( + name: name, columns: columns, referencedTable: referencedTable, + referencedColumns: referencedColumns, onDelete: onDelete.rawValue, onUpdate: onUpdate.rawValue + ) + } + /// Convert back to ForeignKeyInfo (single column only) func toForeignKeyInfo() -> ForeignKeyInfo? { guard let column = columns.first, diff --git a/TablePro/Models/Schema/IndexDefinition.swift b/TablePro/Models/Schema/IndexDefinition.swift index 6d1cf7ab2..a11db551d 100644 --- a/TablePro/Models/Schema/IndexDefinition.swift +++ b/TablePro/Models/Schema/IndexDefinition.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit /// Index definition for schema modification (editable structure tab) struct EditableIndexDefinition: Hashable, Codable, Identifiable { @@ -59,6 +60,10 @@ struct EditableIndexDefinition: Hashable, Codable, Identifiable { ) } + func toPlugin() -> PluginIndexDefinition { + PluginIndexDefinition(name: name, columns: columns, isUnique: isUnique, indexType: type.rawValue) + } + /// Convert back to IndexInfo func toIndexInfo() -> IndexInfo { IndexInfo( diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e24a43872..4b2f46fbd 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7809,6 +7809,9 @@ } } } + }, + "Copy Definition" : { + }, "Copy error message" : { diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 920610f77..cd647c99f 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -31,6 +31,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var onFilterColumn: ((String) -> Void)? var onHideColumn: ((String) -> Void)? var onMoveRow: ((Int, Int) -> Void)? + var rowViewProvider: ((NSTableView, Int, TableViewCoordinator) -> NSTableRowView)? + var emptySpaceMenu: (() -> NSMenu?)? var onNavigateFK: ((String, ForeignKeyInfo) -> Void)? var getVisualState: ((Int) -> RowVisualState)? var dropdownColumns: Set? @@ -243,6 +245,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData onFilterColumn = nil onHideColumn = nil onNavigateFK = nil + rowViewProvider = nil + emptySpaceMenu = nil getVisualState = nil } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 0888fbada..2aee26be8 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -66,6 +66,8 @@ struct DataGridView: NSViewRepresentable { var hiddenColumns: Set = [] var onHideColumn: ((String) -> Void)? var onMoveRow: ((Int, Int) -> Void)? + var rowViewProvider: ((NSTableView, Int, TableViewCoordinator) -> NSTableRowView)? + var emptySpaceMenu: (() -> NSMenu?)? @Binding var selectedRowIndices: Set @Binding var sortState: SortState @@ -177,6 +179,8 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView context.coordinator.onMoveRow = onMoveRow + context.coordinator.rowViewProvider = rowViewProvider + context.coordinator.emptySpaceMenu = emptySpaceMenu context.coordinator.rebuildColumnMetadataCache() if let connectionId { context.coordinator.observeTeardown(connectionId: connectionId) @@ -240,6 +244,8 @@ struct DataGridView: NSViewRepresentable { coordinator.onDeleteRows = onDeleteRows coordinator.getVisualState = getVisualState coordinator.onNavigateFK = onNavigateFK + coordinator.rowViewProvider = rowViewProvider + coordinator.emptySpaceMenu = emptySpaceMenu return } let previousIdentity = coordinator.lastIdentity @@ -307,6 +313,8 @@ struct DataGridView: NSViewRepresentable { coordinator.onMoveRow = onMoveRow coordinator.getVisualState = getVisualState coordinator.onNavigateFK = onNavigateFK + coordinator.rowViewProvider = rowViewProvider + coordinator.emptySpaceMenu = emptySpaceMenu coordinator.dropdownColumns = dropdownColumns coordinator.typePickerColumns = typePickerColumns coordinator.connectionId = connectionId diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 7a6b6851c..315e81587 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -65,6 +65,9 @@ extension TableViewCoordinator { } func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + if let provider = rowViewProvider { + return provider(tableView, row, self) + } let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? TableRowViewWithMenu) ?? TableRowViewWithMenu() rowView.identifier = Self.rowViewIdentifier diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 31dec487c..6d71a94d5 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -420,13 +420,18 @@ final class KeyHandlingTableView: NSTableView { let clickedRow = row(at: point) if clickedRow >= 0, - let rowView = rowView(atRow: clickedRow, makeIfNecessary: false) as? TableRowViewWithMenu { + let rowView = rowView(atRow: clickedRow, makeIfNecessary: false) { if !selectedRowIndexes.contains(clickedRow) { selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) } return rowView.menu(for: event) } + // Empty space: ask coordinator for a fallback menu (e.g., Structure tab "Add" actions) + if let menu = coordinator?.emptySpaceMenu?() { + return menu + } + return super.menu(for: event) } } diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index d9c7f2f22..4336286f1 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -439,13 +439,13 @@ struct CreateTableView: View { let definition = PluginCreateTableDefinition( tableName: tableName.isEmpty ? "untitled" : tableName, - columns: columns.map { toPluginColumnDefinition($0) }, + columns: columns.map { $0.toPlugin() }, indexes: structureChangeManager.workingIndexes .filter { !$0.name.isEmpty && !$0.columns.isEmpty } - .map { toPluginIndexDefinition($0) }, + .map { $0.toPlugin() }, foreignKeys: structureChangeManager.workingForeignKeys .filter { !$0.name.isEmpty && !$0.columns.isEmpty && !$0.referencedTable.isEmpty } - .map { toPluginForeignKeyDefinition($0) }, + .map { $0.toPlugin() }, primaryKeyColumns: pkColumns, engine: showMySQLOptions ? tableOptions.engine : nil, charset: showMySQLOptions ? tableOptions.charset : nil, @@ -457,40 +457,6 @@ struct CreateTableView: View { return pluginDriver?.generateCreateTableSQL(definition: definition) } - private func toPluginColumnDefinition(_ col: EditableColumnDefinition) -> PluginColumnDefinition { - PluginColumnDefinition( - name: col.name, - dataType: col.dataType, - isNullable: col.isNullable, - defaultValue: col.defaultValue, - isPrimaryKey: col.isPrimaryKey, - autoIncrement: col.autoIncrement, - comment: col.comment, - unsigned: col.unsigned, - onUpdate: col.onUpdate - ) - } - - private func toPluginIndexDefinition(_ index: EditableIndexDefinition) -> PluginIndexDefinition { - PluginIndexDefinition( - name: index.name, - columns: index.columns, - isUnique: index.isUnique, - indexType: index.type.rawValue - ) - } - - private func toPluginForeignKeyDefinition(_ fk: EditableForeignKeyDefinition) -> PluginForeignKeyDefinition { - PluginForeignKeyDefinition( - name: fk.name, - columns: fk.columns, - referencedTable: fk.referencedTable, - referencedColumns: fk.referencedColumns, - onDelete: fk.onDelete.rawValue, - onUpdate: fk.onUpdate.rawValue - ) - } - // MARK: - Create Table private func createTable() { diff --git a/TablePro/Views/Structure/StructureRowViewWithMenu.swift b/TablePro/Views/Structure/StructureRowViewWithMenu.swift new file mode 100644 index 000000000..51a91c040 --- /dev/null +++ b/TablePro/Views/Structure/StructureRowViewWithMenu.swift @@ -0,0 +1,126 @@ +// +// StructureRowViewWithMenu.swift +// TablePro +// +// Custom row view with structure-specific context menu. +// Provides Copy Name, Copy Definition, Duplicate, Delete for structure items. +// + +import AppKit + +/// Row view providing a context menu tailored to the Structure tab +final class StructureRowViewWithMenu: NSTableRowView { + weak var coordinator: TableViewCoordinator? + var rowIndex: Int = 0 + var structureTab: StructureTab = .columns + var isStructureEditable: Bool = true + var isRowDeleted: Bool = false + var referencedTableName: String? + + var onCopyName: ((Set) -> Void)? + var onCopyDefinition: ((Set) -> Void)? + var onNavigateFK: ((Int) -> Void)? + var onDuplicate: ((Set) -> Void)? + var onDelete: ((Set) -> Void)? + var onUndoDelete: ((Int) -> Void)? + + override func menu(for event: NSEvent) -> NSMenu? { + guard structureTab != .ddl, structureTab != .parts else { return nil } + + let menu = NSMenu() + + if isRowDeleted { + let undoItem = NSMenuItem( + title: String(localized: "Undo Delete"), + action: #selector(handleUndoDelete), + keyEquivalent: "" + ) + undoItem.target = self + menu.addItem(undoItem) + return menu + } + + let copyNameItem = NSMenuItem( + title: String(localized: "Copy Name"), + action: #selector(handleCopyName), + keyEquivalent: "c" + ) + copyNameItem.keyEquivalentModifierMask = .command + copyNameItem.target = self + menu.addItem(copyNameItem) + + let copyDefItem = NSMenuItem( + title: String(localized: "Copy Definition"), + action: #selector(handleCopyDefinition), + keyEquivalent: "" + ) + copyDefItem.target = self + menu.addItem(copyDefItem) + + if structureTab == .foreignKeys, + let tableName = referencedTableName, !tableName.isEmpty { + menu.addItem(NSMenuItem.separator()) + let navItem = NSMenuItem( + title: String(format: String(localized: "Open %@"), tableName), + action: #selector(handleNavigateFK), + keyEquivalent: "" + ) + navItem.target = self + menu.addItem(navItem) + } + + if isStructureEditable { + menu.addItem(NSMenuItem.separator()) + + let dupItem = NSMenuItem( + title: String(localized: "Duplicate"), + action: #selector(handleDuplicate), + keyEquivalent: "d" + ) + dupItem.keyEquivalentModifierMask = .command + dupItem.target = self + menu.addItem(dupItem) + + let delItem = NSMenuItem( + title: String(localized: "Delete"), + action: #selector(handleDelete), + keyEquivalent: String( + UnicodeScalar(NSBackspaceCharacter).map { Character($0) } ?? "\u{8}" + ) + ) + delItem.keyEquivalentModifierMask = [] + delItem.target = self + menu.addItem(delItem) + } + + return menu + } + + private func effectiveIndices() -> Set { + if let selected = coordinator?.selectedRowIndices, !selected.isEmpty { + return selected + } + return [rowIndex] + } + + @objc private func handleCopyName() { onCopyName?(effectiveIndices()) } + @objc private func handleCopyDefinition() { onCopyDefinition?(effectiveIndices()) } + @objc private func handleNavigateFK() { onNavigateFK?(rowIndex) } + @objc private func handleDuplicate() { onDuplicate?(effectiveIndices()) } + @objc private func handleDelete() { onDelete?(effectiveIndices()) } + @objc private func handleUndoDelete() { onUndoDelete?(rowIndex) } +} + +/// Menu action target for empty-space context menu. +/// Stored as `representedObject` on the menu item to keep it alive while the menu is shown. +final class StructureMenuTarget: NSObject { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func addNewItem() { + action() + } +} diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 51916b002..31f9dc1dd 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -227,6 +227,8 @@ struct TableStructureView: View { connectionId: connection.id, databaseType: getDatabaseType(), onMoveRow: moveRowHandler, + rowViewProvider: makeStructureRowView, + emptySpaceMenu: makeEmptySpaceMenu, selectedRowIndices: $selectedRows, sortState: $sortState, editingCell: $editingCell, @@ -561,6 +563,142 @@ struct TableStructureView: View { } } + // MARK: - Structure Context Menu + + private func makeEmptySpaceMenu() -> NSMenu? { + guard selectedTab != .ddl, selectedTab != .parts else { return nil } + guard connection.type.supportsSchemaEditing else { return nil } + + let menu = NSMenu() + let label: String + switch selectedTab { + case .columns: label = String(localized: "Add Column") + case .indexes: label = String(localized: "Add Index") + case .foreignKeys: label = String(localized: "Add Foreign Key") + case .ddl, .parts: return nil + } + + let target = StructureMenuTarget { [self] in addNewRow() } + let item = NSMenuItem(title: label, action: #selector(StructureMenuTarget.addNewItem), keyEquivalent: "") + item.target = target + item.representedObject = target + menu.addItem(item) + return menu + } + + private static let structureRowViewId = NSUserInterfaceItemIdentifier("StructureRowView") + + private func makeStructureRowView( + _ tableView: NSTableView, _ row: Int, _ coordinator: TableViewCoordinator + ) -> NSTableRowView { + let rowView = (tableView.makeView(withIdentifier: Self.structureRowViewId, owner: nil) + as? StructureRowViewWithMenu) ?? StructureRowViewWithMenu() + rowView.identifier = Self.structureRowViewId + rowView.coordinator = coordinator + rowView.rowIndex = row + rowView.structureTab = selectedTab + rowView.isStructureEditable = connection.type.supportsSchemaEditing + rowView.isRowDeleted = structureChangeManager.getVisualState(for: row, tab: selectedTab).isDeleted + + if selectedTab == .foreignKeys, row < structureChangeManager.workingForeignKeys.count { + rowView.referencedTableName = structureChangeManager.workingForeignKeys[row].referencedTable + } + + rowView.onCopyName = { [self] indices in handleCopyName(indices) } + rowView.onCopyDefinition = { [self] indices in handleCopyDefinition(indices) } + rowView.onNavigateFK = { [self] idx in handleNavigateToFK(idx) } + rowView.onDuplicate = { [self] indices in handleDuplicateItems(indices) } + rowView.onDelete = { [self] indices in handleDeleteRows(indices) } + rowView.onUndoDelete = { [self] _ in handleUndo() } + return rowView + } + + private func handleCopyName(_ indices: Set) { + let provider = StructureRowProvider( + changeManager: structureChangeManager, tab: selectedTab, databaseType: connection.type + ) + let names = indices.sorted().compactMap { provider.row(at: $0)?.first ?? nil } + guard !names.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(names.joined(separator: "\n"), forType: .string) + } + + private func handleCopyDefinition(_ indices: Set) { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } + var definitions: [String] = [] + + for row in indices.sorted() { + switch selectedTab { + case .columns: + guard row < structureChangeManager.workingColumns.count else { continue } + let col = structureChangeManager.workingColumns[row] + if let sql = driver.generateColumnDefinitionSQL(column: col.toPlugin()) { + definitions.append(sql) + } + case .indexes: + guard row < structureChangeManager.workingIndexes.count else { continue } + let idx = structureChangeManager.workingIndexes[row] + if let sql = driver.generateIndexDefinitionSQL(index: idx.toPlugin(), tableName: tableName) { + definitions.append(sql) + } + case .foreignKeys: + guard row < structureChangeManager.workingForeignKeys.count else { continue } + let fk = structureChangeManager.workingForeignKeys[row] + if let sql = driver.generateForeignKeyDefinitionSQL(fk: fk.toPlugin()) { + definitions.append(sql) + } + case .ddl, .parts: + break + } + } + + guard !definitions.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(definitions.joined(separator: "\n"), forType: .string) + } + + private func handleDuplicateItems(_ indices: Set) { + for row in indices.sorted() { + switch selectedTab { + case .columns: + guard row < structureChangeManager.workingColumns.count else { continue } + var copy = structureChangeManager.workingColumns[row] + copy = EditableColumnDefinition( + id: UUID(), name: copy.name, dataType: copy.dataType, isNullable: copy.isNullable, + defaultValue: copy.defaultValue, autoIncrement: copy.autoIncrement, unsigned: copy.unsigned, + comment: copy.comment, collation: copy.collation, onUpdate: copy.onUpdate, + charset: copy.charset, extra: copy.extra, isPrimaryKey: copy.isPrimaryKey + ) + structureChangeManager.addColumn(copy) + case .indexes: + guard row < structureChangeManager.workingIndexes.count else { continue } + var copy = structureChangeManager.workingIndexes[row] + copy = EditableIndexDefinition( + id: UUID(), name: copy.name, columns: copy.columns, + type: copy.type, isUnique: copy.isUnique, isPrimary: false, comment: copy.comment + ) + structureChangeManager.addIndex(copy) + case .foreignKeys: + guard row < structureChangeManager.workingForeignKeys.count else { continue } + var copy = structureChangeManager.workingForeignKeys[row] + copy = EditableForeignKeyDefinition( + id: UUID(), name: copy.name, columns: copy.columns, + referencedTable: copy.referencedTable, referencedColumns: copy.referencedColumns, + onDelete: copy.onDelete, onUpdate: copy.onUpdate + ) + structureChangeManager.addForeignKey(copy) + case .ddl, .parts: + break + } + } + } + + private func handleNavigateToFK(_ row: Int) { + guard row < structureChangeManager.workingForeignKeys.count else { return } + let fk = structureChangeManager.workingForeignKeys[row] + coordinator?.openTableTab(fk.referencedTable, showStructure: false, isView: false) + } + // MARK: - Schema Operations private func generateStructurePreviewSQL() { diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 4d796ad3c..d29493ede 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -181,6 +181,20 @@ Select the column, click **-** or press Delete, confirm, then apply. /> +### Context Menu + +Right-click any row in the Columns, Indexes, or Foreign Keys tabs: + +| Action | Description | +|--------|-------------| +| **Copy Name** | Copy the item name to clipboard | +| **Copy Definition** | Copy the SQL definition (e.g., column definition, index creation clause) | +| **Open [table]** | (Foreign Keys tab only) Navigate to the referenced table | +| **Duplicate** | Copy and paste the selected items | +| **Delete** | Mark items for deletion (apply to execute) | + +Multi-select rows first to copy or delete multiple items at once. + ### Schema Change Preview Before applying, TablePro shows the generated ALTER TABLE SQL for review.