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:
@@ -1344,6 +1344,43 @@ struct SQLTextView: NSViewRepresentable {
|
||||
// MARK: - Connection Editor
|
||||
|
||||
|
||||
// MARK: - Phase 23: Locally stored EXPLAIN snapshots
|
||||
|
||||
private struct WorkbenchPlanSnapshot: Identifiable, Codable {
|
||||
var id = UUID()
|
||||
var capturedAt = Date()
|
||||
var connectionName: String
|
||||
var database: String
|
||||
var statement: String
|
||||
var format: String
|
||||
var columnNames: [String]
|
||||
var resultRows: [[String]]
|
||||
var elapsedSeconds: Double
|
||||
|
||||
var label: String {
|
||||
"\(capturedAt.formatted(date: .abbreviated, time: .shortened)) · \(format) · \(database)"
|
||||
}
|
||||
}
|
||||
|
||||
private enum WorkbenchPlanArchive {
|
||||
private static let key = "WorkbenchReplacement.explainSnapshots.v1"
|
||||
|
||||
static func load() -> [WorkbenchPlanSnapshot] {
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let snapshots = try? JSONDecoder().decode([WorkbenchPlanSnapshot].self, from: data)
|
||||
else { return [] }
|
||||
return snapshots
|
||||
}
|
||||
|
||||
static func save(_ snapshots: [WorkbenchPlanSnapshot]) {
|
||||
// Cap both snapshot count and size so EXPLAIN JSON cannot grow preferences without bound.
|
||||
let trimmed = Array(snapshots.prefix(40))
|
||||
if let data = try? JSONEncoder().encode(trimmed), data.count <= 2_000_000 {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 21: Native MySQL query plan inspector
|
||||
|
||||
private struct WorkbenchExplainView: View {
|
||||
@@ -1356,6 +1393,44 @@ private struct WorkbenchExplainView: View {
|
||||
@State private var isLoading = false
|
||||
@State private var useJSON = false
|
||||
@State private var duration: TimeInterval?
|
||||
@State private var snapshots: [WorkbenchPlanSnapshot] = WorkbenchPlanArchive.load()
|
||||
@State private var baselineID: UUID?
|
||||
@State private var showHistory = false
|
||||
|
||||
private var matchingSnapshots: [WorkbenchPlanSnapshot] {
|
||||
snapshots.filter {
|
||||
$0.statement == normalizedStatement &&
|
||||
$0.database == (model.selectedDatabase.isEmpty ? (model.selected?.database ?? "") : model.selectedDatabase) &&
|
||||
$0.connectionName == (model.selected?.name ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var baseline: WorkbenchPlanSnapshot? {
|
||||
matchingSnapshots.first { $0.id == baselineID }
|
||||
}
|
||||
|
||||
private var comparisonText: String? {
|
||||
guard let baseline, !plan.columns.isEmpty else { return nil }
|
||||
guard baseline.format == (useJSON ? "JSON" : "Table") else {
|
||||
return "Baseline format differs from the current plan. Select a matching format."
|
||||
}
|
||||
if useJSON {
|
||||
return baseline.resultRows == plan.rows ? "JSON plan unchanged from baseline." : "JSON plan differs from baseline. Inspect the plan text for details."
|
||||
}
|
||||
func estimatedRows(_ columns: [String], _ rows: [[String]]) -> Int? {
|
||||
guard let index = columns.firstIndex(where: { $0.lowercased() == "rows" }) else { return nil }
|
||||
let values = rows.compactMap { index < $0.count ? Int($0[index]) : nil }
|
||||
return values.isEmpty ? nil : values.reduce(0, +)
|
||||
}
|
||||
let oldEstimate = estimatedRows(baseline.columnNames, baseline.resultRows)
|
||||
let newEstimate = estimatedRows(plan.columns, plan.rows)
|
||||
let changed = baseline.columnNames != plan.columns || baseline.resultRows != plan.rows
|
||||
var message = changed ? "Plan changed since baseline." : "Plan unchanged from baseline."
|
||||
if let oldEstimate, let newEstimate {
|
||||
message += " Estimated rows across steps: \(oldEstimate) → \(newEstimate)."
|
||||
}
|
||||
return message + " Estimates are not measured query timings."
|
||||
}
|
||||
|
||||
private var normalizedStatement: String {
|
||||
var value = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -1443,6 +1518,15 @@ private struct WorkbenchExplainView: View {
|
||||
.frame(width: 240)
|
||||
Button("Run EXPLAIN") { Task { await loadPlan() } }
|
||||
.disabled(isLoading || !eligible || model.selected == nil)
|
||||
Button("Save Snapshot") { saveSnapshot() }
|
||||
.disabled(plan.columns.isEmpty || isLoading)
|
||||
Button("History") { showHistory.toggle() }
|
||||
if let baseline {
|
||||
Text("Comparing with \(baseline.capturedAt.formatted(date: .abbreviated, time: .shortened))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Clear Baseline") { baselineID = nil }
|
||||
}
|
||||
Button("Copy Plan") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(copyablePlan, forType: .string)
|
||||
@@ -1476,6 +1560,38 @@ private struct WorkbenchExplainView: View {
|
||||
} else {
|
||||
ContentUnavailableView("No plan loaded", systemImage: "chart.bar.doc.horizontal")
|
||||
}
|
||||
if showHistory {
|
||||
DisclosureGroup("Saved plans for this query (\(matchingSnapshots.count))", isExpanded: $showHistory) {
|
||||
if matchingSnapshots.isEmpty {
|
||||
Text("No saved plans for this connection, schema and SQL statement.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(matchingSnapshots) { snapshot in
|
||||
HStack {
|
||||
Text(snapshot.label)
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Button("Compare") { baselineID = snapshot.id }
|
||||
Button("Delete", role: .destructive) {
|
||||
snapshots.removeAll { $0.id == snapshot.id }
|
||||
if baselineID == snapshot.id { baselineID = nil }
|
||||
WorkbenchPlanArchive.save(snapshots)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 130)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let comparisonText {
|
||||
Label(comparisonText, systemImage: "arrow.left.arrow.right")
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if !planInsights.isEmpty {
|
||||
DisclosureGroup("Plan observations (\(planInsights.count))") {
|
||||
ScrollView {
|
||||
@@ -1505,6 +1621,24 @@ private struct WorkbenchExplainView: View {
|
||||
.task { await loadPlan() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func saveSnapshot() {
|
||||
guard let profile = model.selected, !plan.columns.isEmpty else { return }
|
||||
let database = model.selectedDatabase.isEmpty ? profile.database : model.selectedDatabase
|
||||
let snapshot = WorkbenchPlanSnapshot(
|
||||
connectionName: profile.name,
|
||||
database: database,
|
||||
statement: normalizedStatement,
|
||||
format: useJSON ? "JSON" : "Table",
|
||||
columnNames: plan.columns,
|
||||
resultRows: plan.rows,
|
||||
elapsedSeconds: duration ?? 0
|
||||
)
|
||||
snapshots.insert(snapshot, at: 0)
|
||||
WorkbenchPlanArchive.save(snapshots)
|
||||
showHistory = true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadPlan() async {
|
||||
guard eligible, let profile = model.selected, !isLoading else { return }
|
||||
|
||||
Reference in New Issue
Block a user