new ui
This commit is contained in:
@@ -1,218 +0,0 @@
|
|||||||
// !$*UTF8*$!
|
|
||||||
{
|
|
||||||
archiveVersion = 1;
|
|
||||||
classes = {
|
|
||||||
};
|
|
||||||
objectVersion = 56;
|
|
||||||
objects = {
|
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
|
||||||
000000000000000000000013 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000010 /* App.swift */; };
|
|
||||||
000000000000000000000014 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000011 /* MainView.swift */; };
|
|
||||||
000000000000000000000015 /* DatabaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000012 /* DatabaseModel.swift */; };
|
|
||||||
000000000000000000000017 /* MySQLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000016 /* MySQLProtocol.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
|
||||||
00000000000000000000000F /* WorkbenchReplacement.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WorkbenchReplacement.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
000000000000000000000010 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
|
|
||||||
000000000000000000000011 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
|
||||||
000000000000000000000012 /* DatabaseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseModel.swift; sourceTree = "<group>"; };
|
|
||||||
000000000000000000000016 /* MySQLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MySQLProtocol.swift; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
|
||||||
000000000000000000000008 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
|
||||||
000000000000000000000002 = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
000000000000000000000003 /* WorkbenchReplacement */,
|
|
||||||
000000000000000000000004 /* Products */,
|
|
||||||
);
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
000000000000000000000003 /* WorkbenchReplacement */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
000000000000000000000010 /* App.swift */,
|
|
||||||
000000000000000000000011 /* MainView.swift */,
|
|
||||||
000000000000000000000012 /* DatabaseModel.swift */,
|
|
||||||
000000000000000000000016 /* MySQLProtocol.swift */,
|
|
||||||
);
|
|
||||||
path = WorkbenchReplacement;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
000000000000000000000004 /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
00000000000000000000000F /* WorkbenchReplacement.app */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
|
||||||
000000000000000000000005 /* WorkbenchReplacement */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 00000000000000000000000A /* Build configuration list for PBXNativeTarget "WorkbenchReplacement" */;
|
|
||||||
buildPhases = (
|
|
||||||
000000000000000000000006 /* Sources */,
|
|
||||||
000000000000000000000008 /* Frameworks */,
|
|
||||||
000000000000000000000007 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
name = WorkbenchReplacement;
|
|
||||||
productName = WorkbenchReplacement;
|
|
||||||
productReference = 00000000000000000000000F /* WorkbenchReplacement.app */;
|
|
||||||
productType = "com.apple.product-type.application";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
|
||||||
000000000000000000000001 /* Project object */ = {
|
|
||||||
isa = PBXProject;
|
|
||||||
attributes = {
|
|
||||||
LastUpgradeCheck = 1600;
|
|
||||||
TargetAttributes = {
|
|
||||||
000000000000000000000005 = {
|
|
||||||
CreatedOnToolsVersion = 16.0;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
buildConfigurationList = 000000000000000000000009 /* Build configuration list for PBXProject "WorkbenchReplacement" */;
|
|
||||||
compatibilityVersion = "Xcode 14.0";
|
|
||||||
developmentRegion = en;
|
|
||||||
hasScannedForEncodings = 0;
|
|
||||||
knownRegions = (
|
|
||||||
en,
|
|
||||||
Base,
|
|
||||||
);
|
|
||||||
mainGroup = 000000000000000000000002;
|
|
||||||
productRefGroup = 000000000000000000000004 /* Products */;
|
|
||||||
projectDirPath = "";
|
|
||||||
projectRoot = "";
|
|
||||||
targets = (
|
|
||||||
000000000000000000000005 /* WorkbenchReplacement */,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/* End PBXProject section */
|
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
|
||||||
000000000000000000000007 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
|
||||||
000000000000000000000006 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
000000000000000000000013 /* App.swift in Sources */,
|
|
||||||
000000000000000000000014 /* MainView.swift in Sources */,
|
|
||||||
000000000000000000000015 /* DatabaseModel.swift in Sources */,
|
|
||||||
000000000000000000000017 /* MySQLProtocol.swift in Sources */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
|
||||||
00000000000000000000000B /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
|
||||||
SDKROOT = macosx;
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
00000000000000000000000C /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
|
||||||
SDKROOT = macosx;
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
00000000000000000000000D /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
DEVELOPMENT_TEAM = 39R6U3437D;
|
|
||||||
ENABLE_APP_SANDBOX = NO;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Workbench Replacement";
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.WorkbenchReplacement;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
00000000000000000000000E /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
DEVELOPMENT_TEAM = 39R6U3437D;
|
|
||||||
ENABLE_APP_SANDBOX = NO;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Workbench Replacement";
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.WorkbenchReplacement;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
|
||||||
000000000000000000000009 /* Build configuration list for PBXProject "WorkbenchReplacement" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
00000000000000000000000B /* Debug */,
|
|
||||||
00000000000000000000000C /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
00000000000000000000000A /* Build configuration list for PBXNativeTarget "WorkbenchReplacement" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
00000000000000000000000D /* Debug */,
|
|
||||||
00000000000000000000000E /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
|
||||||
};
|
|
||||||
rootObject = 000000000000000000000001 /* Project object */;
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
-14
@@ -1,14 +0,0 @@
|
|||||||
<?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>SchemeUserState</key>
|
|
||||||
<dict>
|
|
||||||
<key>WorkbenchReplacement.xcscheme_^#shared#^_</key>
|
|
||||||
<dict>
|
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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 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 loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
|
||||||
func runSQL() { 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,7 @@ struct MainView: View {
|
|||||||
@State private var selectedTableName: String? = nil
|
@State private var selectedTableName: String? = nil
|
||||||
@State private var outputExpanded = true
|
@State private var outputExpanded = true
|
||||||
|
|
||||||
@State private var navigatorMode = 0
|
@State private var navigatorMode = 1
|
||||||
@State private var administrationPage: WorkbenchAdminPage? = nil
|
@State private var administrationPage: WorkbenchAdminPage? = nil
|
||||||
|
|
||||||
|
|
||||||
@@ -1567,6 +1567,14 @@ struct WorkbenchDatabaseTransferView: View {
|
|||||||
@State private var operationTask: Task<Void, Never>?
|
@State private var operationTask: Task<Void, Never>?
|
||||||
@State private var showingImportConfirmation = false
|
@State private var showingImportConfirmation = false
|
||||||
@State private var pendingImportURL: URL?
|
@State private var pendingImportURL: URL?
|
||||||
|
@State private var availableTables: [String] = []
|
||||||
|
@State private var selectedTables: Set<String> = []
|
||||||
|
@State private var exportStructure = true
|
||||||
|
@State private var exportData = true
|
||||||
|
@State private var includeDrop = false
|
||||||
|
@State private var loadingTables = false
|
||||||
|
@State private var availableDatabases: [String] = []
|
||||||
|
@State private var loadingDatabases = false
|
||||||
|
|
||||||
private var selectedProfile: ConnectionProfile? {
|
private var selectedProfile: ConnectionProfile? {
|
||||||
model.selected
|
model.selected
|
||||||
@@ -1594,20 +1602,50 @@ struct WorkbenchDatabaseTransferView: View {
|
|||||||
Picker("Database", selection: $database) {
|
Picker("Database", selection: $database) {
|
||||||
Text("Select Database").tag("")
|
Text("Select Database").tag("")
|
||||||
|
|
||||||
ForEach(model.databases, id: \.self) { name in
|
ForEach(availableDatabases, id: \.self) { name in
|
||||||
Text(name).tag(name)
|
Text(name).tag(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
HStack {
|
||||||
|
if loadingDatabases { ProgressView().controlSize(.small) }
|
||||||
|
Button("Refresh Databases") {
|
||||||
|
Task { await loadTransferDatabases() }
|
||||||
|
}
|
||||||
|
.disabled(loadingDatabases || selectedProfile == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if selectedMode == 0 {
|
if selectedMode == 0 {
|
||||||
Section("Export Options") {
|
Section("Export Options") {
|
||||||
Text("Export the SQL definitions of all base tables in the selected database.")
|
Toggle("Table structure", isOn: $exportStructure)
|
||||||
.foregroundStyle(.secondary)
|
Toggle("Table data", isOn: $exportData)
|
||||||
|
Toggle("Add DROP TABLE IF EXISTS", isOn: $includeDrop)
|
||||||
Text("This version exports table structure. Table data, views, triggers and stored routines will be added in the next stage.")
|
.disabled(!exportStructure)
|
||||||
.font(.caption)
|
HStack {
|
||||||
.foregroundStyle(.secondary)
|
Text("Tables (\(selectedTables.count) selected)")
|
||||||
|
Spacer()
|
||||||
|
Button("All") { selectedTables = Set(availableTables) }
|
||||||
|
Button("None") { selectedTables.removeAll() }
|
||||||
|
Button("Refresh") { Task { await loadTables() } }
|
||||||
|
.disabled(loadingTables)
|
||||||
|
}
|
||||||
|
if loadingTables { ProgressView("Loading tables…") }
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading) {
|
||||||
|
ForEach(availableTables, id: \.self) { table in
|
||||||
|
Toggle(table, isOn: Binding(
|
||||||
|
get: { selectedTables.contains(table) },
|
||||||
|
set: { checked in
|
||||||
|
if checked { selectedTables.insert(table) }
|
||||||
|
else { selectedTables.remove(table) }
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 180)
|
||||||
|
Text("Exports base tables with batched INSERT statements. Views, triggers, routines and transaction-consistent snapshots are not included.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Section("Import Options") {
|
Section("Import Options") {
|
||||||
@@ -1662,7 +1700,8 @@ struct WorkbenchDatabaseTransferView: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(
|
.disabled(
|
||||||
selectedProfile == nil ||
|
selectedProfile == nil ||
|
||||||
database.isEmpty
|
database.isEmpty ||
|
||||||
|
(selectedMode == 0 && (selectedTables.isEmpty || (!exportStructure && !exportData)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1676,6 +1715,12 @@ struct WorkbenchDatabaseTransferView: View {
|
|||||||
: model.selectedDatabase
|
: model.selectedDatabase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task(id: selectedProfile?.id) {
|
||||||
|
await loadTransferDatabases()
|
||||||
|
}
|
||||||
|
.task(id: database) {
|
||||||
|
if !database.isEmpty { await loadTables() }
|
||||||
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Import SQL into \(database)?",
|
"Import SQL into \(database)?",
|
||||||
isPresented: $showingImportConfirmation
|
isPresented: $showingImportConfirmation
|
||||||
@@ -1759,7 +1804,7 @@ struct WorkbenchDatabaseTransferView: View {
|
|||||||
|
|
||||||
return try await NativeMySQLRunner.run(
|
return try await NativeMySQLRunner.run(
|
||||||
profile: profile,
|
profile: profile,
|
||||||
database: database,
|
database: statement == "SHOW DATABASES" ? "" : database,
|
||||||
password: PasswordStore.read(profile.id),
|
password: PasswordStore.read(profile.id),
|
||||||
sql: statement
|
sql: statement
|
||||||
)
|
)
|
||||||
@@ -1772,84 +1817,150 @@ struct WorkbenchDatabaseTransferView: View {
|
|||||||
) + "`"
|
) + "`"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadTransferDatabases() async {
|
||||||
|
guard selectedProfile != nil else {
|
||||||
|
availableDatabases = []
|
||||||
|
database = ""
|
||||||
|
status = "Select a MySQL connection first"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingDatabases = true
|
||||||
|
defer { loadingDatabases = false }
|
||||||
|
do {
|
||||||
|
// Do not depend on the schema navigator having been opened first.
|
||||||
|
let response = try await execute("SHOW DATABASES")
|
||||||
|
let names = response.rows.compactMap { $0.first }
|
||||||
|
availableDatabases = names
|
||||||
|
if !names.contains(database) {
|
||||||
|
let preferred = model.selectedDatabase
|
||||||
|
database = names.contains(preferred) ? preferred : (names.first ?? "")
|
||||||
|
}
|
||||||
|
status = names.isEmpty ? "No accessible databases" : "Ready"
|
||||||
|
} catch {
|
||||||
|
availableDatabases = []
|
||||||
|
database = ""
|
||||||
|
status = "Cannot load databases: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadTables() async {
|
||||||
|
guard !database.isEmpty else { availableTables = []; selectedTables = []; return }
|
||||||
|
loadingTables = true
|
||||||
|
defer { loadingTables = false }
|
||||||
|
do {
|
||||||
|
let rows = try await execute("SHOW FULL TABLES FROM \(quoteIdentifier(database))")
|
||||||
|
availableTables = rows.rows.compactMap { row in
|
||||||
|
row.count > 1 && row[1].uppercased() == "BASE TABLE" ? row[0] : nil
|
||||||
|
}
|
||||||
|
selectedTables = Set(availableTables)
|
||||||
|
} catch {
|
||||||
|
status = "Cannot list tables: \(error.localizedDescription)"
|
||||||
|
availableTables = []
|
||||||
|
selectedTables = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server constructs SQL literals, so NULL, quotes and binary data are
|
||||||
|
// preserved rather than reconstructed from QueryResult's String values.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func exportSQL(to url: URL) async {
|
private func exportSQL(to url: URL) async {
|
||||||
|
guard !selectedTables.isEmpty, exportStructure || exportData else { return }
|
||||||
isWorking = true
|
isWorking = true
|
||||||
progress = 0
|
progress = 0
|
||||||
status = "Reading database structure…"
|
let tables = availableTables.filter { selectedTables.contains($0) }
|
||||||
|
let tempURL = url.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).sql.tmp")
|
||||||
|
var handle: FileHandle?
|
||||||
defer {
|
defer {
|
||||||
|
try? handle?.close()
|
||||||
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
isWorking = false
|
isWorking = false
|
||||||
operationTask = nil
|
operationTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let quotedDatabase = quoteIdentifier(database)
|
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
|
||||||
|
throw WorkbenchTransferError.message("Cannot create temporary export file")
|
||||||
let tableResult = try await execute(
|
|
||||||
"SHOW FULL TABLES FROM \(quotedDatabase)"
|
|
||||||
)
|
|
||||||
|
|
||||||
let tables = tableResult.rows.compactMap { row -> String? in
|
|
||||||
guard row.count >= 2,
|
|
||||||
row[1].uppercased() == "BASE TABLE"
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
return row[0]
|
|
||||||
}
|
}
|
||||||
|
handle = try FileHandle(forWritingTo: tempURL)
|
||||||
var output = """
|
func write(_ value: String) throws {
|
||||||
-- WorkbenchReplacement SQL Export
|
try handle?.write(contentsOf: Data(value.utf8))
|
||||||
-- Database: \(database)
|
}
|
||||||
-- Generated: \(Date())
|
try write("-- WorkbenchReplacement native export\n-- Database: \(database)\n-- Generated: \(Date())\nSET NAMES utf8mb4;\n\n")
|
||||||
|
let db = quoteIdentifier(database)
|
||||||
SET NAMES utf8mb4;
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
for (index, table) in tables.enumerated() {
|
for (index, table) in tables.enumerated() {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
let qualified = "\(db).\(quoteIdentifier(table))"
|
||||||
status = "Exporting structure: \(table)"
|
status = "Exporting \(table) (\(index + 1)/\(tables.count))"
|
||||||
|
try write("\n-- Table \(qualified)\n")
|
||||||
let result = try await execute(
|
if exportStructure {
|
||||||
"SHOW CREATE TABLE \(quotedDatabase).\(quoteIdentifier(table))"
|
let definition = try await execute("SHOW CREATE TABLE \(qualified)")
|
||||||
)
|
guard let row = definition.rows.first, row.count > 1 else {
|
||||||
|
throw WorkbenchTransferError.message("Missing CREATE TABLE for \(table)")
|
||||||
guard let row = result.rows.first,
|
}
|
||||||
row.count >= 2
|
if includeDrop { try write("DROP TABLE IF EXISTS \(qualified);\n") }
|
||||||
else {
|
try write(row[1] + ";\n")
|
||||||
throw WorkbenchTransferError.message(
|
|
||||||
"Cannot retrieve CREATE TABLE for \(table)"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if exportData {
|
||||||
output += "\n-- Table: \(table)\n"
|
// Column metadata drives binary-safe SQL literal formatting.
|
||||||
output += row[1]
|
let metadata = try await execute("""
|
||||||
output += ";\n\n"
|
SELECT COLUMN_NAME, DATA_TYPE
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
progress = Double(index + 1) /
|
WHERE TABLE_SCHEMA = \(sqlLiteral(database)) AND TABLE_NAME = \(sqlLiteral(table))
|
||||||
Double(max(tables.count, 1))
|
ORDER BY ORDINAL_POSITION
|
||||||
|
""")
|
||||||
|
guard !metadata.rows.isEmpty else {
|
||||||
|
throw WorkbenchTransferError.message("No column metadata for \(table)")
|
||||||
|
}
|
||||||
|
let names = metadata.rows.map { quoteIdentifier($0[0]) }.joined(separator: ", ")
|
||||||
|
let expressions = metadata.rows.map { row -> String in
|
||||||
|
let col = quoteIdentifier(row[0])
|
||||||
|
let kind = row[1].lowercased()
|
||||||
|
// HEX avoids decoding arbitrary bytes as UTF-8.
|
||||||
|
if ["binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob", "bit"].contains(kind) {
|
||||||
|
return "IF(\(col) IS NULL, 'NULL', CONCAT('0x', HEX(\(col))))"
|
||||||
|
}
|
||||||
|
return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))"
|
||||||
|
}.joined(separator: ", ")
|
||||||
|
let statement = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified) LIMIT 250 OFFSET "
|
||||||
|
var offset = 0
|
||||||
|
while true {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let batch = try await execute(statement + String(offset))
|
||||||
|
if batch.rows.isEmpty { break }
|
||||||
|
for row in batch.rows {
|
||||||
|
guard let literalRow = row.first else { continue }
|
||||||
|
try write("INSERT INTO \(qualified) (\(names)) VALUES \(literalRow);\n")
|
||||||
|
}
|
||||||
|
offset += batch.rows.count
|
||||||
|
status = "Exporting \(table): \(offset) rows"
|
||||||
|
if batch.rows.count < 250 { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress = Double(index + 1) / Double(tables.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
try handle?.close()
|
||||||
try output.write(
|
handle = nil
|
||||||
to: url,
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
atomically: true,
|
_ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)
|
||||||
encoding: .utf8
|
} else {
|
||||||
)
|
try FileManager.default.moveItem(at: tempURL, to: url)
|
||||||
|
}
|
||||||
progress = 1
|
progress = 1
|
||||||
status = "Export complete: \(tables.count) table definitions"
|
status = "Export complete: \(tables.count) tables"
|
||||||
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
status = "Export cancelled"
|
status = "Export cancelled; no final backup was written"
|
||||||
} catch {
|
} catch {
|
||||||
status = "Export failed: \(error.localizedDescription)"
|
status = "Export failed: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sqlLiteral(_ value: String) -> String {
|
||||||
|
"'" + value.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
|
.replacingOccurrences(of: "'", with: "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func importSQL(from url: URL) async {
|
private func importSQL(from url: URL) async {
|
||||||
isWorking = true
|
isWorking = true
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
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
|
|
||||||
} detail: {
|
|
||||||
editorPanel
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingEditor) {
|
|
||||||
ConnectionEditor(profile: $editing, password: $editingPassword) {
|
|
||||||
model.save(editing, password: editingPassword)
|
|
||||||
showingEditor = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var navigator: some View {
|
|
||||||
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
|
|
||||||
Button {
|
|
||||||
model.selectedDatabase = db
|
|
||||||
} label: {
|
|
||||||
Label(db, systemImage: "cylinder.split.1x2")
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.sidebar)
|
|
||||||
.navigationTitle("Navigator")
|
|
||||||
.toolbar {
|
|
||||||
Button {
|
|
||||||
editing = ConnectionProfile(name: "New Connection")
|
|
||||||
editingPassword = ""
|
|
||||||
showingEditor = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
}
|
|
||||||
.help("New connection")
|
|
||||||
Button {
|
|
||||||
model.loadDatabases()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.help("Connect / refresh schemas")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var editorPanel: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
editorToolbar
|
|
||||||
Divider()
|
|
||||||
TextEditor(text: $model.sql)
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.frame(minHeight: 190)
|
|
||||||
.padding(8)
|
|
||||||
Divider()
|
|
||||||
resultsPanel
|
|
||||||
Divider()
|
|
||||||
statusBar
|
|
||||||
}
|
|
||||||
.navigationTitle("Workbench Replacement")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var editorToolbar: some View {
|
|
||||||
HStack {
|
|
||||||
Label("SQL Editor", systemImage: "chevron.left.forwardslash.chevron.right")
|
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
|
||||||
Button("Edit Connection") {
|
|
||||||
if let profile = model.selected { edit(profile) }
|
|
||||||
}
|
|
||||||
Button("Execute") { model.runSQL() }
|
|
||||||
.keyboardShortcut(.return, modifiers: [.command])
|
|
||||||
.disabled(model.isRunning)
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var resultsPanel: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack {
|
|
||||||
Text("Result Grid").font(.headline)
|
|
||||||
Spacer()
|
|
||||||
Text(model.result.message).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
if model.result.columns.isEmpty {
|
|
||||||
ContentUnavailableView("No Results", systemImage: "tablecells",
|
|
||||||
description: Text("Run a query to display results."))
|
|
||||||
} else {
|
|
||||||
resultsGrid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var resultsGrid: some View {
|
|
||||||
ScrollView([.horizontal, .vertical]) {
|
|
||||||
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) {
|
|
||||||
GridRow {
|
|
||||||
ForEach(model.result.columns, id: \.self) { name in
|
|
||||||
Text(name)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.frame(minWidth: 130, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ForEach(model.result.rows.indices, id: \.self) { rowIndex in
|
|
||||||
resultRow(model.result.rows[rowIndex])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resultRow(_ row: [String]) -> some View {
|
|
||||||
GridRow {
|
|
||||||
ForEach(model.result.columns.indices, id: \.self) { index in
|
|
||||||
Text(index < row.count ? row[index] : "")
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
.frame(minWidth: 130, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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("Built-in MySQL protocol client. This first version supports loopback TCP connections and local SSH tunnels. Remote TLS and full caching_sha2 authentication are not implemented yet.")
|
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button("Cancel") { dismiss() }
|
|
||||||
Button("Save") { save() }.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
.frame(width: 520)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,854 +0,0 @@
|
|||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,861 +0,0 @@
|
|||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,861 +0,0 @@
|
|||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,861 +0,0 @@
|
|||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,883 +0,0 @@
|
|||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,883 +0,0 @@
|
|||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,960 +0,0 @@
|
|||||||
|
|
||||||
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
@@ -1,329 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CommonCrypto
|
|
||||||
import CoreFoundation
|
|
||||||
import Security
|
|
||||||
|
|
||||||
// Native MySQL wire protocol client. No external mysql executable is used.
|
|
||||||
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
|
|
||||||
struct MySQLWireError: LocalizedError {
|
|
||||||
let message: String
|
|
||||||
var errorDescription: String? { message }
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ByteCursor {
|
|
||||||
let bytes: [UInt8]
|
|
||||||
var offset = 0
|
|
||||||
mutating func take(_ n: Int) throws -> [UInt8] {
|
|
||||||
guard n >= 0, offset + n <= bytes.count else { throw MySQLWireError(message: "Malformed MySQL packet") }
|
|
||||||
defer { offset += n }
|
|
||||||
return Array(bytes[offset..<(offset+n)])
|
|
||||||
}
|
|
||||||
mutating func u8() throws -> UInt8 { try take(1)[0] }
|
|
||||||
mutating func u16() throws -> Int { let b = try take(2); return Int(b[0]) | (Int(b[1]) << 8) }
|
|
||||||
mutating func u32() throws -> Int { let b = try take(4); return Int(b[0]) | (Int(b[1]) << 8) | (Int(b[2]) << 16) | (Int(b[3]) << 24) }
|
|
||||||
mutating func nulString() throws -> String {
|
|
||||||
let start = offset
|
|
||||||
while offset < bytes.count && bytes[offset] != 0 { offset += 1 }
|
|
||||||
guard offset < bytes.count else { throw MySQLWireError(message: "Unterminated server string") }
|
|
||||||
let s = String(decoding: bytes[start..<offset], as: UTF8.self)
|
|
||||||
offset += 1
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
mutating func lenInt() throws -> Int? {
|
|
||||||
let first = try u8()
|
|
||||||
switch first {
|
|
||||||
case 0xfb: return nil
|
|
||||||
case 0xfc: return try u16()
|
|
||||||
case 0xfd:
|
|
||||||
let b = try take(3); return Int(b[0]) | Int(b[1]) << 8 | Int(b[2]) << 16
|
|
||||||
case 0xfe:
|
|
||||||
let b = try take(8)
|
|
||||||
var v: UInt64 = 0
|
|
||||||
for (i, byte) in b.enumerated() { v |= UInt64(byte) << (8 * i) }
|
|
||||||
guard v <= UInt64(Int.max) else { throw MySQLWireError(message: "Field too large") }
|
|
||||||
return Int(v)
|
|
||||||
default: return Int(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mutating func lenString() throws -> String? {
|
|
||||||
guard let n = try lenInt() else { return nil }
|
|
||||||
return String(decoding: try take(n), as: UTF8.self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func le32(_ v: Int) -> [UInt8] { (0..<4).map { UInt8(truncatingIfNeeded: v >> ($0 * 8)) } }
|
|
||||||
private func sha1(_ data: [UInt8]) -> [UInt8] {
|
|
||||||
var result = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
|
|
||||||
data.withUnsafeBytes { ptr in _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &result) }
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
private func sha256(_ data: [UInt8]) -> [UInt8] {
|
|
||||||
var result = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
||||||
data.withUnsafeBytes { ptr in _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &result) }
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
private func authResponse(password: String, salt: [UInt8], plugin: String) throws -> [UInt8] {
|
|
||||||
if password.isEmpty { return [] }
|
|
||||||
let pwd = Array(password.utf8)
|
|
||||||
if plugin == "mysql_native_password" {
|
|
||||||
let first = sha1(pwd), second = sha1(sha1(pwd))
|
|
||||||
let last = sha1(salt + second)
|
|
||||||
return zip(first, last).map { $0 ^ $1 }
|
|
||||||
}
|
|
||||||
if plugin == "caching_sha2_password" {
|
|
||||||
let first = sha256(pwd), second = sha256(sha256(pwd))
|
|
||||||
let last = sha256(second + salt)
|
|
||||||
return zip(first, last).map { $0 ^ $1 }
|
|
||||||
}
|
|
||||||
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse an RSA SubjectPublicKeyInfo PEM supplied by MySQL. Security.framework
|
|
||||||
// expects the inner PKCS#1 RSAPublicKey DER for SecKeyCreateWithData.
|
|
||||||
private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
|
|
||||||
guard packet.first == 0x01 else {
|
|
||||||
throw MySQLWireError(message: "Expected caching_sha2_password public-key response")
|
|
||||||
}
|
|
||||||
let pem = String(decoding: packet.dropFirst(), as: UTF8.self)
|
|
||||||
let base64 = pem.components(separatedBy: .newlines)
|
|
||||||
.filter { !$0.hasPrefix("-----") }.joined()
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard let spki = Data(base64Encoded: base64) else {
|
|
||||||
throw MySQLWireError(message: "Invalid MySQL RSA public key")
|
|
||||||
}
|
|
||||||
let der = [UInt8](spki)
|
|
||||||
func length(_ index: inout Int) throws -> Int {
|
|
||||||
guard index < der.count else { throw MySQLWireError(message: "Invalid RSA DER length") }
|
|
||||||
let first = Int(der[index]); index += 1
|
|
||||||
if first < 128 { return first }
|
|
||||||
let bytes = first & 0x7f
|
|
||||||
guard bytes > 0, bytes <= 4, index + bytes <= der.count else {
|
|
||||||
throw MySQLWireError(message: "Invalid RSA DER length")
|
|
||||||
}
|
|
||||||
var value = 0
|
|
||||||
for _ in 0..<bytes { value = (value << 8) | Int(der[index]); index += 1 }
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
func element(_ index: inout Int, tag: UInt8) throws -> Range<Int> {
|
|
||||||
guard index < der.count, der[index] == tag else {
|
|
||||||
throw MySQLWireError(message: "Invalid RSA DER element")
|
|
||||||
}
|
|
||||||
index += 1
|
|
||||||
let count = try length(&index)
|
|
||||||
guard count <= der.count - index else { throw MySQLWireError(message: "Truncated RSA DER") }
|
|
||||||
let range = index..<(index + count)
|
|
||||||
index += count
|
|
||||||
return range
|
|
||||||
}
|
|
||||||
var i = 0
|
|
||||||
let outer = try element(&i, tag: 0x30)
|
|
||||||
i = outer.lowerBound
|
|
||||||
_ = try element(&i, tag: 0x30) // AlgorithmIdentifier
|
|
||||||
let bits = try element(&i, tag: 0x03)
|
|
||||||
guard bits.count > 1, der[bits.lowerBound] == 0 else {
|
|
||||||
throw MySQLWireError(message: "Invalid RSA public key bit string")
|
|
||||||
}
|
|
||||||
let pkcs1 = Data(der[(bits.lowerBound + 1)..<bits.upperBound])
|
|
||||||
var error: Unmanaged<CFError>?
|
|
||||||
guard let key = SecKeyCreateWithData(pkcs1 as CFData, [
|
|
||||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
|
||||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic
|
|
||||||
] as CFDictionary, &error) else {
|
|
||||||
throw MySQLWireError(message: "Cannot import MySQL RSA public key: \(error?.takeRetainedValue().localizedDescription ?? "unknown error")")
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
final class MySQLWireConnection {
|
|
||||||
private let input: InputStream
|
|
||||||
private let output: OutputStream
|
|
||||||
private var sequence: UInt8 = 0
|
|
||||||
private let maxPacket = 16_777_215
|
|
||||||
|
|
||||||
init(host: String, port: Int) throws {
|
|
||||||
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
|
|
||||||
var read: Unmanaged<CFReadStream>?
|
|
||||||
var write: Unmanaged<CFWriteStream>?
|
|
||||||
CFStreamCreatePairWithSocketToHost(nil, host as CFString, UInt32(port), &read, &write)
|
|
||||||
guard let r = read?.takeRetainedValue(), let w = write?.takeRetainedValue() else {
|
|
||||||
throw MySQLWireError(message: "Cannot create connection")
|
|
||||||
}
|
|
||||||
input = unsafeBitCast(r, to: InputStream.self)
|
|
||||||
output = unsafeBitCast(w, to: OutputStream.self)
|
|
||||||
input.open(); output.open()
|
|
||||||
// A CFStream may still be .opening immediately after open(). The
|
|
||||||
// first blocking read below waits for the server greeting.
|
|
||||||
if input.streamStatus == .error || output.streamStatus == .error {
|
|
||||||
throw MySQLWireError(message: input.streamError?.localizedDescription ?? output.streamError?.localizedDescription ?? "Could not open MySQL connection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deinit { input.close(); output.close() }
|
|
||||||
|
|
||||||
private func readExact(_ count: Int) throws -> [UInt8] {
|
|
||||||
var buffer = [UInt8](repeating: 0, count: count)
|
|
||||||
var done = 0
|
|
||||||
while done < count {
|
|
||||||
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in
|
|
||||||
guard let base = ptr.baseAddress else { return -1 }
|
|
||||||
return input.read(base.advanced(by: done), maxLength: count - done)
|
|
||||||
}
|
|
||||||
guard n > 0 else { throw MySQLWireError(message: input.streamError?.localizedDescription ?? "MySQL connection closed") }
|
|
||||||
done += n
|
|
||||||
}
|
|
||||||
return buffer
|
|
||||||
}
|
|
||||||
private func readPacket() throws -> [UInt8] {
|
|
||||||
let header = try readExact(4)
|
|
||||||
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
|
|
||||||
sequence = header[3] &+ 1
|
|
||||||
return try readExact(count)
|
|
||||||
}
|
|
||||||
private func send(_ payload: [UInt8]) throws {
|
|
||||||
guard payload.count < maxPacket else { throw MySQLWireError(message: "Query exceeds initial client packet limit") }
|
|
||||||
let count = payload.count
|
|
||||||
let packet = [UInt8(count & 255), UInt8((count >> 8) & 255), UInt8((count >> 16) & 255), sequence] + payload
|
|
||||||
sequence = sequence &+ 1
|
|
||||||
var written = 0
|
|
||||||
while written < packet.count {
|
|
||||||
let n = packet.withUnsafeBufferPointer { ptr -> Int in
|
|
||||||
guard let base = ptr.baseAddress else { return -1 }
|
|
||||||
return output.write(base.advanced(by: written), maxLength: packet.count - written)
|
|
||||||
}
|
|
||||||
guard n > 0 else { throw MySQLWireError(message: output.streamError?.localizedDescription ?? "MySQL write failed") }
|
|
||||||
written += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private func checkError(_ p: [UInt8]) throws {
|
|
||||||
guard p.first == 0xff else { return }
|
|
||||||
let details = String(decoding: p.dropFirst(3), as: UTF8.self)
|
|
||||||
throw MySQLWireError(message: "MySQL error: \(details)")
|
|
||||||
}
|
|
||||||
func authenticate(user: String, password: String, database: String) throws {
|
|
||||||
let hello = try readPacket()
|
|
||||||
var c = ByteCursor(bytes: hello)
|
|
||||||
let version = try c.u8()
|
|
||||||
guard version == 10 else { throw MySQLWireError(message: "Unsupported MySQL handshake") }
|
|
||||||
_ = try c.nulString()
|
|
||||||
_ = try c.u32()
|
|
||||||
let salt1 = try c.take(8)
|
|
||||||
_ = try c.u8()
|
|
||||||
let lower = try c.u16()
|
|
||||||
_ = try c.u8() // character set
|
|
||||||
_ = try c.u16() // status
|
|
||||||
let upper = try c.u16()
|
|
||||||
let capabilities = lower | (upper << 16)
|
|
||||||
let authLen = Int(try c.u8())
|
|
||||||
_ = try c.take(10)
|
|
||||||
let salt2 = try c.take(max(13, authLen - 8))
|
|
||||||
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
|
|
||||||
let plugin = (try? c.nulString()) ?? "mysql_native_password"
|
|
||||||
// This initial implementation deliberately refuses cleartext remote credentials.
|
|
||||||
guard hostIsLoopback else { throw MySQLWireError(message: "Direct remote connections require TLS, which is not yet implemented. Use a local SSH tunnel.") }
|
|
||||||
// CLIENT_LONG_PASSWORD, LONG_FLAG, PROTOCOL_41, TRANSACTIONS,
|
|
||||||
// SECURE_CONNECTION and PLUGIN_AUTH. Do not set DEPRECATE_EOF.
|
|
||||||
let clientFlags = 0x00000001 | 0x00000004 | 0x00000200 | 0x00002000 | 0x00008000 | 0x00080000
|
|
||||||
let response = try authResponse(password: password, salt: Array(salt), plugin: plugin)
|
|
||||||
var body = le32(clientFlags & capabilities) + le32(16_777_216) + [UInt8(45)] + [UInt8](repeating: 0, count: 23)
|
|
||||||
body += Array(user.utf8) + [0, UInt8(response.count)] + response
|
|
||||||
body += Array(plugin.utf8) + [0]
|
|
||||||
try send(body)
|
|
||||||
var reply = try readPacket()
|
|
||||||
try checkError(reply)
|
|
||||||
var currentPlugin = plugin
|
|
||||||
var currentSalt = Array(salt)
|
|
||||||
if reply.first == 0xfe {
|
|
||||||
var switchCursor = ByteCursor(bytes: Array(reply.dropFirst()))
|
|
||||||
let newPlugin = try switchCursor.nulString()
|
|
||||||
let rawSalt = Array(reply.dropFirst(1 + newPlugin.utf8.count + 1))
|
|
||||||
let newSalt = rawSalt.last == 0 ? Array(rawSalt.dropLast()) : rawSalt
|
|
||||||
currentPlugin = newPlugin
|
|
||||||
currentSalt = newSalt
|
|
||||||
try send(authResponse(password: password, salt: newSalt, plugin: newPlugin))
|
|
||||||
reply = try readPacket()
|
|
||||||
try checkError(reply)
|
|
||||||
}
|
|
||||||
if reply.first == 0x01 {
|
|
||||||
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
|
|
||||||
throw MySQLWireError(message: "Unsupported authentication continuation")
|
|
||||||
}
|
|
||||||
switch reply[1] {
|
|
||||||
case 0x03: // Fast authentication accepted; wait for OK.
|
|
||||||
reply = try readPacket()
|
|
||||||
case 0x04: // Full authentication required.
|
|
||||||
guard hostIsLoopback else {
|
|
||||||
throw MySQLWireError(message: "Remote full authentication requires TLS (not implemented). Use a trusted local SSH tunnel.")
|
|
||||||
}
|
|
||||||
if password.isEmpty {
|
|
||||||
try send([0])
|
|
||||||
} else {
|
|
||||||
// Request the server's RSA public key (0x02). This is
|
|
||||||
// intended ONLY for a trusted local loopback connection.
|
|
||||||
try send([0x02])
|
|
||||||
let keyPacket = try readPacket()
|
|
||||||
try checkError(keyPacket)
|
|
||||||
let key = try mysqlRSAPublicKey(keyPacket)
|
|
||||||
guard !currentSalt.isEmpty else {
|
|
||||||
throw MySQLWireError(message: "Missing MySQL authentication nonce")
|
|
||||||
}
|
|
||||||
var scrambled = Array(password.utf8) + [0]
|
|
||||||
for i in scrambled.indices { scrambled[i] ^= currentSalt[i % currentSalt.count] }
|
|
||||||
var rsaError: Unmanaged<CFError>?
|
|
||||||
guard let encrypted = SecKeyCreateEncryptedData(
|
|
||||||
key, .rsaEncryptionOAEPSHA1, Data(scrambled) as CFData, &rsaError
|
|
||||||
) as Data? else {
|
|
||||||
throw MySQLWireError(message: "MySQL RSA encryption failed: \(rsaError?.takeRetainedValue().localizedDescription ?? "unknown error")")
|
|
||||||
}
|
|
||||||
try send(Array(encrypted))
|
|
||||||
}
|
|
||||||
reply = try readPacket()
|
|
||||||
default:
|
|
||||||
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try checkError(reply)
|
|
||||||
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
|
|
||||||
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
|
|
||||||
}
|
|
||||||
private var hostIsLoopback = false
|
|
||||||
func markHost(_ host: String) { hostIsLoopback = ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) }
|
|
||||||
|
|
||||||
func query(_ sql: String) throws -> QueryResult {
|
|
||||||
sequence = 0
|
|
||||||
try send([0x03] + Array(sql.utf8))
|
|
||||||
let first = try readPacket()
|
|
||||||
try checkError(first)
|
|
||||||
if first.first == 0x00 { return QueryResult(message: "Statement executed successfully") }
|
|
||||||
var c = ByteCursor(bytes: first)
|
|
||||||
guard let columnCount = try c.lenInt(), columnCount <= 4096 else { throw MySQLWireError(message: "Invalid column count") }
|
|
||||||
var names: [String] = []
|
|
||||||
for _ in 0..<columnCount {
|
|
||||||
var field = ByteCursor(bytes: try readPacket())
|
|
||||||
for _ in 0..<4 { _ = try field.lenString() }
|
|
||||||
names.append(try field.lenString() ?? "")
|
|
||||||
}
|
|
||||||
_ = try readPacket() // column EOF (initial protocol requires EOF)
|
|
||||||
var rows: [[String]] = []
|
|
||||||
while true {
|
|
||||||
let packet = try readPacket()
|
|
||||||
try checkError(packet)
|
|
||||||
if packet.first == 0xfe && packet.count < 9 { break }
|
|
||||||
var row = ByteCursor(bytes: packet)
|
|
||||||
var values: [String] = []
|
|
||||||
for _ in 0..<columnCount { values.append(try row.lenString() ?? "NULL") }
|
|
||||||
rows.append(values)
|
|
||||||
if rows.count > 100_000 { throw MySQLWireError(message: "Result exceeds 100,000 row safety limit") }
|
|
||||||
}
|
|
||||||
return QueryResult(columns: names, rows: rows, message: "\(rows.count) row(s)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum NativeMySQLRunner {
|
|
||||||
static func run(profile: ConnectionProfile, database: String, password: String, sql: String) async throws -> QueryResult {
|
|
||||||
try await Task.detached(priority: .userInitiated) {
|
|
||||||
let connection = try MySQLWireConnection(host: profile.host, port: profile.port)
|
|
||||||
connection.markHost(profile.host)
|
|
||||||
try connection.authenticate(user: profile.username, password: password, database: database)
|
|
||||||
return try connection.query(sql)
|
|
||||||
}.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
+91
-27
@@ -327,7 +327,7 @@
|
|||||||
"key": "Import SQL into %@?",
|
"key": "Import SQL into %@?",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 13,
|
"startingColumn": 13,
|
||||||
"startingLine": 1680
|
"startingLine": 1725
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
"key": "Operation",
|
"key": "Operation",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 20,
|
"startingColumn": 20,
|
||||||
"startingLine": 1581
|
"startingLine": 1589
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
"key": "Data Export",
|
"key": "Data Export",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 22,
|
"startingColumn": 22,
|
||||||
"startingLine": 1582
|
"startingLine": 1590
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
"key": "Data Import / Restore",
|
"key": "Data Import / Restore",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 22,
|
"startingColumn": 22,
|
||||||
"startingLine": 1583
|
"startingLine": 1591
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
"key": "MySQL Connection",
|
"key": "MySQL Connection",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 25,
|
"startingColumn": 25,
|
||||||
"startingLine": 1589
|
"startingLine": 1597
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -367,7 +367,7 @@
|
|||||||
"key": "Export Options",
|
"key": "Export Options",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 29,
|
"startingColumn": 29,
|
||||||
"startingLine": 1604
|
"startingLine": 1619
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -375,7 +375,7 @@
|
|||||||
"key": "Import Options",
|
"key": "Import Options",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 29,
|
"startingColumn": 29,
|
||||||
"startingLine": 1613
|
"startingLine": 1651
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -383,7 +383,7 @@
|
|||||||
"key": "Progress",
|
"key": "Progress",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 25,
|
"startingColumn": 25,
|
||||||
"startingLine": 1623
|
"startingLine": 1661
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -391,7 +391,7 @@
|
|||||||
"key": "Connection",
|
"key": "Connection",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 36,
|
"startingColumn": 36,
|
||||||
"startingLine": 1590
|
"startingLine": 1598
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -399,7 +399,7 @@
|
|||||||
"key": "Database",
|
"key": "Database",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 28,
|
"startingColumn": 28,
|
||||||
"startingLine": 1594
|
"startingLine": 1602
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -407,23 +407,87 @@
|
|||||||
"key": "Select Database",
|
"key": "Select Database",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 30,
|
"startingColumn": 30,
|
||||||
"startingLine": 1595
|
"startingLine": 1603
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"key": "Export the SQL definitions of all base tables in the selected database.",
|
"key": "Refresh Databases",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 30,
|
"startingColumn": 32,
|
||||||
"startingLine": 1605
|
"startingLine": 1611
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"key": "This version exports table structure. Table data, views, triggers and stored routines will be added in the next stage.",
|
"key": "Exports base tables with batched INSERT statements. Views, triggers, routines and transaction-consistent snapshots are not included.",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 30,
|
"startingColumn": 30,
|
||||||
"startingLine": 1608
|
"startingLine": 1647
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"key": "Table structure",
|
||||||
|
"location": {
|
||||||
|
"startingColumn": 32,
|
||||||
|
"startingLine": 1620
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"key": "Table data",
|
||||||
|
"location": {
|
||||||
|
"startingColumn": 32,
|
||||||
|
"startingLine": 1621
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"key": "Add DROP TABLE IF EXISTS",
|
||||||
|
"location": {
|
||||||
|
"startingColumn": 32,
|
||||||
|
"startingLine": 1622
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"key": "Loading tables…",
|
||||||
|
"location": {
|
||||||
|
"startingColumn": 57,
|
||||||
|
"startingLine": 1632
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"key": "Tables (%lld selected)",
|
||||||
|
"location": {
|
||||||
|
"startingColumn": 34,
|
||||||
|
"startingLine": 1625
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"key": "All",
|
||||||
|
"location": {
|
||||||
|
"startingColumn": 36,
|
||||||
|
"startingLine": 1627
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"key": "None",
|
||||||
|
"location": {
|
||||||
|
"startingColumn": 36,
|
||||||
|
"startingLine": 1628
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"key": "Refresh",
|
||||||
|
"location": {
|
||||||
|
"startingColumn": 36,
|
||||||
|
"startingLine": 1629
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -431,7 +495,7 @@
|
|||||||
"key": "Import SQL statements into the selected database.",
|
"key": "Import SQL statements into the selected database.",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 30,
|
"startingColumn": 30,
|
||||||
"startingLine": 1614
|
"startingLine": 1652
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -439,7 +503,7 @@
|
|||||||
"key": "Supports ordinary SQL statements. Dumps using custom DELIMITER directives or stored routines are not supported yet.",
|
"key": "Supports ordinary SQL statements. Dumps using custom DELIMITER directives or stored routines are not supported yet.",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 30,
|
"startingColumn": 30,
|
||||||
"startingLine": 1617
|
"startingLine": 1655
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -447,7 +511,7 @@
|
|||||||
"key": "Close",
|
"key": "Close",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 24,
|
"startingColumn": 24,
|
||||||
"startingLine": 1638
|
"startingLine": 1676
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -455,7 +519,7 @@
|
|||||||
"key": "Cancel",
|
"key": "Cancel",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 28,
|
"startingColumn": 28,
|
||||||
"startingLine": 1646
|
"startingLine": 1684
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -463,7 +527,7 @@
|
|||||||
"key": "Export SQL…",
|
"key": "Export SQL…",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 31,
|
"startingColumn": 31,
|
||||||
"startingLine": 1653
|
"startingLine": 1691
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -471,7 +535,7 @@
|
|||||||
"key": "Import SQL…",
|
"key": "Import SQL…",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 31,
|
"startingColumn": 31,
|
||||||
"startingLine": 1654
|
"startingLine": 1692
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -479,7 +543,7 @@
|
|||||||
"key": "Import SQL",
|
"key": "Import SQL",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 20,
|
"startingColumn": 20,
|
||||||
"startingLine": 1683
|
"startingLine": 1728
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -487,7 +551,7 @@
|
|||||||
"key": "Cancel",
|
"key": "Cancel",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 20,
|
"startingColumn": 20,
|
||||||
"startingLine": 1691
|
"startingLine": 1736
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -495,7 +559,7 @@
|
|||||||
"key": "SQL files can modify or delete existing database objects and data. Ensure you have a backup before continuing.",
|
"key": "SQL files can modify or delete existing database objects and data. Ensure you have a backup before continuing.",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 17,
|
"startingColumn": 17,
|
||||||
"startingLine": 1696
|
"startingLine": 1741
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -503,7 +567,7 @@
|
|||||||
"key": "Database Import / Export",
|
"key": "Database Import / Export",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 22,
|
"startingColumn": 22,
|
||||||
"startingLine": 1708
|
"startingLine": 1753
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -511,7 +575,7 @@
|
|||||||
"key": "Built-in native MySQL database tools",
|
"key": "Built-in native MySQL database tools",
|
||||||
"location": {
|
"location": {
|
||||||
"startingColumn": 22,
|
"startingColumn": 22,
|
||||||
"startingLine": 1711
|
"startingLine": 1756
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -5,3 +5,5 @@
|
|||||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
|
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
|
||||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
|
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
|
||||||
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
|
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
|
||||||
|
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
|
||||||
|
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>cdhash</key>
|
<key>cdhash</key>
|
||||||
<data>
|
<data>
|
||||||
aPFwAkm/guZ4EeZintWn1SNqigs=
|
kACWojMyCPE6G6Uw22tGzRrP2x4=
|
||||||
</data>
|
</data>
|
||||||
<key>requirement</key>
|
<key>requirement</key>
|
||||||
<string>identifier "WorkbenchReplacement.debug" and anchor apple generic and certificate leaf[subject.CN] = "Apple Development: Mark Gregory (FLBRJ3BYDD)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */</string>
|
<string>identifier "WorkbenchReplacement.debug" and anchor apple generic and certificate leaf[subject.CN] = "Apple Development: Mark Gregory (FLBRJ3BYDD)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */</string>
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -6,7 +6,7 @@
|
|||||||
<integer>13</integer>
|
<integer>13</integer>
|
||||||
<key>logs</key>
|
<key>logs</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61</key>
|
<key>087CD1E0-7E94-4934-A62E-C6C070CD1577</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>className</key>
|
<key>className</key>
|
||||||
<string>IDECommandLineBuildLog</string>
|
<string>IDECommandLineBuildLog</string>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>domainType</key>
|
<key>domainType</key>
|
||||||
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
|
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
|
||||||
<key>fileName</key>
|
<key>fileName</key>
|
||||||
<string>5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61.xcactivitylog</string>
|
<string>087CD1E0-7E94-4934-A62E-C6C070CD1577.xcactivitylog</string>
|
||||||
<key>hasPrimaryLog</key>
|
<key>hasPrimaryLog</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>primaryObservable</key>
|
<key>primaryObservable</key>
|
||||||
@@ -40,55 +40,13 @@
|
|||||||
<key>signature</key>
|
<key>signature</key>
|
||||||
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
||||||
<key>timeStartedRecording</key>
|
<key>timeStartedRecording</key>
|
||||||
<real>813875595.87715495</real>
|
<real>813876132.17963398</real>
|
||||||
<key>timeStoppedRecording</key>
|
<key>timeStoppedRecording</key>
|
||||||
<real>813875606.38803506</real>
|
<real>813876144.18060398</real>
|
||||||
<key>title</key>
|
<key>title</key>
|
||||||
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
||||||
<key>uniqueIdentifier</key>
|
<key>uniqueIdentifier</key>
|
||||||
<string>5D8CEBC3-1273-4D6E-BDAB-8D198A9BCA61</string>
|
<string>087CD1E0-7E94-4934-A62E-C6C070CD1577</string>
|
||||||
</dict>
|
|
||||||
<key>8EB6F71B-1109-492D-84B8-8C2FE5C14BB0</key>
|
|
||||||
<dict>
|
|
||||||
<key>className</key>
|
|
||||||
<string>IDECommandLineBuildLog</string>
|
|
||||||
<key>documentTypeString</key>
|
|
||||||
<string><nil></string>
|
|
||||||
<key>domainType</key>
|
|
||||||
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
|
|
||||||
<key>fileName</key>
|
|
||||||
<string>8EB6F71B-1109-492D-84B8-8C2FE5C14BB0.xcactivitylog</string>
|
|
||||||
<key>hasPrimaryLog</key>
|
|
||||||
<true/>
|
|
||||||
<key>primaryObservable</key>
|
|
||||||
<dict>
|
|
||||||
<key>highLevelStatus</key>
|
|
||||||
<string>S</string>
|
|
||||||
<key>totalNumberOfAnalyzerIssues</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
<key>totalNumberOfErrors</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
<key>totalNumberOfTestFailures</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
<key>totalNumberOfWarnings</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
</dict>
|
|
||||||
<key>schemeIdentifier-containerName</key>
|
|
||||||
<string>WorkbenchReplacement project</string>
|
|
||||||
<key>schemeIdentifier-schemeName</key>
|
|
||||||
<string>WorkbenchReplacement</string>
|
|
||||||
<key>schemeIdentifier-sharedScheme</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>signature</key>
|
|
||||||
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
|
||||||
<key>timeStartedRecording</key>
|
|
||||||
<real>813875432.68892801</real>
|
|
||||||
<key>timeStoppedRecording</key>
|
|
||||||
<real>813875443.71130598</real>
|
|
||||||
<key>title</key>
|
|
||||||
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
|
||||||
<key>uniqueIdentifier</key>
|
|
||||||
<string>8EB6F71B-1109-492D-84B8-8C2FE5C14BB0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>AD44F407-7FE5-46AE-A82F-8FAC1CA2720B</key>
|
<key>AD44F407-7FE5-46AE-A82F-8FAC1CA2720B</key>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -132,6 +90,48 @@
|
|||||||
<key>uniqueIdentifier</key>
|
<key>uniqueIdentifier</key>
|
||||||
<string>AD44F407-7FE5-46AE-A82F-8FAC1CA2720B</string>
|
<string>AD44F407-7FE5-46AE-A82F-8FAC1CA2720B</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>AF582207-87B3-4A2E-A532-DA0FD8F61FBD</key>
|
||||||
|
<dict>
|
||||||
|
<key>className</key>
|
||||||
|
<string>IDECommandLineBuildLog</string>
|
||||||
|
<key>documentTypeString</key>
|
||||||
|
<string><nil></string>
|
||||||
|
<key>domainType</key>
|
||||||
|
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
|
||||||
|
<key>fileName</key>
|
||||||
|
<string>AF582207-87B3-4A2E-A532-DA0FD8F61FBD.xcactivitylog</string>
|
||||||
|
<key>hasPrimaryLog</key>
|
||||||
|
<true/>
|
||||||
|
<key>primaryObservable</key>
|
||||||
|
<dict>
|
||||||
|
<key>highLevelStatus</key>
|
||||||
|
<string>S</string>
|
||||||
|
<key>totalNumberOfAnalyzerIssues</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>totalNumberOfErrors</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>totalNumberOfTestFailures</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>totalNumberOfWarnings</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
<key>schemeIdentifier-containerName</key>
|
||||||
|
<string>WorkbenchReplacement project</string>
|
||||||
|
<key>schemeIdentifier-schemeName</key>
|
||||||
|
<string>WorkbenchReplacement</string>
|
||||||
|
<key>schemeIdentifier-sharedScheme</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>signature</key>
|
||||||
|
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
||||||
|
<key>timeStartedRecording</key>
|
||||||
|
<real>813876270.55029094</real>
|
||||||
|
<key>timeStoppedRecording</key>
|
||||||
|
<real>813876283.37291896</real>
|
||||||
|
<key>title</key>
|
||||||
|
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
||||||
|
<key>uniqueIdentifier</key>
|
||||||
|
<string>AF582207-87B3-4A2E-A532-DA0FD8F61FBD</string>
|
||||||
|
</dict>
|
||||||
<key>BA4E5983-8DAA-49CA-A677-11E73E67F0B0</key>
|
<key>BA4E5983-8DAA-49CA-A677-11E73E67F0B0</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>className</key>
|
<key>className</key>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1792183067.957757: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
|
1792183470.9957151: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>LastAccessedDate</key>
|
<key>LastAccessedDate</key>
|
||||||
<date>2026-10-16T20:37:47Z</date>
|
<date>2026-10-16T20:44:30Z</date>
|
||||||
<key>WorkspacePath</key>
|
<key>WorkspacePath</key>
|
||||||
<string>/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj</string>
|
<string>/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
Reference in New Issue
Block a user