diff --git a/WorkbenchReplacement.xcodeproj/project.pbxproj.backup-20261016-173256 b/WorkbenchReplacement.xcodeproj/project.pbxproj.backup-20261016-173256 deleted file mode 100644 index a943e96..0000000 --- a/WorkbenchReplacement.xcodeproj/project.pbxproj.backup-20261016-173256 +++ /dev/null @@ -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 = ""; }; - 000000000000000000000011 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; - 000000000000000000000012 /* DatabaseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseModel.swift; sourceTree = ""; }; - 000000000000000000000016 /* MySQLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MySQLProtocol.swift; sourceTree = ""; }; -/* 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 = ""; - }; - 000000000000000000000003 /* WorkbenchReplacement */ = { - isa = PBXGroup; - children = ( - 000000000000000000000010 /* App.swift */, - 000000000000000000000011 /* MainView.swift */, - 000000000000000000000012 /* DatabaseModel.swift */, - 000000000000000000000016 /* MySQLProtocol.swift */, - ); - path = WorkbenchReplacement; - sourceTree = ""; - }; - 000000000000000000000004 /* Products */ = { - isa = PBXGroup; - children = ( - 00000000000000000000000F /* WorkbenchReplacement.app */, - ); - name = Products; - sourceTree = ""; - }; -/* 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 */; -} diff --git a/WorkbenchReplacement.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate b/WorkbenchReplacement.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 231c149..0000000 Binary files a/WorkbenchReplacement.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/WorkbenchReplacement.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist b/WorkbenchReplacement.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 236528b..0000000 --- a/WorkbenchReplacement.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - WorkbenchReplacement.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/WorkbenchReplacement/App.swift.backup-20261016-172158 b/WorkbenchReplacement/App.swift.backup-20261016-172158 deleted file mode 100644 index cf5480f..0000000 --- a/WorkbenchReplacement/App.swift.backup-20261016-172158 +++ /dev/null @@ -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) {} - } - } -} diff --git a/WorkbenchReplacement/App.swift.backup-20261016-172337 b/WorkbenchReplacement/App.swift.backup-20261016-172337 deleted file mode 100644 index bf1f010..0000000 --- a/WorkbenchReplacement/App.swift.backup-20261016-172337 +++ /dev/null @@ -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) - } -} diff --git a/WorkbenchReplacement/App.swift.backup-20261016-173256 b/WorkbenchReplacement/App.swift.backup-20261016-173256 deleted file mode 100644 index 3b08ab8..0000000 --- a/WorkbenchReplacement/App.swift.backup-20261016-173256 +++ /dev/null @@ -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( - _ 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( - _ 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) - } -} diff --git a/WorkbenchReplacement/DatabaseModel.swift.backup-20261016-165756 b/WorkbenchReplacement/DatabaseModel.swift.backup-20261016-165756 deleted file mode 100644 index cdd07e6..0000000 --- a/WorkbenchReplacement/DatabaseModel.swift.backup-20261016-165756 +++ /dev/null @@ -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 - } - } -} diff --git a/WorkbenchReplacement/DatabaseModel.swift.backup-20261016-170516 b/WorkbenchReplacement/DatabaseModel.swift.backup-20261016-170516 deleted file mode 100644 index d48fa03..0000000 --- a/WorkbenchReplacement/DatabaseModel.swift.backup-20261016-170516 +++ /dev/null @@ -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 = [ - "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.. 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 = [ - "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.. 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 = [ - "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.. 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 = [ - "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.. 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 = [ - "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.. 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 = [ - "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..? @State private var showingImportConfirmation = false @State private var pendingImportURL: URL? + @State private var availableTables: [String] = [] + @State private var selectedTables: Set = [] + @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 diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-165756 b/WorkbenchReplacement/MainView.swift.backup-20261016-165756 deleted file mode 100644 index 4f044c5..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-165756 +++ /dev/null @@ -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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-170516 b/WorkbenchReplacement/MainView.swift.backup-20261016-170516 deleted file mode 100644 index 55e9d80..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-170516 +++ /dev/null @@ -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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-170655 b/WorkbenchReplacement/MainView.swift.backup-20261016-170655 deleted file mode 100644 index 8e8179d..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-170655 +++ /dev/null @@ -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 = [] - @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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-170825 b/WorkbenchReplacement/MainView.swift.backup-20261016-170825 deleted file mode 100644 index 3b64a37..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-170825 +++ /dev/null @@ -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 = [] - @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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-170924 b/WorkbenchReplacement/MainView.swift.backup-20261016-170924 deleted file mode 100644 index 0f9d3fd..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-170924 +++ /dev/null @@ -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 = [] - @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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-171209 b/WorkbenchReplacement/MainView.swift.backup-20261016-171209 deleted file mode 100644 index 0e57561..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-171209 +++ /dev/null @@ -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 = [] - @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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-171257 b/WorkbenchReplacement/MainView.swift.backup-20261016-171257 deleted file mode 100644 index d4eb648..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-171257 +++ /dev/null @@ -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 = [] - @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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-171647 b/WorkbenchReplacement/MainView.swift.backup-20261016-171647 deleted file mode 100644 index 9246cfc..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-171647 +++ /dev/null @@ -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 = [] - @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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-171907 b/WorkbenchReplacement/MainView.swift.backup-20261016-171907 deleted file mode 100644 index 73e9e01..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-171907 +++ /dev/null @@ -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 = [] - @State private var expandedTableFolders: Set = [] - @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) - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-172024 b/WorkbenchReplacement/MainView.swift.backup-20261016-172024 deleted file mode 100644 index 0515f28..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-172024 +++ /dev/null @@ -1,1083 +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 = [] - @State private var expandedTableFolders: Set = [] - @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 { - NativeResultGrid( - columns: model.result.columns, - rows: model.result.rows - ) - } - - private var actionOutput: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: "list.bullet.rectangle") - Text("Action Output") - .fontWeight(.semibold) - - Spacer() - - Button { - outputExpanded = false - } label: { - Image(systemName: "chevron.down") - } - .buttonStyle(.borderless) - } - .font(.system(size: 12)) - .padding(.horizontal, 12) - .frame(height: 26) - .background(WB.header) - - Divider() - - HStack(alignment: .top, spacing: 12) { - Image(systemName: - model.status == "Error" - ? "xmark.circle.fill" - : "checkmark.circle.fill" - ) - .foregroundStyle( - model.status == "Error" - ? .red : .green - ) - - VStack(alignment: .leading, spacing: 5) { - Text(model.status) - .fontWeight(.semibold) - - Text(model.result.message) - .foregroundStyle(.secondary) - } - - Spacer() - } - .font(.system(size: 12)) - .padding(12) - - Spacer() - } - } - - // MARK: - Status Bar - - private var statusBar: some View { - HStack(spacing: 10) { - Circle() - .fill( - model.status == "Error" - ? Color.red : Color.green - ) - .frame(width: 8, height: 8) - - Text(model.status) - .lineLimit(1) - - Spacer() - - Text(model.selected?.name ?? "Disconnected") - - Divider() - .frame(height: 13) - - Text(model.selectedDatabase.isEmpty - ? "No Schema" - : model.selectedDatabase) - - Divider() - .frame(height: 13) - - Text("UTF-8") - } - .font(.system(size: 11)) - .padding(.horizontal, 12) - .frame(height: 22) - .background(WB.header) - } - - private func sectionHeader(_ title: String) -> some View { - HStack { - Text(title) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.secondary) - - Spacer() - } - .padding(.horizontal, 12) - .frame(height: 30) - .background(WB.header) - } - - private func edit(_ profile: ConnectionProfile) { - editing = profile - editingPassword = PasswordStore.read(profile.id) - showingEditor = true - } -} - -// MARK: - Native SQL Text Editor - -struct SQLTextView: NSViewRepresentable { - @Binding var text: String - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scroll = NSTextView.scrollableTextView() - let editor = scroll.documentView as! NSTextView - - scroll.appearance = NSAppearance(named: .aqua) - editor.appearance = NSAppearance(named: .aqua) - editor.backgroundColor = .white - editor.textColor = .black - scroll.backgroundColor = .white - - editor.isEditable = true - editor.isRichText = false - editor.allowsUndo = true - editor.isAutomaticQuoteSubstitutionEnabled = false - editor.isAutomaticDashSubstitutionEnabled = false - editor.font = NSFont.monospacedSystemFont( - ofSize: 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) - } -} - - -// MARK: - High Performance Native Result Grid - -struct NativeResultGrid: NSViewRepresentable { - - let columns: [String] - let rows: [[String]] - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeNSView(context: Context) -> NSScrollView { - let scroll = NSScrollView() - - scroll.hasVerticalScroller = true - scroll.hasHorizontalScroller = true - scroll.autohidesScrollers = true - scroll.drawsBackground = true - - let table = NSTableView() - - table.usesAlternatingRowBackgroundColors = true - table.rowHeight = 24 - table.intercellSpacing = NSSize(width: 1, height: 1) - table.allowsMultipleSelection = true - table.allowsColumnReordering = true - table.allowsColumnResizing = true - table.columnAutoresizingStyle = .noColumnAutoresizing - - table.delegate = context.coordinator - table.dataSource = context.coordinator - - scroll.documentView = table - - context.coordinator.rows = rows - context.coordinator.columns = columns - - configureColumns(table) - - return scroll - } - - func updateNSView( - _ scroll: NSScrollView, - context: Context - ) { - guard let table = scroll.documentView as? NSTableView else { - return - } - - let oldColumns = context.coordinator.columns - - context.coordinator.rows = rows - context.coordinator.columns = columns - - if oldColumns != columns { - configureColumns(table) - } - - table.reloadData() - } - - private func configureColumns(_ table: NSTableView) { - for column in table.tableColumns { - table.removeTableColumn(column) - } - - for (index, name) in columns.enumerated() { - let column = NSTableColumn( - identifier: NSUserInterfaceItemIdentifier( - "column_\\(index)" - ) - ) - - column.title = name - column.width = 170 - column.minWidth = 70 - column.maxWidth = 600 - - table.addTableColumn(column) - } - } - - final class Coordinator: NSObject, - NSTableViewDataSource, - NSTableViewDelegate { - - var columns: [String] = [] - var rows: [[String]] = [] - - func numberOfRows(in tableView: NSTableView) -> Int { - rows.count - } - - func tableView( - _ tableView: NSTableView, - viewFor tableColumn: NSTableColumn?, - row: Int - ) -> NSView? { - - guard let tableColumn, - row >= 0, - row < rows.count else { - return nil - } - - let identifier = tableColumn.identifier.rawValue - - guard let columnIndex = Int( - identifier.replacingOccurrences( - of: "column_", - with: "" - ) - ) else { - return nil - } - - let cellIdentifier = - NSUserInterfaceItemIdentifier("ResultCell") - - let cell: NSTableCellView - - if let reused = tableView.makeView( - withIdentifier: cellIdentifier, - owner: nil - ) as? NSTableCellView { - cell = reused - } else { - cell = NSTableCellView() - cell.identifier = cellIdentifier - - let textField = NSTextField(labelWithString: "") - textField.font = NSFont.monospacedSystemFont( - ofSize: 12, - weight: .regular - ) - - textField.lineBreakMode = .byTruncatingTail - textField.translatesAutoresizingMaskIntoConstraints = false - - cell.addSubview(textField) - cell.textField = textField - - NSLayoutConstraint.activate([ - textField.leadingAnchor.constraint( - equalTo: cell.leadingAnchor, - constant: 7 - ), - textField.trailingAnchor.constraint( - equalTo: cell.trailingAnchor, - constant: -7 - ), - textField.centerYAnchor.constraint( - equalTo: cell.centerYAnchor - ) - ]) - } - - let values = rows[row] - - cell.textField?.stringValue = - columnIndex < values.count - ? values[columnIndex] - : "" - - return cell - } - } -} diff --git a/WorkbenchReplacement/MainView.swift.backup-20261016-172158 b/WorkbenchReplacement/MainView.swift.backup-20261016-172158 deleted file mode 100644 index f8d56ef..0000000 --- a/WorkbenchReplacement/MainView.swift.backup-20261016-172158 +++ /dev/null @@ -1,1082 +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 = [] - @State private var expandedTableFolders: Set = [] - @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 { - NativeResultGrid( - columns: model.result.columns, - rows: model.result.rows - ) - } - - private var actionOutput: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: "list.bullet.rectangle") - Text("Action Output") - .fontWeight(.semibold) - - Spacer() - - Button { - outputExpanded = false - } label: { - Image(systemName: "chevron.down") - } - .buttonStyle(.borderless) - } - .font(.system(size: 12)) - .padding(.horizontal, 12) - .frame(height: 26) - .background(WB.header) - - Divider() - - HStack(alignment: .top, spacing: 12) { - Image(systemName: - model.status == "Error" - ? "xmark.circle.fill" - : "checkmark.circle.fill" - ) - .foregroundStyle( - model.status == "Error" - ? .red : .green - ) - - VStack(alignment: .leading, spacing: 5) { - Text(model.status) - .fontWeight(.semibold) - - Text(model.result.message) - .foregroundStyle(.secondary) - } - - Spacer() - } - .font(.system(size: 12)) - .padding(12) - - Spacer() - } - } - - // MARK: - Status Bar - - private var statusBar: some View { - HStack(spacing: 10) { - Circle() - .fill( - model.status == "Error" - ? Color.red : Color.green - ) - .frame(width: 8, height: 8) - - Text(model.status) - .lineLimit(1) - - Spacer() - - Text(model.selected?.name ?? "Disconnected") - - Divider() - .frame(height: 13) - - Text(model.selectedDatabase.isEmpty - ? "No Schema" - : model.selectedDatabase) - - Divider() - .frame(height: 13) - - Text("UTF-8") - } - .font(.system(size: 11)) - .padding(.horizontal, 12) - .frame(height: 22) - .background(WB.header) - } - - private func sectionHeader(_ title: String) -> some View { - HStack { - Text(title) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.secondary) - - Spacer() - } - .padding(.horizontal, 12) - .frame(height: 30) - .background(WB.header) - } - - private func edit(_ profile: ConnectionProfile) { - editing = profile - editingPassword = PasswordStore.read(profile.id) - showingEditor = true - } -} - -// MARK: - Native SQL Text Editor - -struct SQLTextView: NSViewRepresentable { - @Binding var text: String - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scroll = NSTextView.scrollableTextView() - let editor = scroll.documentView as! NSTextView - - scroll.appearance = NSAppearance(named: .aqua) - editor.appearance = NSAppearance(named: .aqua) - editor.backgroundColor = .white - editor.textColor = .black - scroll.backgroundColor = .white - - editor.isEditable = true - editor.isRichText = false - editor.allowsUndo = true - editor.isAutomaticQuoteSubstitutionEnabled = false - editor.isAutomaticDashSubstitutionEnabled = false - editor.font = NSFont.monospacedSystemFont( - ofSize: 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) - } -} - - - -// MARK: - High Performance Native Result Grid - -struct NativeResultGrid: NSViewRepresentable { - - let columns: [String] - let rows: [[String]] - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeNSView(context: Context) -> NSScrollView { - let scroll = NSScrollView() - scroll.hasVerticalScroller = true - scroll.hasHorizontalScroller = true - scroll.autohidesScrollers = true - scroll.drawsBackground = true - scroll.backgroundColor = .white - - let table = NSTableView() - table.headerView = NSTableHeaderView() - table.usesAlternatingRowBackgroundColors = true - table.rowHeight = 23 - table.intercellSpacing = NSSize(width: 1, height: 1) - table.allowsMultipleSelection = true - table.allowsColumnResizing = true - table.allowsColumnReordering = true - table.columnAutoresizingStyle = .noColumnAutoresizing - table.backgroundColor = .white - - context.coordinator.columns = columns - context.coordinator.rows = rows - - table.delegate = context.coordinator - table.dataSource = context.coordinator - - configureColumns(table) - - scroll.documentView = table - table.reloadData() - - return scroll - } - - func updateNSView( - _ scroll: NSScrollView, - context: Context - ) { - guard let table = scroll.documentView as? NSTableView else { - return - } - - let oldColumns = context.coordinator.columns - - context.coordinator.columns = columns - context.coordinator.rows = rows - - if oldColumns != columns { - configureColumns(table) - } - - table.reloadData() - } - - private func configureColumns(_ table: NSTableView) { - for column in Array(table.tableColumns) { - table.removeTableColumn(column) - } - - for (index, name) in columns.enumerated() { - let identifier = NSUserInterfaceItemIdentifier( - "column_\(index)" - ) - - let column = NSTableColumn(identifier: identifier) - column.title = name - column.width = 170 - column.minWidth = 70 - column.maxWidth = 700 - column.resizingMask = .userResizingMask - - table.addTableColumn(column) - } - } - - final class Coordinator: NSObject, - NSTableViewDataSource, - NSTableViewDelegate { - - var columns: [String] = [] - var rows: [[String]] = [] - - func numberOfRows(in tableView: NSTableView) -> Int { - rows.count - } - - func tableView( - _ tableView: NSTableView, - viewFor tableColumn: NSTableColumn?, - row: Int - ) -> NSView? { - - guard let tableColumn, - row >= 0, - row < rows.count else { - return nil - } - - let identifier = tableColumn.identifier.rawValue - - guard identifier.hasPrefix("column_"), - let columnIndex = Int(identifier.dropFirst(7)), - columnIndex < columns.count else { - return nil - } - - let cellIdentifier = - NSUserInterfaceItemIdentifier("ResultCell") - - let cell: NSTableCellView - - if let reused = tableView.makeView( - withIdentifier: cellIdentifier, - owner: nil - ) as? NSTableCellView { - cell = reused - } else { - cell = NSTableCellView() - cell.identifier = cellIdentifier - - let field = NSTextField(labelWithString: "") - field.font = NSFont.monospacedSystemFont( - ofSize: 12, - weight: .regular - ) - field.lineBreakMode = .byTruncatingTail - field.translatesAutoresizingMaskIntoConstraints = false - - cell.addSubview(field) - cell.textField = field - - NSLayoutConstraint.activate([ - field.leadingAnchor.constraint( - equalTo: cell.leadingAnchor, - constant: 7 - ), - field.trailingAnchor.constraint( - equalTo: cell.trailingAnchor, - constant: -7 - ), - field.centerYAnchor.constraint( - equalTo: cell.centerYAnchor - ) - ]) - } - - let values = rows[row] - - cell.textField?.stringValue = - columnIndex < values.count - ? values[columnIndex] - : "" - - return cell - } - } -} diff --git a/WorkbenchReplacement/MySQLProtocol.swift.backup-20261016-164525 b/WorkbenchReplacement/MySQLProtocol.swift.backup-20261016-164525 deleted file mode 100644 index e4bd3de..0000000 --- a/WorkbenchReplacement/MySQLProtocol.swift.backup-20261016-164525 +++ /dev/null @@ -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.. 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.. Range { - 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)..? - 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? - var write: Unmanaged? - 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? - 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.. 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 - } -} diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o index 98b54f9..cbf994d 100644 Binary files a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o and b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o differ diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.stringsdata b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.stringsdata index a56a8ce..208f643 100644 --- a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.stringsdata +++ b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.stringsdata @@ -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 } } ] diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftdeps b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftdeps index 9db6421..601d65c 100644 Binary files a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftdeps and b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftdeps differ diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.priors b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.priors index a0f8f7a..a5e9cc0 100644 Binary files a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.priors and b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.priors differ diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftmodule b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftmodule index dd0cf2c..c117d65 100644 Binary files a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftmodule and b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftmodule differ diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftsourceinfo b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftsourceinfo index 4d913f3..867aa0f 100644 Binary files a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftsourceinfo and b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftsourceinfo differ diff --git a/build/Build/Intermediates.noindex/XCBuildData/build.db b/build/Build/Intermediates.noindex/XCBuildData/build.db index fc2d94d..e6865a7 100644 Binary files a/build/Build/Intermediates.noindex/XCBuildData/build.db and b/build/Build/Intermediates.noindex/XCBuildData/build.db differ diff --git a/build/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt b/build/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt index 240b2b0..7156a99 100644 --- a/build/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt +++ b/build/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt @@ -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 diff --git a/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/WorkbenchReplacement b/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/WorkbenchReplacement index 75494ed..1746f6f 100755 Binary files a/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/WorkbenchReplacement and b/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/WorkbenchReplacement differ diff --git a/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/WorkbenchReplacement.debug.dylib b/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/WorkbenchReplacement.debug.dylib index 94aada3..d47457c 100755 Binary files a/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/WorkbenchReplacement.debug.dylib and b/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/WorkbenchReplacement.debug.dylib differ diff --git a/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/__preview.dylib b/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/__preview.dylib index 25359c9..b8cda62 100755 Binary files a/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/__preview.dylib and b/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/MacOS/__preview.dylib differ diff --git a/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/_CodeSignature/CodeResources b/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/_CodeSignature/CodeResources index ee2f58b..1d1d6e6 100644 --- a/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/_CodeSignature/CodeResources +++ b/build/Build/Products/Debug/WorkbenchReplacement.app/Contents/_CodeSignature/CodeResources @@ -19,7 +19,7 @@ cdhash - aPFwAkm/guZ4EeZintWn1SNqigs= + kACWojMyCPE6G6Uw22tGzRrP2x4= requirement 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 */ diff --git a/build/Build/Products/Debug/WorkbenchReplacement.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo b/build/Build/Products/Debug/WorkbenchReplacement.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo index 4d913f3..867aa0f 100644 Binary files a/build/Build/Products/Debug/WorkbenchReplacement.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo and b/build/Build/Products/Debug/WorkbenchReplacement.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo differ diff --git a/build/Build/Products/Debug/WorkbenchReplacement.swiftmodule/arm64-apple-macos.swiftmodule b/build/Build/Products/Debug/WorkbenchReplacement.swiftmodule/arm64-apple-macos.swiftmodule index dd0cf2c..c117d65 100644 Binary files a/build/Build/Products/Debug/WorkbenchReplacement.swiftmodule/arm64-apple-macos.swiftmodule and b/build/Build/Products/Debug/WorkbenchReplacement.swiftmodule/arm64-apple-macos.swiftmodule differ diff --git a/build/Index.noindex/DataStore/v5/units/MainView.o-CNVK7PE7JUX3 b/build/Index.noindex/DataStore/v5/units/MainView.o-CNVK7PE7JUX3 index c59df78..1ccd257 100644 Binary files a/build/Index.noindex/DataStore/v5/units/MainView.o-CNVK7PE7JUX3 and b/build/Index.noindex/DataStore/v5/units/MainView.o-CNVK7PE7JUX3 differ diff --git a/build/Logs/Build/LogStoreManifest.plist b/build/Logs/Build/LogStoreManifest.plist index 0462630..15d266a 100644 --- a/build/Logs/Build/LogStoreManifest.plist +++ b/build/Logs/Build/LogStoreManifest.plist @@ -6,7 +6,7 @@ 13 logs - 5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61 + 087CD1E0-7E94-4934-A62E-C6C070CD1577 className IDECommandLineBuildLog @@ -15,7 +15,7 @@ domainType Xcode.IDEActivityLogDomainType.BuildLog fileName - 5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61.xcactivitylog + 087CD1E0-7E94-4934-A62E-C6C070CD1577.xcactivitylog hasPrimaryLog primaryObservable @@ -40,55 +40,13 @@ signature Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug timeStartedRecording - 813875595.87715495 + 813876132.17963398 timeStoppedRecording - 813875606.38803506 + 813876144.18060398 title Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug uniqueIdentifier - 5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61 - - 8EB6F71B-1109-492D-84B8-8C2FE5C14BB0 - - className - IDECommandLineBuildLog - documentTypeString - <nil> - domainType - Xcode.IDEActivityLogDomainType.BuildLog - fileName - 8EB6F71B-1109-492D-84B8-8C2FE5C14BB0.xcactivitylog - hasPrimaryLog - - primaryObservable - - highLevelStatus - S - totalNumberOfAnalyzerIssues - 0 - totalNumberOfErrors - 0 - totalNumberOfTestFailures - 0 - totalNumberOfWarnings - 0 - - schemeIdentifier-containerName - WorkbenchReplacement project - schemeIdentifier-schemeName - WorkbenchReplacement - schemeIdentifier-sharedScheme - 1 - signature - Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug - timeStartedRecording - 813875432.68892801 - timeStoppedRecording - 813875443.71130598 - title - Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug - uniqueIdentifier - 8EB6F71B-1109-492D-84B8-8C2FE5C14BB0 + 087CD1E0-7E94-4934-A62E-C6C070CD1577 AD44F407-7FE5-46AE-A82F-8FAC1CA2720B @@ -132,6 +90,48 @@ uniqueIdentifier AD44F407-7FE5-46AE-A82F-8FAC1CA2720B + AF582207-87B3-4A2E-A532-DA0FD8F61FBD + + className + IDECommandLineBuildLog + documentTypeString + <nil> + domainType + Xcode.IDEActivityLogDomainType.BuildLog + fileName + AF582207-87B3-4A2E-A532-DA0FD8F61FBD.xcactivitylog + hasPrimaryLog + + primaryObservable + + highLevelStatus + S + totalNumberOfAnalyzerIssues + 0 + totalNumberOfErrors + 0 + totalNumberOfTestFailures + 0 + totalNumberOfWarnings + 0 + + schemeIdentifier-containerName + WorkbenchReplacement project + schemeIdentifier-schemeName + WorkbenchReplacement + schemeIdentifier-sharedScheme + 1 + signature + Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug + timeStartedRecording + 813876270.55029094 + timeStoppedRecording + 813876283.37291896 + title + Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug + uniqueIdentifier + AF582207-87B3-4A2E-A532-DA0FD8F61FBD + BA4E5983-8DAA-49CA-A677-11E73E67F0B0 className diff --git a/build/ModuleCache.noindex/Session.modulevalidation b/build/ModuleCache.noindex/Session.modulevalidation index 2a981dd..625956a 100644 --- a/build/ModuleCache.noindex/Session.modulevalidation +++ b/build/ModuleCache.noindex/Session.modulevalidation @@ -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") diff --git a/build/SDKStatCaches.noindex/macosx27.0-26A425-e829b21a6f8c9b70876ba0858053c0447231f6df4def33e022ae2eb8f782df1a.sdkstatcache b/build/SDKStatCaches.noindex/macosx27.0-26A425-e829b21a6f8c9b70876ba0858053c0447231f6df4def33e022ae2eb8f782df1a.sdkstatcache index 647692d..17a2acf 100644 Binary files a/build/SDKStatCaches.noindex/macosx27.0-26A425-e829b21a6f8c9b70876ba0858053c0447231f6df4def33e022ae2eb8f782df1a.sdkstatcache and b/build/SDKStatCaches.noindex/macosx27.0-26A425-e829b21a6f8c9b70876ba0858053c0447231f6df4def33e022ae2eb8f782df1a.sdkstatcache differ diff --git a/build/info.plist b/build/info.plist index 04f8146..c688a7c 100644 --- a/build/info.plist +++ b/build/info.plist @@ -3,7 +3,7 @@ LastAccessedDate - 2026-10-16T20:37:47Z + 2026-10-16T20:44:30Z WorkspacePath /Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj