Files
SQLWorkbench/DBSyn/MainView.swift
T
2026-10-20 09:22:16 +01:00

8155 lines
356 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
if let row = selectedResultRow { openResultRowEditor(row) }
} label: {
Label("Edit Row…", systemImage: "pencil")
}
.disabled(selectedResultRow == nil || parsedResultTarget == nil)
.help(parsedResultTarget == nil
? "Editing requires a single-table SELECT containing its primary key."
: 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; editingResultTarget = nil } }
)) {
if let row = editResultRow, let target = editingResultTarget {
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
openResultRowEditor(row)
})
}
@State private var editingResultTarget: (database: String, table: String)?
private func openResultRowEditor(_ row: [String]) {
guard let target = parsedResultTarget else {
model.status = "Row editing requires a simple SELECT from one table."
return
}
editingResultTarget = target
// Fetch primary-key metadata when the user first edits; do not silently
// disable the editor because the schema browser has not been opened.
if model.introspection(for: target.database) == nil {
model.introspect(database: target.database)
}
editResultRow = row
}
// Parse the result query independently of the asynchronous schema cache.
// The editor validates actual primary keys before allowing an UPDATE.
private var parsedResultTarget: (database: String, table: String)? {
let statements = model.sql.components(separatedBy: ";")
guard let select = statements.last(where: {
$0.range(of: #"(?i)^\s*SELECT\b"#, options: .regularExpression) != nil
}) else { return nil }
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 projectionRange = Range(match.range(at: 1), in: select),
let tableRange = Range(match.range(at: 2), in: select),
let tailRange = Range(match.range(at: 3), in: select) else { return nil }
let projection = String(select[projectionRange]).trimmingCharacters(in: .whitespacesAndNewlines)
let tail = String(select[tailRange]).trimmingCharacters(in: .whitespacesAndNewlines)
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 else { return nil }
}
let parts = String(select[tableRange]).replacingOccurrences(of: "`", with: "").components(separatedBy: ".")
let database = parts.count == 2 ? parts[0] : model.completionDatabase
guard !database.isEmpty, let table = parts.last, !model.result.columns.isEmpty else { return nil }
let names = model.result.columns.map { $0.lowercased() }
guard Set(names).count == names.count else { return nil }
if projection != "*" {
let projected = projection.components(separatedBy: ",").map {
$0.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "`", with: "")
}
guard projected.count == names.count,
zip(projected, names).allSatisfy({ $0.0.lowercased() == $0.1 }) 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.target = context.coordinator
table.doubleAction = #selector(Coordinator.doubleClickedRow(_:))
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 doubleClickedRow(_ sender: Any?) {
guard let table = tableView, rows.indices.contains(table.clickedRow) else { return }
onEdit?(rows[table.clickedRow])
}
@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.first(where: { $0.key.caseInsensitiveCompare(table) == .orderedSame })?.value ?? []
}
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,
columns.allSatisfy({ column in metadata.contains(where: { $0.name.caseInsensitiveCompare(column) == .orderedSame }) }) else { return "Result columns are missing from 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.contains(where: { $0.name.caseInsensitiveCompare(columns[i]) == .orderedSame && $0.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.
// Compare original values byte-for-byte. MySQL may use a different
// connection collation from the column (e.g. utf8mb4_0900_ai_ci vs
// utf8mb4_unicode_ci), so ordinary string <=> can fail with error 1267.
// Binary comparisons preserve optimistic concurrency without relying
// on implicit collation coercion.
let checks = (primaryIndices + changedIndices).map { i in
"CAST(\(identifier(columns[i])) AS BINARY) <=> CAST(\(literal(original[i])) AS BINARY)"
}.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)
}
}