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..448d1fb1c 100644 --- a/TablePro/Core/Database/MySQLDriver.swift +++ b/TablePro/Core/Database/MySQLDriver.swift @@ -502,4 +502,80 @@ final class MySQLDriver: DatabaseDriver { let result = try await execute(query: "SHOW DATABASES") 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 use as a SQL string literal in information_schema queries + let escapedDbLiteral = escapeForSQLStringLiteral(database) + + // 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 { + // 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)" + } + + _ = try await execute(query: query) + } } diff --git a/TablePro/Core/Database/PostgreSQLDriver.swift b/TablePro/Core/Database/PostgreSQLDriver.swift index 316f20aa4..34370e179 100644 --- a/TablePro/Core/Database/PostgreSQLDriver.swift +++ b/TablePro/Core/Database/PostgreSQLDriver.swift @@ -423,4 +423,71 @@ 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"] + let normalizedCharset = charset.uppercased() + guard validCharsets.contains(normalizedCharset) else { + throw DatabaseError.queryFailed("Invalid encoding: \(charset)") + } + + var query = "CREATE DATABASE \"\(escapedName)\" ENCODING '\(normalizedCharset)'" + + // Validate and add collation if provided + if let collation = collation { + // 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") + } + // Escape single quotes for safe SQL literal usage + let escapedCollation = collation.replacingOccurrences(of: "'", with: "''") + query += " LC_COLLATE '\(escapedCollation)'" + } + + _ = 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..60a7f6fe8 --- /dev/null +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -0,0 +1,161 @@ +// +// DatabaseSwitcherViewModel.swift +// TablePro +// +// ViewModel for DatabaseSwitcherSheet. +// Handles database fetching, metadata loading, recent tracking, and switching logic. +// + +import Combine +import Foundation +import SwiftUI + +@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..799802a0b --- /dev/null +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -0,0 +1,152 @@ +// +// 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) + .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 { + 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/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 11a5be008..4ad26ce17 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -2,94 +2,94 @@ // DatabaseSwitcherSheet.swift // TablePro // -// Modal sheet to display and switch between databases. -// Similar to TablePlus's "Open database" feature (Cmd+K). +// Complete redesign of the database switcher dialog. +// Features: Rich metadata, recent databases, refresh, create database, preview panel. // 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 connectionId: UUID 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) - } + @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") + 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) + // Toolbar: Search + Refresh + Create + toolbar Divider() - // Database list or empty state - if isLoading { + // Content + if viewModel.isLoading { loadingView - } else if let error = errorMessage { + } else if let error = viewModel.errorMessage { errorView(error) } else if databaseType == .sqlite { sqliteEmptyState - } else if filteredDatabases.isEmpty { + } else if viewModel.filteredDatabases.isEmpty { emptyState } else { - databaseListView + databaseList } Divider() - // Footer buttons - footerView + // Footer + footer } - .frame(width: 360, height: 340) + .frame(width: 420, height: 480) .background(Color(nsColor: .windowBackgroundColor)) .onAppear { - loadDatabases() + Task { await viewModel.fetchDatabases() } } - .onChange(of: searchText) { _, _ in - // Reset selection when search changes - selectedItem = filteredDatabases.first + .sheet(isPresented: $showCreateDialog) { + CreateDatabaseSheet { name, charset, collation in + try await viewModel.createDatabase( + name: name, charset: charset, collation: collation) + await viewModel.refreshDatabases() + } } - .onKeyPress(.escape) { + .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 @@ -98,90 +98,154 @@ struct DatabaseSwitcherSheet: View { moveSelection(up: false) return .handled } - .onKeyPress(.return) { - openSelectedDatabase() - 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 databaseListView: some View { + private var databaseList: 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 + List(selection: $viewModel.selectedDatabase) { + // 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(.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 + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .onChange(of: viewModel.selectedDatabase) { _, newValue in 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 + private func databaseRow(_ database: DatabaseMetadata) -> some View { + let isSelected = database.name == viewModel.selectedDatabase + let isCurrent = database.name == 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)) + // 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) - .lineLimit(1) Spacer() + // Current badge if isCurrent { Text("current") - .font(.system(size: DesignConstants.FontSize.caption)) - .foregroundStyle(isSelected ? .white.opacity(0.8) : .secondary) + .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) + .tag(database.name) .overlay( DoubleClickView { - selectedItem = database + viewModel.selectedDatabase = database.name openSelectedDatabase() } ) @@ -216,7 +280,7 @@ struct DatabaseSwitcherSheet: View { .padding(.horizontal) Button("Retry") { - loadDatabases() + Task { await viewModel.fetchDatabases() } } .buttonStyle(.bordered) .controlSize(.small) @@ -233,11 +297,13 @@ struct DatabaseSwitcherSheet: View { 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) } @@ -248,14 +314,14 @@ struct DatabaseSwitcherSheet: View { .font(.system(size: DesignConstants.IconSize.extraLarge)) .foregroundStyle(.secondary) - if searchText.isEmpty { + 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 \"\(searchText)\"") + Text("No databases match \"\(viewModel.searchText)\"") .font(.system(size: DesignConstants.FontSize.small)) .foregroundStyle(.secondary) } @@ -265,7 +331,7 @@ struct DatabaseSwitcherSheet: View { // MARK: - Footer - private var footerView: some View { + private var footer: some View { HStack { Button("Cancel") { dismiss() @@ -277,7 +343,10 @@ struct DatabaseSwitcherSheet: View { openSelectedDatabase() } .buttonStyle(.borderedProminent) - .disabled(selectedItem == nil || selectedItem == currentDatabase) + .disabled( + viewModel.selectedDatabase == nil || viewModel.selectedDatabase == currentDatabase + ) + .keyboardShortcut(.return, modifiers: []) } .padding(12) } @@ -285,65 +354,26 @@ struct DatabaseSwitcherSheet: View { // MARK: - Actions private func moveSelection(up: Bool) { - guard !filteredDatabases.isEmpty else { return } + let allDbs = viewModel.recentDatabaseMetadata + viewModel.allDatabases + guard !allDbs.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 let selected = viewModel.selectedDatabase, + let currentIndex = allDbs.firstIndex(where: { $0.name == selected }) + { if up { let newIndex = max(0, currentIndex - 1) - selectedItem = filteredDatabases[newIndex] + viewModel.selectedDatabase = allDbs[newIndex].name } else { - let newIndex = min(filteredDatabases.count - 1, currentIndex + 1) - selectedItem = filteredDatabases[newIndex] + let newIndex = min(allDbs.count - 1, currentIndex + 1) + viewModel.selectedDatabase = allDbs[newIndex].name } } 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 - } - } + viewModel.selectedDatabase = up ? allDbs.last?.name : allDbs.first?.name } } private func openSelectedDatabase() { - guard let database = selectedItem else { return } + guard let database = viewModel.selectedDatabase else { return } // Don't reopen current database if database == currentDatabase { @@ -351,24 +381,27 @@ struct DatabaseSwitcherSheet: View { 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 } @@ -376,7 +409,7 @@ private struct DoubleClickView: NSViewRepresentable { private class PassThroughDoubleClickView: NSView { var onDoubleClick: (() -> Void)? - + override func mouseDown(with event: NSEvent) { if event.clickCount == 2 { onDoubleClick?() @@ -391,15 +424,17 @@ private class PassThroughDoubleClickView: NSView { #Preview("MySQL Databases") { DatabaseSwitcherSheet( isPresented: .constant(true), - currentDatabase: "laravel", - databaseType: .mysql - ) { db in print("Selected: \(db)") } + currentDatabase: "production", + databaseType: .mysql, + connectionId: UUID() + ) { db in print("Selected: \(db)") } } #Preview("SQLite Empty") { DatabaseSwitcherSheet( isPresented: .constant(true), currentDatabase: nil, - databaseType: .sqlite - ) { db in print("Selected: \(db)") } + databaseType: .sqlite, + connectionId: UUID() + ) { db in print("Selected: \(db)") } } 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..c4db8e16c 100644 --- a/TablePro/Views/Main/Child/MainContentAlerts.swift +++ b/TablePro/Views/Main/Child/MainContentAlerts.swift @@ -46,15 +46,12 @@ struct MainContentAlerts: ViewModifier { DatabaseSwitcherSheet( 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 @@ -83,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 @@ -103,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." } @@ -142,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 + )) } } 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