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:
@@ -1,5 +1,6 @@
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import UniformTypeIdentifiers
|
||||
@@ -35,6 +36,7 @@ struct MainView: View {
|
||||
private let resultPageSize = 1_000
|
||||
@State private var showingEditor = false
|
||||
@State private var showingDatabaseTransfer = false
|
||||
@State private var schemaEditorTarget: SchemaEditorTarget? = nil
|
||||
@State private var showingQueryPlan = false
|
||||
@State private var showingQueryHistory = false
|
||||
@State private var showingSavedQueries = false
|
||||
@@ -106,6 +108,13 @@ struct MainView: View {
|
||||
.sheet(isPresented: $showingDatabaseTransfer) {
|
||||
WorkbenchDatabaseTransferView(model: model)
|
||||
}
|
||||
.sheet(item: $schemaEditorTarget, onDismiss: {
|
||||
if !model.selectedDatabase.isEmpty {
|
||||
model.loadTables(database: model.selectedDatabase)
|
||||
}
|
||||
}) { target in
|
||||
WorkbenchSchemaEditorView(profile: model.selected, target: target)
|
||||
}
|
||||
.sheet(isPresented: $showingEditor) {
|
||||
ConnectionEditor(
|
||||
profile: $editing,
|
||||
@@ -420,6 +429,9 @@ struct MainView: View {
|
||||
Button("Refresh Tables") {
|
||||
model.loadTables(database: db)
|
||||
}
|
||||
Button("Create Table…") {
|
||||
schemaEditorTarget = SchemaEditorTarget(database: db, table: nil)
|
||||
}
|
||||
}
|
||||
|
||||
if expandedSchemas.contains(db) {
|
||||
@@ -550,6 +562,9 @@ struct MainView: View {
|
||||
table: table
|
||||
)
|
||||
}
|
||||
Button("Edit Table Schema…") {
|
||||
schemaEditorTarget = SchemaEditorTarget(database: database, table: table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4133,6 +4148,38 @@ private struct WorkbenchDiagnosticTrendsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@State private var selectedMetric = ""
|
||||
|
||||
private var metricNames: [String] {
|
||||
guard let last = samples.last else { return [] }
|
||||
return Array(metricMap(last).keys).sorted()
|
||||
}
|
||||
|
||||
private var activeMetric: String {
|
||||
metricNames.contains(selectedMetric) ? selectedMetric : (metricNames.first ?? "")
|
||||
}
|
||||
|
||||
private struct ChartPoint: Identifiable {
|
||||
let id: Int
|
||||
let time: Date
|
||||
let value: Double
|
||||
}
|
||||
|
||||
private var chartPoints: [ChartPoint] {
|
||||
samples.enumerated().compactMap { index, sample in
|
||||
guard let value = metricMap(sample)[activeMetric], value.isFinite else { return nil }
|
||||
return ChartPoint(id: index, time: sample.at, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartTint: Color {
|
||||
let name = activeMetric.lowercased()
|
||||
if name.contains("abort") || name.contains("error") || name.contains("lock_wait") { return .red }
|
||||
if name.contains("slow") || name.contains("temporary") { return .orange }
|
||||
if name.contains("thread") || name.contains("connection") { return .teal }
|
||||
return .blue
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
@@ -4142,6 +4189,35 @@ private struct WorkbenchDiagnosticTrendsView: View {
|
||||
}
|
||||
Text("\(samples.count) samples. Rates compare the two latest successful refreshes; cumulative counters can reset when MySQL restarts or statistics are cleared.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !metricNames.isEmpty {
|
||||
HStack {
|
||||
Picker("Chart metric", selection: $selectedMetric) {
|
||||
ForEach(metricNames, id: \.self) { name in
|
||||
Text(name).tag(name)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 480)
|
||||
Spacer()
|
||||
Label("Live samples", systemImage: "chart.xyaxis.line")
|
||||
.foregroundStyle(chartTint)
|
||||
}
|
||||
.onAppear {
|
||||
if selectedMetric.isEmpty { selectedMetric = metricNames.first ?? "" }
|
||||
}
|
||||
Chart(chartPoints) { point in
|
||||
AreaMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||||
.foregroundStyle(chartTint.opacity(0.13))
|
||||
.interpolationMethod(.linear)
|
||||
LineMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||||
.foregroundStyle(chartTint)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2.5))
|
||||
.interpolationMethod(.linear)
|
||||
PointMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||||
.foregroundStyle(chartTint)
|
||||
}
|
||||
.chartYAxis { AxisMarks(position: .leading) }
|
||||
.frame(height: 205)
|
||||
}
|
||||
if samples.count < 2 {
|
||||
ContentUnavailableView("More samples needed", systemImage: "chart.xyaxis.line",
|
||||
description: Text("Refresh this screen twice, or enable auto-refresh."))
|
||||
@@ -4162,6 +4238,7 @@ private struct WorkbenchDiagnosticTrendsView: View {
|
||||
Text(item.1.formatted()).frame(width: 110, alignment: .trailing)
|
||||
Text(item.2.formatted()).frame(width: 110, alignment: .trailing)
|
||||
Text(item.3.map { $0.formatted(.number.precision(.fractionLength(2))) } ?? "—")
|
||||
.foregroundStyle(item.3 == nil ? Color.secondary : (item.3! > 0 ? Color.teal : Color.secondary))
|
||||
.frame(width: 110, alignment: .trailing)
|
||||
}.font(.system(.caption, design: .monospaced))
|
||||
Divider()
|
||||
@@ -4176,3 +4253,173 @@ private struct WorkbenchDiagnosticTrendsView: View {
|
||||
.frame(minWidth: 820, minHeight: 480)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Phase 27: Native Schema Inspector and SQL DDL Editor
|
||||
|
||||
private struct SchemaEditorTarget: Identifiable {
|
||||
let id = UUID()
|
||||
let database: String
|
||||
let table: String?
|
||||
}
|
||||
|
||||
private struct WorkbenchSchemaEditorView: View {
|
||||
let profile: ConnectionProfile?
|
||||
let target: SchemaEditorTarget
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var columns = QueryResult()
|
||||
@State private var indexes = QueryResult()
|
||||
@State private var originalDDL = ""
|
||||
@State private var proposedSQL = ""
|
||||
@State private var busy = false
|
||||
@State private var message: String?
|
||||
@State private var confirmExecution = false
|
||||
@State private var successful = false
|
||||
|
||||
private func identifier(_ value: String) -> String {
|
||||
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
|
||||
}
|
||||
|
||||
private var qualifiedName: String {
|
||||
identifier(target.database) + "." + identifier(target.table ?? "new_table")
|
||||
}
|
||||
|
||||
private func query(_ sql: String) async throws -> QueryResult {
|
||||
guard let profile else {
|
||||
throw NSError(domain: "WorkbenchSchemaEditor", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Connect to MySQL first."])
|
||||
}
|
||||
return try await NativeMySQLRunner.run(
|
||||
profile: profile, database: target.database,
|
||||
password: PasswordStore.read(profile.id), sql: sql
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label(target.table == nil ? "Create Table" : "Table Schema Editor",
|
||||
systemImage: "tablecells.badge.ellipsis")
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
Text(target.database).foregroundStyle(.secondary)
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
if let message {
|
||||
Label(message, systemImage: successful ? "checkmark.circle" : "exclamationmark.triangle")
|
||||
.foregroundStyle(successful ? .green : .red)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if target.table != nil {
|
||||
TabView {
|
||||
schemaGrid(columns).tabItem { Text("Columns") }
|
||||
schemaGrid(indexes).tabItem { Text("Indexes") }
|
||||
ScrollView {
|
||||
Text(originalDDL).font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}.tabItem { Text("Original DDL") }
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
HStack {
|
||||
Text("SQL schema changes").font(.headline)
|
||||
Spacer()
|
||||
Text("Review before execution").font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
TextEditor(text: $proposedSQL)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 160)
|
||||
.border(Color.secondary.opacity(0.3))
|
||||
HStack {
|
||||
Text("DDL may implicitly commit and cannot reliably be rolled back.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Reload") { Task { await load() } }.disabled(busy)
|
||||
Button("Execute DDL…") { confirmExecution = true }
|
||||
.disabled(busy || proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 820, minHeight: 620)
|
||||
.task { await load() }
|
||||
.confirmationDialog("Execute schema modification?", isPresented: $confirmExecution,
|
||||
titleVisibility: .visible) {
|
||||
Button("Execute SQL", role: .destructive) { Task { await executeDDL() } }
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text("This SQL changes the database schema and may implicitly commit. Verify the statement and take a backup first.")
|
||||
}
|
||||
}
|
||||
|
||||
private func schemaGrid(_ data: QueryResult) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if data.columns.isEmpty {
|
||||
Text("No metadata loaded").foregroundStyle(.secondary)
|
||||
} else {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 7) {
|
||||
GridRow {
|
||||
ForEach(data.columns, id: \.self) { name in
|
||||
Text(name).fontWeight(.bold)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
ForEach(data.rows.indices, id: \.self) { index in
|
||||
GridRow {
|
||||
ForEach(data.rows[index].indices, id: \.self) { column in
|
||||
Text(data.rows[index][column])
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.padding(8)
|
||||
}
|
||||
}
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func load() async {
|
||||
guard target.table != nil else {
|
||||
proposedSQL = "CREATE TABLE " + qualifiedName + " (\n id BIGINT NOT NULL AUTO_INCREMENT,\n PRIMARY KEY (id)\n) ENGINE=InnoDB;"
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
columns = try await query("SHOW FULL COLUMNS FROM " + qualifiedName)
|
||||
indexes = try await query("SHOW INDEX FROM " + qualifiedName)
|
||||
let ddl = try await query("SHOW CREATE TABLE " + qualifiedName)
|
||||
originalDDL = ddl.rows.first?.last ?? ""
|
||||
if proposedSQL.isEmpty {
|
||||
proposedSQL = "-- Enter ALTER TABLE statements here.\nALTER TABLE " + qualifiedName + "\n ADD COLUMN new_column VARCHAR(255) NULL;"
|
||||
}
|
||||
message = nil
|
||||
} catch {
|
||||
message = error.localizedDescription
|
||||
successful = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func executeDDL() async {
|
||||
let sql = proposedSQL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Single-statement editor: avoid silently executing partial multi-statement input.
|
||||
let statement = sql.hasSuffix(";") ? String(sql.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) : sql
|
||||
guard !statement.isEmpty else { return }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
_ = try await query(statement)
|
||||
successful = true
|
||||
message = "Schema statement executed successfully."
|
||||
if target.table != nil { await load() }
|
||||
} catch {
|
||||
successful = false
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user