import SwiftUI import AppKit // 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 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 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) 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("Exports base tables and selected views. Triggers, routines, consistent snapshots and guaranteed row ordering are not yet 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) } } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) 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 || (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 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: statement == "SHOW DATABASES" ? "" : database, password: PasswordStore.read(profile.id), 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 else { return } isWorking = true progress = 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) isWorking = false operationTask = nil } do { 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: ", ") 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(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() 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) tables, \(views.count) views" } 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 { 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 // 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..