import SwiftUI import AppKit import CryptoKit // MARK: - Classic Workbench Colours private enum WB { static let toolbar = Color(nsColor: NSColor( calibratedWhite: 0.88, alpha: 1 )) static let sidebar = Color(nsColor: NSColor( calibratedWhite: 0.94, alpha: 1 )) static let border = Color(nsColor: NSColor( calibratedWhite: 0.76, alpha: 1 )) static let header = Color(nsColor: NSColor( calibratedWhite: 0.91, alpha: 1 )) } // MARK: - Main Window 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 = "" @State private var expandedSchemas: Set = [] @State private var expandedTableFolders: Set = [] @State private var selectedTableName: String? = nil @State private var outputExpanded = true @State private var navigatorMode = 1 @State private var administrationPage: WorkbenchAdminPage? = nil var body: some View { VStack(spacing: 0) { mainToolbar Rectangle() .fill(WB.border) .frame(height: 1) HSplitView { navigator .frame( minWidth: 180, idealWidth: 250, maxWidth: 420 ) workspace .frame(minWidth: 480) } statusBar } .frame(minWidth: 800, minHeight: 520) .preferredColorScheme( UserDefaults.standard.string( forKey: "preferences.appearance" ) == "dark" ? .dark : .light ) .background(Color(nsColor: .windowBackgroundColor)) .task { if model.databases.isEmpty, model.selected != nil { model.loadDatabases() } } .sheet(isPresented: $showingDatabaseTransfer) { WorkbenchDatabaseTransferView(model: model) } .sheet(isPresented: $showingEditor) { ConnectionEditor( profile: $editing, password: $editingPassword ) { model.save(editing, password: editingPassword) showingEditor = false } } } // MARK: - Main Toolbar private var mainToolbar: some View { HStack(spacing: 16) { Image(systemName: "externaldrive.connected.to.line.below") .font(.system(size: 23)) .foregroundStyle(.blue) Text("MySQL Workbench") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.primary) Rectangle() .fill(WB.border) .frame(width: 1, height: 30) toolbarButton( "New Connection", icon: "plus.rectangle.on.rectangle" ) { editing = ConnectionProfile(name: "New Connection") editingPassword = "" showingEditor = true } toolbarButton( "Import / Export", icon: "arrow.up.arrow.down.square" ) { showingDatabaseTransfer = true } toolbarButton( "Refresh", icon: "arrow.clockwise" ) { model.loadDatabases() } toolbarButton( "Execute", icon: "bolt.fill" ) { model.runSQL() } .keyboardShortcut(.return, modifiers: [.command]) toolbarButton( "Format SQL", icon: "text.alignleft" ) { model.formatSQL() } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(model.selected?.name ?? "No Connection") .font(.system(size: 12, weight: .semibold)) Text(model.selectedDatabase.isEmpty ? "No default schema" : model.selectedDatabase) .font(.system(size: 11)) .foregroundStyle(.secondary) } } .padding(.horizontal, 10) .frame(height: 46) .background(WB.toolbar) } private func toolbarButton( _ title: String, icon: String, action: @escaping () -> Void ) -> some View { Button(action: action) { VStack(spacing: 3) { Image(systemName: icon) .font(.system(size: 19)) .frame(height: 23) Text(title) .font(.system(size: 10)) } .foregroundStyle(.primary) .frame(minWidth: 65) } .buttonStyle(.plain) .help(title) } // MARK: - Navigator private var navigator: some View { VStack(spacing: 0) { Picker("Navigator", selection: $navigatorMode) { Text("Administration").tag(0) Text("Schemas").tag(1) } .pickerStyle(.segmented) .labelsHidden() .padding(8) if navigatorMode == 0 { administrationNavigator } else { schemasNavigator } } .background(WB.sidebar) } private var schemasNavigator: some View { VStack(spacing: 0) { HStack { Text("SCHEMAS") .font(.system(size: 11, weight: .bold)) Spacer() Button { model.loadDatabases() } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) } .padding(.horizontal, 12) .frame(height: 34) Divider() ScrollView { LazyVStack(alignment: .leading, spacing: 2) { ForEach(model.databases, id: \.self) { db in schemaNode(db) } } .padding(.vertical, 8) } Divider() sectionHeader("MYSQL CONNECTIONS") ScrollView { VStack(alignment: .leading, spacing: 4) { ForEach(model.profiles) { profile in Button { model.selectedID = profile.id model.loadDatabases() } label: { Label( profile.name, systemImage: "server.rack" ) .frame( maxWidth: .infinity, alignment: .leading ) .padding(6) .background( model.selectedID == profile.id ? Color.blue.opacity(0.14) : Color.clear ) } .buttonStyle(.plain) .contextMenu { Button("Edit Connection") { edit(profile) } } } } .padding(8) } .frame(height: 125) } } private var administrationNavigator: some View { ScrollView { VStack(alignment: .leading, spacing: 4) { administrationSection( "MANAGEMENT", pages: [ .serverStatus, .clientConnections, .users, .variables ] ) administrationSection( "PERFORMANCE", pages: [ .dashboard, .performanceReports, .performanceSchema ] ) } .padding(8) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private func administrationSection( _ title: String, pages: [WorkbenchAdminPage] ) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.system(size: 11, weight: .bold)) .foregroundStyle(.secondary) .padding(.top, 12) .padding(.bottom, 4) ForEach(pages) { page in Button { administrationPage = page } label: { Label(page.rawValue, systemImage: page.icon) .frame( maxWidth: .infinity, alignment: .leading ) .padding(.horizontal, 8) .padding(.vertical, 7) .background( administrationPage == page ? Color.accentColor.opacity(0.18) : Color.clear ) .clipShape(RoundedRectangle(cornerRadius: 5)) } .buttonStyle(.plain) } } } // MARK: - Compact Schema Tree private func schemaNode(_ db: String) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 5) { Image(systemName: expandedSchemas.contains(db) ? "chevron.down" : "chevron.right" ) .font(.system(size: 10)) .foregroundStyle(.secondary) .frame(width: 13) Image(systemName: "cylinder.split.1x2") .foregroundStyle(.blue) .frame(width: 17) Text(db) .font(.system(size: 12, weight: model.selectedDatabase == db ? .semibold : .regular)) .lineLimit(1) Spacer(minLength: 0) } .frame(height: 25) .padding(.leading, 10) .contentShape(Rectangle()) .onTapGesture { toggleSchema(db) } .onTapGesture(count: 2) { model.selectedDatabase = db expandedSchemas.insert(db) expandedTableFolders.insert(db) if model.schemaTables[db] == nil { model.loadTables(database: db) } } .contextMenu { Button("Set as Default Schema") { model.selectedDatabase = db } Button("Refresh Tables") { model.loadTables(database: db) } } if expandedSchemas.contains(db) { schemaChildren(db) } } .frame(maxWidth: .infinity, alignment: .leading) } private func toggleSchema(_ db: String) { if expandedSchemas.contains(db) { expandedSchemas.remove(db) } else { expandedSchemas.insert(db) expandedTableFolders.insert(db) if model.schemaTables[db] == nil { model.loadTables(database: db) } } } private func schemaChildren(_ db: String) -> some View { VStack(alignment: .leading, spacing: 0) { // Tables folder HStack(spacing: 5) { Image(systemName: expandedTableFolders.contains(db) ? "chevron.down" : "chevron.right" ) .font(.system(size: 10)) .foregroundStyle(.secondary) .frame(width: 13) Image(systemName: "folder") .foregroundStyle(.secondary) .frame(width: 17) Text("Tables") .font(.system(size: 12)) Spacer(minLength: 0) } .frame(height: 24) .padding(.leading, 28) .contentShape(Rectangle()) .onTapGesture { if expandedTableFolders.contains(db) { expandedTableFolders.remove(db) } else { expandedTableFolders.insert(db) if model.schemaTables[db] == nil { model.loadTables(database: db) } } } // Tables if expandedTableFolders.contains(db) { if let tables = model.schemaTables[db] { ForEach(tables, id: \.self) { table in tableNode(database: db, table: table) } } else { Button("Load Tables") { model.loadTables(database: db) } .font(.system(size: 11)) .buttonStyle(.borderless) .padding(.leading, 66) .frame(height: 24) } } folderNode("Views", indent: 28) folderNode("Stored Procedures", indent: 28) folderNode("Functions", indent: 28) } .frame(maxWidth: .infinity, alignment: .leading) } private func tableNode(database: String, table: String) -> some View { let identifier = database + "." + table let isSelected = selectedTableName == identifier return HStack(spacing: 5) { Image(systemName: "tablecells") .font(.system(size: 11)) .foregroundStyle( isSelected ? Color.blue : Color.gray ) .frame(width: 17) Text(table) .font(.system(size: 12)) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 0) } .frame(height: 23) .padding(.leading, 49) .frame(maxWidth: .infinity, alignment: .leading) .background( isSelected ? Color.blue.opacity(0.14) : Color.clear ) .contentShape(Rectangle()) .onTapGesture { selectedTableName = identifier } .onTapGesture(count: 2) { selectedTableName = identifier model.selectTable( database: database, table: table ) } .contextMenu { Button("Select First 100 Rows") { selectedTableName = identifier model.selectTable( database: database, table: table ) } } } private func folderNode( _ title: String, indent: CGFloat ) -> some View { HStack(spacing: 5) { Image(systemName: "chevron.right") .font(.system(size: 10)) .foregroundStyle(.secondary) .frame(width: 13) Image(systemName: "folder") .foregroundStyle(.secondary) .frame(width: 17) Text(title) .font(.system(size: 12)) Spacer(minLength: 0) } .frame(height: 24) .padding(.leading, indent) .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - Workspace private var workspace: some View { Group { if let page = administrationPage { WorkbenchAdministrationView( page: page, profile: model.selected ) { administrationPage = nil navigatorMode = 1 } } else { sqlWorkspace } } } private var sqlWorkspace: some View { VStack(spacing: 0) { queryTabBar Divider() queryToolbar Divider() VSplitView { sqlEditor .frame(minHeight: 150) bottomWorkspace .frame(minHeight: 150) } } } private var queryTabBar: some View { HStack(spacing: 0) { ScrollView(.horizontal) { HStack(spacing: 2) { ForEach(model.queryTabs) { tab in HStack(spacing: 8) { Button { model.activateQuery(tab.id) } label: { Label(tab.title, systemImage: "doc.text") .lineLimit(1) } .buttonStyle(.plain) Button { model.closeQuery(tab.id) } label: { Image(systemName: "xmark") .font(.system(size: 9)) } .buttonStyle(.plain) } .font(.system(size: 12)) .padding(.horizontal, 10) .frame(height: 30) .background( model.activeQueryID == tab.id ? Color(nsColor: .textBackgroundColor) : Color.clear ) .clipShape( UnevenRoundedRectangle( topLeadingRadius: 5, topTrailingRadius: 5 ) ) } } } .scrollIndicators(.hidden) Button { model.newQueryTab() } label: { Image(systemName: "plus") } .buttonStyle(.borderless) .padding(.horizontal, 10) .help("New Query Tab") } .background(WB.header) } private var queryToolbar: some View { HStack(spacing: 14) { Button { model.runSQL() } label: { Image(systemName: "bolt.fill") .foregroundStyle(.orange) } .help("Execute SQL") Button { model.formatSQL() } label: { Image(systemName: "text.alignleft") } .help("Format SQL") Divider() .frame(height: 17) Text("Default Schema:") .font(.system(size: 11)) Text(model.selectedDatabase.isEmpty ? "None" : model.selectedDatabase) .font(.system(size: 11, weight: .semibold)) Spacer() Text("SQL Editor") .font(.system(size: 11)) .foregroundStyle(.secondary) } .buttonStyle(.borderless) .padding(.horizontal, 12) .frame(height: 30) .background(WB.header) } private var sqlEditor: some View { HStack(spacing: 0) { if UserDefaults.standard.object( forKey: "preferences.showLineNumbers" ) as? Bool ?? true { lineNumbers Rectangle() .fill(WB.border) .frame(width: 1) } Rectangle() .fill(WB.border) .frame(width: 1) SQLTextView(text: $model.sql) } .background(Color(nsColor: .textBackgroundColor)) } private var lineNumbers: some View { ScrollView { VStack(alignment: .trailing, spacing: 0) { ForEach( 1...max(1, model.sql.components( separatedBy: "\n" ).count), id: \.self ) { number in Text("\(number)") .frame(height: 19) } } .padding(.horizontal, 9) .padding(.top, 8) } .font(.system(size: 12, design: .monospaced)) .foregroundStyle(.secondary) .frame(width: 48) .background(WB.header) .allowsHitTesting(false) } // MARK: - Results and Action Output private var bottomWorkspace: some View { VSplitView { resultsPanel .frame(minHeight: 230) if outputExpanded { actionOutput .frame(minHeight: 90) } } } private var resultsPanel: some View { VStack(spacing: 0) { HStack { Image(systemName: "tablecells") Text("Result Grid") .fontWeight(.semibold) Spacer() Text(model.result.message) .foregroundStyle(.secondary) } .font(.system(size: 12)) .padding(.horizontal, 12) .frame(height: 28) .background(WB.header) Divider() if model.result.columns.isEmpty { VStack { Spacer() Text("No query results") .foregroundStyle(.secondary) Spacer() } } else { resultGrid } } } private var resultGrid: some View { NativeResultGrid( columns: model.result.columns, rows: model.result.rows ) } private var actionOutput: some View { VStack(spacing: 0) { HStack { Image(systemName: "list.bullet.rectangle") Text("Action Output") .fontWeight(.semibold) Spacer() Button { outputExpanded = false } label: { Image(systemName: "chevron.down") } .buttonStyle(.borderless) } .font(.system(size: 12)) .padding(.horizontal, 12) .frame(height: 26) .background(WB.header) Divider() HStack(alignment: .top, spacing: 12) { Image(systemName: model.status == "Error" ? "xmark.circle.fill" : "checkmark.circle.fill" ) .foregroundStyle( model.status == "Error" ? .red : .green ) VStack(alignment: .leading, spacing: 5) { Text(model.status) .fontWeight(.semibold) Text(model.result.message) .foregroundStyle(.secondary) } Spacer() } .font(.system(size: 12)) .padding(12) Spacer() } } // MARK: - Status Bar private var statusBar: some View { HStack(spacing: 10) { Circle() .fill( model.status == "Error" ? Color.red : Color.green ) .frame(width: 8, height: 8) Text(model.status) .lineLimit(1) Spacer() Text(model.selected?.name ?? "Disconnected") Divider() .frame(height: 13) Text(model.selectedDatabase.isEmpty ? "No Schema" : model.selectedDatabase) Divider() .frame(height: 13) Text("UTF-8") } .font(.system(size: 11)) .padding(.horizontal, 12) .frame(height: 22) .background(WB.header) } private func sectionHeader(_ title: String) -> some View { HStack { Text(title) .font(.system(size: 11, weight: .bold)) .foregroundStyle(.secondary) Spacer() } .padding(.horizontal, 12) .frame(height: 30) .background(WB.header) } private func edit(_ profile: ConnectionProfile) { editing = profile editingPassword = PasswordStore.read(profile.id) showingEditor = true } } // MARK: - Native SQL Text Editor struct SQLTextView: NSViewRepresentable { @Binding var text: String func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let scroll = NSTextView.scrollableTextView() let editor = scroll.documentView as! NSTextView scroll.appearance = NSAppearance(named: .aqua) editor.appearance = NSAppearance(named: .aqua) editor.backgroundColor = .white editor.textColor = .black scroll.backgroundColor = .white editor.isEditable = true editor.isRichText = false editor.allowsUndo = true editor.isAutomaticQuoteSubstitutionEnabled = false editor.isAutomaticDashSubstitutionEnabled = false editor.font = NSFont.monospacedSystemFont( ofSize: CGFloat( UserDefaults.standard.object( forKey: "preferences.editorFontSize" ) as? Double ?? 13 ), weight: .regular ) editor.textContainerInset = NSSize( width: 10, height: 8 ) editor.delegate = context.coordinator editor.string = text context.coordinator.highlight(editor) return scroll } func updateNSView( _ scroll: NSScrollView, context: Context ) { guard let editor = scroll.documentView as? NSTextView else { return } if editor.string != text { let selection = editor.selectedRange() editor.string = text let safeLocation = min( selection.location, (text as NSString).length ) editor.setSelectedRange( NSRange(location: safeLocation, length: 0) ) context.coordinator.highlight(editor) } } final class Coordinator: NSObject, NSTextViewDelegate { var parent: SQLTextView init(_ parent: SQLTextView) { self.parent = parent } func textDidChange(_ notification: Notification) { guard let editor = notification.object as? NSTextView else { return } parent.text = editor.string highlight(editor) } func highlight(_ editor: NSTextView) { guard let storage = editor.textStorage else { return } let text = editor.string as NSString let range = NSRange( location: 0, length: text.length ) storage.beginEditing() storage.setAttributes([ .foregroundColor: NSColor.textColor, .font: NSFont.monospacedSystemFont( ofSize: CGFloat( UserDefaults.standard.object( forKey: "preferences.editorFontSize" ) as? Double ?? 13 ), weight: .regular ) ], range: range) let pattern = """ \\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b """ if let regex = try? NSRegularExpression( pattern: pattern, options: [.caseInsensitive] ) { for match in regex.matches( in: editor.string, range: range ) { storage.addAttribute( .foregroundColor, value: NSColor.systemBlue, range: match.range ) } } storage.endEditing() } } } // MARK: - Connection Editor struct ConnectionEditor: View { @Binding var profile: ConnectionProfile @Binding var password: String let save: () -> Void @Environment(\.dismiss) private var dismiss var body: some View { VStack(alignment: .leading, spacing: 14) { Text("Connection Settings") .font(.title2.bold()) Form { TextField( "Connection Name", text: $profile.name ) TextField( "Hostname", text: $profile.host ) TextField( "Port", value: $profile.port, format: .number ) TextField( "Username", text: $profile.username ) SecureField( "Password (macOS Keychain)", text: $password ) TextField( "Default Schema", text: $profile.database ) } HStack { Spacer() Button("Cancel") { dismiss() } Button("Save") { save() } .buttonStyle(.borderedProminent) } } .padding(24) .frame(width: 520) } } // MARK: - High Performance Native Result Grid struct NativeResultGrid: NSViewRepresentable { let columns: [String] let rows: [[String]] func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSScrollView { let scroll = NSScrollView() scroll.hasVerticalScroller = true scroll.hasHorizontalScroller = true scroll.autohidesScrollers = true scroll.drawsBackground = true scroll.backgroundColor = .white let table = NSTableView() table.headerView = NSTableHeaderView() table.usesAlternatingRowBackgroundColors = UserDefaults.standard.object( forKey: "preferences.alternatingRows" ) as? Bool ?? true table.rowHeight = CGFloat( UserDefaults.standard.object( forKey: "preferences.resultRowHeight" ) as? Double ?? 24 ) table.intercellSpacing = NSSize(width: 1, height: 1) table.allowsMultipleSelection = true table.allowsColumnResizing = true table.allowsColumnReordering = true table.columnAutoresizingStyle = .noColumnAutoresizing table.backgroundColor = .white context.coordinator.columns = columns context.coordinator.rows = rows table.delegate = context.coordinator table.dataSource = context.coordinator configureColumns(table) scroll.documentView = table table.reloadData() return scroll } func updateNSView( _ scroll: NSScrollView, context: Context ) { guard let table = scroll.documentView as? NSTableView else { return } let oldColumns = context.coordinator.columns context.coordinator.columns = columns context.coordinator.rows = rows if oldColumns != columns { configureColumns(table) } table.reloadData() } private func configureColumns(_ table: NSTableView) { for column in Array(table.tableColumns) { table.removeTableColumn(column) } for (index, name) in columns.enumerated() { let identifier = NSUserInterfaceItemIdentifier( "column_\(index)" ) let column = NSTableColumn(identifier: identifier) column.title = name column.width = CGFloat( UserDefaults.standard.object( forKey: "preferences.resultColumnWidth" ) as? Double ?? 170 ) column.minWidth = 70 column.maxWidth = 700 column.resizingMask = .userResizingMask table.addTableColumn(column) } } final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { var columns: [String] = [] var rows: [[String]] = [] func numberOfRows(in tableView: NSTableView) -> Int { rows.count } func tableView( _ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int ) -> NSView? { guard let tableColumn, row >= 0, row < rows.count else { return nil } let identifier = tableColumn.identifier.rawValue guard identifier.hasPrefix("column_"), let columnIndex = Int(identifier.dropFirst(7)), columnIndex < columns.count else { return nil } let cellIdentifier = NSUserInterfaceItemIdentifier("ResultCell") let cell: NSTableCellView if let reused = tableView.makeView( withIdentifier: cellIdentifier, owner: nil ) as? NSTableCellView { cell = reused } else { cell = NSTableCellView() cell.identifier = cellIdentifier let field = NSTextField(labelWithString: "") field.font = NSFont.monospacedSystemFont( ofSize: 12, weight: .regular ) field.lineBreakMode = .byTruncatingTail field.translatesAutoresizingMaskIntoConstraints = false cell.addSubview(field) cell.textField = field NSLayoutConstraint.activate([ field.leadingAnchor.constraint( equalTo: cell.leadingAnchor, constant: 7 ), field.trailingAnchor.constraint( equalTo: cell.trailingAnchor, constant: -7 ), field.centerYAnchor.constraint( equalTo: cell.centerYAnchor ) ]) } let values = rows[row] cell.textField?.stringValue = columnIndex < values.count ? values[columnIndex] : "" return cell } } } // MARK: - MySQL Administration enum WorkbenchAdminPage: String, CaseIterable, Identifiable { case serverStatus = "Server Status" case clientConnections = "Client Connections" case users = "Users and Privileges" case variables = "Status and System Variables" case dashboard = "Dashboard" case performanceReports = "Performance Reports" case performanceSchema = "Performance Schema Setup" var id: String { rawValue } var icon: String { switch self { case .serverStatus: return "server.rack" case .clientConnections: return "person.2" case .users: return "person.crop.circle.badge.checkmark" case .variables: return "slider.horizontal.3" case .dashboard: return "chart.xyaxis.line" case .performanceReports: return "chart.bar.xaxis" case .performanceSchema: return "gauge.with.dots.needle.67percent" } } var statement: String { switch self { case .serverStatus: return """ SHOW GLOBAL STATUS WHERE Variable_name IN ( 'Uptime', 'Threads_connected', 'Threads_running', 'Questions', 'Queries', 'Connections', 'Bytes_received', 'Bytes_sent' ) """ case .clientConnections: return "SHOW FULL PROCESSLIST" case .users: return """ SELECT User, Host, plugin, account_locked FROM mysql.user ORDER BY User, Host """ case .variables: return "SHOW GLOBAL VARIABLES" case .dashboard: return """ SHOW GLOBAL STATUS WHERE Variable_name IN ( 'Uptime', 'Connections', 'Threads_connected', 'Threads_running', 'Questions', 'Queries', 'Bytes_received', 'Bytes_sent', 'Innodb_buffer_pool_reads', 'Innodb_buffer_pool_read_requests', 'Slow_queries' ) """ case .performanceReports: return """ SELECT * FROM sys.statement_analysis LIMIT 100 """ case .performanceSchema: return """ SELECT NAME, ENABLED, TIMED FROM performance_schema.setup_instruments LIMIT 250 """ } } } struct WorkbenchAdministrationView: View { let page: WorkbenchAdminPage let profile: ConnectionProfile? let close: () -> Void @State private var result = QueryResult() @State private var loading = false @State private var errorMessage: String? @State private var lastUpdated: Date? var body: some View { VStack(spacing: 0) { header Divider() if loading && result.columns.isEmpty { Spacer() ProgressView("Loading \(page.rawValue)…") Spacer() } else if let errorMessage { ContentUnavailableView { Label( "Unable to Load \(page.rawValue)", systemImage: "exclamationmark.triangle" ) } description: { Text(errorMessage) } actions: { Button("Retry") { Task { await refresh() } } } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if result.columns.isEmpty { ContentUnavailableView( "No Data", systemImage: "tablecells", description: Text( "The server did not return any columns." ) ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { resultContent } Divider() footer } .frame(maxWidth: .infinity, maxHeight: .infinity) .task(id: "\(page.id)-\(profile?.id.uuidString ?? "")") { await refresh() } } private var header: some View { HStack(spacing: 14) { Image(systemName: page.icon) .font(.system(size: 27)) .foregroundStyle(.blue) .frame(width: 40) VStack(alignment: .leading, spacing: 3) { Text(profile?.name ?? "No Connection") .font(.caption) .foregroundStyle(.secondary) Text(page.rawValue) .font(.system(size: 22, weight: .semibold)) } Spacer() Button { Task { await refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } .disabled(loading) Button { close() } label: { Label("SQL Editor", systemImage: "chevron.left.forwardslash.chevron.right") } } .padding(18) } @ViewBuilder private var resultContent: some View { if page == .dashboard || page == .serverStatus { ScrollView { LazyVGrid( columns: [ GridItem(.adaptive(minimum: 180), spacing: 12) ], spacing: 12 ) { ForEach(result.rows.indices, id: \.self) { index in let row = result.rows[index] if row.count >= 2 { VStack(alignment: .leading, spacing: 10) { Text( row[0] .replacingOccurrences( of: "_", with: " " ) ) .font(.caption) .foregroundStyle(.secondary) Text(row[1]) .font( .system( size: 23, weight: .semibold, design: .rounded ) ) .lineLimit(1) .minimumScaleFactor(0.65) } .frame( maxWidth: .infinity, minHeight: 90, alignment: .leading ) .padding(14) .background( Color(nsColor: .controlBackgroundColor) ) .clipShape( RoundedRectangle(cornerRadius: 10) ) } } } .padding(18) } } else { NativeResultGrid( columns: result.columns, rows: result.rows ) .frame( maxWidth: .infinity, maxHeight: .infinity ) } } private var footer: some View { HStack { if let lastUpdated { Text( "Updated \(lastUpdated.formatted(date: .omitted, time: .standard))" ) } else { Text("Not loaded") } Spacer() Text("\(result.rows.count) rows") } .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 16) .frame(height: 30) } @MainActor private func refresh() async { guard let profile else { errorMessage = "Select a MySQL connection first." return } loading = true errorMessage = nil defer { loading = false } do { let response = try await NativeMySQLRunner.run( profile: profile, database: "", password: PasswordStore.read(profile.id), sql: page.statement ) result = response lastUpdated = Date() } catch { errorMessage = error.localizedDescription } } } // 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 transferredRows: Int64 = 0 @State private var currentTableRows: Int64 = 0 @State private var currentTableExpected: Int64 = 0 @State private var status = "Ready" @State private var operationTask: Task? @State private var showingImportConfirmation = false @State private var pendingImportURL: URL? @State private var availableTables: [String] = [] @State private var selectedTables: Set = [] @State private var exportStructure = true @State private var exportData = true @State private var includeDrop = false @State private var includeViews = true @State private var includeTriggers = false @State private var includeRoutines = false @State private var availableViews: [String] = [] @State private var selectedViews: Set = [] @State private var loadingTables = false @State private var availableDatabases: [String] = [] @State private var loadingDatabases = false 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) ScrollView { VStack(alignment: .leading, spacing: 16) { GroupBox("MySQL Connection") { VStack(alignment: .leading, spacing: 10) { LabeledContent("Connection") { Text(selectedProfile?.name ?? "Not selected") } Picker("Database", selection: $database) { Text("Select Database").tag("") 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 { 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) Toggle("Include triggers", isOn: $includeTriggers) .disabled(!exportStructure) Toggle("Include stored procedures and functions", isOn: $includeRoutines) .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…") } 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) } } )) } } Text("InnoDB data is exported using a consistent snapshot and a single streamed query per table. Tables without unique indexes are supported.") .font(.caption).foregroundStyle(.secondary) } } } else { GroupBox("Import Options") { VStack(alignment: .leading, spacing: 10) { Text("Import SQL statements into the selected database.") .foregroundStyle(.secondary) Text("Supports MySQL DELIMITER directives, triggers and stored routines. Import is not transactional; use a verified backup before restoring.") .font(.caption) .foregroundStyle(.secondary) } } } GroupBox("Progress") { VStack(alignment: .leading, spacing: 10) { ProgressView(value: progress) .frame(maxWidth: .infinity) Text(status) .font(.caption) .foregroundStyle(.secondary) .textSelection(.enabled) if isWorking && selectedMode == 0 && currentTableExpected > 0 { ProgressView(value: Double(currentTableRows), total: Double(currentTableExpected)) Text("Current table: \(currentTableRows.formatted()) / \(currentTableExpected.formatted()) rows · Total exported: \(transferredRows.formatted())") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) Divider() HStack { Button("Close") { operationTask?.cancel() dismiss() } Spacer() if !isWorking { Button("Verify Backup…") { chooseBackupToVerify() } .help("Verify an SQL file against its SHA-256 checksum sidecar") } 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 || (selectedMode == 0 && ((!exportStructure && !exportData) || (selectedTables.isEmpty && !(exportStructure && includeViews && !selectedViews.isEmpty)))) ) } } .padding(16) } .frame(minWidth: 650, idealWidth: 780, minHeight: 500) .onAppear { if database.isEmpty { database = model.selectedDatabase.isEmpty ? (model.databases.first ?? "") : model.selectedDatabase } } .task(id: selectedProfile?.id) { await loadTransferDatabases() } .task(id: database) { availableTables = [] selectedTables = [] availableViews = [] selectedViews = [] if !database.isEmpty { await loadTables() } } .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 chooseBackupToVerify() { let panel = NSOpenPanel() panel.title = "Verify SQL Backup" panel.canChooseDirectories = false panel.canChooseFiles = true guard panel.runModal() == .OK, let url = panel.url else { return } operationTask = Task { isWorking = true progress = 0 defer { isWorking = false; operationTask = nil } do { try await Task.detached(priority: .utility) { try WorkbenchBackupIntegrity.verify(url) }.value progress = 1 status = "Verified SHA-256: \(url.lastPathComponent)" } catch { status = "Verification failed: \(error.localizedDescription)" } } } 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 } // Export operations use a distinct authenticated session so their snapshot // cannot be affected by SQL editor queries or administration refreshes. @State private var exportSession: ConnectionProfile? @State private var exportPassword = "" @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: exportSession ?? profile, database: statement == "SHOW DATABASES" ? "" : database, password: exportSession == nil ? PasswordStore.read(profile.id) : exportPassword, sql: statement ) } private func quoteIdentifier(_ identifier: String) -> String { "`" + identifier.replacingOccurrences( of: "`", with: "``" ) + "`" } @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 = [] availableViews = []; selectedViews = [] 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) 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 = [] } } // 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 exportStructure || exportData else { return } let views = exportStructure && includeViews ? availableViews.filter { selectedViews.contains($0) } : [] guard !selectedTables.isEmpty || !views.isEmpty || (exportStructure && (includeTriggers || includeRoutines)) else { return } guard let activeProfile = selectedProfile else { status = "Export failed: No connection selected" return } // Clone the connection profile with a fresh identity; the session pool // then creates a separate socket, independent of the editor session. var dedicatedProfile = activeProfile dedicatedProfile.id = UUID() exportPassword = PasswordStore.read(activeProfile.id) exportSession = dedicatedProfile isWorking = true progress = 0 transferredRows = 0 currentTableRows = 0 currentTableExpected = 0 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) NativeMySQLRunner.disconnect(profileID: dedicatedProfile.id) exportSession = nil exportPassword = "" isWorking = false operationTask = nil } do { // A consistent InnoDB snapshot requires every SELECT to execute on // this SAME connection. MyISAM/MEMORY cannot participate. for table in tables where exportData { let engine = try await execute(""" SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = \(sqlLiteral(database)) AND TABLE_NAME = \(sqlLiteral(table)) AND TABLE_TYPE = 'BASE TABLE' """) guard let name = engine.rows.first?.first, name.uppercased() == "INNODB" else { throw WorkbenchTransferError.message( "Consistent data export requires InnoDB. Table \(table) uses \(engine.rows.first?.first ?? "an unknown engine"). Export its structure only." ) } } _ = try await execute("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ") _ = try await execute("START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY") guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else { throw WorkbenchTransferError.message("Cannot create temporary export file") } 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() 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") } 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: ", ") // A single streamed SELECT keeps every row in the same // InnoDB read view and works without a primary/unique key. // No OFFSET, no 100,000-row cap, no full result buffering. let indexes = try await execute(""" SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = \(sqlLiteral(database)) AND TABLE_NAME = \(sqlLiteral(table)) AND NON_UNIQUE = 0 ORDER BY CASE WHEN INDEX_NAME = 'PRIMARY' THEN 0 ELSE 1 END, INDEX_NAME, SEQ_IN_INDEX """) let nullable = try await execute(""" SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = \(sqlLiteral(database)) AND TABLE_NAME = \(sqlLiteral(table)) AND IS_NULLABLE = 'YES' """) let nullableColumns = Set(nullable.rows.compactMap { $0.first }) var grouped: [String: [String]] = [:] var indexOrder: [String] = [] for indexRow in indexes.rows where indexRow.count >= 2 { let name = indexRow[0] if grouped[name] == nil { grouped[name] = [] indexOrder.append(name) } grouped[name, default: []].append(indexRow[1]) } let usableIndex = indexOrder.first { name in guard let columns = grouped[name], !columns.isEmpty else { return false } return columns.allSatisfy { !nullableColumns.contains($0) } } let orderBy = usableIndex.flatMap { grouped[$0] }? .map(quoteIdentifier).joined(separator: ", ") let countResult = try await execute("SELECT COUNT(*) FROM \(qualified)") guard let countText = countResult.rows.first?.first, let expectedCount = Int64(countText) else { throw WorkbenchTransferError.message("Cannot count rows in \(table)") } currentTableRows = 0 currentTableExpected = expectedCount try write("-- Streamed snapshot: \(expectedCount) rows; order: \(usableIndex ?? "server scan")\n") let select = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified)" + (orderBy.map { " ORDER BY " + $0 } ?? "") let outputHandle = handle! let prefix = Data("INSERT INTO \(qualified) (\(names)) VALUES ".utf8) let suffix = Data(";\n".utf8) let completedBeforeTable = transferredRows let completedTables = index let totalUnits = max(tables.count + views.count, 1) var streamedInTable: Int64 = 0 let written = try await NativeMySQLRunner.stream( profile: dedicatedProfile, database: database, password: exportPassword, sql: select ) { literal in try outputHandle.write(contentsOf: prefix) try outputHandle.write(contentsOf: Data(literal.utf8)) try outputHandle.write(contentsOf: suffix) streamedInTable += 1 if streamedInTable % 500 == 0 || streamedInTable == expectedCount { let completed = streamedInTable DispatchQueue.main.async { currentTableRows = completed transferredRows = completedBeforeTable + completed progress = (Double(completedTables) + Double(completed) / Double(max(expectedCount, 1))) / Double(totalUnits) status = "Exporting \(table): \(completed.formatted()) / \(expectedCount.formatted()) rows" } } } currentTableRows = written transferredRows = completedBeforeTable + written guard written == expectedCount else { throw WorkbenchTransferError.message( "Table \(table): expected \(expectedCount) rows, streamed \(written). Backup discarded." ) } status = "Exported \(table): \(written) rows" } currentTableExpected = 0 currentTableRows = 0 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)) } // Routines and triggers use client-side delimiters. The importer // strips these directives and sends each definition as one query. if exportStructure && includeRoutines { let routines = try await execute(""" SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = \(sqlLiteral(database)) ORDER BY ROUTINE_TYPE, ROUTINE_NAME """) for routine in routines.rows { try Task.checkCancellation() guard routine.count >= 2 else { continue } let name = routine[0] let kind = routine[1].uppercased() guard kind == "PROCEDURE" || kind == "FUNCTION" else { continue } status = "Exporting \(kind.lowercased()) \(name)" let qualified = "\(db).\(quoteIdentifier(name))" let definition = try await execute("SHOW CREATE \(kind) \(qualified)") // SHOW CREATE PROCEDURE/FUNCTION: Name, sql_mode, Create ..., ... guard let row = definition.rows.first, row.count > 2, !row[2].isEmpty else { throw WorkbenchTransferError.message("Missing CREATE \(kind) for \(name)") } try write("\n-- \(kind) \(qualified)\n") try write("DELIMITER $$\n") if includeDrop { try write("DROP \(kind) IF EXISTS \(qualified)$$\n") } try write(row[2] + "$$\nDELIMITER ;\n") } } if exportStructure && includeTriggers { let triggers = try await execute(""" SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = \(sqlLiteral(database)) ORDER BY TRIGGER_NAME """) for trigger in triggers.rows { try Task.checkCancellation() guard let name = trigger.first else { continue } status = "Exporting trigger \(name)" let qualified = "\(db).\(quoteIdentifier(name))" let definition = try await execute("SHOW CREATE TRIGGER \(qualified)") // SHOW CREATE TRIGGER: Trigger, sql_mode, SQL Original Statement, ... guard let row = definition.rows.first, row.count > 2, !row[2].isEmpty else { throw WorkbenchTransferError.message("Missing CREATE TRIGGER for \(name)") } try write("\n-- Trigger \(qualified)\n") try write("DELIMITER $$\n") if includeDrop { try write("DROP TRIGGER IF EXISTS \(qualified)$$\n") } try write(row[2] + "$$\nDELIMITER ;\n") } } try Task.checkCancellation() // Release the read view before finalizing the export file. _ = try await execute("COMMIT") 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) } // The SQL file is complete before creating its checksum sidecar. // Sidecar failure is reported; it does not delete a completed export. do { try WorkbenchBackupIntegrity.createSidecar(for: url) progress = 1 status = "Export complete and SHA-256 verified: \(tables.count) tables, \(views.count) views, \(transferredRows.formatted()) rows" } catch { progress = 1 status = "SQL export complete, but checksum creation failed: \(error.localizedDescription)" } } catch is CancellationError { 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 progress = 0 status = "Reading SQL file…" defer { isWorking = false operationTask = nil } do { // Refuse a corrupted backup when a checksum sidecar is present. // Third-party SQL files without a sidecar remain importable. if WorkbenchBackupIntegrity.hasSidecar(for: url) { status = "Verifying SQL backup checksum…" try await Task.detached(priority: .utility) { try WorkbenchBackupIntegrity.verify(url) }.value } try Task.checkCancellation() let fileSize = (try FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value ?? 0 var statementNumber = 0 var lastLine = 1 let reader = try WorkbenchStreamingSQLReader(url: url) defer { reader.close() } while let entry = try reader.nextStatement() { try Task.checkCancellation() statementNumber += 1 lastLine = entry.line status = "Importing statement \(statementNumber) · line \(lastLine)" do { _ = try await execute(entry.sql) } catch { throw WorkbenchTransferError.message( "Statement \(statementNumber), line \(lastLine): \(error.localizedDescription)" ) } if statementNumber % 10 == 0 || reader.bytesRead >= fileSize { progress = fileSize > 0 ? min(1, Double(reader.bytesRead) / Double(fileSize)) : 1 } } progress = 1 status = "Import complete: \(statementNumber) 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 // Supports MySQL DELIMITER directives, quoted text, executable /*! */ // comments, and routine bodies. Parsing does not execute SQL. enum WorkbenchSQLImportParser { static func parse(_ sql: String) throws -> [String] { let chars = Array(sql) var statements: [String] = [] var current = "" var delimiter = ";" var quote: Character? = nil var lineComment = false var blockComment = false var escaped = false var lineStart = true var i = 0 func matches(_ value: String, at index: Int) -> Bool { let pattern = Array(value) guard index + pattern.count <= chars.count else { return false } return Array(chars[index..<(index + pattern.count)]) == pattern } func emit() { let value = current.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { statements.append(value) } current = "" } while i < chars.count { let ch = chars[i] let next: Character? = i + 1 < chars.count ? chars[i + 1] : nil // DELIMITER is a client-side directive, never sent to MySQL. if lineStart && quote == nil && !lineComment && !blockComment { var j = i while j < chars.count && (chars[j] == " " || chars[j] == "\t") { j += 1 } if j + 9 <= chars.count && String(chars[j.. URL { URL(fileURLWithPath: sqlURL.path + ".sha256") } static func hasSidecar(for sqlURL: URL) -> Bool { FileManager.default.fileExists(atPath: sidecar(for: sqlURL).path) } static func digest(_ url: URL) throws -> String { let file = try FileHandle(forReadingFrom: url) defer { try? file.close() } var hasher = SHA256() while true { let chunk = try file.read(upToCount: 1024 * 1024) ?? Data() if chunk.isEmpty { break } hasher.update(data: chunk) } return hasher.finalize().map { String(format: "%02x", $0) }.joined() } static func createSidecar(for sqlURL: URL) throws { let value = try digest(sqlURL) // Standard sha256sum-compatible format, with filename rather than path. let line = value + " " + sqlURL.lastPathComponent + "\n" try line.write(to: sidecar(for: sqlURL), atomically: true, encoding: .utf8) } static func verify(_ sqlURL: URL) throws { let checksumURL = sidecar(for: sqlURL) guard FileManager.default.fileExists(atPath: checksumURL.path) else { throw WorkbenchTransferError.message("No checksum file found: \(checksumURL.lastPathComponent)") } let contents = try String(contentsOf: checksumURL, encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines) let expected = String(contents.prefix(64)).lowercased() guard expected.count == 64, expected.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else { throw WorkbenchTransferError.message("Invalid SHA-256 checksum file") } let actual = try digest(sqlURL) guard actual == expected else { throw WorkbenchTransferError.message("SHA-256 mismatch: backup contents have changed") } } } // MARK: - Phase 11: bounded-memory SQL dump reader // Keeps only the current SQL statement in memory. A single very large // INSERT still requires enough memory to hold that statement. final class WorkbenchStreamingSQLReader { struct Entry { let sql: String; let line: Int } private let handle: FileHandle private var bytes = Data() private var reachedEOF = false private(set) var bytesRead: Int64 = 0 private var lineNumber = 0 private var statementStart = 1 private var statement = "" private var delimiter = ";" private var quote: Character? private var escaped = false private var blockComment = false private var lineComment = false private var pending: [Entry] = [] init(url: URL) throws { handle = try FileHandle(forReadingFrom: url) } func close() { try? handle.close() } func nextStatement() throws -> Entry? { while pending.isEmpty { guard let line = try readLine() else { if quote != nil || blockComment { throw WorkbenchTransferError.message("Unterminated quote or comment near line \(lineNumber)") } let tail = statement.trimmingCharacters(in: .whitespacesAndNewlines) statement = "" if !tail.isEmpty { return Entry(sql: tail, line: statementStart) } return nil } lineNumber += 1 try consume(line) } return pending.removeFirst() } private func readLine() throws -> String? { while true { if let end = bytes.firstIndex(of: 10) { let data = bytes.prefix(through: end) bytes.removeSubrange(...end) guard let line = String(data: data, encoding: .utf8) else { throw WorkbenchTransferError.message("Invalid UTF-8 at line \(lineNumber + 1)") } return line } if reachedEOF { guard !bytes.isEmpty else { return nil } let data = bytes bytes.removeAll(keepingCapacity: true) guard let line = String(data: data, encoding: .utf8) else { throw WorkbenchTransferError.message("Invalid UTF-8 at end of SQL file") } return line } let chunk = try handle.read(upToCount: 64 * 1024) ?? Data() bytesRead += Int64(chunk.count) if chunk.isEmpty { reachedEOF = true } else { bytes.append(chunk) } } } private func consume(_ line: String) throws { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if quote == nil && !blockComment && statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && trimmed.uppercased().hasPrefix("DELIMITER ") { let token = String(trimmed.dropFirst(10)).trimmingCharacters(in: .whitespaces) guard !token.isEmpty && !token.contains(where: { $0.isWhitespace }) else { throw WorkbenchTransferError.message("Invalid DELIMITER at line \(lineNumber)") } delimiter = token return } let chars = Array(line) let delim = Array(delimiter) var i = 0 lineComment = false while i < chars.count { let ch = chars[i] let next: Character? = i + 1 < chars.count ? chars[i + 1] : nil if lineComment { break } if blockComment { statement.append(ch) if ch == "*" && next == "/" { statement.append("/"); i += 2; blockComment = false } else { i += 1 } continue } if let q = quote { statement.append(ch) if escaped { escaped = false } else if ch == "\\" && q != "`" { escaped = true } else if ch == q { if next == q { statement.append(ch); i += 1 } else { quote = nil } } i += 1 continue } if ch == "'" || ch == "\"" || ch == "`" { quote = ch; statement.append(ch); i += 1; continue } if ch == "#" || (ch == "-" && next == "-" && (i + 2 >= chars.count || chars[i + 2].isWhitespace)) { lineComment = true; break } if ch == "/" && next == "*" { statement.append("/*"); blockComment = true; i += 2; continue } if i + delim.count <= chars.count && Array(chars[i..<(i + delim.count)]) == delim { let sql = statement.trimmingCharacters(in: .whitespacesAndNewlines) if !sql.isEmpty { pending.append(Entry(sql: sql, line: statementStart)) } statement = ""; i += delim.count; statementStart = lineNumber; continue } if statement.isEmpty && !ch.isWhitespace { statementStart = lineNumber } statement.append(ch) i += 1 } if !statement.isEmpty { statement.append("\n") } } }