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:
Mark Gregory
2026-10-16 22:42:20 +01:00
parent 3f3c7cb0ba
commit 5db95d7212
23 changed files with 454 additions and 196 deletions
+17
View File
@@ -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.
+136
View File
@@ -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
@@ -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
@@ -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>
+42 -42
View File
@@ -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>&lt;nil&gt;</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>&lt;nil&gt;</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")
+1 -1
View File
@@ -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>