From 22b70f476365be3a83ea05e96b973886bdc82511 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Jan 2026 03:13:03 +0700 Subject: [PATCH 01/10] Refactor ESC key handling with declarative environment-based system - Add new declarative ESC key handling system with priority-based coordination - Core/KeyboardHandling/EscapeKeyHandler.swift - Handler types and priorities - Core/KeyboardHandling/EscapeKeyEnvironment.swift - SwiftUI environment integration - Core/KeyboardHandling/EscapeKeyCoordinator.swift - Global NSEvent monitor - Core/KeyboardHandling/View+EscapeKey.swift - Declarative SwiftUI API - Core/KeyboardHandling/EscapeKeyEnvironmentBridge.swift - Environment bridge - Migrate all sheets and views to new .escapeKeyHandler() API - Remove all .keyboardShortcut(.escape) and .keyboardShortcut(.cancelAction) - Remove manual NSEvent monitors from ContentView and DatabaseSwitcherSheetV2 - Remove .onExitCommand handlers in favor of declarative handlers - Clean up global state tracking - Remove AppState.isSheetPresented tracking - Remove isDatabaseSwitcherOpen FocusedValue - Remove unnecessary conditional checks - Add database metadata and creation features - Add DatabaseMetadata model with table count, size, last accessed - Add fetchDatabaseMetadata() to DatabaseDriver protocol - Add createDatabase() to DatabaseDriver protocol - Implement metadata fetching for MySQL, PostgreSQL, SQLite drivers - Add DatabaseSwitcherSheetV2 with rich metadata display - Add CreateDatabaseSheet for creating new databases - Fix critical security vulnerabilities - Fix SQL injection in MySQLDriver.fetchDatabaseMetadata() - Fix SQL injection in MySQLDriver.createDatabase() - Fix SQL injection in PostgreSQLDriver.fetchDatabaseMetadata() - Fix SQL injection in PostgreSQLDriver.createDatabase() - Add input validation and escaping for database names and parameters - Update menu commands to work with new ESC key system - Remove manual state checks in TableProApp commands - Install global ESC key handling via .escapeKeySystem() Build status: Verified successful compilation --- TablePro/ContentView.swift | 47 +- TablePro/Core/Database/DatabaseDriver.swift | 6 + TablePro/Core/Database/MySQLDriver.swift | 64 +++ TablePro/Core/Database/PostgreSQLDriver.swift | 62 +++ TablePro/Core/Database/SQLiteDriver.swift | 18 + .../EscapeKeyCoordinator.swift | 109 +++++ .../EscapeKeyEnvironment.swift | 40 ++ .../EscapeKeyEnvironmentBridge.swift | 52 +++ .../KeyboardHandling/EscapeKeyHandler.swift | 63 +++ .../KeyboardHandling/View+EscapeKey.swift | 101 +++++ TablePro/Extensions/Date+Extensions.swift | 46 ++ .../Extensions/FocusedValues+Extensions.swift | 16 - .../UserDefaults+RecentDatabases.swift | 54 +++ TablePro/Models/DatabaseMetadata.swift | 45 ++ TablePro/TableProApp.swift | 12 +- .../DatabaseSwitcherViewModel.swift | 159 +++++++ .../Views/Connection/ConnectionFormView.swift | 5 +- .../Connection/ConnectionTagEditor.swift | 2 +- .../CreateDatabaseSheet.swift | 147 ++++++ .../DatabaseSwitcherSheetV2.swift | 424 ++++++++++++++++++ ....swift => DatabaseSwitcherSheet_OLD.swift} | 0 TablePro/Views/Editor/CreateTableView.swift | 3 +- TablePro/Views/Editor/TemplateSheets.swift | 20 +- TablePro/Views/Export/ExportDialog.swift | 11 +- TablePro/Views/Filter/SQLPreviewSheet.swift | 2 +- TablePro/Views/Import/ImportDialog.swift | 13 +- .../Views/Main/Child/MainContentAlerts.swift | 15 +- .../Main/MainContentNotificationHandler.swift | 7 +- TablePro/Views/MainContentView.swift | 13 +- TablePro/Views/Sidebar/SidebarView.swift | 3 - .../Views/Sidebar/TableOperationDialog.swift | 2 +- landing | 1 + 32 files changed, 1439 insertions(+), 123 deletions(-) create mode 100644 TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift create mode 100644 TablePro/Core/KeyboardHandling/EscapeKeyEnvironment.swift create mode 100644 TablePro/Core/KeyboardHandling/EscapeKeyEnvironmentBridge.swift create mode 100644 TablePro/Core/KeyboardHandling/EscapeKeyHandler.swift create mode 100644 TablePro/Core/KeyboardHandling/View+EscapeKey.swift create mode 100644 TablePro/Extensions/Date+Extensions.swift create mode 100644 TablePro/Extensions/UserDefaults+RecentDatabases.swift create mode 100644 TablePro/Models/DatabaseMetadata.swift create mode 100644 TablePro/ViewModels/DatabaseSwitcherViewModel.swift create mode 100644 TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift create mode 100644 TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheetV2.swift rename TablePro/Views/DatabaseSwitcher/{DatabaseSwitcherSheet.swift => DatabaseSwitcherSheet_OLD.swift} (100%) create mode 160000 landing diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index e4df83258..9b9fe4904 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -24,7 +24,6 @@ struct ContentView: View { @State private var showDisconnectConfirmation = false @State private var pendingDisconnectSessionId: UUID? @State private var hasLoaded = false - @State private var escapeKeyMonitor: Any? @State private var isInspectorPresented = false // Right sidebar (inspector) visibility @Environment(\.openWindow) @@ -108,23 +107,13 @@ struct ContentView: View { Text("Are you sure you want to disconnect from this database?") } .onChange(of: showDisconnectConfirmation) { _, isShowing in - // Track alert state in AppState so menu commands can check it - AppState.shared.isSheetPresented = isShowing // Reset pending state when alert is dismissed (e.g., by Cmd+W or ESC) if !isShowing { pendingDisconnectSessionId = nil } } - .onChange(of: showUnsavedChangesAlert) { _, isShowing in - // Track alert state in AppState so menu commands can check it - AppState.shared.isSheetPresented = isShowing - } .onAppear { loadConnections() - setupEscapeKeyMonitor() - } - .onDisappear { - removeEscapeKeyMonitor() } .onReceive(NotificationCenter.default.publisher(for: .newConnection)) { _ in openWindow(id: "connection-form", value: nil as UUID?) @@ -363,45 +352,11 @@ struct ContentView: View { storage.deleteConnection(connection) storage.saveConnections(connections) } - - // MARK: - Escape Key Monitor - - private func setupEscapeKeyMonitor() { - escapeKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - // Escape key code is 53 - if event.keyCode == 53 { - // CRITICAL: Check if completion window is visible first - if let frontmostWindow = NSApp.keyWindow, - frontmostWindow.level == .popUpMenu, - frontmostWindow.isVisible { - // Let the completion window handle Escape - return event - } - - // 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 - } - return event - } - } - + private func showAllTablesMetadata() { // Post notification for MainContentView to handle NotificationCenter.default.post(name: .showAllTables, object: nil) } - - private func removeEscapeKeyMonitor() { - if let monitor = escapeKeyMonitor { - NSEvent.removeMonitor(monitor) - escapeKeyMonitor = nil - } - } } #Preview { diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 058a2231f..5514aa630 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -72,6 +72,12 @@ protocol DatabaseDriver: AnyObject { /// Fetch list of all databases on the server func fetchDatabases() async throws -> [String] + + /// Fetch metadata for a specific database (table count, size, etc.) + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata + + /// Create a new database + func createDatabase(name: String, charset: String, collation: String?) async throws } /// Default implementation for common operations diff --git a/TablePro/Core/Database/MySQLDriver.swift b/TablePro/Core/Database/MySQLDriver.swift index 0cbea217a..f6cef0c75 100644 --- a/TablePro/Core/Database/MySQLDriver.swift +++ b/TablePro/Core/Database/MySQLDriver.swift @@ -502,4 +502,68 @@ final class MySQLDriver: DatabaseDriver { let result = try await execute(query: "SHOW DATABASES") return result.rows.compactMap { row in row.first.flatMap { $0 } } } + + /// Fetch metadata for a specific database + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + // Escape database name for SQL (backticks for MySQL identifiers, single quotes for string literals) + let escapedDbLiteral = database.replacingOccurrences(of: "'", with: "''") + + // Query for table count + let countQuery = """ + SELECT COUNT(*) as table_count + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = '\(escapedDbLiteral)' + """ + let countResult = try await execute(query: countQuery) + let tableCount = Int(countResult.rows.first?[0] ?? "0") ?? 0 + + // Query for size + let sizeQuery = """ + SELECT SUM(DATA_LENGTH + INDEX_LENGTH) as size + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = '\(escapedDbLiteral)' + """ + let sizeResult = try await execute(query: sizeQuery) + let sizeString = sizeResult.rows.first?[0] ?? "0" + let sizeBytes = Int64(sizeString) ?? 0 + + // Determine if system database + let systemDatabases = ["information_schema", "mysql", "performance_schema", "sys"] + let isSystem = systemDatabases.contains(database) + + return DatabaseMetadata( + id: database, + name: database, + tableCount: tableCount, + sizeBytes: sizeBytes, + lastAccessed: nil, // Could track separately if needed + isSystemDatabase: isSystem, + icon: isSystem ? "gearshape.fill" : "cylinder.fill" + ) + } + + /// Create a new database + func createDatabase(name: String, charset: String, collation: String?) async throws { + // Escape backticks in database name + let escapedName = name.replacingOccurrences(of: "`", with: "``") + + // Validate charset (basic validation - should be expanded) + let validCharsets = ["utf8mb4", "utf8", "latin1", "ascii"] + guard validCharsets.contains(charset) else { + throw DatabaseError.queryFailed("Invalid character set: \(charset)") + } + + var query = "CREATE DATABASE `\(escapedName)` CHARACTER SET \(charset)" + + // Validate collation if provided + if let collation = collation { + // Basic validation - collation should match charset prefix + guard collation.hasPrefix(charset) else { + throw DatabaseError.queryFailed("Invalid collation for charset") + } + query += " COLLATE \(collation)" + } + + _ = try await execute(query: query) + } } diff --git a/TablePro/Core/Database/PostgreSQLDriver.swift b/TablePro/Core/Database/PostgreSQLDriver.swift index 316f20aa4..ad9581809 100644 --- a/TablePro/Core/Database/PostgreSQLDriver.swift +++ b/TablePro/Core/Database/PostgreSQLDriver.swift @@ -423,4 +423,66 @@ final class PostgreSQLDriver: DatabaseDriver { 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 } } } + + /// Fetch metadata for a specific database + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + // Escape single quotes for SQL string literals + let escapedDbLiteral = database.replacingOccurrences(of: "'", with: "''") + + // Query for table count + let countQuery = """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' AND table_catalog = '\(escapedDbLiteral)' + """ + let countResult = try await execute(query: countQuery) + let tableCount = Int(countResult.rows.first?[0] ?? "0") ?? 0 + + // Query for size + let sizeQuery = """ + SELECT pg_database_size('\(escapedDbLiteral)') + """ + let sizeResult = try await execute(query: sizeQuery) + let sizeString = sizeResult.rows.first?[0] ?? "0" + let sizeBytes = Int64(sizeString) ?? 0 + + // Determine if system database + let systemDatabases = ["postgres", "template0", "template1"] + let isSystem = systemDatabases.contains(database) + + return DatabaseMetadata( + id: database, + name: database, + tableCount: tableCount, + sizeBytes: sizeBytes, + lastAccessed: nil, + isSystemDatabase: isSystem, + icon: isSystem ? "gearshape.fill" : "cylinder.fill" + ) + } + + /// Create a new database + func createDatabase(name: String, charset: String, collation: String?) async throws { + // Escape double quotes in database name (PostgreSQL identifiers) + let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"") + + // Validate charset (basic validation) + let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII"] + guard validCharsets.contains(charset) else { + throw DatabaseError.queryFailed("Invalid encoding: \(charset)") + } + + var query = "CREATE DATABASE \"\(escapedName)\" ENCODING '\(charset)'" + + // Validate and add collation if provided + if let collation = collation { + // Basic validation - ensure no SQL injection attempts + guard !collation.contains("'") && !collation.contains(";") else { + throw DatabaseError.queryFailed("Invalid collation") + } + query += " LC_COLLATE '\(collation)'" + } + + _ = try await execute(query: query) + } } diff --git a/TablePro/Core/Database/SQLiteDriver.swift b/TablePro/Core/Database/SQLiteDriver.swift index 4aa2faf4c..460eb68db 100644 --- a/TablePro/Core/Database/SQLiteDriver.swift +++ b/TablePro/Core/Database/SQLiteDriver.swift @@ -546,4 +546,22 @@ final class SQLiteDriver: DatabaseDriver { // Each SQLite file is a separate database [] } + + /// SQLite is file-based, return minimal metadata + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, + name: database, + tableCount: nil, + sizeBytes: nil, + lastAccessed: nil, + isSystemDatabase: false, + icon: "doc.fill" + ) + } + + /// SQLite databases are created as files, not via SQL + func createDatabase(name: String, charset: String, collation: String?) async throws { + throw DatabaseError.unsupportedOperation + } } diff --git a/TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift b/TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift new file mode 100644 index 000000000..f6b0075e8 --- /dev/null +++ b/TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift @@ -0,0 +1,109 @@ +// +// EscapeKeyCoordinator.swift +// TablePro +// +// Coordinates ESC key handling across the app using SwiftUI environment. +// + +import AppKit +import Combine +import SwiftUI + +// MARK: - Coordinator + +/// Manages global ESC key handling and coordinates with SwiftUI environment +@MainActor +public final class EscapeKeyCoordinator: ObservableObject { + public static let shared = EscapeKeyCoordinator() + + /// The current environment context (updated by root view) + @Published private(set) var currentContext: EscapeKeyContext = EscapeKeyContext() + + /// NSEvent monitor for capturing ESC key globally + private var eventMonitor: Any? + + private init() {} + + // MARK: - Setup + + /// Install global ESC key monitor + /// Should be called once at app startup + public func install() { + guard eventMonitor == nil else { return } + + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self = self else { return event } + + // Check if it's the ESC key (keyCode 53) + if event.keyCode == 53 { + // Process through environment handlers + if self.handleEscape() { + return nil // Consumed + } + } + + return event // Pass through + } + } + + /// Remove global ESC key monitor + public func uninstall() { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + } + + // MARK: - Context Management + + /// Update the current context (called by root view's environment reader) + public func updateContext(_ context: EscapeKeyContext) { + self.currentContext = context + } + + // MARK: - Processing + + /// Process ESC key through all registered handlers + /// Returns true if handled, false if ignored by all + public func handleEscape() -> Bool { + // Check for special cases first (popups, autocomplete) + if shouldIgnoreForSpecialWindows() { + return false + } + + // Process handlers in priority order (highest first) + let handlers = currentContext.sortedHandlers() + + for handler in handlers { + let result = handler.handle() + + switch result { + case .handled: + // Handler consumed the ESC key, stop propagation + return true + + case .ignored: + // Handler didn't process, try next + continue + } + } + + // No handler processed the ESC key + return false + } + + // MARK: - Special Cases + + /// Check if ESC should be ignored for special windows (autocomplete, popups, etc.) + private func shouldIgnoreForSpecialWindows() -> Bool { + // Check if autocomplete/popup window is visible + if let frontmostWindow = NSApp.keyWindow, + frontmostWindow.level == .popUpMenu, + frontmostWindow.isVisible { + // Let the popup handle ESC + return true + } + + return false + } +} diff --git a/TablePro/Core/KeyboardHandling/EscapeKeyEnvironment.swift b/TablePro/Core/KeyboardHandling/EscapeKeyEnvironment.swift new file mode 100644 index 000000000..f4b0735be --- /dev/null +++ b/TablePro/Core/KeyboardHandling/EscapeKeyEnvironment.swift @@ -0,0 +1,40 @@ +// +// EscapeKeyEnvironment.swift +// TablePro +// +// SwiftUI Environment for declarative ESC key handling. +// + +import SwiftUI + +// MARK: - Environment Context + +/// Container for ESC key handlers in the current view hierarchy +public struct EscapeKeyContext { + /// All registered handlers (automatically maintained by SwiftUI environment) + var handlers: [EscapeKeyHandler] = [] + + /// Add a handler to the context + mutating func addHandler(_ handler: EscapeKeyHandler) { + handlers.append(handler) + } + + /// Get sorted handlers (highest priority first) + func sortedHandlers() -> [EscapeKeyHandler] { + handlers.sorted { $0.priority > $1.priority } + } +} + +// MARK: - Environment Key + +private struct EscapeKeyContextKey: EnvironmentKey { + static let defaultValue = EscapeKeyContext() +} + +extension EnvironmentValues { + /// Access the ESC key handler context + public var escapeKeyContext: EscapeKeyContext { + get { self[EscapeKeyContextKey.self] } + set { self[EscapeKeyContextKey.self] = newValue } + } +} diff --git a/TablePro/Core/KeyboardHandling/EscapeKeyEnvironmentBridge.swift b/TablePro/Core/KeyboardHandling/EscapeKeyEnvironmentBridge.swift new file mode 100644 index 000000000..5cc8a2366 --- /dev/null +++ b/TablePro/Core/KeyboardHandling/EscapeKeyEnvironmentBridge.swift @@ -0,0 +1,52 @@ +// +// EscapeKeyEnvironmentBridge.swift +// TablePro +// +// Bridges SwiftUI environment with the global ESC key coordinator. +// + +import SwiftUI + +// MARK: - Environment Bridge + +/// ViewModifier that bridges the SwiftUI environment with the global coordinator +/// Should be applied at the root of the app +struct EscapeKeyEnvironmentBridge: ViewModifier { + @Environment(\.escapeKeyContext) private var context + @StateObject private var coordinator = EscapeKeyCoordinator.shared + + func body(content: Content) -> some View { + content + .onAppear { + // Install global ESC key monitor + coordinator.install() + } + .onDisappear { + // Cleanup on disappear (rare for root views) + coordinator.uninstall() + } + .onChange(of: context.handlers.count) { _, _ in + // Update coordinator whenever environment handlers change + coordinator.updateContext(context) + } + .onReceive(coordinator.$currentContext) { _ in + // Sync context (in case it's updated externally) + if coordinator.currentContext.handlers.count != context.handlers.count { + coordinator.updateContext(context) + } + } + } +} + +extension View { + /// Install the ESC key handling system at the root of your app + /// + /// Usage in TableProApp: + /// ```swift + /// ContentView() + /// .escapeKeySystem() + /// ``` + public func escapeKeySystem() -> some View { + modifier(EscapeKeyEnvironmentBridge()) + } +} diff --git a/TablePro/Core/KeyboardHandling/EscapeKeyHandler.swift b/TablePro/Core/KeyboardHandling/EscapeKeyHandler.swift new file mode 100644 index 000000000..9edb9f465 --- /dev/null +++ b/TablePro/Core/KeyboardHandling/EscapeKeyHandler.swift @@ -0,0 +1,63 @@ +// +// EscapeKeyHandler.swift +// TablePro +// +// Declarative ESC key handling system using SwiftUI environment. +// Views declare their ESC behavior, system automatically coordinates priority. +// + +import Foundation + +// MARK: - Priority + +/// Priority levels for ESC key handlers (higher = handled first) +public enum EscapeKeyPriority: Int, Comparable { + /// Popup windows like autocomplete (highest priority) + case popup = 100 + + /// Nested modal sheets (e.g., Create Database inside Database Switcher) + case nestedSheet = 80 + + /// Top-level modal sheets and dialogs + case sheet = 60 + + /// View-specific behavior (e.g., collapse panel, clear search) + case view = 40 + + /// Global actions (e.g., clear selection, hide sidebar) + case global = 20 + + public static func < (lhs: EscapeKeyPriority, rhs: EscapeKeyPriority) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +// MARK: - Result + +/// Result of an ESC key handler +public enum EscapeKeyResult { + /// Handler processed the ESC key (stop propagation) + case handled + + /// Handler didn't process the ESC key (continue to next handler) + case ignored +} + +// MARK: - Handler + +/// A single ESC key handler with priority and action +public struct EscapeKeyHandler: Identifiable { + public let id: UUID + public let priority: EscapeKeyPriority + public let handle: () -> EscapeKeyResult + + public init( + id: UUID = UUID(), + priority: EscapeKeyPriority, + handle: @escaping () -> EscapeKeyResult + ) { + self.id = id + self.priority = priority + self.handle = handle + } +} diff --git a/TablePro/Core/KeyboardHandling/View+EscapeKey.swift b/TablePro/Core/KeyboardHandling/View+EscapeKey.swift new file mode 100644 index 000000000..e128d523c --- /dev/null +++ b/TablePro/Core/KeyboardHandling/View+EscapeKey.swift @@ -0,0 +1,101 @@ +// +// View+EscapeKey.swift +// TablePro +// +// Declarative SwiftUI API for ESC key handling. +// + +import SwiftUI + +// MARK: - ViewModifier + +/// ViewModifier that registers an ESC key handler in the environment +struct EscapeKeyHandlerModifier: ViewModifier { + let priority: EscapeKeyPriority + let handler: () -> EscapeKeyResult + + @Environment(\.escapeKeyContext) private var context + + func body(content: Content) -> some View { + content + .transformEnvironment(\.escapeKeyContext) { context in + let escapeHandler = EscapeKeyHandler( + priority: priority, + handle: handler + ) + context.addHandler(escapeHandler) + } + } +} + +// MARK: - View Extension + +extension View { + /// Declare an ESC key handler for this view + /// + /// Usage: + /// ```swift + /// .escapeKeyHandler(priority: .sheet) { + /// dismiss() + /// return .handled + /// } + /// ``` + /// + /// - Parameters: + /// - priority: Priority level for this handler + /// - handler: Closure to handle ESC key, returns .handled or .ignored + public func escapeKeyHandler( + priority: EscapeKeyPriority = .view, + _ handler: @escaping () -> EscapeKeyResult + ) -> some View { + modifier(EscapeKeyHandlerModifier(priority: priority, handler: handler)) + } + + /// Convenience: Handle ESC to dismiss a sheet/dialog + /// + /// Usage: + /// ```swift + /// .escapeKeyDismiss(isPresented: $showSheet, priority: .sheet) + /// ``` + public func escapeKeyDismiss( + isPresented: Binding, + priority: EscapeKeyPriority = .sheet + ) -> some View { + self.escapeKeyHandler(priority: priority) { + isPresented.wrappedValue = false + return .handled + } + } + + /// Convenience: Handle ESC to dismiss using Environment dismiss + /// + /// Usage: + /// ```swift + /// .escapeKeyDismiss(priority: .sheet) + /// ``` + public func escapeKeyDismiss( + priority: EscapeKeyPriority = .sheet + ) -> some View { + EscapeKeyDismissView(priority: priority) { + self + } + } +} + +// MARK: - Helper View + +/// Helper view that has access to @Environment(\.dismiss) +private struct EscapeKeyDismissView: View { + let priority: EscapeKeyPriority + let content: () -> Content + + @Environment(\.dismiss) private var dismiss + + var body: some View { + content() + .escapeKeyHandler(priority: priority) { + dismiss() + return .handled + } + } +} diff --git a/TablePro/Extensions/Date+Extensions.swift b/TablePro/Extensions/Date+Extensions.swift new file mode 100644 index 000000000..30d661c63 --- /dev/null +++ b/TablePro/Extensions/Date+Extensions.swift @@ -0,0 +1,46 @@ +// +// Date+Extensions.swift +// TablePro +// +// Date extensions for relative time display. +// + +import Foundation + +extension Date { + /// Returns a human-readable relative time string (e.g., "just now", "2 hours ago", "3 days ago") + func timeAgoDisplay() -> String { + let now = Date() + let components = Calendar.current.dateComponents( + [.second, .minute, .hour, .day, .weekOfYear, .month, .year], + from: self, + to: now + ) + + if let year = components.year, year >= 1 { + return year == 1 ? "1 year ago" : "\(year) years ago" + } + + if let month = components.month, month >= 1 { + return month == 1 ? "1 month ago" : "\(month) months ago" + } + + if let week = components.weekOfYear, week >= 1 { + return week == 1 ? "1 week ago" : "\(week) weeks ago" + } + + if let day = components.day, day >= 1 { + return day == 1 ? "yesterday" : "\(day) days ago" + } + + if let hour = components.hour, hour >= 1 { + return hour == 1 ? "1 hour ago" : "\(hour) hours ago" + } + + if let minute = components.minute, minute >= 1 { + return minute == 1 ? "1 minute ago" : "\(minute) minutes ago" + } + + return "just now" + } +} diff --git a/TablePro/Extensions/FocusedValues+Extensions.swift b/TablePro/Extensions/FocusedValues+Extensions.swift index 6c507d94b..c27920be7 100644 --- a/TablePro/Extensions/FocusedValues+Extensions.swift +++ b/TablePro/Extensions/FocusedValues+Extensions.swift @@ -4,19 +4,3 @@ // 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/TablePro/Extensions/UserDefaults+RecentDatabases.swift b/TablePro/Extensions/UserDefaults+RecentDatabases.swift new file mode 100644 index 000000000..af3630c0a --- /dev/null +++ b/TablePro/Extensions/UserDefaults+RecentDatabases.swift @@ -0,0 +1,54 @@ +// +// UserDefaults+RecentDatabases.swift +// TablePro +// +// UserDefaults extension for tracking recently accessed databases per connection. +// + +import Foundation + +extension UserDefaults { + private static let recentDatabasesKey = "recentDatabases" + private static let maxRecentCount = 5 + + /// Get recent databases for a specific connection + /// - Parameter connectionId: The connection UUID + /// - Returns: Array of recently accessed database names (max 5, ordered by recency) + func recentDatabases(for connectionId: UUID) -> [String] { + guard let dict = dictionary(forKey: Self.recentDatabasesKey) as? [String: [String]] else { + return [] + } + return dict[connectionId.uuidString] ?? [] + } + + /// Track database access for a connection + /// - Parameters: + /// - database: Database name to track + /// - connectionId: The connection UUID + func trackDatabaseAccess(_ database: String, for connectionId: UUID) { + var dict = (dictionary(forKey: Self.recentDatabasesKey) as? [String: [String]]) ?? [:] + var recent = dict[connectionId.uuidString] ?? [] + + // Remove if already exists (will be added to front) + recent.removeAll { $0 == database } + + // Add to front + recent.insert(database, at: 0) + + // Keep only max count + if recent.count > Self.maxRecentCount { + recent = Array(recent.prefix(Self.maxRecentCount)) + } + + dict[connectionId.uuidString] = recent + set(dict, forKey: Self.recentDatabasesKey) + } + + /// Clear recent databases for a connection + /// - Parameter connectionId: The connection UUID + func clearRecentDatabases(for connectionId: UUID) { + var dict = (dictionary(forKey: Self.recentDatabasesKey) as? [String: [String]]) ?? [:] + dict.removeValue(forKey: connectionId.uuidString) + set(dict, forKey: Self.recentDatabasesKey) + } +} diff --git a/TablePro/Models/DatabaseMetadata.swift b/TablePro/Models/DatabaseMetadata.swift new file mode 100644 index 000000000..c99602fd5 --- /dev/null +++ b/TablePro/Models/DatabaseMetadata.swift @@ -0,0 +1,45 @@ +// +// DatabaseMetadata.swift +// TablePro +// +// Enhanced database metadata model for the redesigned database switcher. +// Includes table count, size, last accessed time, and system database detection. +// + +import Foundation + +/// Metadata for a database including statistics and access information +struct DatabaseMetadata: Identifiable, Equatable { + let id: String // Database name (unique identifier) + let name: String // Display name + let tableCount: Int? // Number of tables in database + let sizeBytes: Int64? // Total size in bytes + let lastAccessed: Date? // Last time this database was accessed + let isSystemDatabase: Bool // Whether this is a system database (mysql, information_schema, etc.) + let icon: String // SF Symbol name for icon + + /// Formatted size string (e.g., "14.2 MB") + var formattedSize: String { + guard let bytes = sizeBytes else { return "—" } + return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + /// Relative time string (e.g., "2 hours ago", "just now") + var relativeAccessTime: String { + guard let accessed = lastAccessed else { return "never" } + return accessed.timeAgoDisplay() + } + + /// Creates metadata with minimal information (name only) + static func minimal(name: String, isSystem: Bool = false) -> DatabaseMetadata { + DatabaseMetadata( + id: name, + name: name, + tableCount: nil, + sizeBytes: nil, + lastAccessed: nil, + isSystemDatabase: isSystem, + icon: isSystem ? "gearshape.fill" : "cylinder.fill" + ) + } +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 9ae1b3105..acc50d524 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -17,16 +17,13 @@ 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 +// MARK: - Pasteboard Commands -/// Custom Commands struct to properly access FocusedValue for disabling ESC when sheet is open +/// Custom Commands struct for pasteboard operations struct PasteboardCommands: Commands { @ObservedObject var appState: AppState - @FocusedValue(\.isDatabaseSwitcherOpen) - var isDatabaseSwitcherOpen: Bool? var body: some Commands { CommandGroup(replacing: .pasteboard) { @@ -101,7 +98,6 @@ struct PasteboardCommands: Commands { NotificationCenter.default.post(name: .clearSelection, object: nil) } .keyboardShortcut(.escape, modifiers: []) - .disabled(isDatabaseSwitcherOpen == true) } } } @@ -155,6 +151,7 @@ struct TableProApp: App { .environmentObject(appState) .background(OpenWindowHandler()) .tint(accentTint) + .escapeKeySystem() // Install global ESC key handling } .windowStyle(.automatic) .defaultSize(width: 1_200, height: 800) @@ -202,9 +199,6 @@ struct TableProApp: App { .disabled(!appState.isConnected) Button("Close Tab") { - // Don't process if a modal dialog is shown (like disconnect confirmation) - guard !appState.isSheetPresented else { return } - // Check if key window is the main window let keyWindow = NSApp.keyWindow let isMainWindowKey = keyWindow?.identifier?.rawValue.contains("main") == true diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift new file mode 100644 index 000000000..0ce0cc882 --- /dev/null +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -0,0 +1,159 @@ +// +// DatabaseSwitcherViewModel.swift +// TablePro +// +// ViewModel for DatabaseSwitcherSheetV2. +// Handles database fetching, metadata loading, recent tracking, and switching logic. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class DatabaseSwitcherViewModel: ObservableObject { + // MARK: - Published State + + @Published var databases: [DatabaseMetadata] = [] + @Published var recentDatabases: [String] = [] + @Published var searchText = "" + @Published var selectedDatabase: String? + @Published var isLoading = false + @Published var errorMessage: String? + @Published var showPreview = false + + // MARK: - Dependencies + + private let connectionId: UUID + private let currentDatabase: String? + private let databaseType: DatabaseType + + // MARK: - Computed Properties + + var filteredDatabases: [DatabaseMetadata] { + if searchText.isEmpty { + return databases + } + return databases.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + } + + var recentDatabaseMetadata: [DatabaseMetadata] { + return recentDatabases.compactMap { dbName in + databases.first { $0.name == dbName } + } + } + + var allDatabases: [DatabaseMetadata] { + // Filter out recent databases from "all" list + return filteredDatabases.filter { db in + !recentDatabases.contains(db.name) + } + } + + // MARK: - Initialization + + init(connectionId: UUID, currentDatabase: String?, databaseType: DatabaseType) { + self.connectionId = connectionId + self.currentDatabase = currentDatabase + self.databaseType = databaseType + self.recentDatabases = UserDefaults.standard.recentDatabases(for: connectionId) + } + + // MARK: - Public Methods + + /// Fetch databases and their metadata + func fetchDatabases() async { + isLoading = true + errorMessage = nil + + do { + guard let driver = DatabaseManager.shared.activeDriver else { + errorMessage = "No active connection" + isLoading = false + return + } + + // Fetch database names + let dbNames = try await driver.fetchDatabases() + + // Fetch metadata for each database (in parallel for performance) + let metadataList = await withTaskGroup(of: DatabaseMetadata?.self) { group in + for dbName in dbNames { + group.addTask { + return await self.fetchMetadata(for: dbName, driver: driver) + } + } + + var results: [DatabaseMetadata] = [] + for await metadata in group { + if let metadata = metadata { + results.append(metadata) + } + } + return results + } + + // Update state + databases = metadataList.sorted { $0.name < $1.name } + isLoading = false + + // Pre-select current database or first database + if let current = currentDatabase, databases.contains(where: { $0.name == current }) { + selectedDatabase = current + } else { + selectedDatabase = databases.first?.name + } + + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + + /// Refresh database list + func refreshDatabases() async { + await fetchDatabases() + } + + /// Create a new database + func createDatabase(name: String, charset: String, collation: String?) async throws { + guard let driver = DatabaseManager.shared.activeDriver else { + throw DatabaseError.notConnected + } + + try await driver.createDatabase(name: name, charset: charset, collation: collation) + } + + /// Track database access + func trackAccess(database: String) { + UserDefaults.standard.trackDatabaseAccess(database, for: connectionId) + recentDatabases = UserDefaults.standard.recentDatabases(for: connectionId) + } + + // MARK: - Private Methods + + /// Fetch metadata for a single database + private func fetchMetadata(for database: String, driver: DatabaseDriver) async -> DatabaseMetadata? { + do { + return try await driver.fetchDatabaseMetadata(database) + } catch { + // If metadata fetch fails, return minimal metadata + print("Failed to fetch metadata for \(database): \(error)") + return DatabaseMetadata.minimal(name: database, isSystem: isSystemDatabase(database)) + } + } + + /// Determine if a database is a system database + private func isSystemDatabase(_ database: String) -> Bool { + switch databaseType { + case .mysql, .mariadb: + return ["information_schema", "mysql", "performance_schema", "sys"].contains(database) + case .postgresql: + return ["postgres", "template0", "template1"].contains(database) + case .sqlite: + return false + } + } +} diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 350faf11c..fcfc66a95 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -388,7 +388,6 @@ struct ConnectionFormView: View { Button("Cancel") { dismissWindow(id: "connection-form") } - .keyboardShortcut(.escape) // Save Button(isNew ? "Create" : "Save") { @@ -402,6 +401,10 @@ struct ConnectionFormView: View { .padding(.vertical, 12) } .background(Color(nsColor: .windowBackgroundColor)) + .escapeKeyHandler(priority: .view) { + dismissWindow(id: "connection-form") + return .handled + } } // MARK: - Helpers diff --git a/TablePro/Views/Connection/ConnectionTagEditor.swift b/TablePro/Views/Connection/ConnectionTagEditor.swift index 119ba164b..25840be26 100644 --- a/TablePro/Views/Connection/ConnectionTagEditor.swift +++ b/TablePro/Views/Connection/ConnectionTagEditor.swift @@ -184,7 +184,6 @@ private struct CreateTagSheet: View { Button("Cancel") { dismiss() } - .keyboardShortcut(.escape) Button("Create") { onSave(tagName, tagColor) @@ -197,6 +196,7 @@ private struct CreateTagSheet: View { } .padding(20) .frame(width: 300) + .escapeKeyDismiss(priority: .sheet) } } diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift new file mode 100644 index 000000000..1af0e8009 --- /dev/null +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -0,0 +1,147 @@ +// +// CreateDatabaseSheet.swift +// TablePro +// +// Sheet for creating a new database with charset and collation options. +// + +import SwiftUI + +struct CreateDatabaseSheet: View { + @Environment(\.dismiss) private var dismiss + + let onCreate: (String, String, String?) async throws -> Void + + @State private var databaseName = "" + @State private var charset = "utf8mb4" + @State private var collation = "utf8mb4_unicode_ci" + @State private var isCreating = false + @State private var errorMessage: String? + + private let charsets = [ + "utf8mb4", + "utf8", + "latin1", + "ascii" + ] + + private let collations: [String: [String]] = [ + "utf8mb4": ["utf8mb4_unicode_ci", "utf8mb4_general_ci", "utf8mb4_bin"], + "utf8": ["utf8_unicode_ci", "utf8_general_ci", "utf8_bin"], + "latin1": ["latin1_swedish_ci", "latin1_general_ci", "latin1_bin"], + "ascii": ["ascii_general_ci", "ascii_bin"] + ] + + var body: some View { + VStack(spacing: 0) { + // Header + Text("Create Database") + .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) + .padding(.vertical, 12) + + Divider() + + // Form + VStack(alignment: .leading, spacing: 16) { + // Database name + VStack(alignment: .leading, spacing: 6) { + Text("Database Name") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + + TextField("Enter database name", text: $databaseName) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.body)) + } + + // Charset + VStack(alignment: .leading, spacing: 6) { + Text("Character Set") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + + Picker("", selection: $charset) { + ForEach(charsets, id: \.self) { cs in + Text(cs).tag(cs) + } + } + .labelsHidden() + .pickerStyle(.menu) + .font(.system(size: DesignConstants.FontSize.body)) + } + + // Collation + VStack(alignment: .leading, spacing: 6) { + Text("Collation") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + + Picker("", selection: $collation) { + ForEach(collations[charset] ?? [], id: \.self) { col in + Text(col).tag(col) + } + } + .labelsHidden() + .pickerStyle(.menu) + .font(.system(size: DesignConstants.FontSize.body)) + } + + // Error message + if let error = errorMessage { + Text(error) + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.red) + } + } + .padding(20) + + Divider() + + // Footer + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button(isCreating ? "Creating..." : "Create") { + createDatabase() + } + .buttonStyle(.borderedProminent) + .disabled(databaseName.isEmpty || isCreating) + .keyboardShortcut(.return, modifiers: []) + } + .padding(12) + } + .frame(width: 380) + .escapeKeyDismiss(priority: .nestedSheet) + .onChange(of: charset) { _, newCharset in + // Update collation when charset changes + if let firstCollation = collations[newCharset]?.first { + collation = firstCollation + } + } + } + + private func createDatabase() { + guard !databaseName.isEmpty else { return } + + isCreating = true + errorMessage = nil + + Task { + do { + try await onCreate(databaseName, charset, collation) + await MainActor.run { + dismiss() + } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + isCreating = false + } + } + } + } +} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheetV2.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheetV2.swift new file mode 100644 index 000000000..b9946d894 --- /dev/null +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheetV2.swift @@ -0,0 +1,424 @@ +// +// DatabaseSwitcherSheetV2.swift +// TablePro +// +// Complete redesign of the database switcher dialog. +// Features: Rich metadata, recent databases, refresh, create database, preview panel. +// + +import SwiftUI + +struct DatabaseSwitcherSheetV2: View { + @Binding var isPresented: Bool + @Environment(\.dismiss) private var dismiss + + let currentDatabase: String? + let databaseType: DatabaseType + let connectionId: UUID + let onSelect: (String) -> Void + + @StateObject private var viewModel: DatabaseSwitcherViewModel + @State private var showCreateDialog = false + + init(isPresented: Binding, currentDatabase: String?, databaseType: DatabaseType, connectionId: UUID, onSelect: @escaping (String) -> Void) { + self._isPresented = isPresented + self.currentDatabase = currentDatabase + self.databaseType = databaseType + self.connectionId = connectionId + self.onSelect = onSelect + self._viewModel = StateObject(wrappedValue: DatabaseSwitcherViewModel( + connectionId: connectionId, + currentDatabase: currentDatabase, + databaseType: databaseType + )) + } + + var body: some View { + VStack(spacing: 0) { + // Header + Text("Open Database") + .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) + .padding(.vertical, 12) + + Divider() + + // Toolbar: Search + Refresh + Create + toolbar + + Divider() + + // Content + if viewModel.isLoading { + loadingView + } else if let error = viewModel.errorMessage { + errorView(error) + } else if databaseType == .sqlite { + sqliteEmptyState + } else if viewModel.filteredDatabases.isEmpty { + emptyState + } else { + databaseList + } + + Divider() + + // Footer + footer + } + .frame(width: 420, height: 480) + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { + Task { await viewModel.fetchDatabases() } + } + .sheet(isPresented: $showCreateDialog) { + CreateDatabaseSheet { name, charset, collation in + try await viewModel.createDatabase(name: name, charset: charset, collation: collation) + await viewModel.refreshDatabases() + } + } + .escapeKeyHandler(priority: .sheet) { + // Nested sheet has higher priority (.nestedSheet), so this only runs when no nested sheets are open + dismiss() + return .handled + } + .onKeyPress(.return) { + openSelectedDatabase() + return .handled + } + .onKeyPress(.upArrow) { + moveSelection(up: true) + return .handled + } + .onKeyPress(.downArrow) { + moveSelection(up: false) + return .handled + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 8) { + // Search + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: DesignConstants.FontSize.body)) + .foregroundStyle(.tertiary) + + TextField("Search databases...", text: $viewModel.searchText) + .textFieldStyle(.plain) + .font(.system(size: DesignConstants.FontSize.body)) + + if !viewModel.searchText.isEmpty { + Button(action: { viewModel.searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + + // Refresh + Button(action: { + Task { await viewModel.refreshDatabases() } + }) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14)) + } + .buttonStyle(.borderless) + .help("Refresh database list") + + // Create (only for non-SQLite) + if databaseType != .sqlite { + Button(action: { showCreateDialog = true }) { + Image(systemName: "plus") + .font(.system(size: 14)) + } + .buttonStyle(.borderless) + .help("Create new database") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + // MARK: - Database List + + private var databaseList: some View { + ScrollViewReader { proxy in + List { + // Recent section + if !viewModel.recentDatabaseMetadata.isEmpty { + Section { + ForEach(viewModel.recentDatabaseMetadata) { db in + databaseRow(db) + } + } header: { + Text("RECENT") + .font(.system(size: DesignConstants.FontSize.caption, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + + // All databases + Section { + ForEach(viewModel.allDatabases) { db in + databaseRow(db) + } + } header: { + if !viewModel.recentDatabaseMetadata.isEmpty { + Text("ALL DATABASES") + .font(.system(size: DesignConstants.FontSize.caption, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .onChange(of: viewModel.selectedDatabase) { _, newValue in + if let item = newValue { + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(item, anchor: .center) + } + } + } + } + } + + private func databaseRow(_ database: DatabaseMetadata) -> some View { + let isSelected = database.name == viewModel.selectedDatabase + let isCurrent = database.name == currentDatabase + + return HStack(spacing: 10) { + // Icon + Image(systemName: database.icon) + .font(.system(size: 14)) + .foregroundStyle(isSelected ? .white : (database.isSystemDatabase ? .orange : .blue)) + + // Name + Text(database.name) + .font(.system(size: 13)) + .foregroundStyle(isSelected ? .white : .primary) + + Spacer() + + // Current badge + if isCurrent { + Text("current") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(isSelected ? .white.opacity(0.7) : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(isSelected ? Color.white.opacity(0.15) : Color(nsColor: .separatorColor).opacity(0.5)) + ) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .listRowBackground( + RoundedRectangle(cornerRadius: 4) + .fill(isSelected ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, 4) + ) + .listRowInsets(DesignConstants.swiftUIListRowInsets) + .listRowSeparator(.hidden) + .id(database.name) + .onTapGesture { + viewModel.selectedDatabase = database.name + } + .overlay( + DoubleClickView { + viewModel.selectedDatabase = database.name + openSelectedDatabase() + } + ) + } + + // MARK: - Empty States + + private var loadingView: some View { + VStack(spacing: 12) { + ProgressView() + .scaleEffect(0.8) + Text("Loading databases...") + .font(.system(size: DesignConstants.FontSize.medium)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorView(_ message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: DesignConstants.IconSize.extraLarge)) + .foregroundStyle(.orange) + + Text("Failed to load databases") + .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + + Text(message) + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Retry") { + Task { await viewModel.fetchDatabases() } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var sqliteEmptyState: some View { + VStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.system(size: DesignConstants.IconSize.extraLarge)) + .foregroundStyle(.secondary) + + Text("SQLite is file-based") + .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + + Text("Each SQLite file is a separate database.\nTo open a different database, create a new connection.") + .font(.system(size: DesignConstants.FontSize.small)) + .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: DesignConstants.IconSize.extraLarge)) + .foregroundStyle(.secondary) + + if viewModel.searchText.isEmpty { + Text("No databases found") + .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + } else { + Text("No matching databases") + .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + + Text("No databases match \"\(viewModel.searchText)\"") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button("Open") { + openSelectedDatabase() + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.selectedDatabase == nil || viewModel.selectedDatabase == currentDatabase) + .keyboardShortcut(.return, modifiers: []) + } + .padding(12) + } + + // MARK: - Actions + + private func moveSelection(up: Bool) { + let allDbs = viewModel.recentDatabaseMetadata + viewModel.allDatabases + guard !allDbs.isEmpty else { return } + + if let selected = viewModel.selectedDatabase, + let currentIndex = allDbs.firstIndex(where: { $0.name == selected }) { + if up { + let newIndex = max(0, currentIndex - 1) + viewModel.selectedDatabase = allDbs[newIndex].name + } else { + let newIndex = min(allDbs.count - 1, currentIndex + 1) + viewModel.selectedDatabase = allDbs[newIndex].name + } + } else { + viewModel.selectedDatabase = up ? allDbs.last?.name : allDbs.first?.name + } + } + + private func openSelectedDatabase() { + guard let database = viewModel.selectedDatabase else { return } + + // Don't reopen current database + if database == currentDatabase { + dismiss() + return + } + + // Track access + viewModel.trackAccess(database: database) + + // Call onSelect callback + 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) + } +} + +// MARK: - Preview + +#Preview("MySQL Databases") { + DatabaseSwitcherSheetV2( + isPresented: .constant(true), + currentDatabase: "production", + databaseType: .mysql, + connectionId: UUID() + ) { db in print("Selected: \(db)") } +} + +#Preview("SQLite Empty") { + DatabaseSwitcherSheetV2( + isPresented: .constant(true), + currentDatabase: nil, + databaseType: .sqlite, + connectionId: UUID() + ) { db in print("Selected: \(db)") } +} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet_OLD.swift similarity index 100% rename from TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift rename to TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet_OLD.swift diff --git a/TablePro/Views/Editor/CreateTableView.swift b/TablePro/Views/Editor/CreateTableView.swift index c596e496f..575a33959 100644 --- a/TablePro/Views/Editor/CreateTableView.swift +++ b/TablePro/Views/Editor/CreateTableView.swift @@ -69,7 +69,7 @@ struct CreateTableView: View { } .animation(.easeInOut(duration: DesignConstants.AnimationDuration.smooth), value: showDetailPanel) .background(Color(nsColor: .textBackgroundColor)) - .onKeyPress(.escape) { + .escapeKeyHandler(priority: .view) { if showDetailPanel { showDetailPanel = false return .handled @@ -582,7 +582,6 @@ struct CreateTableView: View { Button("Cancel") { onCancel() } - .keyboardShortcut(.escape) Button("Create Table") { createTable() diff --git a/TablePro/Views/Editor/TemplateSheets.swift b/TablePro/Views/Editor/TemplateSheets.swift index ba6399feb..e6ac483c3 100644 --- a/TablePro/Views/Editor/TemplateSheets.swift +++ b/TablePro/Views/Editor/TemplateSheets.swift @@ -24,7 +24,6 @@ struct SaveTemplateSheet: View { HStack { Button("Cancel", action: onCancel) - .keyboardShortcut(.escape) Spacer() @@ -37,6 +36,10 @@ struct SaveTemplateSheet: View { .padding(DesignConstants.Spacing.md) .fixedSize(horizontal: false, vertical: true) .frame(width: 350) + .escapeKeyHandler(priority: .sheet) { + onCancel() + return .handled + } } } @@ -92,7 +95,6 @@ struct LoadTemplateSheet: View { HStack { Button("Cancel", action: onCancel) - .keyboardShortcut(.escape) Spacer() @@ -109,6 +111,10 @@ struct LoadTemplateSheet: View { .padding(DesignConstants.Spacing.md) .fixedSize(horizontal: false, vertical: true) .frame(width: 400) + .escapeKeyHandler(priority: .sheet) { + onCancel() + return .handled + } } } @@ -138,7 +144,6 @@ struct ImportDDLSheet: View { HStack { Button("Cancel", action: onCancel) - .keyboardShortcut(.escape) Spacer() @@ -153,6 +158,10 @@ struct ImportDDLSheet: View { .padding(DesignConstants.Spacing.md) .fixedSize(horizontal: false, vertical: true) .frame(width: 500) + .escapeKeyHandler(priority: .sheet) { + onCancel() + return .handled + } } } @@ -196,7 +205,6 @@ struct DuplicateTableSheet: View { HStack { Button("Cancel", action: onCancel) - .keyboardShortcut(.escape) Spacer() @@ -211,5 +219,9 @@ struct DuplicateTableSheet: View { .padding(DesignConstants.Spacing.md) .fixedSize(horizontal: false, vertical: true) .frame(width: 400) + .escapeKeyHandler(priority: .sheet) { + onCancel() + return .handled + } } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 857a519e4..7aa1581a1 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -63,13 +63,15 @@ struct ExportDialog: View { } .frame(width: dialogWidth) .background(Color(nsColor: .windowBackgroundColor)) - .task { - await loadDatabaseItems() - } - .onExitCommand { + .escapeKeyHandler(priority: .sheet) { if !isExporting { isPresented = false + return .handled } + return .ignored + } + .task { + await loadDatabaseItems() } .alert("Export Error", isPresented: $showError) { Button("OK") { } @@ -270,7 +272,6 @@ struct ExportDialog: View { Button("Cancel") { isPresented = false } - .keyboardShortcut(.cancelAction) .disabled(isExporting) Spacer() diff --git a/TablePro/Views/Filter/SQLPreviewSheet.swift b/TablePro/Views/Filter/SQLPreviewSheet.swift index 90055b7ec..0a9024c86 100644 --- a/TablePro/Views/Filter/SQLPreviewSheet.swift +++ b/TablePro/Views/Filter/SQLPreviewSheet.swift @@ -65,11 +65,11 @@ struct SQLPreviewSheet: View { } .buttonStyle(.borderedProminent) .controlSize(.small) - .keyboardShortcut(.escape) } } .padding(16) .frame(width: 480, height: 300) + .escapeKeyDismiss(priority: .sheet) } private func copyToClipboard() { diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index d3db7526d..e225141de 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -68,17 +68,19 @@ struct ImportDialog: View { footerView } .background(Color(nsColor: .windowBackgroundColor)) + .escapeKeyHandler(priority: .sheet) { + if !importServiceState.isImporting { + isPresented = false + return .handled + } + return .ignored + } .task { // Load initial file if provided if let initialURL = initialFileURL, fileURL == nil { await loadFile(initialURL) } } - .onExitCommand { - if !importServiceState.isImporting { - isPresented = false - } - } .onDisappear { // Cancel any in-progress file loading when dialog is dismissed loadFileTask?.cancel() @@ -239,7 +241,6 @@ struct ImportDialog: View { Button("Cancel") { isPresented = false } - .keyboardShortcut(.cancelAction) Spacer() diff --git a/TablePro/Views/Main/Child/MainContentAlerts.swift b/TablePro/Views/Main/Child/MainContentAlerts.swift index 396020979..249e3f15d 100644 --- a/TablePro/Views/Main/Child/MainContentAlerts.swift +++ b/TablePro/Views/Main/Child/MainContentAlerts.swift @@ -43,18 +43,15 @@ struct MainContentAlerts: ViewModifier { } .sheet(isPresented: $coordinator.showDatabaseSwitcher) { - DatabaseSwitcherSheet( + DatabaseSwitcherSheetV2( isPresented: $coordinator.showDatabaseSwitcher, currentDatabase: connection.database.isEmpty ? nil : connection.database, - databaseType: connection.type - ) { database in + databaseType: connection.type, + connectionId: connection.id + ) { database in coordinator.switchToDatabase(database) } } - .focusedValue(\.isDatabaseSwitcherOpen, coordinator.showDatabaseSwitcher) - .onChange(of: coordinator.showDatabaseSwitcher) { _, isPresented in - appState.isSheetPresented = isPresented - } .sheet(isPresented: $coordinator.showExportDialog) { ExportDialog( @@ -63,9 +60,6 @@ struct MainContentAlerts: ViewModifier { preselectedTables: Set(selectedTables.map { $0.name }) ) } - .onChange(of: coordinator.showExportDialog) { _, isPresented in - appState.isSheetPresented = isPresented - } .sheet(isPresented: $coordinator.showImportDialog) { ImportDialog( @@ -75,7 +69,6 @@ struct MainContentAlerts: ViewModifier { ) } .onChange(of: coordinator.showImportDialog) { _, isPresented in - appState.isSheetPresented = isPresented // Clear the file URL when dialog is dismissed if !isPresented { coordinator.importFileURL = nil diff --git a/TablePro/Views/Main/MainContentNotificationHandler.swift b/TablePro/Views/Main/MainContentNotificationHandler.swift index d5896a03c..53f212832 100644 --- a/TablePro/Views/Main/MainContentNotificationHandler.swift +++ b/TablePro/Views/Main/MainContentNotificationHandler.swift @@ -30,7 +30,6 @@ final class MainContentNotificationHandler: ObservableObject { private let tableOperationOptions: Binding<[String: TableOperationOptions]> private let isInspectorPresented: Binding private let editingCell: Binding - private let showDatabaseSwitcher: Binding // MARK: - State @@ -48,8 +47,7 @@ final class MainContentNotificationHandler: ObservableObject { pendingDeletes: Binding>, tableOperationOptions: Binding<[String: TableOperationOptions]>, isInspectorPresented: Binding, - editingCell: Binding, - showDatabaseSwitcher: Binding + editingCell: Binding ) { self.coordinator = coordinator self.filterStateManager = filterStateManager @@ -61,7 +59,6 @@ final class MainContentNotificationHandler: ObservableObject { self.tableOperationOptions = tableOperationOptions self.isInspectorPresented = isInspectorPresented self.editingCell = editingCell - self.showDatabaseSwitcher = showDatabaseSwitcher setupObservers() } @@ -460,7 +457,7 @@ final class MainContentNotificationHandler: ObservableObject { NotificationCenter.default.publisher(for: .openDatabaseSwitcher) .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.showDatabaseSwitcher.wrappedValue = true + self?.coordinator?.showDatabaseSwitcher = true } .store(in: &cancellables) } diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index 9295077d4..255f3efc5 100644 --- a/TablePro/Views/MainContentView.swift +++ b/TablePro/Views/MainContentView.swift @@ -36,7 +36,6 @@ struct MainContentView: View { @State var selectedRowIndices: Set = [] @State private var editingCell: CellPosition? @State private var notificationHandler: MainContentNotificationHandler? - @State private var showDatabaseSwitcher = false @StateObject private var sidebarEditState = MultiRowEditState() // MARK: - Environment @@ -131,15 +130,6 @@ struct MainContentView: View { updateSidebarEditState() } .onAppear { setupNotificationHandler() } - .sheet(isPresented: $showDatabaseSwitcher) { - DatabaseSwitcherSheet( - isPresented: $showDatabaseSwitcher, - currentDatabase: DatabaseManager.shared.currentSession?.connection.database, - databaseType: connection.type - ) { database in - switchDatabase(to: database) - } - } } // MARK: - Main Content @@ -268,8 +258,7 @@ struct MainContentView: View { pendingDeletes: $pendingDeletes, tableOperationOptions: $tableOperationOptions, isInspectorPresented: $isInspectorPresented, - editingCell: $editingCell, - showDatabaseSwitcher: $showDatabaseSwitcher + editingCell: $editingCell ) } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 271b39890..b31b9ca03 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -126,9 +126,6 @@ struct SidebarView: View { } } } - .onChange(of: showOperationDialog) { _, isPresented in - AppState.shared.isSheetPresented = isPresented - } } // MARK: - Search Field diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index ccb55f1d2..0a98a5a5f 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -146,7 +146,6 @@ struct TableOperationDialog: View { Button("Cancel") { isPresented = false } - .keyboardShortcut(.cancelAction) Spacer() @@ -160,6 +159,7 @@ struct TableOperationDialog: View { } .frame(width: 320) .background(Color(nsColor: .windowBackgroundColor)) + .escapeKeyDismiss(isPresented: $isPresented, priority: .sheet) .onAppear { // Reset state when dialog opens ignoreForeignKeys = false diff --git a/landing b/landing new file mode 160000 index 000000000..3c3803d5c --- /dev/null +++ b/landing @@ -0,0 +1 @@ +Subproject commit 3c3803d5c8c9b9c1036623c51f46040ea19a7c53 From cc57afb3c657b21dd6acfabc8f120413a025840e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 18 Jan 2026 03:16:47 +0700 Subject: [PATCH 02/10] Update TablePro/Core/Database/PostgreSQLDriver.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Database/PostgreSQLDriver.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/PostgreSQLDriver.swift b/TablePro/Core/Database/PostgreSQLDriver.swift index ad9581809..c6916f827 100644 --- a/TablePro/Core/Database/PostgreSQLDriver.swift +++ b/TablePro/Core/Database/PostgreSQLDriver.swift @@ -468,11 +468,12 @@ final class PostgreSQLDriver: DatabaseDriver { // Validate charset (basic validation) let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII"] - guard validCharsets.contains(charset) else { + let normalizedCharset = charset.uppercased() + guard validCharsets.contains(normalizedCharset) else { throw DatabaseError.queryFailed("Invalid encoding: \(charset)") } - var query = "CREATE DATABASE \"\(escapedName)\" ENCODING '\(charset)'" + var query = "CREATE DATABASE \"\(escapedName)\" ENCODING '\(normalizedCharset)'" // Validate and add collation if provided if let collation = collation { From 6fe8427b471bb10222c8945be6d7b2fa4a1088e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 18 Jan 2026 03:16:54 +0700 Subject: [PATCH 03/10] Update TablePro/Core/Database/PostgreSQLDriver.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Database/PostgreSQLDriver.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/PostgreSQLDriver.swift b/TablePro/Core/Database/PostgreSQLDriver.swift index c6916f827..8251bee6b 100644 --- a/TablePro/Core/Database/PostgreSQLDriver.swift +++ b/TablePro/Core/Database/PostgreSQLDriver.swift @@ -477,8 +477,10 @@ final class PostgreSQLDriver: DatabaseDriver { // Validate and add collation if provided if let collation = collation { - // Basic validation - ensure no SQL injection attempts - guard !collation.contains("'") && !collation.contains(";") else { + // Strict validation: allow only typical locale/collation characters + let allowedCollationChars = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") + let isValidCollation = collation.unicodeScalars.allSatisfy { allowedCollationChars.contains($0) } + guard isValidCollation else { throw DatabaseError.queryFailed("Invalid collation") } query += " LC_COLLATE '\(collation)'" From 9ac3fe592294b40cf3ca180007f43552c336b3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 18 Jan 2026 03:17:00 +0700 Subject: [PATCH 04/10] Update TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index 1af0e8009..799802a0b 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -115,7 +115,12 @@ struct CreateDatabaseSheet: View { .padding(12) } .frame(width: 380) - .escapeKeyDismiss(priority: .nestedSheet) + .onExitCommand { + // Prevent dismissing the sheet via ESC while a database is being created + if !isCreating { + dismiss() + } + } .onChange(of: charset) { _, newCharset in // Update collation when charset changes if let firstCollation = collations[newCharset]?.first { From 62c9eb8cec29fd83d83d53b64ff718e1498c053f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 18 Jan 2026 03:17:07 +0700 Subject: [PATCH 05/10] Update TablePro/Core/Database/MySQLDriver.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Database/MySQLDriver.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/MySQLDriver.swift b/TablePro/Core/Database/MySQLDriver.swift index f6cef0c75..60c4e55b3 100644 --- a/TablePro/Core/Database/MySQLDriver.swift +++ b/TablePro/Core/Database/MySQLDriver.swift @@ -557,8 +557,10 @@ final class MySQLDriver: DatabaseDriver { // Validate collation if provided if let collation = collation { - // Basic validation - collation should match charset prefix - guard collation.hasPrefix(charset) else { + // Collation must match charset prefix and only contain safe identifier characters + let allowedChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + let isSafe = collation.unicodeScalars.allSatisfy { allowedChars.contains($0) } + guard collation.hasPrefix(charset), isSafe else { throw DatabaseError.queryFailed("Invalid collation for charset") } query += " COLLATE \(collation)" From 9de93c2033481bfcaca7d15762a7e8955d3e654d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 18 Jan 2026 03:17:12 +0700 Subject: [PATCH 06/10] Update TablePro/Core/Database/MySQLDriver.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Database/MySQLDriver.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/MySQLDriver.swift b/TablePro/Core/Database/MySQLDriver.swift index 60c4e55b3..448d1fb1c 100644 --- a/TablePro/Core/Database/MySQLDriver.swift +++ b/TablePro/Core/Database/MySQLDriver.swift @@ -503,10 +503,20 @@ final class MySQLDriver: DatabaseDriver { return result.rows.compactMap { row in row.first.flatMap { $0 } } } + /// Escape a value for safe use in a single-quoted SQL string literal. + /// + /// This helper is intended *only* for contexts where the value will be placed + /// inside single quotes (e.g. `WHERE TABLE_SCHEMA = '...'`) and should not be + /// used for identifiers (such as database, table, or column names). + private func escapeForSQLStringLiteral(_ value: String) -> String { + // Escape single quotes by doubling them, per SQL standard. + return value.replacingOccurrences(of: "'", with: "''") + } + /// Fetch metadata for a specific database func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { - // Escape database name for SQL (backticks for MySQL identifiers, single quotes for string literals) - let escapedDbLiteral = database.replacingOccurrences(of: "'", with: "''") + // Escape database name for use as a SQL string literal in information_schema queries + let escapedDbLiteral = escapeForSQLStringLiteral(database) // Query for table count let countQuery = """ From df037529d3cc20b260785fc8256222c3cb33e081 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Jan 2026 03:47:55 +0700 Subject: [PATCH 07/10] Improve database switcher selection with native List binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace custom tap gesture handling with native SwiftUI List selection: - Use List(selection:) with @Published selectedDatabase binding - Apply DoubleClickView overlay pattern (from WelcomeWindowView) - DoubleClickView forwards single-clicks via super.mouseDown() for instant selection - Double-clicks trigger database opening This eliminates the 300-500ms delay from tap gesture disambiguation, making the switcher feel native and responsive like macOS Finder. Files: - Rename DatabaseSwitcherSheetV2 → DatabaseSwitcherSheet - Delete old DatabaseSwitcherSheet_OLD.swift - Update MainContentAlerts to reference new name - Add selectedDatabase @Published property to ViewModel --- .../DatabaseSwitcherViewModel.swift | 58 +-- ...etV2.swift => DatabaseSwitcherSheet.swift} | 163 +++---- .../DatabaseSwitcherSheet_OLD.swift | 405 ------------------ .../Views/Main/Child/MainContentAlerts.swift | 47 +- 4 files changed, 145 insertions(+), 528 deletions(-) rename TablePro/Views/DatabaseSwitcher/{DatabaseSwitcherSheetV2.swift => DatabaseSwitcherSheet.swift} (85%) delete mode 100644 TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet_OLD.swift diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 0ce0cc882..60a7f6fe8 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -2,18 +2,18 @@ // DatabaseSwitcherViewModel.swift // TablePro // -// ViewModel for DatabaseSwitcherSheetV2. +// ViewModel for DatabaseSwitcherSheet. // Handles database fetching, metadata loading, recent tracking, and switching logic. // +import Combine import Foundation import SwiftUI -import Combine @MainActor class DatabaseSwitcherViewModel: ObservableObject { // MARK: - Published State - + @Published var databases: [DatabaseMetadata] = [] @Published var recentDatabases: [String] = [] @Published var searchText = "" @@ -21,15 +21,15 @@ class DatabaseSwitcherViewModel: ObservableObject { @Published var isLoading = false @Published var errorMessage: String? @Published var showPreview = false - + // MARK: - Dependencies - + private let connectionId: UUID private let currentDatabase: String? private let databaseType: DatabaseType - + // MARK: - Computed Properties - + var filteredDatabases: [DatabaseMetadata] { if searchText.isEmpty { return databases @@ -38,46 +38,46 @@ class DatabaseSwitcherViewModel: ObservableObject { $0.name.localizedCaseInsensitiveContains(searchText) } } - + var recentDatabaseMetadata: [DatabaseMetadata] { return recentDatabases.compactMap { dbName in databases.first { $0.name == dbName } } } - + var allDatabases: [DatabaseMetadata] { // Filter out recent databases from "all" list return filteredDatabases.filter { db in !recentDatabases.contains(db.name) } } - + // MARK: - Initialization - + init(connectionId: UUID, currentDatabase: String?, databaseType: DatabaseType) { self.connectionId = connectionId self.currentDatabase = currentDatabase self.databaseType = databaseType self.recentDatabases = UserDefaults.standard.recentDatabases(for: connectionId) } - + // MARK: - Public Methods - + /// Fetch databases and their metadata func fetchDatabases() async { isLoading = true errorMessage = nil - + do { guard let driver = DatabaseManager.shared.activeDriver else { errorMessage = "No active connection" isLoading = false return } - + // Fetch database names let dbNames = try await driver.fetchDatabases() - + // Fetch metadata for each database (in parallel for performance) let metadataList = await withTaskGroup(of: DatabaseMetadata?.self) { group in for dbName in dbNames { @@ -85,7 +85,7 @@ class DatabaseSwitcherViewModel: ObservableObject { return await self.fetchMetadata(for: dbName, driver: driver) } } - + var results: [DatabaseMetadata] = [] for await metadata in group { if let metadata = metadata { @@ -94,48 +94,50 @@ class DatabaseSwitcherViewModel: ObservableObject { } return results } - + // Update state databases = metadataList.sorted { $0.name < $1.name } isLoading = false - + // Pre-select current database or first database if let current = currentDatabase, databases.contains(where: { $0.name == current }) { selectedDatabase = current } else { selectedDatabase = databases.first?.name } - + } catch { errorMessage = error.localizedDescription isLoading = false } } - + /// Refresh database list func refreshDatabases() async { await fetchDatabases() } - + /// Create a new database func createDatabase(name: String, charset: String, collation: String?) async throws { guard let driver = DatabaseManager.shared.activeDriver else { throw DatabaseError.notConnected } - + try await driver.createDatabase(name: name, charset: charset, collation: collation) } - + /// Track database access func trackAccess(database: String) { UserDefaults.standard.trackDatabaseAccess(database, for: connectionId) recentDatabases = UserDefaults.standard.recentDatabases(for: connectionId) } - + // MARK: - Private Methods - + /// Fetch metadata for a single database - private func fetchMetadata(for database: String, driver: DatabaseDriver) async -> DatabaseMetadata? { + private func fetchMetadata(for database: String, driver: DatabaseDriver) async + -> DatabaseMetadata? + { do { return try await driver.fetchDatabaseMetadata(database) } catch { @@ -144,7 +146,7 @@ class DatabaseSwitcherViewModel: ObservableObject { return DatabaseMetadata.minimal(name: database, isSystem: isSystemDatabase(database)) } } - + /// Determine if a database is a system database private func isSystemDatabase(_ database: String) -> Bool { switch databaseType { diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheetV2.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift similarity index 85% rename from TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheetV2.swift rename to TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index b9946d894..e4513c056 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheetV2.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -1,5 +1,5 @@ // -// DatabaseSwitcherSheetV2.swift +// DatabaseSwitcherSheet.swift // TablePro // // Complete redesign of the database switcher dialog. @@ -8,45 +8,49 @@ import SwiftUI -struct DatabaseSwitcherSheetV2: View { +struct DatabaseSwitcherSheet: View { @Binding var isPresented: Bool @Environment(\.dismiss) private var dismiss - + let currentDatabase: String? let databaseType: DatabaseType let connectionId: UUID let onSelect: (String) -> Void - + @StateObject private var viewModel: DatabaseSwitcherViewModel @State private var showCreateDialog = false - - init(isPresented: Binding, currentDatabase: String?, databaseType: DatabaseType, connectionId: UUID, onSelect: @escaping (String) -> Void) { + + init( + isPresented: Binding, currentDatabase: String?, databaseType: DatabaseType, + connectionId: UUID, onSelect: @escaping (String) -> Void + ) { self._isPresented = isPresented self.currentDatabase = currentDatabase self.databaseType = databaseType self.connectionId = connectionId self.onSelect = onSelect - self._viewModel = StateObject(wrappedValue: DatabaseSwitcherViewModel( - connectionId: connectionId, - currentDatabase: currentDatabase, - databaseType: databaseType - )) + self._viewModel = StateObject( + wrappedValue: DatabaseSwitcherViewModel( + connectionId: connectionId, + currentDatabase: currentDatabase, + databaseType: databaseType + )) } - + var body: some View { VStack(spacing: 0) { // Header Text("Open Database") .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) .padding(.vertical, 12) - + Divider() - + // Toolbar: Search + Refresh + Create toolbar - + Divider() - + // Content if viewModel.isLoading { loadingView @@ -59,9 +63,9 @@ struct DatabaseSwitcherSheetV2: View { } else { databaseList } - + Divider() - + // Footer footer } @@ -72,7 +76,8 @@ struct DatabaseSwitcherSheetV2: View { } .sheet(isPresented: $showCreateDialog) { CreateDatabaseSheet { name, charset, collation in - try await viewModel.createDatabase(name: name, charset: charset, collation: collation) + try await viewModel.createDatabase( + name: name, charset: charset, collation: collation) await viewModel.refreshDatabases() } } @@ -94,9 +99,9 @@ struct DatabaseSwitcherSheetV2: View { return .handled } } - + // MARK: - Toolbar - + private var toolbar: some View { HStack(spacing: 8) { // Search @@ -104,11 +109,11 @@ struct DatabaseSwitcherSheetV2: View { Image(systemName: "magnifyingglass") .font(.system(size: DesignConstants.FontSize.body)) .foregroundStyle(.tertiary) - + TextField("Search databases...", text: $viewModel.searchText) .textFieldStyle(.plain) .font(.system(size: DesignConstants.FontSize.body)) - + if !viewModel.searchText.isEmpty { Button(action: { viewModel.searchText = "" }) { Image(systemName: "xmark.circle.fill") @@ -121,7 +126,7 @@ struct DatabaseSwitcherSheetV2: View { .padding(.vertical, 6) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(6) - + // Refresh Button(action: { Task { await viewModel.refreshDatabases() } @@ -131,7 +136,7 @@ struct DatabaseSwitcherSheetV2: View { } .buttonStyle(.borderless) .help("Refresh database list") - + // Create (only for non-SQLite) if databaseType != .sqlite { Button(action: { showCreateDialog = true }) { @@ -145,12 +150,12 @@ struct DatabaseSwitcherSheetV2: View { .padding(.horizontal, 12) .padding(.vertical, 8) } - + // MARK: - Database List - + private var databaseList: some View { ScrollViewReader { proxy in - List { + List(selection: $viewModel.selectedDatabase) { // Recent section if !viewModel.recentDatabaseMetadata.isEmpty { Section { @@ -159,11 +164,13 @@ struct DatabaseSwitcherSheetV2: View { } } header: { Text("RECENT") - .font(.system(size: DesignConstants.FontSize.caption, weight: .semibold)) + .font( + .system(size: DesignConstants.FontSize.caption, weight: .semibold) + ) .foregroundStyle(.secondary) } } - + // All databases Section { ForEach(viewModel.allDatabases) { db in @@ -172,7 +179,9 @@ struct DatabaseSwitcherSheetV2: View { } header: { if !viewModel.recentDatabaseMetadata.isEmpty { Text("ALL DATABASES") - .font(.system(size: DesignConstants.FontSize.caption, weight: .semibold)) + .font( + .system(size: DesignConstants.FontSize.caption, weight: .semibold) + ) .foregroundStyle(.secondary) } } @@ -188,24 +197,25 @@ struct DatabaseSwitcherSheetV2: View { } } } - + private func databaseRow(_ database: DatabaseMetadata) -> some View { let isSelected = database.name == viewModel.selectedDatabase let isCurrent = database.name == currentDatabase - + return HStack(spacing: 10) { // Icon Image(systemName: database.icon) .font(.system(size: 14)) - .foregroundStyle(isSelected ? .white : (database.isSystemDatabase ? .orange : .blue)) - + .foregroundStyle( + isSelected ? .white : (database.isSystemDatabase ? .orange : .blue)) + // Name Text(database.name) .font(.system(size: 13)) .foregroundStyle(isSelected ? .white : .primary) - + Spacer() - + // Current badge if isCurrent { Text("current") @@ -215,7 +225,10 @@ struct DatabaseSwitcherSheetV2: View { .padding(.vertical, 2) .background( Capsule() - .fill(isSelected ? Color.white.opacity(0.15) : Color(nsColor: .separatorColor).opacity(0.5)) + .fill( + isSelected + ? Color.white.opacity(0.15) + : Color(nsColor: .separatorColor).opacity(0.5)) ) } } @@ -229,19 +242,16 @@ struct DatabaseSwitcherSheetV2: View { .listRowInsets(DesignConstants.swiftUIListRowInsets) .listRowSeparator(.hidden) .id(database.name) - .onTapGesture { - viewModel.selectedDatabase = database.name - } + .tag(database.name) .overlay( DoubleClickView { - viewModel.selectedDatabase = database.name openSelectedDatabase() } ) } - + // MARK: - Empty States - + private var loadingView: some View { VStack(spacing: 12) { ProgressView() @@ -252,22 +262,22 @@ struct DatabaseSwitcherSheetV2: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + private func errorView(_ message: String) -> some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: DesignConstants.IconSize.extraLarge)) .foregroundStyle(.orange) - + Text("Failed to load databases") .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - + Text(message) .font(.system(size: DesignConstants.FontSize.small)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) - + Button("Retry") { Task { await viewModel.fetchDatabases() } } @@ -276,38 +286,40 @@ struct DatabaseSwitcherSheetV2: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + private var sqliteEmptyState: some View { VStack(spacing: 12) { Image(systemName: "doc.fill") .font(.system(size: DesignConstants.IconSize.extraLarge)) .foregroundStyle(.secondary) - + Text("SQLite is file-based") .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - - Text("Each SQLite file is a separate database.\nTo open a different database, create a new connection.") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) + + Text( + "Each SQLite file is a separate database.\nTo open a different database, create a new connection." + ) + .font(.system(size: DesignConstants.FontSize.small)) + .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: DesignConstants.IconSize.extraLarge)) .foregroundStyle(.secondary) - + if viewModel.searchText.isEmpty { Text("No databases found") .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) } else { Text("No matching databases") .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - + Text("No databases match \"\(viewModel.searchText)\"") .font(.system(size: DesignConstants.FontSize.small)) .foregroundStyle(.secondary) @@ -315,35 +327,38 @@ struct DatabaseSwitcherSheetV2: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + // MARK: - Footer - + private var footer: some View { HStack { Button("Cancel") { dismiss() } - + Spacer() - + Button("Open") { openSelectedDatabase() } .buttonStyle(.borderedProminent) - .disabled(viewModel.selectedDatabase == nil || viewModel.selectedDatabase == currentDatabase) + .disabled( + viewModel.selectedDatabase == nil || viewModel.selectedDatabase == currentDatabase + ) .keyboardShortcut(.return, modifiers: []) } .padding(12) } - + // MARK: - Actions - + private func moveSelection(up: Bool) { let allDbs = viewModel.recentDatabaseMetadata + viewModel.allDatabases guard !allDbs.isEmpty else { return } - + if let selected = viewModel.selectedDatabase, - let currentIndex = allDbs.firstIndex(where: { $0.name == selected }) { + let currentIndex = allDbs.firstIndex(where: { $0.name == selected }) + { if up { let newIndex = max(0, currentIndex - 1) viewModel.selectedDatabase = allDbs[newIndex].name @@ -355,19 +370,19 @@ struct DatabaseSwitcherSheetV2: View { viewModel.selectedDatabase = up ? allDbs.last?.name : allDbs.first?.name } } - + private func openSelectedDatabase() { guard let database = viewModel.selectedDatabase else { return } - + // Don't reopen current database if database == currentDatabase { dismiss() return } - + // Track access viewModel.trackAccess(database: database) - + // Call onSelect callback onSelect(database) dismiss() @@ -406,7 +421,7 @@ private class PassThroughDoubleClickView: NSView { // MARK: - Preview #Preview("MySQL Databases") { - DatabaseSwitcherSheetV2( + DatabaseSwitcherSheet( isPresented: .constant(true), currentDatabase: "production", databaseType: .mysql, @@ -415,7 +430,7 @@ private class PassThroughDoubleClickView: NSView { } #Preview("SQLite Empty") { - DatabaseSwitcherSheetV2( + DatabaseSwitcherSheet( isPresented: .constant(true), currentDatabase: nil, databaseType: .sqlite, diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet_OLD.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet_OLD.swift deleted file mode 100644 index 11a5be008..000000000 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet_OLD.swift +++ /dev/null @@ -1,405 +0,0 @@ -// -// DatabaseSwitcherSheet.swift -// TablePro -// -// 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: DesignConstants.FontSize.body, weight: .semibold)) - .padding(.vertical, 12) - - Divider() - - // Search field - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.tertiary) - .font(.system(size: DesignConstants.FontSize.body)) - - TextField("Search for database...", text: $searchText) - .textFieldStyle(.plain) - .font(.system(size: DesignConstants.FontSize.body)) - .onSubmit { - openSelectedDatabase() - } - } - .padding(.horizontal, 12) - .padding(.vertical, DesignConstants.Spacing.sm) - - 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(DesignConstants.swiftUIListRowInsets) - .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, DesignConstants.RowHeight.compact) - .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: DesignConstants.FontSize.body)) - .foregroundStyle(isSelected ? .white : (isCurrent ? .blue : .secondary)) - - Text(database) - .font(.system(size: DesignConstants.FontSize.body)) - .foregroundStyle(isSelected ? .white : .primary) - .lineLimit(1) - - Spacer() - - if isCurrent { - Text("current") - .font(.system(size: DesignConstants.FontSize.caption)) - .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: DesignConstants.FontSize.medium)) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func errorView(_ message: String) -> some View { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: DesignConstants.IconSize.extraLarge)) - .foregroundStyle(.orange) - - Text("Failed to load databases") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - - Text(message) - .font(.system(size: DesignConstants.FontSize.small)) - .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: DesignConstants.IconSize.extraLarge)) - .foregroundStyle(.secondary) - - Text("SQLite is file-based") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - - Text("Each SQLite file is a separate database.\nTo open a different database, create a new connection.") - .font(.system(size: DesignConstants.FontSize.small)) - .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: DesignConstants.IconSize.extraLarge)) - .foregroundStyle(.secondary) - - if searchText.isEmpty { - Text("No databases found") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - } else { - Text("No matching databases") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - - Text("No databases match \"\(searchText)\"") - .font(.system(size: DesignConstants.FontSize.small)) - .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) - } -} - -// MARK: - Preview - -#Preview("MySQL Databases") { - DatabaseSwitcherSheet( - isPresented: .constant(true), - currentDatabase: "laravel", - databaseType: .mysql - ) { db in print("Selected: \(db)") } -} - -#Preview("SQLite Empty") { - DatabaseSwitcherSheet( - isPresented: .constant(true), - currentDatabase: nil, - databaseType: .sqlite - ) { db in print("Selected: \(db)") } -} diff --git a/TablePro/Views/Main/Child/MainContentAlerts.swift b/TablePro/Views/Main/Child/MainContentAlerts.swift index 249e3f15d..c4db8e16c 100644 --- a/TablePro/Views/Main/Child/MainContentAlerts.swift +++ b/TablePro/Views/Main/Child/MainContentAlerts.swift @@ -43,7 +43,7 @@ struct MainContentAlerts: ViewModifier { } .sheet(isPresented: $coordinator.showDatabaseSwitcher) { - DatabaseSwitcherSheetV2( + DatabaseSwitcherSheet( isPresented: $coordinator.showDatabaseSwitcher, currentDatabase: connection.database.isEmpty ? nil : connection.database, databaseType: connection.type, @@ -76,16 +76,17 @@ struct MainContentAlerts: ViewModifier { } // Dangerous query confirmation alert - .alert("Potentially Dangerous Query", isPresented: $coordinator.showDangerousQueryAlert) { - Button("Cancel", role: .cancel) { - coordinator.cancelDangerousQuery() - } - Button("Execute", role: .destructive) { - coordinator.confirmDangerousQuery() - } - } message: { - Text(dangerousQueryMessage) + .alert("Potentially Dangerous Query", isPresented: $coordinator.showDangerousQueryAlert) + { + Button("Cancel", role: .cancel) { + coordinator.cancelDangerousQuery() } + Button("Execute", role: .destructive) { + coordinator.confirmDangerousQuery() + } + } message: { + Text(dangerousQueryMessage) + } } // MARK: - Computed Properties @@ -96,11 +97,14 @@ struct MainContentAlerts: ViewModifier { } let uppercased = query.uppercased().trimmingCharacters(in: .whitespacesAndNewlines) if uppercased.hasPrefix("DROP ") { - return "This DROP query will permanently remove database objects. This action cannot be undone." + return + "This DROP query will permanently remove database objects. This action cannot be undone." } else if uppercased.hasPrefix("TRUNCATE ") { - return "This TRUNCATE query will permanently delete all rows in the table. This action cannot be undone." + return + "This TRUNCATE query will permanently delete all rows in the table. This action cannot be undone." } else if uppercased.hasPrefix("DELETE ") { - return "This DELETE query has no WHERE clause and will delete ALL rows in the table. This action cannot be undone." + return + "This DELETE query has no WHERE clause and will delete ALL rows in the table. This action cannot be undone." } return "This query may permanently modify or delete data." } @@ -135,13 +139,14 @@ extension View { tables: [TableInfo], selectedTables: Set ) -> some View { - modifier(MainContentAlerts( - coordinator: coordinator, - connection: connection, - pendingTruncates: pendingTruncates, - pendingDeletes: pendingDeletes, - tables: tables, - selectedTables: selectedTables - )) + modifier( + MainContentAlerts( + coordinator: coordinator, + connection: connection, + pendingTruncates: pendingTruncates, + pendingDeletes: pendingDeletes, + tables: tables, + selectedTables: selectedTables + )) } } From b0d8e7f15ccbb20f7d7825461b6b0724479add78 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Jan 2026 03:54:47 +0700 Subject: [PATCH 08/10] wip --- landing | 1 - 1 file changed, 1 deletion(-) delete mode 160000 landing diff --git a/landing b/landing deleted file mode 160000 index 3c3803d5c..000000000 --- a/landing +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3c3803d5c8c9b9c1036623c51f46040ea19a7c53 From 56bc4d1f58c913b69bacc960ff6f679012d6d656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 18 Jan 2026 03:56:11 +0700 Subject: [PATCH 09/10] Update TablePro/Core/Database/PostgreSQLDriver.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Core/Database/PostgreSQLDriver.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Database/PostgreSQLDriver.swift b/TablePro/Core/Database/PostgreSQLDriver.swift index 8251bee6b..34370e179 100644 --- a/TablePro/Core/Database/PostgreSQLDriver.swift +++ b/TablePro/Core/Database/PostgreSQLDriver.swift @@ -483,7 +483,9 @@ final class PostgreSQLDriver: DatabaseDriver { guard isValidCollation else { throw DatabaseError.queryFailed("Invalid collation") } - query += " LC_COLLATE '\(collation)'" + // Escape single quotes for safe SQL literal usage + let escapedCollation = collation.replacingOccurrences(of: "'", with: "''") + query += " LC_COLLATE '\(escapedCollation)'" } _ = try await execute(query: query) From 0fdbbdb57f099749ea87ecf90fcf2c42d42d73d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 18 Jan 2026 03:56:18 +0700 Subject: [PATCH 10/10] Update TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ngô Quốc Đạt --- TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index e4513c056..4ad26ce17 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -245,6 +245,7 @@ struct DatabaseSwitcherSheet: View { .tag(database.name) .overlay( DoubleClickView { + viewModel.selectedDatabase = database.name openSelectedDatabase() } )