8125 lines
354 KiB
Swift
8125 lines
354 KiB
Swift
|
||
import SwiftUI
|
||
import Charts
|
||
import AppKit
|
||
import CryptoKit
|
||
import UniformTypeIdentifiers
|
||
import Security
|
||
|
||
// MARK: - Classic Workbench Colours
|
||
|
||
private enum WB {
|
||
static let toolbar = Color(nsColor: .controlBackgroundColor)
|
||
static let sidebar = Color(nsColor: .underPageBackgroundColor)
|
||
static let border = Color(nsColor: .separatorColor)
|
||
static let header = Color(nsColor: .controlBackgroundColor)
|
||
}
|
||
|
||
// 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
|
||
@State private var selectedResultRow: [String]? = nil
|
||
@State private var editResultRow: [String]? = nil
|
||
private let resultPageSize = 1_000
|
||
@State private var showingEditor = false
|
||
@State private var showingConnectionManager = false
|
||
@State private var showingDatabaseTransfer = false
|
||
@State private var tableSearchTarget: WorkbenchTableTarget? = nil
|
||
@State private var tableImportTarget: WorkbenchTableTarget? = nil
|
||
@State private var schemaEditorTarget: SchemaEditorTarget? = nil
|
||
@State private var objectEditorTarget: WorkbenchObjectEditorTarget? = nil
|
||
@State private var showingQueryPlan = false
|
||
@State private var schemaChangesDatabase: String? = nil
|
||
@State private var schemaCompareDatabase: String? = nil
|
||
@State private var schemaDiagramDatabase: String? = nil
|
||
@State private var dataCompareDatabase: String? = nil
|
||
@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 expandedObjectFolders: Set<String> = []
|
||
@State private var schemaFilter = ""
|
||
@State private var schemaManagement: WorkbenchSchemaManagementTarget? = nil
|
||
@State private var selectedTableName: String? = nil
|
||
@State private var outputExpanded = true
|
||
|
||
@State private var navigatorMode = 1
|
||
@State private var sqlFocusRequest = 0
|
||
@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)
|
||
.background(Color(nsColor: .windowBackgroundColor))
|
||
.onReceive(NotificationCenter.default.publisher(for: .dbsynOpenConnectionManager)) { _ in
|
||
showingConnectionManager = true
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .workbenchRevealSQLEditor)) { _ in
|
||
administrationPage = nil
|
||
navigatorMode = 1
|
||
DispatchQueue.main.async { sqlFocusRequest += 1 }
|
||
}
|
||
.task {
|
||
if model.databases.isEmpty, model.selected != nil {
|
||
model.loadDatabases()
|
||
}
|
||
}
|
||
.sheet(isPresented: Binding(
|
||
get: { schemaChangesDatabase != nil },
|
||
set: { if !$0 { schemaChangesDatabase = nil } }
|
||
)) {
|
||
if let database = schemaChangesDatabase {
|
||
DBSynSchemaChangesView(model: model, database: database)
|
||
}
|
||
}
|
||
.sheet(isPresented: Binding(
|
||
get: { dataCompareDatabase != nil },
|
||
set: { if !$0 { dataCompareDatabase = nil } }
|
||
)) {
|
||
if let database = dataCompareDatabase {
|
||
DBSynDataCompareView(model: model, sourceDatabase: database)
|
||
}
|
||
}
|
||
.sheet(isPresented: Binding(
|
||
get: { schemaCompareDatabase != nil },
|
||
set: { if !$0 { schemaCompareDatabase = nil } }
|
||
)) {
|
||
if let database = schemaCompareDatabase {
|
||
DBSynSchemaCompareView(model: model, sourceDatabase: database)
|
||
}
|
||
}
|
||
.sheet(isPresented: Binding(
|
||
get: { schemaDiagramDatabase != nil },
|
||
set: { if !$0 { schemaDiagramDatabase = nil } }
|
||
)) {
|
||
if let database = schemaDiagramDatabase {
|
||
DBSynSchemaDiagramView(model: model, database: database, onClose: { schemaDiagramDatabase = nil })
|
||
}
|
||
}
|
||
.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(item: $tableSearchTarget) { target in
|
||
WorkbenchTableSearchSheet(model: model, target: target) { sql in
|
||
revealSchemaSQL(sql)
|
||
model.runSQL()
|
||
}
|
||
}
|
||
.sheet(item: $tableImportTarget) { target in
|
||
WorkbenchCSVImportSheet(model: model, target: target)
|
||
}
|
||
.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(item: $objectEditorTarget, onDismiss: {
|
||
if !model.selectedDatabase.isEmpty { model.loadTables(database: model.selectedDatabase) }
|
||
}) { target in
|
||
WorkbenchObjectEditorView(profile: model.selected, target: target)
|
||
}
|
||
.sheet(item: $schemaManagement, onDismiss: { model.loadDatabases() }) { target in
|
||
WorkbenchSchemaManagementSheet(model: model, target: target)
|
||
}
|
||
.sheet(isPresented: $showingConnectionManager) {
|
||
DBSynConnectionManager(model: model) { profile in
|
||
showingConnectionManager = false
|
||
edit(profile)
|
||
} onAdd: {
|
||
showingConnectionManager = false
|
||
editing = ConnectionProfile(name: "New Connection")
|
||
editingPassword = ""
|
||
showingEditor = true
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingEditor) {
|
||
ConnectionEditor(
|
||
profile: $editing,
|
||
password: $editingPassword
|
||
) {
|
||
model.save(editing, password: editingPassword)
|
||
showingEditor = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Query execution navigation
|
||
|
||
/// A query launched from the global toolbar must reveal its editor and
|
||
/// results, even when an Administration page currently owns the workspace.
|
||
private func executeSQLInEditor() {
|
||
// Make the editor the active workspace before starting execution.
|
||
administrationPage = nil
|
||
navigatorMode = 1
|
||
model.runSQL()
|
||
// The SQLTextView is created only after the workspace changes. Request
|
||
// focus on the next main-loop turn, after SwiftUI mounts that view.
|
||
DispatchQueue.main.async {
|
||
sqlFocusRequest += 1
|
||
}
|
||
}
|
||
|
||
// MARK: - Main Toolbar
|
||
|
||
private var mainToolbar: some View {
|
||
HStack(spacing: 16) {
|
||
Image(nsImage: NSImage(named: "DBSynBrand") ?? NSImage(contentsOfFile: Bundle.main.path(forResource: "DBSynBrand", 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"
|
||
) {
|
||
executeSQLInEditor()
|
||
}
|
||
.keyboardShortcut(.return, modifiers: [.command])
|
||
|
||
toolbarButton(
|
||
"Format SQL",
|
||
icon: "text.alignleft"
|
||
) {
|
||
model.formatSQL()
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Menu {
|
||
ForEach(model.profiles) { profile in
|
||
Button {
|
||
model.selectConnection(profile.id)
|
||
} label: {
|
||
if model.selectedID == profile.id {
|
||
Label(profile.name, systemImage: "checkmark.circle.fill")
|
||
} else {
|
||
Label(profile.name, systemImage: "server.rack")
|
||
}
|
||
}
|
||
}
|
||
Divider()
|
||
Button("Manage Connections…") { showingConnectionManager = true }
|
||
} label: {
|
||
Label(model.selected?.name ?? "Select Connection", systemImage: "server.rack")
|
||
.lineLimit(1)
|
||
.frame(maxWidth: 185)
|
||
}
|
||
.help("Switch active MySQL connection")
|
||
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
Text(model.selected?.host ?? "Not connected")
|
||
.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)
|
||
.onChange(of: navigatorMode) { _, newMode in
|
||
if newMode == 1 {
|
||
administrationPage = nil
|
||
sqlFocusRequest += 1
|
||
}
|
||
}
|
||
|
||
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)
|
||
|
||
HStack(spacing: 5) {
|
||
TextField("Filter schemas", text: $schemaFilter)
|
||
.textFieldStyle(.roundedBorder)
|
||
if !schemaFilter.isEmpty {
|
||
Button { schemaFilter = "" } label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Clear schema filter")
|
||
.accessibilityLabel("Clear schema filter")
|
||
}
|
||
}
|
||
.padding(.horizontal, 9)
|
||
.padding(.bottom, 5)
|
||
|
||
Divider()
|
||
|
||
ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: 2) {
|
||
ForEach(model.databases.filter { schemaFilter.isEmpty || $0.localizedCaseInsensitiveContains(schemaFilter) }, 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.selectConnection(profile.id)
|
||
} 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 {
|
||
model.introspect(database: db)
|
||
} label: {
|
||
Label("Introspection — Refresh Cached Structures", systemImage: "memorychip")
|
||
}
|
||
if let snapshot = model.introspection(for: db) {
|
||
Text("Cached: \(snapshot.tables.count) tables, \(snapshot.views.count) views, \(snapshot.columnsByTable.values.reduce(0) { $0 + $1.count }) columns")
|
||
}
|
||
Button("Generate Schema Diagram…") { schemaDiagramDatabase = db }
|
||
Button("Review Schema Changes…") { schemaChangesDatabase = db }
|
||
Button("Compare Schema Structures…") { schemaCompareDatabase = db }
|
||
Button("Compare Table Data…") { dataCompareDatabase = db }
|
||
Divider()
|
||
Button("Set as Default Schema") { model.selectedDatabase = db }
|
||
Button("Filter to This Schema") { schemaFilter = db }
|
||
Button("Clear Schema Filter") { schemaFilter = "" }
|
||
.disabled(schemaFilter.isEmpty)
|
||
Button("Schema Inspector…") {
|
||
schemaManagement = .init(database: db, action: .inspect)
|
||
}
|
||
Divider()
|
||
Button("Copy Schema Name") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(db, forType: .string)
|
||
}
|
||
Button("Send CREATE Statement to SQL Editor") {
|
||
revealSchemaSQL("SHOW CREATE DATABASE `\(db.replacingOccurrences(of: "`", with: "``"))`;")
|
||
}
|
||
Divider()
|
||
Button("Create Schema…") { schemaManagement = .init(database: nil, action: .create) }
|
||
Button("Alter Schema…") { schemaManagement = .init(database: db, action: .alter) }
|
||
Button("Drop Schema…", role: .destructive) {
|
||
schemaManagement = .init(database: db, action: .drop)
|
||
}
|
||
Divider()
|
||
Button("Create Table…") {
|
||
schemaEditorTarget = SchemaEditorTarget(database: db, table: nil)
|
||
}
|
||
Button("Import CSV into New Table…") {
|
||
tableImportTarget = .init(database: db, table: nil)
|
||
}
|
||
Button("Refresh All") {
|
||
model.loadDatabases()
|
||
model.loadTables(database: db)
|
||
}
|
||
}
|
||
|
||
if expandedSchemas.contains(db) {
|
||
schemaChildren(db)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private func revealSchemaSQL(_ statement: String) {
|
||
model.sql = statement
|
||
administrationPage = nil
|
||
navigatorMode = 1
|
||
DispatchQueue.main.async { sqlFocusRequest += 1 }
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
schemaObjectFolder("Views", database: db, objects: model.schemaViews[db] ?? [], kind: .view)
|
||
schemaObjectFolder("Stored Procedures", database: db, objects: model.schemaProcedures[db] ?? [], kind: .procedure)
|
||
schemaObjectFolder("Functions", database: db, objects: model.schemaFunctions[db] ?? [], kind: .function)
|
||
}
|
||
.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("Search Table Data…") {
|
||
tableSearchTarget = .init(database: database, table: table)
|
||
}
|
||
Button("Import CSV Data…") {
|
||
tableImportTarget = .init(database: database, table: table)
|
||
}
|
||
Divider()
|
||
Button("Edit Table Schema…") {
|
||
schemaEditorTarget = SchemaEditorTarget(database: database, table: table)
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum SchemaObjectKind { case view, procedure, function }
|
||
|
||
private func schemaObjectFolder(_ title: String, database: String, objects: [String], kind: SchemaObjectKind) -> some View {
|
||
let key = database + "." + title
|
||
let expanded = expandedObjectFolders.contains(key)
|
||
return VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 5) {
|
||
Image(systemName: expanded ? "chevron.down" : "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))
|
||
Text("\(objects.count)").font(.caption2).foregroundStyle(.secondary)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(height: 24)
|
||
.padding(.leading, 28)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
if expanded { expandedObjectFolders.remove(key) }
|
||
else {
|
||
expandedObjectFolders.insert(key)
|
||
if model.schemaTables[database] == nil { model.loadTables(database: database) }
|
||
}
|
||
}
|
||
|
||
if expanded {
|
||
ForEach(objects, id: \.self) { name in
|
||
HStack(spacing: 5) {
|
||
Image(systemName: kind == .view ? "tablecells" : "curlybraces")
|
||
.foregroundStyle(.secondary).frame(width: 17)
|
||
Text(name).font(.system(size: 12)).lineLimit(1)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(height: 23)
|
||
.padding(.leading, 49)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(count: 2) { openSchemaObject(database: database, name: name, kind: kind) }
|
||
.contextMenu {
|
||
Button(kind == .view ? "Select First Rows" : "Show Definition") {
|
||
openSchemaObject(database: database, name: name, kind: kind)
|
||
}
|
||
Button("Edit Definition…") {
|
||
objectEditorTarget = WorkbenchObjectEditorTarget(
|
||
database: database, name: name,
|
||
kind: kind == .view ? .view : (kind == .procedure ? .procedure : .function)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
if objects.isEmpty {
|
||
Text("No objects (or metadata unavailable)")
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
.padding(.leading, 49).frame(height: 23)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func openSchemaObject(database: String, name: String, kind: SchemaObjectKind) {
|
||
let db = database.replacingOccurrences(of: "`", with: "``")
|
||
let object = name.replacingOccurrences(of: "`", with: "``")
|
||
model.selectedDatabase = database
|
||
switch kind {
|
||
case .view: model.sql = "SELECT * FROM `\(db)`.`\(object)` LIMIT 500;"
|
||
case .procedure: model.sql = "SHOW CREATE PROCEDURE `\(db)`.`\(object)`;"
|
||
case .function: model.sql = "SHOW CREATE FUNCTION `\(db)`.`\(object)`;"
|
||
}
|
||
administrationPage = nil
|
||
navigatorMode = 1
|
||
model.runSQL()
|
||
DispatchQueue.main.async { sqlFocusRequest += 1 }
|
||
}
|
||
|
||
// MARK: - Workspace
|
||
|
||
private var workspace: some View {
|
||
Group {
|
||
if navigatorMode == 0, 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 {
|
||
administrationPage = nil
|
||
navigatorMode = 1
|
||
model.activateQuery(tab.id)
|
||
DispatchQueue.main.async { sqlFocusRequest += 1 }
|
||
} 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 {
|
||
executeSQLInEditor()
|
||
} 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 {
|
||
model.openSQLScript()
|
||
} label: {
|
||
Image(systemName: "folder")
|
||
}
|
||
.help("Open SQL Script…")
|
||
|
||
Button {
|
||
model.saveSQLScript()
|
||
} label: {
|
||
Image(systemName: "square.and.arrow.down")
|
||
}
|
||
.help("Save SQL Script")
|
||
|
||
Menu {
|
||
if model.recentSQLScripts.isEmpty {
|
||
Text("No Recent Scripts")
|
||
} else {
|
||
ForEach(model.recentSQLScripts, id: \.self) { path in
|
||
Button(URL(fileURLWithPath: path).lastPathComponent) {
|
||
model.openRecentSQLScript(at: path)
|
||
}
|
||
}
|
||
Divider()
|
||
Button("Clear Recent Scripts") { model.clearRecentSQLScripts() }
|
||
}
|
||
} label: {
|
||
Image(systemName: "clock.arrow.circlepath")
|
||
}
|
||
.help("Recent SQL Scripts")
|
||
|
||
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, focusRequest: sqlFocusRequest,
|
||
completions: model.schemaColumns[model.completionDatabase] ?? [],
|
||
tables: model.introspection(for: model.completionDatabase)?.tables ?? model.schemaTables[model.completionDatabase] ?? [],
|
||
views: model.introspection(for: model.completionDatabase)?.views ?? model.schemaViews[model.completionDatabase] ?? [],
|
||
columnsByTable: model.introspection(for: model.completionDatabase)?.columnsByTable.mapValues { $0.map(\.name) } ?? [:],
|
||
databases: model.databases)
|
||
}
|
||
.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()
|
||
|
||
Button {
|
||
editResultRow = selectedResultRow
|
||
} label: {
|
||
Label("Edit Row…", systemImage: "pencil")
|
||
}
|
||
.disabled(selectedResultRow == nil || editableResultTarget == nil)
|
||
.help(editableResultTarget == nil
|
||
? "Editing requires a SELECT * from a single base table with a primary key and loaded introspection metadata."
|
||
: selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL")
|
||
|
||
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)
|
||
}
|
||
}
|
||
.sheet(isPresented: Binding(
|
||
get: { editResultRow != nil },
|
||
set: { if !$0 { editResultRow = nil } }
|
||
)) {
|
||
if let row = editResultRow, let target = editableResultTarget {
|
||
DBSynInlineRowEditor(model: model, database: target.database,
|
||
table: target.table, columns: model.result.columns, original: row)
|
||
}
|
||
}
|
||
.onChange(of: model.result.rows) { _, _ in selectedResultRow = nil }
|
||
.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, onSelect: { selectedResultRow = $0 }, onEdit: { row in
|
||
selectedResultRow = row
|
||
if editableResultTarget != nil { editResultRow = row }
|
||
})
|
||
}
|
||
|
||
// Editing is restricted to a simple SELECT * from a single base table.
|
||
// Arbitrary joins, expressions and aliases cannot be mapped safely to rows.
|
||
private var editableResultTarget: (database: String, table: String)? {
|
||
let statements = model.sql.components(separatedBy: ";")
|
||
guard let select = statements.last(where: { $0.range(of: #"(?i)\bSELECT\b"#, options: .regularExpression) != nil }) else { return nil }
|
||
// Permit ordinary filtered/sorted single-table SELECT * results. The
|
||
// primary-key checks in DBSynInlineRowEditor still guard every UPDATE.
|
||
// Joins, grouping, unions and computed result sets remain read-only.
|
||
let pattern = #"(?is)^\s*SELECT\s+\*\s+FROM\s+((?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)(?:\.(?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*))?)(?=\s|$)(.*)$"#
|
||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||
let match = regex.firstMatch(in: select, range: NSRange(select.startIndex..., in: select)),
|
||
let range = Range(match.range(at: 1), in: select) else { return nil }
|
||
guard let tailRange = Range(match.range(at: 2), in: select) else { return nil }
|
||
let tail = String(select[tailRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
// Only permit WHERE / ORDER BY / LIMIT clauses; do not treat joins,
|
||
// unions, grouped rows, or derived expressions as directly editable.
|
||
if !tail.isEmpty {
|
||
guard tail.range(of: #"(?i)^(WHERE|ORDER\s+BY|LIMIT)\b"#, options: .regularExpression) != nil,
|
||
tail.range(of: #"(?i)\b(JOIN|UNION|GROUP\s+BY|HAVING|INTO|FOR\s+UPDATE|LOCK\s+IN\s+SHARE\s+MODE)\b"#, options: .regularExpression) == nil,
|
||
!tail.contains(";") else { return nil }
|
||
}
|
||
let parts = String(select[range]).replacingOccurrences(of: "`", with: "").components(separatedBy: ".")
|
||
let database = parts.count == 2 ? parts[0] : model.completionDatabase
|
||
let table = parts.last ?? ""
|
||
guard !database.isEmpty, let snapshot = model.introspection(for: database),
|
||
snapshot.tables.contains(where: { $0.caseInsensitiveCompare(table) == .orderedSame }),
|
||
let fields = snapshot.columnsByTable[table],
|
||
fields.map(\.name).map({ $0.lowercased() }) == model.result.columns.map({ $0.lowercased() }),
|
||
fields.contains(where: { $0.key == "PRI" }) else { return nil }
|
||
return (database, table)
|
||
}
|
||
|
||
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 = ""
|
||
@State private var resultFilter = 0
|
||
@State private var selectedConnection = "All connections"
|
||
|
||
private var connections: [String] {
|
||
["All connections"] + Array(Set(model.queryHistory.map(\.connectionName))).sorted()
|
||
}
|
||
|
||
private var entries: [SQLHistoryEntry] {
|
||
model.queryHistory.filter { entry in
|
||
(filter.isEmpty || entry.sql.localizedCaseInsensitiveContains(filter) ||
|
||
entry.connectionName.localizedCaseInsensitiveContains(filter) ||
|
||
entry.database.localizedCaseInsensitiveContains(filter) ||
|
||
entry.detail.localizedCaseInsensitiveContains(filter)) &&
|
||
(resultFilter == 0 || (resultFilter == 1 ? entry.succeeded : !entry.succeeded)) &&
|
||
(selectedConnection == "All connections" || entry.connectionName == selectedConnection)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("Query History").font(.title2).bold()
|
||
Spacer()
|
||
Text("\(entries.count) of \(model.queryHistory.count)")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Button("Clear History", role: .destructive) { model.clearQueryHistory() }
|
||
.disabled(model.queryHistory.isEmpty)
|
||
Button("Done") { dismiss() }
|
||
}
|
||
HStack {
|
||
TextField("Filter SQL, database, or error", text: $filter)
|
||
.textFieldStyle(.roundedBorder)
|
||
Picker("Result", selection: $resultFilter) {
|
||
Text("All").tag(0)
|
||
Text("Succeeded").tag(1)
|
||
Text("Failed").tag(2)
|
||
}.frame(width: 155)
|
||
Picker("Connection", selection: $selectedConnection) {
|
||
ForEach(connections, id: \.self) { Text($0).tag($0) }
|
||
}.frame(width: 190)
|
||
}
|
||
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("Copy SQL") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(entry.sql, forType: .string)
|
||
}
|
||
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: 800, minHeight: 480)
|
||
}
|
||
}
|
||
|
||
private struct SavedSQLQueriesSheet: View {
|
||
@ObservedObject var model: DatabaseModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var filter = ""
|
||
@State private var editingID: UUID?
|
||
@State private var editedName = ""
|
||
@State private var editedSQL = ""
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("Saved SQL Queries").font(.title2).bold()
|
||
Spacer()
|
||
Text("\(model.savedQueries.count) saved")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
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("Copy SQL") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(entry.sql, forType: .string)
|
||
}
|
||
Button("Edit") {
|
||
editingID = entry.id
|
||
editedName = entry.name
|
||
editedSQL = entry.sql
|
||
}
|
||
Button("Open") {
|
||
model.openSavedQuery(entry)
|
||
dismiss()
|
||
}
|
||
Button(role: .destructive) {
|
||
model.deleteSavedQuery(entry.id)
|
||
} label: {
|
||
Image(systemName: "trash")
|
||
}
|
||
}.padding(.vertical, 5)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 750, minHeight: 450)
|
||
.sheet(isPresented: Binding(
|
||
get: { editingID != nil },
|
||
set: { if !$0 { editingID = nil } }
|
||
)) {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Edit Saved Query").font(.title2).bold()
|
||
TextField("Query name", text: $editedName)
|
||
TextEditor(text: $editedSQL)
|
||
.font(.system(.body, design: .monospaced))
|
||
.frame(minHeight: 220)
|
||
.border(Color.secondary.opacity(0.3))
|
||
HStack {
|
||
Spacer()
|
||
Button("Cancel") { editingID = nil }
|
||
Button("Save Changes") {
|
||
if let id = editingID {
|
||
model.updateSavedQuery(id, name: editedName, sql: editedSQL)
|
||
}
|
||
editingID = nil
|
||
}
|
||
.keyboardShortcut(.defaultAction)
|
||
.disabled(editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
||
editedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 620, minHeight: 350)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Native SQL Text Editor
|
||
|
||
struct SQLTextView: NSViewRepresentable {
|
||
@Binding var text: String
|
||
@Binding var selectedSQL: String
|
||
var focusRequest: Int = 0
|
||
var completions: [String] = []
|
||
var tables: [String] = []
|
||
var views: [String] = []
|
||
var columnsByTable: [String: [String]] = [:]
|
||
var databases: [String] = []
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(self)
|
||
}
|
||
|
||
func makeNSView(context: Context) -> NSScrollView {
|
||
let scroll = NSTextView.scrollableTextView()
|
||
guard let editor = scroll.documentView as? NSTextView else {
|
||
assertionFailure("NSTextView.scrollableTextView() did not provide an NSTextView")
|
||
return scroll
|
||
}
|
||
|
||
let nativeAppearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua)
|
||
scroll.appearance = nativeAppearance
|
||
editor.appearance = nativeAppearance
|
||
editor.backgroundColor = .textBackgroundColor
|
||
editor.textColor = .textColor
|
||
scroll.backgroundColor = .textBackgroundColor
|
||
|
||
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.isAutomaticTextCompletionEnabled = false
|
||
editor.string = text
|
||
|
||
context.coordinator.highlight(editor)
|
||
// The editor may be mounted after the Schemas navigation transition.
|
||
DispatchQueue.main.async { [weak editor] in
|
||
guard let editor, let window = editor.window else { return }
|
||
window.makeFirstResponder(editor)
|
||
}
|
||
|
||
return scroll
|
||
}
|
||
|
||
func updateNSView(
|
||
_ scroll: NSScrollView,
|
||
context: Context
|
||
) {
|
||
guard let editor = scroll.documentView as? NSTextView else {
|
||
return
|
||
}
|
||
context.coordinator.parent = self
|
||
let nativeAppearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua)
|
||
scroll.appearance = nativeAppearance
|
||
editor.appearance = nativeAppearance
|
||
editor.backgroundColor = .textBackgroundColor
|
||
editor.textColor = .textColor
|
||
scroll.backgroundColor = .textBackgroundColor
|
||
if context.coordinator.lastFocusRequest != focusRequest {
|
||
context.coordinator.lastFocusRequest = focusRequest
|
||
DispatchQueue.main.async { [weak editor] in
|
||
guard let editor, let window = editor.window else { return }
|
||
window.makeFirstResponder(editor)
|
||
}
|
||
}
|
||
|
||
if editor.string != text {
|
||
let selection = editor.selectedRange()
|
||
editor.string = text
|
||
|
||
let safeLocation = selection.location == NSNotFound
|
||
? 0
|
||
: 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
|
||
var lastFocusRequest: Int
|
||
private var pendingCompletion: DispatchWorkItem?
|
||
private var showingCompletion = false
|
||
private var suggestionPopover: NSPopover?
|
||
private var keyMonitor: Any?
|
||
private var suggestionItems: [String] = []
|
||
private var selectedSuggestion = 0
|
||
private var replacementRange = NSRange(location: 0, length: 0)
|
||
|
||
deinit {
|
||
pendingCompletion?.cancel()
|
||
suggestionPopover?.close()
|
||
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
|
||
}
|
||
|
||
private func dismissSuggestions() {
|
||
suggestionPopover?.close()
|
||
suggestionPopover = nil
|
||
suggestionItems = []
|
||
if let keyMonitor { NSEvent.removeMonitor(keyMonitor); self.keyMonitor = nil }
|
||
}
|
||
|
||
private func acceptSuggestion(_ editor: NSTextView) {
|
||
guard suggestionItems.indices.contains(selectedSuggestion) else { return }
|
||
let value = suggestionItems[selectedSuggestion]
|
||
let range = replacementRange
|
||
dismissSuggestions()
|
||
guard NSMaxRange(range) <= (editor.string as NSString).length else { return }
|
||
editor.insertText(value, replacementRange: range)
|
||
parent.text = editor.string
|
||
highlight(editor)
|
||
}
|
||
|
||
private func scheduleSuggestions(_ editor: NSTextView) {
|
||
pendingCompletion?.cancel()
|
||
let task = DispatchWorkItem { [weak self, weak editor] in
|
||
guard let self, let editor else { return }
|
||
self.showSuggestions(editor)
|
||
}
|
||
pendingCompletion = task
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: task)
|
||
}
|
||
|
||
private func showSuggestions(_ editor: NSTextView) {
|
||
guard editor.window != nil else { dismissSuggestions(); return }
|
||
let selection = editor.selectedRange()
|
||
guard selection.length == 0, selection.location != NSNotFound else { dismissSuggestions(); return }
|
||
let source = editor.string as NSString
|
||
guard selection.location <= source.length else { dismissSuggestions(); return }
|
||
let before = source.substring(to: selection.location)
|
||
guard let expression = try? NSRegularExpression(pattern: #"[A-Za-z_][A-Za-z0-9_]*$"#) else {
|
||
dismissSuggestions()
|
||
return
|
||
}
|
||
let beforeRange = NSRange(location: 0, length: (before as NSString).length)
|
||
guard let match = expression.matches(in: before, range: beforeRange).last else { dismissSuggestions(); return }
|
||
let prefix = (before as NSString).substring(with: match.range)
|
||
guard prefix.count >= 2 else { dismissSuggestions(); return }
|
||
let leading = (before as NSString).substring(to: match.range.location)
|
||
let upper = leading.uppercased()
|
||
let candidates: [String]
|
||
if upper.range(of: #"\bUSE\s+`?$"#, options: .regularExpression) != nil {
|
||
candidates = parent.databases
|
||
} else if upper.range(of: #"\b(FROM|JOIN|INTO|UPDATE|TABLE|DESCRIBE)\s+`?$"#, options: .regularExpression) != nil {
|
||
candidates = parent.tables + parent.views
|
||
} else if let dot = leading.range(of: #"`?([A-Za-z_][A-Za-z0-9_]*)`?\.$"#, options: .regularExpression) {
|
||
let table = String(leading[dot]).replacingOccurrences(of: "`", with: "").dropLast()
|
||
candidates = parent.columnsByTable.first(where: { $0.key.caseInsensitiveCompare(String(table)) == .orderedSame })?.value ?? []
|
||
} else {
|
||
candidates = completionWords + parent.tables + parent.views
|
||
}
|
||
let matches = Array(Set(candidates)).filter {
|
||
$0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil && $0.caseInsensitiveCompare(prefix) != .orderedSame
|
||
}.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||
suggestionItems = Array(matches.prefix(30))
|
||
replacementRange = match.range
|
||
selectedSuggestion = 0
|
||
guard !suggestionItems.isEmpty else { dismissSuggestions(); return }
|
||
dismissPopoverOnly()
|
||
let popover = NSPopover()
|
||
popover.behavior = .applicationDefined
|
||
let items = suggestionItems
|
||
let content = NSHostingView(rootView: VStack(alignment: .leading, spacing: 0) {
|
||
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
|
||
Text(item)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(index == 0 ? Color.accentColor.opacity(0.16) : Color.clear)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
self.selectedSuggestion = index
|
||
self.acceptSuggestion(editor)
|
||
}
|
||
}
|
||
}.padding(.vertical, 5).frame(width: 260))
|
||
content.frame = NSRect(x: 0, y: 0, width: 260, height: min(CGFloat(items.count) * 27 + 10, 350))
|
||
popover.contentViewController = NSViewController()
|
||
popover.contentViewController?.view = content
|
||
popover.contentSize = content.frame.size
|
||
guard let layout = editor.layoutManager, let container = editor.textContainer else { return }
|
||
let glyph = layout.glyphIndexForCharacter(at: max(0, selection.location - 1))
|
||
var rect = layout.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), in: container)
|
||
rect.origin.x += editor.textContainerOrigin.x
|
||
rect.origin.y += editor.textContainerOrigin.y
|
||
rect.origin.x += rect.width
|
||
rect.size.width = 2
|
||
popover.show(relativeTo: rect, of: editor, preferredEdge: .maxY)
|
||
suggestionPopover = popover
|
||
if keyMonitor == nil {
|
||
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self, weak editor] event in
|
||
guard let self, let editor, self.suggestionPopover?.isShown == true,
|
||
let window = editor.window, window.isKeyWindow,
|
||
window.firstResponder === editor else { return event }
|
||
switch event.keyCode {
|
||
case 53: self.dismissSuggestions(); return nil // Escape
|
||
case 36, 48: self.acceptSuggestion(editor); return nil // Return / Tab
|
||
case 125: self.selectedSuggestion = min(self.selectedSuggestion + 1, self.suggestionItems.count - 1); self.refreshSelection(); return nil
|
||
case 126: self.selectedSuggestion = max(0, self.selectedSuggestion - 1); self.refreshSelection(); return nil
|
||
default: return event
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func dismissPopoverOnly() {
|
||
suggestionPopover?.close()
|
||
suggestionPopover = nil
|
||
}
|
||
|
||
private func refreshSelection() {
|
||
// Refresh the highlighted item without changing the editor's selection.
|
||
guard let popover = suggestionPopover else { return }
|
||
let items = suggestionItems
|
||
let selected = selectedSuggestion
|
||
let view = NSHostingView(rootView: VStack(alignment: .leading, spacing: 0) {
|
||
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
|
||
Text(item).font(.system(size: 12, design: .monospaced))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||
.background(index == selected ? Color.accentColor.opacity(0.16) : Color.clear)
|
||
}
|
||
}.padding(.vertical, 5).frame(width: 260))
|
||
view.frame = NSRect(origin: .zero, size: popover.contentSize)
|
||
popover.contentViewController?.view = view
|
||
}
|
||
|
||
init(_ parent: SQLTextView) {
|
||
self.parent = parent
|
||
self.lastFocusRequest = parent.focusRequest
|
||
}
|
||
|
||
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 source = textView.string as NSString
|
||
guard charRange.location != NSNotFound,
|
||
NSMaxRange(charRange) <= source.length else { return [] }
|
||
let prefix = source.substring(with: charRange)
|
||
guard !prefix.isEmpty else { return [] }
|
||
let before = source.substring(to: charRange.location)
|
||
let trailing = String(before.suffix(300))
|
||
let upper = trailing.uppercased()
|
||
let tableContext = upper.range(of: #"\b(FROM|JOIN|INTO|UPDATE|TABLE|DESCRIBE)\s+[`\w.]*$"#, options: .regularExpression) != nil
|
||
let databaseContext = upper.range(of: #"\bUSE\s+[`\w]*$"#, options: .regularExpression) != nil
|
||
let qualified = trailing.range(of: #"`?([A-Za-z_][A-Za-z0-9_]*)`?\.$"#, options: .regularExpression)
|
||
let candidates: [String]
|
||
if databaseContext {
|
||
candidates = parent.databases
|
||
} else if tableContext {
|
||
candidates = parent.tables + parent.views
|
||
} else if let qualified {
|
||
let token = String(trailing[qualified]).replacingOccurrences(of: "`", with: "").dropLast()
|
||
candidates = parent.columnsByTable.first(where: { $0.key.caseInsensitiveCompare(String(token)) == .orderedSame })?.value ?? []
|
||
} else {
|
||
candidates = completionWords + parent.tables + parent.views + parent.completions.filter { !$0.contains(".") }
|
||
}
|
||
return Array(Set(candidates)).filter {
|
||
$0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil
|
||
}.sorted().prefix(40).map { $0 }
|
||
}
|
||
|
||
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)
|
||
// Do not invoke AppKit's completion panel from textDidChange.
|
||
// That re-entrant path caused the previous editor freezes/crashes.
|
||
// Schedule outside the AppKit edit callback to avoid re-entrant completion.
|
||
scheduleSuggestions(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
|
||
)
|
||
}
|
||
}
|
||
|
||
// MySQL # comments are muted, including the entire line.
|
||
if let comments = try? NSRegularExpression(pattern: #"(?m)^[ \t]*#.*$"#) {
|
||
for match in comments.matches(in: editor.string, range: range) {
|
||
storage.addAttribute(.foregroundColor,
|
||
value: NSColor.secondaryLabelColor,
|
||
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
|
||
}
|
||
}
|
||
|
||
// Phase 63: A read-only, explainable query-plan health summary.
|
||
// This intentionally does not execute the SELECT or generate index DDL.
|
||
private struct PlanFinding: Identifiable {
|
||
let id: Int
|
||
let severity: String
|
||
let title: String
|
||
let guidance: String
|
||
}
|
||
|
||
private var planFindings: [PlanFinding] {
|
||
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 findings: [PlanFinding] = []
|
||
for (step, row) in plan.rows.enumerated() {
|
||
let table = field(row, "table")
|
||
let target = table.isEmpty || table.uppercased() == "NULL" ? "Step \(step + 1)" : table
|
||
let access = field(row, "type").uppercased()
|
||
let extra = field(row, "extra").lowercased()
|
||
let estimatedRows = Int(field(row, "rows")) ?? 0
|
||
let key = field(row, "key")
|
||
if access == "ALL" && estimatedRows >= 10_000 {
|
||
findings.append(PlanFinding(id: findings.count, severity: "Review", title: "Large full scan: \(target)", guidance: "MySQL estimates \(estimatedRows.formatted()) rows. Inspect filters, JOIN conditions and available indexes; verify with representative data."))
|
||
} else if access == "ALL" {
|
||
findings.append(PlanFinding(id: findings.count, severity: "Info", title: "Full scan: \(target)", guidance: "A scan may be efficient for small tables. Check row counts and predicate selectivity before changing indexes."))
|
||
}
|
||
if extra.contains("using filesort") {
|
||
findings.append(PlanFinding(id: findings.count, severity: "Review", title: "Additional sorting: \(target)", guidance: "Review ORDER BY/GROUP BY columns and index ordering. Filesort does not necessarily imply disk I/O."))
|
||
}
|
||
if extra.contains("using temporary") {
|
||
findings.append(PlanFinding(id: findings.count, severity: "Review", title: "Temporary result: \(target)", guidance: "Inspect grouping, DISTINCT and derived tables. Temporary results are not inherently slow."))
|
||
}
|
||
if access == "ALL" && key.uppercased() != "NULL" && !key.isEmpty {
|
||
findings.append(PlanFinding(id: findings.count, severity: "Info", title: "Scan despite key: \(target)", guidance: "Inspect why the optimizer chose a scan rather than relying on the reported key value alone."))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
private var planHealthLabel: String {
|
||
guard !plan.columns.isEmpty else { return "No plan" }
|
||
if useJSON { return "JSON plan — inspect manually" }
|
||
if planFindings.contains(where: { $0.severity == "Review" }) { return "Review suggested" }
|
||
return planFindings.isEmpty ? "No flagged patterns" : "Informational observations"
|
||
}
|
||
|
||
private var planSummaryText: String {
|
||
let lines = planFindings.map { "[\($0.severity)] \($0.title): \($0.guidance)" }
|
||
return (["DBSyn Query Plan Review", "Status: \(planHealthLabel)", "Optimizer steps: \(plan.rows.count)", "", "SQL:", normalizedStatement, "", "Findings:"] + (lines.isEmpty ? ["No flagged patterns in tabular EXPLAIN."] : lines) + ["", "These are estimates and heuristics, not measured execution times or index DDL."]).joined(separator: "\n")
|
||
}
|
||
|
||
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 !plan.columns.isEmpty {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Label("Query Plan Review", systemImage: "waveform.path.ecg.rectangle")
|
||
.font(.headline)
|
||
Text(planHealthLabel)
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Text("\(plan.rows.count) optimizer steps")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Button("Copy Review") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(planSummaryText, forType: .string)
|
||
}
|
||
}
|
||
if useJSON {
|
||
Text("Switch to Table format for individual scan, sort and temporary-result findings.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
} else if planFindings.isEmpty {
|
||
Text("No patterns flagged by the current conservative rules. This does not guarantee a fast query.")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ForEach(planFindings) { finding in
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Image(systemName: finding.severity == "Review" ? "exclamationmark.triangle" : "info.circle")
|
||
.foregroundStyle(finding.severity == "Review" ? Color.orange : Color.secondary)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(finding.title).font(.callout.weight(.semibold))
|
||
Text(finding.guidance).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxHeight: 130)
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(Color(nsColor: .controlBackgroundColor))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
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
|
||
@State private var testMessage = ""
|
||
@State private var testing = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
HStack {
|
||
Label("Connection Settings", systemImage: "server.rack").font(.title2.bold())
|
||
Spacer()
|
||
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") }
|
||
.buttonStyle(.plain).help("Close")
|
||
}
|
||
Form {
|
||
Section("MySQL") {
|
||
TextField("Connection Name", text: $profile.name)
|
||
TextField("Hostname", text: $profile.host)
|
||
TextField("Port", value: $profile.port, format: .number.grouping(.never))
|
||
TextField("Username", text: $profile.username)
|
||
SecureField("Password (Keychain)", text: $password)
|
||
TextField("Default Schema", text: $profile.database)
|
||
}
|
||
Section("Secure Transport") {
|
||
Toggle("Require verified TLS for direct remote connections", isOn: $profile.useTLS)
|
||
.disabled(profile.useSSHTunnel)
|
||
Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel)
|
||
if profile.useSSHTunnel {
|
||
TextField("SSH Host", text: $profile.sshHost)
|
||
TextField("SSH Port", value: $profile.sshPort, format: .number.grouping(.never))
|
||
TextField("SSH Username", text: $profile.sshUser)
|
||
TextField("SSH Private Key (optional)", text: $profile.sshIdentityFile)
|
||
Text("Uses your system SSH keys and known_hosts. MySQL hostname is resolved from the SSH server.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
} else {
|
||
Text("Direct remote MySQL uses certificate-verified TLS. The server certificate must match its hostname and be trusted by macOS.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
if !testMessage.isEmpty {
|
||
Text(testMessage).font(.callout).foregroundStyle(testMessage.hasPrefix("Connected") ? .green : .red)
|
||
.textSelection(.enabled)
|
||
}
|
||
HStack {
|
||
Button(testing ? "Testing…" : "Test Connection") {
|
||
testing = true
|
||
testMessage = ""
|
||
let candidate = profile
|
||
let secret = password
|
||
Task {
|
||
defer { testing = false }
|
||
do {
|
||
let result = try await NativeMySQLRunner.run(profile: candidate, database: candidate.database, password: secret, sql: "SELECT VERSION()")
|
||
testMessage = "Connected: " + (result.rows.first?.first ?? "MySQL")
|
||
} catch { testMessage = error.localizedDescription }
|
||
NativeMySQLRunner.disconnect(profileID: candidate.id)
|
||
}
|
||
}.disabled(testing)
|
||
Spacer()
|
||
Button("Cancel") { dismiss() }
|
||
Button("Save") { save() }.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
.padding(24)
|
||
.frame(minWidth: 720, idealWidth: 780, minHeight: 660)
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// MARK: - High Performance Native Result Grid
|
||
|
||
struct NativeResultGrid: NSViewRepresentable {
|
||
|
||
let columns: [String]
|
||
let rows: [[String]]
|
||
var hiddenColumnIndices: Set<Int> = []
|
||
var onSelect: (([String]?) -> Void)? = nil
|
||
var onEdit: (([String]) -> Void)? = nil
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
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 = .textBackgroundColor
|
||
|
||
let table = NSTableView()
|
||
table.headerView = NSTableHeaderView()
|
||
let nativeAppearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua)
|
||
scroll.appearance = nativeAppearance
|
||
table.appearance = nativeAppearance
|
||
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 = .textBackgroundColor
|
||
|
||
context.coordinator.columns = columns
|
||
context.coordinator.rows = rows
|
||
context.coordinator.onSelect = onSelect
|
||
context.coordinator.onEdit = onEdit
|
||
context.coordinator.tableView = table
|
||
|
||
let menu = NSMenu()
|
||
let editItem = NSMenuItem(title: "Edit Row…", action: #selector(Coordinator.editClickedRow(_:)), keyEquivalent: "")
|
||
editItem.target = context.coordinator
|
||
menu.addItem(editItem)
|
||
table.menu = menu
|
||
|
||
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 nativeAppearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua)
|
||
scroll.appearance = nativeAppearance
|
||
table.appearance = nativeAppearance
|
||
scroll.backgroundColor = .textBackgroundColor
|
||
table.backgroundColor = .textBackgroundColor
|
||
|
||
let oldColumns = context.coordinator.columns
|
||
|
||
context.coordinator.columns = columns
|
||
context.coordinator.rows = rows
|
||
context.coordinator.onSelect = onSelect
|
||
context.coordinator.onEdit = onEdit
|
||
|
||
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]] = []
|
||
var onSelect: (([String]?) -> Void)?
|
||
var onEdit: (([String]) -> Void)?
|
||
weak var tableView: NSTableView?
|
||
|
||
@objc func editClickedRow(_ sender: NSMenuItem) {
|
||
guard let table = tableView else { return }
|
||
let index = table.clickedRow >= 0 ? table.clickedRow : table.selectedRow
|
||
guard rows.indices.contains(index) else { return }
|
||
onEdit?(rows[index])
|
||
}
|
||
|
||
func tableViewSelectionDidChange(_ notification: Notification) {
|
||
guard let table = notification.object as? NSTableView else { return }
|
||
let index = table.selectedRow
|
||
onSelect?(index >= 0 && index < rows.count ? rows[index] : nil)
|
||
}
|
||
|
||
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 optionsFile = "Options File"
|
||
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 .optionsFile: return "doc.text"
|
||
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 WHERE Variable_name IN (
|
||
'Uptime', 'Connections', 'Max_used_connections',
|
||
'Threads_connected', 'Threads_running', 'Threads_cached',
|
||
'Queries', 'Questions', 'Slow_queries',
|
||
'Bytes_received', 'Bytes_sent',
|
||
'Aborted_clients', 'Aborted_connects',
|
||
'Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads',
|
||
'Innodb_data_reads', 'Innodb_data_writes',
|
||
'Created_tmp_tables', 'Created_tmp_disk_tables'
|
||
)
|
||
"""
|
||
|
||
case .clientConnections:
|
||
return "SHOW FULL PROCESSLIST"
|
||
|
||
case .users:
|
||
return """
|
||
SELECT User, Host, plugin, account_locked, max_questions, max_updates, max_connections, max_user_connections
|
||
FROM mysql.user
|
||
ORDER BY User, Host
|
||
"""
|
||
|
||
case .optionsFile:
|
||
return "SHOW GLOBAL VARIABLES"
|
||
|
||
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"
|
||
}
|
||
|
||
private enum DBSynAccountTab: String, CaseIterable, Identifiable {
|
||
case login = "Login"
|
||
case limits = "Account Limits"
|
||
case roles = "Administrative Roles"
|
||
case schemas = "Schema Privileges"
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
private struct DBSynOptionsInspector: View {
|
||
let columns: [String]
|
||
let rows: [[String]]
|
||
@State private var category = "General"
|
||
@State private var query = ""
|
||
|
||
private let categories: [(String, [String])] = [
|
||
("General", ["autocommit", "event_scheduler", "server_id", "version", "basedir", "datadir"]),
|
||
("Logging", ["log_error", "general_log", "slow_query_log", "long_query_time", "log_bin"]),
|
||
("InnoDB", ["innodb_", "transaction_isolation"]),
|
||
("Networking", ["port", "bind_address", "max_connections", "connect_timeout", "wait_timeout"]),
|
||
("Advanced", ["sql_mode", "max_allowed_packet", "table_open_cache", "thread_cache_size"]),
|
||
("Security", ["require_secure_transport", "tls_version", "ssl_", "default_authentication_plugin"]),
|
||
("Replication", ["server_id", "log_bin", "replica_", "slave_"]),
|
||
("MyISAM", ["myisam_", "key_buffer_size"]),
|
||
("Performance", ["performance_schema", "query_cache", "tmp_table_size", "sort_buffer_size"])
|
||
]
|
||
private var displayed: [[String]] {
|
||
let patterns = categories.first(where: { $0.0 == category })?.1 ?? []
|
||
return rows.filter { row in
|
||
guard row.count > 1 else { return false }
|
||
let key = row[0].lowercased()
|
||
return patterns.contains(where: { key == $0 || ($0.hasSuffix("_") && key.hasPrefix($0)) })
|
||
&& (query.isEmpty || row.joined(separator: " ").localizedCaseInsensitiveContains(query))
|
||
}
|
||
}
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("MySQL Server Options").font(.title2.bold())
|
||
Spacer()
|
||
TextField("Locate option", text: $query).textFieldStyle(.roundedBorder).frame(width: 230)
|
||
}
|
||
Picker("Category", selection: $category) {
|
||
ForEach(categories.map { $0.0 }, id: \.self) { group in Text(group).tag(group) }
|
||
}.pickerStyle(.segmented)
|
||
Text("Live server variables (read-only). These are not the contents of my.cnf. Changing configuration files requires server filesystem access and a separate reviewed workflow.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: 0) {
|
||
ForEach(displayed.indices, id: \.self) { index in
|
||
let row = displayed[index]
|
||
HStack(alignment: .top) {
|
||
Text(row[0]).font(.system(.body, design: .monospaced)).frame(width: 260, alignment: .leading)
|
||
Text(row[1]).font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(10)
|
||
Divider()
|
||
}
|
||
}
|
||
}
|
||
}.padding(18)
|
||
}
|
||
}
|
||
|
||
// Resource limits are per MySQL account (username + host), not per saved connection.
|
||
private struct DBSynAccountLimitsEditor: View {
|
||
let profile: ConnectionProfile
|
||
let account: [String]
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var queries = ""
|
||
@State private var updates = ""
|
||
@State private var connections = ""
|
||
@State private var concurrent = ""
|
||
@State private var applying = false
|
||
@State private var error: String?
|
||
@State private var completed = false
|
||
|
||
private func quoted(_ value: String) -> String {
|
||
"'" + value.replacingOccurrences(of: "'", with: "''") + "'"
|
||
}
|
||
private var values: [String] { [queries, updates, connections, concurrent] }
|
||
private var valid: Bool {
|
||
values.allSatisfy { value in
|
||
guard let number = UInt32(value) else { return false }
|
||
return String(number) == value
|
||
}
|
||
}
|
||
private var changed: Bool {
|
||
account.count >= 8 && values != Array(account[4...7])
|
||
}
|
||
private var statement: String {
|
||
guard account.count >= 8, valid else { return "" }
|
||
return "ALTER USER \(quoted(account[0]))@\(quoted(account[1])) WITH MAX_QUERIES_PER_HOUR \(queries) MAX_UPDATES_PER_HOUR \(updates) MAX_CONNECTIONS_PER_HOUR \(connections) MAX_USER_CONNECTIONS \(concurrent)"
|
||
}
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Text("Account Limits").font(.title2.bold())
|
||
Text("\(quoted(account[0]))@\(quoted(account[1]))")
|
||
.font(.system(.body, design: .monospaced)).textSelection(.enabled)
|
||
Form {
|
||
TextField("Maximum queries per hour", text: $queries)
|
||
TextField("Maximum updates per hour", text: $updates)
|
||
TextField("Maximum connections per hour", text: $connections)
|
||
TextField("Maximum concurrent connections", text: $concurrent)
|
||
}
|
||
.textFieldStyle(.roundedBorder)
|
||
Text("Enter non-negative whole numbers (0 = unlimited). Requires ALTER USER permission. Changes take effect on the MySQL server.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Text("SQL preview").font(.headline)
|
||
ScrollView {
|
||
Text(valid ? statement + ";" : "Enter valid whole numbers in all four fields.")
|
||
.font(.system(.caption, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(height: 78).padding(10)
|
||
.background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
|
||
if let error {
|
||
Text(error).foregroundStyle(.red).textSelection(.enabled)
|
||
}
|
||
if completed { Text("Account limits updated.").foregroundStyle(.green) }
|
||
HStack {
|
||
Button("Revert") { loadOriginal(); error = nil; completed = false }
|
||
.disabled(applying || !changed)
|
||
Spacer()
|
||
Button("Close") { dismiss() }
|
||
Button("Apply") { Task { await apply() } }
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(!valid || !changed || applying)
|
||
}
|
||
}
|
||
.padding(24)
|
||
.frame(minWidth: 640, minHeight: 440)
|
||
.onAppear { loadOriginal() }
|
||
}
|
||
private func loadOriginal() {
|
||
guard account.count >= 8 else { return }
|
||
queries = account[4]; updates = account[5]
|
||
connections = account[6]; concurrent = account[7]
|
||
}
|
||
@MainActor
|
||
private func apply() async {
|
||
guard valid, changed, !applying else { return }
|
||
applying = true
|
||
error = nil
|
||
completed = false
|
||
defer { applying = false }
|
||
do {
|
||
_ = try await NativeMySQLRunner.run(
|
||
profile: profile, database: "",
|
||
password: PasswordStore.read(profile.id), sql: statement
|
||
)
|
||
completed = true
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
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 accountTab: DBSynAccountTab = .login
|
||
@State private var selectedSchema: String = ""
|
||
@State private var grants: [String] = []
|
||
@State private var grantsError: String? = nil
|
||
@State private var showingGrants = false
|
||
@State private var showingAccountManager = false
|
||
@State private var editingAccountLimits = 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
|
||
@State private var selectedLogEntry: DBSynLogDetail?
|
||
@State private var showingFullError = 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
|
||
}
|
||
}
|
||
|
||
// Phase 69: advisory diagnostics from the already loaded server snapshot.
|
||
// No additional queries, writes, or persistent SQL text are required.
|
||
private var diagnosticFindings: [String] {
|
||
func cell(_ row: [String], _ name: String) -> String {
|
||
guard let index = result.columns.firstIndex(where: { $0.caseInsensitiveCompare(name) == .orderedSame }),
|
||
index < row.count else { return "" }
|
||
return row[index]
|
||
}
|
||
func number(_ row: [String], _ name: String) -> Double {
|
||
Double(cell(row, name)) ?? 0
|
||
}
|
||
switch page {
|
||
case .slowQueries:
|
||
return result.rows.prefix(100).enumerated().compactMap { position, row in
|
||
let calls = number(row, "COUNT_STAR")
|
||
let noIndex = number(row, "SUM_NO_INDEX_USED")
|
||
let examined = number(row, "SUM_ROWS_EXAMINED")
|
||
let sent = number(row, "SUM_ROWS_SENT")
|
||
let digest = cell(row, "DIGEST_TEXT")
|
||
let label = digest.isEmpty ? "Digest #\(position + 1)" : String(digest.prefix(110))
|
||
if calls > 0 && noIndex > 0 {
|
||
return "\(label): \(Int(noIndex)) of \(Int(calls)) executions reported no index usage. Inspect EXPLAIN and predicates."
|
||
}
|
||
if calls > 0 && examined > 10000 && sent < examined / 100 {
|
||
return "\(label): high examined-to-returned row ratio. Check filters and index selectivity."
|
||
}
|
||
return nil
|
||
}
|
||
case .tableIO:
|
||
return result.rows.prefix(20).compactMap { row in
|
||
let reads = number(row, "COUNT_READ")
|
||
let writes = number(row, "COUNT_WRITE")
|
||
guard reads + writes > 10000 else { return nil }
|
||
let table = "\(cell(row, "OBJECT_SCHEMA")).\(cell(row, "OBJECT_NAME"))"
|
||
return "\(table): \(Int(reads)) reads, \(Int(writes)) writes since counters were reset. Review workload before tuning."
|
||
}
|
||
case .innodbLocks:
|
||
guard !result.rows.isEmpty else { return [] }
|
||
return ["\(result.rows.count) lock-wait relationship(s) visible. Inspect active transactions before taking action."]
|
||
case .connectionStatistics:
|
||
let values = Dictionary(uniqueKeysWithValues: result.rows.compactMap { row -> (String, Double)? in
|
||
guard row.count >= 2, let value = Double(row[1]) else { return nil }
|
||
return (row[0], value)
|
||
})
|
||
var findings: [String] = []
|
||
if (values["Aborted_connects"] ?? 0) > 0 {
|
||
findings.append("Aborted connections recorded: \(Int(values["Aborted_connects"] ?? 0)). Check credentials, network failures and connection limits.")
|
||
}
|
||
if (values["Created_tmp_disk_tables"] ?? 0) > 0 {
|
||
findings.append("Disk temporary tables recorded: \(Int(values["Created_tmp_disk_tables"] ?? 0)). Review heavy grouping and sorting queries.")
|
||
}
|
||
return findings
|
||
default:
|
||
return []
|
||
}
|
||
}
|
||
|
||
private var diagnosticReportText: String {
|
||
(["DBSyn Database Diagnostics", page.rawValue, "Snapshot: \(Date().formatted())", "",
|
||
"Advisory findings (cumulative counters; not proof of a defect):"] +
|
||
(diagnosticFindings.isEmpty ? ["No threshold-based findings for this snapshot."] : diagnosticFindings))
|
||
.joined(separator: "\n")
|
||
}
|
||
|
||
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("View Full Error…") { showingFullError = true }
|
||
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
|
||
.layoutPriority(1)
|
||
}
|
||
|
||
if let guidance = diagnosticGuidance {
|
||
Text(guidance)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
if !diagnosticFindings.isEmpty && errorMessage == nil {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
Label("Diagnostic Findings", systemImage: "stethoscope")
|
||
.font(.headline)
|
||
Spacer()
|
||
Button("Copy Report") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(diagnosticReportText, forType: .string)
|
||
}
|
||
}
|
||
ForEach(Array(diagnosticFindings.prefix(5).enumerated()), id: \.offset) { _, finding in
|
||
Text("• " + finding)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
if diagnosticFindings.count > 5 {
|
||
Text("\(diagnosticFindings.count - 5) additional findings available in Copy Report")
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8))
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 6)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
Divider()
|
||
|
||
footer
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.task(id: "\(page.id)-\(profile?.id.uuidString ?? "")") {
|
||
await refresh()
|
||
}
|
||
.task(id: "\(page.id)-\(autoRefresh)-\(profile?.id.uuidString ?? "")") {
|
||
// Gather a second snapshot promptly on first display so rate graphs
|
||
// draw a line without waiting for the full five-second interval.
|
||
if autoRefresh && supportsTrends {
|
||
try? await Task.sleep(for: .seconds(1.5))
|
||
if !Task.isCancelled && autoRefresh {
|
||
if loading { try? await Task.sleep(for: .seconds(1)) }
|
||
if !Task.isCancelled && autoRefresh { await refresh() }
|
||
}
|
||
}
|
||
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: $editingAccountLimits, onDismiss: {
|
||
Task { await refresh() }
|
||
}) {
|
||
if let profile, let selectedAccount,
|
||
let row = result.rows.first(where: { $0.count >= 8 && "\($0[0])\u{001f}\($0[1])" == selectedAccount }) {
|
||
DBSynAccountLimitsEditor(profile: profile, account: row)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingTrends) {
|
||
WorkbenchDiagnosticTrendsView(page: page, samples: diagnosticSamples)
|
||
}
|
||
.sheet(item: $selectedLogEntry) { entry in
|
||
DBSynLogDetailSheet(title: "MySQL Server Log Entry", details: entry.text)
|
||
}
|
||
.sheet(isPresented: $showingFullError) {
|
||
DBSynLogDetailSheet(title: "MySQL Error Details", details: errorMessage ?? "No error available.")
|
||
}
|
||
.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 == .serverLogs, let errorMessage {
|
||
Button("Full Error…") { showingFullError = true }
|
||
.help(errorMessage)
|
||
}
|
||
|
||
if page != .dashboard {
|
||
TextField("Filter results", text: $searchText)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 175)
|
||
}
|
||
|
||
Button {
|
||
exportAdministrationCSV()
|
||
} label: {
|
||
Label("Export CSV…", systemImage: "square.and.arrow.up")
|
||
}
|
||
.disabled(result.columns.isEmpty || result.rows.isEmpty)
|
||
.help("Export the currently filtered administration results as UTF-8 CSV")
|
||
|
||
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)
|
||
}
|
||
|
||
// Phase 60: Export the currently displayed administration data without
|
||
// requiring an external MySQL client. RFC 4180 quoting protects commas,
|
||
// double quotes and multiline field values.
|
||
@MainActor
|
||
private func exportAdministrationCSV() {
|
||
guard !result.columns.isEmpty else { return }
|
||
let panel = NSSavePanel()
|
||
panel.allowedContentTypes = [.commaSeparatedText]
|
||
panel.canCreateDirectories = true
|
||
panel.nameFieldStringValue = "DBSyn-" + page.rawValue
|
||
.replacingOccurrences(of: " ", with: "-") + ".csv"
|
||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
func record(_ values: [String]) -> String {
|
||
values.map { value in
|
||
let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
|
||
return "\"" + escaped + "\""
|
||
}.joined(separator: ",") + "\r\n"
|
||
}
|
||
do {
|
||
let rows = [result.columns] + filteredRows
|
||
let csv = rows.map(record).joined()
|
||
try csv.write(to: url, atomically: true, encoding: .utf8)
|
||
processActionStatus = "Exported \(filteredRows.count) rows to \(url.lastPathComponent)."
|
||
} catch {
|
||
errorMessage = "CSV export failed: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
// The live charts occupy a fixed region ABOVE an independently scrolling
|
||
// counter table. They cannot disappear when the user scrolls the rows.
|
||
@ViewBuilder
|
||
private var resultContent: some View {
|
||
switch page {
|
||
case .serverStatus, .dashboard:
|
||
// Phase 51: one outer scroll container owns the entire dashboard.
|
||
// The former fixed-height top chart pane consumed the available
|
||
// window height and left the overview pane impossible to reach.
|
||
ScrollView(.vertical) {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
WorkbenchInlinePerformanceDashboard(
|
||
samples: diagnosticSamples,
|
||
chartHeight: 155
|
||
)
|
||
.accessibilityIdentifier("live-mysql-line-graphs-phase51")
|
||
|
||
Divider()
|
||
|
||
Label("Server overview · Live graphs", systemImage: "chart.xyaxis.line")
|
||
.font(.title3.bold())
|
||
|
||
WorkbenchServerOverview(rows: filteredRows, samples: diagnosticSamples)
|
||
|
||
if page == .serverStatus {
|
||
Divider()
|
||
HStack {
|
||
Text("Server status metrics").font(.headline)
|
||
Spacer()
|
||
Text("\(filteredRows.count) metrics")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
// The grid owns its own bounded viewport; the outer
|
||
// page can still scroll to every section below charts.
|
||
WorkbenchColouredCounterTable(
|
||
columns: ["Metric", "Value"], rows: filteredRows
|
||
)
|
||
.frame(height: 320)
|
||
}
|
||
}
|
||
.padding(18)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.accessibilityIdentifier("scrollable-dashboard-phase51")
|
||
case .users:
|
||
accountAdministration
|
||
case .serverLogs:
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack {
|
||
Text("Select an entry to view the complete log message and copy it.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button("Copy All Logs") {
|
||
let all = filteredRows.map { logText(for: $0) }.joined(separator: "\n\n---\n\n")
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(all, forType: .string)
|
||
}
|
||
.disabled(filteredRows.isEmpty)
|
||
}
|
||
.padding(12)
|
||
List(Array(filteredRows.enumerated()), id: \.offset) { _, row in
|
||
Button {
|
||
selectedLogEntry = DBSynLogDetail(text: logText(for: row))
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Text(logValue("DATA", in: row))
|
||
.lineLimit(2)
|
||
.foregroundStyle(.primary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Text([logValue("LOGGED", in: row), logValue("PRIO", in: row), logValue("ERROR_CODE", in: row)]
|
||
.filter { !$0.isEmpty }.joined(separator: " · "))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
case .serverCounters, .connectionStatistics, .innodbMetrics:
|
||
WorkbenchLiveMonitorScreen(
|
||
page: page,
|
||
samples: diagnosticSamples,
|
||
rows: filteredRows,
|
||
allRowCount: result.rows.count
|
||
)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
case .optionsFile:
|
||
DBSynOptionsInspector(columns: result.columns, rows: filteredRows)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
default:
|
||
// Status and System Variables is deliberately a distinct variable
|
||
// inspector, never replaced by the monitoring screen.
|
||
NativeResultGrid(columns: result.columns, rows: filteredRows)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
|
||
// Workbench-style account administration: persistent account list and inspector.
|
||
private var accountAdministration: some View {
|
||
HStack(spacing: 0) {
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Label("User Accounts", systemImage: "person.2")
|
||
.font(.headline)
|
||
Spacer()
|
||
Text("\(filteredRows.count)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(12)
|
||
Divider()
|
||
List(selection: $selectedAccount) {
|
||
ForEach(filteredRows.indices, id: \.self) { index in
|
||
let row = filteredRows[index]
|
||
if row.count >= 2 {
|
||
let key = "\(row[0])\u{001f}\(row[1])"
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Label(row[0].isEmpty ? "(anonymous)" : row[0], systemImage: "person.crop.circle")
|
||
.font(.body.weight(.medium))
|
||
Text("Host: \(row[1])")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(.vertical, 4)
|
||
.tag(key)
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.sidebar)
|
||
.onChange(of: selectedAccount) { _, _ in grants = []; grantsError = nil; accountTab = .login }
|
||
Divider()
|
||
HStack {
|
||
Button {
|
||
selectedAccount = nil
|
||
showingAccountManager = true
|
||
} label: { Image(systemName: "plus") }
|
||
.help("Create MySQL account")
|
||
Spacer()
|
||
Button { Task { await refresh() } } label: {
|
||
Image(systemName: "arrow.clockwise")
|
||
}
|
||
.help("Refresh accounts")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.padding(12)
|
||
}
|
||
.frame(minWidth: 205, idealWidth: 245, maxWidth: 290)
|
||
Divider()
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
if let selectedAccount,
|
||
let row = result.rows.first(where: {
|
||
$0.count >= 2 && "\($0[0])\u{001f}\($0[1])" == selectedAccount
|
||
}) {
|
||
HStack(alignment: .top) {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(row[0].isEmpty ? "Anonymous account" : row[0])
|
||
.font(.title2.bold())
|
||
Text("'\(row[0])'@'\(row[1])'")
|
||
.font(.system(.subheadline, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
.textSelection(.enabled)
|
||
}
|
||
Spacer()
|
||
Button("Edit Account…") {
|
||
showingAccountManager = true
|
||
}
|
||
.disabled(profile == nil)
|
||
}
|
||
.padding(18)
|
||
Divider()
|
||
Picker("Account section", selection: $accountTab) {
|
||
ForEach(DBSynAccountTab.allCases) { tab in
|
||
Text(tab.rawValue).tag(tab)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.padding(.horizontal, 18)
|
||
.padding(.vertical, 12)
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
switch accountTab {
|
||
case .login:
|
||
GroupBox("Login") {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
LabeledContent("Login name", value: row[0])
|
||
LabeledContent("Limit to hosts matching", value: row[1])
|
||
if row.count > 2 { LabeledContent("Authentication type", value: row[2]) }
|
||
if row.count > 3 { LabeledContent("Account status", value: row[3] == "Y" ? "Locked" : "Active") }
|
||
Text("Passwords cannot be read from MySQL. Use Edit Account to set a new password.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Button("Change Password…") { showingAccountManager = true }
|
||
}.frame(maxWidth: .infinity, alignment: .leading).padding(10)
|
||
}
|
||
case .limits:
|
||
GroupBox("Account Limits") {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
accountLimit("Max. Queries / hour", row: row, index: 4)
|
||
accountLimit("Max. Updates / hour", row: row, index: 5)
|
||
accountLimit("Max. Connections / hour", row: row, index: 6)
|
||
accountLimit("Concurrent Connections", row: row, index: 7)
|
||
Text("0 means unlimited. Values are read from mysql.user.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Button("Edit Account Limits…") { editingAccountLimits = true }
|
||
.disabled(profile == nil)
|
||
}.frame(maxWidth: .infinity, alignment: .leading).padding(10)
|
||
}
|
||
case .roles:
|
||
GroupBox("Administrative Roles and Global Privileges") {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("MySQL grants and roles are authoritative. Workbench-style role presets are not MySQL server roles.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Button("Load Current Grants") { Task { await loadAccountGrants() } }
|
||
if let grantsError { Text(grantsError).foregroundStyle(.red).textSelection(.enabled) }
|
||
ForEach(grants, id: \.self) { grant in
|
||
Label(grant, systemImage: "checkmark.shield")
|
||
.font(.system(.caption, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
}
|
||
Button("Manage Privileges…") { showingAccountManager = true }
|
||
}.frame(maxWidth: .infinity, alignment: .leading).padding(10)
|
||
}
|
||
case .schemas:
|
||
GroupBox("Schema Privileges") {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Schema-level GRANT statements for this account")
|
||
.foregroundStyle(.secondary)
|
||
Button("Load Schema Grants") { Task { await loadAccountGrants() } }
|
||
ForEach(grants.filter { $0.uppercased().contains(" ON ") && !$0.contains("*.*") }, id: \.self) { grant in
|
||
Text(grant).font(.system(.caption, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
}
|
||
if let grantsError { Text(grantsError).foregroundStyle(.red).textSelection(.enabled) }
|
||
Button("Add / Revoke Schema Privilege…") { showingAccountManager = true }
|
||
}.frame(maxWidth: .infinity, alignment: .leading).padding(10)
|
||
}
|
||
}
|
||
}
|
||
.padding(18)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
Divider()
|
||
HStack {
|
||
Button("View All Grants…") { Task { await showGrants() } }
|
||
Spacer()
|
||
Button("Edit Account…") { showingAccountManager = true }
|
||
}.padding(14)
|
||
Spacer(minLength: 0)
|
||
} else {
|
||
ContentUnavailableView("Select a MySQL Account", systemImage: "person.crop.circle.badge.checkmark", description: Text("Choose an account on the left to inspect its login, authentication and privileges."))
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private func logValue(_ name: String, in row: [String]) -> String {
|
||
guard let index = result.columns.firstIndex(where: { $0.caseInsensitiveCompare(name) == .orderedSame }),
|
||
index < row.count else { return "" }
|
||
return row[index]
|
||
}
|
||
|
||
private func logText(for row: [String]) -> String {
|
||
zip(result.columns, row).map { "\($0.0): \($0.1)" }.joined(separator: "\n")
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
private func accountLimit(_ label: String, row: [String], index: Int) -> some View {
|
||
LabeledContent(label, value: index < row.count ? row[index] : "Unavailable")
|
||
}
|
||
|
||
@MainActor
|
||
private func loadAccountGrants() async {
|
||
guard let profile, let selectedAccount else { return }
|
||
let components = selectedAccount.split(separator: "\u{001f}", omittingEmptySubsequences: false)
|
||
guard components.count == 2 else { return }
|
||
func quote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "''") + "'" }
|
||
grants = []
|
||
grantsError = nil
|
||
do {
|
||
let sql = "SHOW GRANTS FOR \(quote(String(components[0])))@\(quote(String(components[1])))"
|
||
let response = try await NativeMySQLRunner.run(profile: profile, database: "", password: PasswordStore.read(profile.id), sql: sql)
|
||
guard self.selectedAccount == selectedAccount else { return }
|
||
grants = response.rows.compactMap { $0.first }
|
||
} catch {
|
||
guard self.selectedAccount == selectedAccount else { return }
|
||
grantsError = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
@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
|
||
}
|
||
|
||
guard !loading else { 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 = ""
|
||
// Phase 61: Recent backups are kept locally, never uploaded or copied.
|
||
@State private var recentBackups: [String] = UserDefaults.standard.stringArray(forKey: "DBSyn.recentBackups.v1") ?? []
|
||
@State private var selectedBackupPath = ""
|
||
|
||
private func rememberBackup(_ url: URL) {
|
||
let path = url.standardizedFileURL.path
|
||
recentBackups.removeAll { $0 == path }
|
||
recentBackups.insert(path, at: 0)
|
||
recentBackups = Array(recentBackups.prefix(20))
|
||
selectedBackupPath = path
|
||
UserDefaults.standard.set(recentBackups, forKey: "DBSyn.recentBackups.v1")
|
||
}
|
||
|
||
private var selectedBackupURL: URL? {
|
||
guard !selectedBackupPath.isEmpty,
|
||
FileManager.default.fileExists(atPath: selectedBackupPath) else { return nil }
|
||
return URL(fileURLWithPath: selectedBackupPath)
|
||
}
|
||
|
||
private func verifyRecentBackup() {
|
||
guard let url = selectedBackupURL else { return }
|
||
operationTask = Task {
|
||
isWorking = true
|
||
defer { isWorking = false; operationTask = nil }
|
||
do {
|
||
try await Task.detached(priority: .utility) {
|
||
try WorkbenchBackupIntegrity.verify(url)
|
||
}.value
|
||
status = "Verified SHA-256: \(url.lastPathComponent)"
|
||
progress = 1
|
||
} catch {
|
||
status = "Verification failed: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
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("Recent Backups") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Previously exported SQL backups. Files stay in their original locations; missing files cannot be verified or restored.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
if recentBackups.isEmpty {
|
||
Text("No DBSyn backups recorded yet.").foregroundStyle(.secondary)
|
||
} else {
|
||
Picker("Backup", selection: $selectedBackupPath) {
|
||
Text("Select a backup").tag("")
|
||
ForEach(recentBackups, id: \.self) { path in
|
||
Text(URL(fileURLWithPath: path).lastPathComponent +
|
||
(FileManager.default.fileExists(atPath: path) ? "" : " (missing)"))
|
||
.tag(path)
|
||
}
|
||
}
|
||
.labelsHidden()
|
||
HStack {
|
||
Button("Reveal in Finder") {
|
||
if let url = selectedBackupURL {
|
||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||
}
|
||
}.disabled(selectedBackupURL == nil)
|
||
Button("Verify SHA-256") { verifyRecentBackup() }
|
||
.disabled(selectedBackupURL == nil || isWorking)
|
||
Button("Restore Selected…") {
|
||
if let url = selectedBackupURL {
|
||
selectedMode = 1
|
||
pendingImportURL = url
|
||
showingImportConfirmation = true
|
||
}
|
||
}.disabled(selectedBackupURL == nil || isWorking || database.isEmpty || selectedProfile == nil)
|
||
Spacer()
|
||
Button("Clear History") {
|
||
recentBackups = []
|
||
selectedBackupPath = ""
|
||
UserDefaults.standard.removeObject(forKey: "DBSyn.recentBackups.v1")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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("-- DBSyn 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
|
||
rememberBackup(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 selectedPrivileges: Set<String> = ["SELECT"]
|
||
@State private var privilegeScope = "Schema"
|
||
@State private var selectedRole = "Custom"
|
||
private let rolePresets: [(String, String, [String])] = [
|
||
("Custom", "Choose individual privileges", []),
|
||
("Read Only", "Read tables and views", ["SELECT", "SHOW VIEW"]),
|
||
("Read / Write", "Read and modify records", ["SELECT", "INSERT", "UPDATE", "DELETE", "SHOW VIEW"]),
|
||
("DB Designer", "Create and alter schema objects", ["SELECT", "CREATE", "ALTER", "DROP", "INDEX", "REFERENCES", "CREATE VIEW", "SHOW VIEW", "TRIGGER"]),
|
||
("DB Manager", "Manage all supported schema privileges", ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "INDEX", "REFERENCES", "EXECUTE", "CREATE VIEW", "SHOW VIEW", "TRIGGER"])
|
||
]
|
||
@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) {
|
||
HStack {
|
||
Label("Users and Privileges", systemImage: "person.crop.circle.badge.checkmark")
|
||
.font(.title2.bold())
|
||
Spacer()
|
||
Text(profile.name).foregroundStyle(.secondary)
|
||
}
|
||
Text("Manage login credentials, account access and schema privileges. Every change requires SQL review and confirmation.")
|
||
.foregroundStyle(.secondary)
|
||
Form {
|
||
Section("Account") {
|
||
TextField("Username", text: $username)
|
||
.textContentType(.username)
|
||
TextField("Host", text: $hostname)
|
||
Text("MySQL accounts are identified by both username and host.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Section("Account Management") {
|
||
Picker("Operation", selection: $action) {
|
||
ForEach(WorkbenchAccountAction.allCases) { option in
|
||
Text(option.rawValue).tag(option)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
if action == .create || action == .password {
|
||
SecureField("Password", text: $password)
|
||
}
|
||
if action == .grant || action == .revoke {
|
||
Picker("Privilege scope", selection: $privilegeScope) {
|
||
Text("Schema").tag("Schema")
|
||
Text("Global").tag("Global")
|
||
}
|
||
if privilegeScope == "Schema" {
|
||
TextField("Schema name", text: $databaseName)
|
||
.help("Enter an existing database name. Privileges apply to all objects in this schema.")
|
||
} else {
|
||
Text("Global privileges affect every database on this server. Review carefully before applying.")
|
||
.foregroundStyle(.orange)
|
||
}
|
||
Picker("Role preset", selection: $selectedRole) {
|
||
ForEach(rolePresets.map { $0.0 }, id: \.self) { role in
|
||
Text(role).tag(role)
|
||
}
|
||
}
|
||
.onChange(of: selectedRole) { _, newRole in
|
||
if let preset = rolePresets.first(where: { $0.0 == newRole }), newRole != "Custom" {
|
||
selectedPrivileges = Set(preset.2)
|
||
}
|
||
}
|
||
Text("Select the privileges to \(action == .grant ? "grant" : "revoke")")
|
||
.font(.headline)
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 165), spacing: 10)], alignment: .leading, spacing: 12) {
|
||
ForEach(privileges, id: \.self) { item in
|
||
Toggle(item, isOn: Binding(
|
||
get: { selectedPrivileges.contains(item) },
|
||
set: { enabled in
|
||
if enabled { selectedPrivileges.insert(item) }
|
||
else { selectedPrivileges.remove(item) }
|
||
selectedRole = "Custom"
|
||
}
|
||
))
|
||
.toggleStyle(.checkbox)
|
||
}
|
||
}
|
||
HStack {
|
||
Button("Select All") { selectedPrivileges = Set(privileges); selectedRole = "Custom" }
|
||
Button("Clear") { selectedPrivileges.removeAll(); selectedRole = "Custom" }
|
||
Spacer()
|
||
Text("\(selectedPrivileges.count) selected").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.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(minWidth: 650, idealWidth: 760, minHeight: 520)
|
||
.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 !selectedPrivileges.isEmpty, selectedPrivileges.allSatisfy({ privileges.contains($0) }) else {
|
||
throw AccountError.invalid("Select at least one privilege.")
|
||
}
|
||
let target: String
|
||
if privilegeScope == "Global" {
|
||
target = "*.*"
|
||
} else {
|
||
guard !databaseName.isEmpty,
|
||
databaseName.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
|
||
throw AccountError.invalid("Enter a valid schema name.")
|
||
}
|
||
target = "`\(databaseName.replacingOccurrences(of: "`", with: "``"))`.*"
|
||
}
|
||
let rights = selectedPrivileges.sorted().joined(separator: ", ")
|
||
let command = action == .grant
|
||
? "GRANT \(rights) ON \(target) TO \(account)"
|
||
: "REVOKE \(rights) ON \(target) 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]
|
||
var chartHeight: CGFloat = 205
|
||
|
||
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: 15, 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))
|
||
PointMark(x: .value("Time", reading.time), y: .value("Value", reading.value))
|
||
.foregroundStyle(tint)
|
||
}
|
||
.chartYAxis { AxisMarks(position: .leading) }
|
||
.frame(height: chartHeight)
|
||
.accessibilityLabel("Live time-series graph for \(title)")
|
||
}
|
||
.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 MySQL Line Graphs", 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: 360), spacing: 16)], spacing: 16) {
|
||
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
|
||
// MARK: - Phase 39: pinned, visible monitoring graphs
|
||
// The charts and counters are siblings, not nested in one long ScrollView.
|
||
// Single administration monitoring view. The chart region is deliberately outside
|
||
// the counter ScrollView, so scrolling hundreds of counters cannot hide charts.
|
||
private struct WorkbenchServerOverview: View {
|
||
let rows: [[String]]
|
||
let samples: [DiagnosticSample]
|
||
|
||
private let names = [
|
||
"Uptime", "Threads_connected", "Threads_running", "Queries",
|
||
"Slow_queries", "Bytes_received", "Bytes_sent", "Connections"
|
||
]
|
||
|
||
private struct OverviewPoint: Identifiable {
|
||
let id: Int
|
||
let time: Date
|
||
let value: Double
|
||
}
|
||
|
||
private func points(for name: String) -> [OverviewPoint] {
|
||
samples.enumerated().compactMap { index, sample in
|
||
guard let row = sample.rows.first(where: {
|
||
$0.count > 1 && $0[0].caseInsensitiveCompare(name) == .orderedSame
|
||
}), let value = Double(row[1]), value.isFinite else { return nil }
|
||
return OverviewPoint(id: index, time: sample.at, value: value)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 14)], spacing: 14) {
|
||
ForEach(names, id: \.self) { name in
|
||
let raw = rows.first(where: {
|
||
$0.count >= 2 && $0[0].caseInsensitiveCompare(name) == .orderedSame
|
||
})?.last ?? "—"
|
||
let tint = WorkbenchCounterCharts.tint(for: name)
|
||
let series = points(for: name)
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text(name.replacingOccurrences(of: "_", with: " "))
|
||
.font(.subheadline.weight(.semibold))
|
||
Spacer(minLength: 8)
|
||
Text(WorkbenchCounterCharts.displayValue(raw, for: name))
|
||
.font(.headline.monospacedDigit())
|
||
.foregroundStyle(tint)
|
||
}
|
||
Chart(series) { point in
|
||
AreaMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(tint.opacity(0.12))
|
||
LineMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(tint)
|
||
.lineStyle(StrokeStyle(lineWidth: 2.5))
|
||
PointMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(tint)
|
||
}
|
||
.chartYAxis { AxisMarks(position: .leading) }
|
||
.frame(height: 135)
|
||
.overlay {
|
||
if series.isEmpty {
|
||
Text("Waiting for live samples…")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12))
|
||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(tint.opacity(0.20)))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct WorkbenchLiveMonitorScreen: View {
|
||
let page: WorkbenchAdminPage
|
||
let samples: [DiagnosticSample]
|
||
let rows: [[String]]
|
||
let allRowCount: Int
|
||
|
||
@State private var categorySelection = "All"
|
||
@State private var chartGroup = "Activity"
|
||
|
||
private let categories = ["All", "Connections", "Queries", "InnoDB", "Network", "Errors", "Replication", "Other"]
|
||
|
||
private var pageTitle: String {
|
||
switch page {
|
||
case .serverStatus: return "Server Status"
|
||
case .serverCounters: return "Server Counters"
|
||
case .connectionStatistics: return "Connection Statistics"
|
||
case .innodbMetrics: return "InnoDB Metrics"
|
||
default: return "Server Monitoring"
|
||
}
|
||
}
|
||
|
||
private func category(for 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") { return "Queries" }
|
||
return "Other"
|
||
}
|
||
|
||
private var visibleRows: [[String]] {
|
||
categorySelection == "All" ? rows : rows.filter { $0.count > 1 && category(for: $0[0]) == categorySelection }
|
||
}
|
||
|
||
private var metrics: [String] {
|
||
if page == .connectionStatistics {
|
||
return ["Threads_connected", "Threads_running", "Connections", "Aborted_connects"]
|
||
}
|
||
if page == .innodbMetrics {
|
||
return ["Innodb_buffer_pool_read_requests", "Innodb_buffer_pool_reads", "Innodb_data_reads", "Innodb_data_writes"]
|
||
}
|
||
switch chartGroup {
|
||
case "Network": return ["Bytes_received", "Bytes_sent", "Connections", "Aborted_connects"]
|
||
case "InnoDB": return ["Innodb_buffer_pool_read_requests", "Innodb_buffer_pool_reads", "Innodb_data_reads", "Innodb_data_writes"]
|
||
default: return ["Queries", "Threads_connected", "Threads_running", "Slow_queries"]
|
||
}
|
||
}
|
||
|
||
private func value(_ metric: String, in sample: DiagnosticSample) -> Double? {
|
||
guard let row = sample.rows.first(where: { $0.count > 1 && $0[0].caseInsensitiveCompare(metric) == .orderedSame }) else { return nil }
|
||
return Double(row[1])
|
||
}
|
||
|
||
private struct Reading: Identifiable {
|
||
let id: Int
|
||
let time: Date
|
||
let value: Double
|
||
}
|
||
|
||
private func readings(for metric: String) -> [Reading] {
|
||
let gauge = metric == "Threads_connected" || metric == "Threads_running"
|
||
return samples.indices.compactMap { index in
|
||
guard let current = value(metric, in: samples[index]), current.isFinite else { return nil }
|
||
if gauge { return Reading(id: index, time: samples[index].at, value: current) }
|
||
guard index > 0, let previous = value(metric, in: samples[index - 1]), current >= previous else { return nil }
|
||
let interval = samples[index].at.timeIntervalSince(samples[index - 1].at)
|
||
guard interval > 0 else { return nil }
|
||
return Reading(id: index, time: samples[index].at, value: (current - previous) / interval)
|
||
}
|
||
}
|
||
|
||
private func chart(_ metric: String) -> some View {
|
||
let points = readings(for: metric)
|
||
let tint = WorkbenchCounterCharts.tint(for: metric)
|
||
let gauge = metric == "Threads_connected" || metric == "Threads_running"
|
||
return VStack(alignment: .leading, spacing: 6) {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text(metric.replacingOccurrences(of: "_", with: " "))
|
||
.font(.subheadline.weight(.semibold)).lineLimit(1)
|
||
Spacer(minLength: 8)
|
||
Text(points.last.map { $0.value.formatted(.number.precision(.fractionLength(1))) } ?? "—")
|
||
.font(.headline.monospacedDigit()).foregroundStyle(tint)
|
||
Text(gauge ? "current" : "/sec")
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
Chart(points) { point in
|
||
AreaMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(tint.opacity(0.16))
|
||
LineMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||
.foregroundStyle(tint).lineStyle(StrokeStyle(lineWidth: 2.5))
|
||
}
|
||
.chartYAxis { AxisMarks(position: .leading) }
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 90)
|
||
.overlay {
|
||
if points.isEmpty {
|
||
Text("Waiting for samples…")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(9)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(tint.opacity(0.065), in: RoundedRectangle(cornerRadius: 12))
|
||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(tint.opacity(0.24)))
|
||
}
|
||
|
||
private var statusSummary: some View {
|
||
let names = ["Uptime", "Threads_connected", "Threads_running", "Queries", "Slow_queries", "Max_used_connections"]
|
||
return HStack(spacing: 8) {
|
||
ForEach(names, id: \.self) { name in
|
||
let raw = rows.first(where: { $0.count > 1 && $0[0].caseInsensitiveCompare(name) == .orderedSame })?.last ?? "—"
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(name.replacingOccurrences(of: "_", with: " "))
|
||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||
Text(WorkbenchCounterCharts.displayValue(raw, for: name))
|
||
.font(.headline.monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7)
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 45, alignment: .leading)
|
||
.padding(9)
|
||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 9))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fixed-height chart cards live OUTSIDE the independently scrolling table.
|
||
// Avoid GeometryReader and nested graph ScrollViews: they previously allowed
|
||
// the graph viewport to collapse when the parent administration view laid out.
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(spacing: 10) {
|
||
Label(pageTitle + " · LIVE GRAPHS", systemImage: "chart.xyaxis.line")
|
||
.font(.title3.bold())
|
||
Spacer(minLength: 4)
|
||
Text("\(samples.count) samples · 5s refresh")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if page == .serverStatus || page == .serverCounters {
|
||
Picker("Graphs", selection: $chartGroup) {
|
||
Text("Activity").tag("Activity")
|
||
Text("Network").tag("Network")
|
||
Text("InnoDB").tag("InnoDB")
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.frame(maxWidth: 350)
|
||
}
|
||
|
||
// Exactly two rows of two graphs. No graph-region scrolling and no
|
||
// dependence on the size proposed by a surrounding GeometryReader.
|
||
LazyVGrid(
|
||
columns: [GridItem(.flexible(minimum: 0), spacing: 12),
|
||
GridItem(.flexible(minimum: 0), spacing: 12)],
|
||
spacing: 12
|
||
) {
|
||
ForEach(metrics, id: \.self) { metric in
|
||
chart(metric)
|
||
.frame(height: 148)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.accessibilityIdentifier("monitor-graphs-visible")
|
||
|
||
Divider()
|
||
|
||
if page == .serverStatus {
|
||
Text("Server overview").font(.headline)
|
||
ScrollView(.vertical) {
|
||
WorkbenchServerOverview(rows: rows, samples: samples)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
HStack {
|
||
Text(page == .connectionStatistics ? "Connection statistics" : page == .innodbMetrics ? "InnoDB statistics" : "All MySQL server counters")
|
||
.font(.headline)
|
||
Spacer(minLength: 6)
|
||
if page == .serverCounters {
|
||
Picker("Category", selection: $categorySelection) {
|
||
ForEach(categories, id: \.self) { Text($0).tag($0) }
|
||
}
|
||
.frame(width: 155)
|
||
}
|
||
Text("\(visibleRows.count) counters")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
WorkbenchColouredCounterTable(columns: ["Metric", "Value"], rows: visibleRows)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
.padding(16)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
}
|
||
}
|
||
|
||
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: 285), spacing: 14)], 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
|
||
@State private var editedColumns: [[String]] = []
|
||
@State private var originalColumns: [[String]] = []
|
||
@State private var showSQLReview = 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 {
|
||
editableColumnsGrid.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)
|
||
if target.table != nil {
|
||
Button("Generate SQL from Edits") { generateColumnSQL() }
|
||
.disabled(busy || editedColumns == originalColumns)
|
||
}
|
||
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("Copy SQL") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(proposedSQL, forType: .string)
|
||
}
|
||
.disabled(proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
.help("Copy generated SQL to paste into a DBSyn query editor")
|
||
Button("Review SQL…") { showSQLReview = true }
|
||
.disabled(busy || proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
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() }
|
||
.sheet(isPresented: $showSQLReview) {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Review Schema Changes").font(.title2.bold())
|
||
Text("Copy this SQL into any DBSyn query editor, or execute it here after review.")
|
||
.foregroundStyle(.secondary)
|
||
TextEditor(text: $proposedSQL)
|
||
.font(.system(.body, design: .monospaced))
|
||
.frame(minWidth: 720, minHeight: 320)
|
||
HStack {
|
||
Button("Copy SQL") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(proposedSQL, forType: .string)
|
||
}
|
||
Spacer()
|
||
Button("Close") { showSQLReview = false }
|
||
Button("Apply…") { showSQLReview = false; confirmExecution = true }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}.padding(20)
|
||
}
|
||
.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.")
|
||
}
|
||
}
|
||
|
||
// Only editable column properties are offered. Keys and indexes require separate DDL.
|
||
private var editableColumnsGrid: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Edit Type, Null, Default or Extra in place, then generate and review the ALTER TABLE SQL.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
ScrollView([.horizontal, .vertical]) {
|
||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) {
|
||
GridRow {
|
||
ForEach(columns.columns, id: \.self) { heading in
|
||
Text(heading).fontWeight(.semibold).frame(minWidth: 100, alignment: .leading)
|
||
}
|
||
}
|
||
Divider()
|
||
ForEach(editedColumns.indices, id: \.self) { row in
|
||
GridRow {
|
||
ForEach(editedColumns[row].indices, id: \.self) { col in
|
||
if [1, 3, 5, 6].contains(col) {
|
||
TextField(columns.columns[col], text: $editedColumns[row][col])
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(minWidth: 115)
|
||
} else {
|
||
Text(editedColumns[row][col])
|
||
.font(.system(.caption, design: .monospaced))
|
||
.frame(minWidth: 100, alignment: .leading)
|
||
.textSelection(.enabled)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}.padding(8)
|
||
}
|
||
HStack {
|
||
if editedColumns != originalColumns {
|
||
Text("Unsaved column edits").foregroundStyle(.orange)
|
||
Button("Revert Edits") { editedColumns = originalColumns }
|
||
} else {
|
||
Text("No pending column edits").foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button("Generate ALTER TABLE SQL") { generateColumnSQL() }
|
||
.disabled(editedColumns == originalColumns)
|
||
}
|
||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private func generateColumnSQL() {
|
||
guard target.table != nil else { return }
|
||
let names = columns.columns
|
||
func value(_ row: [String], _ name: String) -> String {
|
||
guard let index = names.firstIndex(of: name), index < row.count else { return "" }
|
||
return row[index]
|
||
}
|
||
guard originalColumns.count == editedColumns.count else {
|
||
message = "Column metadata changed. Reload before generating DDL."
|
||
successful = false
|
||
return
|
||
}
|
||
var statements: [String] = []
|
||
for (old, updated) in zip(originalColumns, editedColumns) where old != updated {
|
||
let field = value(old, "Field")
|
||
guard field == value(updated, "Field"),
|
||
value(old, "Key") == value(updated, "Key"),
|
||
value(old, "Collation") == value(updated, "Collation"),
|
||
value(old, "Comment") == value(updated, "Comment") else {
|
||
message = "Only Type, Null, Default and Extra can be modified. Reload and retry."
|
||
successful = false
|
||
return
|
||
}
|
||
let type = value(updated, "Type").trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let null = value(updated, "Null").uppercased()
|
||
let defaultValue = value(updated, "Default")
|
||
let extra = value(updated, "Extra").trimmingCharacters(in: .whitespacesAndNewlines)
|
||
// SHOW FULL COLUMNS does not contain generated-column expressions or
|
||
// every attribute from SHOW CREATE TABLE. Never reconstruct those columns.
|
||
if value(old, "Extra").uppercased().contains("GENERATED") ||
|
||
extra.uppercased().contains("GENERATED") ||
|
||
value(old, "Type").lowercased().contains("vector(") {
|
||
message = "Complex/generated columns cannot be safely modified in place. Use Original DDL and the SQL editor."
|
||
successful = false
|
||
return
|
||
}
|
||
guard !field.isEmpty, !type.isEmpty, (null == "YES" || null == "NO"),
|
||
!type.contains(";"), !type.contains("`"), !extra.contains(";"), !extra.contains("`"),
|
||
!extra.contains("--"), !extra.contains("/*") else {
|
||
message = "Invalid column definition for \(field). Check Type, Null and Extra."
|
||
successful = false
|
||
return
|
||
}
|
||
// SHOW FULL COLUMNS reports SQL NULL as the literal string NULL.
|
||
// Preserve the existing default when unchanged; generated SQL must be reviewed.
|
||
let defaultSQL: String
|
||
if defaultValue.uppercased() == "NULL" {
|
||
defaultSQL = null == "YES" ? " DEFAULT NULL" : ""
|
||
} else if defaultValue.uppercased() == "CURRENT_TIMESTAMP" || defaultValue.uppercased().hasPrefix("CURRENT_TIMESTAMP(") {
|
||
defaultSQL = " DEFAULT " + defaultValue
|
||
} else {
|
||
defaultSQL = " DEFAULT '" + defaultValue.replacingOccurrences(of: "'", with: "''") + "'"
|
||
}
|
||
let collation = value(updated, "Collation")
|
||
let collationSQL = collation != "NULL" && !collation.isEmpty &&
|
||
collation.range(of: "^[A-Za-z0-9_]+$", options: .regularExpression) != nil
|
||
? " COLLATE " + collation : ""
|
||
let comment = value(old, "Comment")
|
||
let commentSQL = comment.isEmpty || comment == "NULL" ? "" :
|
||
" COMMENT '" + comment.replacingOccurrences(of: "'", with: "''") + "'"
|
||
let clause = "MODIFY COLUMN " + identifier(field) + " " + type + collationSQL +
|
||
(null == "YES" ? " NULL" : " NOT NULL") + defaultSQL +
|
||
(extra.isEmpty || extra == "NULL" ? "" : " " + extra) + commentSQL
|
||
statements.append(clause)
|
||
}
|
||
if !statements.isEmpty {
|
||
proposedSQL = "ALTER TABLE " + qualifiedName + "\n " + statements.joined(separator: ",\n ") + ";"
|
||
message = "Generated \(statements.count) statement(s). Review SQL before applying."
|
||
successful = true
|
||
showSQLReview = true
|
||
}
|
||
}
|
||
|
||
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)
|
||
originalColumns = columns.rows
|
||
editedColumns = columns.rows
|
||
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 {
|
||
let expected = editedColumns
|
||
let wasGenerated = expected != originalColumns
|
||
_ = try await query(statement)
|
||
successful = true
|
||
message = "Schema statement executed successfully."
|
||
if target.table != nil {
|
||
await load()
|
||
// Verify the server metadata after execution, rather than trusting success alone.
|
||
if wasGenerated {
|
||
let fieldIndex = columns.columns.firstIndex(of: "Field")
|
||
let actualByName: [String: [String]] = fieldIndex.map { index in
|
||
Dictionary(columns.rows.compactMap { row -> (String, [String])? in
|
||
guard row.indices.contains(index) else { return nil }
|
||
return (row[index], row)
|
||
}, uniquingKeysWith: { first, _ in first })
|
||
} ?? [:]
|
||
let expectedNames = expected.compactMap { row -> String? in
|
||
guard let index = fieldIndex, row.indices.contains(index) else { return nil }
|
||
return row[index]
|
||
}
|
||
let verified = expectedNames.count == expected.count &&
|
||
Set(expectedNames).count == expected.count &&
|
||
columns.rows.count == expected.count &&
|
||
actualByName.count == expected.count &&
|
||
zip(expectedNames, expected).allSatisfy { name, wanted in
|
||
guard let actual = actualByName[name], wanted.count == actual.count else { return false }
|
||
return [1, 3, 5, 6].allSatisfy { index in
|
||
wanted.indices.contains(index) && actual.indices.contains(index) &&
|
||
wanted[index].caseInsensitiveCompare(actual[index]) == .orderedSame
|
||
}
|
||
}
|
||
successful = verified
|
||
message = verified
|
||
? "DDL executed and edited column metadata verified against MySQL."
|
||
: "DDL executed, but reloaded metadata differs from the requested values. Review the columns."
|
||
} else {
|
||
message = "DDL executed. Schema metadata reloaded from MySQL."
|
||
}
|
||
}
|
||
} catch {
|
||
successful = false
|
||
message = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - View and Stored Routine Definition Editor
|
||
|
||
private struct WorkbenchObjectEditorTarget: Identifiable {
|
||
enum Kind: String { case view = "VIEW", procedure = "PROCEDURE", function = "FUNCTION" }
|
||
let database: String
|
||
let name: String
|
||
let kind: Kind
|
||
var id: String { database + "." + kind.rawValue + "." + name }
|
||
}
|
||
|
||
private struct WorkbenchObjectEditorView: View {
|
||
let profile: ConnectionProfile?
|
||
let target: WorkbenchObjectEditorTarget
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var definition = ""
|
||
@State private var originalDefinition = ""
|
||
@State private var message: String?
|
||
@State private var isError = false
|
||
@State private var busy = false
|
||
@State private var confirmSave = false
|
||
|
||
private func quote(_ value: String) -> String {
|
||
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
|
||
}
|
||
private var qualified: String { quote(target.database) + "." + quote(target.name) }
|
||
|
||
private func query(_ sql: String) async throws -> QueryResult {
|
||
guard let profile else {
|
||
throw NSError(domain: "WorkbenchObjectEditor", 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("Edit " + target.kind.rawValue.capitalized, systemImage: "curlybraces")
|
||
.font(.title2.bold())
|
||
Text(qualified).font(.subheadline.monospaced()).foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button("Close") { dismiss() }
|
||
}
|
||
if let message {
|
||
Text(message)
|
||
.foregroundStyle(isError ? .red : .green)
|
||
.textSelection(.enabled)
|
||
}
|
||
Text("Edit the complete CREATE statement. The original definition is kept in memory until this window closes.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
TextEditor(text: $definition)
|
||
.font(.system(.body, design: .monospaced))
|
||
.frame(minHeight: 350)
|
||
.border(Color.secondary.opacity(0.3))
|
||
if target.kind != .view {
|
||
Text("MySQL does not support CREATE OR REPLACE for routines. Saving drops and recreates the routine. This is not atomic: grants, DEFINER and concurrent callers may be affected. Back up the routine first.")
|
||
.font(.caption).foregroundStyle(.orange)
|
||
}
|
||
HStack {
|
||
Button("Reload Original") { definition = originalDefinition }.disabled(busy)
|
||
Spacer()
|
||
ProgressView().controlSize(.small).opacity(busy ? 1 : 0)
|
||
Button("Save Definition…") { confirmSave = true }
|
||
.disabled(busy || definition.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || definition == originalDefinition)
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 850, minHeight: 560)
|
||
.task { await load() }
|
||
.confirmationDialog("Apply database definition?", isPresented: $confirmSave, titleVisibility: .visible) {
|
||
Button(target.kind == .view ? "Replace View" : "Drop and Recreate " + target.kind.rawValue.capitalized,
|
||
role: .destructive) { Task { await save() } }
|
||
Button("Cancel", role: .cancel) { }
|
||
} message: {
|
||
Text(target.kind == .view
|
||
? "This will replace the view definition on the MySQL server."
|
||
: "This drops the existing routine before recreating it. MySQL cannot roll back this operation.")
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
let result = try await query("SHOW CREATE " + target.kind.rawValue + " " + qualified)
|
||
guard let row = result.rows.first,
|
||
let index = result.columns.firstIndex(where: { $0.lowercased().hasPrefix("create ") }),
|
||
row.indices.contains(index) else {
|
||
throw NSError(domain: "WorkbenchObjectEditor", code: 2,
|
||
userInfo: [NSLocalizedDescriptionKey: "MySQL did not return the CREATE definition. Check SHOW VIEW or routine privileges."])
|
||
}
|
||
originalDefinition = row[index]
|
||
definition = originalDefinition
|
||
message = nil
|
||
} catch {
|
||
isError = true
|
||
message = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func normalized(_ sql: String) -> String {
|
||
let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmed.hasSuffix(";") ? String(trimmed.dropLast()) : trimmed
|
||
}
|
||
|
||
@MainActor
|
||
private func save() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
let sql = normalized(definition)
|
||
guard sql.range(of: #"^CREATE\s+"#, options: [.regularExpression, .caseInsensitive]) != nil else {
|
||
isError = true
|
||
message = "The definition must start with CREATE."
|
||
return
|
||
}
|
||
let expectedKind = target.kind.rawValue
|
||
guard sql.range(of: "\\b" + expectedKind + "\\b", options: [.regularExpression, .caseInsensitive]) != nil else {
|
||
isError = true
|
||
message = "Expected a CREATE " + expectedKind + " statement."
|
||
return
|
||
}
|
||
do {
|
||
if target.kind == .view {
|
||
// SHOW CREATE VIEW may include ALGORITHM / DEFINER / SQL SECURITY.
|
||
// Insert OR REPLACE immediately after CREATE, retaining those clauses.
|
||
let replacement = sql.replacingOccurrences(
|
||
of: #"^CREATE\s+(?:OR\s+REPLACE\s+)?"#,
|
||
with: "CREATE OR REPLACE ",
|
||
options: [.regularExpression, .caseInsensitive]
|
||
)
|
||
_ = try await query(replacement)
|
||
} else {
|
||
// MySQL has no CREATE OR REPLACE PROCEDURE/FUNCTION.
|
||
_ = try await query("DROP " + target.kind.rawValue + " " + qualified)
|
||
do {
|
||
_ = try await query(sql)
|
||
} catch {
|
||
// Best effort restoration of the original definition.
|
||
do {
|
||
_ = try await query(normalized(originalDefinition))
|
||
throw NSError(domain: "WorkbenchObjectEditor", code: 3,
|
||
userInfo: [NSLocalizedDescriptionKey: "Updated routine failed: \(error.localizedDescription). The original definition was restored; verify privileges and grants."])
|
||
} catch let restoreError {
|
||
if (restoreError as NSError).code == 3 && (restoreError as NSError).domain == "WorkbenchObjectEditor" {
|
||
throw restoreError
|
||
}
|
||
throw NSError(domain: "WorkbenchObjectEditor", code: 4,
|
||
userInfo: [NSLocalizedDescriptionKey: "Updated routine failed: \(error.localizedDescription). Restoration also failed: \(restoreError.localizedDescription). Restore from your backup immediately."])
|
||
}
|
||
}
|
||
}
|
||
originalDefinition = sql
|
||
definition = sql
|
||
isError = false
|
||
message = "Definition saved successfully."
|
||
} catch {
|
||
isError = true
|
||
message = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Phase 57: Schema management
|
||
private struct WorkbenchSchemaManagementTarget: Identifiable {
|
||
enum Action { case create, alter, drop, inspect }
|
||
let id = UUID()
|
||
let database: String?
|
||
let action: Action
|
||
}
|
||
|
||
private struct WorkbenchSchemaManagementSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@ObservedObject var model: DatabaseModel
|
||
let target: WorkbenchSchemaManagementTarget
|
||
@State private var name = ""
|
||
@State private var charset = "utf8mb4"
|
||
@State private var collation = "utf8mb4_0900_ai_ci"
|
||
@State private var confirmation = ""
|
||
@State private var submitted = false
|
||
|
||
private var escapedName: String { name.replacingOccurrences(of: "`", with: "``") }
|
||
private var title: String {
|
||
switch target.action {
|
||
case .create: return "Create Schema"
|
||
case .alter: return "Alter Schema"
|
||
case .drop: return "Drop Schema"
|
||
case .inspect: return "Schema Inspector"
|
||
}
|
||
}
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Text(title).font(.title2.bold())
|
||
if target.action == .inspect {
|
||
LabeledContent("Schema", value: name)
|
||
Text("Inspect the schema definition and available metadata using the SQL editor.")
|
||
.foregroundStyle(.secondary)
|
||
Button("Show CREATE DATABASE") {
|
||
model.sql = "SHOW CREATE DATABASE `\(escapedName)`;"
|
||
dismiss()
|
||
}
|
||
} else {
|
||
TextField("Schema name", text: $name)
|
||
.disabled(target.action != .create)
|
||
if target.action != .drop {
|
||
TextField("Character set", text: $charset)
|
||
TextField("Collation", text: $collation)
|
||
Text("Character set and collation must be valid identifiers supported by the server.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
} else {
|
||
Text("This permanently deletes the schema and ALL its tables, views, routines and data. This cannot be undone.")
|
||
.foregroundStyle(.red)
|
||
TextField("Type the schema name to confirm", text: $confirmation)
|
||
}
|
||
if submitted {
|
||
Text("Statement submitted. Check the SQL result/status before continuing.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
HStack {
|
||
Spacer()
|
||
Button("Close") { dismiss() }
|
||
if target.action != .inspect {
|
||
Button(target.action == .drop ? "Drop Schema" : "Apply") { apply() }
|
||
.disabled(!valid || submitted)
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
}
|
||
}
|
||
.padding(24)
|
||
.frame(width: 480)
|
||
.onAppear { name = target.database ?? "" }
|
||
}
|
||
private var valid: Bool {
|
||
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
|
||
if target.action == .drop { return confirmation == name }
|
||
if target.action == .inspect { return true }
|
||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_"))
|
||
return !charset.isEmpty && !collation.isEmpty && charset.unicodeScalars.allSatisfy(allowed.contains) && collation.unicodeScalars.allSatisfy(allowed.contains)
|
||
}
|
||
private func apply() {
|
||
guard valid else { return }
|
||
let sql: String
|
||
switch target.action {
|
||
case .create:
|
||
sql = "CREATE DATABASE `\(escapedName)` CHARACTER SET \(charset) COLLATE \(collation);"
|
||
case .alter:
|
||
sql = "ALTER DATABASE `\(escapedName)` CHARACTER SET \(charset) COLLATE \(collation);"
|
||
case .drop:
|
||
sql = "DROP DATABASE `\(escapedName)`;"
|
||
case .inspect: return
|
||
}
|
||
model.runSQL(statement: sql)
|
||
submitted = true
|
||
}
|
||
}
|
||
|
||
// MARK: - Phase 59: Table data search and CSV import
|
||
|
||
private struct WorkbenchTableTarget: Identifiable {
|
||
let id = UUID()
|
||
let database: String
|
||
let table: String?
|
||
}
|
||
|
||
private enum WorkbenchSQLValue {
|
||
static func identifier(_ name: String) -> String {
|
||
"`" + name.replacingOccurrences(of: "`", with: "``") + "`"
|
||
}
|
||
static func literal(_ value: String) -> String {
|
||
// MySQL backslash escaping; use the connection's SQL mode for interpretation.
|
||
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "'", with: "''")
|
||
.replacingOccurrences(of: "\0", with: "\\0")
|
||
.replacingOccurrences(of: "\n", with: "\\n")
|
||
.replacingOccurrences(of: "\r", with: "\\r")
|
||
return "'" + escaped + "'"
|
||
}
|
||
}
|
||
|
||
private struct WorkbenchTableSearchSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@ObservedObject var model: DatabaseModel
|
||
let target: WorkbenchTableTarget
|
||
let execute: (String) -> Void
|
||
@State private var columns: [String] = []
|
||
@State private var column = ""
|
||
@State private var term = ""
|
||
@State private var mode = "Contains"
|
||
@State private var limit = 500
|
||
@State private var error = ""
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Text("Search Table Data").font(.title2.bold())
|
||
Text("\(target.database).\(target.table ?? "")").foregroundStyle(.secondary)
|
||
if !error.isEmpty { Text(error).foregroundStyle(.red) }
|
||
Picker("Column", selection: $column) {
|
||
ForEach(columns, id: \.self) { Text($0).tag($0) }
|
||
}
|
||
Picker("Condition", selection: $mode) {
|
||
ForEach(["Contains", "Equals", "Starts with", "Ends with"], id: \.self) { Text($0).tag($0) }
|
||
}
|
||
TextField("Search value", text: $term)
|
||
Stepper("Maximum rows: \(limit)", value: $limit, in: 1...10000, step: 100)
|
||
HStack {
|
||
Spacer()
|
||
Button("Cancel") { dismiss() }
|
||
Button("Search") {
|
||
let db = WorkbenchSQLValue.identifier(target.database)
|
||
let tbl = WorkbenchSQLValue.identifier(target.table ?? "")
|
||
let expression: String
|
||
switch mode {
|
||
case "Equals": expression = WorkbenchSQLValue.literal(term)
|
||
case "Starts with": expression = WorkbenchSQLValue.literal(term + "%")
|
||
case "Ends with": expression = WorkbenchSQLValue.literal("%" + term)
|
||
default: expression = WorkbenchSQLValue.literal("%" + term + "%")
|
||
}
|
||
let op = mode == "Equals" ? "=" : "LIKE"
|
||
execute("SELECT * FROM \(db).\(tbl) WHERE \(WorkbenchSQLValue.identifier(column)) \(op) \(expression) LIMIT \(limit);")
|
||
dismiss()
|
||
}
|
||
.disabled(column.isEmpty)
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
}
|
||
.padding(24)
|
||
.frame(width: 520)
|
||
.task {
|
||
guard let profile = model.selected, let table = target.table else { return }
|
||
do {
|
||
let sql = "SHOW COLUMNS FROM \(WorkbenchSQLValue.identifier(target.database)).\(WorkbenchSQLValue.identifier(table));"
|
||
let result = try await NativeMySQLRunner.run(profile: profile, database: target.database,
|
||
password: PasswordStore.read(profile.id), sql: sql)
|
||
columns = result.rows.compactMap { $0.first }
|
||
column = columns.first ?? ""
|
||
} catch let caughtError { error = caughtError.localizedDescription }
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct WorkbenchCSVImportSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@ObservedObject var model: DatabaseModel
|
||
let target: WorkbenchTableTarget
|
||
@State private var fileURL: URL?
|
||
@State private var headers: [String] = []
|
||
@State private var records: [[String]] = []
|
||
@State private var selectedColumns: [String] = []
|
||
@State private var availableColumns: [String] = []
|
||
@State private var newTableName = "imported_data"
|
||
@State private var firstRowHeaders = true
|
||
@State private var spatialColumns: Set<Int> = []
|
||
@State private var busy = false
|
||
@State private var progress = 0
|
||
@State private var error = ""
|
||
@State private var completed = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Table Data Import Wizard").font(.title2.bold())
|
||
Text("Schema: \(target.database)").foregroundStyle(.secondary)
|
||
if target.table == nil {
|
||
TextField("New table name", text: $newTableName)
|
||
} else {
|
||
Text("Destination table: \(target.table!)")
|
||
}
|
||
HStack {
|
||
Button("Choose CSV File…") { chooseFile() }
|
||
Text(fileURL?.lastPathComponent ?? "No file selected")
|
||
.lineLimit(1).foregroundStyle(.secondary)
|
||
}
|
||
Toggle("First row contains column names", isOn: $firstRowHeaders)
|
||
.onChange(of: firstRowHeaders) { _, _ in prepareMapping() }
|
||
if !records.isEmpty {
|
||
Text("\(dataRows.count) data rows · \(headers.count) columns").font(.caption)
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ForEach(headers.indices, id: \.self) { index in
|
||
HStack {
|
||
Text(headers[index]).frame(width: 150, alignment: .leading).lineLimit(1)
|
||
if target.table == nil {
|
||
TextField("Destination column", text: $selectedColumns[index])
|
||
.frame(width: 190)
|
||
} else {
|
||
Picker("Destination", selection: $selectedColumns[index]) {
|
||
Text("Skip").tag("")
|
||
ForEach(availableColumns, id: \.self) { Text($0).tag($0) }
|
||
}.frame(width: 220)
|
||
}
|
||
Toggle("WKT geometry", isOn: Binding(
|
||
get: { spatialColumns.contains(index) },
|
||
set: { if $0 { spatialColumns.insert(index) } else { spatialColumns.remove(index) } }
|
||
)).toggleStyle(.checkbox)
|
||
}
|
||
}
|
||
Divider()
|
||
Text("Preview (first 5 rows)").font(.headline)
|
||
ForEach(Array(dataRows.prefix(5).enumerated()), id: \.offset) { _, row in
|
||
Text(row.joined(separator: " | "))
|
||
.font(.system(.caption, design: .monospaced)).lineLimit(2)
|
||
}
|
||
}
|
||
}.frame(maxHeight: 350)
|
||
}
|
||
if !error.isEmpty { Text(error).foregroundStyle(.red).textSelection(.enabled) }
|
||
if busy { ProgressView(value: Double(progress), total: Double(max(1, dataRows.count))) }
|
||
if completed { Label("Imported \(progress) rows", systemImage: "checkmark.circle.fill").foregroundStyle(.green) }
|
||
HStack {
|
||
Spacer()
|
||
Button("Close") { dismiss() }.disabled(busy)
|
||
Button("Import \(dataRows.count) Rows") { Task { await importRows() } }
|
||
.disabled(busy || records.isEmpty || dataRows.isEmpty || (target.table == nil && newTableName.isEmpty))
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
}
|
||
.padding(22)
|
||
.frame(width: 740, height: 660)
|
||
.task { await loadColumns() }
|
||
}
|
||
|
||
private var dataRows: [[String]] { firstRowHeaders ? Array(records.dropFirst()) : records }
|
||
|
||
private func chooseFile() {
|
||
let panel = NSOpenPanel()
|
||
panel.allowedContentTypes = [.commaSeparatedText, .plainText]
|
||
panel.allowsMultipleSelection = false
|
||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
do {
|
||
let data = try Data(contentsOf: url)
|
||
guard let text = String(data: data, encoding: .utf8) else {
|
||
error = "CSV must be UTF-8 encoded."; return
|
||
}
|
||
let parsed = try WorkbenchCSVParser.parse(text)
|
||
guard !parsed.isEmpty else { error = "CSV file is empty."; return }
|
||
fileURL = url
|
||
records = parsed
|
||
error = ""
|
||
progress = 0
|
||
completed = false
|
||
prepareMapping()
|
||
} catch let caughtError { error = caughtError.localizedDescription }
|
||
}
|
||
|
||
private func prepareMapping() {
|
||
let width = records.map(\.count).max() ?? 0
|
||
headers = (0..<width).map { index in
|
||
firstRowHeaders && index < (records.first?.count ?? 0) && !(records.first?[index] ?? "").isEmpty
|
||
? records[0][index] : "Column \(index + 1)"
|
||
}
|
||
selectedColumns = headers.enumerated().map { index, name in
|
||
if target.table == nil { return name }
|
||
return availableColumns.first(where: { $0.caseInsensitiveCompare(name) == .orderedSame })
|
||
?? (index < availableColumns.count ? availableColumns[index] : "")
|
||
}
|
||
}
|
||
|
||
private func loadColumns() async {
|
||
guard let profile = model.selected, let table = target.table else { return }
|
||
do {
|
||
let sql = "SHOW COLUMNS FROM \(WorkbenchSQLValue.identifier(target.database)).\(WorkbenchSQLValue.identifier(table));"
|
||
let result = try await NativeMySQLRunner.run(profile: profile, database: target.database,
|
||
password: PasswordStore.read(profile.id), sql: sql)
|
||
availableColumns = result.rows.compactMap(\.first)
|
||
prepareMapping()
|
||
} catch let caughtError { error = caughtError.localizedDescription }
|
||
}
|
||
|
||
private func importRows() async {
|
||
guard let profile = model.selected else { error = "Select a MySQL connection."; return }
|
||
let mappings = selectedColumns.enumerated().filter { !$0.element.isEmpty }
|
||
guard !mappings.isEmpty else { error = "Map at least one column."; return }
|
||
let destinations = mappings.map(\.element)
|
||
guard Set(destinations.map { $0.lowercased() }).count == destinations.count else {
|
||
error = "Each destination column must be unique."; return
|
||
}
|
||
let table = target.table ?? newTableName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !table.isEmpty else { error = "Enter a table name."; return }
|
||
busy = true; completed = false; error = ""; progress = 0
|
||
let password = PasswordStore.read(profile.id)
|
||
let qualified = "\(WorkbenchSQLValue.identifier(target.database)).\(WorkbenchSQLValue.identifier(table))"
|
||
do {
|
||
if target.table == nil {
|
||
let definitions = mappings.map { index, name in
|
||
"\(WorkbenchSQLValue.identifier(name)) \(spatialColumns.contains(index) ? "GEOMETRY" : "TEXT") NULL"
|
||
}.joined(separator: ", ")
|
||
_ = try await NativeMySQLRunner.run(profile: profile, database: target.database,
|
||
password: password, sql: "CREATE TABLE \(qualified) (\(definitions));")
|
||
}
|
||
let names = destinations.map(WorkbenchSQLValue.identifier).joined(separator: ", ")
|
||
for row in dataRows {
|
||
let values = mappings.map { index, _ -> String in
|
||
let value = index < row.count ? row[index] : ""
|
||
let literal = WorkbenchSQLValue.literal(value)
|
||
return spatialColumns.contains(index) ? "ST_GeomFromText(\(literal))" : literal
|
||
}.joined(separator: ", ")
|
||
_ = try await NativeMySQLRunner.run(profile: profile, database: target.database,
|
||
password: password, sql: "INSERT INTO \(qualified) (\(names)) VALUES (\(values));")
|
||
progress += 1
|
||
}
|
||
completed = true
|
||
model.loadTables(database: target.database)
|
||
} catch let caughtError {
|
||
error = "Stopped after \(progress) rows: \(caughtError.localizedDescription). Previously inserted rows may remain."
|
||
}
|
||
busy = false
|
||
}
|
||
}
|
||
|
||
private enum WorkbenchCSVParser {
|
||
enum CSVError: LocalizedError {
|
||
case unterminatedQuote
|
||
var errorDescription: String? { "CSV contains an unterminated quoted field." }
|
||
}
|
||
static func parse(_ text: String) throws -> [[String]] {
|
||
var rows: [[String]] = []
|
||
var row: [String] = []
|
||
var field = ""
|
||
var quoted = false
|
||
let chars = Array(text)
|
||
var i = 0
|
||
while i < chars.count {
|
||
let c = chars[i]
|
||
if quoted {
|
||
if c == "\"" {
|
||
if i + 1 < chars.count && chars[i + 1] == "\"" { field.append("\""); i += 1 }
|
||
else { quoted = false }
|
||
} else { field.append(c) }
|
||
} else if c == "\"" && field.isEmpty { quoted = true }
|
||
else if c == "," { row.append(field); field = "" }
|
||
else if c == "\n" {
|
||
row.append(field.trimmingCharacters(in: .init(charactersIn: "\r")))
|
||
rows.append(row); row = []; field = ""
|
||
} else { field.append(c) }
|
||
i += 1
|
||
}
|
||
if quoted { throw CSVError.unterminatedQuote }
|
||
if !row.isEmpty || !field.isEmpty { row.append(field); rows.append(row) }
|
||
return rows
|
||
}
|
||
}
|
||
|
||
// MARK: - Phase 62: Schema change review
|
||
private struct DBSynSchemaChangesView: View {
|
||
@ObservedObject var model: DatabaseModel
|
||
let database: String
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Schema Changes — \(database)").font(.title2).bold()
|
||
Text("Compares the latest introspection with the previous cached snapshot.")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button("Refresh Introspection") { model.introspect(database: database) }
|
||
.disabled(model.selected == nil)
|
||
Button("Done") { dismiss() }
|
||
}
|
||
Divider()
|
||
if let report = model.schemaChangeReport(for: database) {
|
||
Text("Last checked: \(report.checkedAt.formatted(date: .abbreviated, time: .standard))")
|
||
.foregroundStyle(.secondary)
|
||
if report.changes.isEmpty {
|
||
ContentUnavailableView("No schema changes detected", systemImage: "checkmark.circle", description: Text("The latest snapshot matches the previous snapshot."))
|
||
} else {
|
||
List(report.changes) { change in
|
||
HStack(alignment: .top, spacing: 14) {
|
||
Text(change.category).font(.caption).foregroundStyle(.secondary)
|
||
.frame(width: 115, alignment: .leading)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(change.object).font(.system(.body, design: .monospaced))
|
||
Text(change.detail).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
ContentUnavailableView("No comparison available", systemImage: "arrow.triangle.2.circlepath", description: Text("Refresh Introspection to compare against the cached snapshot. The first refresh creates a baseline."))
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 680, minHeight: 420)
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Phase 64: Read-only schema structure comparison
|
||
private struct DBSynSchemaCompareView: View {
|
||
@ObservedObject var model: DatabaseModel
|
||
let sourceDatabase: String
|
||
@State private var targetDatabase = ""
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var showMigrationPreview = false
|
||
@State private var selectedMigrationIDs: Set<String> = []
|
||
|
||
private var source: DatabaseModel.SchemaIntrospection? { model.introspection(for: sourceDatabase) }
|
||
private var target: DatabaseModel.SchemaIntrospection? { model.introspection(for: targetDatabase) }
|
||
|
||
private struct Difference: Identifiable {
|
||
let id: String
|
||
let kind: String
|
||
let detail: String
|
||
}
|
||
|
||
private var differences: [Difference] {
|
||
guard let a = source, let b = target else { return [] }
|
||
var items: [Difference] = []
|
||
let left = Set(a.tables), right = Set(b.tables)
|
||
for name in left.subtracting(right).sorted() {
|
||
items.append(.init(id: "table-source-\(name)", kind: "Source only · Table", detail: name))
|
||
}
|
||
for name in right.subtracting(left).sorted() {
|
||
items.append(.init(id: "table-target-\(name)", kind: "Target only · Table", detail: name))
|
||
}
|
||
let lv = Set(a.views), rv = Set(b.views)
|
||
for name in lv.subtracting(rv).sorted() {
|
||
items.append(.init(id: "view-source-\(name)", kind: "Source only · View", detail: name))
|
||
}
|
||
for name in rv.subtracting(lv).sorted() {
|
||
items.append(.init(id: "view-target-\(name)", kind: "Target only · View", detail: name))
|
||
}
|
||
for table in Set(a.columnsByTable.keys).intersection(b.columnsByTable.keys).sorted() {
|
||
let lc = Dictionary(uniqueKeysWithValues: (a.columnsByTable[table] ?? []).map { ($0.name, $0) })
|
||
let rc = Dictionary(uniqueKeysWithValues: (b.columnsByTable[table] ?? []).map { ($0.name, $0) })
|
||
for name in Set(lc.keys).subtracting(rc.keys).sorted() {
|
||
items.append(.init(id: "column-source-\(table)-\(name)", kind: "Source only · Column", detail: "\(table).\(name)"))
|
||
}
|
||
for name in Set(rc.keys).subtracting(lc.keys).sorted() {
|
||
items.append(.init(id: "column-target-\(table)-\(name)", kind: "Target only · Column", detail: "\(table).\(name)"))
|
||
}
|
||
for name in Set(lc.keys).intersection(rc.keys).sorted() {
|
||
guard let x = lc[name], let y = rc[name] else { continue }
|
||
if x.dataType != y.dataType || x.nullable != y.nullable || x.key != y.key || x.defaultValue != y.defaultValue || x.extra != y.extra {
|
||
items.append(.init(id: "column-changed-\(table)-\(name)", kind: "Different · Column", detail: "\(table).\(name): \(x.dataType) → \(y.dataType)"))
|
||
}
|
||
}
|
||
}
|
||
return items
|
||
}
|
||
|
||
// Phase 65: Generate only additive column DDL. Cached introspection does not
|
||
// contain complete CREATE TABLE or CREATE VIEW definitions, so those objects
|
||
// are deliberately excluded rather than producing unsafe guesses.
|
||
private struct Migration: Identifiable {
|
||
let id: String
|
||
let label: String
|
||
let sql: String
|
||
}
|
||
|
||
private func identifier(_ name: String) -> String {
|
||
"`" + name.replacingOccurrences(of: "`", with: "``") + "`"
|
||
}
|
||
|
||
private var migrations: [Migration] {
|
||
guard let source, let target, !targetDatabase.isEmpty else { return [] }
|
||
var result: [Migration] = []
|
||
let sharedTables = Set(source.tables).intersection(target.tables)
|
||
for table in sharedTables.sorted() {
|
||
let existing = Set((target.columnsByTable[table] ?? []).map(\.name))
|
||
for column in source.columnsByTable[table] ?? [] where !existing.contains(column.name) {
|
||
// Avoid inventing defaults or generating DDL that may fail on
|
||
// populated tables. Require manual review of every statement.
|
||
var definition = "\(identifier(column.name)) \(column.dataType)"
|
||
definition += column.nullable ? " NULL" : " NOT NULL"
|
||
if column.extra.uppercased().contains("AUTO_INCREMENT") {
|
||
// Auto-increment needs key/constraint context absent from the cache.
|
||
continue
|
||
}
|
||
let statement = "ALTER TABLE \(identifier(targetDatabase)).\(identifier(table)) ADD COLUMN \(definition);"
|
||
result.append(Migration(id: "\(table).\(column.name)", label: "\(table).\(column.name)", sql: statement))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
private var selectedMigrations: [Migration] {
|
||
migrations.filter { selectedMigrationIDs.contains($0.id) }
|
||
}
|
||
|
||
private var migrationSQL: String {
|
||
let header = "-- DBSyn Phase 65: review carefully before execution.\n" +
|
||
"-- Source: \(sourceDatabase) Target: \(targetDatabase)\n" +
|
||
"-- Additive columns only; no tables, views, keys or destructive changes.\n" +
|
||
"-- MySQL DDL implicitly commits; take a backup first.\n\n"
|
||
return header + selectedMigrations.map(\.sql).joined(separator: "\n")
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Compare Schema Structures").font(.title2).bold()
|
||
Text("Read-only comparison of cached introspection snapshots.").foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button("Done") { dismiss() }
|
||
}
|
||
HStack {
|
||
Text("Source: \(sourceDatabase)")
|
||
Spacer()
|
||
Picker("Compare with", selection: $targetDatabase) {
|
||
Text("Choose database").tag("")
|
||
ForEach(model.databases.filter { $0 != sourceDatabase }, id: \.self) { name in
|
||
Text(name).tag(name)
|
||
}
|
||
}
|
||
.frame(width: 320)
|
||
}
|
||
HStack {
|
||
Button("Refresh Source") { model.introspect(database: sourceDatabase) }
|
||
Button("Refresh Target") { if !targetDatabase.isEmpty { model.introspect(database: targetDatabase) } }
|
||
.disabled(targetDatabase.isEmpty)
|
||
Spacer()
|
||
if source != nil && target != nil {
|
||
Button("Copy Differences") {
|
||
let output = differences.map { "\($0.kind): \($0.detail)" }.joined(separator: "\n")
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(output.isEmpty ? "No structural differences" : output, forType: .string)
|
||
}
|
||
}
|
||
}
|
||
if source != nil && target != nil {
|
||
HStack {
|
||
Button("Review Additive Migration…") {
|
||
selectedMigrationIDs = Set(migrations.map(\.id))
|
||
showMigrationPreview = true
|
||
}
|
||
.disabled(migrations.isEmpty)
|
||
Text("\(migrations.count) eligible missing columns · tables and views require full DDL")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Spacer()
|
||
}
|
||
}
|
||
Divider()
|
||
if targetDatabase.isEmpty {
|
||
ContentUnavailableView("Choose a target database", systemImage: "square.on.square", description: Text("Select another schema on this connection."))
|
||
} else if source == nil || target == nil {
|
||
ContentUnavailableView("Introspection required", systemImage: "memorychip", description: Text("Refresh source and target to load their tables, views and columns."))
|
||
} else if differences.isEmpty {
|
||
ContentUnavailableView("Structures match", systemImage: "checkmark.circle", description: Text("No differences found in cached table, view and column metadata."))
|
||
} else {
|
||
Text("\(differences.count) structural differences").foregroundStyle(.secondary)
|
||
List(differences) { item in
|
||
HStack(alignment: .top, spacing: 14) {
|
||
Text(item.kind).font(.caption).foregroundStyle(.secondary)
|
||
.frame(width: 155, alignment: .leading)
|
||
Text(item.detail).font(.system(.body, design: .monospaced))
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 760, minHeight: 480)
|
||
.onAppear {
|
||
if targetDatabase.isEmpty { targetDatabase = model.databases.first(where: { $0 != sourceDatabase }) ?? "" }
|
||
}
|
||
.sheet(isPresented: $showMigrationPreview) {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Review Additive Migration").font(.title2).bold()
|
||
Text("Select columns to add to \(targetDatabase). Nothing is executed automatically.")
|
||
.foregroundStyle(.secondary)
|
||
List(migrations) { migration in
|
||
Toggle(migration.label, isOn: Binding(
|
||
get: { selectedMigrationIDs.contains(migration.id) },
|
||
set: { enabled in
|
||
if enabled { selectedMigrationIDs.insert(migration.id) }
|
||
else { selectedMigrationIDs.remove(migration.id) }
|
||
}
|
||
))
|
||
}
|
||
.frame(minHeight: 130)
|
||
Text("SQL Preview").font(.headline)
|
||
ScrollView {
|
||
Text(migrationSQL)
|
||
.font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(12)
|
||
}
|
||
.background(.quaternary.opacity(0.3))
|
||
.frame(minHeight: 150)
|
||
Text("Warning: NOT NULL columns without defaults may fail on populated tables. Verify types, indexes and constraints manually. MySQL DDL may implicitly commit.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
HStack {
|
||
Spacer()
|
||
Button("Cancel") { showMigrationPreview = false }
|
||
Button("Copy SQL") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(migrationSQL, forType: .string)
|
||
}
|
||
.disabled(selectedMigrations.isEmpty)
|
||
Button("Open in SQL Editor") {
|
||
model.newQueryTab()
|
||
model.sql = migrationSQL
|
||
showMigrationPreview = false
|
||
dismiss()
|
||
}
|
||
.disabled(selectedMigrations.isEmpty)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 750, minHeight: 560)
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Phase 66: bounded, read-only table data comparison
|
||
private struct DBSynDataCompareView: View {
|
||
@ObservedObject var model: DatabaseModel
|
||
let sourceDatabase: String
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var targetDatabase = ""
|
||
@State private var table = ""
|
||
@State private var isComparing = false
|
||
@State private var report: DatabaseModel.DataComparisonReport?
|
||
@State private var syncPreview: DatabaseModel.DataSyncPreview?
|
||
@State private var errorMessage: String?
|
||
|
||
private var commonTables: [String] {
|
||
guard let source = model.introspection(for: sourceDatabase),
|
||
let target = model.introspection(for: targetDatabase) else { return [] }
|
||
return Array(Set(source.tables).intersection(target.tables)).sorted()
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
HStack {
|
||
Text("Compare Table Data").font(.title2).bold()
|
||
Spacer()
|
||
Button("Done") { dismiss() }
|
||
}
|
||
Text("Read-only comparison of up to 500 rows per table. No data is modified.")
|
||
.foregroundStyle(.secondary)
|
||
HStack {
|
||
Text("Source: \(sourceDatabase)")
|
||
Spacer()
|
||
Picker("Target database", selection: $targetDatabase) {
|
||
Text("Choose database").tag("")
|
||
ForEach(model.databases.filter { $0 != sourceDatabase }, id: \.self) { name in
|
||
Text(name).tag(name)
|
||
}
|
||
}.frame(width: 340)
|
||
}
|
||
HStack {
|
||
Picker("Table", selection: $table) {
|
||
Text("Choose table").tag("")
|
||
ForEach(commonTables, id: \.self) { name in Text(name).tag(name) }
|
||
}.frame(width: 420)
|
||
Button("Refresh Metadata") {
|
||
model.introspect(database: sourceDatabase)
|
||
if !targetDatabase.isEmpty { model.introspect(database: targetDatabase) }
|
||
}
|
||
Spacer()
|
||
Button("Compare") { compare() }
|
||
.disabled(table.isEmpty || targetDatabase.isEmpty || isComparing)
|
||
Button("Preview Missing-Row Sync…") { previewSync() }
|
||
.disabled(table.isEmpty || targetDatabase.isEmpty || isComparing)
|
||
}
|
||
if let syncPreview {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Label("SQL preview — nothing has been executed", systemImage: "exclamationmark.shield")
|
||
.font(.headline)
|
||
Text(syncPreview.explanation).foregroundStyle(.secondary)
|
||
HStack {
|
||
Spacer()
|
||
Button("Copy SQL") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(syncPreview.sql, forType: .string)
|
||
}
|
||
}
|
||
ScrollView {
|
||
Text(syncPreview.sql)
|
||
.font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}.frame(minHeight: 170)
|
||
}
|
||
.padding(12)
|
||
.background(Color(nsColor: .textBackgroundColor))
|
||
}
|
||
if isComparing { ProgressView("Comparing table samples…") }
|
||
if let errorMessage {
|
||
Text(errorMessage).foregroundStyle(.red).textSelection(.enabled)
|
||
}
|
||
if let report {
|
||
HStack {
|
||
Text("Source: \(report.sourceRows) rows sampled")
|
||
Text("Target: \(report.targetRows) rows sampled")
|
||
Spacer()
|
||
Button("Copy Report") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(report.text, forType: .string)
|
||
}
|
||
}.font(.caption)
|
||
if report.truncated {
|
||
Label("At least one table exceeded 500 rows. Differences apply only to the retrieved samples; this is NOT a complete comparison.", systemImage: "exclamationmark.triangle")
|
||
.foregroundStyle(.orange)
|
||
}
|
||
ScrollView {
|
||
Text(report.text)
|
||
.font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(12)
|
||
}
|
||
.background(Color(nsColor: .textBackgroundColor))
|
||
} else if !isComparing && errorMessage == nil {
|
||
ContentUnavailableView("Select two databases and a table", systemImage: "tablecells", description: Text("Introspect both databases to list their shared base tables."))
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 820, minHeight: 520)
|
||
.onAppear {
|
||
if targetDatabase.isEmpty {
|
||
targetDatabase = model.databases.first(where: { $0 != sourceDatabase }) ?? ""
|
||
}
|
||
}
|
||
.onChange(of: targetDatabase) { _, _ in table = ""; report = nil; syncPreview = nil }
|
||
.onChange(of: table) { _, _ in report = nil; syncPreview = nil }
|
||
}
|
||
|
||
private func previewSync() {
|
||
errorMessage = nil
|
||
report = nil
|
||
syncPreview = nil
|
||
do {
|
||
syncPreview = try model.previewMissingRowsSync(source: sourceDatabase, target: targetDatabase, table: table)
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func compare() {
|
||
isComparing = true
|
||
errorMessage = nil
|
||
report = nil
|
||
let source = sourceDatabase
|
||
let target = targetDatabase
|
||
let selectedTable = table
|
||
Task {
|
||
do {
|
||
let value = try await model.compareTableData(source: source, target: target, table: selectedTable)
|
||
report = value
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
isComparing = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Reviewed SQL row editing (Phase 72)
|
||
|
||
private struct DBSynInlineRowEditor: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@ObservedObject var model: DatabaseModel
|
||
let database: String
|
||
let table: String
|
||
let columns: [String]
|
||
let original: [String]
|
||
@State private var values: [String]
|
||
@State private var executing = false
|
||
@State private var errorText: String?
|
||
@State private var passwordColumnIndex: Int?
|
||
|
||
init(model: DatabaseModel, database: String, table: String,
|
||
columns: [String], original: [String]) {
|
||
self.model = model
|
||
self.database = database
|
||
self.table = table
|
||
self.columns = columns
|
||
self.original = original
|
||
_values = State(initialValue: original)
|
||
}
|
||
|
||
private var metadata: [DatabaseModel.IntrospectedColumn] {
|
||
model.introspection(for: database)?.columnsByTable[table] ?? []
|
||
}
|
||
|
||
private var primaryIndices: [Int] {
|
||
columns.indices.filter { i in
|
||
metadata.contains { $0.name.caseInsensitiveCompare(columns[i]) == .orderedSame && $0.key == "PRI" }
|
||
}
|
||
}
|
||
|
||
private var changedIndices: [Int] {
|
||
columns.indices.filter { $0 < values.count && $0 < original.count && values[$0] != original[$0] }
|
||
}
|
||
|
||
private func identifier(_ value: String) -> String {
|
||
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
|
||
}
|
||
|
||
private func literal(_ value: String) -> String {
|
||
// MySQL backslash escapes are SQL-mode-dependent. Hexadecimal UTF-8
|
||
// literals avoid SQL injection and preserve quote/backslash characters.
|
||
"CONVERT(X'" + Data(value.utf8).map { String(format: "%02X", $0) }.joined() + "' USING utf8mb4)"
|
||
}
|
||
|
||
private var validationError: String? {
|
||
guard columns.count == original.count, values.count == columns.count,
|
||
metadata.count == columns.count else { return "Result columns do not match introspection metadata. Refresh introspection." }
|
||
guard !primaryIndices.isEmpty else { return "A primary key is required for safe row updates." }
|
||
guard !changedIndices.isEmpty else { return "Edit at least one non-key value to generate SQL." }
|
||
guard !changedIndices.contains(where: { primaryIndices.contains($0) }) else { return "Primary-key editing is not supported." }
|
||
// The native wire protocol currently renders SQL NULL as the text
|
||
// "NULL". Never generate SQL when this representation is ambiguous.
|
||
let checked = Set(primaryIndices + changedIndices)
|
||
for i in checked {
|
||
if metadata[i].nullable && original[i] == "NULL" {
|
||
return "Column \(columns[i]) may contain SQL NULL. Editing ambiguous NULL values is disabled."
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private var generatedSQL: String {
|
||
guard validationError == nil else { return "" }
|
||
let assignments = changedIndices.map { i in
|
||
"\(identifier(columns[i])) = \(literal(values[i]))"
|
||
}.joined(separator: ",\n ")
|
||
// Optimistic checks prevent overwriting a value modified since the
|
||
// result grid was loaded. LIMIT 1 prevents accidental mass updates.
|
||
let checks = (primaryIndices + changedIndices).map { i in
|
||
"\(identifier(columns[i])) <=> \(literal(original[i]))"
|
||
}.joined(separator: " AND\n ")
|
||
return "UPDATE \(identifier(database)).\(identifier(table))\nSET \(assignments)\nWHERE \(checks)\nLIMIT 1;"
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Edit Row — \(database).\(table)").font(.title3.bold())
|
||
Text("Changes are staged locally. Review the SQL before applying them to MySQL.")
|
||
.foregroundStyle(.secondary)
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ForEach(columns.indices, id: \.self) { i in
|
||
HStack {
|
||
Text(columns[i]).frame(width: 190, alignment: .leading)
|
||
TextField(columns[i], text: $values[i])
|
||
.textFieldStyle(.roundedBorder)
|
||
.disabled(primaryIndices.contains(i) || executing)
|
||
if isLaravelPasswordColumn(i) && !primaryIndices.contains(i) {
|
||
Button {
|
||
passwordColumnIndex = i
|
||
} label: {
|
||
Label("Laravel Password…", systemImage: "key.fill")
|
||
}
|
||
.disabled(executing)
|
||
.help("Generate a Laravel bcrypt password hash for this field")
|
||
}
|
||
if primaryIndices.contains(i) {
|
||
Text("PRIMARY KEY").font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxHeight: 290)
|
||
Text("SQL Preview").font(.headline)
|
||
ScrollView {
|
||
Text(validationError ?? generatedSQL)
|
||
.font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(10)
|
||
}
|
||
.frame(height: 140)
|
||
.background(Color(nsColor: .textBackgroundColor))
|
||
if let errorText {
|
||
Text(errorText).foregroundStyle(.red).textSelection(.enabled)
|
||
}
|
||
HStack {
|
||
Button("Copy SQL") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(generatedSQL, forType: .string)
|
||
}
|
||
.disabled(validationError != nil)
|
||
Spacer()
|
||
Button("Cancel") { dismiss() }.disabled(executing)
|
||
Button(executing ? "Applying…" : "Apply SQL Changes") { apply() }
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(validationError != nil || executing)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(width: 780, height: 620)
|
||
.sheet(item: Binding(
|
||
get: { passwordColumnIndex.map { PasswordFieldSelection(index: $0) } },
|
||
set: { passwordColumnIndex = $0?.index }
|
||
)) { selection in
|
||
DBSynLaravelPasswordGenerator { hash in
|
||
guard values.indices.contains(selection.index) else { return }
|
||
values[selection.index] = hash
|
||
passwordColumnIndex = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private func isLaravelPasswordColumn(_ i: Int) -> Bool {
|
||
guard columns.indices.contains(i) else { return false }
|
||
let name = columns[i].lowercased()
|
||
return name == "password" || name == "password_hash"
|
||
}
|
||
|
||
private func apply() {
|
||
guard validationError == nil, let profile = model.selected else { return }
|
||
let statement = generatedSQL
|
||
let password = PasswordStore.read(profile.id)
|
||
executing = true
|
||
errorText = nil
|
||
Task {
|
||
do {
|
||
let response = try await NativeMySQLRunner.run(
|
||
profile: profile, database: database, password: password, sql: statement)
|
||
// A successful UPDATE may affect zero rows when a concurrent
|
||
// change invalidates the optimistic predicate; do not claim a save.
|
||
model.status = "UPDATE completed: \(response.message). Refresh results to verify the row."
|
||
dismiss()
|
||
model.runSQL()
|
||
} catch {
|
||
errorText = error.localizedDescription
|
||
}
|
||
executing = false
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Laravel bcrypt password generation
|
||
|
||
private struct PasswordFieldSelection: Identifiable {
|
||
let index: Int
|
||
var id: Int { index }
|
||
}
|
||
|
||
private enum LaravelBcryptService {
|
||
enum HashError: LocalizedError {
|
||
case noPHP
|
||
case failed(String)
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .noPHP:
|
||
return "PHP CLI was not found. Install PHP or specify its full executable path. Laravel bcrypt requires PHP's password_hash()."
|
||
case .failed(let message): return message
|
||
}
|
||
}
|
||
}
|
||
|
||
static func randomPassword(length: Int = 24) -> String {
|
||
let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#%+=")
|
||
var bytes = [UInt8](repeating: 0, count: length)
|
||
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
|
||
return "" // Do not silently fall back to a weaker generator
|
||
}
|
||
// Rejection sampling prevents modulo bias.
|
||
var result = ""
|
||
for byte in bytes where Int(byte) < (256 / alphabet.count) * alphabet.count {
|
||
result.append(alphabet[Int(byte) % alphabet.count])
|
||
}
|
||
while result.count < length {
|
||
var byte: UInt8 = 0
|
||
guard SecRandomCopyBytes(kSecRandomDefault, 1, &byte) == errSecSuccess else { break }
|
||
if Int(byte) < (256 / alphabet.count) * alphabet.count {
|
||
result.append(alphabet[Int(byte) % alphabet.count])
|
||
}
|
||
}
|
||
return String(result.prefix(length))
|
||
}
|
||
|
||
static func hash(_ plaintext: String, phpPath: String) throws -> String {
|
||
let candidates = [phpPath, "/opt/homebrew/bin/php", "/usr/local/bin/php", "/usr/bin/php"]
|
||
guard let executable = candidates.first(where: {
|
||
!$0.isEmpty && $0.hasPrefix("/") && FileManager.default.isExecutableFile(atPath: $0)
|
||
}) else { throw HashError.noPHP }
|
||
|
||
let process = Process()
|
||
process.executableURL = URL(fileURLWithPath: executable)
|
||
// Read the secret from stdin, never expose it in command-line arguments.
|
||
process.arguments = ["-r", "$p = stream_get_contents(STDIN); $h = password_hash($p, PASSWORD_BCRYPT, ['cost' => 12]); if (!$h) { fwrite(STDERR, 'bcrypt failed'); exit(1); } echo $h;"]
|
||
let input = Pipe()
|
||
let output = Pipe()
|
||
let errors = Pipe()
|
||
process.standardInput = input
|
||
process.standardOutput = output
|
||
process.standardError = errors
|
||
try process.run()
|
||
input.fileHandleForWriting.write(Data(plaintext.utf8))
|
||
try? input.fileHandleForWriting.close()
|
||
let result = output.fileHandleForReading.readDataToEndOfFile()
|
||
let failure = errors.fileHandleForReading.readDataToEndOfFile()
|
||
process.waitUntilExit()
|
||
let hash = String(decoding: result, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard process.terminationStatus == 0,
|
||
hash.range(of: #"^\$2y\$12\$[./A-Za-z0-9]{53}$"#, options: .regularExpression) != nil else {
|
||
let message = String(decoding: failure, as: UTF8.self)
|
||
throw HashError.failed("PHP bcrypt hashing failed: " + (message.isEmpty ? "Invalid hash output" : message))
|
||
}
|
||
return hash
|
||
}
|
||
}
|
||
|
||
private struct DBSynLaravelPasswordGenerator: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
let onUseHash: (String) -> Void
|
||
@State private var plaintext = ""
|
||
@State private var hashed = ""
|
||
@State private var phpPath = ""
|
||
@State private var showPassword = false
|
||
@State private var busy = false
|
||
@State private var errorText: String?
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Label("Laravel Password Generator", systemImage: "key.fill")
|
||
.font(.title2.bold())
|
||
Text("Creates a Laravel-compatible bcrypt hash (cost 12) for a users.password field. Requires PHP CLI.")
|
||
.foregroundStyle(.secondary)
|
||
HStack {
|
||
if showPassword {
|
||
TextField("Plaintext password", text: $plaintext)
|
||
} else {
|
||
SecureField("Plaintext password", text: $plaintext)
|
||
}
|
||
Button("Generate") {
|
||
plaintext = LaravelBcryptService.randomPassword()
|
||
hashed = ""
|
||
errorText = nil
|
||
}
|
||
Button(showPassword ? "Hide" : "Show") { showPassword.toggle() }
|
||
Button("Copy Password") { copy(plaintext) }.disabled(plaintext.isEmpty)
|
||
}
|
||
HStack {
|
||
TextField("PHP executable path (optional)", text: $phpPath)
|
||
.textFieldStyle(.roundedBorder)
|
||
Text("Uses Homebrew PHP automatically when available")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
HStack {
|
||
Button(busy ? "Hashing…" : "Generate bcrypt Hash") { generateHash() }
|
||
.disabled(plaintext.isEmpty || busy)
|
||
if busy { ProgressView().controlSize(.small) }
|
||
}
|
||
Text("bcrypt hash ($2y$12$)").font(.headline)
|
||
TextEditor(text: .constant(hashed))
|
||
.font(.system(.body, design: .monospaced))
|
||
.frame(height: 86)
|
||
.border(Color(nsColor: .separatorColor))
|
||
if let errorText {
|
||
Text(errorText).foregroundStyle(.red).textSelection(.enabled)
|
||
}
|
||
Text("Copy the plaintext password before closing. It cannot be recovered from its hash.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
HStack {
|
||
Button("Copy Hash") { copy(hashed) }.disabled(hashed.isEmpty)
|
||
Spacer()
|
||
Button("Cancel") { dismiss() }
|
||
Button("Use Hash in Row") { onUseHash(hashed); dismiss() }
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(hashed.isEmpty || busy)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(width: 750)
|
||
.onChange(of: plaintext) { _, _ in hashed = "" }
|
||
}
|
||
|
||
private func copy(_ value: String) {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(value, forType: .string)
|
||
}
|
||
|
||
private func generateHash() {
|
||
let secret = plaintext
|
||
let path = phpPath
|
||
busy = true
|
||
errorText = nil
|
||
Task {
|
||
do {
|
||
let value = try await Task.detached(priority: .userInitiated) {
|
||
try LaravelBcryptService.hash(secret, phpPath: path)
|
||
}.value
|
||
if plaintext == secret { hashed = value }
|
||
} catch {
|
||
errorText = error.localizedDescription
|
||
}
|
||
busy = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Connection Manager
|
||
private struct DBSynConnectionManager: View {
|
||
@ObservedObject var model: DatabaseModel
|
||
let onEdit: (ConnectionProfile) -> Void
|
||
let onAdd: () -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var search = ""
|
||
@State private var confirmDelete: UUID?
|
||
|
||
private var filtered: [ConnectionProfile] {
|
||
model.profiles.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search)
|
||
|| $0.host.localizedCaseInsensitiveContains(search) }
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack {
|
||
Label("Connections", systemImage: "server.rack").font(.title2.bold())
|
||
Spacer()
|
||
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") }
|
||
.buttonStyle(.borderless)
|
||
}
|
||
TextField("Search connections", text: $search)
|
||
.textFieldStyle(.roundedBorder)
|
||
List(filtered) { profile in
|
||
HStack(spacing: 12) {
|
||
Image(systemName: model.selectedID == profile.id ? "checkmark.circle.fill" : "server.rack")
|
||
.foregroundStyle(model.selectedID == profile.id ? Color.blue : Color.secondary)
|
||
VStack(alignment: .leading) {
|
||
Text(profile.name).font(.headline)
|
||
Text("\(profile.username)@\(profile.host):\(profile.port)")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button("Connect") { model.selectConnection(profile.id); dismiss() }
|
||
.disabled(model.selectedID == profile.id)
|
||
Button("Edit") { onEdit(profile) }
|
||
Button {
|
||
var copy = profile
|
||
copy.id = UUID()
|
||
copy.name += " Copy"
|
||
model.save(copy, password: PasswordStore.read(profile.id))
|
||
} label: { Image(systemName: "plus.square.on.square") }
|
||
.help("Duplicate")
|
||
Button(role: .destructive) { confirmDelete = profile.id } label: {
|
||
Image(systemName: "trash")
|
||
}
|
||
.disabled(model.profiles.count <= 1)
|
||
}
|
||
.padding(.vertical, 5)
|
||
}
|
||
HStack {
|
||
Button("Add Connection") { onAdd() }
|
||
Spacer()
|
||
Button("Done") { dismiss() }.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
.padding(24)
|
||
.frame(minWidth: 750, minHeight: 430)
|
||
.alert("Delete connection?", isPresented: Binding(
|
||
get: { confirmDelete != nil }, set: { if !$0 { confirmDelete = nil } }
|
||
)) {
|
||
Button("Delete", role: .destructive) {
|
||
if let id = confirmDelete { model.deleteConnection(id) }
|
||
confirmDelete = nil
|
||
}
|
||
Button("Cancel", role: .cancel) { confirmDelete = nil }
|
||
} message: {
|
||
Text("This removes the saved connection and its DBSyn Keychain credential.")
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Copyable administration log details
|
||
|
||
private struct DBSynLogDetail: Identifiable {
|
||
let id = UUID()
|
||
let text: String
|
||
}
|
||
|
||
private struct DBSynLogDetailSheet: View {
|
||
let title: String
|
||
let details: String
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
HStack {
|
||
Label(title, systemImage: "doc.text.magnifyingglass")
|
||
.font(.title2.bold())
|
||
Spacer()
|
||
Button("Copy Full Text") {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(details, forType: .string)
|
||
}
|
||
.keyboardShortcut("c", modifiers: [.command, .shift])
|
||
Button("Close") { dismiss() }
|
||
.keyboardShortcut(.cancelAction)
|
||
}
|
||
Text("Select text to copy, or use Copy Full Text for the complete error and metadata.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
ScrollView([.vertical, .horizontal]) {
|
||
Text(details)
|
||
.font(.system(.body, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(12)
|
||
}
|
||
.background(Color(nsColor: .textBackgroundColor))
|
||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor)))
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 720, minHeight: 420)
|
||
}
|
||
}
|