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 editing = ConnectionProfile() @State private var editingPassword = "" @State private var expandedSchemas: Set = [] @State private var outputExpanded = true var body: some View { VStack(spacing: 0) { mainToolbar Rectangle() .fill(WB.border) .frame(height: 1) HSplitView { navigator .frame( minWidth: 210, idealWidth: 265, maxWidth: 420 ) workspace .frame(minWidth: 600) } statusBar } .frame(minWidth: 1100, minHeight: 720) .preferredColorScheme(.light) .background(Color(nsColor: .windowBackgroundColor)) .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(.black) 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( "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(.black) .frame(minWidth: 65) } .buttonStyle(.plain) .help(title) } // MARK: - Navigator private var navigator: some View { VStack(spacing: 0) { sectionHeader("NAVIGATOR") 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) } .background(WB.sidebar) } private func schemaNode(_ db: String) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 5) { Button { toggleSchema(db) } label: { Image(systemName: expandedSchemas.contains(db) ? "chevron.down" : "chevron.right" ) .font(.system(size: 10)) .frame(width: 15) } .buttonStyle(.plain) Image(systemName: "cylinder.split.1x2") .foregroundStyle(.blue) Text(db) .font(.system(size: 12)) .fontWeight( model.selectedDatabase == db ? .bold : .regular ) Spacer() } .padding(.horizontal, 10) .padding(.vertical, 5) .background( model.selectedDatabase == db ? Color.blue.opacity(0.13) : Color.clear ) .contentShape(Rectangle()) .onTapGesture(count: 2) { model.selectedDatabase = db expandedSchemas.insert(db) 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) .padding(.leading, 24) } } } private func toggleSchema(_ db: String) { if expandedSchemas.contains(db) { expandedSchemas.remove(db) } else { expandedSchemas.insert(db) if model.schemaTables[db] == nil { model.loadTables(database: db) } } } private func schemaChildren(_ db: String) -> some View { VStack(alignment: .leading, spacing: 2) { DisclosureGroup { if let tables = model.schemaTables[db] { ForEach(tables, id: \.self) { table in HStack(spacing: 6) { Image(systemName: "tablecells") .foregroundStyle(.secondary) .frame(width: 16) Text(table) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 4) .padding(.vertical, 3) .contentShape(Rectangle()) .onTapGesture(count: 2) { model.selectTable( database: db, table: table ) } .contextMenu { Button("Select First 100 Rows") { model.selectTable( database: db, table: table ) } } } } else { Button("Load Tables") { model.loadTables(database: db) } .frame(maxWidth: .infinity, alignment: .leading) .buttonStyle(.borderless) } } label: { Label("Tables", systemImage: "folder") .frame(maxWidth: .infinity, alignment: .leading) } Label("Views", systemImage: "folder") .frame(maxWidth: .infinity, alignment: .leading) Label("Stored Procedures", systemImage: "folder") .frame(maxWidth: .infinity, alignment: .leading) Label("Functions", systemImage: "folder") .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) .font(.system(size: 12)) .padding(.vertical, 3) } // MARK: - Workspace private var workspace: some View { VStack(spacing: 0) { queryTabBar Divider() queryToolbar Divider() VSplitView { sqlEditor .frame(minHeight: 230) bottomWorkspace .frame(minHeight: 230) } } } private var queryTabBar: some View { HStack(spacing: 0) { HStack(spacing: 8) { Image(systemName: "doc.text") .foregroundStyle(.blue) Text("Query 1") .font(.system(size: 12, weight: .semibold)) Image(systemName: "xmark") .font(.system(size: 9)) .foregroundStyle(.secondary) } .padding(.horizontal, 10) .frame(height: 29) .background(Color(nsColor: .textBackgroundColor)) Spacer() } .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) { lineNumbers 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 { ScrollView([.horizontal, .vertical]) { Grid( alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0 ) { GridRow { ForEach( model.result.columns.indices, id: \.self ) { column in Text(model.result.columns[column]) .fontWeight(.semibold) .frame( width: 170, alignment: .leading ) .padding(7) .background(WB.header) } } ForEach( model.result.rows.indices, id: \.self ) { row in GridRow { ForEach( model.result.columns.indices, id: \.self ) { column in Text( column < model.result.rows[row].count ? model.result.rows[row][column] : "" ) .lineLimit(1) .frame( width: 170, alignment: .leading ) .padding(7) .background( row.isMultiple(of: 2) ? Color(nsColor: .textBackgroundColor) : WB.sidebar ) } } } } .font(.system(size: 12, design: .monospaced)) } } 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: 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: 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) } }