Files
SQLWorkbench/WorkbenchReplacement/MainView.swift
T
2026-10-16 17:26:02 +01:00

1114 lines
31 KiB
Swift

import SwiftUI
import AppKit
// MARK: - Classic Workbench Colours
private enum WB {
static let toolbar = Color(nsColor: NSColor(
calibratedWhite: 0.88, alpha: 1
))
static let sidebar = Color(nsColor: NSColor(
calibratedWhite: 0.94, alpha: 1
))
static let border = Color(nsColor: NSColor(
calibratedWhite: 0.76, alpha: 1
))
static let header = Color(nsColor: NSColor(
calibratedWhite: 0.91, alpha: 1
))
}
// MARK: - Main Window
struct MainView: View {
@EnvironmentObject private var model: DatabaseModel
@State private var showingEditor = false
@State private var editing = ConnectionProfile()
@State private var editingPassword = ""
@State private var expandedSchemas: Set<String> = []
@State private var expandedTableFolders: Set<String> = []
@State private var selectedTableName: String? = nil
@State private var outputExpanded = true
var body: some View {
VStack(spacing: 0) {
mainToolbar
Rectangle()
.fill(WB.border)
.frame(height: 1)
HSplitView {
navigator
.frame(
minWidth: 210,
idealWidth: 265,
maxWidth: 420
)
workspace
.frame(minWidth: 600)
}
statusBar
}
.frame(minWidth: 1100, minHeight: 720)
.preferredColorScheme(
UserDefaults.standard.string(
forKey: "preferences.appearance"
) == "dark" ? .dark : .light
)
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $showingEditor) {
ConnectionEditor(
profile: $editing,
password: $editingPassword
) {
model.save(editing, password: editingPassword)
showingEditor = false
}
}
}
// MARK: - Main Toolbar
private var mainToolbar: some View {
HStack(spacing: 16) {
Image(systemName: "externaldrive.connected.to.line.below")
.font(.system(size: 23))
.foregroundStyle(.blue)
Text("MySQL Workbench")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
Rectangle()
.fill(WB.border)
.frame(width: 1, height: 30)
toolbarButton(
"New Connection",
icon: "plus.rectangle.on.rectangle"
) {
editing = ConnectionProfile(name: "New Connection")
editingPassword = ""
showingEditor = true
}
toolbarButton(
"Refresh",
icon: "arrow.clockwise"
) {
model.loadDatabases()
}
toolbarButton(
"Execute",
icon: "bolt.fill"
) {
model.runSQL()
}
.keyboardShortcut(.return, modifiers: [.command])
toolbarButton(
"Format SQL",
icon: "text.alignleft"
) {
model.formatSQL()
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(model.selected?.name ?? "No Connection")
.font(.system(size: 12, weight: .semibold))
Text(model.selectedDatabase.isEmpty
? "No default schema"
: model.selectedDatabase)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.frame(height: 46)
.background(WB.toolbar)
}
private func toolbarButton(
_ title: String,
icon: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 19))
.frame(height: 23)
Text(title)
.font(.system(size: 10))
}
.foregroundStyle(.black)
.frame(minWidth: 65)
}
.buttonStyle(.plain)
.help(title)
}
// MARK: - Navigator
private var navigator: some View {
VStack(spacing: 0) {
sectionHeader("NAVIGATOR")
HStack {
Text("SCHEMAS")
.font(.system(size: 11, weight: .bold))
Spacer()
Button {
model.loadDatabases()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.frame(height: 34)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(model.databases, id: \.self) { db in
schemaNode(db)
}
}
.padding(.vertical, 8)
}
Divider()
sectionHeader("MYSQL CONNECTIONS")
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.profiles) { profile in
Button {
model.selectedID = profile.id
model.loadDatabases()
} label: {
Label(
profile.name,
systemImage: "server.rack"
)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(6)
.background(
model.selectedID == profile.id
? Color.blue.opacity(0.14)
: Color.clear
)
}
.buttonStyle(.plain)
.contextMenu {
Button("Edit Connection") {
edit(profile)
}
}
}
}
.padding(8)
}
.frame(height: 125)
}
.background(WB.sidebar)
}
// MARK: - Compact Schema Tree
private func schemaNode(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 5) {
Image(systemName:
expandedSchemas.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.frame(width: 13)
Image(systemName: "cylinder.split.1x2")
.foregroundStyle(.blue)
.frame(width: 17)
Text(db)
.font(.system(size: 12, weight:
model.selectedDatabase == db
? .semibold : .regular))
.lineLimit(1)
Spacer(minLength: 0)
}
.frame(height: 25)
.padding(.leading, 10)
.contentShape(Rectangle())
.onTapGesture {
toggleSchema(db)
}
.onTapGesture(count: 2) {
model.selectedDatabase = db
expandedSchemas.insert(db)
expandedTableFolders.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
.contextMenu {
Button("Set as Default Schema") {
model.selectedDatabase = db
}
Button("Refresh Tables") {
model.loadTables(database: db)
}
}
if expandedSchemas.contains(db) {
schemaChildren(db)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func toggleSchema(_ db: String) {
if expandedSchemas.contains(db) {
expandedSchemas.remove(db)
} else {
expandedSchemas.insert(db)
expandedTableFolders.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
private func schemaChildren(_ db: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Tables folder
HStack(spacing: 5) {
Image(systemName:
expandedTableFolders.contains(db)
? "chevron.down"
: "chevron.right"
)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.frame(width: 13)
Image(systemName: "folder")
.foregroundStyle(.secondary)
.frame(width: 17)
Text("Tables")
.font(.system(size: 12))
Spacer(minLength: 0)
}
.frame(height: 24)
.padding(.leading, 28)
.contentShape(Rectangle())
.onTapGesture {
if expandedTableFolders.contains(db) {
expandedTableFolders.remove(db)
} else {
expandedTableFolders.insert(db)
if model.schemaTables[db] == nil {
model.loadTables(database: db)
}
}
}
// Tables
if expandedTableFolders.contains(db) {
if let tables = model.schemaTables[db] {
ForEach(tables, id: \.self) { table in
tableNode(database: db, table: table)
}
} else {
Button("Load Tables") {
model.loadTables(database: db)
}
.font(.system(size: 11))
.buttonStyle(.borderless)
.padding(.leading, 66)
.frame(height: 24)
}
}
folderNode("Views", indent: 28)
folderNode("Stored Procedures", indent: 28)
folderNode("Functions", indent: 28)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func tableNode(database: String, table: String) -> some View {
let identifier = database + "." + table
let isSelected = selectedTableName == identifier
return HStack(spacing: 5) {
Image(systemName: "tablecells")
.font(.system(size: 11))
.foregroundStyle(
isSelected ? Color.blue : Color.gray
)
.frame(width: 17)
Text(table)
.font(.system(size: 12))
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.frame(height: 23)
.padding(.leading, 49)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
isSelected
? Color.blue.opacity(0.14)
: Color.clear
)
.contentShape(Rectangle())
.onTapGesture {
selectedTableName = identifier
}
.onTapGesture(count: 2) {
selectedTableName = identifier
model.selectTable(
database: database,
table: table
)
}
.contextMenu {
Button("Select First 100 Rows") {
selectedTableName = identifier
model.selectTable(
database: database,
table: table
)
}
}
}
private func folderNode(
_ title: String,
indent: CGFloat
) -> some View {
HStack(spacing: 5) {
Image(systemName: "chevron.right")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.frame(width: 13)
Image(systemName: "folder")
.foregroundStyle(.secondary)
.frame(width: 17)
Text(title)
.font(.system(size: 12))
Spacer(minLength: 0)
}
.frame(height: 24)
.padding(.leading, indent)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Workspace
private var workspace: some View {
VStack(spacing: 0) {
queryTabBar
Divider()
queryToolbar
Divider()
VSplitView {
sqlEditor
.frame(minHeight: 230)
bottomWorkspace
.frame(minHeight: 230)
}
}
}
private var queryTabBar: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.foregroundStyle(.blue)
Text("Query 1")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "xmark")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.frame(height: 29)
.background(Color(nsColor: .textBackgroundColor))
Spacer()
}
.background(WB.header)
}
private var queryToolbar: some View {
HStack(spacing: 14) {
Button {
model.runSQL()
} label: {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
}
.help("Execute SQL")
Button {
model.formatSQL()
} label: {
Image(systemName: "text.alignleft")
}
.help("Format SQL")
Divider()
.frame(height: 17)
Text("Default Schema:")
.font(.system(size: 11))
Text(model.selectedDatabase.isEmpty
? "None"
: model.selectedDatabase)
.font(.system(size: 11, weight: .semibold))
Spacer()
Text("SQL Editor")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private var sqlEditor: some View {
HStack(spacing: 0) {
if UserDefaults.standard.object(
forKey: "preferences.showLineNumbers"
) as? Bool ?? true {
lineNumbers
Rectangle()
.fill(WB.border)
.frame(width: 1)
}
Rectangle()
.fill(WB.border)
.frame(width: 1)
SQLTextView(text: $model.sql)
}
.background(Color(nsColor: .textBackgroundColor))
}
private var lineNumbers: some View {
ScrollView {
VStack(alignment: .trailing, spacing: 0) {
ForEach(
1...max(1, model.sql.components(
separatedBy: "\n"
).count),
id: \.self
) { number in
Text("\(number)")
.frame(height: 19)
}
}
.padding(.horizontal, 9)
.padding(.top, 8)
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 48)
.background(WB.header)
.allowsHitTesting(false)
}
// MARK: - Results and Action Output
private var bottomWorkspace: some View {
VSplitView {
resultsPanel
.frame(minHeight: 230)
if outputExpanded {
actionOutput
.frame(minHeight: 90)
}
}
}
private var resultsPanel: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "tablecells")
Text("Result Grid")
.fontWeight(.semibold)
Spacer()
Text(model.result.message)
.foregroundStyle(.secondary)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 28)
.background(WB.header)
Divider()
if model.result.columns.isEmpty {
VStack {
Spacer()
Text("No query results")
.foregroundStyle(.secondary)
Spacer()
}
} else {
resultGrid
}
}
}
private var resultGrid: some View {
NativeResultGrid(
columns: model.result.columns,
rows: model.result.rows
)
}
private var actionOutput: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "list.bullet.rectangle")
Text("Action Output")
.fontWeight(.semibold)
Spacer()
Button {
outputExpanded = false
} label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(height: 26)
.background(WB.header)
Divider()
HStack(alignment: .top, spacing: 12) {
Image(systemName:
model.status == "Error"
? "xmark.circle.fill"
: "checkmark.circle.fill"
)
.foregroundStyle(
model.status == "Error"
? .red : .green
)
VStack(alignment: .leading, spacing: 5) {
Text(model.status)
.fontWeight(.semibold)
Text(model.result.message)
.foregroundStyle(.secondary)
}
Spacer()
}
.font(.system(size: 12))
.padding(12)
Spacer()
}
}
// MARK: - Status Bar
private var statusBar: some View {
HStack(spacing: 10) {
Circle()
.fill(
model.status == "Error"
? Color.red : Color.green
)
.frame(width: 8, height: 8)
Text(model.status)
.lineLimit(1)
Spacer()
Text(model.selected?.name ?? "Disconnected")
Divider()
.frame(height: 13)
Text(model.selectedDatabase.isEmpty
? "No Schema"
: model.selectedDatabase)
Divider()
.frame(height: 13)
Text("UTF-8")
}
.font(.system(size: 11))
.padding(.horizontal, 12)
.frame(height: 22)
.background(WB.header)
}
private func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(WB.header)
}
private func edit(_ profile: ConnectionProfile) {
editing = profile
editingPassword = PasswordStore.read(profile.id)
showingEditor = true
}
}
// MARK: - Native SQL Text Editor
struct SQLTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
let editor = scroll.documentView as! NSTextView
scroll.appearance = NSAppearance(named: .aqua)
editor.appearance = NSAppearance(named: .aqua)
editor.backgroundColor = .white
editor.textColor = .black
scroll.backgroundColor = .white
editor.isEditable = true
editor.isRichText = false
editor.allowsUndo = true
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.font = NSFont.monospacedSystemFont(
ofSize: CGFloat(
UserDefaults.standard.object(
forKey: "preferences.editorFontSize"
) as? Double ?? 13
),
weight: .regular
)
editor.textContainerInset = NSSize(
width: 10,
height: 8
)
editor.delegate = context.coordinator
editor.string = text
context.coordinator.highlight(editor)
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let editor = scroll.documentView as? NSTextView else {
return
}
if editor.string != text {
let selection = editor.selectedRange()
editor.string = text
let safeLocation = min(
selection.location,
(text as NSString).length
)
editor.setSelectedRange(
NSRange(location: safeLocation, length: 0)
)
context.coordinator.highlight(editor)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: SQLTextView
init(_ parent: SQLTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let editor = notification.object as? NSTextView else {
return
}
parent.text = editor.string
highlight(editor)
}
func highlight(_ editor: NSTextView) {
guard let storage = editor.textStorage else {
return
}
let text = editor.string as NSString
let range = NSRange(
location: 0,
length: text.length
)
storage.beginEditing()
storage.setAttributes([
.foregroundColor: NSColor.textColor,
.font: NSFont.monospacedSystemFont(
ofSize: CGFloat(
UserDefaults.standard.object(
forKey: "preferences.editorFontSize"
) as? Double ?? 13
),
weight: .regular
)
], range: range)
let pattern = """
\\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|TABLE|DATABASE|SHOW|USE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|ORDER|GROUP|BY|LIMIT|VALUES|SET|NULL|NOT|IN|LIKE|DISTINCT|ALTER|DROP|DESCRIBE|EXPLAIN)\\b
"""
if let regex = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) {
for match in regex.matches(
in: editor.string,
range: range
) {
storage.addAttribute(
.foregroundColor,
value: NSColor.systemBlue,
range: match.range
)
}
}
storage.endEditing()
}
}
}
// MARK: - Connection Editor
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Connection Settings")
.font(.title2.bold())
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField(
"Port",
value: $profile.port,
format: .number
)
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField(
"Default Schema",
text: $profile.database
)
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
}
}
// MARK: - High Performance Native Result Grid
struct NativeResultGrid: NSViewRepresentable {
let columns: [String]
let rows: [[String]]
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = true
scroll.backgroundColor = .white
let table = NSTableView()
table.headerView = NSTableHeaderView()
table.usesAlternatingRowBackgroundColors =
UserDefaults.standard.object(
forKey: "preferences.alternatingRows"
) as? Bool ?? true
table.rowHeight = CGFloat(
UserDefaults.standard.object(
forKey: "preferences.resultRowHeight"
) as? Double ?? 24
)
table.intercellSpacing = NSSize(width: 1, height: 1)
table.allowsMultipleSelection = true
table.allowsColumnResizing = true
table.allowsColumnReordering = true
table.columnAutoresizingStyle = .noColumnAutoresizing
table.backgroundColor = .white
context.coordinator.columns = columns
context.coordinator.rows = rows
table.delegate = context.coordinator
table.dataSource = context.coordinator
configureColumns(table)
scroll.documentView = table
table.reloadData()
return scroll
}
func updateNSView(
_ scroll: NSScrollView,
context: Context
) {
guard let table = scroll.documentView as? NSTableView else {
return
}
let oldColumns = context.coordinator.columns
context.coordinator.columns = columns
context.coordinator.rows = rows
if oldColumns != columns {
configureColumns(table)
}
table.reloadData()
}
private func configureColumns(_ table: NSTableView) {
for column in Array(table.tableColumns) {
table.removeTableColumn(column)
}
for (index, name) in columns.enumerated() {
let identifier = NSUserInterfaceItemIdentifier(
"column_\(index)"
)
let column = NSTableColumn(identifier: identifier)
column.title = name
column.width = CGFloat(
UserDefaults.standard.object(
forKey: "preferences.resultColumnWidth"
) as? Double ?? 170
)
column.minWidth = 70
column.maxWidth = 700
column.resizingMask = .userResizingMask
table.addTableColumn(column)
}
}
final class Coordinator: NSObject,
NSTableViewDataSource,
NSTableViewDelegate {
var columns: [String] = []
var rows: [[String]] = []
func numberOfRows(in tableView: NSTableView) -> Int {
rows.count
}
func tableView(
_ tableView: NSTableView,
viewFor tableColumn: NSTableColumn?,
row: Int
) -> NSView? {
guard let tableColumn,
row >= 0,
row < rows.count else {
return nil
}
let identifier = tableColumn.identifier.rawValue
guard identifier.hasPrefix("column_"),
let columnIndex = Int(identifier.dropFirst(7)),
columnIndex < columns.count else {
return nil
}
let cellIdentifier =
NSUserInterfaceItemIdentifier("ResultCell")
let cell: NSTableCellView
if let reused = tableView.makeView(
withIdentifier: cellIdentifier,
owner: nil
) as? NSTableCellView {
cell = reused
} else {
cell = NSTableCellView()
cell.identifier = cellIdentifier
let field = NSTextField(labelWithString: "")
field.font = NSFont.monospacedSystemFont(
ofSize: 12,
weight: .regular
)
field.lineBreakMode = .byTruncatingTail
field.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(field)
cell.textField = field
NSLayoutConstraint.activate([
field.leadingAnchor.constraint(
equalTo: cell.leadingAnchor,
constant: 7
),
field.trailingAnchor.constraint(
equalTo: cell.trailingAnchor,
constant: -7
),
field.centerYAnchor.constraint(
equalTo: cell.centerYAnchor
)
])
}
let values = rows[row]
cell.textField?.stringValue =
columnIndex < values.count
? values[columnIndex]
: ""
return cell
}
}
}