Skip to content

Commit a9850c7

Browse files
authored
feat: drag to reorder columns in Structure tab (#491)
* chore: mark vendored headers and add loc keys * feat: add drag to reorder columns in Structure tab (MySQL/MariaDB) * fix: avoid ABI crash by not reading supportsColumnReorder from dynamic plugins * fix: preserve supportsColumnReorder from built-in snapshot on plugin reload * fix: clear column layout cache and refresh data tab after column reorder * fix: address code review issues for column reorder * fix: record column reorder SQL to query history
1 parent ec5f7b8 commit a9850c7

14 files changed

Lines changed: 358 additions & 0 deletions

.gitattributes

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,9 @@
4444
*.yaml text eol=lf
4545
*.plist text eol=lf
4646

47+
# Vendored C headers (database driver bridges, tree-sitter grammars)
48+
Plugins/*/C*/include/** linguist-vendored
49+
TablePro/Core/SSH/CLibSSH2/** linguist-vendored
50+
LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/** linguist-vendored
51+
4752
.github/workflows/*.lock.yml linguist-generated=true merge=ours

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Drag to reorder columns in the Structure tab (MySQL/MariaDB)
1213
- Nested hierarchical groups for connection list (up to 3 levels deep)
1314
- Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts
1415
- JSON fields in Row Details sidebar now display in a scrollable monospaced text area

Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,55 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
605605
"EXPLAIN \(sql)"
606606
}
607607

608+
// MARK: - Column Reorder DDL
609+
610+
func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? {
611+
let tableName = quoteIdentifier(table)
612+
let colName = quoteIdentifier(column.name)
613+
614+
var def = "\(column.dataType)"
615+
if column.unsigned {
616+
def += " UNSIGNED"
617+
}
618+
if column.isNullable {
619+
def += " NULL"
620+
} else {
621+
def += " NOT NULL"
622+
}
623+
if let defaultValue = column.defaultValue {
624+
let upper = defaultValue.uppercased()
625+
if upper == "NULL" || upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()"
626+
|| defaultValue.hasPrefix("'") {
627+
def += " DEFAULT \(defaultValue)"
628+
} else if Int64(defaultValue) != nil || Double(defaultValue) != nil {
629+
def += " DEFAULT \(defaultValue)"
630+
} else {
631+
def += " DEFAULT '\(escapeStringLiteral(defaultValue))'"
632+
}
633+
}
634+
if column.autoIncrement {
635+
def += " AUTO_INCREMENT"
636+
}
637+
if let onUpdate = column.onUpdate, !onUpdate.isEmpty {
638+
let upper = onUpdate.uppercased()
639+
if upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()" || upper.hasPrefix("CURRENT_TIMESTAMP(") {
640+
def += " ON UPDATE \(onUpdate)"
641+
}
642+
}
643+
if let comment = column.comment, !comment.isEmpty {
644+
def += " COMMENT '\(escapeStringLiteral(comment))'"
645+
}
646+
647+
let position: String
648+
if let afterCol = afterColumn {
649+
position = "AFTER \(quoteIdentifier(afterCol))"
650+
} else {
651+
position = "FIRST"
652+
}
653+
654+
return "ALTER TABLE \(tableName) MODIFY COLUMN \(colName) \(def) \(position)"
655+
}
656+
608657
// MARK: - View Templates
609658

610659
func createViewTemplate() -> String? {

Plugins/TableProPluginKit/PluginDatabaseDriver.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
101101
func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String?
102102
func generateDropForeignKeySQL(table: String, constraintName: String) -> String?
103103
func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]?
104+
func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String?
104105

105106
// Table operations (optional — return nil to use app-level fallback)
106107
func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]?
@@ -230,6 +231,7 @@ public extension PluginDatabaseDriver {
230231
func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { nil }
231232
func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { nil }
232233
func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { nil }
234+
func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { nil }
233235

234236
func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { nil }
235237
func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { nil }

TablePro/Core/Plugins/PluginDriverAdapter.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
334334
pluginDriver.generateModifyPrimaryKeySQL(table: table, oldColumns: oldColumns, newColumns: newColumns, constraintName: constraintName)
335335
}
336336

337+
func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? {
338+
pluginDriver.generateMoveColumnSQL(table: table, column: column, afterColumn: afterColumn)
339+
}
340+
337341
// MARK: - Table Operations
338342

339343
func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String] {

TablePro/Core/Plugins/PluginManager.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,11 @@ final class PluginManager {
866866
.capabilities.supportsSSL ?? true
867867
}
868868

869+
func supportsColumnReorder(for databaseType: DatabaseType) -> Bool {
870+
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
871+
.supportsColumnReorder ?? false
872+
}
873+
869874
func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle {
870875
guard let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId) else {
871876
return .limit

TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ extension PluginMetadataRegistry {
516516
brandColorHex: "#00ED63",
517517
queryLanguageName: "MQL", editorLanguage: .javascript,
518518
connectionMode: .network, supportsDatabaseSwitching: true,
519+
supportsColumnReorder: false,
519520
capabilities: PluginMetadataSnapshot.CapabilityFlags(
520521
supportsSchemaSwitching: false,
521522
supportsImport: true,
@@ -586,6 +587,7 @@ extension PluginMetadataRegistry {
586587
brandColorHex: "#DC382D",
587588
queryLanguageName: "Redis CLI", editorLanguage: .bash,
588589
connectionMode: .network, supportsDatabaseSwitching: false,
590+
supportsColumnReorder: false,
589591
capabilities: PluginMetadataSnapshot.CapabilityFlags(
590592
supportsSchemaSwitching: false,
591593
supportsImport: false,
@@ -636,6 +638,7 @@ extension PluginMetadataRegistry {
636638
brandColorHex: "#E34517",
637639
queryLanguageName: "SQL", editorLanguage: .sql,
638640
connectionMode: .network, supportsDatabaseSwitching: true,
641+
supportsColumnReorder: false,
639642
capabilities: .defaults,
640643
schema: PluginMetadataSnapshot.SchemaInfo(
641644
defaultSchemaName: "dbo",
@@ -671,6 +674,7 @@ extension PluginMetadataRegistry {
671674
brandColorHex: "#C3160B",
672675
queryLanguageName: "SQL", editorLanguage: .sql,
673676
connectionMode: .network, supportsDatabaseSwitching: true,
677+
supportsColumnReorder: false,
674678
capabilities: PluginMetadataSnapshot.CapabilityFlags(
675679
supportsSchemaSwitching: false,
676680
supportsImport: true,
@@ -726,6 +730,7 @@ extension PluginMetadataRegistry {
726730
brandColorHex: "#FFD100",
727731
queryLanguageName: "SQL", editorLanguage: .sql,
728732
connectionMode: .network, supportsDatabaseSwitching: true,
733+
supportsColumnReorder: false,
729734
capabilities: PluginMetadataSnapshot.CapabilityFlags(
730735
supportsSchemaSwitching: false,
731736
supportsImport: true,
@@ -766,6 +771,7 @@ extension PluginMetadataRegistry {
766771
brandColorHex: "#FFD900",
767772
queryLanguageName: "SQL", editorLanguage: .sql,
768773
connectionMode: .fileBased, supportsDatabaseSwitching: false,
774+
supportsColumnReorder: false,
769775
capabilities: .defaults,
770776
schema: PluginMetadataSnapshot.SchemaInfo(
771777
defaultSchemaName: "public",
@@ -796,6 +802,7 @@ extension PluginMetadataRegistry {
796802
brandColorHex: "#26A0D8",
797803
queryLanguageName: "CQL", editorLanguage: .sql,
798804
connectionMode: .network, supportsDatabaseSwitching: true,
805+
supportsColumnReorder: false,
799806
capabilities: PluginMetadataSnapshot.CapabilityFlags(
800807
supportsSchemaSwitching: false,
801808
supportsImport: false,
@@ -849,6 +856,7 @@ extension PluginMetadataRegistry {
849856
brandColorHex: "#6B2EE3",
850857
queryLanguageName: "CQL", editorLanguage: .sql,
851858
connectionMode: .network, supportsDatabaseSwitching: true,
859+
supportsColumnReorder: false,
852860
capabilities: PluginMetadataSnapshot.CapabilityFlags(
853861
supportsSchemaSwitching: false,
854862
supportsImport: false,
@@ -901,6 +909,7 @@ extension PluginMetadataRegistry {
901909
brandColorHex: "#419EDA",
902910
queryLanguageName: "etcdctl", editorLanguage: .bash,
903911
connectionMode: .network, supportsDatabaseSwitching: false,
912+
supportsColumnReorder: false,
904913
capabilities: PluginMetadataSnapshot.CapabilityFlags(
905914
supportsSchemaSwitching: false,
906915
supportsImport: false,
@@ -982,6 +991,7 @@ extension PluginMetadataRegistry {
982991
brandColorHex: "#F6821F",
983992
queryLanguageName: "SQL", editorLanguage: .sql,
984993
connectionMode: .apiOnly, supportsDatabaseSwitching: true,
994+
supportsColumnReorder: false,
985995
capabilities: PluginMetadataSnapshot.CapabilityFlags(
986996
supportsSchemaSwitching: false,
987997
supportsImport: false,
@@ -1033,6 +1043,7 @@ extension PluginMetadataRegistry {
10331043
brandColorHex: "#4053D6",
10341044
queryLanguageName: "PartiQL", editorLanguage: .sql,
10351045
connectionMode: .apiOnly, supportsDatabaseSwitching: false,
1046+
supportsColumnReorder: false,
10361047
capabilities: PluginMetadataSnapshot.CapabilityFlags(
10371048
supportsSchemaSwitching: false,
10381049
supportsImport: false,

TablePro/Core/Plugins/PluginMetadataRegistry.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ struct PluginMetadataSnapshot: Sendable {
3232
let editorLanguage: EditorLanguage
3333
let connectionMode: ConnectionMode
3434
let supportsDatabaseSwitching: Bool
35+
let supportsColumnReorder: Bool
3536

3637
let capabilities: CapabilityFlags
3738
let schema: SchemaInfo
@@ -130,6 +131,7 @@ struct PluginMetadataSnapshot: Sendable {
130131
brandColorHex: brandColorHex, queryLanguageName: queryLanguageName,
131132
editorLanguage: editorLanguage, connectionMode: connectionMode,
132133
supportsDatabaseSwitching: supportsDatabaseSwitching,
134+
supportsColumnReorder: supportsColumnReorder,
133135
capabilities: capabilities, schema: schema, editor: editor, connection: connection
134136
)
135137
}
@@ -340,6 +342,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
340342
brandColorHex: "#FF9500",
341343
queryLanguageName: "SQL", editorLanguage: .sql,
342344
connectionMode: .network, supportsDatabaseSwitching: true,
345+
supportsColumnReorder: true,
343346
capabilities: .defaults,
344347
schema: PluginMetadataSnapshot.SchemaInfo(
345348
defaultSchemaName: "public",
@@ -369,6 +372,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
369372
brandColorHex: "#00B4D8",
370373
queryLanguageName: "SQL", editorLanguage: .sql,
371374
connectionMode: .network, supportsDatabaseSwitching: true,
375+
supportsColumnReorder: true,
372376
capabilities: .defaults,
373377
schema: PluginMetadataSnapshot.SchemaInfo(
374378
defaultSchemaName: "public",
@@ -398,6 +402,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
398402
brandColorHex: "#336791",
399403
queryLanguageName: "SQL", editorLanguage: .sql,
400404
connectionMode: .network, supportsDatabaseSwitching: true,
405+
supportsColumnReorder: false,
401406
capabilities: PluginMetadataSnapshot.CapabilityFlags(
402407
supportsSchemaSwitching: true,
403408
supportsImport: true,
@@ -440,6 +445,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
440445
brandColorHex: "#205B8E",
441446
queryLanguageName: "SQL", editorLanguage: .sql,
442447
connectionMode: .network, supportsDatabaseSwitching: true,
448+
supportsColumnReorder: false,
443449
capabilities: PluginMetadataSnapshot.CapabilityFlags(
444450
supportsSchemaSwitching: true,
445451
supportsImport: true,
@@ -482,6 +488,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
482488
brandColorHex: "#003B57",
483489
queryLanguageName: "SQL", editorLanguage: .sql,
484490
connectionMode: .fileBased, supportsDatabaseSwitching: false,
491+
supportsColumnReorder: false,
485492
capabilities: PluginMetadataSnapshot.CapabilityFlags(
486493
supportsSchemaSwitching: false,
487494
supportsImport: true,
@@ -614,6 +621,11 @@ final class PluginMetadataRegistry: @unchecked Sendable {
614621
let schemes = driverType.urlSchemes
615622
let primaryScheme = schemes.first ?? driverType.databaseTypeId.lowercased()
616623

624+
// Preserve supportsColumnReorder from existing built-in snapshot.
625+
// Cannot read from driverType directly — stale plugins without the
626+
// property crash with EXC_BAD_INSTRUCTION (missing witness table entry).
627+
let existingSnapshot = snapshot(forTypeId: driverType.databaseTypeId)
628+
617629
return PluginMetadataSnapshot(
618630
displayName: driverType.databaseDisplayName,
619631
iconName: driverType.iconName,
@@ -635,6 +647,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
635647
editorLanguage: driverType.editorLanguage,
636648
connectionMode: driverType.connectionMode,
637649
supportsDatabaseSwitching: driverType.supportsDatabaseSwitching,
650+
supportsColumnReorder: existingSnapshot?.supportsColumnReorder ?? false,
638651
capabilities: PluginMetadataSnapshot.CapabilityFlags(
639652
supportsSchemaSwitching: driverType.supportsSchemaSwitching,
640653
supportsImport: driverType.supportsImport,

TablePro/Resources/Localizable.xcstrings

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18472,6 +18472,9 @@
1847218472
}
1847318473
}
1847418474
}
18475+
},
18476+
"Move Group to..." : {
18477+
1847518478
},
1847618479
"Move to" : {
1847718480
"localizations" : {
@@ -19190,6 +19193,9 @@
1919019193
}
1919119194
}
1919219195
}
19196+
},
19197+
"New Subgroup" : {
19198+
1919319199
},
1919419200
"New Tab" : {
1919519201
"localizations" : {
@@ -20612,6 +20618,9 @@
2061220618
}
2061320619
}
2061420620
}
20621+
},
20622+
"None (Top Level)" : {
20623+
2061520624
},
2061620625
"Normal" : {
2061720626
"localizations" : {
@@ -21844,6 +21853,9 @@
2184421853
}
2184521854
}
2184621855
}
21856+
},
21857+
"Parent Group" : {
21858+
2184721859
},
2184821860
"Partition" : {
2184921861
"localizations" : {
@@ -31756,6 +31768,9 @@
3175631768
}
3175731769
}
3175831770
}
31771+
},
31772+
"Top Level" : {
31773+
3175931774
},
3176031775
"Total Size" : {
3176131776
"localizations" : {

TablePro/Views/Results/DataGridView+RowActions.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,48 @@ extension TableViewCoordinator {
160160
guard let connectionId else { return nil }
161161
return DatabaseManager.shared.driver(for: connectionId)
162162
}
163+
164+
// MARK: - Row Drag and Drop
165+
166+
private static let rowDragType = NSPasteboard.PasteboardType("com.TablePro.rowDrag")
167+
168+
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
169+
guard onMoveRow != nil else { return nil }
170+
let item = NSPasteboardItem()
171+
item.setString(String(row), forType: Self.rowDragType)
172+
return item
173+
}
174+
175+
func tableView(
176+
_ tableView: NSTableView,
177+
validateDrop info: any NSDraggingInfo,
178+
proposedRow row: Int,
179+
proposedDropOperation dropOperation: NSTableView.DropOperation
180+
) -> NSDragOperation {
181+
guard onMoveRow != nil else { return [] }
182+
guard info.draggingSource as? NSTableView === tableView else { return [] }
183+
guard info.draggingPasteboard.availableType(from: [Self.rowDragType]) != nil else { return [] }
184+
guard dropOperation == .above else {
185+
tableView.setDropRow(row, dropOperation: .above)
186+
return .move
187+
}
188+
return .move
189+
}
190+
191+
func tableView(
192+
_ tableView: NSTableView,
193+
acceptDrop info: any NSDraggingInfo,
194+
row: Int,
195+
dropOperation: NSTableView.DropOperation
196+
) -> Bool {
197+
guard let onMoveRow else { return false }
198+
guard let item = info.draggingPasteboard.pasteboardItems?.first,
199+
let rowString = item.string(forType: Self.rowDragType),
200+
let fromRow = Int(rowString) else {
201+
return false
202+
}
203+
guard fromRow != row && fromRow != row - 1 else { return false }
204+
onMoveRow(fromRow, row)
205+
return true
206+
}
163207
}

0 commit comments

Comments
 (0)