new tests

This commit is contained in:
Mark Gregory
2026-10-19 10:32:15 +01:00
parent 6251f2490b
commit f46f598baf
2 changed files with 200 additions and 26 deletions
+63 -12
View File
@@ -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() {
+137 -14
View File
@@ -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.")
}
}
}