diff --git a/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate b/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate index 5dfca6a5c..603de148e 100644 Binary files a/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate and b/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/OpenTable/AppIcon.icon/icon.json b/OpenTable/AppIcon.icon/icon.json index 6485926a7..cd10fd8ec 100644 --- a/OpenTable/AppIcon.icon/icon.json +++ b/OpenTable/AppIcon.icon/icon.json @@ -1,40 +1,40 @@ { - "fill" : { - "automatic-gradient" : "srgb:1.00000,0.57637,0.00000,1.00000" + "fill": { + "automatic-gradient": "srgb:1.00000,0.57637,0.00000,1.00000" }, - "groups" : [ + "groups": [ { - "layers" : [ + "layers": [ { - "fill" : { - "solid" : "display-p3:0.99736,1.00000,0.97886,1.00000" + "fill": { + "solid": "display-p3:0.99736,1.00000,0.97886,1.00000" }, - "image-name" : "cylinder.split.1x2.fill 1.svg", - "name" : "cylinder.split.1x2.fill 1" + "image-name": "cylinder.split.1x2.fill 1.svg", + "name": "cylinder.split.1x2.fill 1" } ], - "position" : { - "scale" : 1.3, - "translation-in-points" : [ + "position": { + "scale": 1.3, + "translation-in-points": [ 0, 0 ] }, - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 + "shadow": { + "kind": "neutral", + "opacity": 0.5 }, - "specular" : false, - "translucency" : { - "enabled" : true, - "value" : 0.5 + "specular": false, + "translucency": { + "enabled": true, + "value": 0.5 } } ], - "supported-platforms" : { - "circles" : [ + "supported-platforms": { + "circles": [ "watchOS" ], - "squares" : "shared" + "squares": "shared" } } \ No newline at end of file diff --git a/OpenTable/ContentView.swift b/OpenTable/ContentView.swift index ccc7de666..3f02b80cc 100644 --- a/OpenTable/ContentView.swift +++ b/OpenTable/ContentView.swift @@ -10,7 +10,7 @@ import SwiftUI struct ContentView: View { @StateObject private var dbManager = DatabaseManager.shared @State private var connections: [DatabaseConnection] = [] - @State private var columnVisibility: NavigationSplitViewVisibility = .detailOnly + @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var showNewConnectionSheet = false @State private var showEditConnectionSheet = false @State private var connectionToEdit: DatabaseConnection? @@ -20,6 +20,7 @@ struct ContentView: View { @State private var pendingCloseSessionId: UUID? @State private var hasLoaded = false @State private var escapeKeyMonitor: Any? + @State private var isInspectorPresented = false // Right sidebar (inspector) visibility @Environment(\.openWindow) private var openWindow @Environment(\.dismissWindow) private var dismissWindow @@ -91,10 +92,16 @@ struct ContentView: View { guard currentSession != nil else { return } Task { @MainActor in withAnimation { - columnVisibility = columnVisibility == .all ? .detailOnly : .all + // Toggle left sidebar (2-column layout: sidebar + detail) + if columnVisibility == .all { + columnVisibility = .detailOnly + } else { + columnVisibility = .all + } } } } + // Right sidebar toggle is handled by MainContentView (has the binding) .onChange(of: dbManager.currentSessionId) { _, newSessionId in Task { @MainActor in withAnimation { @@ -117,6 +124,7 @@ struct ContentView: View { private var mainContent: some View { if currentSession != nil { NavigationSplitView(columnVisibility: $columnVisibility) { + // MARK: - Sidebar (Left) - Table Browser VStack(spacing: 0) { if !sessions.isEmpty { ConnectionSidebarHeader( @@ -152,13 +160,16 @@ struct ContentView: View { pendingDeletes: sessionPendingDeletesBinding ) } + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 350) } detail: { + // MARK: - Detail (Main workspace with optional right sidebar) MainContentView( connection: currentSession!.connection, tables: sessionTablesBinding, selectedTables: sessionSelectedTablesBinding, pendingTruncates: sessionPendingTruncatesBinding, - pendingDeletes: sessionPendingDeletesBinding + pendingDeletes: sessionPendingDeletesBinding, + isInspectorPresented: $isInspectorPresented ) .id(currentSession!.id) } diff --git a/OpenTable/Core/Database/DatabaseDriver.swift b/OpenTable/Core/Database/DatabaseDriver.swift index 789b4ed5a..48901fe9e 100644 --- a/OpenTable/Core/Database/DatabaseDriver.swift +++ b/OpenTable/Core/Database/DatabaseDriver.swift @@ -55,6 +55,9 @@ protocol DatabaseDriver: AnyObject { /// Fetch rows with LIMIT/OFFSET pagination func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult + + /// Fetch table metadata (size, comment, engine, etc.) + func fetchTableMetadata(tableName: String) async throws -> TableMetadata } /// Default implementation for common operations diff --git a/OpenTable/Core/Database/MySQLDriver.swift b/OpenTable/Core/Database/MySQLDriver.swift index c0dc51f5f..501e9c1ea 100644 --- a/OpenTable/Core/Database/MySQLDriver.swift +++ b/OpenTable/Core/Database/MySQLDriver.swift @@ -15,6 +15,13 @@ final class MySQLDriver: DatabaseDriver { /// The underlying MariaDB connection private var mariadbConnection: MariaDBConnection? + + /// Static date formatter for parsing MySQL dates (performance optimization) + private static let mysqlDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() init(connection: DatabaseConnection) { self.connection = connection @@ -294,6 +301,77 @@ final class MySQLDriver: DatabaseDriver { return ddl } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + let escapedTableName = tableName.replacingOccurrences(of: "'", with: "''") + // NOTE: `SHOW TABLE STATUS LIKE` expects a pattern string literal, not an + // identifier. For that reason we must use single-quoted string syntax here + // instead of the backtick identifier quoting used in other schema queries + // (e.g. `SHOW CREATE TABLE \`table\``). The table name is safely embedded + // by escaping single quotes above. + let query = "SHOW TABLE STATUS WHERE Name = '\(escapedTableName)'" + let result = try await execute(query: query) + + guard let row = result.rows.first else { + return TableMetadata( + tableName: tableName, + dataSize: nil, + indexSize: nil, + totalSize: nil, + avgRowLength: nil, + rowCount: nil, + comment: nil, + engine: nil, + collation: nil, + createTime: nil, + updateTime: nil + ) + } + + // SHOW TABLE STATUS columns: + // 0: Name, 1: Engine, 2: Version, 3: Row_format, 4: Rows, 5: Avg_row_length, + // 6: Data_length, 7: Max_data_length, 8: Index_length, 9: Data_free, + // 10: Auto_increment, 11: Create_time, 12: Update_time, 13: Check_time, + // 14: Collation, 15: Checksum, 16: Create_options, 17: Comment + + let engine = row.count > 1 ? row[1] : nil + let rowCount = row.count > 4 ? Int64(row[4] ?? "0") : nil + let avgRowLength = row.count > 5 ? Int64(row[5] ?? "0") : nil + let dataSize = row.count > 6 ? Int64(row[6] ?? "0") : nil + let indexSize = row.count > 8 ? Int64(row[8] ?? "0") : nil + let collation = row.count > 14 ? row[14] : nil + let comment = row.count > 17 ? row[17] : nil + + // Parse dates using static formatter for performance + let createTime: Date? = { + guard row.count > 11, let dateStr = row[11] else { return nil } + return Self.mysqlDateFormatter.date(from: dateStr) + }() + + let updateTime: Date? = { + guard row.count > 12, let dateStr = row[12] else { return nil } + return Self.mysqlDateFormatter.date(from: dateStr) + }() + + let totalSize: Int64? = { + guard let data = dataSize, let index = indexSize else { return nil } + return data + index + }() + + return TableMetadata( + tableName: tableName, + dataSize: dataSize, + indexSize: indexSize, + totalSize: totalSize, + avgRowLength: avgRowLength, + rowCount: rowCount, + comment: comment?.isEmpty == true ? nil : comment, + engine: engine, + collation: collation, + createTime: createTime, + updateTime: updateTime + ) + } // MARK: - Paginated Query Support diff --git a/OpenTable/Core/Database/PostgreSQLDriver.swift b/OpenTable/Core/Database/PostgreSQLDriver.swift index 144618764..c41cc00a2 100644 --- a/OpenTable/Core/Database/PostgreSQLDriver.swift +++ b/OpenTable/Core/Database/PostgreSQLDriver.swift @@ -309,6 +309,64 @@ final class PostgreSQLDriver: DatabaseDriver { let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)" return try await execute(query: paginatedQuery) } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + // Escape single quotes to prevent SQL injection (string literal context) + let safeTableName = tableName.replacingOccurrences(of: "'", with: "''") + + let query = """ + SELECT + pg_total_relation_size(c.oid) AS total_size, + pg_table_size(c.oid) AS data_size, + pg_indexes_size(c.oid) AS index_size, + c.reltuples::bigint AS row_count, + CASE WHEN c.reltuples > 0 THEN pg_table_size(c.oid) / GREATEST(c.reltuples, 1) ELSE 0 END AS avg_row_length, + obj_description(c.oid, 'pg_class') AS comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = '\(safeTableName)' + AND n.nspname = 'public' + """ + + let result = try await execute(query: query) + + guard let row = result.rows.first else { + return TableMetadata( + tableName: tableName, + dataSize: nil, + indexSize: nil, + totalSize: nil, + avgRowLength: nil, + rowCount: nil, + comment: nil, + engine: nil, + collation: nil, + createTime: nil, + updateTime: nil + ) + } + + let totalSize = row.count > 0 ? Int64(row[0] ?? "0") : nil + let dataSize = row.count > 1 ? Int64(row[1] ?? "0") : nil + let indexSize = row.count > 2 ? Int64(row[2] ?? "0") : nil + let rowCount = row.count > 3 ? Int64(row[3] ?? "0") : nil + let avgRowLength = row.count > 4 ? Int64(row[4] ?? "0") : nil + let comment = row.count > 5 ? row[5] : nil + + return TableMetadata( + tableName: tableName, + dataSize: dataSize, + indexSize: indexSize, + totalSize: totalSize, + avgRowLength: avgRowLength, + rowCount: rowCount, + comment: comment?.isEmpty == true ? nil : comment, + engine: "PostgreSQL", + collation: nil, + createTime: nil, + updateTime: nil + ) + } private func stripLimitOffset(from query: String) -> String { var result = query diff --git a/OpenTable/Core/Database/SQLiteDriver.swift b/OpenTable/Core/Database/SQLiteDriver.swift index 2ca6885c5..ac9fe4829 100644 --- a/OpenTable/Core/Database/SQLiteDriver.swift +++ b/OpenTable/Core/Database/SQLiteDriver.swift @@ -297,6 +297,39 @@ final class SQLiteDriver: DatabaseDriver { return try await execute(query: paginatedQuery) } + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + guard status == .connected else { + throw DatabaseError.notConnected + } + + // Escape table name to prevent SQL injection (escape double quotes for identifier quoting) + let safeTableName = tableName.replacingOccurrences(of: "\"", with: "\"\"") + + // Get row count + let countQuery = "SELECT COUNT(*) FROM \"\(safeTableName)\"" + let countResult = try await execute(query: countQuery) + let rowCount: Int64? = { + guard let row = countResult.rows.first, let countStr = row.first else { return nil } + return Int64(countStr ?? "0") + }() + + // SQLite does not expose accurate per-table size information. + // To avoid reporting misleading values, we leave size-related fields as nil. + return TableMetadata( + tableName: tableName, + dataSize: nil, + indexSize: nil, + totalSize: nil, + avgRowLength: nil, + rowCount: rowCount, + comment: nil, + engine: "SQLite", + collation: nil, + createTime: nil, + updateTime: nil + ) + } + private func stripLimitOffset(from query: String) -> String { var result = query diff --git a/OpenTable/Models/TableMetadata.swift b/OpenTable/Models/TableMetadata.swift new file mode 100644 index 000000000..8cbafd3fa --- /dev/null +++ b/OpenTable/Models/TableMetadata.swift @@ -0,0 +1,39 @@ +// +// TableMetadata.swift +// OpenTable +// +// Model for table-level metadata +// + +import Foundation + +/// Represents table-level metadata fetched from database +struct TableMetadata { + let tableName: String + let dataSize: Int64? + let indexSize: Int64? + let totalSize: Int64? + let avgRowLength: Int64? + let rowCount: Int64? + let comment: String? + let engine: String? // MySQL/MariaDB only + let collation: String? // MySQL/MariaDB only + let createTime: Date? + let updateTime: Date? + + /// Format a size in bytes to human readable format + static func formatSize(_ bytes: Int64?) -> String { + guard let bytes = bytes else { return "—" } + if bytes == 0 { return "0 B" } + + let units = ["B", "KB", "MB", "GB", "TB"] + let exponent = min(Int(log(Double(bytes)) / log(1024)), units.count - 1) + let size = Double(bytes) / pow(1024, Double(exponent)) + + if exponent == 0 { + return "\(bytes) B" + } else { + return String(format: "%.1f %@", size, units[exponent]) + } + } +} diff --git a/OpenTable/Views/MainContentView.swift b/OpenTable/Views/MainContentView.swift index addc150b9..4d46768f4 100644 --- a/OpenTable/Views/MainContentView.swift +++ b/OpenTable/Views/MainContentView.swift @@ -17,6 +17,7 @@ struct MainContentView: View { @Binding var selectedTables: Set @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set + @Binding var isInspectorPresented: Bool @StateObject private var tabManager = QueryTabManager() @StateObject private var changeManager = DataChangeManager() @@ -50,6 +51,9 @@ struct MainContentView: View { @State private var isDismissing = false // Prevent saving when view is being destroyed @State private var justRestoredTab = false // Prevent lazy load duplicate execution after restore + // Right sidebar state + @State private var tableMetadata: TableMetadata? = nil + // MARK: - Constants private static let tabSaveDebounceDelay: UInt64 = 500_000_000 // 500ms in nanoseconds @@ -83,8 +87,50 @@ struct MainContentView: View { @ViewBuilder private var mainContentView: some View { - // Main content area (no right sidebar - not implemented) - mainEditorContent + HStack(spacing: 0) { + // Main editor content + mainEditorContent + + // Right sidebar - conditionally rendered for proper collapse + if isInspectorPresented { + Divider() + + RightSidebarView( + tableName: currentTab?.tableName, + tableMetadata: tableMetadata, + selectedRowData: selectedRowDataForSidebar + ) + .frame(width: 280) + .task(id: currentTab?.tableName) { + if let tableName = currentTab?.tableName { + // Only fetch if metadata not already loaded for this table + if tableMetadata?.tableName != tableName { + await loadTableMetadata(tableName: tableName) + } + } else { + tableMetadata = nil + } + } + } + } + } + + /// Compute selected row data for right sidebar + private var selectedRowDataForSidebar: [(column: String, value: String?, type: String)]? { + guard let tab = currentTab, + !selectedRowIndices.isEmpty, + let firstIndex = selectedRowIndices.min(), + firstIndex < tab.resultRows.count else { return nil } + + let row = tab.resultRows[firstIndex] + var data: [(column: String, value: String?, type: String)] = [] + for (i, col) in tab.resultColumns.enumerated() { + let value = i < row.values.count ? row.values[i] : nil + // Simple type indicator - can be enhanced later with actual column type info + let type = "string" + data.append((column: col, value: value, type: type)) + } + return data } // MARK: - Main Editor Content @@ -326,6 +372,16 @@ struct MainContentView: View { // Toggle history panel globally (Cmd+Shift+H) appState.isHistoryPanelVisible.toggle() } + .onReceive(NotificationCenter.default.publisher(for: .toggleRightSidebar)) { _ in + // Toggle inspector (Cmd+Opt+B) - no animation for native feel + isInspectorPresented.toggle() + // Load table metadata only if opening and not already loaded for this table + if isInspectorPresented, + let tableName = currentTab?.tableName, + tableMetadata?.tableName != tableName { + Task { await loadTableMetadata(tableName: tableName) } + } + } .onReceive(NotificationCenter.default.publisher(for: .applyAllFilters)) { _ in // Apply all selected filters (Cmd+Return) if filterStateManager.hasSelectedFilters { @@ -827,6 +883,21 @@ struct MainContentView: View { } await schemaProvider.loadSchema(using: driver, connection: connection) } + + private func loadTableMetadata(tableName: String) async { + guard let driver = DatabaseManager.shared.activeDriver else { + return + } + + do { + let metadata = try await driver.fetchTableMetadata(tableName: tableName) + await MainActor.run { + self.tableMetadata = metadata + } + } catch { + print("[MainContentView] Failed to load table metadata: \(error)") + } + } private func runQuery() { guard let index = tabManager.selectedTabIndex else { @@ -2120,7 +2191,8 @@ struct MainContentView: View { tables: .constant([]), selectedTables: .constant([]), pendingTruncates: .constant([]), - pendingDeletes: .constant([]) + pendingDeletes: .constant([]), + isInspectorPresented: .constant(false) ) .frame(width: 1000, height: 600) } diff --git a/OpenTable/Views/RightSidebar/RightSidebarView.swift b/OpenTable/Views/RightSidebar/RightSidebarView.swift new file mode 100644 index 000000000..3dde30294 --- /dev/null +++ b/OpenTable/Views/RightSidebar/RightSidebarView.swift @@ -0,0 +1,264 @@ +// +// RightSidebarView.swift +// OpenTable +// +// Professional macOS inspector-style right sidebar +// + +import SwiftUI + +/// Right sidebar that shows table metadata or selected row details +struct RightSidebarView: View { + let tableName: String? + let tableMetadata: TableMetadata? + let selectedRowData: [(column: String, value: String?, type: String)]? + + @State private var searchText: String = "" + + private var mode: String { + selectedRowData != nil ? "Row Details" : "Table Info" + } + + var body: some View { + VStack(spacing: 0) { + // Header + headerView + + Divider() + + // Search (only for row details) + if selectedRowData != nil { + searchField + + Divider() + } + + // Content + ScrollView { + LazyVStack(spacing: 0) { + if let rowData = selectedRowData { + rowDetailContent(rowData) + } else if let metadata = tableMetadata { + tableInfoContent(metadata) + } else { + emptyState + } + } + } + } + .background(Color(NSColor.windowBackgroundColor)) + } + + // MARK: - Header + + private var headerView: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(mode) + .font(.system(size: 11, weight: .semibold)) + if let name = tableName { + Text(name) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + // MARK: - Search + + private var searchField: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.tertiary) + .font(.system(size: 10)) + + TextField("Filter", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 11)) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + .font(.system(size: 10)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(NSColor.textBackgroundColor).opacity(0.5)) + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "sidebar.right") + .font(.system(size: 24)) + .foregroundStyle(.quaternary) + Text("No Selection") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + // MARK: - Table Info Content + + @ViewBuilder + private func tableInfoContent(_ metadata: TableMetadata) -> some View { + sectionHeader("SIZE") + propertyRow("Data Size", TableMetadata.formatSize(metadata.dataSize)) + propertyRow("Index Size", TableMetadata.formatSize(metadata.indexSize)) + propertyRow("Total Size", TableMetadata.formatSize(metadata.totalSize)) + + sectionHeader("STATISTICS") + if let rows = metadata.rowCount { + propertyRow("Rows", "\(rows)") + } + if let avgLen = metadata.avgRowLength { + propertyRow("Avg Row", "\(avgLen) B") + } + + if metadata.engine != nil || metadata.collation != nil { + sectionHeader("METADATA") + if let engine = metadata.engine { + propertyRow("Engine", engine) + } + if let collation = metadata.collation { + propertyRow("Collation", collation) + } + } + + if metadata.createTime != nil || metadata.updateTime != nil { + sectionHeader("TIMESTAMPS") + if let create = metadata.createTime { + propertyRow("Created", formatDate(create)) + } + if let update = metadata.updateTime { + propertyRow("Updated", formatDate(update)) + } + } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + private func formatDate(_ date: Date) -> String { + return RightSidebarView.dateFormatter.string(from: date) + } + + // MARK: - Row Detail Content + + @ViewBuilder + private func rowDetailContent(_ rowData: [(column: String, value: String?, type: String)]) -> some View { + let filtered = searchText.isEmpty ? rowData : rowData.filter { + $0.column.localizedCaseInsensitiveContains(searchText) || + ($0.value?.localizedCaseInsensitiveContains(searchText) ?? false) + } + + sectionHeader("FIELDS (\(filtered.count))") + + ForEach(filtered, id: \.column) { field in + fieldRow(field) + } + } + + // MARK: - UI Components + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 4) + } + + private func propertyRow(_ key: String, _ value: String) -> some View { + HStack { + Text(key) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Spacer() + Text(value) + .font(.system(size: 11)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + } + + private func fieldRow(_ field: (column: String, value: String?, type: String)) -> some View { + VStack(alignment: .leading, spacing: 4) { + // Field name + type badge + HStack(spacing: 6) { + Text(field.column) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + + Text(field.type) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(3) + } + + // Value + if let value = field.value { + Text(value) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(3) + } else { + Text("NULL") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } +} + +// MARK: - Preview + +#Preview { + RightSidebarView( + tableName: "users", + tableMetadata: TableMetadata( + tableName: "users", + dataSize: 16384, + indexSize: 8192, + totalSize: 24576, + avgRowLength: 128, + rowCount: 1250, + comment: "User accounts", + engine: "InnoDB", + collation: "utf8mb4_unicode_ci", + createTime: Date(), + updateTime: nil + ), + selectedRowData: nil + ) + .frame(width: 280, height: 400) +} diff --git a/OpenTable/Views/Toolbar/OpenTableToolbarView.swift b/OpenTable/Views/Toolbar/OpenTableToolbarView.swift index f85bc34e4..49877fad1 100644 --- a/OpenTable/Views/Toolbar/OpenTableToolbarView.swift +++ b/OpenTable/Views/Toolbar/OpenTableToolbarView.swift @@ -62,7 +62,6 @@ struct ToolbarPrincipalContent: View { /// Apply this to a view to add the production toolbar struct OpenTableToolbar: ViewModifier { @ObservedObject var state: ConnectionToolbarState - @State private var showNotImplementedAlert = false func body(content: Content) -> some View { content @@ -79,21 +78,16 @@ struct OpenTableToolbar: ViewModifier { } // MARK: - Primary Action (Right) - // Right sidebar (inspector) toggle button - not implemented yet + // Right sidebar (inspector) toggle button ToolbarItem(placement: .primaryAction) { Button { - showNotImplementedAlert = true + NotificationCenter.default.post(name: .toggleRightSidebar, object: nil) } label: { Image(systemName: "sidebar.trailing") } - .help("Toggle Inspector") + .help("Toggle Inspector (⌘⌥B)") } } - .alert("Not Implemented", isPresented: $showNotImplementedAlert) { - Button("OK", role: .cancel) {} - } message: { - Text("The Inspector sidebar is not implemented yet.") - } } }