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
+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