The updated parser identifies the complete procedure as a single statement before passing it to the native MySQL connection. I tested the parser with Swift, including a stored procedure containing multiple SQL statements. The parser test passed. However, I haven’t compiled the complete macOS application or tested importing against a live MySQL server.
2226 lines
70 KiB
Swift
2226 lines
70 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 = 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<Void, Never>?
|
|
@State private var showingImportConfirmation = false
|
|
@State private var pendingImportURL: URL?
|
|
@State private var availableTables: [String] = []
|
|
@State private var selectedTables: Set<String> = []
|
|
@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<String> = []
|
|
@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..<min(j + 9, chars.count)]).uppercased() == "DELIMITER" &&
|
|
(j + 9 == chars.count || chars[j + 9].isWhitespace) {
|
|
let pending = current.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !pending.isEmpty {
|
|
throw WorkbenchTransferError.message("DELIMITER directive encountered inside an unfinished statement")
|
|
}
|
|
var k = j + 9
|
|
while k < chars.count && chars[k] != "\n" && chars[k] != "\r" { k += 1 }
|
|
let token = String(chars[(j + 9)..<k]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !token.isEmpty && !token.contains(where: { $0.isWhitespace }) else {
|
|
throw WorkbenchTransferError.message("Invalid DELIMITER directive")
|
|
}
|
|
delimiter = token
|
|
i = k
|
|
lineStart = true
|
|
continue
|
|
}
|
|
}
|
|
|
|
if lineComment {
|
|
if ch == "\n" || ch == "\r" {
|
|
lineComment = false
|
|
current.append("\n")
|
|
lineStart = true
|
|
}
|
|
i += 1
|
|
continue
|
|
}
|
|
if blockComment {
|
|
// Preserve MySQL executable and optimizer-hint comments in SQL.
|
|
if ch == "*" && next == "/" {
|
|
if current.hasSuffix("*") { /* no-op */ }
|
|
current.append("*/")
|
|
blockComment = false
|
|
i += 2
|
|
} else {
|
|
current.append(ch)
|
|
i += 1
|
|
}
|
|
continue
|
|
}
|
|
if let active = quote {
|
|
current.append(ch)
|
|
if escaped { escaped = false }
|
|
else if ch == "\\" && active != "`" { escaped = true }
|
|
else if ch == active {
|
|
if next == active {
|
|
current.append(next!)
|
|
i += 1
|
|
} else { quote = nil }
|
|
}
|
|
i += 1
|
|
continue
|
|
}
|
|
if ch == "'" || ch == "\"" || ch == "`" {
|
|
quote = ch
|
|
current.append(ch)
|
|
lineStart = false
|
|
i += 1
|
|
continue
|
|
}
|
|
if ch == "#" || (ch == "-" && next == "-" &&
|
|
(i + 2 == chars.count || chars[i + 2].isWhitespace)) {
|
|
lineComment = true
|
|
i += ch == "#" ? 1 : 2
|
|
continue
|
|
}
|
|
if ch == "/" && next == "*" {
|
|
// Keep executable comments; ordinary comments may also contain
|
|
// SQL hints, so preserve all block comments verbatim.
|
|
current.append("/*")
|
|
blockComment = true
|
|
i += 2
|
|
continue
|
|
}
|
|
if matches(delimiter, at: i) {
|
|
emit()
|
|
i += delimiter.count
|
|
lineStart = false
|
|
continue
|
|
}
|
|
current.append(ch)
|
|
if ch == "\n" || ch == "\r" { lineStart = true }
|
|
else if !ch.isWhitespace { lineStart = false }
|
|
i += 1
|
|
}
|
|
guard quote == nil && !blockComment else {
|
|
throw WorkbenchTransferError.message("SQL file ends inside a quoted string or block comment")
|
|
}
|
|
guard delimiter == ";" else {
|
|
throw WorkbenchTransferError.message("SQL file ends without resetting DELIMITER to ;")
|
|
}
|
|
emit()
|
|
return statements
|
|
}
|
|
}
|