import SwiftUI import AppKit extension Notification.Name { static let dbsynOpenConnectionManager = Notification.Name("DBSyn.OpenConnectionManager") static let workbenchRevealSQLEditor = Notification.Name("DBSyn.RevealSQLEditor") } @main struct DBSynApp: App { @StateObject private var model = DatabaseModel() @AppStorage("preferences.appearance") private var appearance = "system" @State private var showingMigrationGenerator = false var body: some Scene { WindowGroup("DBSyn") { WorkbenchLaunchView() .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) .onAppear { if let iconURL = Bundle.main.url(forResource: "DBSyn", withExtension: "icns"), let icon = NSImage(contentsOf: iconURL) { NSApplication.shared.applicationIconImage = icon } } .environmentObject(model) .sheet(isPresented: $showingMigrationGenerator) { LaravelMigrationView(model: model, onClose: { showingMigrationGenerator = false }) } .frame(minWidth: 800, minHeight: 520) } .defaultSize(width: 1200, height: 800) .windowResizability(.contentSize) .commands { CommandGroup(replacing: .appInfo) { Button("About DBSyn") { let icon = NSImage(contentsOfFile: Bundle.main.path(forResource: "DBSynBrand", ofType: "png") ?? "") NSApplication.shared.orderFrontStandardAboutPanel(options: [ .applicationName: "DBSyn", .applicationIcon: icon ?? NSApplication.shared.applicationIconImage ?? NSImage(), .version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0", .credits: NSAttributedString(string: "Native MySQL client for macOS") ]) } } WorkbenchFileCommands(model: model) CommandMenu("Tools") { Button("Generate Laravel Migration…") { showingMigrationGenerator = true } } } 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 = "system" @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 @AppStorage("preferences.appearance") private var appearance = "system" @AppStorage("preferences.migrationFilenamePrefix") private var migrationFilenamePrefix = "create" @State private var selectedSection: SettingsSection? = .general private enum SettingsSection: String, CaseIterable, Identifiable { case general = "General" case connections = "Connections" case security = "Security" case editor = "SQL Editor" case execution = "Query Execution" case results = "Result Grid" case database = "Database" case diagrams = "Schema Designer" case laravel = "Laravel" case administration = "Administration" case exports = "Export & Backup" case advanced = "Advanced" var id: String { rawValue } var icon: String { switch self { case .general: return "gearshape" case .connections: return "server.rack" case .security: return "lock.shield" case .editor: return "chevron.left.forwardslash.chevron.right" case .execution: return "bolt" case .results: return "tablecells" case .database: return "externaldrive" case .diagrams: return "point.3.connected.trianglepath.dotted" case .laravel: return "curlybraces" case .administration: return "gauge.with.dots.needle.67percent" case .exports: return "square.and.arrow.up" case .advanced: return "wrench.and.screwdriver" } } } @AppStorage("preferences.diagramPalette") private var diagramPalette = "Ocean" @AppStorage("preferences.diagramShowAllColumns") private var diagramShowAllColumns = false @AppStorage("preferences.migrationOutputDirectory") private var migrationOutputDirectory = "" @AppStorage("preferences.migrationUseTimestamps") private var migrationUseTimestamps = true @AppStorage("preferences.confirmDestructiveSQL") private var confirmDestructiveSQL = true var body: some View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 4) { Text("DBSyn Settings") .font(.headline) .padding(.horizontal, 14) .padding(.top, 18) .padding(.bottom, 12) ScrollView { VStack(alignment: .leading, spacing: 3) { ForEach(SettingsSection.allCases) { section in Button { selectedSection = section } label: { Label(section.rawValue, systemImage: section.icon) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 9) .background( (selectedSection ?? .general) == section ? Color.accentColor.opacity(0.22) : Color.clear, in: RoundedRectangle(cornerRadius: 7) ) } .buttonStyle(.plain) .accessibilityIdentifier("settings.section.\(section.id)") } } .padding(.horizontal, 10) } } .frame(width: 230) .frame(maxHeight: .infinity, alignment: .top) .background(Color(nsColor: .controlBackgroundColor)) Divider() ScrollView { Group { switch selectedSection ?? .general { case .general: generalSettings case .connections: connectionsSettings case .security: securitySettings case .editor: editorSettings case .execution: executionSettings case .results: resultSettings case .database: databaseSettings case .diagrams: diagramSettings case .laravel: migrationSettings case .administration: administrationSettings case .exports: exportSettings case .advanced: advancedSettings } } .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(minWidth: 850, idealWidth: 1050, minHeight: 570, idealHeight: 710) .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) } private func information(_ message: String) -> some View { Text(message).font(.callout).foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } private var connectionsSettings: some View { settingsPage("Connections") { information("Manage and switch saved connections from the main window's Connections menu. Select a profile to connect, or edit its MySQL host, port and default schema.") information("Connection profiles and credentials are stored separately. Passwords are held in macOS Keychain.") Button("Open Connection Manager") { NotificationCenter.default.post(name: .dbsynOpenConnectionManager, object: nil) // Leave window management to the Settings scene. } .buttonStyle(.borderedProminent) } } private var securitySettings: some View { settingsPage("Security & Transport") { information("Each connection has independent TLS and SSH transport options. Edit a connection to configure certificate-verified TLS, or an SSH tunnel using macOS OpenSSH.") information("Do not disable certificate verification to work around connection failures. Native TLS handshake compatibility must be validated against your server before relying on it.") information("Passwords use the DBSyn.MySQL Keychain service. Legacy WorkbenchReplacement.MySQL credentials are read for compatibility.") } } private var databaseSettings: some View { settingsPage("Database Navigator") { settingsRow("Expand tables") { Toggle("Automatically expand table folders", isOn: $autoExpandTables) } information("Select the active database in the schema navigator. Refresh schema metadata from the main window after switching connections.") } } private var diagramSettings: some View { settingsPage("Schema Designer") { settingsRow("Default palette") { Picker("Palette", selection: $diagramPalette) { ForEach(["Ocean", "Slate", "Forest", "Plum"], id: \.self) { Text($0) } }.labelsHidden().frame(width: 200) } settingsRow("Columns") { Toggle("Show all columns by default", isOn: $diagramShowAllColumns) } information("Diagram-specific layouts and table selections are saved from the Schema Designer. You can override the palette and column visibility there.") } } private var administrationSettings: some View { settingsPage("Administration") { information("Server status, connections, users, variables, performance and logs are available in the Administration navigator.") information("Server start/stop and configuration-file editing require server-level operating-system privileges and are not available through ordinary MySQL credentials.") } } private var exportSettings: some View { settingsPage("Export & Backup") { information("SQL and CSV export, backup and restore actions are available from the main window. Choose a destination when exporting.") information("Backups and restores can be destructive. Review the target server and schema before starting a restore.") } } private var advancedSettings: some View { settingsPage("Advanced") { settingsRow("Safety") { Toggle("Confirm destructive SQL operations", isOn: $confirmDestructiveSQL) } information("The safety preference is reserved for a future SQL confirmation integration; it does not yet intercept SQL execution.") information("For troubleshooting, use the macOS Console and the DBSyn test runner. TLS negotiation must be tested on a macOS host.") } } private var migrationSettings: some View { settingsPage("Laravel & Developer Tools") { Text("Generate Laravel migrations from a connected MySQL table or an imported CREATE TABLE SQL file.") .foregroundStyle(.secondary) settingsRow("Filename prefix") { TextField("create", text: $migrationFilenamePrefix) .textFieldStyle(.roundedBorder) } settingsRow("Output folder") { TextField("Choose folder when saving", text: $migrationOutputDirectory) .textFieldStyle(.roundedBorder) } settingsRow("Filename") { Toggle("Use timestamp prefix", isOn: $migrationUseTimestamps) } Text("Open Tools → Generate Laravel Migration to preview, edit and save the generated PHP file.") .font(.callout).foregroundStyle(.secondary) Text("Generated migrations must be reviewed before execution. DBSyn never executes generated PHP.") .font(.caption).foregroundStyle(.secondary) } } 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, 186) Divider() .padding(.vertical, 12) Text("Examples") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) 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("Appearance") { Picker("Appearance", selection: $appearance) { Text("System").tag("system") Text("Light").tag("light") Text("Dark").tag("dark") } .pickerStyle(.segmented) .labelsHidden() .accessibilityLabel("Application appearance") .frame(width: 260, alignment: .leading) } Text(appearance == "system" ? "DBSyn follows your Mac’s appearance setting." : appearance == "light" ? "DBSyn always uses Light appearance." : "DBSyn always uses Dark appearance.") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 186) 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: .leading) content() Spacer(minLength: 0) } .frame(height: 30) } } // MARK: - Workbench Splash Screen struct WorkbenchLaunchView: View { @State private var showingSplash = true var body: some View { ZStack { MainView() if showingSplash { WorkbenchSplashView() .transition(.opacity) .zIndex(10) } } .task { try? await Task.sleep(for: .seconds(3)) withAnimation(.easeOut(duration: 0.4)) { showingSplash = false } } } } struct WorkbenchSplashView: View { var body: some View { VStack(spacing: 20) { Spacer() if let path = Bundle.main.path( forResource: "DBSynBrand", ofType: "png" ), let image = NSImage(contentsOfFile: path) { Image(nsImage: image) .resizable() .scaledToFit() .frame(width: 160, height: 160) } Text("DBSyn") .font(.system(size: 30, weight: .bold)) Text("Modern MySQL Client for macOS") .font(.system(size: 15)) .foregroundStyle(.secondary) HStack(spacing: 30) { feature("cylinder.split.1x2", "Schemas") feature("chevron.left.forwardslash.chevron.right", "SQL Editor") feature("tablecells", "Result Grid") feature("server.rack", "Connections") } .padding(.top, 20) Spacer() ProgressView() .frame(width: 260) Text("Starting DBSyn…") .font(.caption) .foregroundStyle(.secondary) .padding(.bottom, 30) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( LinearGradient( colors: [ Color(nsColor: .windowBackgroundColor), Color(nsColor: .controlBackgroundColor) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) } private func feature(_ symbol: String, _ title: String) -> some View { VStack(spacing: 8) { Image(systemName: symbol) .font(.system(size: 24)) .foregroundStyle(.blue) Text(title) .font(.system(size: 12)) } .frame(width: 90) } } // MARK: - Workbench File Menu struct WorkbenchFileCommands: Commands { @ObservedObject var model: DatabaseModel var body: some Commands { CommandGroup(replacing: .newItem) { Button("New Query Tab") { model.newQueryTab() NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil) } .keyboardShortcut("t", modifiers: .command) } CommandGroup(after: .newItem) { Button("Open SQL Script…") { model.openSQLScript() } .keyboardShortcut("o", modifiers: .command) Menu("Open Recent SQL Script") { if model.recentSQLScripts.isEmpty { Text("No Recent Scripts") } else { ForEach(model.recentSQLScripts, id: \.self) { path in Button(URL(fileURLWithPath: path).lastPathComponent) { model.openRecentSQLScript(at: path) } .help(path) } Divider() Button("Clear Recent Scripts") { model.clearRecentSQLScripts() } } } Divider() Button("Save SQL Script") { model.saveSQLScript() } .keyboardShortcut("s", modifiers: .command) Button("Save SQL Script As…") { model.saveSQLScript(asNewFile: true) } .keyboardShortcut("s", modifiers: [.command, .shift]) } } }