updates
This commit is contained in:
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -13,5 +13,287 @@ struct WorkbenchReplacementApp: App {
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
|
||||
|
||||
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 = "light"
|
||||
|
||||
@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
|
||||
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
generalSettings
|
||||
.tabItem {
|
||||
Label(
|
||||
"General",
|
||||
systemImage: "gearshape"
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: 620, height: 390)
|
||||
.preferredColorScheme(.light)
|
||||
}
|
||||
|
||||
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, 190)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Text("Examples")
|
||||
.font(.headline)
|
||||
|
||||
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("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: .trailing)
|
||||
|
||||
content()
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: 30)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct WorkbenchReplacementApp: App {
|
||||
@StateObject private var model = DatabaseModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Workbench Replacement") {
|
||||
MainView()
|
||||
.environmentObject(model)
|
||||
.frame(minWidth: 1000, minHeight: 650)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct WorkbenchReplacementApp: App {
|
||||
@StateObject private var model = DatabaseModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Workbench Replacement") {
|
||||
MainView()
|
||||
.environmentObject(model)
|
||||
.frame(minWidth: 1000, minHeight: 650)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
|
||||
|
||||
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 = "light"
|
||||
|
||||
@AppStorage("preferences.autoExpandTables")
|
||||
var autoExpandTables = true
|
||||
}
|
||||
|
||||
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.resultRowHeight")
|
||||
private var resultRowHeight = 24.0
|
||||
|
||||
@AppStorage("preferences.resultColumnWidth")
|
||||
private var resultColumnWidth = 170.0
|
||||
|
||||
@AppStorage("preferences.alternatingRows")
|
||||
private var alternatingRows = true
|
||||
|
||||
@AppStorage("preferences.appearance")
|
||||
private var appearance = "light"
|
||||
|
||||
@AppStorage("preferences.autoExpandTables")
|
||||
private var autoExpandTables = true
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Form {
|
||||
HStack {
|
||||
Text("Editor Font Size")
|
||||
Spacer()
|
||||
Stepper(
|
||||
"\(Int(editorFontSize)) pt",
|
||||
value: $editorFontSize,
|
||||
in: 10...24,
|
||||
step: 1
|
||||
)
|
||||
.frame(width: 130)
|
||||
}
|
||||
|
||||
Toggle(
|
||||
"Automatically uppercase SQL keywords",
|
||||
isOn: $uppercaseKeywords
|
||||
)
|
||||
|
||||
Toggle(
|
||||
"Show line numbers",
|
||||
isOn: $showLineNumbers
|
||||
)
|
||||
|
||||
Picker("Tab Width", selection: $tabWidth) {
|
||||
Text("2 spaces").tag(2)
|
||||
Text("4 spaces").tag(4)
|
||||
Text("8 spaces").tag(8)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.tabItem {
|
||||
Label("SQL Editor", systemImage: "chevron.left.forwardslash.chevron.right")
|
||||
}
|
||||
|
||||
Form {
|
||||
HStack {
|
||||
Text("Result Row Height")
|
||||
Spacer()
|
||||
Stepper(
|
||||
"\(Int(resultRowHeight)) px",
|
||||
value: $resultRowHeight,
|
||||
in: 18...40,
|
||||
step: 1
|
||||
)
|
||||
.frame(width: 130)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Default Column Width")
|
||||
Spacer()
|
||||
Stepper(
|
||||
"\(Int(resultColumnWidth)) px",
|
||||
value: $resultColumnWidth,
|
||||
in: 80...400,
|
||||
step: 10
|
||||
)
|
||||
.frame(width: 140)
|
||||
}
|
||||
|
||||
Toggle(
|
||||
"Alternating row colours",
|
||||
isOn: $alternatingRows
|
||||
)
|
||||
}
|
||||
.padding(20)
|
||||
.tabItem {
|
||||
Label("Result Grid", systemImage: "tablecells")
|
||||
}
|
||||
|
||||
Form {
|
||||
Picker("Appearance", selection: $appearance) {
|
||||
Text("Light").tag("light")
|
||||
Text("Dark").tag("dark")
|
||||
Text("System").tag("system")
|
||||
}
|
||||
|
||||
Toggle(
|
||||
"Automatically expand table folders",
|
||||
isOn: $autoExpandTables
|
||||
)
|
||||
}
|
||||
.padding(20)
|
||||
.tabItem {
|
||||
Label("General", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
.frame(width: 560, height: 330)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ final class DatabaseModel: ObservableObject {
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var schemaViews: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
@@ -74,40 +75,92 @@ final class DatabaseModel: ObservableObject {
|
||||
sql = SQLFormatter.uppercaseKeywords(sql)
|
||||
}
|
||||
|
||||
|
||||
func loadTables(database: String) {
|
||||
guard let profile = selected else { return }
|
||||
guard let profile = selected else {
|
||||
status = "Select a MySQL connection"
|
||||
return
|
||||
}
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(of: "`", with: "``")
|
||||
let escaped = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
status = "Loading tables from \(database)…"
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: "",
|
||||
database: database,
|
||||
password: password,
|
||||
sql: "SHOW TABLES FROM `\\(escaped)`"
|
||||
sql: "SHOW FULL TABLES FROM `\(escaped)`"
|
||||
)
|
||||
|
||||
schemaTables[database] = parsed.rows.compactMap { $0.first }
|
||||
var tables: [String] = []
|
||||
var views: [String] = []
|
||||
|
||||
for row in parsed.rows {
|
||||
guard let name = row.first else {
|
||||
continue
|
||||
}
|
||||
|
||||
if row.count > 1 && row[1] == "VIEW" {
|
||||
views.append(name)
|
||||
} else {
|
||||
tables.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
schemaTables[database] = tables
|
||||
schemaViews[database] = views
|
||||
|
||||
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
|
||||
} catch {
|
||||
status = "Unable to load tables: \\(error.localizedDescription)"
|
||||
status = "Unable to load tables: \(error.localizedDescription)"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(of: "`", with: "``")
|
||||
let tbl = table.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
let tbl = table.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
selectedDatabase = database
|
||||
|
||||
let configuredLimit = UserDefaults.standard.integer(
|
||||
forKey: "preferences.defaultRowLimit"
|
||||
)
|
||||
|
||||
let rowLimit = configuredLimit > 0
|
||||
? min(configuredLimit, 100000)
|
||||
: 500
|
||||
|
||||
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT \(rowLimit);"
|
||||
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
formatSQL()
|
||||
if UserDefaults.standard.object(
|
||||
forKey: "preferences.uppercaseKeywords"
|
||||
) as? Bool ?? true {
|
||||
formatSQL()
|
||||
}
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name = "Local MySQL"
|
||||
var host = "127.0.0.1"
|
||||
var port = 3306
|
||||
var username = "root"
|
||||
var database = ""
|
||||
}
|
||||
struct QueryResult {
|
||||
var columns: [String] = []
|
||||
var rows: [[String]] = []
|
||||
var message = ""
|
||||
}
|
||||
|
||||
enum PasswordStore {
|
||||
static func read(_ id: UUID) -> String {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
||||
var value: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||
let data = value as? Data else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
static func save(_ password: String, id: UUID) {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
guard !password.isEmpty else { return }
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = Data(password.utf8)
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
func save(_ profile: ConnectionProfile, password: String) {
|
||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||
else { profiles.append(profile) }
|
||||
PasswordStore.save(password, id: profile.id)
|
||||
selectedID = profile.id
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||
}
|
||||
|
||||
func formatSQL() {
|
||||
sql = SQLFormatter.uppercaseKeywords(sql)
|
||||
}
|
||||
|
||||
func loadTables(database: String) {
|
||||
guard let profile = selected else { return }
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: "",
|
||||
password: password,
|
||||
sql: "SHOW TABLES FROM `\\(escaped)`"
|
||||
)
|
||||
|
||||
schemaTables[database] = parsed.rows.compactMap { $0.first }
|
||||
} catch {
|
||||
status = "Unable to load tables: \\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(of: "`", with: "``")
|
||||
let tbl = table.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
formatSQL()
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SQL Keyword Formatter
|
||||
|
||||
enum SQLFormatter {
|
||||
|
||||
private static let keywords: Set<String> = [
|
||||
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
|
||||
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
|
||||
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
|
||||
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
|
||||
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
|
||||
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
|
||||
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
|
||||
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
|
||||
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
|
||||
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
|
||||
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
|
||||
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
|
||||
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
|
||||
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
|
||||
]
|
||||
|
||||
static func uppercaseKeywords(_ sql: String) -> String {
|
||||
let chars = Array(sql)
|
||||
var output = ""
|
||||
var index = 0
|
||||
|
||||
enum State {
|
||||
case normal
|
||||
case singleQuote
|
||||
case doubleQuote
|
||||
case backtick
|
||||
case lineComment
|
||||
case blockComment
|
||||
}
|
||||
|
||||
var state = State.normal
|
||||
|
||||
while index < chars.count {
|
||||
let ch = chars[index]
|
||||
let next: Character? = index + 1 < chars.count
|
||||
? chars[index + 1] : nil
|
||||
|
||||
switch state {
|
||||
case .normal:
|
||||
if ch == "'" {
|
||||
state = .singleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
state = .doubleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "`" {
|
||||
state = .backtick
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "-" && next == "-" &&
|
||||
(index + 2 == chars.count ||
|
||||
chars[index + 2].isWhitespace) {
|
||||
state = .lineComment
|
||||
output += "--"
|
||||
index += 2
|
||||
} else if ch == "#" {
|
||||
state = .lineComment
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "/" && next == "*" {
|
||||
state = .blockComment
|
||||
output += "/*"
|
||||
index += 2
|
||||
} else if ch.isLetter || ch == "_" {
|
||||
let start = index
|
||||
while index < chars.count &&
|
||||
(chars[index].isLetter ||
|
||||
chars[index].isNumber ||
|
||||
chars[index] == "_" ||
|
||||
chars[index] == "$") {
|
||||
index += 1
|
||||
}
|
||||
|
||||
let word = String(chars[start..<index])
|
||||
let upper = word.uppercased()
|
||||
|
||||
output += keywords.contains(upper) ? upper : word
|
||||
} else {
|
||||
output.append(ch)
|
||||
index += 1
|
||||
}
|
||||
|
||||
case .singleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "'" {
|
||||
if index < chars.count && chars[index] == "'" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .doubleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
if index < chars.count && chars[index] == "\"" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .backtick:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "`" {
|
||||
if index < chars.count && chars[index] == "`" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .lineComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\n" {
|
||||
state = .normal
|
||||
}
|
||||
|
||||
case .blockComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "*" && index < chars.count &&
|
||||
chars[index] == "/" {
|
||||
output.append("/")
|
||||
index += 1
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name = "Local MySQL"
|
||||
var host = "127.0.0.1"
|
||||
var port = 3306
|
||||
var username = "root"
|
||||
var database = ""
|
||||
}
|
||||
struct QueryResult {
|
||||
var columns: [String] = []
|
||||
var rows: [[String]] = []
|
||||
var message = ""
|
||||
}
|
||||
|
||||
enum PasswordStore {
|
||||
static func read(_ id: UUID) -> String {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
||||
var value: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||
let data = value as? Data else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
static func save(_ password: String, id: UUID) {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
guard !password.isEmpty else { return }
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = Data(password.utf8)
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
func save(_ profile: ConnectionProfile, password: String) {
|
||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||
else { profiles.append(profile) }
|
||||
PasswordStore.save(password, id: profile.id)
|
||||
selectedID = profile.id
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||
}
|
||||
|
||||
func formatSQL() {
|
||||
sql = SQLFormatter.uppercaseKeywords(sql)
|
||||
}
|
||||
|
||||
func loadTables(database: String) {
|
||||
guard let profile = selected else { return }
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: "",
|
||||
password: password,
|
||||
sql: "SHOW TABLES FROM `\\(escaped)`"
|
||||
)
|
||||
|
||||
schemaTables[database] = parsed.rows.compactMap { $0.first }
|
||||
} catch {
|
||||
status = "Unable to load tables: \\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(of: "`", with: "``")
|
||||
let tbl = table.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
formatSQL()
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SQL Keyword Formatter
|
||||
|
||||
enum SQLFormatter {
|
||||
|
||||
private static let keywords: Set<String> = [
|
||||
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
|
||||
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
|
||||
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
|
||||
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
|
||||
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
|
||||
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
|
||||
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
|
||||
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
|
||||
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
|
||||
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
|
||||
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
|
||||
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
|
||||
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
|
||||
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
|
||||
]
|
||||
|
||||
static func uppercaseKeywords(_ sql: String) -> String {
|
||||
let chars = Array(sql)
|
||||
var output = ""
|
||||
var index = 0
|
||||
|
||||
enum State {
|
||||
case normal
|
||||
case singleQuote
|
||||
case doubleQuote
|
||||
case backtick
|
||||
case lineComment
|
||||
case blockComment
|
||||
}
|
||||
|
||||
var state = State.normal
|
||||
|
||||
while index < chars.count {
|
||||
let ch = chars[index]
|
||||
let next: Character? = index + 1 < chars.count
|
||||
? chars[index + 1] : nil
|
||||
|
||||
switch state {
|
||||
case .normal:
|
||||
if ch == "'" {
|
||||
state = .singleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
state = .doubleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "`" {
|
||||
state = .backtick
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "-" && next == "-" &&
|
||||
(index + 2 == chars.count ||
|
||||
chars[index + 2].isWhitespace) {
|
||||
state = .lineComment
|
||||
output += "--"
|
||||
index += 2
|
||||
} else if ch == "#" {
|
||||
state = .lineComment
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "/" && next == "*" {
|
||||
state = .blockComment
|
||||
output += "/*"
|
||||
index += 2
|
||||
} else if ch.isLetter || ch == "_" {
|
||||
let start = index
|
||||
while index < chars.count &&
|
||||
(chars[index].isLetter ||
|
||||
chars[index].isNumber ||
|
||||
chars[index] == "_" ||
|
||||
chars[index] == "$") {
|
||||
index += 1
|
||||
}
|
||||
|
||||
let word = String(chars[start..<index])
|
||||
let upper = word.uppercased()
|
||||
|
||||
output += keywords.contains(upper) ? upper : word
|
||||
} else {
|
||||
output.append(ch)
|
||||
index += 1
|
||||
}
|
||||
|
||||
case .singleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "'" {
|
||||
if index < chars.count && chars[index] == "'" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .doubleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
if index < chars.count && chars[index] == "\"" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .backtick:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "`" {
|
||||
if index < chars.count && chars[index] == "`" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .lineComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\n" {
|
||||
state = .normal
|
||||
}
|
||||
|
||||
case .blockComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "*" && index < chars.count &&
|
||||
chars[index] == "/" {
|
||||
output.append("/")
|
||||
index += 1
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name = "Local MySQL"
|
||||
var host = "127.0.0.1"
|
||||
var port = 3306
|
||||
var username = "root"
|
||||
var database = ""
|
||||
}
|
||||
struct QueryResult {
|
||||
var columns: [String] = []
|
||||
var rows: [[String]] = []
|
||||
var message = ""
|
||||
}
|
||||
|
||||
enum PasswordStore {
|
||||
static func read(_ id: UUID) -> String {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
||||
var value: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||
let data = value as? Data else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
static func save(_ password: String, id: UUID) {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
guard !password.isEmpty else { return }
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = Data(password.utf8)
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var schemaViews: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
func save(_ profile: ConnectionProfile, password: String) {
|
||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||
else { profiles.append(profile) }
|
||||
PasswordStore.save(password, id: profile.id)
|
||||
selectedID = profile.id
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||
}
|
||||
|
||||
func formatSQL() {
|
||||
sql = SQLFormatter.uppercaseKeywords(sql)
|
||||
}
|
||||
|
||||
|
||||
func loadTables(database: String) {
|
||||
guard let profile = selected else {
|
||||
status = "Select a MySQL connection"
|
||||
return
|
||||
}
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
status = "Loading tables from \(database)…"
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: database,
|
||||
password: password,
|
||||
sql: "SHOW FULL TABLES FROM `\(escaped)`"
|
||||
)
|
||||
|
||||
var tables: [String] = []
|
||||
var views: [String] = []
|
||||
|
||||
for row in parsed.rows {
|
||||
guard let name = row.first else {
|
||||
continue
|
||||
}
|
||||
|
||||
if row.count > 1 && row[1] == "VIEW" {
|
||||
views.append(name)
|
||||
} else {
|
||||
tables.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
schemaTables[database] = tables
|
||||
schemaViews[database] = views
|
||||
|
||||
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
|
||||
} catch {
|
||||
status = "Unable to load tables: \(error.localizedDescription)"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(of: "`", with: "``")
|
||||
let tbl = table.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
formatSQL()
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SQL Keyword Formatter
|
||||
|
||||
enum SQLFormatter {
|
||||
|
||||
private static let keywords: Set<String> = [
|
||||
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
|
||||
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
|
||||
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
|
||||
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
|
||||
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
|
||||
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
|
||||
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
|
||||
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
|
||||
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
|
||||
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
|
||||
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
|
||||
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
|
||||
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
|
||||
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
|
||||
]
|
||||
|
||||
static func uppercaseKeywords(_ sql: String) -> String {
|
||||
let chars = Array(sql)
|
||||
var output = ""
|
||||
var index = 0
|
||||
|
||||
enum State {
|
||||
case normal
|
||||
case singleQuote
|
||||
case doubleQuote
|
||||
case backtick
|
||||
case lineComment
|
||||
case blockComment
|
||||
}
|
||||
|
||||
var state = State.normal
|
||||
|
||||
while index < chars.count {
|
||||
let ch = chars[index]
|
||||
let next: Character? = index + 1 < chars.count
|
||||
? chars[index + 1] : nil
|
||||
|
||||
switch state {
|
||||
case .normal:
|
||||
if ch == "'" {
|
||||
state = .singleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
state = .doubleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "`" {
|
||||
state = .backtick
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "-" && next == "-" &&
|
||||
(index + 2 == chars.count ||
|
||||
chars[index + 2].isWhitespace) {
|
||||
state = .lineComment
|
||||
output += "--"
|
||||
index += 2
|
||||
} else if ch == "#" {
|
||||
state = .lineComment
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "/" && next == "*" {
|
||||
state = .blockComment
|
||||
output += "/*"
|
||||
index += 2
|
||||
} else if ch.isLetter || ch == "_" {
|
||||
let start = index
|
||||
while index < chars.count &&
|
||||
(chars[index].isLetter ||
|
||||
chars[index].isNumber ||
|
||||
chars[index] == "_" ||
|
||||
chars[index] == "$") {
|
||||
index += 1
|
||||
}
|
||||
|
||||
let word = String(chars[start..<index])
|
||||
let upper = word.uppercased()
|
||||
|
||||
output += keywords.contains(upper) ? upper : word
|
||||
} else {
|
||||
output.append(ch)
|
||||
index += 1
|
||||
}
|
||||
|
||||
case .singleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "'" {
|
||||
if index < chars.count && chars[index] == "'" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .doubleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
if index < chars.count && chars[index] == "\"" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .backtick:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "`" {
|
||||
if index < chars.count && chars[index] == "`" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .lineComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\n" {
|
||||
state = .normal
|
||||
}
|
||||
|
||||
case .blockComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "*" && index < chars.count &&
|
||||
chars[index] == "/" {
|
||||
output.append("/")
|
||||
index += 1
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name = "Local MySQL"
|
||||
var host = "127.0.0.1"
|
||||
var port = 3306
|
||||
var username = "root"
|
||||
var database = ""
|
||||
}
|
||||
struct QueryResult {
|
||||
var columns: [String] = []
|
||||
var rows: [[String]] = []
|
||||
var message = ""
|
||||
}
|
||||
|
||||
enum PasswordStore {
|
||||
static func read(_ id: UUID) -> String {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
||||
var value: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||
let data = value as? Data else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
static func save(_ password: String, id: UUID) {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
guard !password.isEmpty else { return }
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = Data(password.utf8)
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var schemaViews: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
func save(_ profile: ConnectionProfile, password: String) {
|
||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||
else { profiles.append(profile) }
|
||||
PasswordStore.save(password, id: profile.id)
|
||||
selectedID = profile.id
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||
}
|
||||
|
||||
func formatSQL() {
|
||||
sql = SQLFormatter.uppercaseKeywords(sql)
|
||||
}
|
||||
|
||||
|
||||
func loadTables(database: String) {
|
||||
guard let profile = selected else {
|
||||
status = "Select a MySQL connection"
|
||||
return
|
||||
}
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
status = "Loading tables from \(database)…"
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: database,
|
||||
password: password,
|
||||
sql: "SHOW FULL TABLES FROM `\(escaped)`"
|
||||
)
|
||||
|
||||
var tables: [String] = []
|
||||
var views: [String] = []
|
||||
|
||||
for row in parsed.rows {
|
||||
guard let name = row.first else {
|
||||
continue
|
||||
}
|
||||
|
||||
if row.count > 1 && row[1] == "VIEW" {
|
||||
views.append(name)
|
||||
} else {
|
||||
tables.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
schemaTables[database] = tables
|
||||
schemaViews[database] = views
|
||||
|
||||
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
|
||||
} catch {
|
||||
status = "Unable to load tables: \(error.localizedDescription)"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
let tbl = table.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
selectedDatabase = database
|
||||
|
||||
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT 100;"
|
||||
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
formatSQL()
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SQL Keyword Formatter
|
||||
|
||||
enum SQLFormatter {
|
||||
|
||||
private static let keywords: Set<String> = [
|
||||
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
|
||||
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
|
||||
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
|
||||
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
|
||||
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
|
||||
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
|
||||
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
|
||||
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
|
||||
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
|
||||
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
|
||||
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
|
||||
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
|
||||
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
|
||||
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
|
||||
]
|
||||
|
||||
static func uppercaseKeywords(_ sql: String) -> String {
|
||||
let chars = Array(sql)
|
||||
var output = ""
|
||||
var index = 0
|
||||
|
||||
enum State {
|
||||
case normal
|
||||
case singleQuote
|
||||
case doubleQuote
|
||||
case backtick
|
||||
case lineComment
|
||||
case blockComment
|
||||
}
|
||||
|
||||
var state = State.normal
|
||||
|
||||
while index < chars.count {
|
||||
let ch = chars[index]
|
||||
let next: Character? = index + 1 < chars.count
|
||||
? chars[index + 1] : nil
|
||||
|
||||
switch state {
|
||||
case .normal:
|
||||
if ch == "'" {
|
||||
state = .singleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
state = .doubleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "`" {
|
||||
state = .backtick
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "-" && next == "-" &&
|
||||
(index + 2 == chars.count ||
|
||||
chars[index + 2].isWhitespace) {
|
||||
state = .lineComment
|
||||
output += "--"
|
||||
index += 2
|
||||
} else if ch == "#" {
|
||||
state = .lineComment
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "/" && next == "*" {
|
||||
state = .blockComment
|
||||
output += "/*"
|
||||
index += 2
|
||||
} else if ch.isLetter || ch == "_" {
|
||||
let start = index
|
||||
while index < chars.count &&
|
||||
(chars[index].isLetter ||
|
||||
chars[index].isNumber ||
|
||||
chars[index] == "_" ||
|
||||
chars[index] == "$") {
|
||||
index += 1
|
||||
}
|
||||
|
||||
let word = String(chars[start..<index])
|
||||
let upper = word.uppercased()
|
||||
|
||||
output += keywords.contains(upper) ? upper : word
|
||||
} else {
|
||||
output.append(ch)
|
||||
index += 1
|
||||
}
|
||||
|
||||
case .singleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "'" {
|
||||
if index < chars.count && chars[index] == "'" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .doubleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
if index < chars.count && chars[index] == "\"" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .backtick:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "`" {
|
||||
if index < chars.count && chars[index] == "`" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .lineComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\n" {
|
||||
state = .normal
|
||||
}
|
||||
|
||||
case .blockComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "*" && index < chars.count &&
|
||||
chars[index] == "/" {
|
||||
output.append("/")
|
||||
index += 1
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name = "Local MySQL"
|
||||
var host = "127.0.0.1"
|
||||
var port = 3306
|
||||
var username = "root"
|
||||
var database = ""
|
||||
}
|
||||
struct QueryResult {
|
||||
var columns: [String] = []
|
||||
var rows: [[String]] = []
|
||||
var message = ""
|
||||
}
|
||||
|
||||
enum PasswordStore {
|
||||
static func read(_ id: UUID) -> String {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
||||
var value: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||
let data = value as? Data else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
static func save(_ password: String, id: UUID) {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
guard !password.isEmpty else { return }
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = Data(password.utf8)
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var schemaViews: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
func save(_ profile: ConnectionProfile, password: String) {
|
||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||
else { profiles.append(profile) }
|
||||
PasswordStore.save(password, id: profile.id)
|
||||
selectedID = profile.id
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||
}
|
||||
|
||||
func formatSQL() {
|
||||
sql = SQLFormatter.uppercaseKeywords(sql)
|
||||
}
|
||||
|
||||
|
||||
func loadTables(database: String) {
|
||||
guard let profile = selected else {
|
||||
status = "Select a MySQL connection"
|
||||
return
|
||||
}
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
status = "Loading tables from \(database)…"
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: database,
|
||||
password: password,
|
||||
sql: "SHOW FULL TABLES FROM `\(escaped)`"
|
||||
)
|
||||
|
||||
var tables: [String] = []
|
||||
var views: [String] = []
|
||||
|
||||
for row in parsed.rows {
|
||||
guard let name = row.first else {
|
||||
continue
|
||||
}
|
||||
|
||||
if row.count > 1 && row[1] == "VIEW" {
|
||||
views.append(name)
|
||||
} else {
|
||||
tables.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
schemaTables[database] = tables
|
||||
schemaViews[database] = views
|
||||
|
||||
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
|
||||
} catch {
|
||||
status = "Unable to load tables: \(error.localizedDescription)"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
let tbl = table.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
selectedDatabase = database
|
||||
|
||||
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT 100;"
|
||||
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
formatSQL()
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SQL Keyword Formatter
|
||||
|
||||
enum SQLFormatter {
|
||||
|
||||
private static let keywords: Set<String> = [
|
||||
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
|
||||
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
|
||||
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
|
||||
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
|
||||
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
|
||||
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
|
||||
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
|
||||
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
|
||||
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
|
||||
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
|
||||
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
|
||||
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
|
||||
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
|
||||
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
|
||||
]
|
||||
|
||||
static func uppercaseKeywords(_ sql: String) -> String {
|
||||
let chars = Array(sql)
|
||||
var output = ""
|
||||
var index = 0
|
||||
|
||||
enum State {
|
||||
case normal
|
||||
case singleQuote
|
||||
case doubleQuote
|
||||
case backtick
|
||||
case lineComment
|
||||
case blockComment
|
||||
}
|
||||
|
||||
var state = State.normal
|
||||
|
||||
while index < chars.count {
|
||||
let ch = chars[index]
|
||||
let next: Character? = index + 1 < chars.count
|
||||
? chars[index + 1] : nil
|
||||
|
||||
switch state {
|
||||
case .normal:
|
||||
if ch == "'" {
|
||||
state = .singleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
state = .doubleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "`" {
|
||||
state = .backtick
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "-" && next == "-" &&
|
||||
(index + 2 == chars.count ||
|
||||
chars[index + 2].isWhitespace) {
|
||||
state = .lineComment
|
||||
output += "--"
|
||||
index += 2
|
||||
} else if ch == "#" {
|
||||
state = .lineComment
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "/" && next == "*" {
|
||||
state = .blockComment
|
||||
output += "/*"
|
||||
index += 2
|
||||
} else if ch.isLetter || ch == "_" {
|
||||
let start = index
|
||||
while index < chars.count &&
|
||||
(chars[index].isLetter ||
|
||||
chars[index].isNumber ||
|
||||
chars[index] == "_" ||
|
||||
chars[index] == "$") {
|
||||
index += 1
|
||||
}
|
||||
|
||||
let word = String(chars[start..<index])
|
||||
let upper = word.uppercased()
|
||||
|
||||
output += keywords.contains(upper) ? upper : word
|
||||
} else {
|
||||
output.append(ch)
|
||||
index += 1
|
||||
}
|
||||
|
||||
case .singleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "'" {
|
||||
if index < chars.count && chars[index] == "'" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .doubleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
if index < chars.count && chars[index] == "\"" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .backtick:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "`" {
|
||||
if index < chars.count && chars[index] == "`" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .lineComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\n" {
|
||||
state = .normal
|
||||
}
|
||||
|
||||
case .blockComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "*" && index < chars.count &&
|
||||
chars[index] == "/" {
|
||||
output.append("/")
|
||||
index += 1
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name = "Local MySQL"
|
||||
var host = "127.0.0.1"
|
||||
var port = 3306
|
||||
var username = "root"
|
||||
var database = ""
|
||||
}
|
||||
struct QueryResult {
|
||||
var columns: [String] = []
|
||||
var rows: [[String]] = []
|
||||
var message = ""
|
||||
}
|
||||
|
||||
enum PasswordStore {
|
||||
static func read(_ id: UUID) -> String {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
||||
var value: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||
let data = value as? Data else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
static func save(_ password: String, id: UUID) {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
guard !password.isEmpty else { return }
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = Data(password.utf8)
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var schemaViews: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
func save(_ profile: ConnectionProfile, password: String) {
|
||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||
else { profiles.append(profile) }
|
||||
PasswordStore.save(password, id: profile.id)
|
||||
selectedID = profile.id
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||
}
|
||||
|
||||
func formatSQL() {
|
||||
sql = SQLFormatter.uppercaseKeywords(sql)
|
||||
}
|
||||
|
||||
|
||||
func loadTables(database: String) {
|
||||
guard let profile = selected else {
|
||||
status = "Select a MySQL connection"
|
||||
return
|
||||
}
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
status = "Loading tables from \(database)…"
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: database,
|
||||
password: password,
|
||||
sql: "SHOW FULL TABLES FROM `\(escaped)`"
|
||||
)
|
||||
|
||||
var tables: [String] = []
|
||||
var views: [String] = []
|
||||
|
||||
for row in parsed.rows {
|
||||
guard let name = row.first else {
|
||||
continue
|
||||
}
|
||||
|
||||
if row.count > 1 && row[1] == "VIEW" {
|
||||
views.append(name)
|
||||
} else {
|
||||
tables.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
schemaTables[database] = tables
|
||||
schemaViews[database] = views
|
||||
|
||||
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
|
||||
} catch {
|
||||
status = "Unable to load tables: \(error.localizedDescription)"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
let tbl = table.replacingOccurrences(
|
||||
of: "`",
|
||||
with: "``"
|
||||
)
|
||||
|
||||
selectedDatabase = database
|
||||
|
||||
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT 100;"
|
||||
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
if UserDefaults.standard.object(
|
||||
forKey: "preferences.uppercaseKeywords"
|
||||
) as? Bool ?? true {
|
||||
formatSQL()
|
||||
}
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SQL Keyword Formatter
|
||||
|
||||
enum SQLFormatter {
|
||||
|
||||
private static let keywords: Set<String> = [
|
||||
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
|
||||
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
|
||||
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
|
||||
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
|
||||
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
|
||||
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
|
||||
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
|
||||
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
|
||||
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
|
||||
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
|
||||
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
|
||||
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
|
||||
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
|
||||
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
|
||||
]
|
||||
|
||||
static func uppercaseKeywords(_ sql: String) -> String {
|
||||
let chars = Array(sql)
|
||||
var output = ""
|
||||
var index = 0
|
||||
|
||||
enum State {
|
||||
case normal
|
||||
case singleQuote
|
||||
case doubleQuote
|
||||
case backtick
|
||||
case lineComment
|
||||
case blockComment
|
||||
}
|
||||
|
||||
var state = State.normal
|
||||
|
||||
while index < chars.count {
|
||||
let ch = chars[index]
|
||||
let next: Character? = index + 1 < chars.count
|
||||
? chars[index + 1] : nil
|
||||
|
||||
switch state {
|
||||
case .normal:
|
||||
if ch == "'" {
|
||||
state = .singleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
state = .doubleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "`" {
|
||||
state = .backtick
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "-" && next == "-" &&
|
||||
(index + 2 == chars.count ||
|
||||
chars[index + 2].isWhitespace) {
|
||||
state = .lineComment
|
||||
output += "--"
|
||||
index += 2
|
||||
} else if ch == "#" {
|
||||
state = .lineComment
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "/" && next == "*" {
|
||||
state = .blockComment
|
||||
output += "/*"
|
||||
index += 2
|
||||
} else if ch.isLetter || ch == "_" {
|
||||
let start = index
|
||||
while index < chars.count &&
|
||||
(chars[index].isLetter ||
|
||||
chars[index].isNumber ||
|
||||
chars[index] == "_" ||
|
||||
chars[index] == "$") {
|
||||
index += 1
|
||||
}
|
||||
|
||||
let word = String(chars[start..<index])
|
||||
let upper = word.uppercased()
|
||||
|
||||
output += keywords.contains(upper) ? upper : word
|
||||
} else {
|
||||
output.append(ch)
|
||||
index += 1
|
||||
}
|
||||
|
||||
case .singleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "'" {
|
||||
if index < chars.count && chars[index] == "'" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .doubleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
if index < chars.count && chars[index] == "\"" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .backtick:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "`" {
|
||||
if index < chars.count && chars[index] == "`" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .lineComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\n" {
|
||||
state = .normal
|
||||
}
|
||||
|
||||
case .blockComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "*" && index < chars.count &&
|
||||
chars[index] == "/" {
|
||||
output.append("/")
|
||||
index += 1
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
+906
-282
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
|
||||
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)
|
||||
.preferredColorScheme(.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, 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
|
||||
|
||||
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: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
|
||||
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: 1100, minHeight: 720)
|
||||
.preferredColorScheme(.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)
|
||||
}
|
||||
|
||||
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: 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) {
|
||||
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: 180)
|
||||
|
||||
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 {
|
||||
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: 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: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
|
||||
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: 1100, minHeight: 720)
|
||||
.preferredColorScheme(.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)
|
||||
}
|
||||
|
||||
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: 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) {
|
||||
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: 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 {
|
||||
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: 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: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
|
||||
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: 1100, minHeight: 720)
|
||||
.preferredColorScheme(.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)
|
||||
}
|
||||
|
||||
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: 2) {
|
||||
|
||||
DisclosureGroup {
|
||||
if let tables = model.schemaTables[db] {
|
||||
ForEach(tables, id: \\.self) { table in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "tablecells")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 16)
|
||||
|
||||
Text(table)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 4)
|
||||
.padding(.vertical, 3)
|
||||
.contentShape(Rectangle())
|
||||
.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)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
} label: {
|
||||
Label("Tables", systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Label("Views", systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Label("Stored Procedures", systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Label("Functions", systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.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: 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) {
|
||||
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: 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 {
|
||||
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: 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: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
|
||||
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: 1100, minHeight: 720)
|
||||
.preferredColorScheme(.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)
|
||||
}
|
||||
|
||||
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: 2) {
|
||||
|
||||
DisclosureGroup {
|
||||
if let tables = model.schemaTables[db] {
|
||||
ForEach(tables, id: \.self) { table in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "tablecells")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 16)
|
||||
|
||||
Text(table)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 4)
|
||||
.padding(.vertical, 3)
|
||||
.contentShape(Rectangle())
|
||||
.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)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
} label: {
|
||||
Label("Tables", systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Label("Views", systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Label("Stored Procedures", systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Label("Functions", systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.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: 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) {
|
||||
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: 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 {
|
||||
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: 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: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
|
||||
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(.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) {
|
||||
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: 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 {
|
||||
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: 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: 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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
+152
@@ -9,6 +9,158 @@
|
||||
"startingColumn": 21,
|
||||
"startingLine": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "SQL Editor",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 97
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Query Execution",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 105
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Result Grid",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 113
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "General",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 121
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "%lld pt",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 135
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "",
|
||||
"location": {
|
||||
"startingColumn": 24,
|
||||
"startingLine": 144
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "2 spaces",
|
||||
"location": {
|
||||
"startingColumn": 26,
|
||||
"startingLine": 145
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "4 spaces",
|
||||
"location": {
|
||||
"startingColumn": 26,
|
||||
"startingLine": 146
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "8 spaces",
|
||||
"location": {
|
||||
"startingColumn": 26,
|
||||
"startingLine": 147
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Automatically uppercase SQL keywords",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 155
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Show line numbers",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 162
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Controls the number of rows retrieved when opening a table from the schema navigator.",
|
||||
"location": {
|
||||
"startingColumn": 17,
|
||||
"startingLine": 193
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Examples",
|
||||
"location": {
|
||||
"startingColumn": 18,
|
||||
"startingLine": 203
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 175
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 183
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "%lld px",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 222
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "%lld px",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 232
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Alternating row colours",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 242
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Automatically expand table folders",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 254
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+85
-117
@@ -4,42 +4,18 @@
|
||||
"Localizable": [
|
||||
{
|
||||
"comment": "",
|
||||
"key": "WorkbenchReplacement",
|
||||
"key": "MySQL Workbench",
|
||||
"location": {
|
||||
"startingColumn": 26,
|
||||
"startingLine": 187
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "NAVIGATOR",
|
||||
"location": {
|
||||
"startingColumn": 22,
|
||||
"startingLine": 41
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Refresh schemas",
|
||||
"location": {
|
||||
"startingColumn": 23,
|
||||
"startingLine": 53
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "MYSQL CONNECTIONS",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 61
|
||||
"startingColumn": 18,
|
||||
"startingLine": 84
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "SCHEMAS",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 76
|
||||
"startingColumn": 22,
|
||||
"startingLine": 169
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -47,87 +23,47 @@
|
||||
"key": "Edit Connection",
|
||||
"location": {
|
||||
"startingColumn": 36,
|
||||
"startingLine": 69
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "No tables",
|
||||
"location": {
|
||||
"startingColumn": 22,
|
||||
"startingLine": 137
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Load Tables",
|
||||
"location": {
|
||||
"startingColumn": 20,
|
||||
"startingLine": 160
|
||||
"startingLine": 223
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Set as Default Schema",
|
||||
"location": {
|
||||
"startingColumn": 40,
|
||||
"startingLine": 90
|
||||
"startingColumn": 24,
|
||||
"startingLine": 280
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Refresh Tables",
|
||||
"location": {
|
||||
"startingColumn": 40,
|
||||
"startingLine": 94
|
||||
"startingColumn": 24,
|
||||
"startingLine": 284
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "New Connection",
|
||||
"key": "Load Tables",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 121
|
||||
"startingColumn": 28,
|
||||
"startingLine": 354
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Tables",
|
||||
"location": {
|
||||
"startingColumn": 22,
|
||||
"startingLine": 327
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Select First 100 Rows",
|
||||
"location": {
|
||||
"startingColumn": 36,
|
||||
"startingLine": 150
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "SQL Editor",
|
||||
"location": {
|
||||
"startingColumn": 17,
|
||||
"startingLine": 194
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Refresh schemas",
|
||||
"location": {
|
||||
"startingColumn": 19,
|
||||
"startingLine": 241
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Execute",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 207
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Format SQL",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 221
|
||||
"startingColumn": 20,
|
||||
"startingLine": 410
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -135,39 +71,79 @@
|
||||
"key": "Query 1",
|
||||
"location": {
|
||||
"startingColumn": 22,
|
||||
"startingLine": 251
|
||||
"startingLine": 472
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "MySQL",
|
||||
"key": "Default Schema:",
|
||||
"location": {
|
||||
"startingColumn": 22,
|
||||
"startingLine": 256
|
||||
"startingColumn": 18,
|
||||
"startingLine": 508
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Execute a SQL query to display results.",
|
||||
"key": "SQL Editor",
|
||||
"location": {
|
||||
"startingColumn": 25,
|
||||
"startingLine": 307
|
||||
"startingColumn": 18,
|
||||
"startingLine": 518
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "No Results",
|
||||
"key": "Execute SQL",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 304
|
||||
"startingColumn": 19,
|
||||
"startingLine": 496
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Format SQL",
|
||||
"location": {
|
||||
"startingColumn": 19,
|
||||
"startingLine": 503
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "%lld",
|
||||
"location": {
|
||||
"startingColumn": 26,
|
||||
"startingLine": 558
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Result Grid",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 287
|
||||
"startingColumn": 22,
|
||||
"startingLine": 590
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "No query results",
|
||||
"location": {
|
||||
"startingColumn": 26,
|
||||
"startingLine": 608
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Action Output",
|
||||
"location": {
|
||||
"startingColumn": 22,
|
||||
"startingLine": 630
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "UTF-8",
|
||||
"location": {
|
||||
"startingColumn": 18,
|
||||
"startingLine": 705
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -175,15 +151,7 @@
|
||||
"key": "Connection Settings",
|
||||
"location": {
|
||||
"startingColumn": 18,
|
||||
"startingLine": 431
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Native MySQL connection. Local TCP connections and SSH tunnels are supported.",
|
||||
"location": {
|
||||
"startingColumn": 17,
|
||||
"startingLine": 468
|
||||
"startingLine": 880
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -191,7 +159,7 @@
|
||||
"key": "Connection Name",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 436
|
||||
"startingLine": 885
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -199,7 +167,7 @@
|
||||
"key": "Hostname",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 441
|
||||
"startingLine": 890
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -207,7 +175,7 @@
|
||||
"key": "Port",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 446
|
||||
"startingLine": 895
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -215,15 +183,15 @@
|
||||
"key": "Username",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 452
|
||||
"startingLine": 901
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"key": "Password (stored in macOS Keychain)",
|
||||
"key": "Password (macOS Keychain)",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 457
|
||||
"startingLine": 906
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -231,7 +199,7 @@
|
||||
"key": "Default Schema",
|
||||
"location": {
|
||||
"startingColumn": 21,
|
||||
"startingLine": 462
|
||||
"startingLine": 911
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -239,7 +207,7 @@
|
||||
"key": "Cancel",
|
||||
"location": {
|
||||
"startingColumn": 24,
|
||||
"startingLine": 476
|
||||
"startingLine": 919
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -247,7 +215,7 @@
|
||||
"key": "Save",
|
||||
"location": {
|
||||
"startingColumn": 24,
|
||||
"startingLine": 480
|
||||
"startingLine": 923
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"buildCommand" : {
|
||||
"command" : "build",
|
||||
"skipDependencies" : false,
|
||||
"style" : "buildOnly"
|
||||
},
|
||||
"configuredTargets" : [
|
||||
{
|
||||
"guid" : "34f2007b1d0798da0712fb82baec90fb2878cdfbaa5fcb5fa30c8485a83dfb14"
|
||||
}
|
||||
],
|
||||
"containerPath" : "/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj",
|
||||
"continueBuildingAfterErrors" : false,
|
||||
"dependencyScope" : "workspace",
|
||||
"enableIndexBuildArena" : false,
|
||||
"hideShellScriptEnvironment" : false,
|
||||
"parameters" : {
|
||||
"action" : "build",
|
||||
"activeArchitecture" : "arm64",
|
||||
"activeRunDestination" : {
|
||||
"disableOnlyActiveArch" : false,
|
||||
"hostTargetedPlatform" : null,
|
||||
"platform" : "macosx",
|
||||
"sdk" : "macosx27.0",
|
||||
"sdkVariant" : "macos",
|
||||
"supportedArchitectures" : [
|
||||
"arm64e",
|
||||
"arm64",
|
||||
"x86_64"
|
||||
],
|
||||
"targetArchitecture" : "arm64"
|
||||
},
|
||||
"arenaInfo" : {
|
||||
"buildIntermediatesPath" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex",
|
||||
"buildProductsPath" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products",
|
||||
"derivedDataPath" : "/Volumes/Developer/Applications/WorkbenchReplacement/build",
|
||||
"indexDataStoreFolderPath" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Index.noindex/DataStore",
|
||||
"indexEnableDataStore" : true,
|
||||
"indexPCHPath" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Index.noindex/PrecompiledHeaders",
|
||||
"pchPath" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/PrecompiledHeaders"
|
||||
},
|
||||
"configurationName" : "Debug",
|
||||
"overrides" : {
|
||||
"commandLine" : {
|
||||
"table" : {
|
||||
|
||||
}
|
||||
},
|
||||
"synthesized" : {
|
||||
"table" : {
|
||||
"ACTION" : "build",
|
||||
"ENABLE_PREVIEWS" : "NO",
|
||||
"ENABLE_XOJIT_PREVIEWS" : "YES",
|
||||
"ONLY_ACTIVE_ARCH" : "YES",
|
||||
"TOOLCHAINS" : "com.apple.dt.toolchain.Metal.32023.921.5 $(inherited)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemeCommand" : "launch",
|
||||
"showNonLoggedProgress" : true,
|
||||
"useDryRun" : false,
|
||||
"useImplicitDependencies" : true,
|
||||
"useLegacyBuildLocations" : false,
|
||||
"useParallelTargets" : true
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
@rpath/WorkbenchReplacement.debug.dylib
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"" : {
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.dia",
|
||||
"emit-module-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary-emit-module.d",
|
||||
"emit-module-diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary-emit-module.dia",
|
||||
"pch" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary-Bridging-header.pch",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.swiftdeps"
|
||||
},
|
||||
"/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/App.swift" : {
|
||||
"const-values" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.swiftconstvalues",
|
||||
"dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.d",
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.dia",
|
||||
"index-unit-output-path" : "/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.o",
|
||||
"llvm-bc" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.bc",
|
||||
"object" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.o",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.swiftdeps",
|
||||
"swiftmodule" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App~partial.swiftmodule"
|
||||
},
|
||||
"/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/DatabaseModel.swift" : {
|
||||
"const-values" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.swiftconstvalues",
|
||||
"dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.d",
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.dia",
|
||||
"index-unit-output-path" : "/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.o",
|
||||
"llvm-bc" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.bc",
|
||||
"object" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.o",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.swiftdeps",
|
||||
"swiftmodule" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel~partial.swiftmodule"
|
||||
},
|
||||
"/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/MainView.swift" : {
|
||||
"const-values" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftconstvalues",
|
||||
"dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.d",
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.dia",
|
||||
"index-unit-output-path" : "/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o",
|
||||
"llvm-bc" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.bc",
|
||||
"object" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftdeps",
|
||||
"swiftmodule" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView~partial.swiftmodule"
|
||||
},
|
||||
"/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/MySQLProtocol.swift" : {
|
||||
"const-values" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.swiftconstvalues",
|
||||
"dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.d",
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.dia",
|
||||
"index-unit-output-path" : "/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.o",
|
||||
"llvm-bc" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.bc",
|
||||
"object" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.o",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.swiftdeps",
|
||||
"swiftmodule" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol~partial.swiftmodule"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"case-sensitive":"false","roots":[],"version":0}
|
||||
+1
@@ -0,0 +1 @@
|
||||
["AnyResolverProviding","AppEntity","AppEnum","AppExtension","AppIntent","AppIntentsPackage","AppShortcutProviding","AppShortcutsProvider","AppUnionValue","AppUnionValueCasesProviding","DynamicOptionsProvider","EntityQuery","ExtensionPointDefining","IntentValueQuery","Resolver","TransientEntity","_AssistantIntentsProvider","_GenerativeFunctionExtractable","_IntentValueRepresentable"]
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.o
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.o
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.o
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.swiftconstvalues
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftconstvalues
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.swiftconstvalues
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.swiftconstvalues
|
||||
BIN
Binary file not shown.
+4
@@ -0,0 +1,4 @@
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/App.swift
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/MainView.swift
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/DatabaseModel.swift
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/MySQLProtocol.swift
|
||||
+1
@@ -0,0 +1 @@
|
||||
WorkbenchReplacement.debug.dylib
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.get-task-allow</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
Binary file not shown.
+1
-1
@@ -50,7 +50,7 @@
|
||||
"table" : {
|
||||
"ACTION" : "build",
|
||||
"COLOR_DIAGNOSTICS" : "YES",
|
||||
"diagnostic_message_length" : "167",
|
||||
"diagnostic_message_length" : "162",
|
||||
"ENABLE_PREVIEWS" : "NO",
|
||||
"ENABLE_XOJIT_PREVIEWS" : "YES",
|
||||
"ONLY_ACTIVE_ARCH" : "YES",
|
||||
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
+2
@@ -0,0 +1,2 @@
|
||||
Target dependency graph (1 target)
|
||||
Target 'WorkbenchReplacement' in project 'WorkbenchReplacement' (no dependencies)
|
||||
BIN
Binary file not shown.
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
@rpath/WorkbenchReplacement.debug.dylib
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"" : {
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.dia",
|
||||
"emit-module-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary-emit-module.d",
|
||||
"emit-module-diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary-emit-module.dia",
|
||||
"pch" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary-Bridging-header.pch",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.swiftdeps"
|
||||
},
|
||||
"/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/App.swift" : {
|
||||
"const-values" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.swiftconstvalues",
|
||||
"dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.d",
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.dia",
|
||||
"index-unit-output-path" : "/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.o",
|
||||
"llvm-bc" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.bc",
|
||||
"object" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.o",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.swiftdeps",
|
||||
"swiftmodule" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App~partial.swiftmodule"
|
||||
},
|
||||
"/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/DatabaseModel.swift" : {
|
||||
"const-values" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.swiftconstvalues",
|
||||
"dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.d",
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.dia",
|
||||
"index-unit-output-path" : "/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.o",
|
||||
"llvm-bc" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.bc",
|
||||
"object" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.o",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.swiftdeps",
|
||||
"swiftmodule" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel~partial.swiftmodule"
|
||||
},
|
||||
"/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/MainView.swift" : {
|
||||
"const-values" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftconstvalues",
|
||||
"dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.d",
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.dia",
|
||||
"index-unit-output-path" : "/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o",
|
||||
"llvm-bc" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.bc",
|
||||
"object" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftdeps",
|
||||
"swiftmodule" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView~partial.swiftmodule"
|
||||
},
|
||||
"/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement/MySQLProtocol.swift" : {
|
||||
"const-values" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.swiftconstvalues",
|
||||
"dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.d",
|
||||
"diagnostics" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.dia",
|
||||
"index-unit-output-path" : "/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.o",
|
||||
"llvm-bc" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.bc",
|
||||
"object" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.o",
|
||||
"swift-dependencies" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.swiftdeps",
|
||||
"swiftmodule" : "/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol~partial.swiftmodule"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
{"case-sensitive":"false","roots":[],"version":0}
|
||||
+1
@@ -0,0 +1 @@
|
||||
["AnyResolverProviding","AppEntity","AppEnum","AppExtension","AppIntent","AppIntentsPackage","AppShortcutProviding","AppShortcutsProvider","AppUnionValue","AppUnionValueCasesProviding","DynamicOptionsProvider","EntityQuery","ExtensionPointDefining","IntentValueQuery","Resolver","TransientEntity","_AssistantIntentsProvider","_GenerativeFunctionExtractable","_IntentValueRepresentable"]
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.o
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.o
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.o
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.o
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/App.swiftconstvalues
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MainView.swiftconstvalues
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/DatabaseModel.swiftconstvalues
|
||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/MySQLProtocol.swiftconstvalues
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user