From c389d91da2dc07ec394b7d25b723c86ee6960abd Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sun, 18 Oct 2026 12:02:42 +0100 Subject: [PATCH] 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. --- WorkbenchReplacement/MainView.swift | 143 ++++++++++++++++++++++++++-- docs/PHASE57-SCHEMA-MANAGEMENT.md | 11 +++ scripts/release-workbench.sh | 59 ++++++++++++ 3 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 docs/PHASE57-SCHEMA-MANAGEMENT.md create mode 100755 scripts/release-workbench.sh diff --git a/WorkbenchReplacement/MainView.swift b/WorkbenchReplacement/MainView.swift index 6f68df7..5700696 100644 --- a/WorkbenchReplacement/MainView.swift +++ b/WorkbenchReplacement/MainView.swift @@ -50,6 +50,8 @@ struct MainView: View { @State private var expandedSchemas: Set = [] @State private var expandedTableFolders: Set = [] @State private var expandedObjectFolders: Set = [] + @State private var schemaFilter = "" + @State private var schemaManagement: WorkbenchSchemaManagementTarget? = nil @State private var selectedTableName: String? = nil @State private var outputExpanded = true @@ -123,6 +125,9 @@ struct MainView: View { }) { target in WorkbenchObjectEditorView(profile: model.selected, target: target) } + .sheet(item: $schemaManagement, onDismiss: { model.loadDatabases() }) { target in + WorkbenchSchemaManagementSheet(model: model, target: target) + } .sheet(isPresented: $showingEditor) { ConnectionEditor( profile: $editing, @@ -296,11 +301,16 @@ struct MainView: View { .padding(.horizontal, 12) .frame(height: 34) + TextField("Filter schemas", text: $schemaFilter) + .textFieldStyle(.roundedBorder) + .padding(.horizontal, 9) + .padding(.bottom, 5) + Divider() ScrollView { LazyVStack(alignment: .leading, spacing: 2) { - ForEach(model.databases, id: \.self) { db in + ForEach(model.databases.filter { schemaFilter.isEmpty || $0.localizedCaseInsensitiveContains(schemaFilter) }, id: \.self) { db in schemaNode(db) } } @@ -459,16 +469,35 @@ struct MainView: View { } } .contextMenu { - Button("Set as Default Schema") { - model.selectedDatabase = db + Button("Set as Default Schema") { model.selectedDatabase = db } + Button("Filter to This Schema") { schemaFilter = db } + Button("Clear Schema Filter") { schemaFilter = "" } + .disabled(schemaFilter.isEmpty) + Button("Schema Inspector…") { + schemaManagement = .init(database: db, action: .inspect) } - - Button("Refresh Tables") { - model.loadTables(database: db) + Divider() + Button("Copy Schema Name") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(db, forType: .string) } + Button("Send CREATE Statement to SQL Editor") { + revealSchemaSQL("SHOW CREATE DATABASE `\(db.replacingOccurrences(of: "`", with: "``"))`;") + } + Divider() + Button("Create Schema…") { schemaManagement = .init(database: nil, action: .create) } + Button("Alter Schema…") { schemaManagement = .init(database: db, action: .alter) } + Button("Drop Schema…", role: .destructive) { + schemaManagement = .init(database: db, action: .drop) + } + Divider() Button("Create Table…") { schemaEditorTarget = SchemaEditorTarget(database: db, table: nil) } + Button("Refresh All") { + model.loadDatabases() + model.loadTables(database: db) + } } if expandedSchemas.contains(db) { @@ -478,6 +507,13 @@ struct MainView: View { .frame(maxWidth: .infinity, alignment: .leading) } + private func revealSchemaSQL(_ statement: String) { + model.sql = statement + administrationPage = nil + navigatorMode = 1 + DispatchQueue.main.async { sqlFocusRequest += 1 } + } + private func toggleSchema(_ db: String) { if expandedSchemas.contains(db) { expandedSchemas.remove(db) @@ -5366,3 +5402,98 @@ private struct WorkbenchObjectEditorView: View { } } } + + +// MARK: - Phase 57: Schema management +private struct WorkbenchSchemaManagementTarget: Identifiable { + enum Action { case create, alter, drop, inspect } + let id = UUID() + let database: String? + let action: Action +} + +private struct WorkbenchSchemaManagementSheet: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var model: DatabaseModel + let target: WorkbenchSchemaManagementTarget + @State private var name = "" + @State private var charset = "utf8mb4" + @State private var collation = "utf8mb4_0900_ai_ci" + @State private var confirmation = "" + @State private var submitted = false + + private var escapedName: String { name.replacingOccurrences(of: "`", with: "``") } + private var title: String { + switch target.action { + case .create: return "Create Schema" + case .alter: return "Alter Schema" + case .drop: return "Drop Schema" + case .inspect: return "Schema Inspector" + } + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(title).font(.title2.bold()) + if target.action == .inspect { + LabeledContent("Schema", value: name) + Text("Inspect the schema definition and available metadata using the SQL editor.") + .foregroundStyle(.secondary) + Button("Show CREATE DATABASE") { + model.sql = "SHOW CREATE DATABASE `\(escapedName)`;" + dismiss() + } + } else { + TextField("Schema name", text: $name) + .disabled(target.action != .create) + if target.action != .drop { + TextField("Character set", text: $charset) + TextField("Collation", text: $collation) + Text("Character set and collation must be valid identifiers supported by the server.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("This permanently deletes the schema and ALL its tables, views, routines and data. This cannot be undone.") + .foregroundStyle(.red) + TextField("Type the schema name to confirm", text: $confirmation) + } + if submitted { + Text("Statement submitted. Check the SQL result/status before continuing.") + .font(.caption).foregroundStyle(.secondary) + } + } + HStack { + Spacer() + Button("Close") { dismiss() } + if target.action != .inspect { + Button(target.action == .drop ? "Drop Schema" : "Apply") { apply() } + .disabled(!valid || submitted) + .keyboardShortcut(.defaultAction) + } + } + } + .padding(24) + .frame(width: 480) + .onAppear { name = target.database ?? "" } + } + private var valid: Bool { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + if target.action == .drop { return confirmation == name } + if target.action == .inspect { return true } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + return !charset.isEmpty && !collation.isEmpty && charset.unicodeScalars.allSatisfy(allowed.contains) && collation.unicodeScalars.allSatisfy(allowed.contains) + } + private func apply() { + guard valid else { return } + let sql: String + switch target.action { + case .create: + sql = "CREATE DATABASE `\(escapedName)` CHARACTER SET \(charset) COLLATE \(collation);" + case .alter: + sql = "ALTER DATABASE `\(escapedName)` CHARACTER SET \(charset) COLLATE \(collation);" + case .drop: + sql = "DROP DATABASE `\(escapedName)`;" + case .inspect: return + } + model.runSQL(statement: sql) + submitted = true + } +} diff --git a/docs/PHASE57-SCHEMA-MANAGEMENT.md b/docs/PHASE57-SCHEMA-MANAGEMENT.md new file mode 100644 index 0000000..634bd72 --- /dev/null +++ b/docs/PHASE57-SCHEMA-MANAGEMENT.md @@ -0,0 +1,11 @@ +# Phase 57 — Schema management + +Based on Phase 56. Adds schema filtering and a richer schema context menu: default schema, filter/clear filter, inspector, copy schema name, send SHOW CREATE DATABASE to SQL editor, create/alter/drop schema, create table and refresh. + +Create/alter forms submit MySQL DDL with selectable charset/collation. Drop requires typing the full schema name. The submitted statement's success or failure is shown by the existing SQL execution status; submission is not a guarantee of success. Refresh the schema list after the operation. The schema inspector currently offers a SHOW CREATE DATABASE action; full metadata inspector is planned for a later iteration. + +The other features in the Phase 57 roadmap (spatial import, table data import wizard, search table data) are NOT implemented here; they are allocated to Phase 59. This release is not a complete clone of the Workbench context menu. + +Local syntax check: `swiftc -frontend -parse WorkbenchReplacement/MainView.swift`. A full Xcode build and database tests must be run on macOS. + +Direct release: `./scripts/release-workbench.sh 57 Schema-Management`. diff --git a/scripts/release-workbench.sh b/scripts/release-workbench.sh new file mode 100755 index 0000000..213c0e5 --- /dev/null +++ b/scripts/release-workbench.sh @@ -0,0 +1,59 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_DIR="${PROJECT_DIR:-/Volumes/Developer/Applications/WorkbenchReplacement}" +PHASE="${1:-57}" +LABEL="${2:-Development}" +RELEASE_DIR="${RELEASE_DIR:-$PROJECT_DIR/releases}" +SCHEME="WorkbenchReplacement" + +if [[ ! "$PHASE" =~ '^[0-9]+$' ]]; then + echo 'Usage: ./release-workbench.sh PHASE [Release-Label]' >&2 + exit 2 +fi +if [[ ! -f "$PROJECT_DIR/WorkbenchReplacement.xcodeproj/project.pbxproj" ]]; then + echo "Xcode project not found: $PROJECT_DIR" >&2 + exit 1 +fi +if [[ ! -d "$PROJECT_DIR/docs" ]]; then + echo 'docs/ directory is required.' >&2 + exit 1 +fi +mkdir -p "$RELEASE_DIR" +SAFE_LABEL=$(printf '%s' "$LABEL" | tr -cs 'A-Za-z0-9._-' '-') +NAME="WorkbenchReplacement-Phase${PHASE}-${SAFE_LABEL}" +DERIVED="$PROJECT_DIR/build" +APP="$DERIVED/Build/Products/Release/${SCHEME}.app" + +echo "Building ${NAME}..." +xcodebuild \ + -project "$PROJECT_DIR/WorkbenchReplacement.xcodeproj" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$DERIVED" \ + CODE_SIGNING_ALLOWED=NO \ + clean build + +if [[ ! -d "$APP" ]]; then + echo "Build succeeded but app was not found: $APP" >&2 + exit 1 +fi + +# Source release excludes local build artifacts, git internals, and prior releases. +SOURCE_ZIP="$RELEASE_DIR/${NAME}-Source.zip" +( + cd "$PROJECT_DIR" + /usr/bin/zip -qry "$SOURCE_ZIP" \ + WorkbenchReplacement WorkbenchReplacement.xcodeproj docs \ + -x '*.DS_Store' '*/.build/*' '*/DerivedData/*' +) +APP_ZIP="$RELEASE_DIR/${NAME}-App-Unsigned.zip" +( + cd "${APP:h}" + /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP:t" "$APP_ZIP" +) +/usr/bin/unzip -tq "$SOURCE_ZIP" >/dev/null +/usr/bin/unzip -tq "$APP_ZIP" >/dev/null +printf '\nRelease completed:\n%s\n%s\n' "$SOURCE_ZIP" "$APP_ZIP" +printf 'Note: App ZIP is unsigned and not notarized; intended for local testing.\n'