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-17 10:54:02 +01:00
parent 9cd4a3c443
commit c511256f32
28 changed files with 676 additions and 206 deletions
+9
View File
@@ -0,0 +1,9 @@
# Phase 27 — Charts, colours, schema editor
- Imports Swift Charts; adds coloured line/area charts to Administration > View Trends, based on existing in-memory samples.
- Metric picker and coloured rates in performance counters.
- Schema tree: right-click schema > Create Table; right-click table > Edit Table Schema.
- Schema inspector displays SHOW FULL COLUMNS, SHOW INDEX, SHOW CREATE TABLE.
- SQL DDL editing with confirmation; requires suitable MySQL permissions. No automatic table reconstruction.
IMPORTANT: Not compiled in Xcode here. Test against a disposable MySQL database. DDL may implicitly commit.
+247
View File
@@ -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
}
}
}
@@ -9,6 +9,11 @@
"moduleName" : "AppKit",
"modulePath" : "\/Volumes\/Developer\/Applications\/WorkbenchReplacement\/build\/SDKExplicitPrecompiledModules\/AppKit-H8IDH8MPDKFF.swiftmodule"
},
{
"isFramework" : true,
"moduleName" : "Charts",
"modulePath" : "\/Volumes\/Developer\/Applications\/WorkbenchReplacement\/build\/SDKExplicitPrecompiledModules\/Charts-1DHAJW5E3WRKQ.swiftmodule"
},
{
"isFramework" : true,
"moduleName" : "ColorSync",
@@ -31,3 +31,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>
Fuz3gDgwkgMS+lEzJ6OSoxSzPqo=
HkhN7yfGWFkk5ENSYPEwPzG7Oxw=
</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>
+5 -5
View File
@@ -6,7 +6,7 @@
<integer>13</integer>
<key>logs</key>
<dict>
<key>64DBE2F7-41EB-44D3-A0AB-CCC5468F4E72</key>
<key>4C1297D6-59C6-453E-95AE-E4B963DF2DA6</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
@@ -15,7 +15,7 @@
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>64DBE2F7-41EB-44D3-A0AB-CCC5468F4E72.xcactivitylog</string>
<string>4C1297D6-59C6-453E-95AE-E4B963DF2DA6.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
@@ -40,13 +40,13 @@
<key>signature</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>timeStartedRecording</key>
<real>813880254.37309003</real>
<real>813923577.72105396</real>
<key>timeStoppedRecording</key>
<real>813880270.59995699</real>
<real>813923596.45017803</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>64DBE2F7-41EB-44D3-A0AB-CCC5468F4E72</string>
<string>4C1297D6-59C6-453E-95AE-E4B963DF2DA6</string>
</dict>
<key>F4C8C8BA-B9A7-46F3-8FF6-180215A4D5E4</key>
<dict>
@@ -1 +1 @@
1792226131.7427912: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
1792230778.133908: 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-17T08:35:31Z</date>
<date>2026-10-17T09:52:58Z</date>
<key>WorkspacePath</key>
<string>/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj</string>
</dict>