Files
SQLWorkbench/DBSyn/App.swift
T
2026-10-19 10:05:48 +01:00

522 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
extension Notification.Name {
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) }
.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,
.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"
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"
)
}
migrationSettings
.tabItem { Label("Laravel", systemImage: "curlybraces") }
generalSettings
.tabItem {
Label(
"General",
systemImage: "gearshape"
)
}
}
.frame(width: 760, height: 480)
.preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
}
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)
}
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])
}
}
}