490 lines
13 KiB
Swift
490 lines
13 KiB
Swift
|
|
import SwiftUI
|
|
|
|
struct MainView: View {
|
|
|
|
@EnvironmentObject private var model: DatabaseModel
|
|
|
|
@State private var showingEditor = false
|
|
@State private var editing = ConnectionProfile()
|
|
@State private var editingPassword = ""
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
navigator
|
|
.navigationSplitViewColumnWidth(
|
|
min: 220,
|
|
ideal: 270,
|
|
max: 400
|
|
)
|
|
} detail: {
|
|
editorPanel
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
.frame(minWidth: 1000, minHeight: 650)
|
|
.sheet(isPresented: $showingEditor) {
|
|
ConnectionEditor(
|
|
profile: $editing,
|
|
password: $editingPassword
|
|
) {
|
|
model.save(editing, password: editingPassword)
|
|
showingEditor = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigator
|
|
|
|
private var navigator: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("NAVIGATOR")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
model.loadDatabases()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("Refresh schemas")
|
|
}
|
|
.padding(12)
|
|
|
|
Divider()
|
|
|
|
List(selection: $model.selectedID) {
|
|
|
|
Section("MYSQL CONNECTIONS") {
|
|
ForEach(model.profiles) { profile in
|
|
Label(
|
|
profile.name,
|
|
systemImage: "server.rack"
|
|
)
|
|
.tag(profile.id)
|
|
.contextMenu {
|
|
Button("Edit Connection") {
|
|
edit(profile)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("SCHEMAS") {
|
|
ForEach(model.databases, id: \.self) { db in
|
|
DisclosureGroup {
|
|
schemaContents(db)
|
|
} label: {
|
|
Label(
|
|
db,
|
|
systemImage: "cylinder.split.1x2"
|
|
)
|
|
.fontWeight(
|
|
model.selectedDatabase == db
|
|
? .semibold : .regular
|
|
)
|
|
.contextMenu {
|
|
Button("Set as Default Schema") {
|
|
model.selectedDatabase = db
|
|
}
|
|
|
|
Button("Refresh Tables") {
|
|
model.loadTables(database: db)
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
model.selectedDatabase = db
|
|
if model.schemaTables[db] == nil {
|
|
model.loadTables(database: db)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.sidebar)
|
|
|
|
Divider()
|
|
|
|
HStack {
|
|
Button {
|
|
editing = ConnectionProfile(
|
|
name: "New Connection"
|
|
)
|
|
editingPassword = ""
|
|
showingEditor = true
|
|
} label: {
|
|
Label(
|
|
"New Connection",
|
|
systemImage: "plus"
|
|
)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(10)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func schemaContents(_ database: String) -> some View {
|
|
if let tables = model.schemaTables[database] {
|
|
if tables.isEmpty {
|
|
Text("No tables")
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(tables, id: \.self) { table in
|
|
Label(table, systemImage: "tablecells")
|
|
.font(.system(size: 12))
|
|
.onTapGesture(count: 2) {
|
|
model.selectTable(
|
|
database: database,
|
|
table: table
|
|
)
|
|
}
|
|
.contextMenu {
|
|
Button("Select First 100 Rows") {
|
|
model.selectTable(
|
|
database: database,
|
|
table: table
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Button("Load Tables") {
|
|
model.loadTables(database: database)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
|
|
// MARK: - SQL Editor
|
|
|
|
private var editorPanel: some View {
|
|
VStack(spacing: 0) {
|
|
editorToolbar
|
|
|
|
Divider()
|
|
|
|
VSplitView {
|
|
sqlEditor
|
|
.frame(minHeight: 180)
|
|
|
|
resultsPanel
|
|
.frame(minHeight: 180)
|
|
}
|
|
|
|
Divider()
|
|
|
|
statusBar
|
|
}
|
|
.navigationTitle("WorkbenchReplacement")
|
|
}
|
|
|
|
private var editorToolbar: some View {
|
|
HStack(spacing: 14) {
|
|
|
|
Label(
|
|
"SQL Editor",
|
|
systemImage:
|
|
"chevron.left.forwardslash.chevron.right"
|
|
)
|
|
.font(.headline)
|
|
|
|
Divider()
|
|
.frame(height: 20)
|
|
|
|
Button {
|
|
model.runSQL()
|
|
} label: {
|
|
Label(
|
|
"Execute",
|
|
systemImage: "bolt.fill"
|
|
)
|
|
}
|
|
.keyboardShortcut(
|
|
.return,
|
|
modifiers: [.command]
|
|
)
|
|
.disabled(model.isRunning)
|
|
|
|
Button {
|
|
model.formatSQL()
|
|
} label: {
|
|
Label(
|
|
"Format SQL",
|
|
systemImage: "text.alignleft"
|
|
)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if !model.selectedDatabase.isEmpty {
|
|
Label(
|
|
model.selectedDatabase,
|
|
systemImage: "cylinder"
|
|
)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Button {
|
|
model.loadDatabases()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.help("Refresh schemas")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.padding(12)
|
|
}
|
|
|
|
private var sqlEditor: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "doc.text")
|
|
Text("Query 1")
|
|
.fontWeight(.medium)
|
|
|
|
Spacer()
|
|
|
|
Text("MySQL")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.font(.system(size: 12))
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
|
|
Divider()
|
|
|
|
TextEditor(text: $model.sql)
|
|
.font(
|
|
.system(
|
|
size: 14,
|
|
design: .monospaced
|
|
)
|
|
)
|
|
.scrollContentBackground(.hidden)
|
|
.padding(12)
|
|
.background(
|
|
Color(nsColor: .textBackgroundColor)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Results
|
|
|
|
private var resultsPanel: some View {
|
|
VStack(spacing: 0) {
|
|
|
|
HStack {
|
|
Label(
|
|
"Result Grid",
|
|
systemImage: "tablecells"
|
|
)
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Text(model.result.message)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(12)
|
|
|
|
Divider()
|
|
|
|
if model.result.columns.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Results",
|
|
systemImage: "tablecells",
|
|
description: Text(
|
|
"Execute a SQL query to display results."
|
|
)
|
|
)
|
|
} else {
|
|
resultsGrid
|
|
}
|
|
}
|
|
}
|
|
|
|
private var resultsGrid: some View {
|
|
ScrollView([.horizontal, .vertical]) {
|
|
Grid(
|
|
alignment: .leading,
|
|
horizontalSpacing: 0,
|
|
verticalSpacing: 0
|
|
) {
|
|
GridRow {
|
|
ForEach(
|
|
model.result.columns.indices,
|
|
id: \.self
|
|
) { index in
|
|
Text(model.result.columns[index])
|
|
.fontWeight(.semibold)
|
|
.frame(
|
|
minWidth: 150,
|
|
alignment: .leading
|
|
)
|
|
.padding(8)
|
|
.background(
|
|
Color(nsColor:
|
|
.controlBackgroundColor)
|
|
)
|
|
}
|
|
}
|
|
|
|
ForEach(
|
|
model.result.rows.indices,
|
|
id: \.self
|
|
) { rowIndex in
|
|
resultRow(
|
|
model.result.rows[rowIndex],
|
|
index: rowIndex
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func resultRow(
|
|
_ row: [String],
|
|
index: Int
|
|
) -> some View {
|
|
GridRow {
|
|
ForEach(
|
|
model.result.columns.indices,
|
|
id: \.self
|
|
) { columnIndex in
|
|
Text(
|
|
columnIndex < row.count
|
|
? row[columnIndex] : ""
|
|
)
|
|
.font(
|
|
.system(
|
|
size: 12,
|
|
design: .monospaced
|
|
)
|
|
)
|
|
.frame(
|
|
minWidth: 150,
|
|
alignment: .leading
|
|
)
|
|
.padding(8)
|
|
.background(
|
|
index.isMultiple(of: 2)
|
|
? Color.clear
|
|
: Color(nsColor:
|
|
.alternatingContentBackgroundColors[0])
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Status
|
|
|
|
private var statusBar: some View {
|
|
HStack {
|
|
Circle()
|
|
.fill(
|
|
model.status == "Error"
|
|
? Color.red : Color.green
|
|
)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Text(model.status)
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
|
|
Text(model.selected?.name ?? "No connection")
|
|
}
|
|
.font(.caption)
|
|
.padding(9)
|
|
}
|
|
|
|
private func edit(_ profile: ConnectionProfile) {
|
|
editing = profile
|
|
editingPassword = PasswordStore.read(profile.id)
|
|
showingEditor = true
|
|
}
|
|
}
|
|
|
|
// 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 (stored in macOS Keychain)",
|
|
text: $password
|
|
)
|
|
|
|
TextField(
|
|
"Default Schema",
|
|
text: $profile.database
|
|
)
|
|
}
|
|
|
|
Text(
|
|
"Native MySQL connection. Local TCP connections and SSH tunnels are supported."
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack {
|
|
Spacer()
|
|
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
|
|
Button("Save") {
|
|
save()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(width: 520)
|
|
}
|
|
}
|