Previously, the progress bar updated only after completing an entire table. For large tables such as LIBARC, this could make the application appear to have stopped responding. Phase 10 updates the interface while rows are being exported.
4791 lines
189 KiB
Swift
4791 lines
189 KiB
Swift
|
||
import SwiftUI
|
||
import Charts
|
||
import AppKit
|
||
import CryptoKit
|
||
import UniformTypeIdentifiers
|
||
|
||
// 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 selectedSQL = ""
|
||
@State private var resultFilter = ""
|
||
@State private var resultSortColumn = 0
|
||
@State private var resultSortAscending = true
|
||
@State private var hiddenResultColumns: Set<Int> = []
|
||
@State private var resultPage = 0
|
||
private let resultPageSize = 1_000
|
||
@State private var showingEditor = false
|
||
@State private var showingDatabaseTransfer = false
|
||
@State private var schemaEditorTarget: SchemaEditorTarget? = nil
|
||
@State private var showingQueryPlan = false
|
||
@State private var showingQueryHistory = false
|
||
@State private var showingSavedQueries = false
|
||
@State private var showingSaveQuery = false
|
||
@State private var newSavedQueryName = ""
|
||
|
||
@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? = .dashboard
|
||
|
||
|
||
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: $showingQueryPlan) {
|
||
WorkbenchExplainView(model: model, statement: selectedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.sql : selectedSQL)
|
||
}
|
||
.sheet(isPresented: $showingQueryHistory) {
|
||
SQLHistorySheet(model: model)
|
||
}
|
||
.sheet(isPresented: $showingSavedQueries) {
|
||
SavedSQLQueriesSheet(model: model)
|
||
}
|
||
.alert("Save SQL Query", isPresented: $showingSaveQuery) {
|
||
TextField("Query name", text: $newSavedQueryName)
|
||
Button("Save") { model.saveCurrentQuery(named: newSavedQueryName) }
|
||
Button("Cancel", role: .cancel) { }
|
||
} message: {
|
||
Text("Save the current SQL in your local query library.")
|
||
}
|
||
.sheet(isPresented: $showingDatabaseTransfer) {
|
||
WorkbenchDatabaseTransferView(model: model)
|
||
}
|
||
.sheet(item: $schemaEditorTarget, onDismiss: {
|
||
if !model.selectedDatabase.isEmpty {
|
||
model.loadTables(database: model.selectedDatabase)
|
||
}
|
||
}) { target in
|
||
WorkbenchSchemaEditorView(profile: model.selected, target: target)
|
||
}
|
||
.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(nsImage: NSImage(named: "WorkbenchBrand") ?? NSImage(contentsOfFile: Bundle.main.path(forResource: "WorkbenchBrand", ofType: "png") ?? "") ?? NSImage())
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 30, height: 30)
|
||
|
||
Text("MySQL Workbench")
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(.primary)
|
||
|
||
Rectangle()
|
||
.fill(WB.border)
|
||
.frame(width: 1, height: 30)
|
||
|
||
toolbarButton("Dashboard", icon: "chart.xyaxis.line") {
|
||
navigatorMode = 0
|
||
administrationPage = .dashboard
|
||
}
|
||
|
||
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,
|
||
.serverCounters,
|
||
.clientConnections,
|
||
.users,
|
||
.variables,
|
||
.serverLogs
|
||
]
|
||
)
|
||
|
||
administrationSection(
|
||
"PERFORMANCE",
|
||
pages: [
|
||
.dashboard,
|
||
.performanceReports,
|
||
.slowQueries,
|
||
.tableIO,
|
||
.innodbMetrics,
|
||
.innodbLocks,
|
||
.connectionStatistics,
|
||
.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)
|
||
}
|
||
Button("Create Table…") {
|
||
schemaEditorTarget = SchemaEditorTarget(database: db, table: nil)
|
||
}
|
||
}
|
||
|
||
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
|
||
)
|
||
}
|
||
Button("Edit Table Schema…") {
|
||
schemaEditorTarget = SchemaEditorTarget(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.runSQL(statement: selectedSQL)
|
||
} label: {
|
||
Label("Run Selection", systemImage: "selection.pin.in.out")
|
||
}
|
||
.disabled(selectedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
.help("Execute selected SQL without modifying the editor")
|
||
|
||
Button {
|
||
showingQueryPlan = true
|
||
} label: {
|
||
Label("Explain", systemImage: "chart.bar.doc.horizontal")
|
||
}
|
||
.disabled(model.isRunning || model.selected == nil)
|
||
.help("Inspect the optimizer plan for the selected SQL or editor contents")
|
||
|
||
Button {
|
||
model.formatSQL()
|
||
} label: {
|
||
Image(systemName: "text.alignleft")
|
||
}
|
||
.help("Format SQL")
|
||
|
||
Divider()
|
||
.frame(height: 17)
|
||
|
||
Button {
|
||
showingQueryHistory = true
|
||
} label: {
|
||
Label("History", systemImage: "clock.arrow.circlepath")
|
||
}
|
||
.help("Query execution history")
|
||
|
||
Button {
|
||
showingSavedQueries = true
|
||
} label: {
|
||
Label("Saved", systemImage: "bookmark")
|
||
}
|
||
.help("Open saved SQL queries")
|
||
|
||
Button {
|
||
newSavedQueryName = ""
|
||
showingSaveQuery = true
|
||
} label: {
|
||
Image(systemName: "bookmark.fill")
|
||
}
|
||
.help("Save current SQL query")
|
||
|
||
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 {
|
||
VStack(spacing: 0) {
|
||
if model.status == "Error" && !model.result.message.isEmpty {
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.red)
|
||
Text(model.result.message)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(8)
|
||
.background(Color.red.opacity(0.09))
|
||
}
|
||
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, selectedSQL: $selectedSQL, completions: Array(Set(model.schemaTables.values.flatMap { $0 } + model.schemaViews.values.flatMap { $0 } + (model.schemaColumns[model.selectedDatabase] ?? []))).sorted())
|
||
}
|
||
.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()
|
||
|
||
if !model.result.columns.isEmpty {
|
||
TextField("Filter rows", text: $resultFilter)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 170)
|
||
Picker("Sort", selection: $resultSortColumn) {
|
||
ForEach(model.result.columns.indices, id: \.self) { index in
|
||
Text(model.result.columns[index]).tag(index)
|
||
}
|
||
}
|
||
.frame(width: 155)
|
||
Button(resultSortAscending ? "Ascending" : "Descending") {
|
||
resultSortAscending.toggle()
|
||
}
|
||
Menu("Columns") {
|
||
Button("Show All Columns") { hiddenResultColumns.removeAll() }
|
||
Divider()
|
||
ForEach(model.result.columns.indices, id: \.self) { index in
|
||
Button {
|
||
if hiddenResultColumns.contains(index) {
|
||
hiddenResultColumns.remove(index)
|
||
} else {
|
||
hiddenResultColumns.insert(index)
|
||
}
|
||
} label: {
|
||
Label(model.result.columns[index], systemImage: hiddenResultColumns.contains(index) ? "square" : "checkmark.square")
|
||
}
|
||
}
|
||
}
|
||
Button("Export CSV") { exportResultCSV() }
|
||
}
|
||
Text(model.queryDurationText)
|
||
.foregroundStyle(.secondary)
|
||
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
|
||
HStack(spacing: 12) {
|
||
Text("\(displayedRows.count) matching rows · showing \(min(resultPage * resultPageSize + 1, displayedRows.count))–\(min((resultPage + 1) * resultPageSize, displayedRows.count))")
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button("Previous") { resultPage = max(0, resultPage - 1) }
|
||
.disabled(resultPage == 0)
|
||
Text("Page \(resultPage + 1) / \(max(1, (displayedRows.count + resultPageSize - 1) / resultPageSize))")
|
||
Button("Next") { resultPage += 1 }
|
||
.disabled((resultPage + 1) * resultPageSize >= displayedRows.count)
|
||
}
|
||
.font(.system(size: 11))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 5)
|
||
.background(WB.header)
|
||
}
|
||
}
|
||
.onChange(of: resultFilter) { _, _ in resultPage = 0 }
|
||
.onChange(of: resultSortColumn) { _, _ in resultPage = 0 }
|
||
.onChange(of: resultSortAscending) { _, _ in resultPage = 0 }
|
||
.onChange(of: model.result.columns) { _, _ in
|
||
resultPage = 0
|
||
hiddenResultColumns.removeAll()
|
||
}
|
||
}
|
||
|
||
private var resultGrid: some View {
|
||
let rows = displayedRows
|
||
let start = min(resultPage * resultPageSize, rows.count)
|
||
let end = min(start + resultPageSize, rows.count)
|
||
return NativeResultGrid(columns: model.result.columns, rows: Array(rows[start..<end]), hiddenColumnIndices: hiddenResultColumns)
|
||
}
|
||
|
||
private var displayedRows: [[String]] {
|
||
let filtered = resultFilter.isEmpty ? model.result.rows : model.result.rows.filter { row in
|
||
row.contains { $0.localizedCaseInsensitiveContains(resultFilter) }
|
||
}
|
||
guard !model.result.columns.isEmpty else { return filtered }
|
||
let index = min(resultSortColumn, model.result.columns.count - 1)
|
||
return filtered.sorted { left, right in
|
||
let a = index < left.count ? left[index] : ""
|
||
let b = index < right.count ? right[index] : ""
|
||
let comparison = a.localizedStandardCompare(b)
|
||
return resultSortAscending ? comparison == .orderedAscending : comparison == .orderedDescending
|
||
}
|
||
}
|
||
|
||
private func exportResultCSV() {
|
||
let panel = NSSavePanel()
|
||
panel.nameFieldStringValue = "query-results.csv"
|
||
panel.allowedContentTypes = [.commaSeparatedText]
|
||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
|
||
// Write one CSV record at a time instead of constructing the entire
|
||
// document as a single String. Only already-loaded result rows are saved.
|
||
func csvField(_ value: String) -> String {
|
||
"\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
||
}
|
||
func csvRecord(_ values: [String]) -> Data {
|
||
Data((values.map(csvField).joined(separator: ",") + "\r\n").utf8)
|
||
}
|
||
|
||
let rows = displayedRows
|
||
let tempURL = url.deletingLastPathComponent()
|
||
.appendingPathComponent(".\(UUID().uuidString).csv.tmp")
|
||
do {
|
||
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
|
||
throw CocoaError(.fileWriteUnknown)
|
||
}
|
||
let handle = try FileHandle(forWritingTo: tempURL)
|
||
defer { try? handle.close() }
|
||
try handle.write(contentsOf: Data([0xEF, 0xBB, 0xBF]))
|
||
try handle.write(contentsOf: csvRecord(model.result.columns))
|
||
for row in rows {
|
||
try handle.write(contentsOf: csvRecord(row))
|
||
}
|
||
try handle.synchronize()
|
||
if FileManager.default.fileExists(atPath: url.path) {
|
||
_ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)
|
||
} else {
|
||
try FileManager.default.moveItem(at: tempURL, to: url)
|
||
}
|
||
model.status = "Exported \(rows.count) rows to \(url.lastPathComponent)"
|
||
} catch {
|
||
try? FileManager.default.removeItem(at: tempURL)
|
||
model.status = "CSV export failed: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
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: - Phase 17: Query History and Saved Queries
|
||
|
||
private struct SQLHistorySheet: View {
|
||
@ObservedObject var model: DatabaseModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var filter = ""
|
||
|
||
private var entries: [SQLHistoryEntry] {
|
||
model.queryHistory.filter {
|
||
filter.isEmpty || $0.sql.localizedCaseInsensitiveContains(filter) ||
|
||
$0.connectionName.localizedCaseInsensitiveContains(filter)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("Query History").font(.title2).bold()
|
||
Spacer()
|
||
Button("Clear History") { model.clearQueryHistory() }
|
||
.disabled(model.queryHistory.isEmpty)
|
||
Button("Done") { dismiss() }
|
||
}
|
||
TextField("Filter SQL or connection", text: $filter)
|
||
.textFieldStyle(.roundedBorder)
|
||
List(entries) { entry in
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
HStack {
|
||
Image(systemName: entry.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||
.foregroundStyle(entry.succeeded ? .green : .red)
|
||
Text(entry.executedAt, style: .date)
|
||
Text(entry.executedAt, style: .time)
|
||
Text(entry.connectionName).foregroundStyle(.secondary)
|
||
Text(entry.database).foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button("Open in New Tab") {
|
||
model.newQueryTab()
|
||
model.sql = entry.sql
|
||
model.saveActiveQuery()
|
||
dismiss()
|
||
}
|
||
}
|
||
Text(entry.sql).font(.system(.body, design: .monospaced))
|
||
.lineLimit(3).textSelection(.enabled)
|
||
Text(entry.detail).font(.caption).foregroundStyle(.secondary)
|
||
}.padding(.vertical, 4)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 740, minHeight: 460)
|
||
}
|
||
}
|
||
|
||
private struct SavedSQLQueriesSheet: View {
|
||
@ObservedObject var model: DatabaseModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var filter = ""
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("Saved SQL Queries").font(.title2).bold()
|
||
Spacer()
|
||
Button("Done") { dismiss() }
|
||
}
|
||
TextField("Filter saved queries", text: $filter)
|
||
.textFieldStyle(.roundedBorder)
|
||
List(model.savedQueries.filter {
|
||
filter.isEmpty || $0.name.localizedCaseInsensitiveContains(filter) ||
|
||
$0.sql.localizedCaseInsensitiveContains(filter)
|
||
}) { entry in
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(entry.name).font(.headline)
|
||
Text(entry.sql).font(.system(.caption, design: .monospaced))
|
||
.lineLimit(2).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button("Open") {
|
||
model.openSavedQuery(entry)
|
||
dismiss()
|
||
}
|
||
Button(role: .destructive) {
|
||
model.deleteSavedQuery(entry.id)
|
||
} label: {
|
||
Image(systemName: "trash")
|
||
}
|
||
}.padding(.vertical, 5)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 650, minHeight: 420)
|
||
}
|
||
}
|
||
|
||
// MARK: - Native SQL Text Editor
|
||
|
||
struct SQLTextView: NSViewRepresentable {
|
||
@Binding var text: String
|
||
@Binding var selectedSQL: String
|
||
var completions: [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
|
||
}
|
||
|
||
private let completionWords = [
|
||
"SELECT", "FROM", "WHERE", "INSERT INTO", "UPDATE", "DELETE FROM",
|
||
"CREATE TABLE", "ALTER TABLE", "DROP TABLE", "JOIN", "LEFT JOIN",
|
||
"INNER JOIN", "GROUP BY", "ORDER BY", "HAVING", "LIMIT",
|
||
"DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX", "AS",
|
||
"AND", "OR", "NOT", "NULL", "IS NULL", "LIKE", "IN",
|
||
"BETWEEN", "EXISTS", "UNION", "WITH", "CASE", "WHEN", "THEN",
|
||
"ELSE", "END", "SHOW TABLES", "SHOW DATABASES", "DESCRIBE",
|
||
"EXPLAIN", "PRIMARY KEY", "FOREIGN KEY", "INDEX", "VALUES"
|
||
]
|
||
|
||
func textView(_ textView: NSTextView, completions words: [String],
|
||
forPartialWordRange charRange: NSRange,
|
||
indexOfSelectedItem index: UnsafeMutablePointer<Int>?) -> [String] {
|
||
let text = textView.string as NSString
|
||
guard charRange.location != NSNotFound,
|
||
NSMaxRange(charRange) <= text.length else { return [] }
|
||
let prefix = text.substring(with: charRange)
|
||
guard !prefix.isEmpty else { return [] }
|
||
return Array(Set(completionWords + parent.completions))
|
||
.filter { $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil }
|
||
.sorted()
|
||
}
|
||
|
||
func textViewDidChangeSelection(_ notification: Notification) {
|
||
guard let editor = notification.object as? NSTextView else { return }
|
||
let range = editor.selectedRange()
|
||
let value = editor.string as NSString
|
||
let selection = range.length > 0 && NSMaxRange(range) <= value.length
|
||
? value.substring(with: range) : ""
|
||
if parent.selectedSQL != selection { parent.selectedSQL = selection }
|
||
}
|
||
|
||
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
|
||
|
||
|
||
// MARK: - Phase 23: Locally stored EXPLAIN snapshots
|
||
|
||
private struct WorkbenchPlanSnapshot: Identifiable, Codable {
|
||
var id = UUID()
|
||
var capturedAt = Date()
|
||
var connectionName: String
|
||
var database: String
|
||
var statement: String
|
||
var format: String
|
||
var columnNames: [String]
|
||
var resultRows: [[String]]
|
||
var elapsedSeconds: Double
|
||
|
||
var label: String {
|
||
"\(capturedAt.formatted(date: .abbreviated, time: .shortened)) · \(format) · \(database)"
|
||
}
|
||
}
|
||
|
||
private enum WorkbenchPlanArchive {
|
||
private static let key = "WorkbenchReplacement.explainSnapshots.v1"
|
||
|
||
static func load() -> [WorkbenchPlanSnapshot] {
|
||
guard let data = UserDefaults.standard.data(forKey: key),
|
||
let snapshots = try? JSONDecoder().decode([WorkbenchPlanSnapshot].self, from: data)
|
||
else { return [] }
|
||
return snapshots
|
||
}
|
||
|
||
static func save(_ snapshots: [WorkbenchPlanSnapshot]) {
|
||
// Cap both snapshot count and size so EXPLAIN JSON cannot grow preferences without bound.
|
||
let trimmed = Array(snapshots.prefix(40))
|
||
if let data = try? JSONEncoder().encode(trimmed), data.count <= 2_000_000 {
|
||
UserDefaults.standard.set(data, forKey: key)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Phase 21: Native MySQL query plan inspector
|
||
|
||
private struct WorkbenchExplainView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@ObservedObject var model: DatabaseModel
|
||
let statement: String
|
||
|
||
@State private var plan = QueryResult()
|
||
@State private var errorText: String?
|
||
@State private var isLoading = false
|
||
@State private var useJSON = false
|
||
@State private var duration: TimeInterval?
|
||
@State private var snapshots: [WorkbenchPlanSnapshot] = WorkbenchPlanArchive.load()
|
||
@State private var baselineID: UUID?
|
||
@State private var showHistory = false
|
||
|
||
private var matchingSnapshots: [WorkbenchPlanSnapshot] {
|
||
snapshots.filter {
|
||
$0.statement == normalizedStatement &&
|
||
$0.database == (model.selectedDatabase.isEmpty ? (model.selected?.database ?? "") : model.selectedDatabase) &&
|
||
$0.connectionName == (model.selected?.name ?? "")
|
||
}
|
||
}
|
||
|
||
private var baseline: WorkbenchPlanSnapshot? {
|
||
matchingSnapshots.first { $0.id == baselineID }
|
||
}
|
||
|
||
private var comparisonText: String? {
|
||
guard let baseline, !plan.columns.isEmpty else { return nil }
|
||
guard baseline.format == (useJSON ? "JSON" : "Table") else {
|
||
return "Baseline format differs from the current plan. Select a matching format."
|
||
}
|
||
if useJSON {
|
||
return baseline.resultRows == plan.rows ? "JSON plan unchanged from baseline." : "JSON plan differs from baseline. Inspect the plan text for details."
|
||
}
|
||
func estimatedRows(_ columns: [String], _ rows: [[String]]) -> Int? {
|
||
guard let index = columns.firstIndex(where: { $0.lowercased() == "rows" }) else { return nil }
|
||
let values = rows.compactMap { index < $0.count ? Int($0[index]) : nil }
|
||
return values.isEmpty ? nil : values.reduce(0, +)
|
||
}
|
||
let oldEstimate = estimatedRows(baseline.columnNames, baseline.resultRows)
|
||
let newEstimate = estimatedRows(plan.columns, plan.rows)
|
||
let changed = baseline.columnNames != plan.columns || baseline.resultRows != plan.rows
|
||
var message = changed ? "Plan changed since baseline." : "Plan unchanged from baseline."
|
||
if let oldEstimate, let newEstimate {
|
||
message += " Estimated rows across steps: \(oldEstimate) → \(newEstimate)."
|
||
}
|
||
return message + " Estimates are not measured query timings."
|
||
}
|
||
|
||
// Phase 24: Compare tabular plan steps by position and table, not only
|
||
// by aggregate row estimates. EXPLAIN estimates are not runtime metrics.
|
||
private var stepComparison: [String] {
|
||
guard let baseline, !useJSON, baseline.format == "Table", !plan.columns.isEmpty else { return [] }
|
||
func value(_ columns: [String], _ row: [String], _ field: String) -> String {
|
||
guard let index = columns.firstIndex(where: { $0.caseInsensitiveCompare(field) == .orderedSame }),
|
||
index < row.count else { return "—" }
|
||
return row[index]
|
||
}
|
||
var details: [String] = []
|
||
let count = max(baseline.resultRows.count, plan.rows.count)
|
||
for index in 0..<count {
|
||
guard index < baseline.resultRows.count else {
|
||
details.append("Step \(index + 1): new optimizer step added.")
|
||
continue
|
||
}
|
||
guard index < plan.rows.count else {
|
||
details.append("Step \(index + 1): previous optimizer step removed.")
|
||
continue
|
||
}
|
||
let before = baseline.resultRows[index]
|
||
let after = plan.rows[index]
|
||
let oldTable = value(baseline.columnNames, before, "table")
|
||
let newTable = value(plan.columns, after, "table")
|
||
let label = "Step \(index + 1) (\(oldTable) → \(newTable))"
|
||
for field in ["type", "key", "possible_keys", "rows", "filtered", "Extra"] {
|
||
let oldValue = value(baseline.columnNames, before, field)
|
||
let newValue = value(plan.columns, after, field)
|
||
if oldValue != newValue {
|
||
details.append("\(label): \(field) changed from \(oldValue) to \(newValue).")
|
||
}
|
||
}
|
||
}
|
||
return details
|
||
}
|
||
|
||
// Conservative index diagnostics: surface the optimizer's own candidates,
|
||
// without suggesting unverified CREATE INDEX statements.
|
||
private var indexDiagnostics: [String] {
|
||
guard !useJSON, !plan.columns.isEmpty else { return [] }
|
||
let names = plan.columns.map { $0.lowercased() }
|
||
func value(_ row: [String], _ name: String) -> String {
|
||
guard let index = names.firstIndex(of: name), index < row.count else { return "" }
|
||
return row[index]
|
||
}
|
||
return plan.rows.compactMap { row -> String? in
|
||
let table = value(row, "table")
|
||
let access = value(row, "type").uppercased()
|
||
let selected = value(row, "key")
|
||
let possible = value(row, "possible_keys")
|
||
let label = table.isEmpty ? "Query step" : "Table \(table)"
|
||
if access == "ALL" && possible.uppercased() != "NULL" && !possible.isEmpty {
|
||
return "\(label): full scan despite candidate indexes [\(possible)]. Review predicate selectivity and statistics."
|
||
}
|
||
if access == "ALL" && (possible.isEmpty || possible.uppercased() == "NULL") {
|
||
return "\(label): full scan with no candidate index listed. Inspect WHERE/JOIN predicates before considering an index."
|
||
}
|
||
if selected.isEmpty || selected.uppercased() == "NULL" {
|
||
return "\(label): optimizer did not select an index. This may be optimal for small tables."
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private var diagnosticReport: String {
|
||
let sections = [
|
||
"SQL:", normalizedStatement,
|
||
"", "Plan observations:",
|
||
planInsights.isEmpty ? "None" : planInsights.joined(separator: "\n"),
|
||
"", "Index analysis:",
|
||
indexDiagnostics.isEmpty ? "None (or JSON format selected)" : indexDiagnostics.joined(separator: "\n"),
|
||
"", "Baseline comparison:",
|
||
comparisonText ?? "No baseline selected",
|
||
"", "Changed plan steps:",
|
||
stepComparison.isEmpty ? "None" : stepComparison.joined(separator: "\n"),
|
||
"", "These are optimizer estimates, not measured query performance."
|
||
]
|
||
return sections.joined(separator: "\n")
|
||
}
|
||
|
||
private var normalizedStatement: String {
|
||
var value = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if value.hasSuffix(";") { value.removeLast() }
|
||
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
// Advisory analysis of the tabular EXPLAIN result. These are optimizer
|
||
// estimates, not measured execution statistics or automatic index DDL.
|
||
private var planInsights: [String] {
|
||
guard !useJSON, !plan.columns.isEmpty else { return [] }
|
||
let headings = plan.columns.map { $0.lowercased() }
|
||
func field(_ row: [String], _ name: String) -> String {
|
||
guard let index = headings.firstIndex(of: name), index < row.count else { return "" }
|
||
return row[index]
|
||
}
|
||
var messages: [String] = []
|
||
for row in plan.rows {
|
||
let table = field(row, "table")
|
||
let label = table.isEmpty ? "A query step" : "Table \(table)"
|
||
let access = field(row, "type").uppercased()
|
||
let key = field(row, "key")
|
||
let estimatedRows = field(row, "rows")
|
||
let extra = field(row, "extra").lowercased()
|
||
if access == "ALL" {
|
||
messages.append("\(label): full table scan (type=ALL). Check selectivity, predicates and available indexes before considering a new index.")
|
||
} else if access == "INDEX" {
|
||
messages.append("\(label): full index scan (type=index). This is not necessarily a problem for small tables or covering indexes.")
|
||
}
|
||
if key.isEmpty || key.uppercased() == "NULL" {
|
||
messages.append("\(label): no index selected for this step. An index is not always beneficial.")
|
||
}
|
||
if extra.contains("using temporary") {
|
||
messages.append("\(label): temporary table may be used for grouping, sorting or other operations.")
|
||
}
|
||
if extra.contains("using filesort") {
|
||
messages.append("\(label): additional sort operation indicated (Using filesort).")
|
||
}
|
||
if !estimatedRows.isEmpty, estimatedRows.uppercased() != "NULL" {
|
||
messages.append("\(label): optimizer estimates \(estimatedRows) rows examined for this step.")
|
||
}
|
||
}
|
||
return messages
|
||
}
|
||
|
||
private var copyablePlan: String {
|
||
if useJSON { return plan.rows.first?.first ?? "" }
|
||
return ([plan.columns.joined(separator: "\t")] + plan.rows.map { $0.joined(separator: "\t") })
|
||
.joined(separator: "\n")
|
||
}
|
||
|
||
private var eligible: Bool {
|
||
// Deliberately restrict this inspector to SELECT queries. EXPLAIN for
|
||
// UPDATE/DELETE is supported by MySQL but not offered here to keep
|
||
// the first diagnostic release conservative.
|
||
let upper = normalizedStatement.uppercased()
|
||
return upper.hasPrefix("SELECT ") || upper.hasPrefix("SELECT\n") ||
|
||
upper.hasPrefix("WITH ") || upper.hasPrefix("WITH\n")
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Label("Query Execution Plan", systemImage: "chart.bar.doc.horizontal")
|
||
.font(.title2.bold())
|
||
Spacer()
|
||
Button("Close") { dismiss() }
|
||
}
|
||
Text("Uses MySQL EXPLAIN to inspect the optimizer's estimated plan without executing the SELECT statement itself.")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
Text(normalizedStatement)
|
||
.font(.system(.callout, design: .monospaced))
|
||
.lineLimit(4)
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(8)
|
||
.background(Color(nsColor: .controlBackgroundColor))
|
||
HStack {
|
||
Picker("Plan format", selection: $useJSON) {
|
||
Text("Table").tag(false)
|
||
Text("JSON").tag(true)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.frame(width: 240)
|
||
Button("Run EXPLAIN") { Task { await loadPlan() } }
|
||
.disabled(isLoading || !eligible || model.selected == nil)
|
||
Button("Save Snapshot") { saveSnapshot() }
|
||
.disabled(plan.columns.isEmpty || isLoading)
|
||
Button("History") { showHistory.toggle() }
|
||
if let baseline {
|
||
Text("Comparing with \(baseline.capturedAt.formatted(date: .abbreviated, time: .shortened))")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Button("Clear Baseline") { baselineID = nil }
|
||
}
|
||
Button("Copy Plan") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(copyablePlan, forType: .string)
|
||
}
|
||
.disabled(plan.columns.isEmpty)
|
||
if isLoading { ProgressView().controlSize(.small) }
|
||
if let duration {
|
||
Text(String(format: "%.3f s", duration))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
if !eligible {
|
||
ContentUnavailableView("SELECT query required", systemImage: "exclamationmark.triangle", description: Text("Select a SELECT or WITH query in the SQL editor before opening EXPLAIN."))
|
||
} else if let errorText {
|
||
ScrollView {
|
||
Text(errorText)
|
||
.foregroundStyle(.red)
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
} else if useJSON, let json = plan.rows.first?.first {
|
||
ScrollView([.vertical, .horizontal]) {
|
||
Text(json)
|
||
.font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
} else if !plan.columns.isEmpty {
|
||
NativeResultGrid(columns: plan.columns, rows: plan.rows)
|
||
} else {
|
||
ContentUnavailableView("No plan loaded", systemImage: "chart.bar.doc.horizontal")
|
||
}
|
||
if showHistory {
|
||
DisclosureGroup("Saved plans for this query (\(matchingSnapshots.count))", isExpanded: $showHistory) {
|
||
if matchingSnapshots.isEmpty {
|
||
Text("No saved plans for this connection, schema and SQL statement.")
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(matchingSnapshots) { snapshot in
|
||
HStack {
|
||
Text(snapshot.label)
|
||
.font(.callout)
|
||
Spacer()
|
||
Button("Compare") { baselineID = snapshot.id }
|
||
Button("Delete", role: .destructive) {
|
||
snapshots.removeAll { $0.id == snapshot.id }
|
||
if baselineID == snapshot.id { baselineID = nil }
|
||
WorkbenchPlanArchive.save(snapshots)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxHeight: 130)
|
||
}
|
||
}
|
||
}
|
||
if let comparisonText {
|
||
Label(comparisonText, systemImage: "arrow.left.arrow.right")
|
||
.font(.callout)
|
||
.textSelection(.enabled)
|
||
}
|
||
if !plan.columns.isEmpty {
|
||
HStack {
|
||
Label("Index analysis", systemImage: "magnifyingglass")
|
||
.font(.headline)
|
||
Spacer()
|
||
Button("Copy Diagnostics") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(diagnosticReport, forType: .string)
|
||
}
|
||
}
|
||
if !indexDiagnostics.isEmpty {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(Array(indexDiagnostics.enumerated()), id: \.offset) { _, note in
|
||
Text(note)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.textSelection(.enabled)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxHeight: 100)
|
||
} else {
|
||
Text("No additional index warnings for this plan format.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if !stepComparison.isEmpty {
|
||
DisclosureGroup("Changed plan steps (\(stepComparison.count))") {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(Array(stepComparison.enumerated()), id: \.offset) { _, change in
|
||
Text(change)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.textSelection(.enabled)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxHeight: 130)
|
||
}
|
||
}
|
||
if !planInsights.isEmpty {
|
||
DisclosureGroup("Plan observations (\(planInsights.count))") {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(Array(planInsights.enumerated()), id: \.offset) { _, observation in
|
||
Label(observation, systemImage: "info.circle")
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
.font(.callout)
|
||
.padding(.vertical, 6)
|
||
}
|
||
.frame(maxHeight: 160)
|
||
}
|
||
}
|
||
Text("EXPLAIN shows estimates, not actual query execution times. Observations are heuristics, not index recommendations. Confirm with representative data before changing production indexes.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(16)
|
||
.frame(minWidth: 760, minHeight: 490)
|
||
.onChange(of: useJSON) { _, _ in
|
||
plan = QueryResult()
|
||
errorText = nil
|
||
Task { await loadPlan() }
|
||
}
|
||
.task { await loadPlan() }
|
||
}
|
||
|
||
@MainActor
|
||
private func saveSnapshot() {
|
||
guard let profile = model.selected, !plan.columns.isEmpty else { return }
|
||
let database = model.selectedDatabase.isEmpty ? profile.database : model.selectedDatabase
|
||
let snapshot = WorkbenchPlanSnapshot(
|
||
connectionName: profile.name,
|
||
database: database,
|
||
statement: normalizedStatement,
|
||
format: useJSON ? "JSON" : "Table",
|
||
columnNames: plan.columns,
|
||
resultRows: plan.rows,
|
||
elapsedSeconds: duration ?? 0
|
||
)
|
||
snapshots.insert(snapshot, at: 0)
|
||
WorkbenchPlanArchive.save(snapshots)
|
||
showHistory = true
|
||
}
|
||
|
||
@MainActor
|
||
private func loadPlan() async {
|
||
guard eligible, let profile = model.selected, !isLoading else { return }
|
||
isLoading = true
|
||
errorText = nil
|
||
plan = QueryResult()
|
||
let started = Date()
|
||
defer {
|
||
duration = Date().timeIntervalSince(started)
|
||
isLoading = false
|
||
}
|
||
let database = model.selectedDatabase.isEmpty ? profile.database : model.selectedDatabase
|
||
let query = (useJSON ? "EXPLAIN FORMAT=JSON " : "EXPLAIN ") + normalizedStatement
|
||
do {
|
||
plan = try await NativeMySQLRunner.run(
|
||
profile: profile,
|
||
database: database,
|
||
password: PasswordStore.read(profile.id),
|
||
sql: query
|
||
)
|
||
} catch {
|
||
errorText = "EXPLAIN failed: " + error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
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]]
|
||
var hiddenColumnIndices: Set<Int> = []
|
||
|
||
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, hiddenIndices: hiddenColumnIndices)
|
||
|
||
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 || Set(table.tableColumns.compactMap { column -> Int? in
|
||
let identifier = column.identifier.rawValue
|
||
return identifier.hasPrefix("column_") ? Int(identifier.dropFirst(7)) : nil
|
||
}) != Set(columns.indices).subtracting(hiddenColumnIndices) {
|
||
configureColumns(table, hiddenIndices: hiddenColumnIndices)
|
||
}
|
||
|
||
table.reloadData()
|
||
}
|
||
|
||
private func configureColumns(_ table: NSTableView, hiddenIndices: Set<Int>) {
|
||
for column in Array(table.tableColumns) {
|
||
table.removeTableColumn(column)
|
||
}
|
||
|
||
for (index, name) in columns.enumerated() where !hiddenIndices.contains(index) {
|
||
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 serverCounters = "Server Counters"
|
||
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"
|
||
case serverLogs = "Server Error Log"
|
||
case slowQueries = "Slow Query Analysis"
|
||
case tableIO = "Table I/O Hotspots"
|
||
case innodbMetrics = "InnoDB Metrics"
|
||
case innodbLocks = "InnoDB Lock Waits"
|
||
case connectionStatistics = "Connection Statistics"
|
||
|
||
var id: String { rawValue }
|
||
|
||
var icon: String {
|
||
switch self {
|
||
case .slowQueries: return "tortoise"
|
||
case .tableIO: return "externaldrive"
|
||
case .innodbMetrics: return "internaldrive"
|
||
case .innodbLocks: return "lock.shield"
|
||
case .connectionStatistics: return "network"
|
||
case .serverLogs: return "text.alignleft"
|
||
case .serverStatus: return "server.rack"
|
||
case .serverCounters: return "chart.xyaxis.line"
|
||
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 .slowQueries:
|
||
return """
|
||
SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT, AVG_TIMER_WAIT,
|
||
MAX_TIMER_WAIT, SUM_ROWS_EXAMINED, SUM_ROWS_SENT,
|
||
SUM_NO_INDEX_USED, FIRST_SEEN, LAST_SEEN
|
||
FROM performance_schema.events_statements_summary_by_digest
|
||
WHERE DIGEST_TEXT IS NOT NULL
|
||
ORDER BY SUM_TIMER_WAIT DESC LIMIT 100
|
||
"""
|
||
case .tableIO:
|
||
return """
|
||
SELECT OBJECT_SCHEMA, OBJECT_NAME, COUNT_READ, COUNT_WRITE,
|
||
SUM_TIMER_READ, SUM_TIMER_WRITE
|
||
FROM performance_schema.table_io_waits_summary_by_table
|
||
WHERE OBJECT_SCHEMA IS NOT NULL
|
||
ORDER BY SUM_TIMER_WAIT DESC LIMIT 100
|
||
"""
|
||
case .innodbMetrics:
|
||
return """
|
||
SHOW GLOBAL STATUS WHERE Variable_name IN (
|
||
'Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads',
|
||
'Innodb_buffer_pool_pages_total', 'Innodb_buffer_pool_pages_free',
|
||
'Innodb_buffer_pool_pages_dirty', 'Innodb_data_reads',
|
||
'Innodb_data_writes', 'Innodb_log_waits',
|
||
'Innodb_row_lock_waits', 'Innodb_row_lock_time',
|
||
'Innodb_rows_read', 'Innodb_rows_inserted',
|
||
'Innodb_rows_updated', 'Innodb_rows_deleted'
|
||
)
|
||
"""
|
||
case .innodbLocks:
|
||
return """
|
||
SELECT REQUESTING_ENGINE_TRANSACTION_ID, BLOCKING_ENGINE_TRANSACTION_ID,
|
||
REQUESTING_ENGINE_LOCK_ID, BLOCKING_ENGINE_LOCK_ID
|
||
FROM performance_schema.data_lock_waits
|
||
LIMIT 100
|
||
"""
|
||
case .connectionStatistics:
|
||
return """
|
||
SHOW GLOBAL STATUS WHERE Variable_name IN (
|
||
'Uptime', 'Connections', 'Max_used_connections',
|
||
'Threads_connected', 'Threads_running', 'Threads_cached',
|
||
'Threads_created', 'Aborted_clients', 'Aborted_connects',
|
||
'Connection_errors_max_connections', 'Slow_queries',
|
||
'Questions', 'Queries', 'Created_tmp_disk_tables',
|
||
'Select_full_join', 'Sort_merge_passes'
|
||
)
|
||
"""
|
||
case .serverLogs:
|
||
return "SELECT LOGGED, PRIO, ERROR_CODE, SUBSYSTEM, DATA FROM performance_schema.error_log ORDER BY LOGGED DESC LIMIT 500"
|
||
case .serverCounters:
|
||
return "SHOW GLOBAL STATUS"
|
||
case .serverStatus:
|
||
return "SHOW GLOBAL STATUS"
|
||
|
||
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
|
||
"""
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum WorkbenchProcessAction: String {
|
||
case query = "Stop query for"
|
||
case connection = "Disconnect"
|
||
}
|
||
|
||
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?
|
||
@State private var searchText = ""
|
||
@State private var autoRefresh = true
|
||
@State private var selectedAccount: String? = nil
|
||
@State private var grants: [String] = []
|
||
@State private var grantsError: String? = nil
|
||
@State private var showingGrants = false
|
||
@State private var showingAccountManager = false
|
||
@State private var selectedProcessID: String? = nil
|
||
@State private var pendingProcessAction: WorkbenchProcessAction? = nil
|
||
@State private var processActionError: String? = nil
|
||
@State private var processActionStatus: String? = nil
|
||
@State private var terminatingProcess = false
|
||
// Phase 25/26: bounded, in-session samples. No credentials or SQL persisted.
|
||
@State private var diagnosticSamples: [DiagnosticSample] = []
|
||
@State private var showingTrends = false
|
||
|
||
private var supportsTrends: Bool {
|
||
page == .slowQueries || page == .connectionStatistics ||
|
||
page == .innodbMetrics || page == .dashboard || page == .serverStatus || page == .serverCounters
|
||
}
|
||
|
||
private var diagnosticGuidance: String? {
|
||
switch page {
|
||
case .slowQueries:
|
||
return "Aggregated statement digests since Performance Schema statistics were last reset. Times are in picoseconds; this is not the server slow-query log. SQL text may be truncated."
|
||
case .tableIO:
|
||
return "Cumulative Performance Schema table I/O counters since last reset. Times are in picoseconds; refresh to observe changes."
|
||
case .innodbMetrics:
|
||
return "Cumulative InnoDB status counters; calculate rates from measurements at two different times."
|
||
case .innodbLocks:
|
||
return "Current InnoDB lock-wait relationships only. An empty result means no waits were visible at the time of the query."
|
||
case .connectionStatistics:
|
||
return "Server-wide counters since startup. Connections is cumulative, whereas Threads_connected is the current count."
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private var filteredRows: [[String]] {
|
||
guard !searchText.isEmpty else { return result.rows }
|
||
return result.rows.filter { row in
|
||
row.contains { $0.localizedCaseInsensitiveContains(searchText) }
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
if let guidance = diagnosticGuidance {
|
||
Text(guidance)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
if supportsTrends && !diagnosticSamples.isEmpty && page != .dashboard && page != .serverStatus {
|
||
HStack(spacing: 12) {
|
||
Label("\(diagnosticSamples.count) samples", systemImage: "chart.xyaxis.line")
|
||
.foregroundStyle(.secondary)
|
||
Button("View Trends…") { showingTrends = true }
|
||
Button("Clear Samples") { diagnosticSamples.removeAll() }
|
||
Spacer()
|
||
Text("Samples are kept in memory for this screen only")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 6)
|
||
}
|
||
|
||
if page == .clientConnections {
|
||
processControls
|
||
}
|
||
|
||
if page == .users && !result.rows.isEmpty {
|
||
HStack {
|
||
Picker("Account", selection: $selectedAccount) {
|
||
Text("Select account").tag(String?.none)
|
||
ForEach(result.rows.indices, id: \.self) { i in
|
||
let row = result.rows[i]
|
||
if row.count >= 2 {
|
||
Text("\(row[0])@\(row[1])")
|
||
.tag(Optional("\(row[0])\u{001f}\(row[1])"))
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: 320)
|
||
Button("Show Grants") {
|
||
Task { await showGrants() }
|
||
}
|
||
.disabled(selectedAccount == nil)
|
||
Button("Manage Accounts…") {
|
||
autoRefresh = false
|
||
showingAccountManager = true
|
||
}
|
||
.disabled(profile == nil)
|
||
Spacer()
|
||
Text("Changes require confirmation")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
Divider()
|
||
|
||
footer
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.task(id: "\(page.id)-\(profile?.id.uuidString ?? "")") {
|
||
await refresh()
|
||
}
|
||
.task(id: "\(page.id)-\(autoRefresh)-\(profile?.id.uuidString ?? "")") {
|
||
while autoRefresh && !Task.isCancelled {
|
||
try? await Task.sleep(for: .seconds(5))
|
||
if Task.isCancelled || !autoRefresh { break }
|
||
await refresh()
|
||
}
|
||
}
|
||
.confirmationDialog(
|
||
"Terminate MySQL session?",
|
||
isPresented: Binding(
|
||
get: { pendingProcessAction != nil },
|
||
set: { if !$0 { pendingProcessAction = nil } }
|
||
),
|
||
titleVisibility: .visible
|
||
) {
|
||
if let action = pendingProcessAction, let id = selectedProcessID {
|
||
Button("\(action.rawValue) session \(id)", role: .destructive) {
|
||
pendingProcessAction = nil
|
||
Task { await terminateProcess(id: id, action: action) }
|
||
}
|
||
}
|
||
Button("Cancel", role: .cancel) { pendingProcessAction = nil }
|
||
} message: {
|
||
Text("This operation affects a live MySQL session. Terminating a connection can interrupt transactions and roll back uncommitted work. Verify the session ID before proceeding.")
|
||
}
|
||
.sheet(isPresented: $showingAccountManager, onDismiss: {
|
||
Task { await refresh() }
|
||
}) {
|
||
if let profile {
|
||
WorkbenchAccountManagerView(
|
||
profile: profile,
|
||
selectedAccount: selectedAccount
|
||
)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingTrends) {
|
||
WorkbenchDiagnosticTrendsView(page: page, samples: diagnosticSamples)
|
||
}
|
||
.sheet(isPresented: $showingGrants) {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Grants: \(selectedAccount ?? "")")
|
||
.font(.title2.bold())
|
||
if let grantsError {
|
||
Text(grantsError).foregroundStyle(.red)
|
||
} else {
|
||
ScrollView {
|
||
Text(grants.joined(separator: "\n\n"))
|
||
.font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
Button("Close") { showingGrants = false }
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 580, minHeight: 300)
|
||
}
|
||
}
|
||
|
||
private var processControls: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 12) {
|
||
Picker("Session", selection: $selectedProcessID) {
|
||
Text("Select a session").tag(String?.none)
|
||
ForEach(processIDs, id: \.self) { id in
|
||
Text("ID \(id)").tag(Optional(id))
|
||
}
|
||
}
|
||
.frame(maxWidth: 250)
|
||
Button("Stop Query") { pendingProcessAction = .query }
|
||
.disabled(selectedProcessID == nil || terminatingProcess)
|
||
Button("Disconnect Session") { pendingProcessAction = .connection }
|
||
.disabled(selectedProcessID == nil || terminatingProcess)
|
||
Spacer()
|
||
if terminatingProcess { ProgressView().controlSize(.small) }
|
||
}
|
||
if let processActionError {
|
||
Text(processActionError).foregroundStyle(.red).textSelection(.enabled)
|
||
}
|
||
if let processActionStatus {
|
||
Text(processActionStatus).foregroundStyle(.secondary)
|
||
}
|
||
Text("Requires PROCESS privilege to see other users' sessions and appropriate privileges to terminate them.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
private var processIDs: [String] {
|
||
guard let index = result.columns.firstIndex(where: { $0.caseInsensitiveCompare("Id") == .orderedSame }) else {
|
||
return []
|
||
}
|
||
return result.rows.compactMap { row in
|
||
guard row.indices.contains(index), let id = UInt64(row[index]), id > 0 else { return nil }
|
||
return String(id)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func terminateProcess(id: String, action: WorkbenchProcessAction) async {
|
||
guard let profile, let numericID = UInt64(id), numericID > 0 else { return }
|
||
terminatingProcess = true
|
||
processActionError = nil
|
||
processActionStatus = nil
|
||
defer { terminatingProcess = false }
|
||
do {
|
||
let own = try await NativeMySQLRunner.run(
|
||
profile: profile, database: "",
|
||
password: PasswordStore.read(profile.id), sql: "SELECT CONNECTION_ID()"
|
||
)
|
||
if own.rows.first?.first == id {
|
||
processActionError = "Cannot terminate this application's own MySQL session."
|
||
return
|
||
}
|
||
let sql = "KILL \(action == .query ? "QUERY" : "CONNECTION") \(numericID)"
|
||
_ = try await NativeMySQLRunner.run(
|
||
profile: profile, database: "",
|
||
password: PasswordStore.read(profile.id), sql: sql
|
||
)
|
||
processActionStatus = "\(action.rawValue) requested for session \(id)."
|
||
selectedProcessID = nil
|
||
await refresh()
|
||
} catch {
|
||
processActionError = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
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()
|
||
|
||
if page != .dashboard {
|
||
TextField("Filter results", text: $searchText)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 175)
|
||
}
|
||
|
||
Toggle("Auto-refresh", isOn: $autoRefresh)
|
||
.toggleStyle(.checkbox)
|
||
.help("Refresh every five seconds")
|
||
|
||
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)
|
||
}
|
||
|
||
// Keep the administration pages distinct: the complete server-status table and
|
||
// system-variable grid must never be replaced by a dashboard of metric cards.
|
||
@ViewBuilder
|
||
private var resultContent: some View {
|
||
switch page {
|
||
case .serverCounters:
|
||
WorkbenchServerCountersScreen(samples: diagnosticSamples, rows: filteredRows)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
case .dashboard:
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
WorkbenchInlinePerformanceDashboard(samples: diagnosticSamples)
|
||
Text("Server statistics").font(.title2.bold())
|
||
NativeResultGrid(columns: result.columns, rows: filteredRows)
|
||
.frame(minHeight: 340)
|
||
}
|
||
.padding(18)
|
||
}
|
||
case .serverStatus:
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
Text("Server Status").font(.title2.bold())
|
||
WorkbenchInlinePerformanceDashboard(samples: diagnosticSamples)
|
||
Text("Server statistics").font(.title2.bold())
|
||
NativeResultGrid(columns: result.columns, rows: filteredRows)
|
||
.frame(minHeight: 420)
|
||
}
|
||
.padding(18)
|
||
}
|
||
case .connectionStatistics, .innodbMetrics:
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
WorkbenchCounterCharts(page: page, samples: diagnosticSamples)
|
||
Text("All statistics").font(.title2.bold())
|
||
NativeResultGrid(columns: result.columns, rows: filteredRows)
|
||
.frame(minHeight: 440)
|
||
}
|
||
.padding(18)
|
||
}
|
||
default:
|
||
// Status and System Variables and all other administration screens
|
||
// retain their original searchable tabular presentation.
|
||
NativeResultGrid(columns: result.columns, rows: filteredRows)
|
||
.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 showGrants() async {
|
||
guard let profile, let selectedAccount else { return }
|
||
let components = selectedAccount.split(separator: "\u{001f}", omittingEmptySubsequences: false)
|
||
guard components.count == 2 else { return }
|
||
func quoted(_ text: String) -> String {
|
||
"'" + text.replacingOccurrences(of: "'", with: "''") + "'"
|
||
}
|
||
grants = []
|
||
grantsError = nil
|
||
showingGrants = true
|
||
do {
|
||
let sql = "SHOW GRANTS FOR \(quoted(String(components[0])))@\(quoted(String(components[1])))"
|
||
let response = try await NativeMySQLRunner.run(
|
||
profile: profile, database: "",
|
||
password: PasswordStore.read(profile.id), sql: sql
|
||
)
|
||
grants = response.rows.compactMap { $0.first }
|
||
} catch {
|
||
grantsError = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
@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
|
||
if supportsTrends {
|
||
diagnosticSamples.append(DiagnosticSample(at: Date(), columns: response.columns, rows: response.rows))
|
||
if diagnosticSamples.count > 60 {
|
||
diagnosticSamples.removeFirst(diagnosticSamples.count - 60)
|
||
}
|
||
}
|
||
if page == .clientConnections,
|
||
let selectedProcessID,
|
||
!processIDs.contains(selectedProcessID) {
|
||
self.selectedProcessID = nil
|
||
}
|
||
lastUpdated = Date()
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Built-in Database Import / Export
|
||
|
||
struct WorkbenchDatabaseTransferView: View {
|
||
@ObservedObject var model: DatabaseModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var selectedMode = 0
|
||
@State private var database = ""
|
||
@State private var isWorking = false
|
||
@State private var progress = 0.0
|
||
@State private var transferredRows: Int64 = 0
|
||
@State private var currentTableRows: Int64 = 0
|
||
@State private var currentTableExpected: Int64 = 0
|
||
@State private var status = "Ready"
|
||
@State private var operationTask: Task<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 includeTriggers = false
|
||
@State private var includeRoutines = false
|
||
@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
|
||
@State private var lastBackupURL: URL?
|
||
@State private var lastVerificationReport = ""
|
||
|
||
private var selectedProfile: ConnectionProfile? {
|
||
model.selected
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
header
|
||
|
||
Divider()
|
||
|
||
Picker("Operation", selection: $selectedMode) {
|
||
Text("Data Export").tag(0)
|
||
Text("Data Import / Restore").tag(1)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.padding(20)
|
||
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
GroupBox("MySQL Connection") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
LabeledContent("Connection") {
|
||
Text(selectedProfile?.name ?? "Not selected")
|
||
}
|
||
|
||
Picker("Database", selection: $database) {
|
||
Text("Select Database").tag("")
|
||
|
||
ForEach(availableDatabases, id: \.self) { name in
|
||
Text(name).tag(name)
|
||
}
|
||
}
|
||
HStack {
|
||
if loadingDatabases { ProgressView().controlSize(.small) }
|
||
Button("Refresh Databases") {
|
||
Task { await loadTransferDatabases() }
|
||
}
|
||
.disabled(loadingDatabases || selectedProfile == nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
if selectedMode == 0 {
|
||
GroupBox("Export Options") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Toggle("Table structure", isOn: $exportStructure)
|
||
Toggle("Table data", isOn: $exportData)
|
||
Toggle("Add DROP TABLE IF EXISTS", isOn: $includeDrop)
|
||
.disabled(!exportStructure)
|
||
Toggle("Include views (definitions)", isOn: $includeViews)
|
||
.disabled(!exportStructure)
|
||
Toggle("Include triggers", isOn: $includeTriggers)
|
||
.disabled(!exportStructure)
|
||
Toggle("Include stored procedures and functions", isOn: $includeRoutines)
|
||
.disabled(!exportStructure)
|
||
HStack {
|
||
Text("Tables (\(selectedTables.count) selected)")
|
||
Spacer()
|
||
Button("All") { selectedTables = Set(availableTables) }
|
||
Button("None") { selectedTables.removeAll() }
|
||
Button("Refresh") { Task { await loadTables() } }
|
||
.disabled(loadingTables)
|
||
}
|
||
if loadingTables { ProgressView("Loading tables…") }
|
||
if availableTables.isEmpty && !loadingTables {
|
||
Text("No tables found. Select a database or refresh the list.")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
ForEach(availableTables, id: \.self) { table in
|
||
Toggle(table, isOn: Binding(
|
||
get: { selectedTables.contains(table) },
|
||
set: { checked in
|
||
if checked { selectedTables.insert(table) }
|
||
else { selectedTables.remove(table) }
|
||
}
|
||
))
|
||
}
|
||
if includeViews && exportStructure {
|
||
HStack {
|
||
Text("Views (\(selectedViews.count) selected)")
|
||
Spacer()
|
||
Button("All") { selectedViews = Set(availableViews) }
|
||
Button("None") { selectedViews.removeAll() }
|
||
}
|
||
ForEach(availableViews, id: \.self) { view in
|
||
Toggle(view, isOn: Binding(
|
||
get: { selectedViews.contains(view) },
|
||
set: { checked in
|
||
if checked { selectedViews.insert(view) }
|
||
else { selectedViews.remove(view) }
|
||
}
|
||
))
|
||
}
|
||
}
|
||
Text("InnoDB data is exported using a consistent snapshot and a single streamed query per table. Tables without unique indexes are supported.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
} else {
|
||
GroupBox("Import Options") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Import SQL statements into the selected database.")
|
||
.foregroundStyle(.secondary)
|
||
|
||
Text("Supports MySQL DELIMITER directives, triggers and stored routines. Import is not transactional; use a verified backup before restoring.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
GroupBox("Progress") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
ProgressView(value: progress)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
Text(status)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.textSelection(.enabled)
|
||
if !lastVerificationReport.isEmpty {
|
||
Text(lastVerificationReport)
|
||
.font(.system(.caption, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
if isWorking && selectedMode == 0 && currentTableExpected > 0 {
|
||
ProgressView(value: Double(currentTableRows), total: Double(currentTableExpected))
|
||
Text("Current table: \(currentTableRows.formatted()) / \(currentTableExpected.formatted()) rows · Total exported: \(transferredRows.formatted())")
|
||
.font(.caption.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(20)
|
||
|
||
Divider()
|
||
|
||
HStack {
|
||
Button("Close") {
|
||
operationTask?.cancel()
|
||
dismiss()
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if !isWorking {
|
||
Button("Verify Backup…") {
|
||
chooseBackupToVerify()
|
||
}
|
||
.help("Verify an SQL file against its SHA-256 checksum sidecar")
|
||
Button("Verify Restored Database…") {
|
||
chooseBackupToValidate()
|
||
}
|
||
.disabled(selectedProfile == nil || database.isEmpty)
|
||
.help("Compare restored table row counts and object names with the export manifest")
|
||
}
|
||
|
||
if isWorking {
|
||
Button("Cancel") {
|
||
operationTask?.cancel()
|
||
status = "Cancelling after current statement…"
|
||
}
|
||
} else {
|
||
Button(
|
||
selectedMode == 0
|
||
? "Export SQL…"
|
||
: "Import SQL…"
|
||
) {
|
||
if selectedMode == 0 {
|
||
chooseExportDestination()
|
||
} else {
|
||
chooseImportSource()
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(
|
||
selectedProfile == nil ||
|
||
database.isEmpty ||
|
||
(selectedMode == 0 && ((!exportStructure && !exportData) || (selectedTables.isEmpty && !(exportStructure && includeViews && !selectedViews.isEmpty))))
|
||
)
|
||
}
|
||
}
|
||
.padding(16)
|
||
}
|
||
.frame(minWidth: 650, idealWidth: 780, minHeight: 500)
|
||
.onAppear {
|
||
if database.isEmpty {
|
||
database = model.selectedDatabase.isEmpty
|
||
? (model.databases.first ?? "")
|
||
: model.selectedDatabase
|
||
}
|
||
}
|
||
.task(id: selectedProfile?.id) {
|
||
await loadTransferDatabases()
|
||
}
|
||
.task(id: database) {
|
||
availableTables = []
|
||
selectedTables = []
|
||
availableViews = []
|
||
selectedViews = []
|
||
if !database.isEmpty { await loadTables() }
|
||
}
|
||
.confirmationDialog(
|
||
"Import SQL into \(database)?",
|
||
isPresented: $showingImportConfirmation
|
||
) {
|
||
Button("Import SQL") {
|
||
guard let url = pendingImportURL else { return }
|
||
|
||
operationTask = Task {
|
||
await importSQL(from: url)
|
||
}
|
||
}
|
||
|
||
Button("Cancel", role: .cancel) {
|
||
pendingImportURL = nil
|
||
}
|
||
} message: {
|
||
Text(
|
||
"SQL files can modify or delete existing database objects and data. Ensure you have a backup before continuing."
|
||
)
|
||
}
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "externaldrive.badge.timemachine")
|
||
.font(.system(size: 28))
|
||
.foregroundStyle(.blue)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Database Import / Export")
|
||
.font(.system(size: 22, weight: .semibold))
|
||
|
||
Text("Built-in native MySQL database tools")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.padding(20)
|
||
}
|
||
|
||
private func chooseExportDestination() {
|
||
let panel = NSSavePanel()
|
||
panel.title = "Export MySQL Database"
|
||
panel.nameFieldStringValue = "\(database).sql"
|
||
panel.canCreateDirectories = true
|
||
|
||
guard panel.runModal() == .OK,
|
||
let url = panel.url
|
||
else { return }
|
||
|
||
operationTask = Task {
|
||
await exportSQL(to: url)
|
||
}
|
||
}
|
||
|
||
private func chooseBackupToVerify() {
|
||
let panel = NSOpenPanel()
|
||
panel.title = "Verify SQL Backup"
|
||
panel.canChooseDirectories = false
|
||
panel.canChooseFiles = true
|
||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
operationTask = Task {
|
||
isWorking = true
|
||
progress = 0
|
||
defer { isWorking = false; operationTask = nil }
|
||
do {
|
||
try await Task.detached(priority: .utility) {
|
||
try WorkbenchBackupIntegrity.verify(url)
|
||
}.value
|
||
progress = 1
|
||
status = "Verified SHA-256: \(url.lastPathComponent)"
|
||
} catch {
|
||
status = "Verification failed: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
}
|
||
|
||
private func chooseBackupToValidate() {
|
||
let panel = NSOpenPanel()
|
||
panel.title = "Select Exported SQL Backup to Validate"
|
||
panel.canChooseDirectories = false
|
||
panel.canChooseFiles = true
|
||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
operationTask = Task { await validateRestoredDatabase(from: url) }
|
||
}
|
||
|
||
@MainActor
|
||
private func validateRestoredDatabase(from url: URL) async {
|
||
isWorking = true
|
||
progress = 0
|
||
lastVerificationReport = ""
|
||
defer { isWorking = false; operationTask = nil }
|
||
do {
|
||
let manifest = try WorkbenchBackupManifest.read(for: url)
|
||
guard manifest.database == database else {
|
||
throw WorkbenchTransferError.message(
|
||
"Manifest is for database \(manifest.database), but \(database) is selected."
|
||
)
|
||
}
|
||
if WorkbenchBackupIntegrity.hasSidecar(for: url) {
|
||
status = "Verifying SQL checksum…"
|
||
try await Task.detached(priority: .utility) {
|
||
try WorkbenchBackupIntegrity.verify(url)
|
||
}.value
|
||
}
|
||
var failures: [String] = []
|
||
var checked = 0
|
||
let total = manifest.tables.count + manifest.views.count + manifest.triggers.count + manifest.routines.count
|
||
for table in manifest.tables {
|
||
try Task.checkCancellation()
|
||
status = "Validating table \(table.name)"
|
||
do {
|
||
let rows = try await execute("SELECT COUNT(*) FROM \(quoteIdentifier(database)).\(quoteIdentifier(table.name))")
|
||
guard let actual = rows.rows.first?.first.flatMap(Int64.init) else {
|
||
failures.append("\(table.name): count unavailable")
|
||
continue
|
||
}
|
||
if let expected = table.rows, actual != expected {
|
||
failures.append("\(table.name): expected \(expected), found \(actual) rows")
|
||
}
|
||
} catch {
|
||
failures.append("\(table.name): \(error.localizedDescription)")
|
||
}
|
||
checked += 1
|
||
progress = Double(checked) / Double(max(total, 1))
|
||
}
|
||
for view in manifest.views {
|
||
try Task.checkCancellation()
|
||
status = "Validating view \(view)"
|
||
do { _ = try await execute("SHOW CREATE VIEW \(quoteIdentifier(database)).\(quoteIdentifier(view))") }
|
||
catch { failures.append("View \(view): \(error.localizedDescription)") }
|
||
checked += 1
|
||
progress = Double(checked) / Double(max(total, 1))
|
||
}
|
||
for trigger in manifest.triggers {
|
||
try Task.checkCancellation()
|
||
status = "Validating trigger \(trigger)"
|
||
do { _ = try await execute("SHOW CREATE TRIGGER \(quoteIdentifier(database)).\(quoteIdentifier(trigger))") }
|
||
catch { failures.append("Trigger \(trigger): \(error.localizedDescription)") }
|
||
checked += 1
|
||
progress = Double(checked) / Double(max(total, 1))
|
||
}
|
||
for routine in manifest.routines {
|
||
try Task.checkCancellation()
|
||
status = "Validating \(routine.kind) \(routine.name)"
|
||
do { _ = try await execute("SHOW CREATE \(routine.kind) \(quoteIdentifier(database)).\(quoteIdentifier(routine.name))") }
|
||
catch { failures.append("\(routine.kind) \(routine.name): \(error.localizedDescription)") }
|
||
checked += 1
|
||
progress = Double(checked) / Double(max(total, 1))
|
||
}
|
||
progress = 1
|
||
lastVerificationReport = failures.isEmpty
|
||
? "PASS: \(checked) objects checked. Counts and object existence match the manifest."
|
||
: "FAILED: \(failures.count) mismatches:\n" + failures.joined(separator: "\n")
|
||
status = failures.isEmpty ? "Restore validation passed" : "Restore validation found \(failures.count) problems"
|
||
} catch is CancellationError {
|
||
status = "Validation cancelled"
|
||
} catch {
|
||
status = "Validation failed: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func chooseImportSource() {
|
||
let panel = NSOpenPanel()
|
||
panel.title = "Import MySQL SQL File"
|
||
panel.allowedContentTypes = [.plainText]
|
||
panel.allowsOtherFileTypes = true
|
||
panel.canChooseDirectories = false
|
||
panel.canChooseFiles = true
|
||
|
||
guard panel.runModal() == .OK,
|
||
let url = panel.url
|
||
else { return }
|
||
|
||
pendingImportURL = url
|
||
showingImportConfirmation = true
|
||
}
|
||
|
||
// Export operations use a distinct authenticated session so their snapshot
|
||
// cannot be affected by SQL editor queries or administration refreshes.
|
||
@State private var exportSession: ConnectionProfile?
|
||
@State private var exportPassword = ""
|
||
|
||
@MainActor
|
||
private func execute(_ statement: String) async throws -> QueryResult {
|
||
guard let profile = selectedProfile else {
|
||
throw WorkbenchTransferError.message(
|
||
"No MySQL connection selected"
|
||
)
|
||
}
|
||
|
||
return try await NativeMySQLRunner.run(
|
||
profile: exportSession ?? profile,
|
||
database: statement == "SHOW DATABASES" ? "" : database,
|
||
password: exportSession == nil ? PasswordStore.read(profile.id) : exportPassword,
|
||
sql: statement
|
||
)
|
||
}
|
||
|
||
private func quoteIdentifier(_ identifier: String) -> String {
|
||
"`" + identifier.replacingOccurrences(
|
||
of: "`",
|
||
with: "``"
|
||
) + "`"
|
||
}
|
||
|
||
@MainActor
|
||
private func loadTransferDatabases() async {
|
||
guard selectedProfile != nil else {
|
||
availableDatabases = []
|
||
database = ""
|
||
status = "Select a MySQL connection first"
|
||
return
|
||
}
|
||
loadingDatabases = true
|
||
defer { loadingDatabases = false }
|
||
do {
|
||
// Do not depend on the schema navigator having been opened first.
|
||
let response = try await execute("SHOW DATABASES")
|
||
let names = response.rows.compactMap { $0.first }
|
||
availableDatabases = names
|
||
if !names.contains(database) {
|
||
let preferred = model.selectedDatabase
|
||
database = names.contains(preferred) ? preferred : (names.first ?? "")
|
||
}
|
||
status = names.isEmpty ? "No accessible databases" : "Ready"
|
||
} catch {
|
||
availableDatabases = []
|
||
database = ""
|
||
status = "Cannot load databases: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func loadTables() async {
|
||
guard !database.isEmpty else {
|
||
availableTables = []; selectedTables = []
|
||
availableViews = []; selectedViews = []
|
||
return
|
||
}
|
||
loadingTables = true
|
||
defer { loadingTables = false }
|
||
do {
|
||
let rows = try await execute("SHOW FULL TABLES FROM \(quoteIdentifier(database))")
|
||
availableTables = rows.rows.compactMap { row in
|
||
row.count > 1 && row[1].uppercased() == "BASE TABLE" ? row[0] : nil
|
||
}
|
||
selectedTables = Set(availableTables)
|
||
availableViews = rows.rows.compactMap { row in
|
||
row.count > 1 && row[1].uppercased() == "VIEW" ? row[0] : nil
|
||
}
|
||
selectedViews = Set(availableViews)
|
||
} catch {
|
||
status = "Cannot list tables: \(error.localizedDescription)"
|
||
availableTables = []
|
||
selectedTables = []
|
||
availableViews = []
|
||
selectedViews = []
|
||
}
|
||
}
|
||
|
||
// The server constructs SQL literals, so NULL, quotes and binary data are
|
||
// preserved rather than reconstructed from QueryResult's String values.
|
||
@MainActor
|
||
private func exportSQL(to url: URL) async {
|
||
guard exportStructure || exportData else { return }
|
||
let views = exportStructure && includeViews
|
||
? availableViews.filter { selectedViews.contains($0) }
|
||
: []
|
||
guard !selectedTables.isEmpty || !views.isEmpty || (exportStructure && (includeTriggers || includeRoutines)) else { return }
|
||
guard let activeProfile = selectedProfile else {
|
||
status = "Export failed: No connection selected"
|
||
return
|
||
}
|
||
// Clone the connection profile with a fresh identity; the session pool
|
||
// then creates a separate socket, independent of the editor session.
|
||
var dedicatedProfile = activeProfile
|
||
dedicatedProfile.id = UUID()
|
||
exportPassword = PasswordStore.read(activeProfile.id)
|
||
exportSession = dedicatedProfile
|
||
isWorking = true
|
||
progress = 0
|
||
transferredRows = 0
|
||
lastVerificationReport = ""
|
||
var manifestTables: [WorkbenchBackupManifest.Table] = []
|
||
var manifestRoutines: [WorkbenchBackupManifest.Routine] = []
|
||
var manifestTriggers: [String] = []
|
||
currentTableRows = 0
|
||
currentTableExpected = 0
|
||
let tables = availableTables.filter { selectedTables.contains($0) }
|
||
let tempURL = url.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).sql.tmp")
|
||
var handle: FileHandle?
|
||
defer {
|
||
try? handle?.close()
|
||
try? FileManager.default.removeItem(at: tempURL)
|
||
NativeMySQLRunner.disconnect(profileID: dedicatedProfile.id)
|
||
exportSession = nil
|
||
exportPassword = ""
|
||
isWorking = false
|
||
operationTask = nil
|
||
}
|
||
do {
|
||
// A consistent InnoDB snapshot requires every SELECT to execute on
|
||
// this SAME connection. MyISAM/MEMORY cannot participate.
|
||
for table in tables where exportData {
|
||
let engine = try await execute("""
|
||
SELECT ENGINE FROM information_schema.TABLES
|
||
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
||
AND TABLE_NAME = \(sqlLiteral(table))
|
||
AND TABLE_TYPE = 'BASE TABLE'
|
||
""")
|
||
guard let name = engine.rows.first?.first,
|
||
name.uppercased() == "INNODB" else {
|
||
throw WorkbenchTransferError.message(
|
||
"Consistent data export requires InnoDB. Table \(table) uses \(engine.rows.first?.first ?? "an unknown engine"). Export its structure only."
|
||
)
|
||
}
|
||
}
|
||
_ = try await execute("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ")
|
||
_ = try await execute("START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY")
|
||
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
|
||
throw WorkbenchTransferError.message("Cannot create temporary export file")
|
||
}
|
||
handle = try FileHandle(forWritingTo: tempURL)
|
||
func write(_ value: String) throws {
|
||
try handle?.write(contentsOf: Data(value.utf8))
|
||
}
|
||
try write("-- WorkbenchReplacement native export\n-- Database: \(database)\n-- Generated: \(Date())\nSET NAMES utf8mb4;\n\n")
|
||
let db = quoteIdentifier(database)
|
||
for (index, table) in tables.enumerated() {
|
||
try Task.checkCancellation()
|
||
let qualified = "\(db).\(quoteIdentifier(table))"
|
||
status = "Exporting \(table) (\(index + 1)/\(tables.count))"
|
||
try write("\n-- Table \(qualified)\n")
|
||
if exportStructure {
|
||
let definition = try await execute("SHOW CREATE TABLE \(qualified)")
|
||
guard let row = definition.rows.first, row.count > 1 else {
|
||
throw WorkbenchTransferError.message("Missing CREATE TABLE for \(table)")
|
||
}
|
||
if includeDrop { try write("DROP TABLE IF EXISTS \(qualified);\n") }
|
||
try write(row[1] + ";\n")
|
||
}
|
||
if exportData {
|
||
// Column metadata drives binary-safe SQL literal formatting.
|
||
let metadata = try await execute("""
|
||
SELECT COLUMN_NAME, DATA_TYPE
|
||
FROM information_schema.COLUMNS
|
||
WHERE TABLE_SCHEMA = \(sqlLiteral(database)) AND TABLE_NAME = \(sqlLiteral(table))
|
||
ORDER BY ORDINAL_POSITION
|
||
""")
|
||
guard !metadata.rows.isEmpty else {
|
||
throw WorkbenchTransferError.message("No column metadata for \(table)")
|
||
}
|
||
let names = metadata.rows.map { quoteIdentifier($0[0]) }.joined(separator: ", ")
|
||
let expressions = metadata.rows.map { row -> String in
|
||
let col = quoteIdentifier(row[0])
|
||
let kind = row[1].lowercased()
|
||
// HEX avoids decoding arbitrary bytes as UTF-8.
|
||
if ["binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob", "bit"].contains(kind) {
|
||
return "IF(\(col) IS NULL, 'NULL', CONCAT('0x', HEX(\(col))))"
|
||
}
|
||
return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))"
|
||
}.joined(separator: ", ")
|
||
// A single streamed SELECT keeps every row in the same
|
||
// InnoDB read view and works without a primary/unique key.
|
||
// No OFFSET, no 100,000-row cap, no full result buffering.
|
||
let indexes = try await execute("""
|
||
SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
||
FROM information_schema.STATISTICS
|
||
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
||
AND TABLE_NAME = \(sqlLiteral(table))
|
||
AND NON_UNIQUE = 0
|
||
ORDER BY CASE WHEN INDEX_NAME = 'PRIMARY' THEN 0 ELSE 1 END,
|
||
INDEX_NAME, SEQ_IN_INDEX
|
||
""")
|
||
let nullable = try await execute("""
|
||
SELECT COLUMN_NAME
|
||
FROM information_schema.COLUMNS
|
||
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
||
AND TABLE_NAME = \(sqlLiteral(table))
|
||
AND IS_NULLABLE = 'YES'
|
||
""")
|
||
let nullableColumns = Set(nullable.rows.compactMap { $0.first })
|
||
var grouped: [String: [String]] = [:]
|
||
var indexOrder: [String] = []
|
||
for indexRow in indexes.rows where indexRow.count >= 2 {
|
||
let name = indexRow[0]
|
||
if grouped[name] == nil {
|
||
grouped[name] = []
|
||
indexOrder.append(name)
|
||
}
|
||
grouped[name, default: []].append(indexRow[1])
|
||
}
|
||
let usableIndex = indexOrder.first { name in
|
||
guard let columns = grouped[name], !columns.isEmpty else { return false }
|
||
return columns.allSatisfy { !nullableColumns.contains($0) }
|
||
}
|
||
let orderBy = usableIndex.flatMap { grouped[$0] }?
|
||
.map(quoteIdentifier).joined(separator: ", ")
|
||
let countResult = try await execute("SELECT COUNT(*) FROM \(qualified)")
|
||
guard let countText = countResult.rows.first?.first,
|
||
let expectedCount = Int64(countText) else {
|
||
throw WorkbenchTransferError.message("Cannot count rows in \(table)")
|
||
}
|
||
currentTableRows = 0
|
||
currentTableExpected = expectedCount
|
||
try write("-- Streamed snapshot: \(expectedCount) rows; order: \(usableIndex ?? "server scan")\n")
|
||
let select = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified)" +
|
||
(orderBy.map { " ORDER BY " + $0 } ?? "")
|
||
let outputHandle = handle!
|
||
let prefix = Data("INSERT INTO \(qualified) (\(names)) VALUES ".utf8)
|
||
let suffix = Data(";\n".utf8)
|
||
let completedBeforeTable = transferredRows
|
||
let completedTables = index
|
||
let totalUnits = max(tables.count + views.count, 1)
|
||
var streamedInTable: Int64 = 0
|
||
let written = try await NativeMySQLRunner.stream(
|
||
profile: dedicatedProfile,
|
||
database: database,
|
||
password: exportPassword,
|
||
sql: select
|
||
) { literal in
|
||
try outputHandle.write(contentsOf: prefix)
|
||
try outputHandle.write(contentsOf: Data(literal.utf8))
|
||
try outputHandle.write(contentsOf: suffix)
|
||
streamedInTable += 1
|
||
if streamedInTable % 500 == 0 || streamedInTable == expectedCount {
|
||
let completed = streamedInTable
|
||
DispatchQueue.main.async {
|
||
currentTableRows = completed
|
||
transferredRows = completedBeforeTable + completed
|
||
progress = (Double(completedTables) +
|
||
Double(completed) / Double(max(expectedCount, 1))) /
|
||
Double(totalUnits)
|
||
status = "Exporting \(table): \(completed.formatted()) / \(expectedCount.formatted()) rows"
|
||
}
|
||
}
|
||
}
|
||
currentTableRows = written
|
||
transferredRows = completedBeforeTable + written
|
||
guard written == expectedCount else {
|
||
throw WorkbenchTransferError.message(
|
||
"Table \(table): expected \(expectedCount) rows, streamed \(written). Backup discarded."
|
||
)
|
||
}
|
||
manifestTables.append(.init(name: table, rows: written))
|
||
status = "Exported \(table): \(written) rows"
|
||
}
|
||
if !exportData {
|
||
manifestTables.append(.init(name: table, rows: nil))
|
||
}
|
||
currentTableExpected = 0
|
||
currentTableRows = 0
|
||
progress = Double(index + 1) / Double(max(tables.count + views.count, 1))
|
||
}
|
||
// Views are exported after base tables so dependencies exist on restore.
|
||
// The server's SHOW CREATE VIEW response may contain a DEFINER clause;
|
||
// importing under a different MySQL account can require extra privileges.
|
||
for (index, view) in views.enumerated() {
|
||
try Task.checkCancellation()
|
||
let qualified = "\(db).\(quoteIdentifier(view))"
|
||
status = "Exporting view \(view)"
|
||
let definition = try await execute("SHOW CREATE VIEW \(qualified)")
|
||
guard let row = definition.rows.first, row.count > 1 else {
|
||
throw WorkbenchTransferError.message("Missing CREATE VIEW for \(view)")
|
||
}
|
||
try write("\n-- View \(qualified)\n")
|
||
if includeDrop { try write("DROP VIEW IF EXISTS \(qualified);\n") }
|
||
try write(row[1] + ";\n")
|
||
progress = Double(tables.count + index + 1) / Double(max(tables.count + views.count, 1))
|
||
}
|
||
// Routines and triggers use client-side delimiters. The importer
|
||
// strips these directives and sends each definition as one query.
|
||
if exportStructure && includeRoutines {
|
||
let routines = try await execute("""
|
||
SELECT ROUTINE_NAME, ROUTINE_TYPE
|
||
FROM information_schema.ROUTINES
|
||
WHERE ROUTINE_SCHEMA = \(sqlLiteral(database))
|
||
ORDER BY ROUTINE_TYPE, ROUTINE_NAME
|
||
""")
|
||
for routine in routines.rows {
|
||
try Task.checkCancellation()
|
||
guard routine.count >= 2 else { continue }
|
||
let name = routine[0]
|
||
let kind = routine[1].uppercased()
|
||
guard kind == "PROCEDURE" || kind == "FUNCTION" else { continue }
|
||
status = "Exporting \(kind.lowercased()) \(name)"
|
||
let qualified = "\(db).\(quoteIdentifier(name))"
|
||
let definition = try await execute("SHOW CREATE \(kind) \(qualified)")
|
||
// SHOW CREATE PROCEDURE/FUNCTION: Name, sql_mode, Create ..., ...
|
||
guard let row = definition.rows.first, row.count > 2,
|
||
!row[2].isEmpty else {
|
||
throw WorkbenchTransferError.message("Missing CREATE \(kind) for \(name)")
|
||
}
|
||
try write("\n-- \(kind) \(qualified)\n")
|
||
try write("DELIMITER $$\n")
|
||
if includeDrop { try write("DROP \(kind) IF EXISTS \(qualified)$$\n") }
|
||
try write(row[2] + "$$\nDELIMITER ;\n")
|
||
manifestRoutines.append(.init(name: name, kind: kind))
|
||
}
|
||
}
|
||
if exportStructure && includeTriggers {
|
||
let triggers = try await execute("""
|
||
SELECT TRIGGER_NAME
|
||
FROM information_schema.TRIGGERS
|
||
WHERE TRIGGER_SCHEMA = \(sqlLiteral(database))
|
||
ORDER BY TRIGGER_NAME
|
||
""")
|
||
for trigger in triggers.rows {
|
||
try Task.checkCancellation()
|
||
guard let name = trigger.first else { continue }
|
||
status = "Exporting trigger \(name)"
|
||
let qualified = "\(db).\(quoteIdentifier(name))"
|
||
let definition = try await execute("SHOW CREATE TRIGGER \(qualified)")
|
||
// SHOW CREATE TRIGGER: Trigger, sql_mode, SQL Original Statement, ...
|
||
guard let row = definition.rows.first, row.count > 2,
|
||
!row[2].isEmpty else {
|
||
throw WorkbenchTransferError.message("Missing CREATE TRIGGER for \(name)")
|
||
}
|
||
try write("\n-- Trigger \(qualified)\n")
|
||
try write("DELIMITER $$\n")
|
||
if includeDrop { try write("DROP TRIGGER IF EXISTS \(qualified)$$\n") }
|
||
try write(row[2] + "$$\nDELIMITER ;\n")
|
||
manifestTriggers.append(name)
|
||
}
|
||
}
|
||
try Task.checkCancellation()
|
||
// Release the read view before finalizing the export file.
|
||
_ = try await execute("COMMIT")
|
||
try handle?.close()
|
||
handle = nil
|
||
if FileManager.default.fileExists(atPath: url.path) {
|
||
_ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)
|
||
} else {
|
||
try FileManager.default.moveItem(at: tempURL, to: url)
|
||
}
|
||
// The SQL file is complete before creating its checksum sidecar.
|
||
// Sidecar failure is reported; it does not delete a completed export.
|
||
do {
|
||
try WorkbenchBackupIntegrity.createSidecar(for: url)
|
||
let manifest = WorkbenchBackupManifest(
|
||
database: database,
|
||
tables: manifestTables,
|
||
views: views,
|
||
triggers: manifestTriggers,
|
||
routines: manifestRoutines
|
||
)
|
||
try manifest.write(for: url)
|
||
lastBackupURL = url
|
||
progress = 1
|
||
status = "Export complete and SHA-256 verified: \(tables.count) tables, \(views.count) views, \(transferredRows.formatted()) rows"
|
||
} catch {
|
||
progress = 1
|
||
status = "SQL export complete, but checksum creation failed: \(error.localizedDescription)"
|
||
}
|
||
} catch is CancellationError {
|
||
status = "Export cancelled; no final backup was written"
|
||
} catch {
|
||
status = "Export failed: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func sqlLiteral(_ value: String) -> String {
|
||
"'" + value.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "'", with: "''") + "'"
|
||
}
|
||
|
||
@MainActor
|
||
private func importSQL(from url: URL) async {
|
||
isWorking = true
|
||
progress = 0
|
||
status = "Reading SQL file…"
|
||
|
||
defer {
|
||
isWorking = false
|
||
operationTask = nil
|
||
}
|
||
|
||
do {
|
||
// Refuse a corrupted backup when a checksum sidecar is present.
|
||
// Third-party SQL files without a sidecar remain importable.
|
||
if WorkbenchBackupIntegrity.hasSidecar(for: url) {
|
||
status = "Verifying SQL backup checksum…"
|
||
try await Task.detached(priority: .utility) {
|
||
try WorkbenchBackupIntegrity.verify(url)
|
||
}.value
|
||
}
|
||
try Task.checkCancellation()
|
||
let fileSize = (try FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value ?? 0
|
||
var statementNumber = 0
|
||
var lastLine = 1
|
||
let reader = try WorkbenchStreamingSQLReader(url: url)
|
||
defer { reader.close() }
|
||
|
||
while let entry = try reader.nextStatement() {
|
||
try Task.checkCancellation()
|
||
statementNumber += 1
|
||
lastLine = entry.line
|
||
status = "Importing statement \(statementNumber) · line \(lastLine)"
|
||
do {
|
||
_ = try await execute(entry.sql)
|
||
} catch {
|
||
throw WorkbenchTransferError.message(
|
||
"Statement \(statementNumber), line \(lastLine): \(error.localizedDescription)"
|
||
)
|
||
}
|
||
if statementNumber % 10 == 0 || reader.bytesRead >= fileSize {
|
||
progress = fileSize > 0 ? min(1, Double(reader.bytesRead) / Double(fileSize)) : 1
|
||
}
|
||
}
|
||
progress = 1
|
||
status = "Import complete: \(statementNumber) statements. Use Verify Restored Database to compare with the export manifest."
|
||
lastBackupURL = url
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Backup integrity (SHA-256)
|
||
// A checksum detects accidental changes to a completed SQL dump. It does NOT
|
||
// establish transaction consistency, completeness or authenticity.
|
||
private enum WorkbenchBackupIntegrity {
|
||
static func sidecar(for sqlURL: URL) -> URL {
|
||
URL(fileURLWithPath: sqlURL.path + ".sha256")
|
||
}
|
||
|
||
static func hasSidecar(for sqlURL: URL) -> Bool {
|
||
FileManager.default.fileExists(atPath: sidecar(for: sqlURL).path)
|
||
}
|
||
|
||
static func digest(_ url: URL) throws -> String {
|
||
let file = try FileHandle(forReadingFrom: url)
|
||
defer { try? file.close() }
|
||
var hasher = SHA256()
|
||
while true {
|
||
let chunk = try file.read(upToCount: 1024 * 1024) ?? Data()
|
||
if chunk.isEmpty { break }
|
||
hasher.update(data: chunk)
|
||
}
|
||
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||
}
|
||
|
||
static func createSidecar(for sqlURL: URL) throws {
|
||
let value = try digest(sqlURL)
|
||
// Standard sha256sum-compatible format, with filename rather than path.
|
||
let line = value + " " + sqlURL.lastPathComponent + "\n"
|
||
try line.write(to: sidecar(for: sqlURL), atomically: true, encoding: .utf8)
|
||
}
|
||
|
||
static func verify(_ sqlURL: URL) throws {
|
||
let checksumURL = sidecar(for: sqlURL)
|
||
guard FileManager.default.fileExists(atPath: checksumURL.path) else {
|
||
throw WorkbenchTransferError.message("No checksum file found: \(checksumURL.lastPathComponent)")
|
||
}
|
||
let contents = try String(contentsOf: checksumURL, encoding: .utf8)
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let expected = String(contents.prefix(64)).lowercased()
|
||
guard expected.count == 64,
|
||
expected.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else {
|
||
throw WorkbenchTransferError.message("Invalid SHA-256 checksum file")
|
||
}
|
||
let actual = try digest(sqlURL)
|
||
guard actual == expected else {
|
||
throw WorkbenchTransferError.message("SHA-256 mismatch: backup contents have changed")
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Phase 11: bounded-memory SQL dump reader
|
||
// Keeps only the current SQL statement in memory. A single very large
|
||
// INSERT still requires enough memory to hold that statement.
|
||
final class WorkbenchStreamingSQLReader {
|
||
struct Entry { let sql: String; let line: Int }
|
||
private let handle: FileHandle
|
||
private var bytes = Data()
|
||
private var reachedEOF = false
|
||
private(set) var bytesRead: Int64 = 0
|
||
private var lineNumber = 0
|
||
private var statementStart = 1
|
||
private var statement = ""
|
||
private var delimiter = ";"
|
||
private var quote: Character?
|
||
private var escaped = false
|
||
private var blockComment = false
|
||
private var lineComment = false
|
||
private var pending: [Entry] = []
|
||
|
||
init(url: URL) throws { handle = try FileHandle(forReadingFrom: url) }
|
||
func close() { try? handle.close() }
|
||
|
||
func nextStatement() throws -> Entry? {
|
||
while pending.isEmpty {
|
||
guard let line = try readLine() else {
|
||
if quote != nil || blockComment {
|
||
throw WorkbenchTransferError.message("Unterminated quote or comment near line \(lineNumber)")
|
||
}
|
||
let tail = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
statement = ""
|
||
if !tail.isEmpty { return Entry(sql: tail, line: statementStart) }
|
||
return nil
|
||
}
|
||
lineNumber += 1
|
||
try consume(line)
|
||
}
|
||
return pending.removeFirst()
|
||
}
|
||
|
||
private func readLine() throws -> String? {
|
||
while true {
|
||
if let end = bytes.firstIndex(of: 10) {
|
||
let data = bytes.prefix(through: end)
|
||
bytes.removeSubrange(...end)
|
||
guard let line = String(data: data, encoding: .utf8) else {
|
||
throw WorkbenchTransferError.message("Invalid UTF-8 at line \(lineNumber + 1)")
|
||
}
|
||
return line
|
||
}
|
||
if reachedEOF {
|
||
guard !bytes.isEmpty else { return nil }
|
||
let data = bytes
|
||
bytes.removeAll(keepingCapacity: true)
|
||
guard let line = String(data: data, encoding: .utf8) else {
|
||
throw WorkbenchTransferError.message("Invalid UTF-8 at end of SQL file")
|
||
}
|
||
return line
|
||
}
|
||
let chunk = try handle.read(upToCount: 64 * 1024) ?? Data()
|
||
bytesRead += Int64(chunk.count)
|
||
if chunk.isEmpty { reachedEOF = true }
|
||
else { bytes.append(chunk) }
|
||
}
|
||
}
|
||
|
||
private func consume(_ line: String) throws {
|
||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if quote == nil && !blockComment && statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||
trimmed.uppercased().hasPrefix("DELIMITER ") {
|
||
let token = String(trimmed.dropFirst(10)).trimmingCharacters(in: .whitespaces)
|
||
guard !token.isEmpty && !token.contains(where: { $0.isWhitespace }) else {
|
||
throw WorkbenchTransferError.message("Invalid DELIMITER at line \(lineNumber)")
|
||
}
|
||
delimiter = token
|
||
return
|
||
}
|
||
let chars = Array(line)
|
||
let delim = Array(delimiter)
|
||
var i = 0
|
||
lineComment = false
|
||
while i < chars.count {
|
||
let ch = chars[i]
|
||
let next: Character? = i + 1 < chars.count ? chars[i + 1] : nil
|
||
if lineComment { break }
|
||
if blockComment {
|
||
statement.append(ch)
|
||
if ch == "*" && next == "/" {
|
||
statement.append("/"); i += 2; blockComment = false
|
||
} else { i += 1 }
|
||
continue
|
||
}
|
||
if let q = quote {
|
||
statement.append(ch)
|
||
if escaped { escaped = false }
|
||
else if ch == "\\" && q != "`" { escaped = true }
|
||
else if ch == q {
|
||
if next == q { statement.append(ch); i += 1 }
|
||
else { quote = nil }
|
||
}
|
||
i += 1
|
||
continue
|
||
}
|
||
if ch == "'" || ch == "\"" || ch == "`" {
|
||
quote = ch; statement.append(ch); i += 1; continue
|
||
}
|
||
if ch == "#" || (ch == "-" && next == "-" &&
|
||
(i + 2 >= chars.count || chars[i + 2].isWhitespace)) {
|
||
lineComment = true; break
|
||
}
|
||
if ch == "/" && next == "*" {
|
||
statement.append("/*"); blockComment = true; i += 2; continue
|
||
}
|
||
if i + delim.count <= chars.count && Array(chars[i..<(i + delim.count)]) == delim {
|
||
let sql = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !sql.isEmpty { pending.append(Entry(sql: sql, line: statementStart)) }
|
||
statement = ""; i += delim.count; statementStart = lineNumber; continue
|
||
}
|
||
if statement.isEmpty && !ch.isWhitespace { statementStart = lineNumber }
|
||
statement.append(ch)
|
||
i += 1
|
||
}
|
||
if !statement.isEmpty { statement.append("\n") }
|
||
}
|
||
}
|
||
|
||
// MARK: - Phase 12 restore validation manifest
|
||
// This is a post-restore comparison, not a proof of byte-for-byte data equality.
|
||
struct WorkbenchBackupManifest: Codable {
|
||
struct Table: Codable {
|
||
let name: String
|
||
let rows: Int64? // nil when the export contains no table data
|
||
}
|
||
struct Routine: Codable {
|
||
let name: String
|
||
let kind: String
|
||
}
|
||
let version: Int
|
||
let database: String
|
||
let tables: [Table]
|
||
let views: [String]
|
||
let triggers: [String]
|
||
let routines: [Routine]
|
||
|
||
init(database: String, tables: [Table], views: [String], triggers: [String], routines: [Routine]) {
|
||
self.version = 1
|
||
self.database = database
|
||
self.tables = tables
|
||
self.views = views
|
||
self.triggers = triggers
|
||
self.routines = routines
|
||
}
|
||
|
||
static func sidecarURL(for sql: URL) -> URL {
|
||
URL(fileURLWithPath: sql.path + ".manifest.json")
|
||
}
|
||
|
||
func write(for sql: URL) throws {
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||
try encoder.encode(self).write(to: Self.sidecarURL(for: sql), options: .atomic)
|
||
}
|
||
|
||
static func read(for sql: URL) throws -> Self {
|
||
let url = sidecarURL(for: sql)
|
||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||
throw WorkbenchTransferError.message("No .manifest.json sidecar found. Export a backup with Phase 12 first.")
|
||
}
|
||
let manifest = try JSONDecoder().decode(Self.self, from: Data(contentsOf: url))
|
||
guard manifest.version == 1 else {
|
||
throw WorkbenchTransferError.message("Unsupported backup manifest version \(manifest.version)")
|
||
}
|
||
return manifest
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Phase 14: Explicitly confirmed MySQL account administration
|
||
|
||
private enum WorkbenchAccountAction: String, CaseIterable, Identifiable {
|
||
case create = "Create Account"
|
||
case password = "Change Password"
|
||
case lock = "Lock Account"
|
||
case unlock = "Unlock Account"
|
||
case grant = "Grant Privileges"
|
||
case revoke = "Revoke Privileges"
|
||
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
private struct WorkbenchAccountManagerView: View {
|
||
let profile: ConnectionProfile
|
||
let selectedAccount: String?
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var action: WorkbenchAccountAction = .create
|
||
@State private var username = ""
|
||
@State private var hostname = "%"
|
||
@State private var password = ""
|
||
@State private var databaseName = ""
|
||
@State private var privilege = "SELECT"
|
||
@State private var previewSQL = ""
|
||
@State private var confirm = false
|
||
@State private var running = false
|
||
@State private var status: String?
|
||
@State private var failure: String?
|
||
|
||
private let privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "INDEX", "REFERENCES", "EXECUTE", "CREATE VIEW", "SHOW VIEW", "TRIGGER"]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Text("Manage MySQL Accounts").font(.title2.bold())
|
||
Text("Account changes run on \(profile.name). Review the SQL before applying it.")
|
||
.foregroundStyle(.secondary)
|
||
Form {
|
||
Picker("Operation", selection: $action) {
|
||
ForEach(WorkbenchAccountAction.allCases) { option in
|
||
Text(option.rawValue).tag(option)
|
||
}
|
||
}
|
||
TextField("Username", text: $username)
|
||
.textContentType(.username)
|
||
TextField("Host", text: $hostname)
|
||
if action == .create || action == .password {
|
||
SecureField("Password", text: $password)
|
||
}
|
||
if action == .grant || action == .revoke {
|
||
TextField("Database", text: $databaseName)
|
||
Picker("Privilege", selection: $privilege) {
|
||
ForEach(privileges, id: \.self) { item in
|
||
Text(item).tag(item)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
if let failure {
|
||
Text(failure).foregroundStyle(.red).textSelection(.enabled)
|
||
}
|
||
if let status {
|
||
Text(status).foregroundStyle(.green)
|
||
}
|
||
HStack {
|
||
Button("Close") { dismiss() }
|
||
Spacer()
|
||
Button("Review SQL…") { review() }
|
||
.disabled(running)
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(width: 590)
|
||
.onAppear {
|
||
if let selectedAccount {
|
||
let parts = selectedAccount.split(separator: "\u{001f}", omittingEmptySubsequences: false)
|
||
if parts.count == 2 {
|
||
username = String(parts[0])
|
||
hostname = String(parts[1])
|
||
}
|
||
}
|
||
}
|
||
.alert("Confirm \(action.rawValue)", isPresented: $confirm) {
|
||
Button("Cancel", role: .cancel) { previewSQL = "" }
|
||
Button("Apply Change", role: .destructive) {
|
||
Task { await execute() }
|
||
}
|
||
} message: {
|
||
// Passwords are never shown in the SQL preview.
|
||
Text("Run this operation against \(profile.name)?\n\n\(previewSQL)")
|
||
}
|
||
}
|
||
|
||
// Strict account and database validation keeps identifiers out of SQL syntax.
|
||
private func sql() throws -> (command: String, preview: String) {
|
||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-%")
|
||
guard !username.isEmpty, username.unicodeScalars.allSatisfy({ allowed.contains($0) }),
|
||
!hostname.isEmpty, hostname.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
|
||
throw AccountError.invalid("Username/host may contain only letters, digits, _, ., -, and %.")
|
||
}
|
||
let account = "'\(username)'@'\(hostname)'"
|
||
switch action {
|
||
case .create, .password:
|
||
guard !password.isEmpty else { throw AccountError.invalid("Enter a password.") }
|
||
// MySQL's default SQL mode interprets backslashes as escapes.
|
||
// We check SQL mode before execution to avoid changing password meaning.
|
||
let literal = password.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "'", with: "''")
|
||
let prefix = action == .create ? "CREATE USER" : "ALTER USER"
|
||
return ("\(prefix) \(account) IDENTIFIED BY '\(literal)'",
|
||
"\(prefix) \(account) IDENTIFIED BY '••••••••'")
|
||
case .lock:
|
||
return ("ALTER USER \(account) ACCOUNT LOCK", "ALTER USER \(account) ACCOUNT LOCK")
|
||
case .unlock:
|
||
return ("ALTER USER \(account) ACCOUNT UNLOCK", "ALTER USER \(account) ACCOUNT UNLOCK")
|
||
case .grant, .revoke:
|
||
guard !databaseName.isEmpty,
|
||
databaseName.unicodeScalars.allSatisfy({ allowed.contains($0) }),
|
||
privileges.contains(privilege) else {
|
||
throw AccountError.invalid("Enter a valid database name and privilege.")
|
||
}
|
||
let db = "`\(databaseName)`.*"
|
||
let command = action == .grant
|
||
? "GRANT \(privilege) ON \(db) TO \(account)"
|
||
: "REVOKE \(privilege) ON \(db) FROM \(account)"
|
||
return (command, command)
|
||
}
|
||
}
|
||
|
||
private func review() {
|
||
failure = nil
|
||
status = nil
|
||
do {
|
||
previewSQL = try sql().preview
|
||
confirm = true
|
||
} catch {
|
||
failure = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func execute() async {
|
||
running = true
|
||
defer { running = false; password = "" }
|
||
do {
|
||
let command = try sql().command
|
||
let credentials = PasswordStore.read(profile.id)
|
||
if action == .create || action == .password {
|
||
let mode = try await NativeMySQLRunner.run(
|
||
profile: profile, database: "", password: credentials,
|
||
sql: "SELECT @@SESSION.sql_mode"
|
||
)
|
||
let flags = mode.rows.first?.first ?? ""
|
||
if flags.split(separator: ",").contains(where: { $0 == "NO_BACKSLASH_ESCAPES" }) {
|
||
throw AccountError.invalid("Password changes are disabled for NO_BACKSLASH_ESCAPES SQL mode.")
|
||
}
|
||
}
|
||
_ = try await NativeMySQLRunner.run(
|
||
profile: profile, database: "", password: credentials, sql: command
|
||
)
|
||
status = "\(action.rawValue) completed successfully."
|
||
failure = nil
|
||
} catch {
|
||
failure = error.localizedDescription
|
||
status = nil
|
||
}
|
||
}
|
||
|
||
private enum AccountError: LocalizedError {
|
||
case invalid(String)
|
||
var errorDescription: String? {
|
||
if case let .invalid(message) = self { return message }
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Phase 28: Front-screen performance charts
|
||
|
||
private struct WorkbenchInlinePerformanceDashboard: View {
|
||
let samples: [DiagnosticSample]
|
||
|
||
private struct Reading: Identifiable {
|
||
let id: Int
|
||
let time: Date
|
||
let value: Double
|
||
}
|
||
|
||
private func counter(_ sample: DiagnosticSample, _ name: String) -> Double? {
|
||
guard let row = sample.rows.first(where: {
|
||
$0.count > 1 && $0[0].caseInsensitiveCompare(name) == .orderedSame
|
||
}) else { return nil }
|
||
return Double(row[1])
|
||
}
|
||
|
||
private func points(_ name: String, rate: Bool) -> [Reading] {
|
||
samples.indices.compactMap { i in
|
||
guard let current = counter(samples[i], name) else { return nil }
|
||
if !rate { return Reading(id: i, time: samples[i].at, value: current) }
|
||
guard i > 0, let previous = counter(samples[i - 1], name) else { return nil }
|
||
let interval = samples[i].at.timeIntervalSince(samples[i - 1].at)
|
||
guard interval > 0, current >= previous else { return nil }
|
||
return Reading(id: i, time: samples[i].at, value: (current - previous) / interval)
|
||
}
|
||
}
|
||
|
||
private func panel(_ title: String, metric: String, rate: Bool, tint: Color) -> some View {
|
||
let readings = points(metric, rate: rate)
|
||
return VStack(alignment: .leading, spacing: 10) {
|
||
HStack {
|
||
Text(title).font(.headline)
|
||
Spacer()
|
||
Circle().fill(tint).frame(width: 9, height: 9)
|
||
}
|
||
if let last = readings.last {
|
||
Text(last.value.formatted(.number.precision(.fractionLength(1))) + (rate ? " /sec" : ""))
|
||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(tint)
|
||
} else {
|
||
Text("Collecting samples…")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Chart(readings) { reading in
|
||
AreaMark(x: .value("Time", reading.time), y: .value("Value", reading.value))
|
||
.foregroundStyle(tint.opacity(0.14))
|
||
LineMark(x: .value("Time", reading.time), y: .value("Value", reading.value))
|
||
.foregroundStyle(tint)
|
||
.lineStyle(StrokeStyle(lineWidth: 2.5))
|
||
}
|
||
.chartYAxis { AxisMarks(position: .leading) }
|
||
.frame(height: 130)
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color(nsColor: .controlBackgroundColor))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Label("Live server performance", systemImage: "waveform.path.ecg")
|
||
.font(.title2.bold())
|
||
Spacer()
|
||
Text("\(samples.count) samples · refreshes every 5 seconds")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 410), spacing: 16)], spacing: 12) {
|
||
panel("Queries per second", metric: "Queries", rate: true, tint: .blue)
|
||
panel("Active connections", metric: "Threads_connected", rate: false, tint: .teal)
|
||
panel("Running threads", metric: "Threads_running", rate: false, tint: .orange)
|
||
panel("Slow queries per second", metric: "Slow_queries", rate: true, tint: .red)
|
||
panel("Bytes received per second", metric: "Bytes_received", rate: true, tint: .purple)
|
||
panel("Bytes sent per second", metric: "Bytes_sent", rate: true, tint: .green)
|
||
}
|
||
Text("Rates are calculated from consecutive MySQL status snapshots. Counters reset after server restarts; graphs show up to 60 in-session samples.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Phase 34: Responsive server-counter workspace
|
||
private struct WorkbenchServerCountersScreen: View {
|
||
let samples: [DiagnosticSample]
|
||
let rows: [[String]]
|
||
@State private var category = "All"
|
||
private let categories = ["All", "Connections", "Queries", "InnoDB", "Network", "Errors", "Replication", "Other"]
|
||
|
||
private func group(_ name: String) -> String {
|
||
let n = name.lowercased()
|
||
if n.hasPrefix("innodb_") { return "InnoDB" }
|
||
if n.contains("replica") || n.contains("slave") || n.contains("binlog") || n.contains("relay_log") { return "Replication" }
|
||
if n.contains("abort") || n.contains("error") || n.contains("failed") || n.contains("denied") { return "Errors" }
|
||
if n.contains("bytes_") || n.contains("network") { return "Network" }
|
||
if n.contains("connection") || n.contains("thread") || n.contains("client") { return "Connections" }
|
||
if n.contains("quer") || n.contains("question") || n.hasPrefix("com_") || n.contains("select") || n.contains("sort") || n.contains("tmp_") { return "Queries" }
|
||
return "Other"
|
||
}
|
||
private func value(_ name: String) -> String {
|
||
guard let row = rows.first(where: { $0.count >= 2 && $0[0].caseInsensitiveCompare(name) == .orderedSame }) else { return "—" }
|
||
return WorkbenchCounterCharts.displayValue(row[1], for: name)
|
||
}
|
||
private func overview(_ title: String, metric: String, tint: Color) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(title).font(.caption.weight(.medium)).foregroundStyle(.secondary)
|
||
Text(value(metric)).font(.system(size: 23, weight: .bold, design: .rounded))
|
||
.foregroundStyle(tint).lineLimit(1).minimumScaleFactor(0.7)
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 66, alignment: .leading)
|
||
.padding(12)
|
||
.background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
private var visibleRows: [[String]] {
|
||
category == "All" ? rows : rows.filter { $0.count >= 2 && group($0[0]) == category }
|
||
}
|
||
var body: some View {
|
||
ScrollView(.vertical) {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
// Graphs are the primary content, never hidden below summary cards.
|
||
WorkbenchCounterCharts(page: .serverCounters, samples: samples)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
Divider()
|
||
HStack {
|
||
Label("All MySQL server counters", systemImage: "list.bullet.rectangle")
|
||
.font(.title3.bold())
|
||
Spacer()
|
||
Text("\(visibleRows.count) of \(rows.count) counters")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Picker("Counter category", selection: $category) {
|
||
ForEach(categories, id: \.self) { Text($0).tag($0) }
|
||
}
|
||
.pickerStyle(.segmented)
|
||
WorkbenchColouredCounterTable(columns: ["Variable_name", "Value"], rows: visibleRows)
|
||
.frame(height: 450)
|
||
Text("Colours identify counter families, not server health. Error totals are cumulative; nonzero values do not necessarily indicate a current fault.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.padding(20)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Phase 29: Inline coloured counter graphs
|
||
// MARK: - Phase 33: pinned server graphs and complete coloured statistics
|
||
private struct WorkbenchPinnedCounterGraphs: View {
|
||
let samples: [DiagnosticSample]
|
||
private let metrics = ["Queries", "Questions", "Threads_connected", "Threads_running", "Slow_queries", "Bytes_received", "Bytes_sent", "Innodb_buffer_pool_reads", "Innodb_data_reads"]
|
||
|
||
private func sampleValue(_ sample: DiagnosticSample, metric: String) -> Double? {
|
||
guard let row = sample.rows.first(where: { $0.count > 1 && $0[0].caseInsensitiveCompare(metric) == .orderedSame }) else { return nil }
|
||
return Double(row[1])
|
||
}
|
||
|
||
private func values(for metric: String) -> [CounterGraphPoint] {
|
||
samples.indices.compactMap { index in
|
||
guard let current = sampleValue(samples[index], metric: metric) else { return nil }
|
||
if metric.hasPrefix("Threads_") {
|
||
return CounterGraphPoint(time: samples[index].at, value: current)
|
||
}
|
||
guard index > 0, let prior = sampleValue(samples[index - 1], metric: metric), current >= prior else { return nil }
|
||
let seconds = samples[index].at.timeIntervalSince(samples[index - 1].at)
|
||
guard seconds > 0 else { return nil }
|
||
return CounterGraphPoint(time: samples[index].at, value: (current - prior) / seconds)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollView(.horizontal) {
|
||
HStack(spacing: 12) {
|
||
ForEach(metrics, id: \.self) { metric in
|
||
let points = values(for: metric)
|
||
let colour = WorkbenchCounterCharts.tint(for: metric)
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(metric.replacingOccurrences(of: "_", with: " "))
|
||
.font(.subheadline.bold())
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text(points.last.map { $0.value.formatted(.number.precision(.fractionLength(1))) } ?? "—")
|
||
.font(.title2.bold()).foregroundStyle(colour)
|
||
Text(metric.hasPrefix("Threads_") ? "current" : "/sec")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Chart(points) { point in
|
||
AreaMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(colour.opacity(0.18))
|
||
LineMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(colour)
|
||
.lineStyle(StrokeStyle(lineWidth: 2.5))
|
||
}
|
||
.chartYAxis { AxisMarks(position: .leading) }
|
||
.frame(height: 105)
|
||
if points.isEmpty {
|
||
Text("Waiting for samples")
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(width: 290, height: 200, alignment: .topLeading)
|
||
.background(colour.opacity(0.09))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
}
|
||
.padding(.horizontal, 2)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct CounterGraphPoint: Identifiable {
|
||
let id = UUID()
|
||
let time: Date
|
||
let value: Double
|
||
}
|
||
|
||
private struct WorkbenchColouredCounterTable: View {
|
||
let columns: [String]
|
||
let rows: [[String]]
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Text("Variable name").frame(maxWidth: .infinity, alignment: .leading)
|
||
Text("Value").frame(width: 210, alignment: .trailing)
|
||
}
|
||
.font(.subheadline.bold())
|
||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||
.background(Color.secondary.opacity(0.09))
|
||
Divider()
|
||
ScrollView {
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(rows.indices, id: \.self) { index in
|
||
let row = rows[index]
|
||
if row.count >= 2 {
|
||
let colour = WorkbenchCounterCharts.tint(for: row[0])
|
||
HStack(spacing: 10) {
|
||
RoundedRectangle(cornerRadius: 2)
|
||
.fill(colour).frame(width: 4, height: 22)
|
||
Text(row[0])
|
||
.font(.system(.body, design: .monospaced))
|
||
.lineLimit(1)
|
||
.help(row[0])
|
||
Spacer(minLength: 8)
|
||
Text(WorkbenchCounterCharts.displayValue(row[1], for: row[0]))
|
||
.font(.system(.body, design: .monospaced).weight(.semibold))
|
||
.foregroundStyle(colour)
|
||
.lineLimit(1)
|
||
.help(row[1])
|
||
}
|
||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||
.background(index.isMultiple(of: 2) ? Color.secondary.opacity(0.045) : Color.clear)
|
||
Divider().opacity(0.35)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.18)))
|
||
}
|
||
}
|
||
|
||
private struct WorkbenchCounterCharts: View {
|
||
let page: WorkbenchAdminPage
|
||
let samples: [DiagnosticSample]
|
||
|
||
private struct Point: Identifiable {
|
||
let id: Int
|
||
let time: Date
|
||
let value: Double
|
||
}
|
||
|
||
static func tint(for name: String) -> Color {
|
||
let key = name.lowercased()
|
||
if key.contains("abort") || key.contains("error") || key.contains("slow") || key.contains("wait") { return .red }
|
||
if key.contains("thread") || key.contains("connection") { return .teal }
|
||
if key.contains("innodb") || key.contains("buffer") { return .purple }
|
||
if key.contains("bytes") || key.contains("network") { return .green }
|
||
if key.contains("disk") || key.contains("tmp") { return .orange }
|
||
return .blue
|
||
}
|
||
|
||
static func displayValue(_ raw: String, for name: String) -> String {
|
||
guard let number = Double(raw), number.isFinite else { return raw }
|
||
let key = name.lowercased()
|
||
if key == "uptime" {
|
||
let seconds = max(0, Int(number))
|
||
let days = seconds / 86400
|
||
let hours = (seconds % 86400) / 3600
|
||
let minutes = (seconds % 3600) / 60
|
||
return days > 0 ? "\(days)d \(hours)h \(minutes)m" : "\(hours)h \(minutes)m"
|
||
}
|
||
if key.contains("bytes") {
|
||
return ByteCountFormatter.string(fromByteCount: Int64(number), countStyle: .binary)
|
||
}
|
||
return number.formatted(.number.grouping(.automatic).precision(.fractionLength(0...2)))
|
||
}
|
||
|
||
private var metrics: [String] {
|
||
switch page {
|
||
case .innodbMetrics:
|
||
return ["Innodb_buffer_pool_reads", "Innodb_buffer_pool_read_requests", "Innodb_data_reads", "Innodb_data_writes", "Innodb_rows_read", "Innodb_row_lock_waits"]
|
||
case .connectionStatistics:
|
||
return ["Threads_connected", "Threads_running", "Connections", "Queries", "Slow_queries", "Aborted_connects"]
|
||
case .serverCounters:
|
||
return ["Queries", "Questions", "Threads_connected", "Threads_running", "Slow_queries", "Bytes_received", "Bytes_sent", "Innodb_buffer_pool_reads", "Innodb_data_reads"]
|
||
case .variables:
|
||
return ["Queries", "Threads_connected", "Threads_running", "Connections", "Slow_queries", "Bytes_sent"]
|
||
default:
|
||
return ["Queries", "Threads_connected", "Threads_running", "Slow_queries"]
|
||
}
|
||
}
|
||
|
||
private func value(_ sample: DiagnosticSample, _ metric: String) -> Double? {
|
||
guard let row = sample.rows.first(where: { $0.count >= 2 && $0[0].caseInsensitiveCompare(metric) == .orderedSame }) else { return nil }
|
||
return Double(row[1])
|
||
}
|
||
|
||
private func points(_ metric: String, rate: Bool) -> [Point] {
|
||
samples.indices.compactMap { i in
|
||
guard let current = value(samples[i], metric), current.isFinite else { return nil }
|
||
if !rate { return Point(id: i, time: samples[i].at, value: current) }
|
||
guard i > 0, let previous = value(samples[i - 1], metric), current >= previous else { return nil }
|
||
let elapsed = samples[i].at.timeIntervalSince(samples[i - 1].at)
|
||
guard elapsed > 0 else { return nil }
|
||
return Point(id: i, time: samples[i].at, value: (current - previous) / elapsed)
|
||
}
|
||
}
|
||
|
||
private func graph(_ metric: String) -> some View {
|
||
let instantaneous = metric.hasPrefix("Threads_")
|
||
let values = points(metric, rate: !instantaneous)
|
||
let tint = Self.tint(for: metric)
|
||
return VStack(alignment: .leading, spacing: 8) {
|
||
Text(metric.replacingOccurrences(of: "_", with: " ")).font(.subheadline.weight(.semibold))
|
||
.lineLimit(1).minimumScaleFactor(0.8)
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text(values.last.map { $0.value.formatted(.number.precision(.fractionLength(1))) } ?? "—")
|
||
.font(.system(size: 23, weight: .bold, design: .rounded))
|
||
.foregroundStyle(tint)
|
||
Text(instantaneous ? "current" : "/sec")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Chart(values) { point in
|
||
AreaMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(tint.opacity(0.13))
|
||
LineMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(tint).lineStyle(StrokeStyle(lineWidth: 2.5))
|
||
}
|
||
.chartYAxis { AxisMarks(position: .leading) }
|
||
.frame(height: 205)
|
||
if values.isEmpty {
|
||
Text("Waiting for samples").font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(16)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12))
|
||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(tint.opacity(0.35), lineWidth: 1))
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Label(page == .serverCounters ? "Server Counters · Live Graphs" : "Live counter graphs", systemImage: "chart.xyaxis.line")
|
||
.font(.title2.bold())
|
||
Spacer()
|
||
Text("\(samples.count) samples · 5-second refresh")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 410), spacing: 16)], spacing: 12) {
|
||
ForEach(metrics, id: \.self) { metric in graph(metric) }
|
||
}
|
||
Text("Live MySQL data • refreshes every five seconds. Rates appear after two successful samples; thread counts show current values.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Phase 25/26: Live performance samples and counter rates
|
||
|
||
private struct DiagnosticSample: Identifiable {
|
||
let id = UUID()
|
||
let at: Date
|
||
let columns: [String]
|
||
let rows: [[String]]
|
||
}
|
||
|
||
private struct WorkbenchDiagnosticTrendsView: View {
|
||
let page: WorkbenchAdminPage
|
||
let samples: [DiagnosticSample]
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
private func metricMap(_ sample: DiagnosticSample) -> [String: Double] {
|
||
var values: [String: Double] = [:]
|
||
if page == .slowQueries {
|
||
guard let name = sample.columns.firstIndex(of: "DIGEST_TEXT"),
|
||
let count = sample.columns.firstIndex(of: "COUNT_STAR") else { return values }
|
||
for row in sample.rows where row.indices.contains(name) && row.indices.contains(count) {
|
||
if let value = Double(row[count]) { values[row[name]] = value }
|
||
}
|
||
} else {
|
||
for row in sample.rows where row.count >= 2 {
|
||
if let value = Double(row[1]) { values[row[0]] = value }
|
||
}
|
||
}
|
||
return values
|
||
}
|
||
|
||
private var comparisons: [(String, Double, Double, Double?)] {
|
||
guard let latest = samples.last else { return [] }
|
||
let current = metricMap(latest)
|
||
let prior = samples.count > 1 ? metricMap(samples[samples.count - 2]) : [:]
|
||
let seconds = samples.count > 1
|
||
? latest.at.timeIntervalSince(samples[samples.count - 2].at) : 0
|
||
return current.keys.sorted().map { key in
|
||
let value = current[key] ?? 0
|
||
let before = prior[key] ?? value
|
||
let rate: Double? = seconds > 0 && prior[key] != nil && value >= before
|
||
? (value - before) / seconds : nil
|
||
return (key, value, before, rate)
|
||
}
|
||
}
|
||
|
||
@State private var selectedMetric = ""
|
||
|
||
private var metricNames: [String] {
|
||
guard let last = samples.last else { return [] }
|
||
return Array(metricMap(last).keys).sorted()
|
||
}
|
||
|
||
private var activeMetric: String {
|
||
metricNames.contains(selectedMetric) ? selectedMetric : (metricNames.first ?? "")
|
||
}
|
||
|
||
private struct ChartPoint: Identifiable {
|
||
let id: Int
|
||
let time: Date
|
||
let value: Double
|
||
}
|
||
|
||
private var chartPoints: [ChartPoint] {
|
||
samples.enumerated().compactMap { index, sample in
|
||
guard let value = metricMap(sample)[activeMetric], value.isFinite else { return nil }
|
||
return ChartPoint(id: index, time: sample.at, value: value)
|
||
}
|
||
}
|
||
|
||
private var chartTint: Color {
|
||
let name = activeMetric.lowercased()
|
||
if name.contains("abort") || name.contains("error") || name.contains("lock_wait") { return .red }
|
||
if name.contains("slow") || name.contains("temporary") { return .orange }
|
||
if name.contains("thread") || name.contains("connection") { return .teal }
|
||
return .blue
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("\(page.rawValue) — Trends").font(.title2.bold())
|
||
Spacer()
|
||
Button("Close") { dismiss() }
|
||
}
|
||
Text("\(samples.count) samples. Rates compare the two latest successful refreshes; cumulative counters can reset when MySQL restarts or statistics are cleared.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
if !metricNames.isEmpty {
|
||
HStack {
|
||
Picker("Chart metric", selection: $selectedMetric) {
|
||
ForEach(metricNames, id: \.self) { name in
|
||
Text(name).tag(name)
|
||
}
|
||
}
|
||
.frame(maxWidth: 480)
|
||
Spacer()
|
||
Label("Live samples", systemImage: "chart.xyaxis.line")
|
||
.foregroundStyle(chartTint)
|
||
}
|
||
.onAppear {
|
||
if selectedMetric.isEmpty { selectedMetric = metricNames.first ?? "" }
|
||
}
|
||
Chart(chartPoints) { point in
|
||
AreaMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(chartTint.opacity(0.13))
|
||
.interpolationMethod(.linear)
|
||
LineMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(chartTint)
|
||
.lineStyle(StrokeStyle(lineWidth: 2.5))
|
||
.interpolationMethod(.linear)
|
||
PointMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(chartTint)
|
||
}
|
||
.chartYAxis { AxisMarks(position: .leading) }
|
||
.frame(height: 205)
|
||
}
|
||
if samples.count < 2 {
|
||
ContentUnavailableView("More samples needed", systemImage: "chart.xyaxis.line",
|
||
description: Text("Refresh this screen twice, or enable auto-refresh."))
|
||
} else {
|
||
ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text("Metric").frame(maxWidth: .infinity, alignment: .leading)
|
||
Text("Current").frame(width: 110, alignment: .trailing)
|
||
Text("Previous").frame(width: 110, alignment: .trailing)
|
||
Text("Change/sec").frame(width: 110, alignment: .trailing)
|
||
}.font(.headline)
|
||
Divider()
|
||
ForEach(comparisons.indices, id: \.self) { index in
|
||
let item = comparisons[index]
|
||
HStack {
|
||
Text(item.0).lineLimit(2).frame(maxWidth: .infinity, alignment: .leading)
|
||
Text(item.1.formatted()).frame(width: 110, alignment: .trailing)
|
||
Text(item.2.formatted()).frame(width: 110, alignment: .trailing)
|
||
Text(item.3.map { $0.formatted(.number.precision(.fractionLength(2))) } ?? "—")
|
||
.foregroundStyle(item.3 == nil ? Color.secondary : (item.3! > 0 ? Color.teal : Color.secondary))
|
||
.frame(width: 110, alignment: .trailing)
|
||
}.font(.system(.caption, design: .monospaced))
|
||
Divider()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Text("Query digest counts are grouped by SQL fingerprint. This is a sampled comparison, not a durable monitoring database.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 820, minHeight: 480)
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Phase 27: Native Schema Inspector and SQL DDL Editor
|
||
|
||
private struct SchemaEditorTarget: Identifiable {
|
||
let id = UUID()
|
||
let database: String
|
||
let table: String?
|
||
}
|
||
|
||
private struct WorkbenchSchemaEditorView: View {
|
||
let profile: ConnectionProfile?
|
||
let target: SchemaEditorTarget
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var columns = QueryResult()
|
||
@State private var indexes = QueryResult()
|
||
@State private var originalDDL = ""
|
||
@State private var proposedSQL = ""
|
||
@State private var busy = false
|
||
@State private var message: String?
|
||
@State private var confirmExecution = false
|
||
@State private var successful = false
|
||
|
||
private func identifier(_ value: String) -> String {
|
||
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
|
||
}
|
||
|
||
private var qualifiedName: String {
|
||
identifier(target.database) + "." + identifier(target.table ?? "new_table")
|
||
}
|
||
|
||
private func query(_ sql: String) async throws -> QueryResult {
|
||
guard let profile else {
|
||
throw NSError(domain: "WorkbenchSchemaEditor", code: 1,
|
||
userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."])
|
||
}
|
||
return try await NativeMySQLRunner.run(
|
||
profile: profile, database: target.database,
|
||
password: PasswordStore.read(profile.id), sql: sql
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Label(target.table == nil ? "Create Table" : "Table Schema Editor",
|
||
systemImage: "tablecells.badge.ellipsis")
|
||
.font(.title2.bold())
|
||
Spacer()
|
||
Text(target.database).foregroundStyle(.secondary)
|
||
Button("Close") { dismiss() }
|
||
}
|
||
if let message {
|
||
Label(message, systemImage: successful ? "checkmark.circle" : "exclamationmark.triangle")
|
||
.foregroundStyle(successful ? .green : .red)
|
||
.textSelection(.enabled)
|
||
}
|
||
if target.table != nil {
|
||
TabView {
|
||
schemaGrid(columns).tabItem { Text("Columns") }
|
||
schemaGrid(indexes).tabItem { Text("Indexes") }
|
||
ScrollView {
|
||
Text(originalDDL).font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
}.tabItem { Text("Original DDL") }
|
||
}
|
||
.frame(minHeight: 180)
|
||
}
|
||
HStack {
|
||
Text("SQL schema changes").font(.headline)
|
||
Spacer()
|
||
Text("Review before execution").font(.caption).foregroundStyle(.orange)
|
||
}
|
||
TextEditor(text: $proposedSQL)
|
||
.font(.system(.body, design: .monospaced))
|
||
.frame(minHeight: 160)
|
||
.border(Color.secondary.opacity(0.3))
|
||
HStack {
|
||
Text("DDL may implicitly commit and cannot reliably be rolled back.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button("Reload") { Task { await load() } }.disabled(busy)
|
||
Button("Execute DDL…") { confirmExecution = true }
|
||
.disabled(busy || proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 820, minHeight: 620)
|
||
.task { await load() }
|
||
.confirmationDialog("Execute schema modification?", isPresented: $confirmExecution,
|
||
titleVisibility: .visible) {
|
||
Button("Execute SQL", role: .destructive) { Task { await executeDDL() } }
|
||
Button("Cancel", role: .cancel) { }
|
||
} message: {
|
||
Text("This SQL changes the database schema and may implicitly commit. Verify the statement and take a backup first.")
|
||
}
|
||
}
|
||
|
||
private func schemaGrid(_ data: QueryResult) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
if data.columns.isEmpty {
|
||
Text("No metadata loaded").foregroundStyle(.secondary)
|
||
} else {
|
||
ScrollView([.horizontal, .vertical]) {
|
||
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 7) {
|
||
GridRow {
|
||
ForEach(data.columns, id: \.self) { name in
|
||
Text(name).fontWeight(.bold)
|
||
}
|
||
}
|
||
Divider()
|
||
ForEach(data.rows.indices, id: \.self) { index in
|
||
GridRow {
|
||
ForEach(data.rows[index].indices, id: \.self) { column in
|
||
Text(data.rows[index][column])
|
||
.font(.system(.caption, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
}
|
||
}
|
||
}
|
||
}.padding(8)
|
||
}
|
||
}
|
||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
@MainActor
|
||
private func load() async {
|
||
guard target.table != nil else {
|
||
proposedSQL = "CREATE TABLE " + qualifiedName + " (\n id BIGINT NOT NULL AUTO_INCREMENT,\n PRIMARY KEY (id)\n) ENGINE=InnoDB;"
|
||
return
|
||
}
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
columns = try await query("SHOW FULL COLUMNS FROM " + qualifiedName)
|
||
indexes = try await query("SHOW INDEX FROM " + qualifiedName)
|
||
let ddl = try await query("SHOW CREATE TABLE " + qualifiedName)
|
||
originalDDL = ddl.rows.first?.last ?? ""
|
||
if proposedSQL.isEmpty {
|
||
proposedSQL = "-- Enter ALTER TABLE statements here.\nALTER TABLE " + qualifiedName + "\n ADD COLUMN new_column VARCHAR(255) NULL;"
|
||
}
|
||
message = nil
|
||
} catch {
|
||
message = error.localizedDescription
|
||
successful = false
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func executeDDL() async {
|
||
let sql = proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
// Single-statement editor: avoid silently executing partial multi-statement input.
|
||
let statement = sql.hasSuffix(";") ? String(sql.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) : sql
|
||
guard !statement.isEmpty else { return }
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
_ = try await query(statement)
|
||
successful = true
|
||
message = "Schema statement executed successfully."
|
||
if target.table != nil { await load() }
|
||
} catch {
|
||
successful = false
|
||
message = error.localizedDescription
|
||
}
|
||
}
|
||
}
|