diff --git a/WorkbenchReplacement/DatabaseModel.swift b/WorkbenchReplacement/DatabaseModel.swift index 10b50d6..7592da7 100644 --- a/WorkbenchReplacement/DatabaseModel.swift +++ b/WorkbenchReplacement/DatabaseModel.swift @@ -143,6 +143,8 @@ final class DatabaseModel: ObservableObject { @Published var databases: [String] = [] @Published var schemaTables: [String: [String]] = [:] @Published var schemaViews: [String: [String]] = [:] + @Published var schemaProcedures: [String: [String]] = [:] + @Published var schemaFunctions: [String: [String]] = [:] @Published var schemaColumns: [String: [String]] = [:] @Published var selectedDatabase = "" @Published var queryDurationText = "" @@ -324,6 +326,26 @@ final class DatabaseModel: ObservableObject { schemaTables[database] = tables schemaViews[database] = views + // Routine metadata is separate from SHOW FULL TABLES. + // Failure (e.g. insufficient INFORMATION_SCHEMA privileges) + // must not prevent browsing tables and views. + do { + let quotedSchema = database.replacingOccurrences(of: "'", with: "''") + let routineResult = try await NativeMySQLRunner.run( + profile: profile, database: database, password: password, + sql: "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '\(quotedSchema)' ORDER BY ROUTINE_NAME" + ) + schemaProcedures[database] = routineResult.rows.compactMap { row in + row.count >= 2 && row[1].uppercased() == "PROCEDURE" ? row[0] : nil + } + schemaFunctions[database] = routineResult.rows.compactMap { row in + row.count >= 2 && row[1].uppercased() == "FUNCTION" ? row[0] : nil + } + } catch { + schemaProcedures[database] = [] + schemaFunctions[database] = [] + } + // Cache column names for SQL completion. The lookup is read-only, // scoped to the selected schema and uses the current connection. // Failures (e.g. metadata permissions) do not prevent table browsing. diff --git a/WorkbenchReplacement/MainView.swift b/WorkbenchReplacement/MainView.swift index 5371109..620c34e 100644 --- a/WorkbenchReplacement/MainView.swift +++ b/WorkbenchReplacement/MainView.swift @@ -48,6 +48,7 @@ struct MainView: View { @State private var expandedSchemas: Set = [] @State private var expandedTableFolders: Set = [] + @State private var expandedObjectFolders: Set = [] @State private var selectedTableName: String? = nil @State private var outputExpanded = true @@ -132,8 +133,9 @@ struct MainView: View { /// A query launched from the global toolbar must reveal its editor and /// results, even when an Administration page currently owns the workspace. private func executeSQLInEditor() { - navigatorMode = 1 + // Make the editor the active workspace before starting execution. administrationPage = nil + navigatorMode = 1 model.runSQL() // The SQLTextView is created only after the workspace changes. Request // focus on the next main-loop turn, after SwiftUI mounts that view. @@ -538,9 +540,9 @@ struct MainView: View { } } - folderNode("Views", indent: 28) - folderNode("Stored Procedures", indent: 28) - folderNode("Functions", indent: 28) + schemaObjectFolder("Views", database: db, objects: model.schemaViews[db] ?? [], kind: .view) + schemaObjectFolder("Stored Procedures", database: db, objects: model.schemaProcedures[db] ?? [], kind: .procedure) + schemaObjectFolder("Functions", database: db, objects: model.schemaFunctions[db] ?? [], kind: .function) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -597,35 +599,78 @@ struct MainView: View { } } - private func folderNode( - _ title: String, - indent: CGFloat - ) -> some View { - HStack(spacing: 5) { - Image(systemName: "chevron.right") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .frame(width: 13) + private enum SchemaObjectKind { case view, procedure, function } - Image(systemName: "folder") - .foregroundStyle(.secondary) - .frame(width: 17) + private func schemaObjectFolder(_ title: String, database: String, objects: [String], kind: SchemaObjectKind) -> some View { + let key = database + "." + title + let expanded = expandedObjectFolders.contains(key) + return VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 5) { + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10)).foregroundStyle(.secondary).frame(width: 13) + Image(systemName: "folder").foregroundStyle(.secondary).frame(width: 17) + Text(title).font(.system(size: 12)) + Text("\(objects.count)").font(.caption2).foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .frame(height: 24) + .padding(.leading, 28) + .contentShape(Rectangle()) + .onTapGesture { + if expanded { expandedObjectFolders.remove(key) } + else { + expandedObjectFolders.insert(key) + if model.schemaTables[database] == nil { model.loadTables(database: database) } + } + } - Text(title) - .font(.system(size: 12)) - - Spacer(minLength: 0) + if expanded { + ForEach(objects, id: \.self) { name in + HStack(spacing: 5) { + Image(systemName: kind == .view ? "tablecells" : "curlybraces") + .foregroundStyle(.secondary).frame(width: 17) + Text(name).font(.system(size: 12)).lineLimit(1) + Spacer(minLength: 0) + } + .frame(height: 23) + .padding(.leading, 49) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { openSchemaObject(database: database, name: name, kind: kind) } + .contextMenu { + Button(kind == .view ? "Select First Rows" : "Show Definition") { + openSchemaObject(database: database, name: name, kind: kind) + } + } + } + if objects.isEmpty { + Text("No objects (or metadata unavailable)") + .font(.caption2).foregroundStyle(.secondary) + .padding(.leading, 49).frame(height: 23) + } + } } - .frame(height: 24) - .padding(.leading, indent) - .frame(maxWidth: .infinity, alignment: .leading) + } + + private func openSchemaObject(database: String, name: String, kind: SchemaObjectKind) { + let db = database.replacingOccurrences(of: "`", with: "``") + let object = name.replacingOccurrences(of: "`", with: "``") + model.selectedDatabase = database + switch kind { + case .view: model.sql = "SELECT * FROM `\(db)`.`\(object)` LIMIT 500;" + case .procedure: model.sql = "SHOW CREATE PROCEDURE `\(db)`.`\(object)`;" + case .function: model.sql = "SHOW CREATE FUNCTION `\(db)`.`\(object)`;" + } + administrationPage = nil + navigatorMode = 1 + model.runSQL() + DispatchQueue.main.async { sqlFocusRequest += 1 } } // MARK: - Workspace private var workspace: some View { Group { - if let page = administrationPage { + if navigatorMode == 0, let page = administrationPage { WorkbenchAdministrationView( page: page, profile: model.selected @@ -667,7 +712,10 @@ struct MainView: View { ForEach(model.queryTabs) { tab in HStack(spacing: 8) { Button { + administrationPage = nil + navigatorMode = 1 model.activateQuery(tab.id) + DispatchQueue.main.async { sqlFocusRequest += 1 } } label: { Label(tab.title, systemImage: "doc.text") .lineLimit(1) @@ -716,7 +764,7 @@ struct MainView: View { private var queryToolbar: some View { HStack(spacing: 14) { Button { - model.runSQL() + executeSQLInEditor() } label: { Image(systemName: "bolt.fill") .foregroundStyle(.orange) diff --git a/docs/PHASE54.md b/docs/PHASE54.md new file mode 100644 index 0000000..9ca7435 --- /dev/null +++ b/docs/PHASE54.md @@ -0,0 +1,9 @@ +# Phase 54: SQL editor navigation + +The workspace now renders an administration screen only while the Administration navigator is selected. This prevents stale administration-page state from obscuring the SQL editor after selecting Schemas. + +Both global and editor Execute buttons route through `executeSQLInEditor()`, which selects Schemas, clears the administration page, executes SQL and requests editor focus on the next main-loop turn. Selecting a query tab also reveals and focuses the editor. + +Phase 50 chart implementation and subsequent scroll changes are otherwise unchanged. + +macOS build and visual verification must be performed in Xcode. diff --git a/docs/PHASE55-SCHEMA-OBJECTS.md b/docs/PHASE55-SCHEMA-OBJECTS.md new file mode 100644 index 0000000..a8af58a --- /dev/null +++ b/docs/PHASE55-SCHEMA-OBJECTS.md @@ -0,0 +1,11 @@ +# Phase 55 — Schema browser objects + +- Views folder expands to show discovered views. Double-click opens and executes a SELECT query with 500-row limit. +- Stored Procedures and Functions folders expand to show routines from `information_schema.ROUTINES`. +- Double-click a routine to display its definition using `SHOW CREATE PROCEDURE` or `SHOW CREATE FUNCTION` in the SQL editor results. +- Right-click actions are also available. +- Expanding a schema loads tables, views, and routine metadata. The Refresh Tables action refreshes all three types. +- Insufficient routine metadata permissions yield an empty routine list without breaking table browsing. +- The approved Phase 50 graphs and Phase 54 SQL navigation are unchanged. + +Native macOS compilation and UI verification must be performed in Xcode.