522 lines
16 KiB
Swift
522 lines
16 KiB
Swift
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 ?? 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"
|
||
|
||
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])
|
||
}
|
||
}
|
||
}
|