new ui
This commit is contained in:
@@ -35,7 +35,7 @@ struct MainView: View {
|
||||
@State private var selectedTableName: String? = nil
|
||||
@State private var outputExpanded = true
|
||||
|
||||
@State private var navigatorMode = 0
|
||||
@State private var navigatorMode = 1
|
||||
@State private var administrationPage: WorkbenchAdminPage? = nil
|
||||
|
||||
|
||||
@@ -1567,6 +1567,14 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
@State private var operationTask: Task<Void, Never>?
|
||||
@State private var showingImportConfirmation = false
|
||||
@State private var pendingImportURL: URL?
|
||||
@State private var availableTables: [String] = []
|
||||
@State private var selectedTables: Set<String> = []
|
||||
@State private var exportStructure = true
|
||||
@State private var exportData = true
|
||||
@State private var includeDrop = false
|
||||
@State private var loadingTables = false
|
||||
@State private var availableDatabases: [String] = []
|
||||
@State private var loadingDatabases = false
|
||||
|
||||
private var selectedProfile: ConnectionProfile? {
|
||||
model.selected
|
||||
@@ -1594,20 +1602,50 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
Picker("Database", selection: $database) {
|
||||
Text("Select Database").tag("")
|
||||
|
||||
ForEach(model.databases, id: \.self) { name in
|
||||
ForEach(availableDatabases, id: \.self) { name in
|
||||
Text(name).tag(name)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
if loadingDatabases { ProgressView().controlSize(.small) }
|
||||
Button("Refresh Databases") {
|
||||
Task { await loadTransferDatabases() }
|
||||
}
|
||||
.disabled(loadingDatabases || selectedProfile == nil)
|
||||
}
|
||||
}
|
||||
|
||||
if selectedMode == 0 {
|
||||
Section("Export Options") {
|
||||
Text("Export the SQL definitions of all base tables in the selected database.")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("This version exports table structure. Table data, views, triggers and stored routines will be added in the next stage.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Table structure", isOn: $exportStructure)
|
||||
Toggle("Table data", isOn: $exportData)
|
||||
Toggle("Add DROP TABLE IF EXISTS", isOn: $includeDrop)
|
||||
.disabled(!exportStructure)
|
||||
HStack {
|
||||
Text("Tables (\(selectedTables.count) selected)")
|
||||
Spacer()
|
||||
Button("All") { selectedTables = Set(availableTables) }
|
||||
Button("None") { selectedTables.removeAll() }
|
||||
Button("Refresh") { Task { await loadTables() } }
|
||||
.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) }
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 180)
|
||||
Text("Exports base tables with batched INSERT statements. Views, triggers, routines and transaction-consistent snapshots are not included.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Section("Import Options") {
|
||||
@@ -1662,7 +1700,8 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(
|
||||
selectedProfile == nil ||
|
||||
database.isEmpty
|
||||
database.isEmpty ||
|
||||
(selectedMode == 0 && (selectedTables.isEmpty || (!exportStructure && !exportData)))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1676,6 +1715,12 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
: model.selectedDatabase
|
||||
}
|
||||
}
|
||||
.task(id: selectedProfile?.id) {
|
||||
await loadTransferDatabases()
|
||||
}
|
||||
.task(id: database) {
|
||||
if !database.isEmpty { await loadTables() }
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Import SQL into \(database)?",
|
||||
isPresented: $showingImportConfirmation
|
||||
@@ -1759,7 +1804,7 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
|
||||
return try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: database,
|
||||
database: statement == "SHOW DATABASES" ? "" : database,
|
||||
password: PasswordStore.read(profile.id),
|
||||
sql: statement
|
||||
)
|
||||
@@ -1772,84 +1817,150 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
) + "`"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadTransferDatabases() async {
|
||||
guard selectedProfile != nil else {
|
||||
availableDatabases = []
|
||||
database = ""
|
||||
status = "Select a MySQL connection first"
|
||||
return
|
||||
}
|
||||
loadingDatabases = true
|
||||
defer { loadingDatabases = false }
|
||||
do {
|
||||
// Do not depend on the schema navigator having been opened first.
|
||||
let response = try await execute("SHOW DATABASES")
|
||||
let names = response.rows.compactMap { $0.first }
|
||||
availableDatabases = names
|
||||
if !names.contains(database) {
|
||||
let preferred = model.selectedDatabase
|
||||
database = names.contains(preferred) ? preferred : (names.first ?? "")
|
||||
}
|
||||
status = names.isEmpty ? "No accessible databases" : "Ready"
|
||||
} catch {
|
||||
availableDatabases = []
|
||||
database = ""
|
||||
status = "Cannot load databases: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadTables() async {
|
||||
guard !database.isEmpty else { availableTables = []; selectedTables = []; return }
|
||||
loadingTables = true
|
||||
defer { loadingTables = false }
|
||||
do {
|
||||
let rows = try await execute("SHOW FULL TABLES FROM \(quoteIdentifier(database))")
|
||||
availableTables = rows.rows.compactMap { row in
|
||||
row.count > 1 && row[1].uppercased() == "BASE TABLE" ? row[0] : nil
|
||||
}
|
||||
selectedTables = Set(availableTables)
|
||||
} catch {
|
||||
status = "Cannot list tables: \(error.localizedDescription)"
|
||||
availableTables = []
|
||||
selectedTables = []
|
||||
}
|
||||
}
|
||||
|
||||
// The server constructs SQL literals, so NULL, quotes and binary data are
|
||||
// preserved rather than reconstructed from QueryResult's String values.
|
||||
@MainActor
|
||||
private func exportSQL(to url: URL) async {
|
||||
guard !selectedTables.isEmpty, exportStructure || exportData else { return }
|
||||
isWorking = true
|
||||
progress = 0
|
||||
status = "Reading database structure…"
|
||||
|
||||
let tables = availableTables.filter { selectedTables.contains($0) }
|
||||
let tempURL = url.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).sql.tmp")
|
||||
var handle: FileHandle?
|
||||
defer {
|
||||
try? handle?.close()
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
isWorking = false
|
||||
operationTask = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let quotedDatabase = quoteIdentifier(database)
|
||||
|
||||
let tableResult = try await execute(
|
||||
"SHOW FULL TABLES FROM \(quotedDatabase)"
|
||||
)
|
||||
|
||||
let tables = tableResult.rows.compactMap { row -> String? in
|
||||
guard row.count >= 2,
|
||||
row[1].uppercased() == "BASE TABLE"
|
||||
else { return nil }
|
||||
|
||||
return row[0]
|
||||
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
|
||||
throw WorkbenchTransferError.message("Cannot create temporary export file")
|
||||
}
|
||||
|
||||
var output = """
|
||||
-- WorkbenchReplacement SQL Export
|
||||
-- Database: \(database)
|
||||
-- Generated: \(Date())
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
"""
|
||||
|
||||
handle = try FileHandle(forWritingTo: tempURL)
|
||||
func write(_ value: String) throws {
|
||||
try handle?.write(contentsOf: Data(value.utf8))
|
||||
}
|
||||
try write("-- WorkbenchReplacement native export\n-- Database: \(database)\n-- Generated: \(Date())\nSET NAMES utf8mb4;\n\n")
|
||||
let db = quoteIdentifier(database)
|
||||
for (index, table) in tables.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
|
||||
status = "Exporting structure: \(table)"
|
||||
|
||||
let result = try await execute(
|
||||
"SHOW CREATE TABLE \(quotedDatabase).\(quoteIdentifier(table))"
|
||||
)
|
||||
|
||||
guard let row = result.rows.first,
|
||||
row.count >= 2
|
||||
else {
|
||||
throw WorkbenchTransferError.message(
|
||||
"Cannot retrieve CREATE TABLE for \(table)"
|
||||
)
|
||||
let qualified = "\(db).\(quoteIdentifier(table))"
|
||||
status = "Exporting \(table) (\(index + 1)/\(tables.count))"
|
||||
try write("\n-- Table \(qualified)\n")
|
||||
if exportStructure {
|
||||
let definition = try await execute("SHOW CREATE TABLE \(qualified)")
|
||||
guard let row = definition.rows.first, row.count > 1 else {
|
||||
throw WorkbenchTransferError.message("Missing CREATE TABLE for \(table)")
|
||||
}
|
||||
if includeDrop { try write("DROP TABLE IF EXISTS \(qualified);\n") }
|
||||
try write(row[1] + ";\n")
|
||||
}
|
||||
|
||||
output += "\n-- Table: \(table)\n"
|
||||
output += row[1]
|
||||
output += ";\n\n"
|
||||
|
||||
progress = Double(index + 1) /
|
||||
Double(max(tables.count, 1))
|
||||
if exportData {
|
||||
// Column metadata drives binary-safe SQL literal formatting.
|
||||
let metadata = try await execute("""
|
||||
SELECT COLUMN_NAME, DATA_TYPE
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = \(sqlLiteral(database)) AND TABLE_NAME = \(sqlLiteral(table))
|
||||
ORDER BY ORDINAL_POSITION
|
||||
""")
|
||||
guard !metadata.rows.isEmpty else {
|
||||
throw WorkbenchTransferError.message("No column metadata for \(table)")
|
||||
}
|
||||
let names = metadata.rows.map { quoteIdentifier($0[0]) }.joined(separator: ", ")
|
||||
let expressions = metadata.rows.map { row -> String in
|
||||
let col = quoteIdentifier(row[0])
|
||||
let kind = row[1].lowercased()
|
||||
// HEX avoids decoding arbitrary bytes as UTF-8.
|
||||
if ["binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob", "bit"].contains(kind) {
|
||||
return "IF(\(col) IS NULL, 'NULL', CONCAT('0x', HEX(\(col))))"
|
||||
}
|
||||
return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))"
|
||||
}.joined(separator: ", ")
|
||||
let statement = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified) LIMIT 250 OFFSET "
|
||||
var offset = 0
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let batch = try await execute(statement + String(offset))
|
||||
if batch.rows.isEmpty { break }
|
||||
for row in batch.rows {
|
||||
guard let literalRow = row.first else { continue }
|
||||
try write("INSERT INTO \(qualified) (\(names)) VALUES \(literalRow);\n")
|
||||
}
|
||||
offset += batch.rows.count
|
||||
status = "Exporting \(table): \(offset) rows"
|
||||
if batch.rows.count < 250 { break }
|
||||
}
|
||||
}
|
||||
progress = Double(index + 1) / Double(tables.count)
|
||||
}
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
try output.write(
|
||||
to: url,
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
|
||||
try handle?.close()
|
||||
handle = nil
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
_ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: tempURL, to: url)
|
||||
}
|
||||
progress = 1
|
||||
status = "Export complete: \(tables.count) table definitions"
|
||||
|
||||
status = "Export complete: \(tables.count) tables"
|
||||
} catch is CancellationError {
|
||||
status = "Export cancelled"
|
||||
status = "Export cancelled; no final backup was written"
|
||||
} catch {
|
||||
status = "Export failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func sqlLiteral(_ value: String) -> String {
|
||||
"'" + value.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "''") + "'"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func importSQL(from url: URL) async {
|
||||
isWorking = true
|
||||
|
||||
Reference in New Issue
Block a user