From f46f598baf70713f40d59e7cc4adce7621a8601a Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 19 Oct 2026 10:32:15 +0100 Subject: [PATCH] new tests --- DBSyn/DatabaseModel.swift | 75 ++++++++++++++++--- DBSyn/MainView.swift | 151 ++++++++++++++++++++++++++++++++++---- 2 files changed, 200 insertions(+), 26 deletions(-) diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index 63f74fa..c4565ad 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -17,27 +17,54 @@ struct QueryResult { } 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] + private static let service = "DBSyn.MySQL" + private static let legacyService = "WorkbenchReplacement.MySQL" + + private static func lookup(_ id: UUID, service: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + 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) ?? "" + let data = value as? Data else { return nil } + return String(data: data, encoding: .utf8) } + + static func read(_ id: UUID) -> String { + if let password = lookup(id, service: service) { return password } + // One-time compatibility read. Never delete the legacy item. + if let password = lookup(id, service: legacyService) { + save(password, id: id) + return password + } + return "" + } + 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] + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + 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) } + + static func delete(_ id: UUID) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: id.uuidString + ] + SecItemDelete(query as CFDictionary) + } } @@ -507,7 +534,8 @@ final class DatabaseModel: ObservableObject { let cached = try? JSONDecoder().decode([String: SchemaIntrospection].self, from: data) { introspections = cached.filter { entry in profiles.contains(where: { $0.id == entry.value.connectionID }) } } - selectedID = profiles.first?.id + let rememberedID = UserDefaults.standard.string(forKey: "DBSyn.selectedConnectionID") + selectedID = profiles.first(where: { $0.id.uuidString == rememberedID })?.id ?? profiles.first?.id if let id = selectedID { for snapshot in introspections.values where snapshot.connectionID == id { applyIntrospection(snapshot) @@ -620,11 +648,34 @@ final class DatabaseModel: ObservableObject { } var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } } + + func selectConnection(_ id: UUID) { + guard let profile = profiles.first(where: { $0.id == id }) else { return } + saveActiveQuery() + selectedID = id + UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID") + selectedDatabase = profile.database + databases = [] + status = "Selected connection: \(profile.name)" + loadDatabases() + } + + func deleteConnection(_ id: UUID) { + guard profiles.count > 1 else { return } + NativeMySQLRunner.disconnect(profileID: id) + PasswordStore.delete(id) + profiles.removeAll { $0.id == id } + if selectedID == id, let first = profiles.first { selectConnection(first.id) } + persist() + } + 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 + selectedDatabase = profile.database + UserDefaults.standard.set(profile.id.uuidString, forKey: "DBSyn.selectedConnectionID") persist() } private func persist() { diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index b5723ae..f127772 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -29,6 +29,7 @@ struct MainView: View { @State private var editResultRow: [String]? = nil private let resultPageSize = 1_000 @State private var showingEditor = false + @State private var showingConnectionManager = false @State private var showingDatabaseTransfer = false @State private var tableSearchTarget: WorkbenchTableTarget? = nil @State private var tableImportTarget: WorkbenchTableTarget? = nil @@ -169,6 +170,17 @@ struct MainView: View { .sheet(item: $schemaManagement, onDismiss: { model.loadDatabases() }) { target in WorkbenchSchemaManagementSheet(model: model, target: target) } + .sheet(isPresented: $showingConnectionManager) { + DBSynConnectionManager(model: model) { profile in + showingConnectionManager = false + edit(profile) + } onAdd: { + showingConnectionManager = false + editing = ConnectionProfile(name: "New Connection") + editingPassword = "" + showingEditor = true + } + } .sheet(isPresented: $showingEditor) { ConnectionEditor( profile: $editing, @@ -258,8 +270,29 @@ struct MainView: View { Spacer() + Menu { + ForEach(model.profiles) { profile in + Button { + model.selectConnection(profile.id) + } label: { + if model.selectedID == profile.id { + Label(profile.name, systemImage: "checkmark.circle.fill") + } else { + Label(profile.name, systemImage: "server.rack") + } + } + } + Divider() + Button("Manage Connections…") { showingConnectionManager = true } + } label: { + Label(model.selected?.name ?? "Select Connection", systemImage: "server.rack") + .lineLimit(1) + .frame(maxWidth: 185) + } + .help("Switch active MySQL connection") + VStack(alignment: .trailing, spacing: 2) { - Text(model.selected?.name ?? "No Connection") + Text(model.selected?.host ?? "Not connected") .font(.system(size: 12, weight: .semibold)) Text(model.selectedDatabase.isEmpty @@ -366,8 +399,7 @@ struct MainView: View { VStack(alignment: .leading, spacing: 4) { ForEach(model.profiles) { profile in Button { - model.selectedID = profile.id - model.loadDatabases() + model.selectConnection(profile.id) } label: { Label( profile.name, @@ -2455,11 +2487,17 @@ struct ConnectionEditor: View { let save: () -> Void @Environment(\.dismiss) private var dismiss + @State private var testMessage = "" var body: some View { VStack(alignment: .leading, spacing: 14) { - Text("Connection Settings") - .font(.title2.bold()) + HStack { + Text("Connection Settings").font(.title2.bold()) + Spacer() + Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.borderless) + .help("Close") + } Form { TextField( @@ -2472,11 +2510,7 @@ struct ConnectionEditor: View { text: $profile.host ) - TextField( - "Port", - value: $profile.port, - format: .number - ) + TextField("Port", value: $profile.port, format: .number.grouping(.never)) TextField( "Username", @@ -2488,13 +2522,28 @@ struct ConnectionEditor: View { text: $password ) - TextField( - "Default Schema", - text: $profile.database - ) + TextField("Default Schema", text: $profile.database) + Text("Remote MySQL requires a trusted SSH tunnel until native TLS support is available.") + .font(.caption).foregroundStyle(.secondary) } + if !testMessage.isEmpty { + Text(testMessage).font(.caption).foregroundStyle(.secondary) + .textSelection(.enabled) + } HStack { + Button("Test Connection") { + let candidate = profile + let secret = password + Task { + do { + let result = try await NativeMySQLRunner.run( + profile: candidate, database: candidate.database, + password: secret, sql: "SELECT VERSION()") + testMessage = "Connection successful: " + (result.rows.first?.first ?? "MySQL") + } catch { testMessage = error.localizedDescription } + } + } Spacer() Button("Cancel") { @@ -7102,3 +7151,77 @@ private struct DBSynInlineRowEditor: View { } } } + + +// MARK: - Connection Manager +private struct DBSynConnectionManager: View { + @ObservedObject var model: DatabaseModel + let onEdit: (ConnectionProfile) -> Void + let onAdd: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var search = "" + @State private var confirmDelete: UUID? + + private var filtered: [ConnectionProfile] { + model.profiles.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) + || $0.host.localizedCaseInsensitiveContains(search) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Label("Connections", systemImage: "server.rack").font(.title2.bold()) + Spacer() + Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.borderless) + } + TextField("Search connections", text: $search) + .textFieldStyle(.roundedBorder) + List(filtered) { profile in + HStack(spacing: 12) { + Image(systemName: model.selectedID == profile.id ? "checkmark.circle.fill" : "server.rack") + .foregroundStyle(model.selectedID == profile.id ? Color.blue : Color.secondary) + VStack(alignment: .leading) { + Text(profile.name).font(.headline) + Text("\(profile.username)@\(profile.host):\(profile.port)") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Connect") { model.selectConnection(profile.id); dismiss() } + .disabled(model.selectedID == profile.id) + Button("Edit") { onEdit(profile) } + Button { + var copy = profile + copy.id = UUID() + copy.name += " Copy" + model.save(copy, password: PasswordStore.read(profile.id)) + } label: { Image(systemName: "plus.square.on.square") } + .help("Duplicate") + Button(role: .destructive) { confirmDelete = profile.id } label: { + Image(systemName: "trash") + } + .disabled(model.profiles.count <= 1) + } + .padding(.vertical, 5) + } + HStack { + Button("Add Connection") { onAdd() } + Spacer() + Button("Done") { dismiss() }.buttonStyle(.borderedProminent) + } + } + .padding(24) + .frame(minWidth: 750, minHeight: 430) + .alert("Delete connection?", isPresented: Binding( + get: { confirmDelete != nil }, set: { if !$0 { confirmDelete = nil } } + )) { + Button("Delete", role: .destructive) { + if let id = confirmDelete { model.deleteConnection(id) } + confirmDelete = nil + } + Button("Cancel", role: .cancel) { confirmDelete = nil } + } message: { + Text("This removes the saved connection and its DBSyn Keychain credential.") + } + } +}