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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user