* Replaced the nested export form with a scrollable layout.
* Made the table selection checkboxes directly visible.
* Preserved the Select All and Select None buttons.
* Kept the view selection controls.
* Cleared stale table selections when switching databases.
* Added automatic database loading when the application opens.
* Preserved the existing SQL editor and administration functionality.
This commit is contained in:
Mark Gregory
2026-10-16 21:51:45 +01:00
parent 86df4fb185
commit 819e5cf46e
20 changed files with 263 additions and 150 deletions
+94 -23
View File
@@ -68,6 +68,11 @@ struct MainView: View {
) == "dark" ? .dark : .light
)
.background(Color(nsColor: .windowBackgroundColor))
.task {
if model.databases.isEmpty, model.selected != nil {
model.loadDatabases()
}
}
.sheet(isPresented: $showingDatabaseTransfer) {
WorkbenchDatabaseTransferView(model: model)
}
@@ -1572,6 +1577,9 @@ struct WorkbenchDatabaseTransferView: View {
@State private var exportStructure = true
@State private var exportData = true
@State private var includeDrop = false
@State private var includeViews = true
@State private var availableViews: [String] = []
@State private var selectedViews: Set<String> = []
@State private var loadingTables = false
@State private var availableDatabases: [String] = []
@State private var loadingDatabases = false
@@ -1593,8 +1601,10 @@ struct WorkbenchDatabaseTransferView: View {
.pickerStyle(.segmented)
.padding(20)
Form {
Section("MySQL Connection") {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
GroupBox("MySQL Connection") {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Connection") {
Text(selectedProfile?.name ?? "Not selected")
}
@@ -1613,14 +1623,18 @@ struct WorkbenchDatabaseTransferView: View {
}
.disabled(loadingDatabases || selectedProfile == nil)
}
}
}
if selectedMode == 0 {
Section("Export Options") {
GroupBox("Export Options") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Table structure", isOn: $exportStructure)
Toggle("Table data", isOn: $exportData)
Toggle("Add DROP TABLE IF EXISTS", isOn: $includeDrop)
.disabled(!exportStructure)
Toggle("Include views (definitions)", isOn: $includeViews)
.disabled(!exportStructure)
HStack {
Text("Tables (\(selectedTables.count) selected)")
Spacer()
@@ -1630,25 +1644,43 @@ struct WorkbenchDatabaseTransferView: View {
.disabled(loadingTables)
}
if loadingTables { ProgressView("Loading tables…") }
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(availableTables, id: \.self) { table in
Toggle(table, isOn: Binding(
get: { selectedTables.contains(table) },
set: { checked in
if checked { selectedTables.insert(table) }
else { selectedTables.remove(table) }
}
))
if availableTables.isEmpty && !loadingTables {
Text("No tables found. Select a database or refresh the list.")
.foregroundStyle(.secondary)
}
ForEach(availableTables, id: \.self) { table in
Toggle(table, isOn: Binding(
get: { selectedTables.contains(table) },
set: { checked in
if checked { selectedTables.insert(table) }
else { selectedTables.remove(table) }
}
))
}
if includeViews && exportStructure {
HStack {
Text("Views (\(selectedViews.count) selected)")
Spacer()
Button("All") { selectedViews = Set(availableViews) }
Button("None") { selectedViews.removeAll() }
}
ForEach(availableViews, id: \.self) { view in
Toggle(view, isOn: Binding(
get: { selectedViews.contains(view) },
set: { checked in
if checked { selectedViews.insert(view) }
else { selectedViews.remove(view) }
}
))
}
}
.frame(maxHeight: 180)
Text("Exports base tables with batched INSERT statements. Views, triggers, routines and transaction-consistent snapshots are not included.")
Text("Exports base tables and selected views. Triggers, routines, consistent snapshots and guaranteed row ordering are not yet supported.")
.font(.caption).foregroundStyle(.secondary)
}
}
} else {
Section("Import Options") {
GroupBox("Import Options") {
VStack(alignment: .leading, spacing: 10) {
Text("Import SQL statements into the selected database.")
.foregroundStyle(.secondary)
@@ -1656,9 +1688,11 @@ struct WorkbenchDatabaseTransferView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section("Progress") {
GroupBox("Progress") {
VStack(alignment: .leading, spacing: 10) {
ProgressView(value: progress)
.frame(maxWidth: .infinity)
@@ -1666,9 +1700,12 @@ struct WorkbenchDatabaseTransferView: View {
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
}
.formStyle(.grouped)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
Divider()
@@ -1701,7 +1738,7 @@ struct WorkbenchDatabaseTransferView: View {
.disabled(
selectedProfile == nil ||
database.isEmpty ||
(selectedMode == 0 && (selectedTables.isEmpty || (!exportStructure && !exportData)))
(selectedMode == 0 && ((!exportStructure && !exportData) || (selectedTables.isEmpty && !(exportStructure && includeViews && !selectedViews.isEmpty))))
)
}
}
@@ -1719,6 +1756,10 @@ struct WorkbenchDatabaseTransferView: View {
await loadTransferDatabases()
}
.task(id: database) {
availableTables = []
selectedTables = []
availableViews = []
selectedViews = []
if !database.isEmpty { await loadTables() }
}
.confirmationDialog(
@@ -1846,7 +1887,11 @@ struct WorkbenchDatabaseTransferView: View {
@MainActor
private func loadTables() async {
guard !database.isEmpty else { availableTables = []; selectedTables = []; return }
guard !database.isEmpty else {
availableTables = []; selectedTables = []
availableViews = []; selectedViews = []
return
}
loadingTables = true
defer { loadingTables = false }
do {
@@ -1855,10 +1900,16 @@ struct WorkbenchDatabaseTransferView: View {
row.count > 1 && row[1].uppercased() == "BASE TABLE" ? row[0] : nil
}
selectedTables = Set(availableTables)
availableViews = rows.rows.compactMap { row in
row.count > 1 && row[1].uppercased() == "VIEW" ? row[0] : nil
}
selectedViews = Set(availableViews)
} catch {
status = "Cannot list tables: \(error.localizedDescription)"
availableTables = []
selectedTables = []
availableViews = []
selectedViews = []
}
}
@@ -1866,7 +1917,11 @@ struct WorkbenchDatabaseTransferView: View {
// preserved rather than reconstructed from QueryResult's String values.
@MainActor
private func exportSQL(to url: URL) async {
guard !selectedTables.isEmpty, exportStructure || exportData else { return }
guard exportStructure || exportData else { return }
let views = exportStructure && includeViews
? availableViews.filter { selectedViews.contains($0) }
: []
guard !selectedTables.isEmpty || !views.isEmpty else { return }
isWorking = true
progress = 0
let tables = availableTables.filter { selectedTables.contains($0) }
@@ -1937,7 +1992,23 @@ struct WorkbenchDatabaseTransferView: View {
if batch.rows.count < 250 { break }
}
}
progress = Double(index + 1) / Double(tables.count)
progress = Double(index + 1) / Double(max(tables.count + views.count, 1))
}
// Views are exported after base tables so dependencies exist on restore.
// The server's SHOW CREATE VIEW response may contain a DEFINER clause;
// importing under a different MySQL account can require extra privileges.
for (index, view) in views.enumerated() {
try Task.checkCancellation()
let qualified = "\(db).\(quoteIdentifier(view))"
status = "Exporting view \(view)"
let definition = try await execute("SHOW CREATE VIEW \(qualified)")
guard let row = definition.rows.first, row.count > 1 else {
throw WorkbenchTransferError.message("Missing CREATE VIEW for \(view)")
}
try write("\n-- View \(qualified)\n")
if includeDrop { try write("DROP VIEW IF EXISTS \(qualified);\n") }
try write(row[1] + ";\n")
progress = Double(tables.count + index + 1) / Double(max(tables.count + views.count, 1))
}
try Task.checkCancellation()
try handle?.close()
@@ -1948,7 +2019,7 @@ struct WorkbenchDatabaseTransferView: View {
try FileManager.default.moveItem(at: tempURL, to: url)
}
progress = 1
status = "Export complete: \(tables.count) tables"
status = "Export complete: \(tables.count) tables, \(views.count) views"
} catch is CancellationError {
status = "Export cancelled; no final backup was written"
} catch {