Files
SQLWorkbench/WorkbenchReplacement/MainView.swift
T
2026-10-16 21:40:07 +01:00

2054 lines
59 KiB
Swift

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