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'