diff --git a/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate b/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 603de148e..000000000 Binary files a/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/OpenTable/ContentView.swift b/OpenTable/ContentView.swift index 3f02b80cc..6f00d3266 100644 --- a/OpenTable/ContentView.swift +++ b/OpenTable/ContentView.swift @@ -304,6 +304,11 @@ struct ContentView: View { escapeKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in // Escape key code is 53 if event.keyCode == 53 { + // Don't consume ESC if a sheet is presented - let it dismiss the sheet + if AppState.shared.isSheetPresented { + return event + } + NotificationCenter.default.post(name: .clearSelection, object: nil) // Return nil to consume the event, or return event to let it propagate return nil diff --git a/OpenTable/Core/Database/DatabaseDriver.swift b/OpenTable/Core/Database/DatabaseDriver.swift index 48901fe9e..0fbf3b090 100644 --- a/OpenTable/Core/Database/DatabaseDriver.swift +++ b/OpenTable/Core/Database/DatabaseDriver.swift @@ -58,6 +58,9 @@ protocol DatabaseDriver: AnyObject { /// Fetch table metadata (size, comment, engine, etc.) func fetchTableMetadata(tableName: String) async throws -> TableMetadata + + /// Fetch list of all databases on the server + func fetchDatabases() async throws -> [String] } /// Default implementation for common operations diff --git a/OpenTable/Core/Database/MySQLDriver.swift b/OpenTable/Core/Database/MySQLDriver.swift index 501e9c1ea..b6642409c 100644 --- a/OpenTable/Core/Database/MySQLDriver.swift +++ b/OpenTable/Core/Database/MySQLDriver.swift @@ -448,4 +448,10 @@ final class MySQLDriver: DatabaseDriver { return result.trimmingCharacters(in: .whitespacesAndNewlines) } + + /// Fetch list of all databases on the server + func fetchDatabases() async throws -> [String] { + let result = try await execute(query: "SHOW DATABASES") + return result.rows.compactMap { row in row.first.flatMap { $0 } } + } } diff --git a/OpenTable/Core/Database/PostgreSQLDriver.swift b/OpenTable/Core/Database/PostgreSQLDriver.swift index c41cc00a2..1a6856319 100644 --- a/OpenTable/Core/Database/PostgreSQLDriver.swift +++ b/OpenTable/Core/Database/PostgreSQLDriver.swift @@ -385,4 +385,10 @@ final class PostgreSQLDriver: DatabaseDriver { return result.trimmingCharacters(in: .whitespacesAndNewlines) } + + /// Fetch list of all databases on the server + func fetchDatabases() async throws -> [String] { + let result = try await execute(query: "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname") + return result.rows.compactMap { row in row.first.flatMap { $0 } } + } } diff --git a/OpenTable/Core/Database/SQLiteDriver.swift b/OpenTable/Core/Database/SQLiteDriver.swift index ac9fe4829..79f255609 100644 --- a/OpenTable/Core/Database/SQLiteDriver.swift +++ b/OpenTable/Core/Database/SQLiteDriver.swift @@ -347,11 +347,18 @@ final class SQLiteDriver: DatabaseDriver { } // MARK: - Helpers - + private func expandPath(_ path: String) -> String { if path.hasPrefix("~") { return NSString(string: path).expandingTildeInPath } return path } + + /// SQLite databases are file-based, so this returns an empty array + func fetchDatabases() async throws -> [String] { + // SQLite doesn't have a concept of multiple databases on a server + // Each SQLite file is a separate database + return [] + } } diff --git a/OpenTable/Extensions/FocusedValues+Extensions.swift b/OpenTable/Extensions/FocusedValues+Extensions.swift new file mode 100644 index 000000000..c79d8acd6 --- /dev/null +++ b/OpenTable/Extensions/FocusedValues+Extensions.swift @@ -0,0 +1,22 @@ +// +// FocusedValues+Extensions.swift +// OpenTable +// + +import SwiftUI + +// MARK: - Database Switcher Focus + +/// Key for tracking whether DatabaseSwitcher sheet is currently open +struct IsDatabaseSwitcherOpenKey: FocusedValueKey { + typealias Value = Bool +} + +extension FocusedValues { + /// Whether the DatabaseSwitcher sheet is currently presented + /// Used by commands to disable conflicting keyboard shortcuts + var isDatabaseSwitcherOpen: Bool? { + get { self[IsDatabaseSwitcherOpenKey.self] } + set { self[IsDatabaseSwitcherOpenKey.self] = newValue } + } +} diff --git a/OpenTable/Models/ConnectionToolbarState.swift b/OpenTable/Models/ConnectionToolbarState.swift index a4afcb033..e8a7d37c0 100644 --- a/OpenTable/Models/ConnectionToolbarState.swift +++ b/OpenTable/Models/ConnectionToolbarState.swift @@ -111,6 +111,9 @@ final class ConnectionToolbarState: ObservableObject { /// Connection name for display @Published var connectionName: String = "" + /// Current database name + @Published var databaseName: String = "" + /// Custom display color for the connection (uses database type color if not set) @Published var displayColor: Color = .orange @@ -188,6 +191,7 @@ final class ConnectionToolbarState: ObservableObject { /// Update state from a DatabaseConnection model func update(from connection: DatabaseConnection) { connectionName = connection.name + databaseName = connection.database databaseType = connection.type displayColor = connection.displayColor tagId = connection.tagId @@ -213,6 +217,7 @@ final class ConnectionToolbarState: ObservableObject { databaseType = .mysql databaseVersion = nil connectionName = "" + databaseName = "" displayColor = databaseType.themeColor connectionState = .disconnected isExecuting = false diff --git a/OpenTable/OpenTableApp.swift b/OpenTable/OpenTableApp.swift index 9e5469f56..f030b4129 100644 --- a/OpenTable/OpenTableApp.swift +++ b/OpenTable/OpenTableApp.swift @@ -17,6 +17,81 @@ final class AppState: ObservableObject { @Published var hasRowSelection: Bool = false // True when rows are selected in data grid @Published var hasTableSelection: Bool = false // True when tables are selected in sidebar @Published var isHistoryPanelVisible: Bool = false // Global history panel visibility + @Published var isSheetPresented: Bool = false // True when any modal sheet is open (blocks ESC key handling) +} + +// MARK: - Pasteboard Commands with FocusedValue Support + +/// Custom Commands struct to properly access FocusedValue for disabling ESC when sheet is open +struct PasteboardCommands: Commands { + @ObservedObject var appState: AppState + @FocusedValue(\.isDatabaseSwitcherOpen) var isDatabaseSwitcherOpen: Bool? + + var body: some Commands { + CommandGroup(replacing: .pasteboard) { + Button("Cut") { + NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: nil) + } + .keyboardShortcut("x", modifiers: .command) + + Button("Copy") { + // Check if user is editing text in a cell (firstResponder is NSTextView field editor) + if let firstResponder = NSApp.keyWindow?.firstResponder, + firstResponder is NSTextView { + // User is editing text - let standard copy handle selected text + NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) + } else if appState.hasRowSelection { + // Copy entire rows when rows are selected + NotificationCenter.default.post(name: .copySelectedRows, object: nil) + } else if appState.hasTableSelection { + // Copy table names when tables are selected + NotificationCenter.default.post(name: .copyTableNames, object: nil) + } else { + // Fallback to standard copy + NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) + } + } + .keyboardShortcut("c", modifiers: .command) + + Button("Paste") { + NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil) + } + .keyboardShortcut("v", modifiers: .command) + + Button("Delete") { + // Check if first responder is the history panel's table view + // History panel uses responder chain for delete actions + // Data grid uses notifications for batched undo support + if let firstResponder = NSApp.keyWindow?.firstResponder { + // Check class name to identify HistoryTableView + let className = String(describing: type(of: firstResponder)) + if className.contains("HistoryTableView") { + // Let history panel handle via responder chain + NSApp.sendAction(#selector(NSText.delete(_:)), to: nil, from: nil) + return + } + } + + // For data grid and other views, use notification for batched undo + NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) + } + .keyboardShortcut(.delete, modifiers: .command) + .disabled(!appState.isCurrentTabEditable && !appState.hasTableSelection) + + Divider() + + Button("Select All") { + NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) + } + .keyboardShortcut("a", modifiers: .command) + + Button("Clear Selection") { + NotificationCenter.default.post(name: .clearSelection, object: nil) + } + .keyboardShortcut(.escape, modifiers: []) + .disabled(isDatabaseSwitcherOpen == true) + } + } } // MARK: - App @@ -69,6 +144,12 @@ struct OpenTableApp: App { .keyboardShortcut("t", modifiers: .command) .disabled(!appState.isConnected) + Button("Open Database...") { + NotificationCenter.default.post(name: .openDatabaseSwitcher, object: nil) + } + .keyboardShortcut("k", modifiers: .command) + .disabled(!appState.isConnected) + Divider() Button("Save Changes") { @@ -129,70 +210,9 @@ struct OpenTableApp: App { .keyboardShortcut("z", modifiers: [.command, .shift]) } - // Edit menu - replace pasteboard to add our Delete with shortcut - CommandGroup(replacing: .pasteboard) { - Button("Cut") { - NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: nil) - } - .keyboardShortcut("x", modifiers: .command) - - Button("Copy") { - // Check if user is editing text in a cell (firstResponder is NSTextView field editor) - if let firstResponder = NSApp.keyWindow?.firstResponder, - firstResponder is NSTextView { - // User is editing text - let standard copy handle selected text - NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) - } else if appState.hasRowSelection { - // Copy entire rows when rows are selected - NotificationCenter.default.post(name: .copySelectedRows, object: nil) - } else if appState.hasTableSelection { - // Copy table names when tables are selected - NotificationCenter.default.post(name: .copyTableNames, object: nil) - } else { - // Fallback to standard copy - NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) - } - } - .keyboardShortcut("c", modifiers: .command) - - Button("Paste") { - NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil) - } - .keyboardShortcut("v", modifiers: .command) - - Button("Delete") { - // Check if first responder is the history panel's table view - // History panel uses responder chain for delete actions - // Data grid uses notifications for batched undo support - if let firstResponder = NSApp.keyWindow?.firstResponder { - // Check class name to identify HistoryTableView - let className = String(describing: type(of: firstResponder)) - if className.contains("HistoryTableView") { - // Let history panel handle via responder chain - NSApp.sendAction(#selector(NSText.delete(_:)), to: nil, from: nil) - return - } - } - - // For data grid and other views, use notification for batched undo - NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) - } - .keyboardShortcut(.delete, modifiers: .command) - .disabled(!appState.isCurrentTabEditable && !appState.hasTableSelection) - - Divider() - - Button("Select All") { - NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) - } - .keyboardShortcut("a", modifiers: .command) - - Button("Clear Selection") { - NotificationCenter.default.post(name: .clearSelection, object: nil) - } - .keyboardShortcut(.escape, modifiers: []) - } - + // Edit menu - pasteboard commands with FocusedValue support + PasteboardCommands(appState: appState) + // Edit menu - row operations (after pasteboard) CommandGroup(after: .pasteboard) { Divider() @@ -286,7 +306,10 @@ extension Notification.Name { // History panel notifications static let toggleHistoryPanel = Notification.Name("toggleHistoryPanel") - + + // Database switcher notifications + static let openDatabaseSwitcher = Notification.Name("openDatabaseSwitcher") + // Window lifecycle notifications static let mainWindowWillClose = Notification.Name("mainWindowWillClose") } diff --git a/OpenTable/Views/Connection/ConnectionSidebarHeader.swift b/OpenTable/Views/Connection/ConnectionSidebarHeader.swift index 2cc7b81ae..6536a4af2 100644 --- a/OpenTable/Views/Connection/ConnectionSidebarHeader.swift +++ b/OpenTable/Views/Connection/ConnectionSidebarHeader.swift @@ -37,7 +37,7 @@ struct ConnectionSidebarHeader: View { Image(systemName: session.connection.type.iconName) .foregroundStyle(session.connection.displayColor) - Text(session.connection.name) + Text(session.connection.database) Spacer() diff --git a/OpenTable/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/OpenTable/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift new file mode 100644 index 000000000..3bd42a94d --- /dev/null +++ b/OpenTable/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -0,0 +1,411 @@ +// +// DatabaseSwitcherSheet.swift +// OpenTable +// +// Modal sheet to display and switch between databases. +// Similar to TablePlus's "Open database" feature (Cmd+K). +// + +import SwiftUI + +/// Modal sheet to display available databases and switch between them +struct DatabaseSwitcherSheet: View { + @Binding var isPresented: Bool + @Environment(\.dismiss) private var dismiss + let currentDatabase: String? + let databaseType: DatabaseType + let onSelect: (String) -> Void + + @State private var databases: [String] = [] + @State private var searchText = "" + @State private var isLoading = true + @State private var errorMessage: String? + @State private var selectedItem: String? + @State private var shouldScrollToSelection = false + + var filteredDatabases: [String] { + if searchText.isEmpty { + return databases + } + return databases.filter { + $0.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + Text("Open database") + .font(.system(size: 13, weight: .semibold)) + .padding(.vertical, 12) + + Divider() + + // Search field + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + + TextField("Search for database...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .onSubmit { + openSelectedDatabase() + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Divider() + + // Database list or empty state + if isLoading { + loadingView + } else if let error = errorMessage { + errorView(error) + } else if databaseType == .sqlite { + sqliteEmptyState + } else if filteredDatabases.isEmpty { + emptyState + } else { + databaseListView + } + + Divider() + + // Footer buttons + footerView + } + .frame(width: 360, height: 340) + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { + loadDatabases() + } + .onChange(of: searchText) { _, _ in + // Reset selection when search changes + selectedItem = filteredDatabases.first + } + .onKeyPress(.escape) { + dismiss() + return .handled + } + .onKeyPress(.upArrow) { + moveSelection(up: true) + return .handled + } + .onKeyPress(.downArrow) { + moveSelection(up: false) + return .handled + } + .onKeyPress(.return) { + openSelectedDatabase() + return .handled + } + } + + // MARK: - Database List + + private var databaseListView: some View { + ScrollViewReader { proxy in + List(filteredDatabases, id: \.self) { database in + databaseRow(database) + .id(database) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) + .listRowBackground( + RoundedRectangle(cornerRadius: 4) + .fill(database == selectedItem ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, 4) + ) + .onTapGesture { + selectedItem = database + } + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + .alternatingRowBackgrounds(.disabled) + .environment(\.defaultMinListRowHeight, 28) + .onChange(of: filteredDatabases) { _, newList in + // Reset selection when list changes + if let selected = selectedItem, !newList.contains(selected) { + selectedItem = newList.first + } + } + .onChange(of: selectedItem) { _, newValue in + // Scroll to selected item when navigating with keyboard + if let item = newValue { + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(item, anchor: .center) + } + } + } + .onChange(of: shouldScrollToSelection) { _, shouldScroll in + // Scroll to selection after databases load + if shouldScroll, let item = selectedItem { + shouldScrollToSelection = false + + // Delay scroll to ensure List is fully rendered + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(item, anchor: .center) + } + } + } + } + } + } + + private func databaseRow(_ database: String) -> some View { + let isSelected = database == selectedItem + let isCurrent = database == currentDatabase + + return HStack(spacing: 10) { + Image(systemName: "cylinder") + .font(.system(size: 13)) + .foregroundStyle(isSelected ? .white : (isCurrent ? .blue : .secondary)) + + Text(database) + .font(.system(size: 13)) + .foregroundStyle(isSelected ? .white : .primary) + .lineLimit(1) + + Spacer() + + if isCurrent { + Text("current") + .font(.system(size: 10)) + .foregroundStyle(isSelected ? .white.opacity(0.8) : .secondary) + } + } + .contentShape(Rectangle()) + .overlay( + DoubleClickView { + selectedItem = database + openSelectedDatabase() + } + ) + } + + // MARK: - Empty States + + private var loadingView: some View { + VStack(spacing: 12) { + ProgressView() + .scaleEffect(0.8) + Text("Loading databases...") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorView(_ message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 24)) + .foregroundStyle(.orange) + + Text("Failed to load databases") + .font(.system(size: 13, weight: .medium)) + + Text(message) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Retry") { + loadDatabases() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var sqliteEmptyState: some View { + VStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.system(size: 24)) + .foregroundStyle(.secondary) + + Text("SQLite is file-based") + .font(.system(size: 13, weight: .medium)) + + Text("Each SQLite file is a separate database.\nTo open a different database, create a new connection.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 24)) + .foregroundStyle(.secondary) + + if searchText.isEmpty { + Text("No databases found") + .font(.system(size: 13, weight: .medium)) + } else { + Text("No matching databases") + .font(.system(size: 13, weight: .medium)) + + Text("No databases match \"\(searchText)\"") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Footer + + private var footerView: some View { + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button("Open") { + openSelectedDatabase() + } + .buttonStyle(.borderedProminent) + .disabled(selectedItem == nil || selectedItem == currentDatabase) + } + .padding(12) + } + + // MARK: - Actions + + private func moveSelection(up: Bool) { + guard !filteredDatabases.isEmpty else { return } + + // Determine the current index only if the selected item exists in the filtered list + if let selected = selectedItem, + let currentIndex = filteredDatabases.firstIndex(of: selected) { + if up { + let newIndex = max(0, currentIndex - 1) + selectedItem = filteredDatabases[newIndex] + } else { + let newIndex = min(filteredDatabases.count - 1, currentIndex + 1) + selectedItem = filteredDatabases[newIndex] + } + } else { + // No valid current selection; choose a sensible starting point + selectedItem = up ? filteredDatabases.last : filteredDatabases.first + } + } + + private func loadDatabases() { + isLoading = true + errorMessage = nil + + Task { + do { + guard let driver = DatabaseManager.shared.activeDriver else { + await MainActor.run { + errorMessage = "No active connection" + isLoading = false + } + return + } + + let result = try await driver.fetchDatabases() + + await MainActor.run { + databases = result + isLoading = false + + // Pre-select current database if available + if let current = currentDatabase, result.contains(current) { + selectedItem = current + } else { + selectedItem = result.first + } + + // Trigger scroll to selection and focus + shouldScrollToSelection = true + } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + isLoading = false + } + } + } + } + + private func openSelectedDatabase() { + guard let database = selectedItem else { return } + + // Don't reopen current database + if database == currentDatabase { + dismiss() + return + } + + onSelect(database) + dismiss() + } +} + + +// MARK: - DoubleClickView + +/// NSViewRepresentable that detects double-clicks without interfering with native List selection +private struct DoubleClickView: NSViewRepresentable { + let onDoubleClick: () -> Void + + func makeNSView(context: Context) -> NSView { + let view = PassThroughDoubleClickView() + view.onDoubleClick = onDoubleClick + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + (nsView as? PassThroughDoubleClickView)?.onDoubleClick = onDoubleClick + } +} + +private class PassThroughDoubleClickView: NSView { + var onDoubleClick: (() -> Void)? + + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { + onDoubleClick?() + } + // Always forward to next responder for List selection + super.mouseDown(with: event) + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + } +} + +// MARK: - Preview + +#Preview("MySQL Databases") { + DatabaseSwitcherSheet( + isPresented: .constant(true), + currentDatabase: "laravel", + databaseType: .mysql, + onSelect: { db in print("Selected: \(db)") } + ) +} + +#Preview("SQLite Empty") { + DatabaseSwitcherSheet( + isPresented: .constant(true), + currentDatabase: nil, + databaseType: .sqlite, + onSelect: { db in print("Selected: \(db)") } + ) +} diff --git a/OpenTable/Views/MainContentView.swift b/OpenTable/Views/MainContentView.swift index 4d46768f4..d3c9efe41 100644 --- a/OpenTable/Views/MainContentView.swift +++ b/OpenTable/Views/MainContentView.swift @@ -63,7 +63,10 @@ struct MainContentView: View { // Error alert state @State private var showErrorAlert = false @State private var errorAlertMessage = "" - + + // Database switcher state + @State private var showDatabaseSwitcher = false + // Global app state for history panel @EnvironmentObject private var appState: AppState @@ -256,6 +259,20 @@ struct MainContentView: View { } message: { Text(errorAlertMessage) } + .sheet(isPresented: $showDatabaseSwitcher) { + DatabaseSwitcherSheet( + isPresented: $showDatabaseSwitcher, + currentDatabase: connection.database.isEmpty ? nil : connection.database, + databaseType: connection.type, + onSelect: { database in + switchToDatabase(database) + } + ) + } + .focusedValue(\.isDatabaseSwitcherOpen, showDatabaseSwitcher) + .onChange(of: showDatabaseSwitcher) { _, isPresented in + appState.isSheetPresented = isPresented + } .onChange(of: tabManager.selectedTabId) { oldTabId, newTabId in // Must be synchronous - save state BEFORE SwiftUI updates the view handleTabChange(oldTabId: oldTabId, newTabId: newTabId) @@ -372,6 +389,9 @@ struct MainContentView: View { // Toggle history panel globally (Cmd+Shift+H) appState.isHistoryPanelVisible.toggle() } + .onReceive(NotificationCenter.default.publisher(for: .openDatabaseSwitcher)) { _ in + showDatabaseSwitcher = true + } .onReceive(NotificationCenter.default.publisher(for: .toggleRightSidebar)) { _ in // Toggle inspector (Cmd+Opt+B) - no animation for native feel isInspectorPresented.toggle() @@ -2183,6 +2203,33 @@ struct MainContentView: View { // Execute the query runQuery() } + + /// Switch to a different database on the same server + private func switchToDatabase(_ database: String) { + let newConnection = DatabaseConnection( + id: UUID(), + name: connection.name, + host: connection.host, + port: connection.port, + database: database, + username: connection.username, + type: connection.type, + sshConfig: connection.sshConfig, + color: connection.color, + tagId: connection.tagId + ) + + Task { + do { + try await DatabaseManager.shared.connectToSession(newConnection) + } catch { + await MainActor.run { + errorAlertMessage = "Failed to connect to database '\(database)': \(error.localizedDescription)" + showErrorAlert = true + } + } + } + } } #Preview("With Connection") { diff --git a/OpenTable/Views/Results/DataGridView.swift b/OpenTable/Views/Results/DataGridView.swift index ed2066bda..ba96cc336 100644 --- a/OpenTable/Views/Results/DataGridView.swift +++ b/OpenTable/Views/Results/DataGridView.swift @@ -551,59 +551,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // MARK: - Row Actions - func deleteRow(at index: Int) { - deleteRows(at: [index]) - } - - func deleteRows(at indices: Set) { - guard !indices.isEmpty else { return } - - var insertedRowsToDelete: [Int] = [] - var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = [] - - for rowIndex in indices { - if changeManager.isRowInserted(rowIndex) { - insertedRowsToDelete.append(rowIndex) - } else if !changeManager.isRowDeleted(rowIndex) { - guard let rowData = rowProvider.row(at: rowIndex) else { continue } - existingRowsToDelete.append((rowIndex: rowIndex, originalRow: rowData.values)) - } - } - - if !insertedRowsToDelete.isEmpty { - let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) - for rowIndex in sortedInsertedRows { - rowProvider.removeRow(at: rowIndex) - } - changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) - updateCache() - } - - if !existingRowsToDelete.isEmpty { - changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) - } - - let minSelectedRow = indices.min() ?? 0 - let maxSelectedRow = indices.max() ?? 0 - let totalRows = cachedRowCount - let insertedRowsDeletedCount = insertedRowsToDelete.count - - let adjustedMaxRow = maxSelectedRow - insertedRowsDeletedCount - let adjustedMinRow = minSelectedRow - insertedRowsToDelete.filter { $0 < minSelectedRow }.count - - var newSelection = Set() - if adjustedMaxRow + 1 < totalRows { - newSelection.insert(min(adjustedMaxRow + 1, totalRows - 1)) - } else if adjustedMinRow > 0 { - newSelection.insert(adjustedMinRow - 1) - } else if totalRows > 0 { - newSelection.insert(0) - } - - self.selectedRowIndices = newSelection - tableView?.reloadData() - } - func undoDeleteRow(at index: Int) { changeManager.undoRowDeletion(rowIndex: index) tableView?.reloadData( diff --git a/OpenTable/Views/Results/KeyHandlingTableView.swift b/OpenTable/Views/Results/KeyHandlingTableView.swift index 7deb194e8..d7a594b61 100644 --- a/OpenTable/Views/Results/KeyHandlingTableView.swift +++ b/OpenTable/Views/Results/KeyHandlingTableView.swift @@ -95,9 +95,8 @@ final class KeyHandlingTableView: NSTableView, NSMenuItemValidation { @objc func delete(_ sender: Any?) { guard coordinator?.isEditable == true else { return } - let selectedIndices = Set(selectedRowIndexes.map { $0 }) - guard !selectedIndices.isEmpty else { return } - coordinator?.deleteRows(at: selectedIndices) + guard !selectedRowIndexes.isEmpty else { return } + NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) } func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { @@ -114,9 +113,8 @@ final class KeyHandlingTableView: NSTableView, NSMenuItemValidation { override func performKeyEquivalent(with event: NSEvent) -> Bool { if event.keyCode == 51 || event.keyCode == 117 { - let selectedIndices = Set(selectedRowIndexes.map { $0 }) - if !selectedIndices.isEmpty && coordinator?.isEditable == true { - coordinator?.deleteRows(at: selectedIndices) + if !selectedRowIndexes.isEmpty && coordinator?.isEditable == true { + NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) return true } } diff --git a/OpenTable/Views/Results/TableRowViewWithMenu.swift b/OpenTable/Views/Results/TableRowViewWithMenu.swift index d22867854..9e0ecb7fe 100644 --- a/OpenTable/Views/Results/TableRowViewWithMenu.swift +++ b/OpenTable/Views/Results/TableRowViewWithMenu.swift @@ -101,7 +101,7 @@ final class TableRowViewWithMenu: NSTableRowView { } @objc private func deleteRow() { - coordinator?.deleteRow(at: rowIndex) + NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) } @objc private func duplicateRow() { diff --git a/OpenTable/Views/Toolbar/ConnectionStatusView.swift b/OpenTable/Views/Toolbar/ConnectionStatusView.swift index 11e6b92cf..cf60ca49e 100644 --- a/OpenTable/Views/Toolbar/ConnectionStatusView.swift +++ b/OpenTable/Views/Toolbar/ConnectionStatusView.swift @@ -12,6 +12,7 @@ import SwiftUI struct ConnectionStatusView: View { let databaseType: DatabaseType let databaseVersion: String? + let databaseName: String let connectionName: String let connectionState: ToolbarConnectionState let displayColor: Color @@ -25,6 +26,15 @@ struct ConnectionStatusView: View { Divider() .frame(height: 16) + // Database name (clickable to switch databases) + if !databaseName.isEmpty { + databaseNameSection + + // Vertical separator + Divider() + .frame(height: 16) + } + // Connection name + status indicator connectionInfoSection } @@ -48,6 +58,25 @@ struct ConnectionStatusView: View { .help("Database: \(formattedDatabaseInfo)") } + /// Database name (clickable to open database switcher) + private var databaseNameSection: some View { + Button { + NotificationCenter.default.post(name: .openDatabaseSwitcher, object: nil) + } label: { + HStack(spacing: 4) { + Image(systemName: "cylinder") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + Text(databaseName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + } + } + .buttonStyle(.plain) + .help("Current database: \(databaseName) (⌘K to switch)") + } + /// Connection name and status dot private var connectionInfoSection: some View { HStack(spacing: 8) { @@ -121,6 +150,7 @@ private struct PulseAnimation: ViewModifier { ConnectionStatusView( databaseType: .mariadb, databaseVersion: "11.1.2", + databaseName: "production_db", connectionName: "Production Database", connectionState: .connected, displayColor: .cyan @@ -133,6 +163,7 @@ private struct PulseAnimation: ViewModifier { ConnectionStatusView( databaseType: .mysql, databaseVersion: "8.0.35", + databaseName: "dev_db", connectionName: "Development", connectionState: .executing, displayColor: .orange @@ -145,6 +176,7 @@ private struct PulseAnimation: ViewModifier { ConnectionStatusView( databaseType: .postgresql, databaseVersion: "16.1", + databaseName: "analytics", connectionName: "Analytics DB", connectionState: .connected, displayColor: .blue diff --git a/OpenTable/Views/Toolbar/OpenTableToolbarView.swift b/OpenTable/Views/Toolbar/OpenTableToolbarView.swift index 49877fad1..8d57d16d4 100644 --- a/OpenTable/Views/Toolbar/OpenTableToolbarView.swift +++ b/OpenTable/Views/Toolbar/OpenTableToolbarView.swift @@ -34,6 +34,7 @@ struct ToolbarPrincipalContent: View { ConnectionStatusView( databaseType: state.databaseType, databaseVersion: state.databaseVersion, + databaseName: state.databaseName, connectionName: state.connectionName, connectionState: state.connectionState, displayColor: state.displayColor