This commit is contained in:
Mark Gregory
2026-10-16 21:45:53 +01:00
parent 7a0a8a1511
commit 8f7fcd97a7
45 changed files with 320 additions and 12219 deletions
@@ -1,218 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
000000000000000000000013 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000010 /* App.swift */; };
000000000000000000000014 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000011 /* MainView.swift */; };
000000000000000000000015 /* DatabaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000012 /* DatabaseModel.swift */; };
000000000000000000000017 /* MySQLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000016 /* MySQLProtocol.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
00000000000000000000000F /* WorkbenchReplacement.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WorkbenchReplacement.app; sourceTree = BUILT_PRODUCTS_DIR; };
000000000000000000000010 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
000000000000000000000011 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
000000000000000000000012 /* DatabaseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseModel.swift; sourceTree = "<group>"; };
000000000000000000000016 /* MySQLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MySQLProtocol.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
000000000000000000000008 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
000000000000000000000002 = {
isa = PBXGroup;
children = (
000000000000000000000003 /* WorkbenchReplacement */,
000000000000000000000004 /* Products */,
);
sourceTree = "<group>";
};
000000000000000000000003 /* WorkbenchReplacement */ = {
isa = PBXGroup;
children = (
000000000000000000000010 /* App.swift */,
000000000000000000000011 /* MainView.swift */,
000000000000000000000012 /* DatabaseModel.swift */,
000000000000000000000016 /* MySQLProtocol.swift */,
);
path = WorkbenchReplacement;
sourceTree = "<group>";
};
000000000000000000000004 /* Products */ = {
isa = PBXGroup;
children = (
00000000000000000000000F /* WorkbenchReplacement.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
000000000000000000000005 /* WorkbenchReplacement */ = {
isa = PBXNativeTarget;
buildConfigurationList = 00000000000000000000000A /* Build configuration list for PBXNativeTarget "WorkbenchReplacement" */;
buildPhases = (
000000000000000000000006 /* Sources */,
000000000000000000000008 /* Frameworks */,
000000000000000000000007 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = WorkbenchReplacement;
productName = WorkbenchReplacement;
productReference = 00000000000000000000000F /* WorkbenchReplacement.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
000000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1600;
TargetAttributes = {
000000000000000000000005 = {
CreatedOnToolsVersion = 16.0;
};
};
};
buildConfigurationList = 000000000000000000000009 /* Build configuration list for PBXProject "WorkbenchReplacement" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 000000000000000000000002;
productRefGroup = 000000000000000000000004 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
000000000000000000000005 /* WorkbenchReplacement */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
000000000000000000000007 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
000000000000000000000006 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
000000000000000000000013 /* App.swift in Sources */,
000000000000000000000014 /* MainView.swift in Sources */,
000000000000000000000015 /* DatabaseModel.swift in Sources */,
000000000000000000000017 /* MySQLProtocol.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
00000000000000000000000B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
SDKROOT = macosx;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
00000000000000000000000C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
SDKROOT = macosx;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
00000000000000000000000D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Workbench Replacement";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.WorkbenchReplacement;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
00000000000000000000000E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Workbench Replacement";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.WorkbenchReplacement;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
000000000000000000000009 /* Build configuration list for PBXProject "WorkbenchReplacement" */ = {
isa = XCConfigurationList;
buildConfigurations = (
00000000000000000000000B /* Debug */,
00000000000000000000000C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
00000000000000000000000A /* Build configuration list for PBXNativeTarget "WorkbenchReplacement" */ = {
isa = XCConfigurationList;
buildConfigurations = (
00000000000000000000000D /* Debug */,
00000000000000000000000E /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 000000000000000000000001 /* Project object */;
}
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>WorkbenchReplacement.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
@@ -1,17 +0,0 @@
import SwiftUI
@main
struct WorkbenchReplacementApp: App {
@StateObject private var model = DatabaseModel()
var body: some Scene {
WindowGroup("Workbench Replacement") {
MainView()
.environmentObject(model)
.frame(minWidth: 1000, minHeight: 650)
}
.commands {
CommandGroup(replacing: .newItem) {}
}
}
}
@@ -1,177 +0,0 @@
import SwiftUI
@main
struct WorkbenchReplacementApp: App {
@StateObject private var model = DatabaseModel()
var body: some Scene {
WindowGroup("Workbench Replacement") {
MainView()
.environmentObject(model)
.frame(minWidth: 1000, minHeight: 650)
}
.commands {
CommandGroup(replacing: .newItem) {}
}
Settings {
WorkbenchSettingsView()
}
}
}
// MARK: - Application Preferences
@MainActor
final class WorkbenchPreferences: ObservableObject {
static let shared = WorkbenchPreferences()
@AppStorage("preferences.editorFontSize")
var editorFontSize = 13.0
@AppStorage("preferences.uppercaseKeywords")
var uppercaseKeywords = true
@AppStorage("preferences.showLineNumbers")
var showLineNumbers = true
@AppStorage("preferences.tabWidth")
var tabWidth = 4
@AppStorage("preferences.resultRowHeight")
var resultRowHeight = 24.0
@AppStorage("preferences.resultColumnWidth")
var resultColumnWidth = 170.0
@AppStorage("preferences.alternatingRows")
var alternatingRows = true
@AppStorage("preferences.appearance")
var appearance = "light"
@AppStorage("preferences.autoExpandTables")
var autoExpandTables = true
}
struct WorkbenchSettingsView: View {
@AppStorage("preferences.editorFontSize")
private var editorFontSize = 13.0
@AppStorage("preferences.uppercaseKeywords")
private var uppercaseKeywords = true
@AppStorage("preferences.showLineNumbers")
private var showLineNumbers = true
@AppStorage("preferences.tabWidth")
private var tabWidth = 4
@AppStorage("preferences.resultRowHeight")
private var resultRowHeight = 24.0
@AppStorage("preferences.resultColumnWidth")
private var resultColumnWidth = 170.0
@AppStorage("preferences.alternatingRows")
private var alternatingRows = true
@AppStorage("preferences.appearance")
private var appearance = "light"
@AppStorage("preferences.autoExpandTables")
private var autoExpandTables = true
var body: some View {
TabView {
Form {
HStack {
Text("Editor Font Size")
Spacer()
Stepper(
"\(Int(editorFontSize)) pt",
value: $editorFontSize,
in: 10...24,
step: 1
)
.frame(width: 130)
}
Toggle(
"Automatically uppercase SQL keywords",
isOn: $uppercaseKeywords
)
Toggle(
"Show line numbers",
isOn: $showLineNumbers
)
Picker("Tab Width", selection: $tabWidth) {
Text("2 spaces").tag(2)
Text("4 spaces").tag(4)
Text("8 spaces").tag(8)
}
}
.padding(20)
.tabItem {
Label("SQL Editor", systemImage: "chevron.left.forwardslash.chevron.right")
}
Form {
HStack {
Text("Result Row Height")
Spacer()
Stepper(
"\(Int(resultRowHeight)) px",
value: $resultRowHeight,
in: 18...40,
step: 1
)
.frame(width: 130)
}
HStack {
Text("Default Column Width")
Spacer()
Stepper(
"\(Int(resultColumnWidth)) px",
value: $resultColumnWidth,
in: 80...400,
step: 10
)
.frame(width: 140)
}
Toggle(
"Alternating row colours",
isOn: $alternatingRows
)
}
.padding(20)
.tabItem {
Label("Result Grid", systemImage: "tablecells")
}
Form {
Picker("Appearance", selection: $appearance) {
Text("Light").tag("light")
Text("Dark").tag("dark")
Text("System").tag("system")
}
Toggle(
"Automatically expand table folders",
isOn: $autoExpandTables
)
}
.padding(20)
.tabItem {
Label("General", systemImage: "gearshape")
}
}
.frame(width: 560, height: 330)
}
}
@@ -1,299 +0,0 @@
import SwiftUI
@main
struct WorkbenchReplacementApp: App {
@StateObject private var model = DatabaseModel()
var body: some Scene {
WindowGroup("Workbench Replacement") {
MainView()
.environmentObject(model)
.frame(minWidth: 1000, minHeight: 650)
}
.commands {
CommandGroup(replacing: .newItem) {}
}
Settings {
WorkbenchSettingsView()
}
}
}
// MARK: - Application Preferences
@MainActor
final class WorkbenchPreferences: ObservableObject {
static let shared = WorkbenchPreferences()
@AppStorage("preferences.editorFontSize")
var editorFontSize = 13.0
@AppStorage("preferences.uppercaseKeywords")
var uppercaseKeywords = true
@AppStorage("preferences.showLineNumbers")
var showLineNumbers = true
@AppStorage("preferences.tabWidth")
var tabWidth = 4
@AppStorage("preferences.resultRowHeight")
var resultRowHeight = 24.0
@AppStorage("preferences.resultColumnWidth")
var resultColumnWidth = 170.0
@AppStorage("preferences.alternatingRows")
var alternatingRows = true
@AppStorage("preferences.appearance")
var appearance = "light"
@AppStorage("preferences.autoExpandTables")
var autoExpandTables = true
}
// MARK: - Workbench Settings
struct WorkbenchSettingsView: View {
@AppStorage("preferences.editorFontSize")
private var editorFontSize = 13.0
@AppStorage("preferences.uppercaseKeywords")
private var uppercaseKeywords = true
@AppStorage("preferences.showLineNumbers")
private var showLineNumbers = true
@AppStorage("preferences.tabWidth")
private var tabWidth = 4
@AppStorage("preferences.defaultRowLimit")
private var defaultRowLimit = 500
@AppStorage("preferences.resultRowHeight")
private var resultRowHeight = 24.0
@AppStorage("preferences.resultColumnWidth")
private var resultColumnWidth = 170.0
@AppStorage("preferences.alternatingRows")
private var alternatingRows = true
@AppStorage("preferences.autoExpandTables")
private var autoExpandTables = true
var body: some View {
TabView {
editorSettings
.tabItem {
Label(
"SQL Editor",
systemImage: "chevron.left.forwardslash.chevron.right"
)
}
executionSettings
.tabItem {
Label(
"Query Execution",
systemImage: "bolt"
)
}
resultSettings
.tabItem {
Label(
"Result Grid",
systemImage: "tablecells"
)
}
generalSettings
.tabItem {
Label(
"General",
systemImage: "gearshape"
)
}
}
.frame(width: 620, height: 390)
.preferredColorScheme(.light)
}
private var editorSettings: some View {
settingsPage("SQL Editor") {
settingsRow("Font Size") {
Stepper(
"\(Int(editorFontSize)) pt",
value: $editorFontSize,
in: 10...24,
step: 1
)
.frame(width: 130)
}
settingsRow("Tab Width") {
Picker("", selection: $tabWidth) {
Text("2 spaces").tag(2)
Text("4 spaces").tag(4)
Text("8 spaces").tag(8)
}
.labelsHidden()
.frame(width: 150)
}
settingsRow("SQL Formatting") {
Toggle(
"Automatically uppercase SQL keywords",
isOn: $uppercaseKeywords
)
}
settingsRow("Editor Display") {
Toggle(
"Show line numbers",
isOn: $showLineNumbers
)
}
}
}
private var executionSettings: some View {
settingsPage("Query Execution") {
settingsRow("Default Row Limit") {
HStack(spacing: 12) {
TextField(
"",
value: $defaultRowLimit,
format: .number
)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
Stepper(
"",
value: $defaultRowLimit,
in: 50...100000,
step: 50
)
.labelsHidden()
}
}
Text(
"Controls the number of rows retrieved when opening a table from the schema navigator."
)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 190)
Divider()
.padding(.vertical, 12)
Text("Examples")
.font(.headline)
Text(
"500 rows: Faster browsing and lower memory usage.\n" +
"5,000 rows: Useful for larger datasets.\n" +
"50,000 rows: May take longer to retrieve."
)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var resultSettings: some View {
settingsPage("Result Grid") {
settingsRow("Row Height") {
Stepper(
"\(Int(resultRowHeight)) px",
value: $resultRowHeight,
in: 18...40,
step: 1
)
.frame(width: 130)
}
settingsRow("Column Width") {
Stepper(
"\(Int(resultColumnWidth)) px",
value: $resultColumnWidth,
in: 80...400,
step: 10
)
.frame(width: 140)
}
settingsRow("Appearance") {
Toggle(
"Alternating row colours",
isOn: $alternatingRows
)
}
}
}
private var generalSettings: some View {
settingsPage("General") {
settingsRow("Navigator") {
Toggle(
"Automatically expand table folders",
isOn: $autoExpandTables
)
}
}
}
private func settingsPage<Content: View>(
_ title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 18) {
Text(title)
.font(.system(size: 20, weight: .semibold))
Divider()
content()
Spacer(minLength: 0)
}
.padding(26)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
private func settingsRow<Content: View>(
_ title: String,
@ViewBuilder content: () -> Content
) -> some View {
HStack(alignment: .center, spacing: 16) {
Text(title)
.font(.system(size: 13))
.frame(width: 170, alignment: .trailing)
content()
Spacer(minLength: 0)
}
.frame(height: 30)
}
}
@@ -1,97 +0,0 @@
import Foundation
import Security
struct ConnectionProfile: Identifiable, Codable, Hashable {
var id = UUID()
var name = "Local MySQL"
var host = "127.0.0.1"
var port = 3306
var username = "root"
var database = ""
}
struct QueryResult {
var columns: [String] = []
var rows: [[String]] = []
var message = ""
}
enum PasswordStore {
static func read(_ id: UUID) -> String {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var value: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
let data = value as? Data else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published var selectedID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var selectedDatabase = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
if profiles.isEmpty { profiles = [ConnectionProfile()] }
selectedID = profiles.first?.id
}
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
func save(_ profile: ConnectionProfile, password: String) {
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
selectedID = profile.id
persist()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() { execute(sql, listingDatabases: false) }
private func execute(_ statement: String, listingDatabases: Bool) {
guard let profile = selected else { status = "Select a connection"; return }
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
status = "Connecting…"
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
let password = PasswordStore.read(profile.id)
Task {
do {
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
if listingDatabases {
databases = parsed.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
} else {
result = parsed
status = "Query completed (\(parsed.rows.count) rows)"
}
} catch {
status = "Error"
result = QueryResult(message: error.localizedDescription)
}
isRunning = false
}
}
}
@@ -1,294 +0,0 @@
import Foundation
import Security
struct ConnectionProfile: Identifiable, Codable, Hashable {
var id = UUID()
var name = "Local MySQL"
var host = "127.0.0.1"
var port = 3306
var username = "root"
var database = ""
}
struct QueryResult {
var columns: [String] = []
var rows: [[String]] = []
var message = ""
}
enum PasswordStore {
static func read(_ id: UUID) -> String {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var value: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
let data = value as? Data else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published var selectedID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var schemaTables: [String: [String]] = [:]
@Published var selectedDatabase = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
if profiles.isEmpty { profiles = [ConnectionProfile()] }
selectedID = profiles.first?.id
}
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
func save(_ profile: ConnectionProfile, password: String) {
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
selectedID = profile.id
persist()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
}
func formatSQL() {
sql = SQLFormatter.uppercaseKeywords(sql)
}
func loadTables(database: String) {
guard let profile = selected else { return }
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(of: "`", with: "``")
Task {
do {
let parsed = try await NativeMySQLRunner.run(
profile: profile,
database: "",
password: password,
sql: "SHOW TABLES FROM `\\(escaped)`"
)
schemaTables[database] = parsed.rows.compactMap { $0.first }
} catch {
status = "Unable to load tables: \\(error.localizedDescription)"
}
}
}
func selectTable(database: String, table: String) {
let db = database.replacingOccurrences(of: "`", with: "``")
let tbl = table.replacingOccurrences(of: "`", with: "``")
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
runSQL()
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() {
formatSQL()
execute(sql, listingDatabases: false)
}
private func execute(_ statement: String, listingDatabases: Bool) {
guard let profile = selected else { status = "Select a connection"; return }
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
status = "Connecting…"
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
let password = PasswordStore.read(profile.id)
Task {
do {
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
if listingDatabases {
databases = parsed.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
} else {
result = parsed
status = "Query completed (\(parsed.rows.count) rows)"
}
} catch {
status = "Error"
result = QueryResult(message: error.localizedDescription)
}
isRunning = false
}
}
}
// MARK: - SQL Keyword Formatter
enum SQLFormatter {
private static let keywords: Set<String> = [
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
]
static func uppercaseKeywords(_ sql: String) -> String {
let chars = Array(sql)
var output = ""
var index = 0
enum State {
case normal
case singleQuote
case doubleQuote
case backtick
case lineComment
case blockComment
}
var state = State.normal
while index < chars.count {
let ch = chars[index]
let next: Character? = index + 1 < chars.count
? chars[index + 1] : nil
switch state {
case .normal:
if ch == "'" {
state = .singleQuote
output.append(ch)
index += 1
} else if ch == "\"" {
state = .doubleQuote
output.append(ch)
index += 1
} else if ch == "`" {
state = .backtick
output.append(ch)
index += 1
} else if ch == "-" && next == "-" &&
(index + 2 == chars.count ||
chars[index + 2].isWhitespace) {
state = .lineComment
output += "--"
index += 2
} else if ch == "#" {
state = .lineComment
output.append(ch)
index += 1
} else if ch == "/" && next == "*" {
state = .blockComment
output += "/*"
index += 2
} else if ch.isLetter || ch == "_" {
let start = index
while index < chars.count &&
(chars[index].isLetter ||
chars[index].isNumber ||
chars[index] == "_" ||
chars[index] == "$") {
index += 1
}
let word = String(chars[start..<index])
let upper = word.uppercased()
output += keywords.contains(upper) ? upper : word
} else {
output.append(ch)
index += 1
}
case .singleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "'" {
if index < chars.count && chars[index] == "'" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .doubleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "\"" {
if index < chars.count && chars[index] == "\"" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .backtick:
output.append(ch)
index += 1
if ch == "`" {
if index < chars.count && chars[index] == "`" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .lineComment:
output.append(ch)
index += 1
if ch == "\n" {
state = .normal
}
case .blockComment:
output.append(ch)
index += 1
if ch == "*" && index < chars.count &&
chars[index] == "/" {
output.append("/")
index += 1
state = .normal
}
}
}
return output
}
}
@@ -1,294 +0,0 @@
import Foundation
import Security
struct ConnectionProfile: Identifiable, Codable, Hashable {
var id = UUID()
var name = "Local MySQL"
var host = "127.0.0.1"
var port = 3306
var username = "root"
var database = ""
}
struct QueryResult {
var columns: [String] = []
var rows: [[String]] = []
var message = ""
}
enum PasswordStore {
static func read(_ id: UUID) -> String {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var value: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
let data = value as? Data else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published var selectedID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var schemaTables: [String: [String]] = [:]
@Published var selectedDatabase = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
if profiles.isEmpty { profiles = [ConnectionProfile()] }
selectedID = profiles.first?.id
}
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
func save(_ profile: ConnectionProfile, password: String) {
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
selectedID = profile.id
persist()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
}
func formatSQL() {
sql = SQLFormatter.uppercaseKeywords(sql)
}
func loadTables(database: String) {
guard let profile = selected else { return }
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(of: "`", with: "``")
Task {
do {
let parsed = try await NativeMySQLRunner.run(
profile: profile,
database: "",
password: password,
sql: "SHOW TABLES FROM `\\(escaped)`"
)
schemaTables[database] = parsed.rows.compactMap { $0.first }
} catch {
status = "Unable to load tables: \\(error.localizedDescription)"
}
}
}
func selectTable(database: String, table: String) {
let db = database.replacingOccurrences(of: "`", with: "``")
let tbl = table.replacingOccurrences(of: "`", with: "``")
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
runSQL()
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() {
formatSQL()
execute(sql, listingDatabases: false)
}
private func execute(_ statement: String, listingDatabases: Bool) {
guard let profile = selected else { status = "Select a connection"; return }
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
status = "Connecting…"
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
let password = PasswordStore.read(profile.id)
Task {
do {
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
if listingDatabases {
databases = parsed.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
} else {
result = parsed
status = "Query completed (\(parsed.rows.count) rows)"
}
} catch {
status = "Error"
result = QueryResult(message: error.localizedDescription)
}
isRunning = false
}
}
}
// MARK: - SQL Keyword Formatter
enum SQLFormatter {
private static let keywords: Set<String> = [
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
]
static func uppercaseKeywords(_ sql: String) -> String {
let chars = Array(sql)
var output = ""
var index = 0
enum State {
case normal
case singleQuote
case doubleQuote
case backtick
case lineComment
case blockComment
}
var state = State.normal
while index < chars.count {
let ch = chars[index]
let next: Character? = index + 1 < chars.count
? chars[index + 1] : nil
switch state {
case .normal:
if ch == "'" {
state = .singleQuote
output.append(ch)
index += 1
} else if ch == "\"" {
state = .doubleQuote
output.append(ch)
index += 1
} else if ch == "`" {
state = .backtick
output.append(ch)
index += 1
} else if ch == "-" && next == "-" &&
(index + 2 == chars.count ||
chars[index + 2].isWhitespace) {
state = .lineComment
output += "--"
index += 2
} else if ch == "#" {
state = .lineComment
output.append(ch)
index += 1
} else if ch == "/" && next == "*" {
state = .blockComment
output += "/*"
index += 2
} else if ch.isLetter || ch == "_" {
let start = index
while index < chars.count &&
(chars[index].isLetter ||
chars[index].isNumber ||
chars[index] == "_" ||
chars[index] == "$") {
index += 1
}
let word = String(chars[start..<index])
let upper = word.uppercased()
output += keywords.contains(upper) ? upper : word
} else {
output.append(ch)
index += 1
}
case .singleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "'" {
if index < chars.count && chars[index] == "'" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .doubleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "\"" {
if index < chars.count && chars[index] == "\"" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .backtick:
output.append(ch)
index += 1
if ch == "`" {
if index < chars.count && chars[index] == "`" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .lineComment:
output.append(ch)
index += 1
if ch == "\n" {
state = .normal
}
case .blockComment:
output.append(ch)
index += 1
if ch == "*" && index < chars.count &&
chars[index] == "/" {
output.append("/")
index += 1
state = .normal
}
}
}
return output
}
}
@@ -1,323 +0,0 @@
import Foundation
import Security
struct ConnectionProfile: Identifiable, Codable, Hashable {
var id = UUID()
var name = "Local MySQL"
var host = "127.0.0.1"
var port = 3306
var username = "root"
var database = ""
}
struct QueryResult {
var columns: [String] = []
var rows: [[String]] = []
var message = ""
}
enum PasswordStore {
static func read(_ id: UUID) -> String {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var value: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
let data = value as? Data else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published var selectedID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var schemaTables: [String: [String]] = [:]
@Published var schemaViews: [String: [String]] = [:]
@Published var selectedDatabase = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
if profiles.isEmpty { profiles = [ConnectionProfile()] }
selectedID = profiles.first?.id
}
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
func save(_ profile: ConnectionProfile, password: String) {
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
selectedID = profile.id
persist()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
}
func formatSQL() {
sql = SQLFormatter.uppercaseKeywords(sql)
}
func loadTables(database: String) {
guard let profile = selected else {
status = "Select a MySQL connection"
return
}
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(
of: "`",
with: "``"
)
status = "Loading tables from \(database)…"
Task {
do {
let parsed = try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: password,
sql: "SHOW FULL TABLES FROM `\(escaped)`"
)
var tables: [String] = []
var views: [String] = []
for row in parsed.rows {
guard let name = row.first else {
continue
}
if row.count > 1 && row[1] == "VIEW" {
views.append(name)
} else {
tables.append(name)
}
}
schemaTables[database] = tables
schemaViews[database] = views
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
} catch {
status = "Unable to load tables: \(error.localizedDescription)"
result = QueryResult(message: error.localizedDescription)
}
}
}
func selectTable(database: String, table: String) {
let db = database.replacingOccurrences(of: "`", with: "``")
let tbl = table.replacingOccurrences(of: "`", with: "``")
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
runSQL()
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() {
formatSQL()
execute(sql, listingDatabases: false)
}
private func execute(_ statement: String, listingDatabases: Bool) {
guard let profile = selected else { status = "Select a connection"; return }
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
status = "Connecting…"
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
let password = PasswordStore.read(profile.id)
Task {
do {
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
if listingDatabases {
databases = parsed.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
} else {
result = parsed
status = "Query completed (\(parsed.rows.count) rows)"
}
} catch {
status = "Error"
result = QueryResult(message: error.localizedDescription)
}
isRunning = false
}
}
}
// MARK: - SQL Keyword Formatter
enum SQLFormatter {
private static let keywords: Set<String> = [
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
]
static func uppercaseKeywords(_ sql: String) -> String {
let chars = Array(sql)
var output = ""
var index = 0
enum State {
case normal
case singleQuote
case doubleQuote
case backtick
case lineComment
case blockComment
}
var state = State.normal
while index < chars.count {
let ch = chars[index]
let next: Character? = index + 1 < chars.count
? chars[index + 1] : nil
switch state {
case .normal:
if ch == "'" {
state = .singleQuote
output.append(ch)
index += 1
} else if ch == "\"" {
state = .doubleQuote
output.append(ch)
index += 1
} else if ch == "`" {
state = .backtick
output.append(ch)
index += 1
} else if ch == "-" && next == "-" &&
(index + 2 == chars.count ||
chars[index + 2].isWhitespace) {
state = .lineComment
output += "--"
index += 2
} else if ch == "#" {
state = .lineComment
output.append(ch)
index += 1
} else if ch == "/" && next == "*" {
state = .blockComment
output += "/*"
index += 2
} else if ch.isLetter || ch == "_" {
let start = index
while index < chars.count &&
(chars[index].isLetter ||
chars[index].isNumber ||
chars[index] == "_" ||
chars[index] == "$") {
index += 1
}
let word = String(chars[start..<index])
let upper = word.uppercased()
output += keywords.contains(upper) ? upper : word
} else {
output.append(ch)
index += 1
}
case .singleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "'" {
if index < chars.count && chars[index] == "'" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .doubleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "\"" {
if index < chars.count && chars[index] == "\"" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .backtick:
output.append(ch)
index += 1
if ch == "`" {
if index < chars.count && chars[index] == "`" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .lineComment:
output.append(ch)
index += 1
if ch == "\n" {
state = .normal
}
case .blockComment:
output.append(ch)
index += 1
if ch == "*" && index < chars.count &&
chars[index] == "/" {
output.append("/")
index += 1
state = .normal
}
}
}
return output
}
}
@@ -1,334 +0,0 @@
import Foundation
import Security
struct ConnectionProfile: Identifiable, Codable, Hashable {
var id = UUID()
var name = "Local MySQL"
var host = "127.0.0.1"
var port = 3306
var username = "root"
var database = ""
}
struct QueryResult {
var columns: [String] = []
var rows: [[String]] = []
var message = ""
}
enum PasswordStore {
static func read(_ id: UUID) -> String {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var value: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
let data = value as? Data else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published var selectedID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var schemaTables: [String: [String]] = [:]
@Published var schemaViews: [String: [String]] = [:]
@Published var selectedDatabase = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
if profiles.isEmpty { profiles = [ConnectionProfile()] }
selectedID = profiles.first?.id
}
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
func save(_ profile: ConnectionProfile, password: String) {
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
selectedID = profile.id
persist()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
}
func formatSQL() {
sql = SQLFormatter.uppercaseKeywords(sql)
}
func loadTables(database: String) {
guard let profile = selected else {
status = "Select a MySQL connection"
return
}
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(
of: "`",
with: "``"
)
status = "Loading tables from \(database)…"
Task {
do {
let parsed = try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: password,
sql: "SHOW FULL TABLES FROM `\(escaped)`"
)
var tables: [String] = []
var views: [String] = []
for row in parsed.rows {
guard let name = row.first else {
continue
}
if row.count > 1 && row[1] == "VIEW" {
views.append(name)
} else {
tables.append(name)
}
}
schemaTables[database] = tables
schemaViews[database] = views
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
} catch {
status = "Unable to load tables: \(error.localizedDescription)"
result = QueryResult(message: error.localizedDescription)
}
}
}
func selectTable(database: String, table: String) {
let db = database.replacingOccurrences(
of: "`",
with: "``"
)
let tbl = table.replacingOccurrences(
of: "`",
with: "``"
)
selectedDatabase = database
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT 100;"
runSQL()
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() {
formatSQL()
execute(sql, listingDatabases: false)
}
private func execute(_ statement: String, listingDatabases: Bool) {
guard let profile = selected else { status = "Select a connection"; return }
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
status = "Connecting…"
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
let password = PasswordStore.read(profile.id)
Task {
do {
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
if listingDatabases {
databases = parsed.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
} else {
result = parsed
status = "Query completed (\(parsed.rows.count) rows)"
}
} catch {
status = "Error"
result = QueryResult(message: error.localizedDescription)
}
isRunning = false
}
}
}
// MARK: - SQL Keyword Formatter
enum SQLFormatter {
private static let keywords: Set<String> = [
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
]
static func uppercaseKeywords(_ sql: String) -> String {
let chars = Array(sql)
var output = ""
var index = 0
enum State {
case normal
case singleQuote
case doubleQuote
case backtick
case lineComment
case blockComment
}
var state = State.normal
while index < chars.count {
let ch = chars[index]
let next: Character? = index + 1 < chars.count
? chars[index + 1] : nil
switch state {
case .normal:
if ch == "'" {
state = .singleQuote
output.append(ch)
index += 1
} else if ch == "\"" {
state = .doubleQuote
output.append(ch)
index += 1
} else if ch == "`" {
state = .backtick
output.append(ch)
index += 1
} else if ch == "-" && next == "-" &&
(index + 2 == chars.count ||
chars[index + 2].isWhitespace) {
state = .lineComment
output += "--"
index += 2
} else if ch == "#" {
state = .lineComment
output.append(ch)
index += 1
} else if ch == "/" && next == "*" {
state = .blockComment
output += "/*"
index += 2
} else if ch.isLetter || ch == "_" {
let start = index
while index < chars.count &&
(chars[index].isLetter ||
chars[index].isNumber ||
chars[index] == "_" ||
chars[index] == "$") {
index += 1
}
let word = String(chars[start..<index])
let upper = word.uppercased()
output += keywords.contains(upper) ? upper : word
} else {
output.append(ch)
index += 1
}
case .singleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "'" {
if index < chars.count && chars[index] == "'" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .doubleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "\"" {
if index < chars.count && chars[index] == "\"" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .backtick:
output.append(ch)
index += 1
if ch == "`" {
if index < chars.count && chars[index] == "`" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .lineComment:
output.append(ch)
index += 1
if ch == "\n" {
state = .normal
}
case .blockComment:
output.append(ch)
index += 1
if ch == "*" && index < chars.count &&
chars[index] == "/" {
output.append("/")
index += 1
state = .normal
}
}
}
return output
}
}
@@ -1,335 +0,0 @@
import Foundation
import Security
struct ConnectionProfile: Identifiable, Codable, Hashable {
var id = UUID()
var name = "Local MySQL"
var host = "127.0.0.1"
var port = 3306
var username = "root"
var database = ""
}
struct QueryResult {
var columns: [String] = []
var rows: [[String]] = []
var message = ""
}
enum PasswordStore {
static func read(_ id: UUID) -> String {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var value: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
let data = value as? Data else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published var selectedID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var schemaTables: [String: [String]] = [:]
@Published var schemaViews: [String: [String]] = [:]
@Published var selectedDatabase = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
if profiles.isEmpty { profiles = [ConnectionProfile()] }
selectedID = profiles.first?.id
}
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
func save(_ profile: ConnectionProfile, password: String) {
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
selectedID = profile.id
persist()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
}
func formatSQL() {
sql = SQLFormatter.uppercaseKeywords(sql)
}
func loadTables(database: String) {
guard let profile = selected else {
status = "Select a MySQL connection"
return
}
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(
of: "`",
with: "``"
)
status = "Loading tables from \(database)…"
Task {
do {
let parsed = try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: password,
sql: "SHOW FULL TABLES FROM `\(escaped)`"
)
var tables: [String] = []
var views: [String] = []
for row in parsed.rows {
guard let name = row.first else {
continue
}
if row.count > 1 && row[1] == "VIEW" {
views.append(name)
} else {
tables.append(name)
}
}
schemaTables[database] = tables
schemaViews[database] = views
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
} catch {
status = "Unable to load tables: \(error.localizedDescription)"
result = QueryResult(message: error.localizedDescription)
}
}
}
func selectTable(database: String, table: String) {
let db = database.replacingOccurrences(
of: "`",
with: "``"
)
let tbl = table.replacingOccurrences(
of: "`",
with: "``"
)
selectedDatabase = database
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT 100;"
runSQL()
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() {
formatSQL()
execute(sql, listingDatabases: false)
}
private func execute(_ statement: String, listingDatabases: Bool) {
guard let profile = selected else { status = "Select a connection"; return }
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
status = "Connecting…"
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
let password = PasswordStore.read(profile.id)
Task {
do {
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
if listingDatabases {
databases = parsed.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
} else {
result = parsed
status = "Query completed (\(parsed.rows.count) rows)"
}
} catch {
status = "Error"
result = QueryResult(message: error.localizedDescription)
}
isRunning = false
}
}
}
// MARK: - SQL Keyword Formatter
enum SQLFormatter {
private static let keywords: Set<String> = [
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
]
static func uppercaseKeywords(_ sql: String) -> String {
let chars = Array(sql)
var output = ""
var index = 0
enum State {
case normal
case singleQuote
case doubleQuote
case backtick
case lineComment
case blockComment
}
var state = State.normal
while index < chars.count {
let ch = chars[index]
let next: Character? = index + 1 < chars.count
? chars[index + 1] : nil
switch state {
case .normal:
if ch == "'" {
state = .singleQuote
output.append(ch)
index += 1
} else if ch == "\"" {
state = .doubleQuote
output.append(ch)
index += 1
} else if ch == "`" {
state = .backtick
output.append(ch)
index += 1
} else if ch == "-" && next == "-" &&
(index + 2 == chars.count ||
chars[index + 2].isWhitespace) {
state = .lineComment
output += "--"
index += 2
} else if ch == "#" {
state = .lineComment
output.append(ch)
index += 1
} else if ch == "/" && next == "*" {
state = .blockComment
output += "/*"
index += 2
} else if ch.isLetter || ch == "_" {
let start = index
while index < chars.count &&
(chars[index].isLetter ||
chars[index].isNumber ||
chars[index] == "_" ||
chars[index] == "$") {
index += 1
}
let word = String(chars[start..<index])
let upper = word.uppercased()
output += keywords.contains(upper) ? upper : word
} else {
output.append(ch)
index += 1
}
case .singleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "'" {
if index < chars.count && chars[index] == "'" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .doubleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "\"" {
if index < chars.count && chars[index] == "\"" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .backtick:
output.append(ch)
index += 1
if ch == "`" {
if index < chars.count && chars[index] == "`" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .lineComment:
output.append(ch)
index += 1
if ch == "\n" {
state = .normal
}
case .blockComment:
output.append(ch)
index += 1
if ch == "*" && index < chars.count &&
chars[index] == "/" {
output.append("/")
index += 1
state = .normal
}
}
}
return output
}
}
@@ -1,339 +0,0 @@
import Foundation
import Security
struct ConnectionProfile: Identifiable, Codable, Hashable {
var id = UUID()
var name = "Local MySQL"
var host = "127.0.0.1"
var port = 3306
var username = "root"
var database = ""
}
struct QueryResult {
var columns: [String] = []
var rows: [[String]] = []
var message = ""
}
enum PasswordStore {
static func read(_ id: UUID) -> String {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var value: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
let data = value as? Data else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "WorkbenchReplacement.MySQL",
kSecAttrAccount as String: id.uuidString]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published var selectedID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var schemaTables: [String: [String]] = [:]
@Published var schemaViews: [String: [String]] = [:]
@Published var selectedDatabase = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
if profiles.isEmpty { profiles = [ConnectionProfile()] }
selectedID = profiles.first?.id
}
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
func save(_ profile: ConnectionProfile, password: String) {
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
selectedID = profile.id
persist()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
}
func formatSQL() {
sql = SQLFormatter.uppercaseKeywords(sql)
}
func loadTables(database: String) {
guard let profile = selected else {
status = "Select a MySQL connection"
return
}
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(
of: "`",
with: "``"
)
status = "Loading tables from \(database)…"
Task {
do {
let parsed = try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: password,
sql: "SHOW FULL TABLES FROM `\(escaped)`"
)
var tables: [String] = []
var views: [String] = []
for row in parsed.rows {
guard let name = row.first else {
continue
}
if row.count > 1 && row[1] == "VIEW" {
views.append(name)
} else {
tables.append(name)
}
}
schemaTables[database] = tables
schemaViews[database] = views
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
} catch {
status = "Unable to load tables: \(error.localizedDescription)"
result = QueryResult(message: error.localizedDescription)
}
}
}
func selectTable(database: String, table: String) {
let db = database.replacingOccurrences(
of: "`",
with: "``"
)
let tbl = table.replacingOccurrences(
of: "`",
with: "``"
)
selectedDatabase = database
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT 100;"
runSQL()
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() {
if UserDefaults.standard.object(
forKey: "preferences.uppercaseKeywords"
) as? Bool ?? true {
formatSQL()
}
execute(sql, listingDatabases: false)
}
private func execute(_ statement: String, listingDatabases: Bool) {
guard let profile = selected else { status = "Select a connection"; return }
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
status = "Connecting…"
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
let password = PasswordStore.read(profile.id)
Task {
do {
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
if listingDatabases {
databases = parsed.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
} else {
result = parsed
status = "Query completed (\(parsed.rows.count) rows)"
}
} catch {
status = "Error"
result = QueryResult(message: error.localizedDescription)
}
isRunning = false
}
}
}
// MARK: - SQL Keyword Formatter
enum SQLFormatter {
private static let keywords: Set<String> = [
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
]
static func uppercaseKeywords(_ sql: String) -> String {
let chars = Array(sql)
var output = ""
var index = 0
enum State {
case normal
case singleQuote
case doubleQuote
case backtick
case lineComment
case blockComment
}
var state = State.normal
while index < chars.count {
let ch = chars[index]
let next: Character? = index + 1 < chars.count
? chars[index + 1] : nil
switch state {
case .normal:
if ch == "'" {
state = .singleQuote
output.append(ch)
index += 1
} else if ch == "\"" {
state = .doubleQuote
output.append(ch)
index += 1
} else if ch == "`" {
state = .backtick
output.append(ch)
index += 1
} else if ch == "-" && next == "-" &&
(index + 2 == chars.count ||
chars[index + 2].isWhitespace) {
state = .lineComment
output += "--"
index += 2
} else if ch == "#" {
state = .lineComment
output.append(ch)
index += 1
} else if ch == "/" && next == "*" {
state = .blockComment
output += "/*"
index += 2
} else if ch.isLetter || ch == "_" {
let start = index
while index < chars.count &&
(chars[index].isLetter ||
chars[index].isNumber ||
chars[index] == "_" ||
chars[index] == "$") {
index += 1
}
let word = String(chars[start..<index])
let upper = word.uppercased()
output += keywords.contains(upper) ? upper : word
} else {
output.append(ch)
index += 1
}
case .singleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "'" {
if index < chars.count && chars[index] == "'" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .doubleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "\"" {
if index < chars.count && chars[index] == "\"" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .backtick:
output.append(ch)
index += 1
if ch == "`" {
if index < chars.count && chars[index] == "`" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .lineComment:
output.append(ch)
index += 1
if ch == "\n" {
state = .normal
}
case .blockComment:
output.append(ch)
index += 1
if ch == "*" && index < chars.count &&
chars[index] == "/" {
output.append("/")
index += 1
state = .normal
}
}
}
return output
}
}
+177 -66
View File
@@ -35,7 +35,7 @@ struct MainView: View {
@State private var selectedTableName: String? = nil
@State private var outputExpanded = true
@State private var navigatorMode = 0
@State private var navigatorMode = 1
@State private var administrationPage: WorkbenchAdminPage? = nil
@@ -1567,6 +1567,14 @@ struct WorkbenchDatabaseTransferView: View {
@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 loadingTables = false
@State private var availableDatabases: [String] = []
@State private var loadingDatabases = false
private var selectedProfile: ConnectionProfile? {
model.selected
@@ -1594,20 +1602,50 @@ struct WorkbenchDatabaseTransferView: View {
Picker("Database", selection: $database) {
Text("Select Database").tag("")
ForEach(model.databases, id: \.self) { name in
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 {
Section("Export Options") {
Text("Export the SQL definitions of all base tables in the selected database.")
.foregroundStyle(.secondary)
Text("This version exports table structure. Table data, views, triggers and stored routines will be added in the next stage.")
.font(.caption)
.foregroundStyle(.secondary)
Toggle("Table structure", isOn: $exportStructure)
Toggle("Table data", isOn: $exportData)
Toggle("Add DROP TABLE IF EXISTS", isOn: $includeDrop)
.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…") }
ScrollView {
LazyVStack(alignment: .leading) {
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) }
}
))
}
}
}
.frame(maxHeight: 180)
Text("Exports base tables with batched INSERT statements. Views, triggers, routines and transaction-consistent snapshots are not included.")
.font(.caption).foregroundStyle(.secondary)
}
} else {
Section("Import Options") {
@@ -1662,7 +1700,8 @@ struct WorkbenchDatabaseTransferView: View {
.buttonStyle(.borderedProminent)
.disabled(
selectedProfile == nil ||
database.isEmpty
database.isEmpty ||
(selectedMode == 0 && (selectedTables.isEmpty || (!exportStructure && !exportData)))
)
}
}
@@ -1676,6 +1715,12 @@ struct WorkbenchDatabaseTransferView: View {
: model.selectedDatabase
}
}
.task(id: selectedProfile?.id) {
await loadTransferDatabases()
}
.task(id: database) {
if !database.isEmpty { await loadTables() }
}
.confirmationDialog(
"Import SQL into \(database)?",
isPresented: $showingImportConfirmation
@@ -1759,7 +1804,7 @@ struct WorkbenchDatabaseTransferView: View {
return try await NativeMySQLRunner.run(
profile: profile,
database: database,
database: statement == "SHOW DATABASES" ? "" : database,
password: PasswordStore.read(profile.id),
sql: statement
)
@@ -1772,84 +1817,150 @@ struct WorkbenchDatabaseTransferView: View {
) + "`"
}
@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 = []; 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)
} catch {
status = "Cannot list tables: \(error.localizedDescription)"
availableTables = []
selectedTables = []
}
}
// 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 !selectedTables.isEmpty, exportStructure || exportData else { return }
isWorking = true
progress = 0
status = "Reading database structure…"
let tables = availableTables.filter { selectedTables.contains($0) }
let tempURL = url.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).sql.tmp")
var handle: FileHandle?
defer {
try? handle?.close()
try? FileManager.default.removeItem(at: tempURL)
isWorking = false
operationTask = nil
}
do {
let quotedDatabase = quoteIdentifier(database)
let tableResult = try await execute(
"SHOW FULL TABLES FROM \(quotedDatabase)"
)
let tables = tableResult.rows.compactMap { row -> String? in
guard row.count >= 2,
row[1].uppercased() == "BASE TABLE"
else { return nil }
return row[0]
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
throw WorkbenchTransferError.message("Cannot create temporary export file")
}
var output = """
-- WorkbenchReplacement SQL Export
-- Database: \(database)
-- Generated: \(Date())
SET NAMES utf8mb4;
"""
handle = try FileHandle(forWritingTo: tempURL)
func write(_ value: String) throws {
try handle?.write(contentsOf: Data(value.utf8))
}
try write("-- WorkbenchReplacement native export\n-- Database: \(database)\n-- Generated: \(Date())\nSET NAMES utf8mb4;\n\n")
let db = quoteIdentifier(database)
for (index, table) in tables.enumerated() {
try Task.checkCancellation()
status = "Exporting structure: \(table)"
let result = try await execute(
"SHOW CREATE TABLE \(quotedDatabase).\(quoteIdentifier(table))"
)
guard let row = result.rows.first,
row.count >= 2
else {
throw WorkbenchTransferError.message(
"Cannot retrieve CREATE TABLE for \(table)"
)
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")
}
output += "\n-- Table: \(table)\n"
output += row[1]
output += ";\n\n"
progress = Double(index + 1) /
Double(max(tables.count, 1))
if exportData {
// Column metadata drives binary-safe SQL literal formatting.
let metadata = try await execute("""
SELECT COLUMN_NAME, DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = \(sqlLiteral(database)) AND TABLE_NAME = \(sqlLiteral(table))
ORDER BY ORDINAL_POSITION
""")
guard !metadata.rows.isEmpty else {
throw WorkbenchTransferError.message("No column metadata for \(table)")
}
let names = metadata.rows.map { quoteIdentifier($0[0]) }.joined(separator: ", ")
let expressions = metadata.rows.map { row -> String in
let col = quoteIdentifier(row[0])
let kind = row[1].lowercased()
// HEX avoids decoding arbitrary bytes as UTF-8.
if ["binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob", "bit"].contains(kind) {
return "IF(\(col) IS NULL, 'NULL', CONCAT('0x', HEX(\(col))))"
}
return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))"
}.joined(separator: ", ")
let statement = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified) LIMIT 250 OFFSET "
var offset = 0
while true {
try Task.checkCancellation()
let batch = try await execute(statement + String(offset))
if batch.rows.isEmpty { break }
for row in batch.rows {
guard let literalRow = row.first else { continue }
try write("INSERT INTO \(qualified) (\(names)) VALUES \(literalRow);\n")
}
offset += batch.rows.count
status = "Exporting \(table): \(offset) rows"
if batch.rows.count < 250 { break }
}
}
progress = Double(index + 1) / Double(tables.count)
}
try Task.checkCancellation()
try output.write(
to: url,
atomically: true,
encoding: .utf8
)
try handle?.close()
handle = nil
if FileManager.default.fileExists(atPath: url.path) {
_ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)
} else {
try FileManager.default.moveItem(at: tempURL, to: url)
}
progress = 1
status = "Export complete: \(tables.count) table definitions"
status = "Export complete: \(tables.count) tables"
} catch is CancellationError {
status = "Export cancelled"
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
@@ -1,189 +0,0 @@
import SwiftUI
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
var body: some View {
NavigationSplitView {
navigator
} detail: {
editorPanel
}
.sheet(isPresented: $showingEditor) {
ConnectionEditor(profile: $editing, password: $editingPassword) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
private var navigator: some View {
List(selection: $model.selectedID) {
Section("MYSQL CONNECTIONS") {
ForEach(model.profiles) { profile in
Label(profile.name, systemImage: "server.rack")
.tag(profile.id)
.contextMenu {
Button("Edit Connection") { edit(profile) }
}
}
}
Section("SCHEMAS") {
ForEach(model.databases, id: \.self) { db in
Button {
model.selectedDatabase = db
} label: {
Label(db, systemImage: "cylinder.split.1x2")
}
.buttonStyle(.plain)
}
}
}
.listStyle(.sidebar)
.navigationTitle("Navigator")
.toolbar {
Button {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
} label: {
Image(systemName: "plus")
}
.help("New connection")
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.help("Connect / refresh schemas")
}
}
private var editorPanel: some View {
VStack(spacing: 0) {
editorToolbar
Divider()
TextEditor(text: $model.sql)
.font(.system(size: 14, design: .monospaced))
.frame(minHeight: 190)
.padding(8)
Divider()
resultsPanel
Divider()
statusBar
}
.navigationTitle("Workbench Replacement")
}
private var editorToolbar: some View {
HStack {
Label("SQL Editor", systemImage: "chevron.left.forwardslash.chevron.right")
.font(.headline)
Spacer()
Button("Edit Connection") {
if let profile = model.selected { edit(profile) }
}
Button("Execute") { model.runSQL() }
.keyboardShortcut(.return, modifiers: [.command])
.disabled(model.isRunning)
}
.padding(12)
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Text("Result Grid").font(.headline)
Spacer()
Text(model.result.message).foregroundStyle(.secondary)
}
.padding(12)
if model.result.columns.isEmpty {
ContentUnavailableView("No Results", systemImage: "tablecells",
description: Text("Run a query to display results."))
} else {
resultsGrid
}
}
}
private var resultsGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
ForEach(model.result.columns, id: \.self) { name in
Text(name)
.fontWeight(.semibold)
.frame(minWidth: 130, alignment: .leading)
}
}
ForEach(model.result.rows.indices, id: \.self) { rowIndex in
resultRow(model.result.rows[rowIndex])
}
}
.padding(12)
}
}
private func resultRow(_ row: [String]) -> some View {
GridRow {
ForEach(model.result.columns.indices, id: \.self) { index in
Text(index < row.count ? row[index] : "")
.font(.system(.body, design: .monospaced))
.frame(minWidth: 130, alignment: .leading)
}
}
}
private var statusBar: some View {
HStack {
Circle()
.fill(model.status == "Error" ? Color.red : Color.green)
.frame(width: 8, height: 8)
Text(model.status).lineLimit(1)
Spacer()
Text(model.selected?.name ?? "No connection")
}
.font(.caption)
.padding(9)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings").font(.title2.bold())
Form {
TextField("Connection Name", text: $profile.name)
TextField("Hostname", text: $profile.host)
TextField("Port", value: $profile.port, format: .number)
TextField("Username", text: $profile.username)
SecureField("Password (stored in macOS Keychain)", text: $password)
TextField("Default Schema", text: $profile.database)
}
Text("Built-in MySQL protocol client. This first version supports loopback TCP connections and local SSH tunnels. Remote TLS and full caching_sha2 authentication are not implemented yet.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Save") { save() }.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
@@ -1,489 +0,0 @@
import SwiftUI
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
var body: some View {
NavigationSplitView {
navigator
.navigationSplitViewColumnWidth(
min: 220,
ideal: 270,
max: 400
)
} detail: {
editorPanel
}
.navigationSplitViewStyle(.balanced)
.frame(minWidth: 1000, minHeight: 650)
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
HStack {
Text("NAVIGATOR")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh schemas")
}
.padding(12)
Divider()
List(selection: $model.selectedID) {
Section("MYSQL CONNECTIONS") {
ForEach(model.profiles) { profile in
Label(
profile.name,
systemImage: "server.rack"
)
.tag(profile.id)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
Section("SCHEMAS") {
ForEach(model.databases, id: \.self) { db in
DisclosureGroup {
schemaContents(db)
} label: {
Label(
db,
systemImage: "cylinder.split.1x2"
)
.fontWeight(
model.selectedDatabase == db
? .semibold : .regular
)
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
.onTapGesture {
model.selectedDatabase = db
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
}
}
}
.listStyle(.sidebar)
Divider()
HStack {
Button {
editing = ConnectionProfile(
name: "New Connection"
)
editingPassword = ""
showingEditor = true
} label: {
Label(
"New Connection",
systemImage: "plus"
)
}
.buttonStyle(.borderless)
Spacer()
}
.padding(10)
}
}
@ViewBuilder
private func schemaContents(_ database: String) -> some View {
if let tables = model.schemaTables[database] {
if tables.isEmpty {
Text("No tables")
.foregroundStyle(.secondary)
} else {
ForEach(tables, id: \.self) { table in
Label(table, systemImage: "tablecells")
.font(.system(size: 12))
.onTapGesture(count: 2) {
model.selectTable(
database: database,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
model.selectTable(
database: database,
table: table
)
}
}
}
}
} else {
Button("Load Tables") {
model.loadTables(database: database)
}
.buttonStyle(.borderless)
}
}
// MARK: - SQL Editor
private var editorPanel: some View {
VStack(spacing: 0) {
editorToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 180)
resultsPanel
.frame(minHeight: 180)
}
Divider()
statusBar
}
.navigationTitle("WorkbenchReplacement")
}
private var editorToolbar: some View {
HStack(spacing: 14) {
Label(
"SQL Editor",
systemImage:
"chevron.left.forwardslash.chevron.right"
)
.font(.headline)
Divider()
.frame(height: 20)
Button {
model.runSQL()
} label: {
Label(
"Execute",
systemImage: "bolt.fill"
)
}
.keyboardShortcut(
.return,
modifiers: [.command]
)
.disabled(model.isRunning)
Button {
model.formatSQL()
} label: {
Label(
"Format SQL",
systemImage: "text.alignleft"
)
}
Spacer()
if !model.selectedDatabase.isEmpty {
Label(
model.selectedDatabase,
systemImage: "cylinder"
)
.foregroundStyle(.secondary)
}
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.help("Refresh schemas")
}
.buttonStyle(.borderless)
.padding(12)
}
private var sqlEditor: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Image(systemName: "doc.text")
Text("Query 1")
.fontWeight(.medium)
Spacer()
Text("MySQL")
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
TextEditor(text: $model.sql)
.font(
.system(
size: 14,
design: .monospaced
)
)
.scrollContentBackground(.hidden)
.padding(12)
.background(
Color(nsColor: .textBackgroundColor)
)
}
}
// MARK: - Results
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Label(
"Result Grid",
systemImage: "tablecells"
)
.font(.headline)
Spacer()
Text(model.result.message)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(12)
Divider()
if model.result.columns.isEmpty {
ContentUnavailableView(
"No Results",
systemImage: "tablecells",
description: Text(
"Execute a SQL query to display results."
)
)
} else {
resultsGrid
}
}
}
private var resultsGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(
alignment: .leading,
horizontalSpacing: 0,
verticalSpacing: 0
) {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { index in
Text(model.result.columns[index])
.fontWeight(.semibold)
.frame(
minWidth: 150,
alignment: .leading
)
.padding(8)
.background(
Color(nsColor:
.controlBackgroundColor)
)
}
}
ForEach(
model.result.rows.indices,
id: \.self
) { rowIndex in
resultRow(
model.result.rows[rowIndex],
index: rowIndex
)
}
}
}
}
private func resultRow(
_ row: [String],
index: Int
) -> some View {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { columnIndex in
Text(
columnIndex < row.count
? row[columnIndex] : ""
)
.font(
.system(
size: 12,
design: .monospaced
)
)
.frame(
minWidth: 150,
alignment: .leading
)
.padding(8)
.background(
index.isMultiple(of: 2)
? Color.clear
: Color(nsColor:
.alternatingContentBackgroundColors[0])
)
}
}
}
// MARK: - Status
private var statusBar: some View {
HStack {
Circle()
.fill(
model.status == "Error"
? Color.red : Color.green
)
.frame(width: 8, height: 8)
Text(model.status)
.lineLimit(1)
Spacer()
Text(model.selected?.name ?? "No connection")
}
.font(.caption)
.padding(9)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (stored in macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
Text(
"Native MySQL connection. Local TCP connections and SSH tunnels are supported."
)
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
@@ -1,854 +0,0 @@
import SwiftUI
import AppKit
// MARK: - Classic Workbench Colours
private enum WB {
static let toolbar = Color(nsColor: NSColor(
calibratedWhite: 0.88, alpha: 1
))
static let sidebar = Color(nsColor: NSColor(
calibratedWhite: 0.94, alpha: 1
))
static let border = Color(nsColor: NSColor(
calibratedWhite: 0.76, alpha: 1
))
static let header = Color(nsColor: NSColor(
calibratedWhite: 0.91, alpha: 1
))
}
// MARK: - Main Window
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@State private var expandedSchemas: Set<String> = []
@State private var outputExpanded = true
var body: some View {
VStack(spacing: 0) {
mainToolbar
Rectangle()
.fill(WB.border)
.frame(height: 1)
HSplitView {
navigator
.frame(
minWidth: 210,
idealWidth: 265,
maxWidth: 420
)
workspace
.frame(minWidth: 600)
}
statusBar
}
.frame(minWidth: 1050, minHeight: 700)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Main Toolbar
private var mainToolbar: some View {
HStack(spacing: 16) {
Image(systemName: "externaldrive.connected.to.line.below")
.font(.system(size: 23))
.foregroundStyle(.blue)
Text("MySQL Workbench")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
Rectangle()
.fill(WB.border)
.frame(width: 1, height: 30)
toolbarButton(
"New Connection",
icon: "plus.rectangle.on.rectangle"
) {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
) {
model.loadDatabases()
}
toolbarButton(
"Execute",
icon: "bolt.fill"
) {
model.runSQL()
}
.keyboardShortcut(.return, modifiers: [.command])
toolbarButton(
"Format SQL",
icon: "text.alignleft"
) {
model.formatSQL()
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(model.selected?.name ?? "No Connection")
.font(.system(size: 12, weight: .semibold))
Text(model.selectedDatabase.isEmpty
? "No default schema"
: model.selectedDatabase)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 14)
.frame(height: 58)
.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(.black)
.frame(minWidth: 65)
}
.buttonStyle(.plain)
.help(title)
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
sectionHeader("NAVIGATOR")
HStack {
Text("SCHEMAS")
.font(.system(size: 11, weight: .bold))
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.frame(height: 34)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(model.databases, id: \.self) { db in
schemaNode(db)
}
}
.padding(.vertical, 8)
}
Divider()
sectionHeader("MYSQL CONNECTIONS")
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.profiles) { profile in
Button {
model.selectedID = profile.id
model.loadDatabases()
} label: {
Label(
profile.name,
systemImage: "server.rack"
)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(6)
.background(
model.selectedID == profile.id
? Color.blue.opacity(0.14)
: Color.clear
)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
.padding(8)
}
.frame(height: 125)
}
.background(WB.sidebar)
}
private func schemaNode(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Button {
toggleSchema(db)
} label: {
Image(systemName:
expandedSchemas.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.frame(width: 15)
}
.buttonStyle(.plain)
Image(systemName: "cylinder.split.1x2")
.foregroundStyle(.blue)
Text(db)
.font(.system(size: 12))
.fontWeight(
model.selectedDatabase == db
? .bold : .regular
)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
model.selectedDatabase == db
? Color.blue.opacity(0.13)
: Color.clear
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
model.selectedDatabase = db
expandedSchemas.insert(db)
model.loadTables(database: db)
}
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
if expandedSchemas.contains(db) {
schemaChildren(db)
.padding(.leading, 24)
}
}
}
private func toggleSchema(_ db: String) {
if expandedSchemas.contains(db) {
expandedSchemas.remove(db)
} else {
expandedSchemas.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
private func schemaChildren(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 5) {
DisclosureGroup {
if let tables = model.schemaTables[db] {
ForEach(tables, id: \.self) { table in
Label(table, systemImage: "tablecells")
.font(.system(size: 12))
.padding(.vertical, 3)
.onTapGesture(count: 2) {
model.selectTable(
database: db,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
model.selectTable(
database: db,
table: table
)
}
}
}
} else {
Button("Load Tables") {
model.loadTables(database: db)
}
.buttonStyle(.borderless)
}
} label: {
Label("Tables", systemImage: "folder")
}
Label("Views", systemImage: "folder")
Label("Stored Procedures", systemImage: "folder")
Label("Functions", systemImage: "folder")
}
.font(.system(size: 12))
.padding(.vertical, 3)
}
// MARK: - Workspace
private var workspace: some View {
VStack(spacing: 0) {
queryTabBar
Divider()
queryToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 180)
bottomWorkspace
.frame(minHeight: 230)
}
}
}
private var queryTabBar: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.foregroundStyle(.blue)
Text("Query 1")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "xmark")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 15)
.frame(height: 33)
.background(Color(nsColor: .textBackgroundColor))
Spacer()
}
.background(WB.header)
}
private var queryToolbar: some View {
HStack(spacing: 14) {
Button {
model.runSQL()
} label: {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
}
.help("Execute SQL")
Button {
model.formatSQL()
} label: {
Image(systemName: "text.alignleft")
}
.help("Format SQL")
Divider()
.frame(height: 17)
Text("Default Schema:")
.font(.system(size: 11))
Text(model.selectedDatabase.isEmpty
? "None"
: model.selectedDatabase)
.font(.system(size: 11, weight: .semibold))
Spacer()
Text("SQL Editor")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.padding(.horizontal, 12)
.frame(height: 36)
.background(WB.header)
}
private var sqlEditor: some View {
HStack(spacing: 0) {
lineNumbers
Rectangle()
.fill(WB.border)
.frame(width: 1)
SQLTextView(text: $model.sql)
}
.background(Color(nsColor: .textBackgroundColor))
}
private var lineNumbers: some View {
ScrollView {
VStack(alignment: .trailing, spacing: 0) {
ForEach(
1...max(1, model.sql.components(
separatedBy: "\n"
).count),
id: \.self
) { number in
Text("\(number)")
.frame(height: 19)
}
}
.padding(.horizontal, 9)
.padding(.top, 8)
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 48)
.background(WB.header)
.allowsHitTesting(false)
}
// MARK: - Results and Action Output
private var bottomWorkspace: some View {
VSplitView {
resultsPanel
.frame(minHeight: 150)
if outputExpanded {
actionOutput
.frame(minHeight: 85)
}
}
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "tablecells")
Text("Result Grid")
.fontWeight(.semibold)
Spacer()
Text(model.result.message)
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 32)
.background(WB.header)
Divider()
if model.result.columns.isEmpty {
VStack {
Spacer()
Text("No query results")
.foregroundStyle(.secondary)
Spacer()
}
} else {
resultGrid
}
}
}
private var resultGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(
alignment: .leading,
horizontalSpacing: 0,
verticalSpacing: 0
) {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(model.result.columns[column])
.fontWeight(.semibold)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(WB.header)
}
}
ForEach(
model.result.rows.indices,
id: \.self
) { row in
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(
column < model.result.rows[row].count
? model.result.rows[row][column]
: ""
)
.lineLimit(1)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(
row.isMultiple(of: 2)
? Color(nsColor: .textBackgroundColor)
: WB.sidebar
)
}
}
}
}
.font(.system(size: 12, design: .monospaced))
}
}
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: 30)
.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: 25)
.background(WB.header)
}
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Native SQL Text Editor
struct SQLTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
editor.isEditable = true
editor.isRichText = false
editor.allowsUndo = true
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.font = NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
editor.textContainerInset = NSSize(
width: 10,
height: 8
)
editor.delegate = context.coordinator
editor.string = text
context.coordinator.highlight(editor)
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let editor = scroll.documentView as? NSTextView else {
return
}
if editor.string != text {
let selection = editor.selectedRange()
editor.string = text
let safeLocation = min(
selection.location,
(text as NSString).length
)
editor.setSelectedRange(
NSRange(location: safeLocation, length: 0)
)
context.coordinator.highlight(editor)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: SQLTextView
init(_ parent: SQLTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let editor = notification.object as? NSTextView else {
return
}
parent.text = editor.string
highlight(editor)
}
func highlight(_ editor: NSTextView) {
guard let storage = editor.textStorage else {
return
}
let text = editor.string as NSString
let range = NSRange(
location: 0,
length: text.length
)
storage.beginEditing()
storage.setAttributes([
.foregroundColor: NSColor.textColor,
.font: NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
], range: range)
let pattern = """
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
"""
if let regex = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) {
for match in regex.matches(
in: editor.string,
range: range
) {
storage.addAttribute(
.foregroundColor,
value: NSColor.systemBlue,
range: match.range
)
}
}
storage.endEditing()
}
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
@@ -1,861 +0,0 @@
import SwiftUI
import AppKit
// MARK: - Classic Workbench Colours
private enum WB {
static let toolbar = Color(nsColor: NSColor(
calibratedWhite: 0.88, alpha: 1
))
static let sidebar = Color(nsColor: NSColor(
calibratedWhite: 0.94, alpha: 1
))
static let border = Color(nsColor: NSColor(
calibratedWhite: 0.76, alpha: 1
))
static let header = Color(nsColor: NSColor(
calibratedWhite: 0.91, alpha: 1
))
}
// MARK: - Main Window
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@State private var expandedSchemas: Set<String> = []
@State private var outputExpanded = true
var body: some View {
VStack(spacing: 0) {
mainToolbar
Rectangle()
.fill(WB.border)
.frame(height: 1)
HSplitView {
navigator
.frame(
minWidth: 210,
idealWidth: 265,
maxWidth: 420
)
workspace
.frame(minWidth: 600)
}
statusBar
}
.frame(minWidth: 1050, minHeight: 700)
.preferredColorScheme(.light)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Main Toolbar
private var mainToolbar: some View {
HStack(spacing: 16) {
Image(systemName: "externaldrive.connected.to.line.below")
.font(.system(size: 23))
.foregroundStyle(.blue)
Text("MySQL Workbench")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
Rectangle()
.fill(WB.border)
.frame(width: 1, height: 30)
toolbarButton(
"New Connection",
icon: "plus.rectangle.on.rectangle"
) {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
) {
model.loadDatabases()
}
toolbarButton(
"Execute",
icon: "bolt.fill"
) {
model.runSQL()
}
.keyboardShortcut(.return, modifiers: [.command])
toolbarButton(
"Format SQL",
icon: "text.alignleft"
) {
model.formatSQL()
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(model.selected?.name ?? "No Connection")
.font(.system(size: 12, weight: .semibold))
Text(model.selectedDatabase.isEmpty
? "No default schema"
: model.selectedDatabase)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 14)
.frame(height: 58)
.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(.black)
.frame(minWidth: 65)
}
.buttonStyle(.plain)
.help(title)
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
sectionHeader("NAVIGATOR")
HStack {
Text("SCHEMAS")
.font(.system(size: 11, weight: .bold))
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.frame(height: 34)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(model.databases, id: \.self) { db in
schemaNode(db)
}
}
.padding(.vertical, 8)
}
Divider()
sectionHeader("MYSQL CONNECTIONS")
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.profiles) { profile in
Button {
model.selectedID = profile.id
model.loadDatabases()
} label: {
Label(
profile.name,
systemImage: "server.rack"
)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(6)
.background(
model.selectedID == profile.id
? Color.blue.opacity(0.14)
: Color.clear
)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
.padding(8)
}
.frame(height: 125)
}
.background(WB.sidebar)
}
private func schemaNode(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Button {
toggleSchema(db)
} label: {
Image(systemName:
expandedSchemas.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.frame(width: 15)
}
.buttonStyle(.plain)
Image(systemName: "cylinder.split.1x2")
.foregroundStyle(.blue)
Text(db)
.font(.system(size: 12))
.fontWeight(
model.selectedDatabase == db
? .bold : .regular
)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
model.selectedDatabase == db
? Color.blue.opacity(0.13)
: Color.clear
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
model.selectedDatabase = db
expandedSchemas.insert(db)
model.loadTables(database: db)
}
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
if expandedSchemas.contains(db) {
schemaChildren(db)
.padding(.leading, 24)
}
}
}
private func toggleSchema(_ db: String) {
if expandedSchemas.contains(db) {
expandedSchemas.remove(db)
} else {
expandedSchemas.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
private func schemaChildren(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 5) {
DisclosureGroup {
if let tables = model.schemaTables[db] {
ForEach(tables, id: \.self) { table in
Label(table, systemImage: "tablecells")
.font(.system(size: 12))
.padding(.vertical, 3)
.onTapGesture(count: 2) {
model.selectTable(
database: db,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
model.selectTable(
database: db,
table: table
)
}
}
}
} else {
Button("Load Tables") {
model.loadTables(database: db)
}
.buttonStyle(.borderless)
}
} label: {
Label("Tables", systemImage: "folder")
}
Label("Views", systemImage: "folder")
Label("Stored Procedures", systemImage: "folder")
Label("Functions", systemImage: "folder")
}
.font(.system(size: 12))
.padding(.vertical, 3)
}
// MARK: - Workspace
private var workspace: some View {
VStack(spacing: 0) {
queryTabBar
Divider()
queryToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 180)
bottomWorkspace
.frame(minHeight: 230)
}
}
}
private var queryTabBar: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.foregroundStyle(.blue)
Text("Query 1")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "xmark")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 15)
.frame(height: 33)
.background(Color(nsColor: .textBackgroundColor))
Spacer()
}
.background(WB.header)
}
private var queryToolbar: some View {
HStack(spacing: 14) {
Button {
model.runSQL()
} label: {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
}
.help("Execute SQL")
Button {
model.formatSQL()
} label: {
Image(systemName: "text.alignleft")
}
.help("Format SQL")
Divider()
.frame(height: 17)
Text("Default Schema:")
.font(.system(size: 11))
Text(model.selectedDatabase.isEmpty
? "None"
: model.selectedDatabase)
.font(.system(size: 11, weight: .semibold))
Spacer()
Text("SQL Editor")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.padding(.horizontal, 12)
.frame(height: 36)
.background(WB.header)
}
private var sqlEditor: some View {
HStack(spacing: 0) {
lineNumbers
Rectangle()
.fill(WB.border)
.frame(width: 1)
SQLTextView(text: $model.sql)
}
.background(Color(nsColor: .textBackgroundColor))
}
private var lineNumbers: some View {
ScrollView {
VStack(alignment: .trailing, spacing: 0) {
ForEach(
1...max(1, model.sql.components(
separatedBy: "\n"
).count),
id: \.self
) { number in
Text("\(number)")
.frame(height: 19)
}
}
.padding(.horizontal, 9)
.padding(.top, 8)
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 48)
.background(WB.header)
.allowsHitTesting(false)
}
// MARK: - Results and Action Output
private var bottomWorkspace: some View {
VSplitView {
resultsPanel
.frame(minHeight: 150)
if outputExpanded {
actionOutput
.frame(minHeight: 85)
}
}
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "tablecells")
Text("Result Grid")
.fontWeight(.semibold)
Spacer()
Text(model.result.message)
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 32)
.background(WB.header)
Divider()
if model.result.columns.isEmpty {
VStack {
Spacer()
Text("No query results")
.foregroundStyle(.secondary)
Spacer()
}
} else {
resultGrid
}
}
}
private var resultGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(
alignment: .leading,
horizontalSpacing: 0,
verticalSpacing: 0
) {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(model.result.columns[column])
.fontWeight(.semibold)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(WB.header)
}
}
ForEach(
model.result.rows.indices,
id: \.self
) { row in
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(
column < model.result.rows[row].count
? model.result.rows[row][column]
: ""
)
.lineLimit(1)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(
row.isMultiple(of: 2)
? Color(nsColor: .textBackgroundColor)
: WB.sidebar
)
}
}
}
}
.font(.system(size: 12, design: .monospaced))
}
}
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: 30)
.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: 25)
.background(WB.header)
}
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Native SQL Text Editor
struct SQLTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
scroll.appearance = NSAppearance(named: .aqua)
editor.appearance = NSAppearance(named: .aqua)
editor.backgroundColor = .white
editor.textColor = .black
scroll.backgroundColor = .white
editor.isEditable = true
editor.isRichText = false
editor.allowsUndo = true
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.font = NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
editor.textContainerInset = NSSize(
width: 10,
height: 8
)
editor.delegate = context.coordinator
editor.string = text
context.coordinator.highlight(editor)
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let editor = scroll.documentView as? NSTextView else {
return
}
if editor.string != text {
let selection = editor.selectedRange()
editor.string = text
let safeLocation = min(
selection.location,
(text as NSString).length
)
editor.setSelectedRange(
NSRange(location: safeLocation, length: 0)
)
context.coordinator.highlight(editor)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: SQLTextView
init(_ parent: SQLTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let editor = notification.object as? NSTextView else {
return
}
parent.text = editor.string
highlight(editor)
}
func highlight(_ editor: NSTextView) {
guard let storage = editor.textStorage else {
return
}
let text = editor.string as NSString
let range = NSRange(
location: 0,
length: text.length
)
storage.beginEditing()
storage.setAttributes([
.foregroundColor: NSColor.textColor,
.font: NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
], range: range)
let pattern = """
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
"""
if let regex = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) {
for match in regex.matches(
in: editor.string,
range: range
) {
storage.addAttribute(
.foregroundColor,
value: NSColor.systemBlue,
range: match.range
)
}
}
storage.endEditing()
}
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
@@ -1,861 +0,0 @@
import SwiftUI
import AppKit
// MARK: - Classic Workbench Colours
private enum WB {
static let toolbar = Color(nsColor: NSColor(
calibratedWhite: 0.88, alpha: 1
))
static let sidebar = Color(nsColor: NSColor(
calibratedWhite: 0.94, alpha: 1
))
static let border = Color(nsColor: NSColor(
calibratedWhite: 0.76, alpha: 1
))
static let header = Color(nsColor: NSColor(
calibratedWhite: 0.91, alpha: 1
))
}
// MARK: - Main Window
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@State private var expandedSchemas: Set<String> = []
@State private var outputExpanded = true
var body: some View {
VStack(spacing: 0) {
mainToolbar
Rectangle()
.fill(WB.border)
.frame(height: 1)
HSplitView {
navigator
.frame(
minWidth: 210,
idealWidth: 265,
maxWidth: 420
)
workspace
.frame(minWidth: 600)
}
statusBar
}
.frame(minWidth: 1100, minHeight: 720)
.preferredColorScheme(.light)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Main Toolbar
private var mainToolbar: some View {
HStack(spacing: 16) {
Image(systemName: "externaldrive.connected.to.line.below")
.font(.system(size: 23))
.foregroundStyle(.blue)
Text("MySQL Workbench")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
Rectangle()
.fill(WB.border)
.frame(width: 1, height: 30)
toolbarButton(
"New Connection",
icon: "plus.rectangle.on.rectangle"
) {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
) {
model.loadDatabases()
}
toolbarButton(
"Execute",
icon: "bolt.fill"
) {
model.runSQL()
}
.keyboardShortcut(.return, modifiers: [.command])
toolbarButton(
"Format SQL",
icon: "text.alignleft"
) {
model.formatSQL()
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(model.selected?.name ?? "No Connection")
.font(.system(size: 12, weight: .semibold))
Text(model.selectedDatabase.isEmpty
? "No default schema"
: model.selectedDatabase)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.frame(height: 46)
.background(WB.toolbar)
}
private func toolbarButton(
_ title: String,
icon: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 19))
.frame(height: 23)
Text(title)
.font(.system(size: 10))
}
.foregroundStyle(.black)
.frame(minWidth: 65)
}
.buttonStyle(.plain)
.help(title)
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
sectionHeader("NAVIGATOR")
HStack {
Text("SCHEMAS")
.font(.system(size: 11, weight: .bold))
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.frame(height: 34)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(model.databases, id: \.self) { db in
schemaNode(db)
}
}
.padding(.vertical, 8)
}
Divider()
sectionHeader("MYSQL CONNECTIONS")
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.profiles) { profile in
Button {
model.selectedID = profile.id
model.loadDatabases()
} label: {
Label(
profile.name,
systemImage: "server.rack"
)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(6)
.background(
model.selectedID == profile.id
? Color.blue.opacity(0.14)
: Color.clear
)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
.padding(8)
}
.frame(height: 125)
}
.background(WB.sidebar)
}
private func schemaNode(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Button {
toggleSchema(db)
} label: {
Image(systemName:
expandedSchemas.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.frame(width: 15)
}
.buttonStyle(.plain)
Image(systemName: "cylinder.split.1x2")
.foregroundStyle(.blue)
Text(db)
.font(.system(size: 12))
.fontWeight(
model.selectedDatabase == db
? .bold : .regular
)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
model.selectedDatabase == db
? Color.blue.opacity(0.13)
: Color.clear
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
model.selectedDatabase = db
expandedSchemas.insert(db)
model.loadTables(database: db)
}
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
if expandedSchemas.contains(db) {
schemaChildren(db)
.padding(.leading, 24)
}
}
}
private func toggleSchema(_ db: String) {
if expandedSchemas.contains(db) {
expandedSchemas.remove(db)
} else {
expandedSchemas.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
private func schemaChildren(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 5) {
DisclosureGroup {
if let tables = model.schemaTables[db] {
ForEach(tables, id: \.self) { table in
Label(table, systemImage: "tablecells")
.font(.system(size: 12))
.padding(.vertical, 3)
.onTapGesture(count: 2) {
model.selectTable(
database: db,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
model.selectTable(
database: db,
table: table
)
}
}
}
} else {
Button("Load Tables") {
model.loadTables(database: db)
}
.buttonStyle(.borderless)
}
} label: {
Label("Tables", systemImage: "folder")
}
Label("Views", systemImage: "folder")
Label("Stored Procedures", systemImage: "folder")
Label("Functions", systemImage: "folder")
}
.font(.system(size: 12))
.padding(.vertical, 3)
}
// MARK: - Workspace
private var workspace: some View {
VStack(spacing: 0) {
queryTabBar
Divider()
queryToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 230)
bottomWorkspace
.frame(minHeight: 230)
}
}
}
private var queryTabBar: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.foregroundStyle(.blue)
Text("Query 1")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "xmark")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.frame(height: 29)
.background(Color(nsColor: .textBackgroundColor))
Spacer()
}
.background(WB.header)
}
private var queryToolbar: some View {
HStack(spacing: 14) {
Button {
model.runSQL()
} label: {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
}
.help("Execute SQL")
Button {
model.formatSQL()
} label: {
Image(systemName: "text.alignleft")
}
.help("Format SQL")
Divider()
.frame(height: 17)
Text("Default Schema:")
.font(.system(size: 11))
Text(model.selectedDatabase.isEmpty
? "None"
: model.selectedDatabase)
.font(.system(size: 11, weight: .semibold))
Spacer()
Text("SQL Editor")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private var sqlEditor: some View {
HStack(spacing: 0) {
lineNumbers
Rectangle()
.fill(WB.border)
.frame(width: 1)
SQLTextView(text: $model.sql)
}
.background(Color(nsColor: .textBackgroundColor))
}
private var lineNumbers: some View {
ScrollView {
VStack(alignment: .trailing, spacing: 0) {
ForEach(
1...max(1, model.sql.components(
separatedBy: "\n"
).count),
id: \.self
) { number in
Text("\(number)")
.frame(height: 19)
}
}
.padding(.horizontal, 9)
.padding(.top, 8)
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 48)
.background(WB.header)
.allowsHitTesting(false)
}
// MARK: - Results and Action Output
private var bottomWorkspace: some View {
VSplitView {
resultsPanel
.frame(minHeight: 180)
if outputExpanded {
actionOutput
.frame(minHeight: 90)
}
}
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "tablecells")
Text("Result Grid")
.fontWeight(.semibold)
Spacer()
Text(model.result.message)
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 28)
.background(WB.header)
Divider()
if model.result.columns.isEmpty {
VStack {
Spacer()
Text("No query results")
.foregroundStyle(.secondary)
Spacer()
}
} else {
resultGrid
}
}
}
private var resultGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(
alignment: .leading,
horizontalSpacing: 0,
verticalSpacing: 0
) {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(model.result.columns[column])
.fontWeight(.semibold)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(WB.header)
}
}
ForEach(
model.result.rows.indices,
id: \.self
) { row in
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(
column < model.result.rows[row].count
? model.result.rows[row][column]
: ""
)
.lineLimit(1)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(
row.isMultiple(of: 2)
? Color(nsColor: .textBackgroundColor)
: WB.sidebar
)
}
}
}
}
.font(.system(size: 12, design: .monospaced))
}
}
private var actionOutput: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "list.bullet.rectangle")
Text("Action Output")
.fontWeight(.semibold)
Spacer()
Button {
outputExpanded = false
} label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 26)
.background(WB.header)
Divider()
HStack(alignment: .top, spacing: 12) {
Image(systemName:
model.status == "Error"
? "xmark.circle.fill"
: "checkmark.circle.fill"
)
.foregroundStyle(
model.status == "Error"
? .red : .green
)
VStack(alignment: .leading, spacing: 5) {
Text(model.status)
.fontWeight(.semibold)
Text(model.result.message)
.foregroundStyle(.secondary)
}
Spacer()
}
.font(.system(size: 12))
.padding(12)
Spacer()
}
}
// MARK: - Status Bar
private var statusBar: some View {
HStack(spacing: 10) {
Circle()
.fill(
model.status == "Error"
? Color.red : Color.green
)
.frame(width: 8, height: 8)
Text(model.status)
.lineLimit(1)
Spacer()
Text(model.selected?.name ?? "Disconnected")
Divider()
.frame(height: 13)
Text(model.selectedDatabase.isEmpty
? "No Schema"
: model.selectedDatabase)
Divider()
.frame(height: 13)
Text("UTF-8")
}
.font(.system(size: 11))
.padding(.horizontal, 12)
.frame(height: 22)
.background(WB.header)
}
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Native SQL Text Editor
struct SQLTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
scroll.appearance = NSAppearance(named: .aqua)
editor.appearance = NSAppearance(named: .aqua)
editor.backgroundColor = .white
editor.textColor = .black
scroll.backgroundColor = .white
editor.isEditable = true
editor.isRichText = false
editor.allowsUndo = true
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.font = NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
editor.textContainerInset = NSSize(
width: 10,
height: 8
)
editor.delegate = context.coordinator
editor.string = text
context.coordinator.highlight(editor)
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let editor = scroll.documentView as? NSTextView else {
return
}
if editor.string != text {
let selection = editor.selectedRange()
editor.string = text
let safeLocation = min(
selection.location,
(text as NSString).length
)
editor.setSelectedRange(
NSRange(location: safeLocation, length: 0)
)
context.coordinator.highlight(editor)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: SQLTextView
init(_ parent: SQLTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let editor = notification.object as? NSTextView else {
return
}
parent.text = editor.string
highlight(editor)
}
func highlight(_ editor: NSTextView) {
guard let storage = editor.textStorage else {
return
}
let text = editor.string as NSString
let range = NSRange(
location: 0,
length: text.length
)
storage.beginEditing()
storage.setAttributes([
.foregroundColor: NSColor.textColor,
.font: NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
], range: range)
let pattern = """
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
"""
if let regex = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) {
for match in regex.matches(
in: editor.string,
range: range
) {
storage.addAttribute(
.foregroundColor,
value: NSColor.systemBlue,
range: match.range
)
}
}
storage.endEditing()
}
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
@@ -1,861 +0,0 @@
import SwiftUI
import AppKit
// MARK: - Classic Workbench Colours
private enum WB {
static let toolbar = Color(nsColor: NSColor(
calibratedWhite: 0.88, alpha: 1
))
static let sidebar = Color(nsColor: NSColor(
calibratedWhite: 0.94, alpha: 1
))
static let border = Color(nsColor: NSColor(
calibratedWhite: 0.76, alpha: 1
))
static let header = Color(nsColor: NSColor(
calibratedWhite: 0.91, alpha: 1
))
}
// MARK: - Main Window
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@State private var expandedSchemas: Set<String> = []
@State private var outputExpanded = true
var body: some View {
VStack(spacing: 0) {
mainToolbar
Rectangle()
.fill(WB.border)
.frame(height: 1)
HSplitView {
navigator
.frame(
minWidth: 210,
idealWidth: 265,
maxWidth: 420
)
workspace
.frame(minWidth: 600)
}
statusBar
}
.frame(minWidth: 1100, minHeight: 720)
.preferredColorScheme(.light)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Main Toolbar
private var mainToolbar: some View {
HStack(spacing: 16) {
Image(systemName: "externaldrive.connected.to.line.below")
.font(.system(size: 23))
.foregroundStyle(.blue)
Text("MySQL Workbench")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
Rectangle()
.fill(WB.border)
.frame(width: 1, height: 30)
toolbarButton(
"New Connection",
icon: "plus.rectangle.on.rectangle"
) {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
) {
model.loadDatabases()
}
toolbarButton(
"Execute",
icon: "bolt.fill"
) {
model.runSQL()
}
.keyboardShortcut(.return, modifiers: [.command])
toolbarButton(
"Format SQL",
icon: "text.alignleft"
) {
model.formatSQL()
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(model.selected?.name ?? "No Connection")
.font(.system(size: 12, weight: .semibold))
Text(model.selectedDatabase.isEmpty
? "No default schema"
: model.selectedDatabase)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.frame(height: 46)
.background(WB.toolbar)
}
private func toolbarButton(
_ title: String,
icon: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 19))
.frame(height: 23)
Text(title)
.font(.system(size: 10))
}
.foregroundStyle(.black)
.frame(minWidth: 65)
}
.buttonStyle(.plain)
.help(title)
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
sectionHeader("NAVIGATOR")
HStack {
Text("SCHEMAS")
.font(.system(size: 11, weight: .bold))
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.frame(height: 34)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(model.databases, id: \.self) { db in
schemaNode(db)
}
}
.padding(.vertical, 8)
}
Divider()
sectionHeader("MYSQL CONNECTIONS")
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.profiles) { profile in
Button {
model.selectedID = profile.id
model.loadDatabases()
} label: {
Label(
profile.name,
systemImage: "server.rack"
)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(6)
.background(
model.selectedID == profile.id
? Color.blue.opacity(0.14)
: Color.clear
)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
.padding(8)
}
.frame(height: 125)
}
.background(WB.sidebar)
}
private func schemaNode(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Button {
toggleSchema(db)
} label: {
Image(systemName:
expandedSchemas.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.frame(width: 15)
}
.buttonStyle(.plain)
Image(systemName: "cylinder.split.1x2")
.foregroundStyle(.blue)
Text(db)
.font(.system(size: 12))
.fontWeight(
model.selectedDatabase == db
? .bold : .regular
)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
model.selectedDatabase == db
? Color.blue.opacity(0.13)
: Color.clear
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
model.selectedDatabase = db
expandedSchemas.insert(db)
model.loadTables(database: db)
}
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
if expandedSchemas.contains(db) {
schemaChildren(db)
.padding(.leading, 24)
}
}
}
private func toggleSchema(_ db: String) {
if expandedSchemas.contains(db) {
expandedSchemas.remove(db)
} else {
expandedSchemas.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
private func schemaChildren(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 5) {
DisclosureGroup {
if let tables = model.schemaTables[db] {
ForEach(tables, id: \.self) { table in
Label(table, systemImage: "tablecells")
.font(.system(size: 12))
.padding(.vertical, 3)
.onTapGesture(count: 2) {
model.selectTable(
database: db,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
model.selectTable(
database: db,
table: table
)
}
}
}
} else {
Button("Load Tables") {
model.loadTables(database: db)
}
.buttonStyle(.borderless)
}
} label: {
Label("Tables", systemImage: "folder")
}
Label("Views", systemImage: "folder")
Label("Stored Procedures", systemImage: "folder")
Label("Functions", systemImage: "folder")
}
.font(.system(size: 12))
.padding(.vertical, 3)
}
// MARK: - Workspace
private var workspace: some View {
VStack(spacing: 0) {
queryTabBar
Divider()
queryToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 230)
bottomWorkspace
.frame(minHeight: 230)
}
}
}
private var queryTabBar: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.foregroundStyle(.blue)
Text("Query 1")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "xmark")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.frame(height: 29)
.background(Color(nsColor: .textBackgroundColor))
Spacer()
}
.background(WB.header)
}
private var queryToolbar: some View {
HStack(spacing: 14) {
Button {
model.runSQL()
} label: {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
}
.help("Execute SQL")
Button {
model.formatSQL()
} label: {
Image(systemName: "text.alignleft")
}
.help("Format SQL")
Divider()
.frame(height: 17)
Text("Default Schema:")
.font(.system(size: 11))
Text(model.selectedDatabase.isEmpty
? "None"
: model.selectedDatabase)
.font(.system(size: 11, weight: .semibold))
Spacer()
Text("SQL Editor")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private var sqlEditor: some View {
HStack(spacing: 0) {
lineNumbers
Rectangle()
.fill(WB.border)
.frame(width: 1)
SQLTextView(text: $model.sql)
}
.background(Color(nsColor: .textBackgroundColor))
}
private var lineNumbers: some View {
ScrollView {
VStack(alignment: .trailing, spacing: 0) {
ForEach(
1...max(1, model.sql.components(
separatedBy: "\n"
).count),
id: \.self
) { number in
Text("\(number)")
.frame(height: 19)
}
}
.padding(.horizontal, 9)
.padding(.top, 8)
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 48)
.background(WB.header)
.allowsHitTesting(false)
}
// MARK: - Results and Action Output
private var bottomWorkspace: some View {
VSplitView {
resultsPanel
.frame(minHeight: 230)
if outputExpanded {
actionOutput
.frame(minHeight: 90)
}
}
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "tablecells")
Text("Result Grid")
.fontWeight(.semibold)
Spacer()
Text(model.result.message)
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 28)
.background(WB.header)
Divider()
if model.result.columns.isEmpty {
VStack {
Spacer()
Text("No query results")
.foregroundStyle(.secondary)
Spacer()
}
} else {
resultGrid
}
}
}
private var resultGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(
alignment: .leading,
horizontalSpacing: 0,
verticalSpacing: 0
) {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(model.result.columns[column])
.fontWeight(.semibold)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(WB.header)
}
}
ForEach(
model.result.rows.indices,
id: \.self
) { row in
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(
column < model.result.rows[row].count
? model.result.rows[row][column]
: ""
)
.lineLimit(1)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(
row.isMultiple(of: 2)
? Color(nsColor: .textBackgroundColor)
: WB.sidebar
)
}
}
}
}
.font(.system(size: 12, design: .monospaced))
}
}
private var actionOutput: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "list.bullet.rectangle")
Text("Action Output")
.fontWeight(.semibold)
Spacer()
Button {
outputExpanded = false
} label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 26)
.background(WB.header)
Divider()
HStack(alignment: .top, spacing: 12) {
Image(systemName:
model.status == "Error"
? "xmark.circle.fill"
: "checkmark.circle.fill"
)
.foregroundStyle(
model.status == "Error"
? .red : .green
)
VStack(alignment: .leading, spacing: 5) {
Text(model.status)
.fontWeight(.semibold)
Text(model.result.message)
.foregroundStyle(.secondary)
}
Spacer()
}
.font(.system(size: 12))
.padding(12)
Spacer()
}
}
// MARK: - Status Bar
private var statusBar: some View {
HStack(spacing: 10) {
Circle()
.fill(
model.status == "Error"
? Color.red : Color.green
)
.frame(width: 8, height: 8)
Text(model.status)
.lineLimit(1)
Spacer()
Text(model.selected?.name ?? "Disconnected")
Divider()
.frame(height: 13)
Text(model.selectedDatabase.isEmpty
? "No Schema"
: model.selectedDatabase)
Divider()
.frame(height: 13)
Text("UTF-8")
}
.font(.system(size: 11))
.padding(.horizontal, 12)
.frame(height: 22)
.background(WB.header)
}
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Native SQL Text Editor
struct SQLTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
scroll.appearance = NSAppearance(named: .aqua)
editor.appearance = NSAppearance(named: .aqua)
editor.backgroundColor = .white
editor.textColor = .black
scroll.backgroundColor = .white
editor.isEditable = true
editor.isRichText = false
editor.allowsUndo = true
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.font = NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
editor.textContainerInset = NSSize(
width: 10,
height: 8
)
editor.delegate = context.coordinator
editor.string = text
context.coordinator.highlight(editor)
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let editor = scroll.documentView as? NSTextView else {
return
}
if editor.string != text {
let selection = editor.selectedRange()
editor.string = text
let safeLocation = min(
selection.location,
(text as NSString).length
)
editor.setSelectedRange(
NSRange(location: safeLocation, length: 0)
)
context.coordinator.highlight(editor)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: SQLTextView
init(_ parent: SQLTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let editor = notification.object as? NSTextView else {
return
}
parent.text = editor.string
highlight(editor)
}
func highlight(_ editor: NSTextView) {
guard let storage = editor.textStorage else {
return
}
let text = editor.string as NSString
let range = NSRange(
location: 0,
length: text.length
)
storage.beginEditing()
storage.setAttributes([
.foregroundColor: NSColor.textColor,
.font: NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
], range: range)
let pattern = """
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
"""
if let regex = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) {
for match in regex.matches(
in: editor.string,
range: range
) {
storage.addAttribute(
.foregroundColor,
value: NSColor.systemBlue,
range: match.range
)
}
}
storage.endEditing()
}
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
@@ -1,883 +0,0 @@
import SwiftUI
import AppKit
// MARK: - Classic Workbench Colours
private enum WB {
static let toolbar = Color(nsColor: NSColor(
calibratedWhite: 0.88, alpha: 1
))
static let sidebar = Color(nsColor: NSColor(
calibratedWhite: 0.94, alpha: 1
))
static let border = Color(nsColor: NSColor(
calibratedWhite: 0.76, alpha: 1
))
static let header = Color(nsColor: NSColor(
calibratedWhite: 0.91, alpha: 1
))
}
// MARK: - Main Window
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@State private var expandedSchemas: Set<String> = []
@State private var outputExpanded = true
var body: some View {
VStack(spacing: 0) {
mainToolbar
Rectangle()
.fill(WB.border)
.frame(height: 1)
HSplitView {
navigator
.frame(
minWidth: 210,
idealWidth: 265,
maxWidth: 420
)
workspace
.frame(minWidth: 600)
}
statusBar
}
.frame(minWidth: 1100, minHeight: 720)
.preferredColorScheme(.light)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Main Toolbar
private var mainToolbar: some View {
HStack(spacing: 16) {
Image(systemName: "externaldrive.connected.to.line.below")
.font(.system(size: 23))
.foregroundStyle(.blue)
Text("MySQL Workbench")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
Rectangle()
.fill(WB.border)
.frame(width: 1, height: 30)
toolbarButton(
"New Connection",
icon: "plus.rectangle.on.rectangle"
) {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
) {
model.loadDatabases()
}
toolbarButton(
"Execute",
icon: "bolt.fill"
) {
model.runSQL()
}
.keyboardShortcut(.return, modifiers: [.command])
toolbarButton(
"Format SQL",
icon: "text.alignleft"
) {
model.formatSQL()
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(model.selected?.name ?? "No Connection")
.font(.system(size: 12, weight: .semibold))
Text(model.selectedDatabase.isEmpty
? "No default schema"
: model.selectedDatabase)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.frame(height: 46)
.background(WB.toolbar)
}
private func toolbarButton(
_ title: String,
icon: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 19))
.frame(height: 23)
Text(title)
.font(.system(size: 10))
}
.foregroundStyle(.black)
.frame(minWidth: 65)
}
.buttonStyle(.plain)
.help(title)
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
sectionHeader("NAVIGATOR")
HStack {
Text("SCHEMAS")
.font(.system(size: 11, weight: .bold))
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.frame(height: 34)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(model.databases, id: \.self) { db in
schemaNode(db)
}
}
.padding(.vertical, 8)
}
Divider()
sectionHeader("MYSQL CONNECTIONS")
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.profiles) { profile in
Button {
model.selectedID = profile.id
model.loadDatabases()
} label: {
Label(
profile.name,
systemImage: "server.rack"
)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(6)
.background(
model.selectedID == profile.id
? Color.blue.opacity(0.14)
: Color.clear
)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
.padding(8)
}
.frame(height: 125)
}
.background(WB.sidebar)
}
private func schemaNode(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Button {
toggleSchema(db)
} label: {
Image(systemName:
expandedSchemas.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.frame(width: 15)
}
.buttonStyle(.plain)
Image(systemName: "cylinder.split.1x2")
.foregroundStyle(.blue)
Text(db)
.font(.system(size: 12))
.fontWeight(
model.selectedDatabase == db
? .bold : .regular
)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
model.selectedDatabase == db
? Color.blue.opacity(0.13)
: Color.clear
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
model.selectedDatabase = db
expandedSchemas.insert(db)
model.loadTables(database: db)
}
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
if expandedSchemas.contains(db) {
schemaChildren(db)
.padding(.leading, 24)
}
}
}
private func toggleSchema(_ db: String) {
if expandedSchemas.contains(db) {
expandedSchemas.remove(db)
} else {
expandedSchemas.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
private func schemaChildren(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
DisclosureGroup {
if let tables = model.schemaTables[db] {
ForEach(tables, id: \\.self) { table in
HStack(spacing: 6) {
Image(systemName: "tablecells")
.foregroundStyle(.secondary)
.frame(width: 16)
Text(table)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 4)
.padding(.vertical, 3)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
model.selectTable(
database: db,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
model.selectTable(
database: db,
table: table
)
}
}
}
} else {
Button("Load Tables") {
model.loadTables(database: db)
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.borderless)
}
} label: {
Label("Tables", systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
}
Label("Views", systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
Label("Stored Procedures", systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
Label("Functions", systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.system(size: 12))
.padding(.vertical, 3)
}
// MARK: - Workspace
private var workspace: some View {
VStack(spacing: 0) {
queryTabBar
Divider()
queryToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 230)
bottomWorkspace
.frame(minHeight: 230)
}
}
}
private var queryTabBar: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.foregroundStyle(.blue)
Text("Query 1")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "xmark")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.frame(height: 29)
.background(Color(nsColor: .textBackgroundColor))
Spacer()
}
.background(WB.header)
}
private var queryToolbar: some View {
HStack(spacing: 14) {
Button {
model.runSQL()
} label: {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
}
.help("Execute SQL")
Button {
model.formatSQL()
} label: {
Image(systemName: "text.alignleft")
}
.help("Format SQL")
Divider()
.frame(height: 17)
Text("Default Schema:")
.font(.system(size: 11))
Text(model.selectedDatabase.isEmpty
? "None"
: model.selectedDatabase)
.font(.system(size: 11, weight: .semibold))
Spacer()
Text("SQL Editor")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private var sqlEditor: some View {
HStack(spacing: 0) {
lineNumbers
Rectangle()
.fill(WB.border)
.frame(width: 1)
SQLTextView(text: $model.sql)
}
.background(Color(nsColor: .textBackgroundColor))
}
private var lineNumbers: some View {
ScrollView {
VStack(alignment: .trailing, spacing: 0) {
ForEach(
1...max(1, model.sql.components(
separatedBy: "\n"
).count),
id: \.self
) { number in
Text("\(number)")
.frame(height: 19)
}
}
.padding(.horizontal, 9)
.padding(.top, 8)
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 48)
.background(WB.header)
.allowsHitTesting(false)
}
// MARK: - Results and Action Output
private var bottomWorkspace: some View {
VSplitView {
resultsPanel
.frame(minHeight: 230)
if outputExpanded {
actionOutput
.frame(minHeight: 90)
}
}
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "tablecells")
Text("Result Grid")
.fontWeight(.semibold)
Spacer()
Text(model.result.message)
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 28)
.background(WB.header)
Divider()
if model.result.columns.isEmpty {
VStack {
Spacer()
Text("No query results")
.foregroundStyle(.secondary)
Spacer()
}
} else {
resultGrid
}
}
}
private var resultGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(
alignment: .leading,
horizontalSpacing: 0,
verticalSpacing: 0
) {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(model.result.columns[column])
.fontWeight(.semibold)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(WB.header)
}
}
ForEach(
model.result.rows.indices,
id: \.self
) { row in
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(
column < model.result.rows[row].count
? model.result.rows[row][column]
: ""
)
.lineLimit(1)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(
row.isMultiple(of: 2)
? Color(nsColor: .textBackgroundColor)
: WB.sidebar
)
}
}
}
}
.font(.system(size: 12, design: .monospaced))
}
}
private var actionOutput: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "list.bullet.rectangle")
Text("Action Output")
.fontWeight(.semibold)
Spacer()
Button {
outputExpanded = false
} label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 26)
.background(WB.header)
Divider()
HStack(alignment: .top, spacing: 12) {
Image(systemName:
model.status == "Error"
? "xmark.circle.fill"
: "checkmark.circle.fill"
)
.foregroundStyle(
model.status == "Error"
? .red : .green
)
VStack(alignment: .leading, spacing: 5) {
Text(model.status)
.fontWeight(.semibold)
Text(model.result.message)
.foregroundStyle(.secondary)
}
Spacer()
}
.font(.system(size: 12))
.padding(12)
Spacer()
}
}
// MARK: - Status Bar
private var statusBar: some View {
HStack(spacing: 10) {
Circle()
.fill(
model.status == "Error"
? Color.red : Color.green
)
.frame(width: 8, height: 8)
Text(model.status)
.lineLimit(1)
Spacer()
Text(model.selected?.name ?? "Disconnected")
Divider()
.frame(height: 13)
Text(model.selectedDatabase.isEmpty
? "No Schema"
: model.selectedDatabase)
Divider()
.frame(height: 13)
Text("UTF-8")
}
.font(.system(size: 11))
.padding(.horizontal, 12)
.frame(height: 22)
.background(WB.header)
}
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Native SQL Text Editor
struct SQLTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
scroll.appearance = NSAppearance(named: .aqua)
editor.appearance = NSAppearance(named: .aqua)
editor.backgroundColor = .white
editor.textColor = .black
scroll.backgroundColor = .white
editor.isEditable = true
editor.isRichText = false
editor.allowsUndo = true
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.font = NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
editor.textContainerInset = NSSize(
width: 10,
height: 8
)
editor.delegate = context.coordinator
editor.string = text
context.coordinator.highlight(editor)
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let editor = scroll.documentView as? NSTextView else {
return
}
if editor.string != text {
let selection = editor.selectedRange()
editor.string = text
let safeLocation = min(
selection.location,
(text as NSString).length
)
editor.setSelectedRange(
NSRange(location: safeLocation, length: 0)
)
context.coordinator.highlight(editor)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: SQLTextView
init(_ parent: SQLTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let editor = notification.object as? NSTextView else {
return
}
parent.text = editor.string
highlight(editor)
}
func highlight(_ editor: NSTextView) {
guard let storage = editor.textStorage else {
return
}
let text = editor.string as NSString
let range = NSRange(
location: 0,
length: text.length
)
storage.beginEditing()
storage.setAttributes([
.foregroundColor: NSColor.textColor,
.font: NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
], range: range)
let pattern = """
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
"""
if let regex = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) {
for match in regex.matches(
in: editor.string,
range: range
) {
storage.addAttribute(
.foregroundColor,
value: NSColor.systemBlue,
range: match.range
)
}
}
storage.endEditing()
}
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
@@ -1,883 +0,0 @@
import SwiftUI
import AppKit
// MARK: - Classic Workbench Colours
private enum WB {
static let toolbar = Color(nsColor: NSColor(
calibratedWhite: 0.88, alpha: 1
))
static let sidebar = Color(nsColor: NSColor(
calibratedWhite: 0.94, alpha: 1
))
static let border = Color(nsColor: NSColor(
calibratedWhite: 0.76, alpha: 1
))
static let header = Color(nsColor: NSColor(
calibratedWhite: 0.91, alpha: 1
))
}
// MARK: - Main Window
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@State private var expandedSchemas: Set<String> = []
@State private var outputExpanded = true
var body: some View {
VStack(spacing: 0) {
mainToolbar
Rectangle()
.fill(WB.border)
.frame(height: 1)
HSplitView {
navigator
.frame(
minWidth: 210,
idealWidth: 265,
maxWidth: 420
)
workspace
.frame(minWidth: 600)
}
statusBar
}
.frame(minWidth: 1100, minHeight: 720)
.preferredColorScheme(.light)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Main Toolbar
private var mainToolbar: some View {
HStack(spacing: 16) {
Image(systemName: "externaldrive.connected.to.line.below")
.font(.system(size: 23))
.foregroundStyle(.blue)
Text("MySQL Workbench")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
Rectangle()
.fill(WB.border)
.frame(width: 1, height: 30)
toolbarButton(
"New Connection",
icon: "plus.rectangle.on.rectangle"
) {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
) {
model.loadDatabases()
}
toolbarButton(
"Execute",
icon: "bolt.fill"
) {
model.runSQL()
}
.keyboardShortcut(.return, modifiers: [.command])
toolbarButton(
"Format SQL",
icon: "text.alignleft"
) {
model.formatSQL()
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(model.selected?.name ?? "No Connection")
.font(.system(size: 12, weight: .semibold))
Text(model.selectedDatabase.isEmpty
? "No default schema"
: model.selectedDatabase)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.frame(height: 46)
.background(WB.toolbar)
}
private func toolbarButton(
_ title: String,
icon: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 19))
.frame(height: 23)
Text(title)
.font(.system(size: 10))
}
.foregroundStyle(.black)
.frame(minWidth: 65)
}
.buttonStyle(.plain)
.help(title)
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
sectionHeader("NAVIGATOR")
HStack {
Text("SCHEMAS")
.font(.system(size: 11, weight: .bold))
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.frame(height: 34)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(model.databases, id: \.self) { db in
schemaNode(db)
}
}
.padding(.vertical, 8)
}
Divider()
sectionHeader("MYSQL CONNECTIONS")
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.profiles) { profile in
Button {
model.selectedID = profile.id
model.loadDatabases()
} label: {
Label(
profile.name,
systemImage: "server.rack"
)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(6)
.background(
model.selectedID == profile.id
? Color.blue.opacity(0.14)
: Color.clear
)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
.padding(8)
}
.frame(height: 125)
}
.background(WB.sidebar)
}
private func schemaNode(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Button {
toggleSchema(db)
} label: {
Image(systemName:
expandedSchemas.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.frame(width: 15)
}
.buttonStyle(.plain)
Image(systemName: "cylinder.split.1x2")
.foregroundStyle(.blue)
Text(db)
.font(.system(size: 12))
.fontWeight(
model.selectedDatabase == db
? .bold : .regular
)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
model.selectedDatabase == db
? Color.blue.opacity(0.13)
: Color.clear
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
model.selectedDatabase = db
expandedSchemas.insert(db)
model.loadTables(database: db)
}
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
if expandedSchemas.contains(db) {
schemaChildren(db)
.padding(.leading, 24)
}
}
}
private func toggleSchema(_ db: String) {
if expandedSchemas.contains(db) {
expandedSchemas.remove(db)
} else {
expandedSchemas.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
private func schemaChildren(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
DisclosureGroup {
if let tables = model.schemaTables[db] {
ForEach(tables, id: \.self) { table in
HStack(spacing: 6) {
Image(systemName: "tablecells")
.foregroundStyle(.secondary)
.frame(width: 16)
Text(table)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 4)
.padding(.vertical, 3)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
model.selectTable(
database: db,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
model.selectTable(
database: db,
table: table
)
}
}
}
} else {
Button("Load Tables") {
model.loadTables(database: db)
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.borderless)
}
} label: {
Label("Tables", systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
}
Label("Views", systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
Label("Stored Procedures", systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
Label("Functions", systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.system(size: 12))
.padding(.vertical, 3)
}
// MARK: - Workspace
private var workspace: some View {
VStack(spacing: 0) {
queryTabBar
Divider()
queryToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 230)
bottomWorkspace
.frame(minHeight: 230)
}
}
}
private var queryTabBar: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.foregroundStyle(.blue)
Text("Query 1")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "xmark")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.frame(height: 29)
.background(Color(nsColor: .textBackgroundColor))
Spacer()
}
.background(WB.header)
}
private var queryToolbar: some View {
HStack(spacing: 14) {
Button {
model.runSQL()
} label: {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
}
.help("Execute SQL")
Button {
model.formatSQL()
} label: {
Image(systemName: "text.alignleft")
}
.help("Format SQL")
Divider()
.frame(height: 17)
Text("Default Schema:")
.font(.system(size: 11))
Text(model.selectedDatabase.isEmpty
? "None"
: model.selectedDatabase)
.font(.system(size: 11, weight: .semibold))
Spacer()
Text("SQL Editor")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private var sqlEditor: some View {
HStack(spacing: 0) {
lineNumbers
Rectangle()
.fill(WB.border)
.frame(width: 1)
SQLTextView(text: $model.sql)
}
.background(Color(nsColor: .textBackgroundColor))
}
private var lineNumbers: some View {
ScrollView {
VStack(alignment: .trailing, spacing: 0) {
ForEach(
1...max(1, model.sql.components(
separatedBy: "\n"
).count),
id: \.self
) { number in
Text("\(number)")
.frame(height: 19)
}
}
.padding(.horizontal, 9)
.padding(.top, 8)
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 48)
.background(WB.header)
.allowsHitTesting(false)
}
// MARK: - Results and Action Output
private var bottomWorkspace: some View {
VSplitView {
resultsPanel
.frame(minHeight: 230)
if outputExpanded {
actionOutput
.frame(minHeight: 90)
}
}
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "tablecells")
Text("Result Grid")
.fontWeight(.semibold)
Spacer()
Text(model.result.message)
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 28)
.background(WB.header)
Divider()
if model.result.columns.isEmpty {
VStack {
Spacer()
Text("No query results")
.foregroundStyle(.secondary)
Spacer()
}
} else {
resultGrid
}
}
}
private var resultGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(
alignment: .leading,
horizontalSpacing: 0,
verticalSpacing: 0
) {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(model.result.columns[column])
.fontWeight(.semibold)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(WB.header)
}
}
ForEach(
model.result.rows.indices,
id: \.self
) { row in
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(
column < model.result.rows[row].count
? model.result.rows[row][column]
: ""
)
.lineLimit(1)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(
row.isMultiple(of: 2)
? Color(nsColor: .textBackgroundColor)
: WB.sidebar
)
}
}
}
}
.font(.system(size: 12, design: .monospaced))
}
}
private var actionOutput: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "list.bullet.rectangle")
Text("Action Output")
.fontWeight(.semibold)
Spacer()
Button {
outputExpanded = false
} label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 26)
.background(WB.header)
Divider()
HStack(alignment: .top, spacing: 12) {
Image(systemName:
model.status == "Error"
? "xmark.circle.fill"
: "checkmark.circle.fill"
)
.foregroundStyle(
model.status == "Error"
? .red : .green
)
VStack(alignment: .leading, spacing: 5) {
Text(model.status)
.fontWeight(.semibold)
Text(model.result.message)
.foregroundStyle(.secondary)
}
Spacer()
}
.font(.system(size: 12))
.padding(12)
Spacer()
}
}
// MARK: - Status Bar
private var statusBar: some View {
HStack(spacing: 10) {
Circle()
.fill(
model.status == "Error"
? Color.red : Color.green
)
.frame(width: 8, height: 8)
Text(model.status)
.lineLimit(1)
Spacer()
Text(model.selected?.name ?? "Disconnected")
Divider()
.frame(height: 13)
Text(model.selectedDatabase.isEmpty
? "No Schema"
: model.selectedDatabase)
Divider()
.frame(height: 13)
Text("UTF-8")
}
.font(.system(size: 11))
.padding(.horizontal, 12)
.frame(height: 22)
.background(WB.header)
}
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Native SQL Text Editor
struct SQLTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
scroll.appearance = NSAppearance(named: .aqua)
editor.appearance = NSAppearance(named: .aqua)
editor.backgroundColor = .white
editor.textColor = .black
scroll.backgroundColor = .white
editor.isEditable = true
editor.isRichText = false
editor.allowsUndo = true
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.font = NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
editor.textContainerInset = NSSize(
width: 10,
height: 8
)
editor.delegate = context.coordinator
editor.string = text
context.coordinator.highlight(editor)
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let editor = scroll.documentView as? NSTextView else {
return
}
if editor.string != text {
let selection = editor.selectedRange()
editor.string = text
let safeLocation = min(
selection.location,
(text as NSString).length
)
editor.setSelectedRange(
NSRange(location: safeLocation, length: 0)
)
context.coordinator.highlight(editor)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: SQLTextView
init(_ parent: SQLTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let editor = notification.object as? NSTextView else {
return
}
parent.text = editor.string
highlight(editor)
}
func highlight(_ editor: NSTextView) {
guard let storage = editor.textStorage else {
return
}
let text = editor.string as NSString
let range = NSRange(
location: 0,
length: text.length
)
storage.beginEditing()
storage.setAttributes([
.foregroundColor: NSColor.textColor,
.font: NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
], range: range)
let pattern = """
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
"""
if let regex = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) {
for match in regex.matches(
in: editor.string,
range: range
) {
storage.addAttribute(
.foregroundColor,
value: NSColor.systemBlue,
range: match.range
)
}
}
storage.endEditing()
}
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
@@ -1,960 +0,0 @@
import SwiftUI
import AppKit
// MARK: - Classic Workbench Colours
private enum WB {
static let toolbar = Color(nsColor: NSColor(
calibratedWhite: 0.88, alpha: 1
))
static let sidebar = Color(nsColor: NSColor(
calibratedWhite: 0.94, alpha: 1
))
static let border = Color(nsColor: NSColor(
calibratedWhite: 0.76, alpha: 1
))
static let header = Color(nsColor: NSColor(
calibratedWhite: 0.91, alpha: 1
))
}
// MARK: - Main Window
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@State private var expandedSchemas: Set<String> = []
@State private var expandedTableFolders: Set<String> = []
@State private var selectedTableName: String? = nil
@State private var outputExpanded = true
var body: some View {
VStack(spacing: 0) {
mainToolbar
Rectangle()
.fill(WB.border)
.frame(height: 1)
HSplitView {
navigator
.frame(
minWidth: 210,
idealWidth: 265,
maxWidth: 420
)
workspace
.frame(minWidth: 600)
}
statusBar
}
.frame(minWidth: 1100, minHeight: 720)
.preferredColorScheme(.light)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Main Toolbar
private var mainToolbar: some View {
HStack(spacing: 16) {
Image(systemName: "externaldrive.connected.to.line.below")
.font(.system(size: 23))
.foregroundStyle(.blue)
Text("MySQL Workbench")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
Rectangle()
.fill(WB.border)
.frame(width: 1, height: 30)
toolbarButton(
"New Connection",
icon: "plus.rectangle.on.rectangle"
) {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
) {
model.loadDatabases()
}
toolbarButton(
"Execute",
icon: "bolt.fill"
) {
model.runSQL()
}
.keyboardShortcut(.return, modifiers: [.command])
toolbarButton(
"Format SQL",
icon: "text.alignleft"
) {
model.formatSQL()
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(model.selected?.name ?? "No Connection")
.font(.system(size: 12, weight: .semibold))
Text(model.selectedDatabase.isEmpty
? "No default schema"
: model.selectedDatabase)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.frame(height: 46)
.background(WB.toolbar)
}
private func toolbarButton(
_ title: String,
icon: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 19))
.frame(height: 23)
Text(title)
.font(.system(size: 10))
}
.foregroundStyle(.black)
.frame(minWidth: 65)
}
.buttonStyle(.plain)
.help(title)
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
sectionHeader("NAVIGATOR")
HStack {
Text("SCHEMAS")
.font(.system(size: 11, weight: .bold))
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.frame(height: 34)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(model.databases, id: \.self) { db in
schemaNode(db)
}
}
.padding(.vertical, 8)
}
Divider()
sectionHeader("MYSQL CONNECTIONS")
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.profiles) { profile in
Button {
model.selectedID = profile.id
model.loadDatabases()
} label: {
Label(
profile.name,
systemImage: "server.rack"
)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(6)
.background(
model.selectedID == profile.id
? Color.blue.opacity(0.14)
: Color.clear
)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
.padding(8)
}
.frame(height: 125)
}
.background(WB.sidebar)
}
// MARK: - Compact Schema Tree
private func schemaNode(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 5) {
Image(systemName:
expandedSchemas.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.frame(width: 13)
Image(systemName: "cylinder.split.1x2")
.foregroundStyle(.blue)
.frame(width: 17)
Text(db)
.font(.system(size: 12, weight:
model.selectedDatabase == db
? .semibold : .regular))
.lineLimit(1)
Spacer(minLength: 0)
}
.frame(height: 25)
.padding(.leading, 10)
.contentShape(Rectangle())
.onTapGesture {
toggleSchema(db)
}
.onTapGesture(count: 2) {
model.selectedDatabase = db
expandedSchemas.insert(db)
expandedTableFolders.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
if expandedSchemas.contains(db) {
schemaChildren(db)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func toggleSchema(_ db: String) {
if expandedSchemas.contains(db) {
expandedSchemas.remove(db)
} else {
expandedSchemas.insert(db)
expandedTableFolders.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
private func schemaChildren(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Tables folder
HStack(spacing: 5) {
Image(systemName:
expandedTableFolders.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.frame(width: 13)
Image(systemName: "folder")
.foregroundStyle(.secondary)
.frame(width: 17)
Text("Tables")
.font(.system(size: 12))
Spacer(minLength: 0)
}
.frame(height: 24)
.padding(.leading, 28)
.contentShape(Rectangle())
.onTapGesture {
if expandedTableFolders.contains(db) {
expandedTableFolders.remove(db)
} else {
expandedTableFolders.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
// Tables
if expandedTableFolders.contains(db) {
if let tables = model.schemaTables[db] {
ForEach(tables, id: \.self) { table in
tableNode(database: db, table: table)
}
} else {
Button("Load Tables") {
model.loadTables(database: db)
}
.font(.system(size: 11))
.buttonStyle(.borderless)
.padding(.leading, 66)
.frame(height: 24)
}
}
folderNode("Views", indent: 28)
folderNode("Stored Procedures", indent: 28)
folderNode("Functions", indent: 28)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func tableNode(database: String, table: String) -> some View {
let identifier = database + "." + table
let isSelected = selectedTableName == identifier
return HStack(spacing: 5) {
Image(systemName: "tablecells")
.font(.system(size: 11))
.foregroundStyle(
isSelected ? Color.blue : Color.gray
)
.frame(width: 17)
Text(table)
.font(.system(size: 12))
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.frame(height: 23)
.padding(.leading, 49)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
isSelected
? Color.blue.opacity(0.14)
: Color.clear
)
.contentShape(Rectangle())
.onTapGesture {
selectedTableName = identifier
}
.onTapGesture(count: 2) {
selectedTableName = identifier
model.selectTable(
database: database,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
selectedTableName = identifier
model.selectTable(
database: database,
table: table
)
}
}
}
private func folderNode(
_ title: String,
indent: CGFloat
) -> some View {
HStack(spacing: 5) {
Image(systemName: "chevron.right")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.frame(width: 13)
Image(systemName: "folder")
.foregroundStyle(.secondary)
.frame(width: 17)
Text(title)
.font(.system(size: 12))
Spacer(minLength: 0)
}
.frame(height: 24)
.padding(.leading, indent)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Workspace
private var workspace: some View {
VStack(spacing: 0) {
queryTabBar
Divider()
queryToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 230)
bottomWorkspace
.frame(minHeight: 230)
}
}
}
private var queryTabBar: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.foregroundStyle(.blue)
Text("Query 1")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "xmark")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.frame(height: 29)
.background(Color(nsColor: .textBackgroundColor))
Spacer()
}
.background(WB.header)
}
private var queryToolbar: some View {
HStack(spacing: 14) {
Button {
model.runSQL()
} label: {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
}
.help("Execute SQL")
Button {
model.formatSQL()
} label: {
Image(systemName: "text.alignleft")
}
.help("Format SQL")
Divider()
.frame(height: 17)
Text("Default Schema:")
.font(.system(size: 11))
Text(model.selectedDatabase.isEmpty
? "None"
: model.selectedDatabase)
.font(.system(size: 11, weight: .semibold))
Spacer()
Text("SQL Editor")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private var sqlEditor: some View {
HStack(spacing: 0) {
lineNumbers
Rectangle()
.fill(WB.border)
.frame(width: 1)
SQLTextView(text: $model.sql)
}
.background(Color(nsColor: .textBackgroundColor))
}
private var lineNumbers: some View {
ScrollView {
VStack(alignment: .trailing, spacing: 0) {
ForEach(
1...max(1, model.sql.components(
separatedBy: "\n"
).count),
id: \.self
) { number in
Text("\(number)")
.frame(height: 19)
}
}
.padding(.horizontal, 9)
.padding(.top, 8)
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 48)
.background(WB.header)
.allowsHitTesting(false)
}
// MARK: - Results and Action Output
private var bottomWorkspace: some View {
VSplitView {
resultsPanel
.frame(minHeight: 230)
if outputExpanded {
actionOutput
.frame(minHeight: 90)
}
}
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "tablecells")
Text("Result Grid")
.fontWeight(.semibold)
Spacer()
Text(model.result.message)
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 28)
.background(WB.header)
Divider()
if model.result.columns.isEmpty {
VStack {
Spacer()
Text("No query results")
.foregroundStyle(.secondary)
Spacer()
}
} else {
resultGrid
}
}
}
private var resultGrid: some View {
ScrollView([.horizontal, .vertical]) {
Grid(
alignment: .leading,
horizontalSpacing: 0,
verticalSpacing: 0
) {
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(model.result.columns[column])
.fontWeight(.semibold)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(WB.header)
}
}
ForEach(
model.result.rows.indices,
id: \.self
) { row in
GridRow {
ForEach(
model.result.columns.indices,
id: \.self
) { column in
Text(
column < model.result.rows[row].count
? model.result.rows[row][column]
: ""
)
.lineLimit(1)
.frame(
width: 170,
alignment: .leading
)
.padding(7)
.background(
row.isMultiple(of: 2)
? Color(nsColor: .textBackgroundColor)
: WB.sidebar
)
}
}
}
}
.font(.system(size: 12, design: .monospaced))
}
}
private var actionOutput: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "list.bullet.rectangle")
Text("Action Output")
.fontWeight(.semibold)
Spacer()
Button {
outputExpanded = false
} label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 26)
.background(WB.header)
Divider()
HStack(alignment: .top, spacing: 12) {
Image(systemName:
model.status == "Error"
? "xmark.circle.fill"
: "checkmark.circle.fill"
)
.foregroundStyle(
model.status == "Error"
? .red : .green
)
VStack(alignment: .leading, spacing: 5) {
Text(model.status)
.fontWeight(.semibold)
Text(model.result.message)
.foregroundStyle(.secondary)
}
Spacer()
}
.font(.system(size: 12))
.padding(12)
Spacer()
}
}
// MARK: - Status Bar
private var statusBar: some View {
HStack(spacing: 10) {
Circle()
.fill(
model.status == "Error"
? Color.red : Color.green
)
.frame(width: 8, height: 8)
Text(model.status)
.lineLimit(1)
Spacer()
Text(model.selected?.name ?? "Disconnected")
Divider()
.frame(height: 13)
Text(model.selectedDatabase.isEmpty
? "No Schema"
: model.selectedDatabase)
Divider()
.frame(height: 13)
Text("UTF-8")
}
.font(.system(size: 11))
.padding(.horizontal, 12)
.frame(height: 22)
.background(WB.header)
}
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Native SQL Text Editor
struct SQLTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
scroll.appearance = NSAppearance(named: .aqua)
editor.appearance = NSAppearance(named: .aqua)
editor.backgroundColor = .white
editor.textColor = .black
scroll.backgroundColor = .white
editor.isEditable = true
editor.isRichText = false
editor.allowsUndo = true
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.font = NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
editor.textContainerInset = NSSize(
width: 10,
height: 8
)
editor.delegate = context.coordinator
editor.string = text
context.coordinator.highlight(editor)
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let editor = scroll.documentView as? NSTextView else {
return
}
if editor.string != text {
let selection = editor.selectedRange()
editor.string = text
let safeLocation = min(
selection.location,
(text as NSString).length
)
editor.setSelectedRange(
NSRange(location: safeLocation, length: 0)
)
context.coordinator.highlight(editor)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: SQLTextView
init(_ parent: SQLTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let editor = notification.object as? NSTextView else {
return
}
parent.text = editor.string
highlight(editor)
}
func highlight(_ editor: NSTextView) {
guard let storage = editor.textStorage else {
return
}
let text = editor.string as NSString
let range = NSRange(
location: 0,
length: text.length
)
storage.beginEditing()
storage.setAttributes([
.foregroundColor: NSColor.textColor,
.font: NSFont.monospacedSystemFont(
ofSize: 13,
weight: .regular
)
], range: range)
let pattern = """
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
"""
if let regex = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) {
for match in regex.matches(
in: editor.string,
range: range
) {
storage.addAttribute(
.foregroundColor,
value: NSColor.systemBlue,
range: match.range
)
}
}
storage.endEditing()
}
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,329 +0,0 @@
import Foundation
import CommonCrypto
import CoreFoundation
import Security
// Native MySQL wire protocol client. No external mysql executable is used.
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
struct MySQLWireError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
private struct ByteCursor {
let bytes: [UInt8]
var offset = 0
mutating func take(_ n: Int) throws -> [UInt8] {
guard n >= 0, offset + n <= bytes.count else { throw MySQLWireError(message: "Malformed MySQL packet") }
defer { offset += n }
return Array(bytes[offset..<(offset+n)])
}
mutating func u8() throws -> UInt8 { try take(1)[0] }
mutating func u16() throws -> Int { let b = try take(2); return Int(b[0]) | (Int(b[1]) << 8) }
mutating func u32() throws -> Int { let b = try take(4); return Int(b[0]) | (Int(b[1]) << 8) | (Int(b[2]) << 16) | (Int(b[3]) << 24) }
mutating func nulString() throws -> String {
let start = offset
while offset < bytes.count && bytes[offset] != 0 { offset += 1 }
guard offset < bytes.count else { throw MySQLWireError(message: "Unterminated server string") }
let s = String(decoding: bytes[start..<offset], as: UTF8.self)
offset += 1
return s
}
mutating func lenInt() throws -> Int? {
let first = try u8()
switch first {
case 0xfb: return nil
case 0xfc: return try u16()
case 0xfd:
let b = try take(3); return Int(b[0]) | Int(b[1]) << 8 | Int(b[2]) << 16
case 0xfe:
let b = try take(8)
var v: UInt64 = 0
for (i, byte) in b.enumerated() { v |= UInt64(byte) << (8 * i) }
guard v <= UInt64(Int.max) else { throw MySQLWireError(message: "Field too large") }
return Int(v)
default: return Int(first)
}
}
mutating func lenString() throws -> String? {
guard let n = try lenInt() else { return nil }
return String(decoding: try take(n), as: UTF8.self)
}
}
private func le32(_ v: Int) -> [UInt8] { (0..<4).map { UInt8(truncatingIfNeeded: v >> ($0 * 8)) } }
private func sha1(_ data: [UInt8]) -> [UInt8] {
var result = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
data.withUnsafeBytes { ptr in _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &result) }
return result
}
private func sha256(_ data: [UInt8]) -> [UInt8] {
var result = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { ptr in _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &result) }
return result
}
private func authResponse(password: String, salt: [UInt8], plugin: String) throws -> [UInt8] {
if password.isEmpty { return [] }
let pwd = Array(password.utf8)
if plugin == "mysql_native_password" {
let first = sha1(pwd), second = sha1(sha1(pwd))
let last = sha1(salt + second)
return zip(first, last).map { $0 ^ $1 }
}
if plugin == "caching_sha2_password" {
let first = sha256(pwd), second = sha256(sha256(pwd))
let last = sha256(second + salt)
return zip(first, last).map { $0 ^ $1 }
}
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
}
// Parse an RSA SubjectPublicKeyInfo PEM supplied by MySQL. Security.framework
// expects the inner PKCS#1 RSAPublicKey DER for SecKeyCreateWithData.
private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
guard packet.first == 0x01 else {
throw MySQLWireError(message: "Expected caching_sha2_password public-key response")
}
let pem = String(decoding: packet.dropFirst(), as: UTF8.self)
let base64 = pem.components(separatedBy: .newlines)
.filter { !$0.hasPrefix("-----") }.joined()
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let spki = Data(base64Encoded: base64) else {
throw MySQLWireError(message: "Invalid MySQL RSA public key")
}
let der = [UInt8](spki)
func length(_ index: inout Int) throws -> Int {
guard index < der.count else { throw MySQLWireError(message: "Invalid RSA DER length") }
let first = Int(der[index]); index += 1
if first < 128 { return first }
let bytes = first & 0x7f
guard bytes > 0, bytes <= 4, index + bytes <= der.count else {
throw MySQLWireError(message: "Invalid RSA DER length")
}
var value = 0
for _ in 0..<bytes { value = (value << 8) | Int(der[index]); index += 1 }
return value
}
func element(_ index: inout Int, tag: UInt8) throws -> Range<Int> {
guard index < der.count, der[index] == tag else {
throw MySQLWireError(message: "Invalid RSA DER element")
}
index += 1
let count = try length(&index)
guard count <= der.count - index else { throw MySQLWireError(message: "Truncated RSA DER") }
let range = index..<(index + count)
index += count
return range
}
var i = 0
let outer = try element(&i, tag: 0x30)
i = outer.lowerBound
_ = try element(&i, tag: 0x30) // AlgorithmIdentifier
let bits = try element(&i, tag: 0x03)
guard bits.count > 1, der[bits.lowerBound] == 0 else {
throw MySQLWireError(message: "Invalid RSA public key bit string")
}
let pkcs1 = Data(der[(bits.lowerBound + 1)..<bits.upperBound])
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(pkcs1 as CFData, [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic
] as CFDictionary, &error) else {
throw MySQLWireError(message: "Cannot import MySQL RSA public key: \(error?.takeRetainedValue().localizedDescription ?? "unknown error")")
}
return key
}
final class MySQLWireConnection {
private let input: InputStream
private let output: OutputStream
private var sequence: UInt8 = 0
private let maxPacket = 16_777_215
init(host: String, port: Int) throws {
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
var read: Unmanaged<CFReadStream>?
var write: Unmanaged<CFWriteStream>?
CFStreamCreatePairWithSocketToHost(nil, host as CFString, UInt32(port), &read, &write)
guard let r = read?.takeRetainedValue(), let w = write?.takeRetainedValue() else {
throw MySQLWireError(message: "Cannot create connection")
}
input = unsafeBitCast(r, to: InputStream.self)
output = unsafeBitCast(w, to: OutputStream.self)
input.open(); output.open()
// A CFStream may still be .opening immediately after open(). The
// first blocking read below waits for the server greeting.
if input.streamStatus == .error || output.streamStatus == .error {
throw MySQLWireError(message: input.streamError?.localizedDescription ?? output.streamError?.localizedDescription ?? "Could not open MySQL connection")
}
}
deinit { input.close(); output.close() }
private func readExact(_ count: Int) throws -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: count)
var done = 0
while done < count {
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 }
return input.read(base.advanced(by: done), maxLength: count - done)
}
guard n > 0 else { throw MySQLWireError(message: input.streamError?.localizedDescription ?? "MySQL connection closed") }
done += n
}
return buffer
}
private func readPacket() throws -> [UInt8] {
let header = try readExact(4)
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
sequence = header[3] &+ 1
return try readExact(count)
}
private func send(_ payload: [UInt8]) throws {
guard payload.count < maxPacket else { throw MySQLWireError(message: "Query exceeds initial client packet limit") }
let count = payload.count
let packet = [UInt8(count & 255), UInt8((count >> 8) & 255), UInt8((count >> 16) & 255), sequence] + payload
sequence = sequence &+ 1
var written = 0
while written < packet.count {
let n = packet.withUnsafeBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 }
return output.write(base.advanced(by: written), maxLength: packet.count - written)
}
guard n > 0 else { throw MySQLWireError(message: output.streamError?.localizedDescription ?? "MySQL write failed") }
written += n
}
}
private func checkError(_ p: [UInt8]) throws {
guard p.first == 0xff else { return }
let details = String(decoding: p.dropFirst(3), as: UTF8.self)
throw MySQLWireError(message: "MySQL error: \(details)")
}
func authenticate(user: String, password: String, database: String) throws {
let hello = try readPacket()
var c = ByteCursor(bytes: hello)
let version = try c.u8()
guard version == 10 else { throw MySQLWireError(message: "Unsupported MySQL handshake") }
_ = try c.nulString()
_ = try c.u32()
let salt1 = try c.take(8)
_ = try c.u8()
let lower = try c.u16()
_ = try c.u8() // character set
_ = try c.u16() // status
let upper = try c.u16()
let capabilities = lower | (upper << 16)
let authLen = Int(try c.u8())
_ = try c.take(10)
let salt2 = try c.take(max(13, authLen - 8))
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
let plugin = (try? c.nulString()) ?? "mysql_native_password"
// This initial implementation deliberately refuses cleartext remote credentials.
guard hostIsLoopback else { throw MySQLWireError(message: "Direct remote connections require TLS, which is not yet implemented. Use a local SSH tunnel.") }
// CLIENT_LONG_PASSWORD, LONG_FLAG, PROTOCOL_41, TRANSACTIONS,
// SECURE_CONNECTION and PLUGIN_AUTH. Do not set DEPRECATE_EOF.
let clientFlags = 0x00000001 | 0x00000004 | 0x00000200 | 0x00002000 | 0x00008000 | 0x00080000
let response = try authResponse(password: password, salt: Array(salt), plugin: plugin)
var body = le32(clientFlags & capabilities) + le32(16_777_216) + [UInt8(45)] + [UInt8](repeating: 0, count: 23)
body += Array(user.utf8) + [0, UInt8(response.count)] + response
body += Array(plugin.utf8) + [0]
try send(body)
var reply = try readPacket()
try checkError(reply)
var currentPlugin = plugin
var currentSalt = Array(salt)
if reply.first == 0xfe {
var switchCursor = ByteCursor(bytes: Array(reply.dropFirst()))
let newPlugin = try switchCursor.nulString()
let rawSalt = Array(reply.dropFirst(1 + newPlugin.utf8.count + 1))
let newSalt = rawSalt.last == 0 ? Array(rawSalt.dropLast()) : rawSalt
currentPlugin = newPlugin
currentSalt = newSalt
try send(authResponse(password: password, salt: newSalt, plugin: newPlugin))
reply = try readPacket()
try checkError(reply)
}
if reply.first == 0x01 {
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
throw MySQLWireError(message: "Unsupported authentication continuation")
}
switch reply[1] {
case 0x03: // Fast authentication accepted; wait for OK.
reply = try readPacket()
case 0x04: // Full authentication required.
guard hostIsLoopback else {
throw MySQLWireError(message: "Remote full authentication requires TLS (not implemented). Use a trusted local SSH tunnel.")
}
if password.isEmpty {
try send([0])
} else {
// Request the server's RSA public key (0x02). This is
// intended ONLY for a trusted local loopback connection.
try send([0x02])
let keyPacket = try readPacket()
try checkError(keyPacket)
let key = try mysqlRSAPublicKey(keyPacket)
guard !currentSalt.isEmpty else {
throw MySQLWireError(message: "Missing MySQL authentication nonce")
}
var scrambled = Array(password.utf8) + [0]
for i in scrambled.indices { scrambled[i] ^= currentSalt[i % currentSalt.count] }
var rsaError: Unmanaged<CFError>?
guard let encrypted = SecKeyCreateEncryptedData(
key, .rsaEncryptionOAEPSHA1, Data(scrambled) as CFData, &rsaError
) as Data? else {
throw MySQLWireError(message: "MySQL RSA encryption failed: \(rsaError?.takeRetainedValue().localizedDescription ?? "unknown error")")
}
try send(Array(encrypted))
}
reply = try readPacket()
default:
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
}
}
try checkError(reply)
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
}
private var hostIsLoopback = false
func markHost(_ host: String) { hostIsLoopback = ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) }
func query(_ sql: String) throws -> QueryResult {
sequence = 0
try send([0x03] + Array(sql.utf8))
let first = try readPacket()
try checkError(first)
if first.first == 0x00 { return QueryResult(message: "Statement executed successfully") }
var c = ByteCursor(bytes: first)
guard let columnCount = try c.lenInt(), columnCount <= 4096 else { throw MySQLWireError(message: "Invalid column count") }
var names: [String] = []
for _ in 0..<columnCount {
var field = ByteCursor(bytes: try readPacket())
for _ in 0..<4 { _ = try field.lenString() }
names.append(try field.lenString() ?? "")
}
_ = try readPacket() // column EOF (initial protocol requires EOF)
var rows: [[String]] = []
while true {
let packet = try readPacket()
try checkError(packet)
if packet.first == 0xfe && packet.count < 9 { break }
var row = ByteCursor(bytes: packet)
var values: [String] = []
for _ in 0..<columnCount { values.append(try row.lenString() ?? "NULL") }
rows.append(values)
if rows.count > 100_000 { throw MySQLWireError(message: "Result exceeds 100,000 row safety limit") }
}
return QueryResult(columns: names, rows: rows, message: "\(rows.count) row(s)")
}
}
enum NativeMySQLRunner {
static func run(profile: ConnectionProfile, database: String, password: String, sql: String) async throws -> QueryResult {
try await Task.detached(priority: .userInitiated) {
let connection = try MySQLWireConnection(host: profile.host, port: profile.port)
connection.markHost(profile.host)
try connection.authenticate(user: profile.username, password: password, database: database)
return try connection.query(sql)
}.value
}
}
@@ -327,7 +327,7 @@
"key": "Import SQL into %@?",
"location": {
"startingColumn": 13,
"startingLine": 1680
"startingLine": 1725
}
},
{
@@ -335,7 +335,7 @@
"key": "Operation",
"location": {
"startingColumn": 20,
"startingLine": 1581
"startingLine": 1589
}
},
{
@@ -343,7 +343,7 @@
"key": "Data Export",
"location": {
"startingColumn": 22,
"startingLine": 1582
"startingLine": 1590
}
},
{
@@ -351,7 +351,7 @@
"key": "Data Import / Restore",
"location": {
"startingColumn": 22,
"startingLine": 1583
"startingLine": 1591
}
},
{
@@ -359,7 +359,7 @@
"key": "MySQL Connection",
"location": {
"startingColumn": 25,
"startingLine": 1589
"startingLine": 1597
}
},
{
@@ -367,7 +367,7 @@
"key": "Export Options",
"location": {
"startingColumn": 29,
"startingLine": 1604
"startingLine": 1619
}
},
{
@@ -375,7 +375,7 @@
"key": "Import Options",
"location": {
"startingColumn": 29,
"startingLine": 1613
"startingLine": 1651
}
},
{
@@ -383,7 +383,7 @@
"key": "Progress",
"location": {
"startingColumn": 25,
"startingLine": 1623
"startingLine": 1661
}
},
{
@@ -391,7 +391,7 @@
"key": "Connection",
"location": {
"startingColumn": 36,
"startingLine": 1590
"startingLine": 1598
}
},
{
@@ -399,7 +399,7 @@
"key": "Database",
"location": {
"startingColumn": 28,
"startingLine": 1594
"startingLine": 1602
}
},
{
@@ -407,23 +407,87 @@
"key": "Select Database",
"location": {
"startingColumn": 30,
"startingLine": 1595
"startingLine": 1603
}
},
{
"comment": "",
"key": "Export the SQL definitions of all base tables in the selected database.",
"key": "Refresh Databases",
"location": {
"startingColumn": 30,
"startingLine": 1605
"startingColumn": 32,
"startingLine": 1611
}
},
{
"comment": "",
"key": "This version exports table structure. Table data, views, triggers and stored routines will be added in the next stage.",
"key": "Exports base tables with batched INSERT statements. Views, triggers, routines and transaction-consistent snapshots are not included.",
"location": {
"startingColumn": 30,
"startingLine": 1608
"startingLine": 1647
}
},
{
"comment": "",
"key": "Table structure",
"location": {
"startingColumn": 32,
"startingLine": 1620
}
},
{
"comment": "",
"key": "Table data",
"location": {
"startingColumn": 32,
"startingLine": 1621
}
},
{
"comment": "",
"key": "Add DROP TABLE IF EXISTS",
"location": {
"startingColumn": 32,
"startingLine": 1622
}
},
{
"comment": "",
"key": "Loading tables…",
"location": {
"startingColumn": 57,
"startingLine": 1632
}
},
{
"comment": "",
"key": "Tables (%lld selected)",
"location": {
"startingColumn": 34,
"startingLine": 1625
}
},
{
"comment": "",
"key": "All",
"location": {
"startingColumn": 36,
"startingLine": 1627
}
},
{
"comment": "",
"key": "None",
"location": {
"startingColumn": 36,
"startingLine": 1628
}
},
{
"comment": "",
"key": "Refresh",
"location": {
"startingColumn": 36,
"startingLine": 1629
}
},
{
@@ -431,7 +495,7 @@
"key": "Import SQL statements into the selected database.",
"location": {
"startingColumn": 30,
"startingLine": 1614
"startingLine": 1652
}
},
{
@@ -439,7 +503,7 @@
"key": "Supports ordinary SQL statements. Dumps using custom DELIMITER directives or stored routines are not supported yet.",
"location": {
"startingColumn": 30,
"startingLine": 1617
"startingLine": 1655
}
},
{
@@ -447,7 +511,7 @@
"key": "Close",
"location": {
"startingColumn": 24,
"startingLine": 1638
"startingLine": 1676
}
},
{
@@ -455,7 +519,7 @@
"key": "Cancel",
"location": {
"startingColumn": 28,
"startingLine": 1646
"startingLine": 1684
}
},
{
@@ -463,7 +527,7 @@
"key": "Export SQL…",
"location": {
"startingColumn": 31,
"startingLine": 1653
"startingLine": 1691
}
},
{
@@ -471,7 +535,7 @@
"key": "Import SQL…",
"location": {
"startingColumn": 31,
"startingLine": 1654
"startingLine": 1692
}
},
{
@@ -479,7 +543,7 @@
"key": "Import SQL",
"location": {
"startingColumn": 20,
"startingLine": 1683
"startingLine": 1728
}
},
{
@@ -487,7 +551,7 @@
"key": "Cancel",
"location": {
"startingColumn": 20,
"startingLine": 1691
"startingLine": 1736
}
},
{
@@ -495,7 +559,7 @@
"key": "SQL files can modify or delete existing database objects and data. Ensure you have a backup before continuing.",
"location": {
"startingColumn": 17,
"startingLine": 1696
"startingLine": 1741
}
},
{
@@ -503,7 +567,7 @@
"key": "Database Import / Export",
"location": {
"startingColumn": 22,
"startingLine": 1708
"startingLine": 1753
}
},
{
@@ -511,7 +575,7 @@
"key": "Built-in native MySQL database tools",
"location": {
"startingColumn": 22,
"startingLine": 1711
"startingLine": 1756
}
}
]
@@ -5,3 +5,5 @@
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
@@ -19,7 +19,7 @@
<dict>
<key>cdhash</key>
<data>
aPFwAkm/guZ4EeZintWn1SNqigs=
kACWojMyCPE6G6Uw22tGzRrP2x4=
</data>
<key>requirement</key>
<string>identifier "WorkbenchReplacement.debug" and anchor apple generic and certificate leaf[subject.CN] = "Apple Development: Mark Gregory (FLBRJ3BYDD)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */</string>
+47 -47
View File
@@ -6,7 +6,7 @@
<integer>13</integer>
<key>logs</key>
<dict>
<key>5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61</key>
<key>087CD1E0-7E94-4934-A62E-C6C070CD1577</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
@@ -15,7 +15,7 @@
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61.xcactivitylog</string>
<string>087CD1E0-7E94-4934-A62E-C6C070CD1577.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
@@ -40,55 +40,13 @@
<key>signature</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>timeStartedRecording</key>
<real>813875595.87715495</real>
<real>813876132.17963398</real>
<key>timeStoppedRecording</key>
<real>813875606.38803506</real>
<real>813876144.18060398</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61</string>
</dict>
<key>8EB6F71B-1109-492D-84B8-8C2FE5C14BB0</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
<key>documentTypeString</key>
<string>&lt;nil&gt;</string>
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>8EB6F71B-1109-492D-84B8-8C2FE5C14BB0.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
<dict>
<key>highLevelStatus</key>
<string>S</string>
<key>totalNumberOfAnalyzerIssues</key>
<integer>0</integer>
<key>totalNumberOfErrors</key>
<integer>0</integer>
<key>totalNumberOfTestFailures</key>
<integer>0</integer>
<key>totalNumberOfWarnings</key>
<integer>0</integer>
</dict>
<key>schemeIdentifier-containerName</key>
<string>WorkbenchReplacement project</string>
<key>schemeIdentifier-schemeName</key>
<string>WorkbenchReplacement</string>
<key>schemeIdentifier-sharedScheme</key>
<integer>1</integer>
<key>signature</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>timeStartedRecording</key>
<real>813875432.68892801</real>
<key>timeStoppedRecording</key>
<real>813875443.71130598</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>8EB6F71B-1109-492D-84B8-8C2FE5C14BB0</string>
<string>087CD1E0-7E94-4934-A62E-C6C070CD1577</string>
</dict>
<key>AD44F407-7FE5-46AE-A82F-8FAC1CA2720B</key>
<dict>
@@ -132,6 +90,48 @@
<key>uniqueIdentifier</key>
<string>AD44F407-7FE5-46AE-A82F-8FAC1CA2720B</string>
</dict>
<key>AF582207-87B3-4A2E-A532-DA0FD8F61FBD</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
<key>documentTypeString</key>
<string>&lt;nil&gt;</string>
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>AF582207-87B3-4A2E-A532-DA0FD8F61FBD.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
<dict>
<key>highLevelStatus</key>
<string>S</string>
<key>totalNumberOfAnalyzerIssues</key>
<integer>0</integer>
<key>totalNumberOfErrors</key>
<integer>0</integer>
<key>totalNumberOfTestFailures</key>
<integer>0</integer>
<key>totalNumberOfWarnings</key>
<integer>0</integer>
</dict>
<key>schemeIdentifier-containerName</key>
<string>WorkbenchReplacement project</string>
<key>schemeIdentifier-schemeName</key>
<string>WorkbenchReplacement</string>
<key>schemeIdentifier-sharedScheme</key>
<integer>1</integer>
<key>signature</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>timeStartedRecording</key>
<real>813876270.55029094</real>
<key>timeStoppedRecording</key>
<real>813876283.37291896</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>AF582207-87B3-4A2E-A532-DA0FD8F61FBD</string>
</dict>
<key>BA4E5983-8DAA-49CA-A677-11E73E67F0B0</key>
<dict>
<key>className</key>
@@ -1 +1 @@
1792183067.957757: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
1792183470.9957151: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastAccessedDate</key>
<date>2026-10-16T20:37:47Z</date>
<date>2026-10-16T20:44:30Z</date>
<key>WorkspacePath</key>
<string>/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj</string>
</dict>