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:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+400
-192
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
+5
@@ -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",
|
||||
|
||||
+4
-4
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+1
-1
@@ -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>
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user