new ui
This commit is contained in:
@@ -25,6 +25,8 @@ struct MainView: View {
|
||||
@EnvironmentObject private var model: DatabaseModel
|
||||
|
||||
@State private var showingEditor = false
|
||||
@State private var showingDatabaseTransfer = false
|
||||
|
||||
@State private var editing = ConnectionProfile()
|
||||
@State private var editingPassword = ""
|
||||
|
||||
@@ -66,6 +68,9 @@ struct MainView: View {
|
||||
) == "dark" ? .dark : .light
|
||||
)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.sheet(isPresented: $showingDatabaseTransfer) {
|
||||
WorkbenchDatabaseTransferView(model: model)
|
||||
}
|
||||
.sheet(isPresented: $showingEditor) {
|
||||
ConnectionEditor(
|
||||
profile: $editing,
|
||||
@@ -102,6 +107,13 @@ struct MainView: View {
|
||||
showingEditor = true
|
||||
}
|
||||
|
||||
toolbarButton(
|
||||
"Import / Export",
|
||||
icon: "arrow.up.arrow.down.square"
|
||||
) {
|
||||
showingDatabaseTransfer = true
|
||||
}
|
||||
|
||||
toolbarButton(
|
||||
"Refresh",
|
||||
icon: "arrow.clockwise"
|
||||
@@ -1539,3 +1551,503 @@ struct WorkbenchAdministrationView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Built-in Database Import / Export
|
||||
|
||||
struct WorkbenchDatabaseTransferView: View {
|
||||
@ObservedObject var model: DatabaseModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedMode = 0
|
||||
@State private var database = ""
|
||||
@State private var isWorking = false
|
||||
@State private var progress = 0.0
|
||||
@State private var status = "Ready"
|
||||
@State private var operationTask: Task<Void, Never>?
|
||||
@State private var showingImportConfirmation = false
|
||||
@State private var pendingImportURL: URL?
|
||||
|
||||
private var selectedProfile: ConnectionProfile? {
|
||||
model.selected
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
Divider()
|
||||
|
||||
Picker("Operation", selection: $selectedMode) {
|
||||
Text("Data Export").tag(0)
|
||||
Text("Data Import / Restore").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(20)
|
||||
|
||||
Form {
|
||||
Section("MySQL Connection") {
|
||||
LabeledContent("Connection") {
|
||||
Text(selectedProfile?.name ?? "Not selected")
|
||||
}
|
||||
|
||||
Picker("Database", selection: $database) {
|
||||
Text("Select Database").tag("")
|
||||
|
||||
ForEach(model.databases, id: \.self) { name in
|
||||
Text(name).tag(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
Section("Import Options") {
|
||||
Text("Import SQL statements into the selected database.")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Supports ordinary SQL statements. Dumps using custom DELIMITER directives or stored routines are not supported yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Progress") {
|
||||
ProgressView(value: progress)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button("Close") {
|
||||
operationTask?.cancel()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isWorking {
|
||||
Button("Cancel") {
|
||||
operationTask?.cancel()
|
||||
status = "Cancelling after current statement…"
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
selectedMode == 0
|
||||
? "Export SQL…"
|
||||
: "Import SQL…"
|
||||
) {
|
||||
if selectedMode == 0 {
|
||||
chooseExportDestination()
|
||||
} else {
|
||||
chooseImportSource()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(
|
||||
selectedProfile == nil ||
|
||||
database.isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(minWidth: 650, idealWidth: 780, minHeight: 500)
|
||||
.onAppear {
|
||||
if database.isEmpty {
|
||||
database = model.selectedDatabase.isEmpty
|
||||
? (model.databases.first ?? "")
|
||||
: model.selectedDatabase
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Import SQL into \(database)?",
|
||||
isPresented: $showingImportConfirmation
|
||||
) {
|
||||
Button("Import SQL") {
|
||||
guard let url = pendingImportURL else { return }
|
||||
|
||||
operationTask = Task {
|
||||
await importSQL(from: url)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {
|
||||
pendingImportURL = nil
|
||||
}
|
||||
} message: {
|
||||
Text(
|
||||
"SQL files can modify or delete existing database objects and data. Ensure you have a backup before continuing."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "externaldrive.badge.timemachine")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Database Import / Export")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
|
||||
Text("Built-in native MySQL database tools")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private func chooseExportDestination() {
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export MySQL Database"
|
||||
panel.nameFieldStringValue = "\(database).sql"
|
||||
panel.canCreateDirectories = true
|
||||
|
||||
guard panel.runModal() == .OK,
|
||||
let url = panel.url
|
||||
else { return }
|
||||
|
||||
operationTask = Task {
|
||||
await exportSQL(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseImportSource() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Import MySQL SQL File"
|
||||
panel.allowedContentTypes = [.plainText]
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
|
||||
guard panel.runModal() == .OK,
|
||||
let url = panel.url
|
||||
else { return }
|
||||
|
||||
pendingImportURL = url
|
||||
showingImportConfirmation = true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func execute(_ statement: String) async throws -> QueryResult {
|
||||
guard let profile = selectedProfile else {
|
||||
throw WorkbenchTransferError.message(
|
||||
"No MySQL connection selected"
|
||||
)
|
||||
}
|
||||
|
||||
return try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: database,
|
||||
password: PasswordStore.read(profile.id),
|
||||
sql: statement
|
||||
)
|
||||
}
|
||||
|
||||
private func quoteIdentifier(_ identifier: String) -> String {
|
||||
"`" + identifier.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
) + "`"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func exportSQL(to url: URL) async {
|
||||
isWorking = true
|
||||
progress = 0
|
||||
status = "Reading database structure…"
|
||||
|
||||
defer {
|
||||
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]
|
||||
}
|
||||
|
||||
var output = """
|
||||
-- WorkbenchReplacement SQL Export
|
||||
-- Database: \(database)
|
||||
-- Generated: \(Date())
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
"""
|
||||
|
||||
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)"
|
||||
)
|
||||
}
|
||||
|
||||
output += "\n-- Table: \(table)\n"
|
||||
output += row[1]
|
||||
output += ";\n\n"
|
||||
|
||||
progress = Double(index + 1) /
|
||||
Double(max(tables.count, 1))
|
||||
}
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
try output.write(
|
||||
to: url,
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
|
||||
progress = 1
|
||||
status = "Export complete: \(tables.count) table definitions"
|
||||
|
||||
} catch is CancellationError {
|
||||
status = "Export cancelled"
|
||||
} catch {
|
||||
status = "Export failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func importSQL(from url: URL) async {
|
||||
isWorking = true
|
||||
progress = 0
|
||||
status = "Reading SQL file…"
|
||||
|
||||
defer {
|
||||
isWorking = false
|
||||
operationTask = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let sql = try String(
|
||||
contentsOf: url,
|
||||
encoding: .utf8
|
||||
)
|
||||
|
||||
let statements = try WorkbenchSQLImportParser.parse(sql)
|
||||
|
||||
guard !statements.isEmpty else {
|
||||
status = "No SQL statements found"
|
||||
return
|
||||
}
|
||||
|
||||
for (index, statement) in statements.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
|
||||
status = "Executing statement \(index + 1) of \(statements.count)"
|
||||
|
||||
_ = try await execute(statement)
|
||||
|
||||
progress = Double(index + 1) /
|
||||
Double(statements.count)
|
||||
}
|
||||
|
||||
status = "Import complete: \(statements.count) statements"
|
||||
|
||||
model.loadDatabases()
|
||||
|
||||
} catch is CancellationError {
|
||||
status = "Import cancelled; previously executed statements may remain applied"
|
||||
} catch {
|
||||
status = "Import failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkbenchTransferError: LocalizedError {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SQL Import Parser
|
||||
//
|
||||
// Handles ordinary SQL statements with quoted strings,
|
||||
// quoted identifiers, line comments and block comments.
|
||||
// Stored routines and custom DELIMITER directives are
|
||||
// deliberately rejected rather than executed incorrectly.
|
||||
|
||||
enum WorkbenchSQLImportParser {
|
||||
static func parse(_ sql: String) throws -> [String] {
|
||||
var statements: [String] = []
|
||||
var current = ""
|
||||
var quote: Character?
|
||||
var escaped = false
|
||||
var lineComment = false
|
||||
var blockComment = false
|
||||
|
||||
let characters = Array(sql)
|
||||
var index = 0
|
||||
|
||||
while index < characters.count {
|
||||
let char = characters[index]
|
||||
let next: Character? = index + 1 < characters.count
|
||||
? characters[index + 1]
|
||||
: nil
|
||||
|
||||
if lineComment {
|
||||
if char == "\n" {
|
||||
lineComment = false
|
||||
current.append("\n")
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if blockComment {
|
||||
if char == "*", next == "/" {
|
||||
blockComment = false
|
||||
index += 2
|
||||
} else {
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if let activeQuote = quote {
|
||||
current.append(char)
|
||||
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if char == "\\" && activeQuote != "`" {
|
||||
escaped = true
|
||||
} else if char == activeQuote {
|
||||
if next == activeQuote {
|
||||
current.append(next!)
|
||||
index += 1
|
||||
} else {
|
||||
quote = nil
|
||||
}
|
||||
}
|
||||
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if char == "'" || char == "\"" || char == "`" {
|
||||
quote = char
|
||||
current.append(char)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if char == "#"
|
||||
|| (char == "-" && next == "-"
|
||||
&& (index + 2 >= characters.count
|
||||
|| characters[index + 2].isWhitespace)) {
|
||||
lineComment = true
|
||||
index += char == "#" ? 1 : 2
|
||||
continue
|
||||
}
|
||||
|
||||
if char == "/", next == "*" {
|
||||
// Executable MySQL comments are not safe to discard.
|
||||
if index + 2 < characters.count,
|
||||
characters[index + 2] == "!" {
|
||||
throw WorkbenchTransferError.message(
|
||||
"This SQL file contains MySQL executable comments, which are not supported by this importer yet."
|
||||
)
|
||||
}
|
||||
|
||||
blockComment = true
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if char == ";" {
|
||||
let statement = current.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
)
|
||||
|
||||
if !statement.isEmpty {
|
||||
statements.append(statement)
|
||||
}
|
||||
|
||||
current = ""
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
current.append(char)
|
||||
index += 1
|
||||
}
|
||||
|
||||
if quote != nil || blockComment {
|
||||
throw WorkbenchTransferError.message(
|
||||
"SQL file contains an unterminated quote or comment."
|
||||
)
|
||||
}
|
||||
|
||||
let remaining = current.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
)
|
||||
|
||||
if !remaining.isEmpty {
|
||||
statements.append(remaining)
|
||||
}
|
||||
|
||||
for statement in statements {
|
||||
let upper = statement.uppercased()
|
||||
|
||||
if upper.hasPrefix("DELIMITER ")
|
||||
|| upper.hasPrefix("CREATE PROCEDURE")
|
||||
|| upper.hasPrefix("CREATE FUNCTION")
|
||||
|| upper.hasPrefix("CREATE TRIGGER")
|
||||
|| upper.hasPrefix("CREATE EVENT") {
|
||||
throw WorkbenchTransferError.message(
|
||||
"Stored routines, triggers, events and DELIMITER directives require the advanced SQL importer."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user