From c1bfb36de5608fa21bd9a5a564eee9fba4ca0d47 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sun, 18 Oct 2026 11:46:19 +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/DatabaseModel.swift | 22 ++++++ WorkbenchReplacement/MainView.swift | 98 ++++++++++++++++++------ docs/PHASE54.md | 9 +++ docs/PHASE55-SCHEMA-OBJECTS.md | 11 +++ 4 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 docs/PHASE54.md create mode 100644 docs/PHASE55-SCHEMA-OBJECTS.md 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.