diff --git a/.gitattributes b/.gitattributes index 332934cb..182262e4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -44,4 +44,9 @@ *.yaml text eol=lf *.plist text eol=lf +# Vendored C headers (database driver bridges, tree-sitter grammars) +Plugins/*/C*/include/** linguist-vendored +TablePro/Core/SSH/CLibSSH2/** linguist-vendored +LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/** linguist-vendored + .github/workflows/*.lock.yml linguist-generated=true merge=ours diff --git a/CHANGELOG.md b/CHANGELOG.md index a70122dd..4336fb81 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 +- Drag to reorder columns in the Structure tab (MySQL/MariaDB) - Nested hierarchical groups for connection list (up to 3 levels deep) - Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts - JSON fields in Row Details sidebar now display in a scrollable monospaced text area diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index a02b7fdb..1b2e9fa3 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -605,6 +605,55 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN \(sql)" } + // MARK: - Column Reorder DDL + + func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { + let tableName = quoteIdentifier(table) + let colName = quoteIdentifier(column.name) + + var def = "\(column.dataType)" + if column.unsigned { + def += " UNSIGNED" + } + if column.isNullable { + def += " NULL" + } else { + def += " NOT NULL" + } + if let defaultValue = column.defaultValue { + let upper = defaultValue.uppercased() + if upper == "NULL" || upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()" + || defaultValue.hasPrefix("'") { + def += " DEFAULT \(defaultValue)" + } else if Int64(defaultValue) != nil || Double(defaultValue) != nil { + def += " DEFAULT \(defaultValue)" + } else { + def += " DEFAULT '\(escapeStringLiteral(defaultValue))'" + } + } + if column.autoIncrement { + def += " AUTO_INCREMENT" + } + if let onUpdate = column.onUpdate, !onUpdate.isEmpty { + let upper = onUpdate.uppercased() + if upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()" || upper.hasPrefix("CURRENT_TIMESTAMP(") { + def += " ON UPDATE \(onUpdate)" + } + } + if let comment = column.comment, !comment.isEmpty { + def += " COMMENT '\(escapeStringLiteral(comment))'" + } + + let position: String + if let afterCol = afterColumn { + position = "AFTER \(quoteIdentifier(afterCol))" + } else { + position = "FIRST" + } + + return "ALTER TABLE \(tableName) MODIFY COLUMN \(colName) \(def) \(position)" + } + // MARK: - View Templates func createViewTemplate() -> String? { diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 74200bc7..aaa9f572 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -101,6 +101,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? func generateDropForeignKeySQL(table: String, constraintName: String) -> String? func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? + func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? // Table operations (optional — return nil to use app-level fallback) func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? @@ -230,6 +231,7 @@ public extension PluginDatabaseDriver { func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { nil } func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { nil } func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { nil } + func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { nil } func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { nil } func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { nil } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 77a700fa..2fe3002d 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -334,6 +334,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.generateModifyPrimaryKeySQL(table: table, oldColumns: oldColumns, newColumns: newColumns, constraintName: constraintName) } + func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { + pluginDriver.generateMoveColumnSQL(table: table, column: column, afterColumn: afterColumn) + } + // MARK: - Table Operations func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String] { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index f72db7b4..569f2d66 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -866,6 +866,11 @@ final class PluginManager { .capabilities.supportsSSL ?? true } + func supportsColumnReorder(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .supportsColumnReorder ?? false + } + func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle { guard let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId) else { return .limit diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 71002c94..5bde62bc 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -516,6 +516,7 @@ extension PluginMetadataRegistry { brandColorHex: "#00ED63", queryLanguageName: "MQL", editorLanguage: .javascript, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: true, @@ -586,6 +587,7 @@ extension PluginMetadataRegistry { brandColorHex: "#DC382D", queryLanguageName: "Redis CLI", editorLanguage: .bash, connectionMode: .network, supportsDatabaseSwitching: false, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: false, @@ -636,6 +638,7 @@ extension PluginMetadataRegistry { brandColorHex: "#E34517", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: .defaults, schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "dbo", @@ -671,6 +674,7 @@ extension PluginMetadataRegistry { brandColorHex: "#C3160B", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: true, @@ -726,6 +730,7 @@ extension PluginMetadataRegistry { brandColorHex: "#FFD100", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: true, @@ -766,6 +771,7 @@ extension PluginMetadataRegistry { brandColorHex: "#FFD900", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .fileBased, supportsDatabaseSwitching: false, + supportsColumnReorder: false, capabilities: .defaults, schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -796,6 +802,7 @@ extension PluginMetadataRegistry { brandColorHex: "#26A0D8", queryLanguageName: "CQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: false, @@ -849,6 +856,7 @@ extension PluginMetadataRegistry { brandColorHex: "#6B2EE3", queryLanguageName: "CQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: false, @@ -901,6 +909,7 @@ extension PluginMetadataRegistry { brandColorHex: "#419EDA", queryLanguageName: "etcdctl", editorLanguage: .bash, connectionMode: .network, supportsDatabaseSwitching: false, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: false, @@ -982,6 +991,7 @@ extension PluginMetadataRegistry { brandColorHex: "#F6821F", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .apiOnly, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: false, @@ -1033,6 +1043,7 @@ extension PluginMetadataRegistry { brandColorHex: "#4053D6", queryLanguageName: "PartiQL", editorLanguage: .sql, connectionMode: .apiOnly, supportsDatabaseSwitching: false, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: false, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 7c42bc37..f8bc43ec 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -32,6 +32,7 @@ struct PluginMetadataSnapshot: Sendable { let editorLanguage: EditorLanguage let connectionMode: ConnectionMode let supportsDatabaseSwitching: Bool + let supportsColumnReorder: Bool let capabilities: CapabilityFlags let schema: SchemaInfo @@ -130,6 +131,7 @@ struct PluginMetadataSnapshot: Sendable { brandColorHex: brandColorHex, queryLanguageName: queryLanguageName, editorLanguage: editorLanguage, connectionMode: connectionMode, supportsDatabaseSwitching: supportsDatabaseSwitching, + supportsColumnReorder: supportsColumnReorder, capabilities: capabilities, schema: schema, editor: editor, connection: connection ) } @@ -340,6 +342,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { brandColorHex: "#FF9500", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: true, capabilities: .defaults, schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -369,6 +372,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { brandColorHex: "#00B4D8", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: true, capabilities: .defaults, schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -398,6 +402,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { brandColorHex: "#336791", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: true, supportsImport: true, @@ -440,6 +445,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { brandColorHex: "#205B8E", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: true, supportsImport: true, @@ -482,6 +488,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { brandColorHex: "#003B57", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .fileBased, supportsDatabaseSwitching: false, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: false, supportsImport: true, @@ -614,6 +621,11 @@ final class PluginMetadataRegistry: @unchecked Sendable { let schemes = driverType.urlSchemes let primaryScheme = schemes.first ?? driverType.databaseTypeId.lowercased() + // Preserve supportsColumnReorder from existing built-in snapshot. + // Cannot read from driverType directly — stale plugins without the + // property crash with EXC_BAD_INSTRUCTION (missing witness table entry). + let existingSnapshot = snapshot(forTypeId: driverType.databaseTypeId) + return PluginMetadataSnapshot( displayName: driverType.databaseDisplayName, iconName: driverType.iconName, @@ -635,6 +647,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { editorLanguage: driverType.editorLanguage, connectionMode: driverType.connectionMode, supportsDatabaseSwitching: driverType.supportsDatabaseSwitching, + supportsColumnReorder: existingSnapshot?.supportsColumnReorder ?? false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: driverType.supportsSchemaSwitching, supportsImport: driverType.supportsImport, diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 07834cb3..deefc533 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -18472,6 +18472,9 @@ } } } + }, + "Move Group to..." : { + }, "Move to" : { "localizations" : { @@ -19190,6 +19193,9 @@ } } } + }, + "New Subgroup" : { + }, "New Tab" : { "localizations" : { @@ -20612,6 +20618,9 @@ } } } + }, + "None (Top Level)" : { + }, "Normal" : { "localizations" : { @@ -21844,6 +21853,9 @@ } } } + }, + "Parent Group" : { + }, "Partition" : { "localizations" : { @@ -31756,6 +31768,9 @@ } } } + }, + "Top Level" : { + }, "Total Size" : { "localizations" : { diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 9481e46a..3df55e1e 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -160,4 +160,48 @@ extension TableViewCoordinator { guard let connectionId else { return nil } return DatabaseManager.shared.driver(for: connectionId) } + + // MARK: - Row Drag and Drop + + private static let rowDragType = NSPasteboard.PasteboardType("com.TablePro.rowDrag") + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { + guard onMoveRow != nil else { return nil } + let item = NSPasteboardItem() + item.setString(String(row), forType: Self.rowDragType) + return item + } + + func tableView( + _ tableView: NSTableView, + validateDrop info: any NSDraggingInfo, + proposedRow row: Int, + proposedDropOperation dropOperation: NSTableView.DropOperation + ) -> NSDragOperation { + guard onMoveRow != nil else { return [] } + guard info.draggingSource as? NSTableView === tableView else { return [] } + guard info.draggingPasteboard.availableType(from: [Self.rowDragType]) != nil else { return [] } + guard dropOperation == .above else { + tableView.setDropRow(row, dropOperation: .above) + return .move + } + return .move + } + + func tableView( + _ tableView: NSTableView, + acceptDrop info: any NSDraggingInfo, + row: Int, + dropOperation: NSTableView.DropOperation + ) -> Bool { + guard let onMoveRow else { return false } + guard let item = info.draggingPasteboard.pasteboardItems?.first, + let rowString = item.string(forType: Self.rowDragType), + let fromRow = Int(rowString) else { + return false + } + guard fromRow != row && fromRow != row - 1 else { return false } + onMoveRow(fromRow, row) + return true + } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 7cb32bac..3a87fa46 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -65,6 +65,7 @@ struct DataGridView: NSViewRepresentable { var showRowNumbers: Bool = true var hiddenColumns: Set = [] var onHideColumn: ((String) -> Void)? + var onMoveRow: ((Int, Int) -> Void)? @Binding var selectedRowIndices: Set @Binding var sortState: SortState @@ -164,8 +165,15 @@ struct DataGridView: NSViewRepresentable { headerView.menu = headerMenu } + // Register for row drag-and-drop if onMoveRow is provided + if onMoveRow != nil { + tableView.registerForDraggedTypes([NSPasteboard.PasteboardType("com.TablePro.rowDrag")]) + tableView.draggingDestinationFeedbackStyle = .gap + } + scrollView.documentView = tableView context.coordinator.tableView = tableView + context.coordinator.onMoveRow = onMoveRow if let connectionId { context.coordinator.observeTeardown(connectionId: connectionId) } @@ -190,6 +198,20 @@ struct DataGridView: NSViewRepresentable { } } + // Sync row drag registration when onMoveRow availability changes + let rowDragType = NSPasteboard.PasteboardType("com.TablePro.rowDrag") + let hasDragRegistered = tableView.registeredDraggedTypes.contains(rowDragType) + if onMoveRow != nil && !hasDragRegistered { + tableView.registerForDraggedTypes([rowDragType]) + tableView.draggingDestinationFeedbackStyle = .gap + } else if onMoveRow == nil && hasDragRegistered { + let remaining = tableView.registeredDraggedTypes.filter { $0 != rowDragType } + tableView.unregisterDraggedTypes() + if !remaining.isEmpty { + tableView.registerForDraggedTypes(remaining) + } + } + // Identity-based early-return BEFORE reading settings — avoids // AppSettingsManager access on every SwiftUI re-evaluation. let currentIdentity = DataGridIdentity( @@ -209,6 +231,7 @@ struct DataGridView: NSViewRepresentable { coordinator.onUndoInsert = onUndoInsert coordinator.onFilterColumn = onFilterColumn coordinator.onHideColumn = onHideColumn + coordinator.onMoveRow = onMoveRow coordinator.onRefresh = onRefresh coordinator.onDeleteRows = onDeleteRows coordinator.getVisualState = getVisualState @@ -267,6 +290,7 @@ struct DataGridView: NSViewRepresentable { coordinator.onUndoInsert = onUndoInsert coordinator.onFilterColumn = onFilterColumn coordinator.onHideColumn = onHideColumn + coordinator.onMoveRow = onMoveRow coordinator.getVisualState = getVisualState coordinator.onNavigateFK = onNavigateFK coordinator.dropdownColumns = dropdownColumns @@ -677,6 +701,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var onUndoInsert: ((Int) -> Void)? var onFilterColumn: ((String) -> Void)? var onHideColumn: ((String) -> Void)? + var onMoveRow: ((Int, Int) -> Void)? var onNavigateFK: ((String, ForeignKeyInfo) -> Void)? var getVisualState: ((Int) -> RowVisualState)? var dropdownColumns: Set? diff --git a/TablePro/Views/Structure/StructureColumnReorderHandler.swift b/TablePro/Views/Structure/StructureColumnReorderHandler.swift new file mode 100644 index 00000000..df274db1 --- /dev/null +++ b/TablePro/Views/Structure/StructureColumnReorderHandler.swift @@ -0,0 +1,130 @@ +// +// StructureColumnReorderHandler.swift +// TablePro +// +// Orchestrates column reorder via ALTER TABLE ... MODIFY COLUMN ... AFTER +// when the user drags a row in the Structure tab's column list. +// + +import Foundation +import os +import TableProPluginKit + +@MainActor +enum StructureColumnReorderHandler { + private static let logger = Logger(subsystem: "com.TablePro", category: "StructureColumnReorderHandler") + + enum ReorderError: LocalizedError { + case noDriver + case notSupported + case invalidIndices + case sqlGenerationFailed + case executionFailed(String) + + var errorDescription: String? { + switch self { + case .noDriver: + return String(localized: "No active database connection") + case .notSupported: + return String(localized: "Column reorder is not supported for this database type") + case .invalidIndices: + return String(localized: "Invalid column indices for reorder operation") + case .sqlGenerationFailed: + return String(localized: "Failed to generate SQL for column reorder") + case .executionFailed(let message): + return String(localized: "Column reorder failed: \(message)") + } + } + } + + /// Move a column from one position to another in the table's column order. + /// + /// - Parameters: + /// - fromIndex: The source row index in the NSTableView (0-based). + /// - toIndex: The drop target row index from NSTableView's `acceptDrop`. + /// This is the row ABOVE which the item will be inserted. + /// - workingColumns: The current column definitions in display order. + /// - tableName: The table being modified. + /// - connectionId: The connection to execute the SQL on. + static func moveColumn( + fromIndex: Int, + toIndex: Int, + workingColumns: [EditableColumnDefinition], + tableName: String, + connectionId: UUID + ) async throws -> String { + guard fromIndex >= 0, fromIndex < workingColumns.count, + toIndex >= 0, toIndex <= workingColumns.count else { + throw ReorderError.invalidIndices + } + + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw ReorderError.noDriver + } + + guard let adapter = driver as? PluginDriverAdapter else { + throw ReorderError.notSupported + } + + let movingColumn = workingColumns[fromIndex] + let pluginColumn = buildPluginColumn(from: movingColumn) + + // Compute the "after" column name. + // NSTableView acceptDrop toIndex is the row ABOVE which the drop occurs. + // toIndex == 0 means FIRST position (afterColumn = nil). + // Otherwise, build a virtual list with the source removed, then pick + // the column at (insertionIndex - 1) as the "after" target. + let afterColumn: String? + if toIndex == 0 { + afterColumn = nil + } else { + var columnNames = workingColumns.map(\.name) + columnNames.remove(at: fromIndex) + + // Adjust insertion point: if source was above the drop target, the + // indices shift down by one after removal. + let adjustedIndex = fromIndex < toIndex ? toIndex - 1 : toIndex + + // The column just before the insertion point is the "after" target + let afterIndex = adjustedIndex - 1 + if afterIndex >= 0, afterIndex < columnNames.count { + afterColumn = columnNames[afterIndex] + } else { + afterColumn = nil + } + } + + guard let sql = adapter.generateMoveColumnSQL( + table: tableName, + column: pluginColumn, + afterColumn: afterColumn + ) else { + throw ReorderError.sqlGenerationFailed + } + + logger.info("Reordering column '\(movingColumn.name)' — \(sql)") + + do { + _ = try await driver.execute(query: sql) + } catch { + logger.error("Column reorder failed: \(error.localizedDescription, privacy: .public)") + throw ReorderError.executionFailed(error.localizedDescription) + } + + return sql + } + + private static func buildPluginColumn(from 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 + ) + } +} diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index a23a2618..51916b00 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -161,6 +161,49 @@ struct TableStructureView: View { let provider = StructureRowProvider(changeManager: structureChangeManager, tab: selectedTab, databaseType: connection.type) let canEdit = connection.type.supportsSchemaEditing + let moveRowHandler: ((Int, Int) -> Void)? = { + guard selectedTab == .columns, + canEdit, + !structureChangeManager.hasChanges, + PluginManager.shared.supportsColumnReorder(for: connection.type) else { + return nil + } + return { fromIndex, toIndex in + let columnsSnapshot = structureChangeManager.workingColumns + Task { @MainActor in + do { + let executedSQL = try await StructureColumnReorderHandler.moveColumn( + fromIndex: fromIndex, + toIndex: toIndex, + workingColumns: columnsSnapshot, + tableName: tableName, + connectionId: connection.id + ) + QueryHistoryManager.shared.recordQuery( + query: executedSQL.hasSuffix(";") ? executedSQL : executedSQL + ";", + connectionId: connection.id, + databaseName: connection.database, + executionTime: 0, + rowCount: 0, + wasSuccessful: true + ) + isReloadingAfterSave = true + await loadColumns() + loadSchemaForEditing() + isReloadingAfterSave = false + ColumnLayoutStorage.shared.clear(for: tableName, connectionId: connection.id) + NotificationCenter.default.post(name: .refreshData, object: nil) + } catch { + AlertHelper.showErrorSheet( + title: String(localized: "Column Reorder Failed"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } + } + }() + return DataGridView( rowProvider: provider.asInMemoryProvider(), changeManager: wrappedChangeManager, @@ -183,6 +226,7 @@ struct TableStructureView: View { typePickerColumns: provider.typePickerColumns, connectionId: connection.id, databaseType: getDatabaseType(), + onMoveRow: moveRowHandler, selectedRowIndices: $selectedRows, sortState: $sortState, editingCell: $editingCell, diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 57964e56..cc237f6d 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -199,6 +199,16 @@ Before applying, TablePro shows the generated ALTER TABLE SQL for review. /> +### Reordering Columns + +Drag a column row up or down in the Columns tab to change its position. The reorder executes immediately as an `ALTER TABLE ... MODIFY COLUMN ... AFTER` statement. + + +Column reordering is only available for MySQL and MariaDB. Other databases do not support changing column order without recreating the table. + + +Drag is disabled when you have unsaved structure changes. Apply or discard pending changes first. + ### Adding Columns via SQL ```sql