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:
@@ -37,6 +37,7 @@ struct MainView: View {
|
||||
@State private var showingEditor = false
|
||||
@State private var showingDatabaseTransfer = false
|
||||
@State private var schemaEditorTarget: SchemaEditorTarget? = nil
|
||||
@State private var objectEditorTarget: WorkbenchObjectEditorTarget? = nil
|
||||
@State private var showingQueryPlan = false
|
||||
@State private var showingQueryHistory = false
|
||||
@State private var showingSavedQueries = false
|
||||
@@ -117,6 +118,11 @@ struct MainView: View {
|
||||
}) { target in
|
||||
WorkbenchSchemaEditorView(profile: model.selected, target: target)
|
||||
}
|
||||
.sheet(item: $objectEditorTarget, onDismiss: {
|
||||
if !model.selectedDatabase.isEmpty { model.loadTables(database: model.selectedDatabase) }
|
||||
}) { target in
|
||||
WorkbenchObjectEditorView(profile: model.selected, target: target)
|
||||
}
|
||||
.sheet(isPresented: $showingEditor) {
|
||||
ConnectionEditor(
|
||||
profile: $editing,
|
||||
@@ -640,6 +646,12 @@ struct MainView: View {
|
||||
Button(kind == .view ? "Select First Rows" : "Show Definition") {
|
||||
openSchemaObject(database: database, name: name, kind: kind)
|
||||
}
|
||||
Button("Edit Definition…") {
|
||||
objectEditorTarget = WorkbenchObjectEditorTarget(
|
||||
database: database, name: name,
|
||||
kind: kind == .view ? .view : (kind == .procedure ? .procedure : .function)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if objects.isEmpty {
|
||||
@@ -5186,3 +5198,171 @@ private struct WorkbenchSchemaEditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - View and Stored Routine Definition Editor
|
||||
|
||||
private struct WorkbenchObjectEditorTarget: Identifiable {
|
||||
enum Kind: String { case view = "VIEW", procedure = "PROCEDURE", function = "FUNCTION" }
|
||||
let database: String
|
||||
let name: String
|
||||
let kind: Kind
|
||||
var id: String { database + "." + kind.rawValue + "." + name }
|
||||
}
|
||||
|
||||
private struct WorkbenchObjectEditorView: View {
|
||||
let profile: ConnectionProfile?
|
||||
let target: WorkbenchObjectEditorTarget
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var definition = ""
|
||||
@State private var originalDefinition = ""
|
||||
@State private var message: String?
|
||||
@State private var isError = false
|
||||
@State private var busy = false
|
||||
@State private var confirmSave = false
|
||||
|
||||
private func quote(_ value: String) -> String {
|
||||
"`" + value.replacingOccurrences(of: "`", with: "``") + "`"
|
||||
}
|
||||
private var qualified: String { quote(target.database) + "." + quote(target.name) }
|
||||
|
||||
private func query(_ sql: String) async throws -> QueryResult {
|
||||
guard let profile else {
|
||||
throw NSError(domain: "WorkbenchObjectEditor", 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("Edit " + target.kind.rawValue.capitalized, systemImage: "curlybraces")
|
||||
.font(.title2.bold())
|
||||
Text(qualified).font(.subheadline.monospaced()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
if let message {
|
||||
Text(message)
|
||||
.foregroundStyle(isError ? .red : .green)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
Text("Edit the complete CREATE statement. The original definition is kept in memory until this window closes.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
TextEditor(text: $definition)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 350)
|
||||
.border(Color.secondary.opacity(0.3))
|
||||
if target.kind != .view {
|
||||
Text("MySQL does not support CREATE OR REPLACE for routines. Saving drops and recreates the routine. This is not atomic: grants, DEFINER and concurrent callers may be affected. Back up the routine first.")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
HStack {
|
||||
Button("Reload Original") { definition = originalDefinition }.disabled(busy)
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small).opacity(busy ? 1 : 0)
|
||||
Button("Save Definition…") { confirmSave = true }
|
||||
.disabled(busy || definition.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || definition == originalDefinition)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 850, minHeight: 560)
|
||||
.task { await load() }
|
||||
.confirmationDialog("Apply database definition?", isPresented: $confirmSave, titleVisibility: .visible) {
|
||||
Button(target.kind == .view ? "Replace View" : "Drop and Recreate " + target.kind.rawValue.capitalized,
|
||||
role: .destructive) { Task { await save() } }
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text(target.kind == .view
|
||||
? "This will replace the view definition on the MySQL server."
|
||||
: "This drops the existing routine before recreating it. MySQL cannot roll back this operation.")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func load() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
let result = try await query("SHOW CREATE " + target.kind.rawValue + " " + qualified)
|
||||
guard let row = result.rows.first,
|
||||
let index = result.columns.firstIndex(where: { $0.lowercased().hasPrefix("create ") }),
|
||||
row.indices.contains(index) else {
|
||||
throw NSError(domain: "WorkbenchObjectEditor", code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "MySQL did not return the CREATE definition. Check SHOW VIEW or routine privileges."])
|
||||
}
|
||||
originalDefinition = row[index]
|
||||
definition = originalDefinition
|
||||
message = nil
|
||||
} catch {
|
||||
isError = true
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func normalized(_ sql: String) -> String {
|
||||
let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.hasSuffix(";") ? String(trimmed.dropLast()) : trimmed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func save() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
let sql = normalized(definition)
|
||||
guard sql.range(of: #"^CREATE\s+"#, options: [.regularExpression, .caseInsensitive]) != nil else {
|
||||
isError = true
|
||||
message = "The definition must start with CREATE."
|
||||
return
|
||||
}
|
||||
let expectedKind = target.kind.rawValue
|
||||
guard sql.range(of: "\\b" + expectedKind + "\\b", options: [.regularExpression, .caseInsensitive]) != nil else {
|
||||
isError = true
|
||||
message = "Expected a CREATE " + expectedKind + " statement."
|
||||
return
|
||||
}
|
||||
do {
|
||||
if target.kind == .view {
|
||||
// SHOW CREATE VIEW may include ALGORITHM / DEFINER / SQL SECURITY.
|
||||
// Insert OR REPLACE immediately after CREATE, retaining those clauses.
|
||||
let replacement = sql.replacingOccurrences(
|
||||
of: #"^CREATE\s+(?:OR\s+REPLACE\s+)?"#,
|
||||
with: "CREATE OR REPLACE ",
|
||||
options: [.regularExpression, .caseInsensitive]
|
||||
)
|
||||
_ = try await query(replacement)
|
||||
} else {
|
||||
// MySQL has no CREATE OR REPLACE PROCEDURE/FUNCTION.
|
||||
_ = try await query("DROP " + target.kind.rawValue + " " + qualified)
|
||||
do {
|
||||
_ = try await query(sql)
|
||||
} catch {
|
||||
// Best effort restoration of the original definition.
|
||||
do {
|
||||
_ = try await query(normalized(originalDefinition))
|
||||
throw NSError(domain: "WorkbenchObjectEditor", code: 3,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Updated routine failed: \(error.localizedDescription). The original definition was restored; verify privileges and grants."])
|
||||
} catch let restoreError {
|
||||
if (restoreError as NSError).code == 3 && (restoreError as NSError).domain == "WorkbenchObjectEditor" {
|
||||
throw restoreError
|
||||
}
|
||||
throw NSError(domain: "WorkbenchObjectEditor", code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Updated routine failed: \(error.localizedDescription). Restoration also failed: \(restoreError.localizedDescription). Restore from your backup immediately."])
|
||||
}
|
||||
}
|
||||
}
|
||||
originalDefinition = sql
|
||||
definition = sql
|
||||
isError = false
|
||||
message = "Definition saved successfully."
|
||||
} catch {
|
||||
isError = true
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Phase 56 — Edit schema objects
|
||||
|
||||
Baseline: Phase 55. Schema navigator context menus for Views, Stored Procedures and Functions now provide **Edit Definition…**.
|
||||
|
||||
- Loads original DDL with `SHOW CREATE VIEW`, `SHOW CREATE PROCEDURE` or `SHOW CREATE FUNCTION`.
|
||||
- Displays an editable full-definition SQL text editor, a reload-original button, and explicit save confirmation.
|
||||
- Views are updated with `CREATE OR REPLACE VIEW`, preserving ALGORITHM / DEFINER clauses from MySQL's output.
|
||||
- MySQL routines require DROP then CREATE; save explicitly warns that this is not atomic, and attempts to restore the original definition if recreation fails. Restoration may also fail. Backup and check grants/DEFINER before editing.
|
||||
- Save errors are displayed without closing the editor. Schema objects are refreshed when the editor closes.
|
||||
|
||||
Swift syntax parsed on Linux; native macOS compilation and interactive database testing were not available.
|
||||
Reference in New Issue
Block a user