665 lines
23 KiB
Swift
665 lines
23 KiB
Swift
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<Content: View>(
|
||
_ title: String,
|
||
@ViewBuilder content: () -> Content
|
||
) -> some View {
|
||
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
|
||
Text(title)
|
||
.font(.system(size: 20, weight: .semibold))
|
||
|
||
Divider()
|
||
|
||
content()
|
||
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(26)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(Color(nsColor: .windowBackgroundColor))
|
||
}
|
||
|
||
private func settingsRow<Content: View>(
|
||
_ title: String,
|
||
@ViewBuilder content: () -> Content
|
||
) -> some View {
|
||
|
||
HStack(alignment: .center, spacing: 16) {
|
||
|
||
Text(title)
|
||
.font(.system(size: 13))
|
||
.frame(width: 170, alignment: .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])
|
||
}
|
||
}
|
||
}
|