From 18fe2f86f37cda067b1d844dce5503569081de1b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 19:40:57 +0700 Subject: [PATCH 1/7] chore: mark vendored headers and add loc keys --- .gitattributes | 5 +++++ TablePro/Resources/Localizable.xcstrings | 15 +++++++++++++++ 2 files changed, 20 insertions(+) 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/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" : { From 3f99a787201fa0d60d77a673dcc282b55ae78ec1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 20:17:47 +0700 Subject: [PATCH 2/7] feat: add drag to reorder columns in Structure tab (MySQL/MariaDB) --- CHANGELOG.md | 1 + Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 1 + .../MySQLDriverPlugin/MySQLPluginDriver.swift | 46 ++++++ Plugins/TableProPluginKit/DriverPlugin.swift | 2 + .../PluginDatabaseDriver.swift | 2 + .../Core/Plugins/PluginDriverAdapter.swift | 4 + TablePro/Core/Plugins/PluginManager.swift | 5 + ...ginMetadataRegistry+RegistryDefaults.swift | 11 ++ .../Core/Plugins/PluginMetadataRegistry.swift | 8 + .../Results/DataGridView+RowActions.swift | 42 ++++++ TablePro/Views/Results/DataGridView.swift | 21 +++ .../StructureColumnReorderHandler.swift | 138 ++++++++++++++++++ .../Views/Structure/TableStructureView.swift | 33 +++++ docs/features/table-structure.mdx | 10 ++ 14 files changed, 324 insertions(+) create mode 100644 TablePro/Views/Structure/StructureColumnReorderHandler.swift 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/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index c0cbf9cf..0959af80 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -27,6 +27,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { // MARK: - UI/Capability Metadata + static let supportsColumnReorder = true static let urlSchemes: [String] = ["mysql"] static let brandColorHex = "#FF9500" static let systemDatabaseNames: [String] = ["information_schema", "mysql", "performance_schema", "sys"] diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index a02b7fdb..02924ac2 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -605,6 +605,52 @@ 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 { + 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/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 466fd324..8c9c90fa 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -54,6 +54,7 @@ public protocol DriverPlugin: TableProPlugin { static var isDownloadable: Bool { get } static var postConnectActions: [PostConnectAction] { get } static var parameterStyle: ParameterStyle { get } + static var supportsColumnReorder: Bool { get } } public extension DriverPlugin { @@ -114,4 +115,5 @@ public extension DriverPlugin { static var parameterStyle: ParameterStyle { .questionMark } static var isDownloadable: Bool { false } static var postConnectActions: [PostConnectAction] { [] } + static var supportsColumnReorder: Bool { false } } 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..71f75e26 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, @@ -635,6 +642,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { editorLanguage: driverType.editorLanguage, connectionMode: driverType.connectionMode, supportsDatabaseSwitching: driverType.supportsDatabaseSwitching, + supportsColumnReorder: driverType.supportsColumnReorder, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: driverType.supportsSchemaSwitching, supportsImport: driverType.supportsImport, diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 9481e46a..92d6cc56 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -160,4 +160,46 @@ 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 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..00b1ed22 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,16 @@ 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 { + tableView.unregisterDraggedTypes() + } + // Identity-based early-return BEFORE reading settings — avoids // AppSettingsManager access on every SwiftUI re-evaluation. let currentIdentity = DataGridIdentity( @@ -209,6 +227,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 +286,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 +697,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..8c858c96 --- /dev/null +++ b/TablePro/Views/Structure/StructureColumnReorderHandler.swift @@ -0,0 +1,138 @@ +// +// 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 { + guard fromIndex >= 0, fromIndex < 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) + let movedName = columnNames.remove(at: fromIndex) + + // Adjust insertion point: if source was above the drop target, the + // indices shift down by one after removal. + let adjustedIndex: Int + if fromIndex < toIndex { + adjustedIndex = toIndex - 1 + } else { + adjustedIndex = toIndex + } + + // The column just before the insertion point is the "after" target + let afterIndex = adjustedIndex - 1 + if afterIndex >= 0, afterIndex < columnNames.count { + let candidate = columnNames[afterIndex] + // Guard against placing after itself (shouldn't happen but defensive) + if candidate == movedName { + afterColumn = nil + } else { + afterColumn = candidate + } + } 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) + } + } + + 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..803c1090 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -161,6 +161,38 @@ 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 + Task { @MainActor in + do { + try await StructureColumnReorderHandler.moveColumn( + fromIndex: fromIndex, + toIndex: toIndex, + workingColumns: structureChangeManager.workingColumns, + tableName: tableName, + connectionId: connection.id + ) + isReloadingAfterSave = true + await loadColumns() + loadSchemaForEditing() + isReloadingAfterSave = false + } catch { + AlertHelper.showErrorSheet( + title: String(localized: "Column Reorder Failed"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } + } + }() + return DataGridView( rowProvider: provider.asInMemoryProvider(), changeManager: wrappedChangeManager, @@ -183,6 +215,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 From 6231147d3775469ca2aff46f3e790818f17c662a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 20:20:34 +0700 Subject: [PATCH 3/7] fix: avoid ABI crash by not reading supportsColumnReorder from dynamic plugins --- Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 1 - Plugins/TableProPluginKit/DriverPlugin.swift | 2 -- TablePro/Core/Plugins/PluginMetadataRegistry.swift | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 0959af80..c0cbf9cf 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -27,7 +27,6 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { // MARK: - UI/Capability Metadata - static let supportsColumnReorder = true static let urlSchemes: [String] = ["mysql"] static let brandColorHex = "#FF9500" static let systemDatabaseNames: [String] = ["information_schema", "mysql", "performance_schema", "sys"] diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 8c9c90fa..466fd324 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -54,7 +54,6 @@ public protocol DriverPlugin: TableProPlugin { static var isDownloadable: Bool { get } static var postConnectActions: [PostConnectAction] { get } static var parameterStyle: ParameterStyle { get } - static var supportsColumnReorder: Bool { get } } public extension DriverPlugin { @@ -115,5 +114,4 @@ public extension DriverPlugin { static var parameterStyle: ParameterStyle { .questionMark } static var isDownloadable: Bool { false } static var postConnectActions: [PostConnectAction] { [] } - static var supportsColumnReorder: Bool { false } } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 71f75e26..ed329e0a 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -642,7 +642,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { editorLanguage: driverType.editorLanguage, connectionMode: driverType.connectionMode, supportsDatabaseSwitching: driverType.supportsDatabaseSwitching, - supportsColumnReorder: driverType.supportsColumnReorder, + supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: driverType.supportsSchemaSwitching, supportsImport: driverType.supportsImport, From c8a4ebfcee24cc3f4aa91cd474a7fbb0a61016cd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 20:24:11 +0700 Subject: [PATCH 4/7] fix: preserve supportsColumnReorder from built-in snapshot on plugin reload --- TablePro/Core/Plugins/PluginMetadataRegistry.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index ed329e0a..f8bc43ec 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -621,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, @@ -642,7 +647,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { editorLanguage: driverType.editorLanguage, connectionMode: driverType.connectionMode, supportsDatabaseSwitching: driverType.supportsDatabaseSwitching, - supportsColumnReorder: false, + supportsColumnReorder: existingSnapshot?.supportsColumnReorder ?? false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: driverType.supportsSchemaSwitching, supportsImport: driverType.supportsImport, From ccf25dc60e027923be69f0b0b678657d46fc6b09 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 20:29:28 +0700 Subject: [PATCH 5/7] fix: clear column layout cache and refresh data tab after column reorder --- TablePro/Views/Structure/TableStructureView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 803c1090..f87766bd 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -182,6 +182,8 @@ struct TableStructureView: View { 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"), From adefe4430fb64511d0e3105842ab4900d8b466b5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 20:35:32 +0700 Subject: [PATCH 6/7] fix: address code review issues for column reorder --- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 5 ++++- .../Results/DataGridView+RowActions.swift | 2 ++ TablePro/Views/Results/DataGridView.swift | 4 ++++ .../StructureColumnReorderHandler.swift | 20 +++++-------------- .../Views/Structure/TableStructureView.swift | 3 ++- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 02924ac2..1b2e9fa3 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -635,7 +635,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { def += " AUTO_INCREMENT" } if let onUpdate = column.onUpdate, !onUpdate.isEmpty { - def += " ON UPDATE \(onUpdate)" + 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))'" diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 92d6cc56..3df55e1e 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -179,6 +179,8 @@ extension TableViewCoordinator { 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 diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 00b1ed22..3a87fa46 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -205,7 +205,11 @@ struct DataGridView: NSViewRepresentable { 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 diff --git a/TablePro/Views/Structure/StructureColumnReorderHandler.swift b/TablePro/Views/Structure/StructureColumnReorderHandler.swift index 8c858c96..d31f1670 100644 --- a/TablePro/Views/Structure/StructureColumnReorderHandler.swift +++ b/TablePro/Views/Structure/StructureColumnReorderHandler.swift @@ -53,7 +53,8 @@ enum StructureColumnReorderHandler { tableName: String, connectionId: UUID ) async throws { - guard fromIndex >= 0, fromIndex < workingColumns.count else { + guard fromIndex >= 0, fromIndex < workingColumns.count, + toIndex >= 0, toIndex <= workingColumns.count else { throw ReorderError.invalidIndices } @@ -78,27 +79,16 @@ enum StructureColumnReorderHandler { afterColumn = nil } else { var columnNames = workingColumns.map(\.name) - let movedName = columnNames.remove(at: fromIndex) + columnNames.remove(at: fromIndex) // Adjust insertion point: if source was above the drop target, the // indices shift down by one after removal. - let adjustedIndex: Int - if fromIndex < toIndex { - adjustedIndex = toIndex - 1 - } else { - adjustedIndex = toIndex - } + 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 { - let candidate = columnNames[afterIndex] - // Guard against placing after itself (shouldn't happen but defensive) - if candidate == movedName { - afterColumn = nil - } else { - afterColumn = candidate - } + afterColumn = columnNames[afterIndex] } else { afterColumn = nil } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index f87766bd..7ee6dcc4 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -169,12 +169,13 @@ struct TableStructureView: View { return nil } return { fromIndex, toIndex in + let columnsSnapshot = structureChangeManager.workingColumns Task { @MainActor in do { try await StructureColumnReorderHandler.moveColumn( fromIndex: fromIndex, toIndex: toIndex, - workingColumns: structureChangeManager.workingColumns, + workingColumns: columnsSnapshot, tableName: tableName, connectionId: connection.id ) From 66adabfa7e759b19e8fe02e9850d0aea511fccb9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 20:40:10 +0700 Subject: [PATCH 7/7] fix: record column reorder SQL to query history --- .../Structure/StructureColumnReorderHandler.swift | 4 +++- TablePro/Views/Structure/TableStructureView.swift | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Structure/StructureColumnReorderHandler.swift b/TablePro/Views/Structure/StructureColumnReorderHandler.swift index d31f1670..df274db1 100644 --- a/TablePro/Views/Structure/StructureColumnReorderHandler.swift +++ b/TablePro/Views/Structure/StructureColumnReorderHandler.swift @@ -52,7 +52,7 @@ enum StructureColumnReorderHandler { workingColumns: [EditableColumnDefinition], tableName: String, connectionId: UUID - ) async throws { + ) async throws -> String { guard fromIndex >= 0, fromIndex < workingColumns.count, toIndex >= 0, toIndex <= workingColumns.count else { throw ReorderError.invalidIndices @@ -110,6 +110,8 @@ enum StructureColumnReorderHandler { logger.error("Column reorder failed: \(error.localizedDescription, privacy: .public)") throw ReorderError.executionFailed(error.localizedDescription) } + + return sql } private static func buildPluginColumn(from col: EditableColumnDefinition) -> PluginColumnDefinition { diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 7ee6dcc4..51916b00 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -172,13 +172,21 @@ struct TableStructureView: View { let columnsSnapshot = structureChangeManager.workingColumns Task { @MainActor in do { - try await StructureColumnReorderHandler.moveColumn( + 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()