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-16 22:24:53 +01:00
parent fb1430ad1a
commit ff40ddd1a9
3 changed files with 134 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Phase 15 — Live MySQL Session Management
Adds session control to Administration → Client Connections. Select a session ID from `SHOW FULL PROCESSLIST`, then choose **Stop Query** (`KILL QUERY`) or **Disconnect Session** (`KILL CONNECTION`). Both actions require a destructive confirmation. The app validates the session ID as a positive UInt64, rejects terminating its own active MySQL connection, reports errors and refreshes the process list afterward. Search and five-second auto-refresh from Phase 13 remain available.
**Permissions:** MySQL privileges determine which sessions can be seen or terminated. Session IDs may be reused after a connection exits; always verify the selected session before confirming. A race remains possible between selecting an ID and execution. Terminating connections may interrupt work or roll back uncommitted transactions. There is no undo.
**Scope:** This phase focuses on session management. Advanced server configuration editing and performance diagnostics are not implemented here.
**Validation:** Source patch and ZIP integrity checked. No macOS Xcode compiler or live MySQL server available in this environment; test on a disposable MySQL instance before production use.
+106
View File
@@ -1355,6 +1355,11 @@ enum WorkbenchAdminPage: String, CaseIterable, Identifiable {
}
}
private enum WorkbenchProcessAction: String {
case query = "Stop query for"
case connection = "Disconnect"
}
struct WorkbenchAdministrationView: View {
let page: WorkbenchAdminPage
let profile: ConnectionProfile?
@@ -1371,6 +1376,11 @@ struct WorkbenchAdministrationView: View {
@State private var grantsError: String? = nil
@State private var showingGrants = false
@State private var showingAccountManager = false
@State private var selectedProcessID: String? = nil
@State private var pendingProcessAction: WorkbenchProcessAction? = nil
@State private var processActionError: String? = nil
@State private var processActionStatus: String? = nil
@State private var terminatingProcess = false
private var filteredRows: [[String]] {
guard !searchText.isEmpty else { return result.rows }
@@ -1418,6 +1428,10 @@ struct WorkbenchAdministrationView: View {
resultContent
}
if page == .clientConnections {
processControls
}
if page == .users && !result.rows.isEmpty {
HStack {
Picker("Account", selection: $selectedAccount) {
@@ -1463,6 +1477,24 @@ struct WorkbenchAdministrationView: View {
await refresh()
}
}
.confirmationDialog(
"Terminate MySQL session?",
isPresented: Binding(
get: { pendingProcessAction != nil },
set: { if !$0 { pendingProcessAction = nil } }
),
titleVisibility: .visible
) {
if let action = pendingProcessAction, let id = selectedProcessID {
Button("\(action.rawValue) session \(id)", role: .destructive) {
pendingProcessAction = nil
Task { await terminateProcess(id: id, action: action) }
}
}
Button("Cancel", role: .cancel) { pendingProcessAction = nil }
} message: {
Text("This operation affects a live MySQL session. Terminating a connection can interrupt transactions and roll back uncommitted work. Verify the session ID before proceeding.")
}
.sheet(isPresented: $showingAccountManager, onDismiss: {
Task { await refresh() }
}) {
@@ -1494,6 +1526,75 @@ struct WorkbenchAdministrationView: View {
}
}
private var processControls: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Picker("Session", selection: $selectedProcessID) {
Text("Select a session").tag(String?.none)
ForEach(processIDs, id: \.self) { id in
Text("ID \(id)").tag(Optional(id))
}
}
.frame(maxWidth: 250)
Button("Stop Query") { pendingProcessAction = .query }
.disabled(selectedProcessID == nil || terminatingProcess)
Button("Disconnect Session") { pendingProcessAction = .connection }
.disabled(selectedProcessID == nil || terminatingProcess)
Spacer()
if terminatingProcess { ProgressView().controlSize(.small) }
}
if let processActionError {
Text(processActionError).foregroundStyle(.red).textSelection(.enabled)
}
if let processActionStatus {
Text(processActionStatus).foregroundStyle(.secondary)
}
Text("Requires PROCESS privilege to see other users' sessions and appropriate privileges to terminate them.")
.font(.caption).foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private var processIDs: [String] {
guard let index = result.columns.firstIndex(where: { $0.caseInsensitiveCompare("Id") == .orderedSame }) else {
return []
}
return result.rows.compactMap { row in
guard row.indices.contains(index), let id = UInt64(row[index]), id > 0 else { return nil }
return String(id)
}
}
@MainActor
private func terminateProcess(id: String, action: WorkbenchProcessAction) async {
guard let profile, let numericID = UInt64(id), numericID > 0 else { return }
terminatingProcess = true
processActionError = nil
processActionStatus = nil
defer { terminatingProcess = false }
do {
let own = try await NativeMySQLRunner.run(
profile: profile, database: "",
password: PasswordStore.read(profile.id), sql: "SELECT CONNECTION_ID()"
)
if own.rows.first?.first == id {
processActionError = "Cannot terminate this application's own MySQL session."
return
}
let sql = "KILL \(action == .query ? "QUERY" : "CONNECTION") \(numericID)"
_ = try await NativeMySQLRunner.run(
profile: profile, database: "",
password: PasswordStore.read(profile.id), sql: sql
)
processActionStatus = "\(action.rawValue) requested for session \(id)."
selectedProcessID = nil
await refresh()
} catch {
processActionError = error.localizedDescription
}
}
private var header: some View {
HStack(spacing: 14) {
Image(systemName: page.icon)
@@ -1669,6 +1770,11 @@ struct WorkbenchAdministrationView: View {
)
result = response
if page == .clientConnections,
let selectedProcessID,
!processIDs.contains(selectedProcessID) {
self.selectedProcessID = nil
}
lastUpdated = Date()
} catch {
errorMessage = error.localizedDescription
+19
View File
@@ -0,0 +1,19 @@
{
"name": "xcode build server",
"version": "1.3.0",
"bspVersion": "2.2.0",
"languages": [
"c",
"cpp",
"objective-c",
"objective-cpp",
"swift"
],
"argv": [
"/opt/homebrew/bin/xcode-build-server"
],
"workspace": "/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj/project.xcworkspace",
"build_root": "/Users/markgregory/Library/Developer/Xcode/DerivedData/WorkbenchReplacement-cckfuhwqasossyecgsucncuxddvg",
"scheme": "WorkbenchReplacement",
"kind": "xcode"
}