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:20:48 +01:00
parent d479d3c07b
commit fb1430ad1a
21 changed files with 439 additions and 111 deletions
+197 -1
View File
@@ -1370,6 +1370,7 @@ struct WorkbenchAdministrationView: View {
@State private var grants: [String] = []
@State private var grantsError: String? = nil
@State private var showingGrants = false
@State private var showingAccountManager = false
private var filteredRows: [[String]] {
guard !searchText.isEmpty else { return result.rows }
@@ -1434,8 +1435,13 @@ struct WorkbenchAdministrationView: View {
Task { await showGrants() }
}
.disabled(selectedAccount == nil)
Button("Manage Accounts…") {
autoRefresh = false
showingAccountManager = true
}
.disabled(profile == nil)
Spacer()
Text("Read-only account inspection")
Text("Changes require confirmation")
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
@@ -1457,6 +1463,16 @@ struct WorkbenchAdministrationView: View {
await refresh()
}
}
.sheet(isPresented: $showingAccountManager, onDismiss: {
Task { await refresh() }
}) {
if let profile {
WorkbenchAccountManagerView(
profile: profile,
selectedAccount: selectedAccount
)
}
}
.sheet(isPresented: $showingGrants) {
VStack(alignment: .leading, spacing: 12) {
Text("Grants: \(selectedAccount ?? "")")
@@ -2897,3 +2913,183 @@ struct WorkbenchBackupManifest: Codable {
return manifest
}
}
// MARK: - Phase 14: Explicitly confirmed MySQL account administration
private enum WorkbenchAccountAction: String, CaseIterable, Identifiable {
case create = "Create Account"
case password = "Change Password"
case lock = "Lock Account"
case unlock = "Unlock Account"
case grant = "Grant Privileges"
case revoke = "Revoke Privileges"
var id: String { rawValue }
}
private struct WorkbenchAccountManagerView: View {
let profile: ConnectionProfile
let selectedAccount: String?
@Environment(\.dismiss) private var dismiss
@State private var action: WorkbenchAccountAction = .create
@State private var username = ""
@State private var hostname = "%"
@State private var password = ""
@State private var databaseName = ""
@State private var privilege = "SELECT"
@State private var previewSQL = ""
@State private var confirm = false
@State private var running = false
@State private var status: String?
@State private var failure: String?
private let privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "INDEX", "REFERENCES", "EXECUTE", "CREATE VIEW", "SHOW VIEW", "TRIGGER"]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Manage MySQL Accounts").font(.title2.bold())
Text("Account changes run on \(profile.name). Review the SQL before applying it.")
.foregroundStyle(.secondary)
Form {
Picker("Operation", selection: $action) {
ForEach(WorkbenchAccountAction.allCases) { option in
Text(option.rawValue).tag(option)
}
}
TextField("Username", text: $username)
.textContentType(.username)
TextField("Host", text: $hostname)
if action == .create || action == .password {
SecureField("Password", text: $password)
}
if action == .grant || action == .revoke {
TextField("Database", text: $databaseName)
Picker("Privilege", selection: $privilege) {
ForEach(privileges, id: \.self) { item in
Text(item).tag(item)
}
}
}
}
.formStyle(.grouped)
if let failure {
Text(failure).foregroundStyle(.red).textSelection(.enabled)
}
if let status {
Text(status).foregroundStyle(.green)
}
HStack {
Button("Close") { dismiss() }
Spacer()
Button("Review SQL…") { review() }
.disabled(running)
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 590)
.onAppear {
if let selectedAccount {
let parts = selectedAccount.split(separator: "\u{001f}", omittingEmptySubsequences: false)
if parts.count == 2 {
username = String(parts[0])
hostname = String(parts[1])
}
}
}
.alert("Confirm \(action.rawValue)", isPresented: $confirm) {
Button("Cancel", role: .cancel) { previewSQL = "" }
Button("Apply Change", role: .destructive) {
Task { await execute() }
}
} message: {
// Passwords are never shown in the SQL preview.
Text("Run this operation against \(profile.name)?\n\n\(previewSQL)")
}
}
// Strict account and database validation keeps identifiers out of SQL syntax.
private func sql() throws -> (command: String, preview: String) {
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-%")
guard !username.isEmpty, username.unicodeScalars.allSatisfy({ allowed.contains($0) }),
!hostname.isEmpty, hostname.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
throw AccountError.invalid("Username/host may contain only letters, digits, _, ., -, and %.")
}
let account = "'\(username)'@'\(hostname)'"
switch action {
case .create, .password:
guard !password.isEmpty else { throw AccountError.invalid("Enter a password.") }
// MySQL's default SQL mode interprets backslashes as escapes.
// We check SQL mode before execution to avoid changing password meaning.
let literal = password.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "''")
let prefix = action == .create ? "CREATE USER" : "ALTER USER"
return ("\(prefix) \(account) IDENTIFIED BY '\(literal)'",
"\(prefix) \(account) IDENTIFIED BY '••••••••'")
case .lock:
return ("ALTER USER \(account) ACCOUNT LOCK", "ALTER USER \(account) ACCOUNT LOCK")
case .unlock:
return ("ALTER USER \(account) ACCOUNT UNLOCK", "ALTER USER \(account) ACCOUNT UNLOCK")
case .grant, .revoke:
guard !databaseName.isEmpty,
databaseName.unicodeScalars.allSatisfy({ allowed.contains($0) }),
privileges.contains(privilege) else {
throw AccountError.invalid("Enter a valid database name and privilege.")
}
let db = "`\(databaseName)`.*"
let command = action == .grant
? "GRANT \(privilege) ON \(db) TO \(account)"
: "REVOKE \(privilege) ON \(db) FROM \(account)"
return (command, command)
}
}
private func review() {
failure = nil
status = nil
do {
previewSQL = try sql().preview
confirm = true
} catch {
failure = error.localizedDescription
}
}
@MainActor
private func execute() async {
running = true
defer { running = false; password = "" }
do {
let command = try sql().command
let credentials = PasswordStore.read(profile.id)
if action == .create || action == .password {
let mode = try await NativeMySQLRunner.run(
profile: profile, database: "", password: credentials,
sql: "SELECT @@SESSION.sql_mode"
)
let flags = mode.rows.first?.first ?? ""
if flags.split(separator: ",").contains(where: { $0 == "NO_BACKSLASH_ESCAPES" }) {
throw AccountError.invalid("Password changes are disabled for NO_BACKSLASH_ESCAPES SQL mode.")
}
}
_ = try await NativeMySQLRunner.run(
profile: profile, database: "", password: credentials, sql: command
)
status = "\(action.rawValue) completed successfully."
failure = nil
} catch {
failure = error.localizedDescription
status = nil
}
}
private enum AccountError: LocalizedError {
case invalid(String)
var errorDescription: String? {
if case let .invalid(message) = self { return message }
return nil
}
}
}