updates
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
|
||||
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 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: 1050, minHeight: 700)
|
||||
.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, 14)
|
||||
.frame(height: 58)
|
||||
.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)
|
||||
}
|
||||
|
||||
private func schemaNode(_ db: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 5) {
|
||||
Button {
|
||||
toggleSchema(db)
|
||||
} label: {
|
||||
Image(systemName:
|
||||
expandedSchemas.contains(db)
|
||||
? "chevron.down"
|
||||
: "chevron.right"
|
||||
)
|
||||
.font(.system(size: 10))
|
||||
.frame(width: 15)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Image(systemName: "cylinder.split.1x2")
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text(db)
|
||||
.font(.system(size: 12))
|
||||
.fontWeight(
|
||||
model.selectedDatabase == db
|
||||
? .bold : .regular
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
model.selectedDatabase == db
|
||||
? Color.blue.opacity(0.13)
|
||||
: Color.clear
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
model.selectedDatabase = db
|
||||
expandedSchemas.insert(db)
|
||||
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)
|
||||
.padding(.leading, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSchema(_ db: String) {
|
||||
if expandedSchemas.contains(db) {
|
||||
expandedSchemas.remove(db)
|
||||
} else {
|
||||
expandedSchemas.insert(db)
|
||||
|
||||
if model.schemaTables[db] == nil {
|
||||
model.loadTables(database: db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func schemaChildren(_ db: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
DisclosureGroup {
|
||||
if let tables = model.schemaTables[db] {
|
||||
ForEach(tables, id: \.self) { table in
|
||||
Label(table, systemImage: "tablecells")
|
||||
.font(.system(size: 12))
|
||||
.padding(.vertical, 3)
|
||||
.onTapGesture(count: 2) {
|
||||
model.selectTable(
|
||||
database: db,
|
||||
table: table
|
||||
)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Select First 100 Rows") {
|
||||
model.selectTable(
|
||||
database: db,
|
||||
table: table
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Load Tables") {
|
||||
model.loadTables(database: db)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
} label: {
|
||||
Label("Tables", systemImage: "folder")
|
||||
}
|
||||
|
||||
Label("Views", systemImage: "folder")
|
||||
Label("Stored Procedures", systemImage: "folder")
|
||||
Label("Functions", systemImage: "folder")
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
|
||||
// MARK: - Workspace
|
||||
|
||||
private var workspace: some View {
|
||||
VStack(spacing: 0) {
|
||||
queryTabBar
|
||||
|
||||
Divider()
|
||||
|
||||
queryToolbar
|
||||
|
||||
Divider()
|
||||
|
||||
VSplitView {
|
||||
sqlEditor
|
||||
.frame(minHeight: 180)
|
||||
|
||||
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, 15)
|
||||
.frame(height: 33)
|
||||
.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: 36)
|
||||
.background(WB.header)
|
||||
}
|
||||
|
||||
private var sqlEditor: some View {
|
||||
HStack(spacing: 0) {
|
||||
lineNumbers
|
||||
|
||||
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: 150)
|
||||
|
||||
if outputExpanded {
|
||||
actionOutput
|
||||
.frame(minHeight: 85)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: 32)
|
||||
.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 {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
Grid(
|
||||
alignment: .leading,
|
||||
horizontalSpacing: 0,
|
||||
verticalSpacing: 0
|
||||
) {
|
||||
GridRow {
|
||||
ForEach(
|
||||
model.result.columns.indices,
|
||||
id: \.self
|
||||
) { column in
|
||||
Text(model.result.columns[column])
|
||||
.fontWeight(.semibold)
|
||||
.frame(
|
||||
width: 170,
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(7)
|
||||
.background(WB.header)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(
|
||||
model.result.rows.indices,
|
||||
id: \.self
|
||||
) { row in
|
||||
GridRow {
|
||||
ForEach(
|
||||
model.result.columns.indices,
|
||||
id: \.self
|
||||
) { column in
|
||||
Text(
|
||||
column < model.result.rows[row].count
|
||||
? model.result.rows[row][column]
|
||||
: ""
|
||||
)
|
||||
.lineLimit(1)
|
||||
.frame(
|
||||
width: 170,
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(7)
|
||||
.background(
|
||||
row.isMultiple(of: 2)
|
||||
? Color(nsColor: .textBackgroundColor)
|
||||
: WB.sidebar
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
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: 30)
|
||||
.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: 25)
|
||||
.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
|
||||
|
||||
editor.isEditable = true
|
||||
editor.isRichText = false
|
||||
editor.allowsUndo = true
|
||||
editor.isAutomaticQuoteSubstitutionEnabled = false
|
||||
editor.isAutomaticDashSubstitutionEnabled = false
|
||||
editor.font = NSFont.monospacedSystemFont(
|
||||
ofSize: 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: 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user