Improved progress display
Previously, the progress bar updated only after completing an entire table. For large tables such as LIBARC, this could make the application appear to have stopped responding. Phase 10 updates the interface while rows are being exported.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Phase 21 — SQL Query Plan Inspector
|
||||
|
||||
Based on Phase 20.
|
||||
|
||||
## Added
|
||||
- **Explain** button in SQL editor toolbar. Uses selected SQL when present, otherwise editor SQL.
|
||||
- Separate query-plan window showing MySQL `EXPLAIN` in table format or `EXPLAIN FORMAT=JSON`.
|
||||
- Shows optimizer plan columns in the existing native result grid; JSON format is selectable and copyable.
|
||||
- Displays diagnostic query duration and server errors.
|
||||
- Limits analysis to SELECT/WITH statements, does not run `EXPLAIN ANALYZE` (which executes the query).
|
||||
- Does not replace editor query results or add EXPLAIN to normal query history.
|
||||
|
||||
## Notes
|
||||
- Requires an active connection and MySQL permissions to explain the query.
|
||||
- `EXPLAIN` gives estimated plans, not actual execution metrics.
|
||||
- Only a single SQL statement should be selected; multi-statement SQL is not supported in the plan inspector.
|
||||
- Not built in Xcode or tested against a live MySQL server in this environment.
|
||||
@@ -35,6 +35,7 @@ struct MainView: View {
|
||||
private let resultPageSize = 1_000
|
||||
@State private var showingEditor = false
|
||||
@State private var showingDatabaseTransfer = false
|
||||
@State private var showingQueryPlan = false
|
||||
@State private var showingQueryHistory = false
|
||||
@State private var showingSavedQueries = false
|
||||
@State private var showingSaveQuery = false
|
||||
@@ -86,6 +87,9 @@ struct MainView: View {
|
||||
model.loadDatabases()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingQueryPlan) {
|
||||
WorkbenchExplainView(model: model, statement: selectedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.sql : selectedSQL)
|
||||
}
|
||||
.sheet(isPresented: $showingQueryHistory) {
|
||||
SQLHistorySheet(model: model)
|
||||
}
|
||||
@@ -683,6 +687,14 @@ struct MainView: View {
|
||||
.disabled(selectedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.help("Execute selected SQL without modifying the editor")
|
||||
|
||||
Button {
|
||||
showingQueryPlan = true
|
||||
} label: {
|
||||
Label("Explain", systemImage: "chart.bar.doc.horizontal")
|
||||
}
|
||||
.disabled(model.isRunning || model.selected == nil)
|
||||
.help("Inspect the optimizer plan for the selected SQL or editor contents")
|
||||
|
||||
Button {
|
||||
model.formatSQL()
|
||||
} label: {
|
||||
@@ -1317,6 +1329,130 @@ struct SQLTextView: NSViewRepresentable {
|
||||
|
||||
// MARK: - Connection Editor
|
||||
|
||||
|
||||
// MARK: - Phase 21: Native MySQL query plan inspector
|
||||
|
||||
private struct WorkbenchExplainView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: DatabaseModel
|
||||
let statement: String
|
||||
|
||||
@State private var plan = QueryResult()
|
||||
@State private var errorText: String?
|
||||
@State private var isLoading = false
|
||||
@State private var useJSON = false
|
||||
@State private var duration: TimeInterval?
|
||||
|
||||
private var normalizedStatement: String {
|
||||
var value = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasSuffix(";") { value.removeLast() }
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var eligible: Bool {
|
||||
// Deliberately restrict this inspector to SELECT queries. EXPLAIN for
|
||||
// UPDATE/DELETE is supported by MySQL but not offered here to keep
|
||||
// the first diagnostic release conservative.
|
||||
let upper = normalizedStatement.uppercased()
|
||||
return upper.hasPrefix("SELECT ") || upper.hasPrefix("SELECT\n") ||
|
||||
upper.hasPrefix("WITH ") || upper.hasPrefix("WITH\n")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label("Query Execution Plan", systemImage: "chart.bar.doc.horizontal")
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
Text("Uses MySQL EXPLAIN to inspect the optimizer's estimated plan without executing the SELECT statement itself.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(normalizedStatement)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.lineLimit(4)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
HStack {
|
||||
Picker("Plan format", selection: $useJSON) {
|
||||
Text("Table").tag(false)
|
||||
Text("JSON").tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 240)
|
||||
Button("Run EXPLAIN") { Task { await loadPlan() } }
|
||||
.disabled(isLoading || !eligible || model.selected == nil)
|
||||
if isLoading { ProgressView().controlSize(.small) }
|
||||
if let duration {
|
||||
Text(String(format: "%.3f s", duration))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
if !eligible {
|
||||
ContentUnavailableView("SELECT query required", systemImage: "exclamationmark.triangle", description: Text("Select a SELECT or WITH query in the SQL editor before opening EXPLAIN."))
|
||||
} else if let errorText {
|
||||
ScrollView {
|
||||
Text(errorText)
|
||||
.foregroundStyle(.red)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else if useJSON, let json = plan.rows.first?.first {
|
||||
ScrollView([.vertical, .horizontal]) {
|
||||
Text(json)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else if !plan.columns.isEmpty {
|
||||
NativeResultGrid(columns: plan.columns, rows: plan.rows)
|
||||
} else {
|
||||
ContentUnavailableView("No plan loaded", systemImage: "chart.bar.doc.horizontal")
|
||||
}
|
||||
Text("EXPLAIN shows estimates, not actual query execution times. The plan depends on the server's current statistics and configuration.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(minWidth: 760, minHeight: 490)
|
||||
.onChange(of: useJSON) { _, _ in
|
||||
plan = QueryResult()
|
||||
errorText = nil
|
||||
Task { await loadPlan() }
|
||||
}
|
||||
.task { await loadPlan() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadPlan() async {
|
||||
guard eligible, let profile = model.selected, !isLoading else { return }
|
||||
isLoading = true
|
||||
errorText = nil
|
||||
plan = QueryResult()
|
||||
let started = Date()
|
||||
defer {
|
||||
duration = Date().timeIntervalSince(started)
|
||||
isLoading = false
|
||||
}
|
||||
let database = model.selectedDatabase.isEmpty ? profile.database : model.selectedDatabase
|
||||
let query = (useJSON ? "EXPLAIN FORMAT=JSON " : "EXPLAIN ") + normalizedStatement
|
||||
do {
|
||||
plan = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: database,
|
||||
password: PasswordStore.read(profile.id),
|
||||
sql: query
|
||||
)
|
||||
} catch {
|
||||
errorText = "EXPLAIN failed: " + error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionEditor: View {
|
||||
@Binding var profile: ConnectionProfile
|
||||
@Binding var password: String
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+255
-151
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -26,3 +26,4 @@
|
||||
/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>
|
||||
<key>cdhash</key>
|
||||
<data>
|
||||
/Hh7cyggIpVXXYLygkR1SnAFcAM=
|
||||
gxPm9V32PbdbRR8yOyYXPS9q9hU=
|
||||
</data>
|
||||
<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>
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -6,48 +6,6 @@
|
||||
<integer>13</integer>
|
||||
<key>logs</key>
|
||||
<dict>
|
||||
<key>04704647-E534-492A-ADC4-87C265C050C8</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>04704647-E534-492A-ADC4-87C265C050C8.xcactivitylog</string>
|
||||
<key>hasPrimaryLog</key>
|
||||
<true/>
|
||||
<key>primaryObservable</key>
|
||||
<dict>
|
||||
<key>highLevelStatus</key>
|
||||
<string>E</string>
|
||||
<key>totalNumberOfAnalyzerIssues</key>
|
||||
<integer>0</integer>
|
||||
<key>totalNumberOfErrors</key>
|
||||
<integer>1</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>813879039.75688195</real>
|
||||
<key>timeStoppedRecording</key>
|
||||
<real>813879050.66503203</real>
|
||||
<key>title</key>
|
||||
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
||||
<key>uniqueIdentifier</key>
|
||||
<string>04704647-E534-492A-ADC4-87C265C050C8</string>
|
||||
</dict>
|
||||
<key>0C437F7E-D2AF-4660-9194-7E7E610BB7CE</key>
|
||||
<dict>
|
||||
<key>className</key>
|
||||
@@ -216,6 +174,48 @@
|
||||
<key>uniqueIdentifier</key>
|
||||
<string>67C568D7-BB93-4240-AB48-EF7CBFC85D9F</string>
|
||||
</dict>
|
||||
<key>9B404BEF-D5CC-4B4B-93CF-3A0C95C748D2</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>9B404BEF-D5CC-4B4B-93CF-3A0C95C748D2.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>813879702.54720795</real>
|
||||
<key>timeStoppedRecording</key>
|
||||
<real>813879718.93544304</real>
|
||||
<key>title</key>
|
||||
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
|
||||
<key>uniqueIdentifier</key>
|
||||
<string>9B404BEF-D5CC-4B4B-93CF-3A0C95C748D2</string>
|
||||
</dict>
|
||||
<key>C0104C63-BBE9-4E40-811C-E7DC2E1277F6</key>
|
||||
<dict>
|
||||
<key>className</key>
|
||||
|
||||
@@ -1 +1 @@
|
||||
1792186785.4944181: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
|
||||
1792186902.93557: 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">
|
||||
<dict>
|
||||
<key>LastAccessedDate</key>
|
||||
<date>2026-10-16T21:39:45Z</date>
|
||||
<date>2026-10-16T21:41:42Z</date>
|
||||
<key>WorkspacePath</key>
|
||||
<string>/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj</string>
|
||||
</dict>
|
||||
|
||||
Reference in New Issue
Block a user