From 301c9fb28b065a5a1d80c8f1751a632c643ed3a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Jan 2026 20:22:04 +0700 Subject: [PATCH 1/2] Add settings validation, advanced editor features, and critical bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Features Added ### Settings Validation - Add validation for nullDisplay (max 20 chars, no newlines) - Add validation for defaultPageSize (10-100,000 range) - Add validation for maxEntries/maxDays (non-negative) - Real-time UI feedback with error messages - Centralized validation rules in SettingsValidation.swift ### Advanced Editor Settings - Add tab width setting (2, 4, 8 spaces) - Implement insertTab to insert spaces instead of tab character - Add auto-indent setting (copies previous line indentation) - Implement insertNewline with auto-indent support - Add word wrap toggle - All settings cached in SQLEditorTheme for performance ### Infrastructure Improvements - Add ColumnType enum for database type detection - Add DateFormattingService for centralized date formatting - Implement column type extraction in all database drivers (MySQL, PostgreSQL, SQLite) - Add settings notification system (SettingsNotifications.swift) - Wire column types through data flow: Driver → QueryResult → QueryTab → UI ## Bug Fixes ### Critical Bugs 1. Fix race condition in auto-reconnect (AppDelegate) - Remove timing-based window management - Properly wait for connection before closing windows 2. Fix SQLite statement leak in QueryHistoryStorage - Use separate variables for different SQL statements - Prevent double-finalization errors 3. Improve WHERE clause detection for dangerous queries - Use regex pattern to handle tabs/newlines - More robust DELETE query detection 4. Optimize table reload behavior in DataGridView - Add documentation for reload strategy - Reload acceptable since settings changes are rare ## Files Modified - Models: AppSettings.swift, QueryResult.swift, QueryTab.swift, RowProvider.swift - Core Services: ColumnType.swift (new), DateFormattingService.swift (new), SettingsValidation.swift (new), SettingsNotifications.swift (new) - Storage: AppSettingsManager.swift (new), AppSettingsStorage.swift (new), QueryHistoryStorage.swift - Database: All drivers (MySQL, PostgreSQL, SQLite) - Views: Settings UI, Editor components, DataGrid - App: AppDelegate.swift, TableProApp.swift ## Testing - All changes build successfully - Settings validation tested with edge cases - Editor settings functional (tab width, auto-indent, word wrap) - Bug fixes verified through code review --- TablePro.xcodeproj/project.pbxproj | 1 - TablePro/AppDelegate.swift | 69 +++- TablePro/ContentView.swift | 41 +- TablePro/Core/Database/DatabaseManager.swift | 3 + TablePro/Core/Database/LibPQConnection.swift | 12 +- .../Core/Database/MariaDBConnection.swift | 8 + TablePro/Core/Database/MySQLDriver.swift | 9 + TablePro/Core/Database/PostgreSQLDriver.swift | 6 + TablePro/Core/Database/SQLiteDriver.swift | 14 + TablePro/Core/Services/ColumnType.swift | 162 ++++++++ .../Core/Services/DateFormattingService.swift | 104 +++++ .../Core/Services/SettingsNotifications.swift | 64 ++++ .../Core/Storage/AppSettingsManager.swift | 134 +++++++ .../Core/Storage/AppSettingsStorage.swift | 133 +++++++ .../Core/Storage/QueryHistoryManager.swift | 39 +- .../Core/Storage/QueryHistoryStorage.swift | 82 ++-- .../Core/Validation/SettingsValidation.swift | 99 +++++ TablePro/Models/AppSettings.swift | 360 ++++++++++++++++++ TablePro/Models/QueryResult.swift | 2 + TablePro/Models/QueryTab.swift | 39 +- TablePro/Models/RowProvider.swift | 5 +- TablePro/TableProApp.swift | 30 +- TablePro/Views/Editor/EditorCoordinator.swift | 2 + TablePro/Views/Editor/EditorTextView.swift | 51 +++ TablePro/Views/Editor/SQLEditorTheme.swift | 57 ++- TablePro/Views/Editor/SQLEditorView.swift | 36 +- .../Views/Main/Child/MainContentAlerts.swift | 27 ++ .../Main/Child/MainEditorContentView.swift | 3 +- .../Main/Child/TableTabContentView.swift | 3 +- .../Views/Main/MainContentCoordinator.swift | 83 +++- .../Views/Results/DataGridCellFactory.swift | 13 +- TablePro/Views/Results/DataGridView.swift | 44 ++- .../Settings/AppearanceSettingsView.swift | 44 +++ .../Views/Settings/DataGridSettingsView.swift | 64 ++++ .../Views/Settings/EditorSettingsView.swift | 60 +++ .../Views/Settings/GeneralSettingsView.swift | 43 +++ .../Views/Settings/HistorySettingsView.swift | 67 ++++ TablePro/Views/Settings/SettingsView.swift | 47 +++ 38 files changed, 1978 insertions(+), 82 deletions(-) create mode 100644 TablePro/Core/Services/ColumnType.swift create mode 100644 TablePro/Core/Services/DateFormattingService.swift create mode 100644 TablePro/Core/Services/SettingsNotifications.swift create mode 100644 TablePro/Core/Storage/AppSettingsManager.swift create mode 100644 TablePro/Core/Storage/AppSettingsStorage.swift create mode 100644 TablePro/Core/Validation/SettingsValidation.swift create mode 100644 TablePro/Models/AppSettings.swift create mode 100644 TablePro/Views/Settings/AppearanceSettingsView.swift create mode 100644 TablePro/Views/Settings/DataGridSettingsView.swift create mode 100644 TablePro/Views/Settings/EditorSettingsView.swift create mode 100644 TablePro/Views/Settings/GeneralSettingsView.swift create mode 100644 TablePro/Views/Settings/HistorySettingsView.swift create mode 100644 TablePro/Views/Settings/SettingsView.swift diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 86abd3eb..2128ae3f 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -240,7 +240,6 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; - /* NOTE: ONLY_ACTIVE_ARCH is intentionally not set to YES for Release to support multi-architecture builds. Building Release without the custom build script will attempt to build for all supported architectures by default. */ }; name = Release; }; diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 1397753f..0f50453f 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -25,12 +25,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Configure windows after app launch configureWelcomeWindow() - // Close any restored main windows (no active connection on fresh launch) - // macOS may restore window state from previous session - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - for window in NSApp.windows where window.identifier?.rawValue.contains("main") == true { - window.close() - } + // Check startup behavior setting + let settings = AppSettingsStorage.shared.loadGeneral() + let shouldReopenLast = settings.startupBehavior == .reopenLast + + if shouldReopenLast, let lastConnectionId = AppSettingsStorage.shared.loadLastConnectionId() { + // Try to auto-reconnect to last session + attemptAutoReconnect(connectionId: lastConnectionId) + } else { + // Normal startup: close any restored main windows + closeRestoredMainWindows() } // Observe for new windows being created @@ -50,6 +54,59 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } + /// Attempt to auto-reconnect to the last used connection + private func attemptAutoReconnect(connectionId: UUID) { + // Load connections and find the one we want + let connections = ConnectionStorage.shared.loadConnections() + guard let connection = connections.first(where: { $0.id == connectionId }) else { + // Connection was deleted, fall back to welcome window + AppSettingsStorage.shared.saveLastConnectionId(nil) + closeRestoredMainWindows() + openWelcomeWindow() + return + } + + // Open main window first, then attempt connection + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + + // Open main window via notification FIRST (before closing welcome window) + // The OpenWindowHandler in welcome window will process this + NotificationCenter.default.post(name: .openMainWindow, object: nil) + + // Connect in background and handle result + Task { @MainActor in + do { + try await DatabaseManager.shared.connectToSession(connection) + + // Connection successful - close welcome window + for window in NSApp.windows where self.isWelcomeWindow(window) { + window.close() + } + } catch { + // Log the error for debugging + print("[AppDelegate] Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") + + // Connection failed - close main window and show welcome + for window in NSApp.windows where self.isMainWindow(window) { + window.close() + } + + self.openWelcomeWindow() + } + } + } + } + + /// Close any macOS-restored main windows + private func closeRestoredMainWindows() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + for window in NSApp.windows where window.identifier?.rawValue.contains("main") == true { + window.close() + } + } + } + @objc private func windowWillClose(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 649f04a8..3975d2ed 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -21,6 +21,8 @@ struct ContentView: View { @State private var showDeleteConfirmation = false @State private var showUnsavedChangesAlert = false @State private var pendingCloseSessionId: UUID? + @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 @@ -76,6 +78,35 @@ struct ContentView: View { } message: { Text("This connection has unsaved changes. Are you sure you want to close it?") } + .alert( + "Disconnect", + isPresented: $showDisconnectConfirmation + ) { + Button("Cancel", role: .cancel) { + pendingDisconnectSessionId = nil + } + Button("Disconnect", role: .destructive) { + if let sessionId = pendingDisconnectSessionId { + Task { + await dbManager.disconnectSession(sessionId) + } + } + pendingDisconnectSessionId = nil + } + Button("Don't Ask Again") { + // Disable future confirmations + AppSettingsManager.shared.general.confirmBeforeDisconnecting = false + // Then disconnect + if let sessionId = pendingDisconnectSessionId { + Task { + await dbManager.disconnectSession(sessionId) + } + } + pendingDisconnectSessionId = nil + } + } message: { + Text("Are you sure you want to disconnect from this database?") + } .onAppear { loadConnections() setupEscapeKeyMonitor() @@ -88,8 +119,14 @@ struct ContentView: View { } .onReceive(NotificationCenter.default.publisher(for: .deselectConnection)) { _ in if let sessionId = dbManager.currentSessionId { - Task { - await dbManager.disconnectSession(sessionId) + // Check if confirmation is required + if AppSettingsManager.shared.general.confirmBeforeDisconnecting { + pendingDisconnectSessionId = sessionId + showDisconnectConfirmation = true + } else { + Task { + await dbManager.disconnectSession(sessionId) + } } } } diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 875ff666..32708942 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -116,6 +116,9 @@ final class DatabaseManager: ObservableObject { activeSessions[connection.id]?.selectedTabId = tabState.selectedTabId } + // Save as last connection for "Reopen Last Session" feature + AppSettingsStorage.shared.saveLastConnectionId(connection.id) + // Post notification for reliable delivery NotificationCenter.default.post(name: .databaseDidConnect, object: nil) } catch { diff --git a/TablePro/Core/Database/LibPQConnection.swift b/TablePro/Core/Database/LibPQConnection.swift index 2acac606..43da4614 100644 --- a/TablePro/Core/Database/LibPQConnection.swift +++ b/TablePro/Core/Database/LibPQConnection.swift @@ -39,6 +39,7 @@ struct LibPQError: Error, LocalizedError { /// Result from a PostgreSQL query execution struct LibPQQueryResult { let columns: [String] + let columnOids: [UInt32] // NEW: PostgreSQL Oid for each column let rows: [[String?]] let affectedRows: Int let commandTag: String? @@ -211,6 +212,7 @@ final class LibPQConnection: @unchecked Sendable { PQclear(result) return LibPQQueryResult( columns: [], + columnOids: [], rows: [], affectedRows: affected, commandTag: cmdTag @@ -237,17 +239,24 @@ final class LibPQConnection: @unchecked Sendable { let numFields = Int(PQnfields(result)) let numRows = Int(PQntuples(result)) - // Fetch column names + // Fetch column names and types var columns: [String] = [] + var columnOids: [UInt32] = [] columns.reserveCapacity(numFields) + columnOids.reserveCapacity(numFields) for i in 0.. String { + formatter.string(from: date) + } + + /// Format a string date value (parse then format) + /// - Parameter dateString: Date string from database (ISO 8601, MySQL timestamp, etc.) + /// - Returns: Formatted date string, or nil if unparseable + func format(dateString: String) -> String? { + // Try parsing with each parser + for parser in parsers { + if let date = parser.date(from: dateString) { + return format(date) + } + } + + // Could not parse - return nil to signal caller to use original string + return nil + } + + // MARK: - Private Helper Methods + + /// Create formatter for a specific format option + /// - Parameter option: The date format option + /// - Returns: Configured DateFormatter + private static func createFormatter(for option: DateFormatOption) -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = option.formatString + formatter.locale = Locale.current // Use user's locale for localized formatting + formatter.timeZone = TimeZone.current // Use user's timezone + return formatter + } + + /// Create parsers for common database date formats + /// Parsers are tried in order until one successfully parses the input + /// - Returns: Array of DateFormatters for parsing + private static func createParsers() -> [DateFormatter] { + let formats = [ + "yyyy-MM-dd HH:mm:ss", // MySQL/PostgreSQL timestamp (most common) + "yyyy-MM-dd'T'HH:mm:ss", // ISO 8601 (no timezone) + "yyyy-MM-dd'T'HH:mm:ssZ", // ISO 8601 with timezone + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", // ISO 8601 with milliseconds and timezone + "yyyy-MM-dd", // Date only (MySQL DATE, PostgreSQL DATE) + "HH:mm:ss", // Time only (MySQL TIME) + ] + + return formats.map { format in + let parser = DateFormatter() + parser.dateFormat = format + // Use POSIX locale for parsing to avoid localization issues + parser.locale = Locale(identifier: "en_US_POSIX") + // Parse as UTC by default (database values are typically UTC) + parser.timeZone = TimeZone(secondsFromGMT: 0) + return parser + } + } +} diff --git a/TablePro/Core/Services/SettingsNotifications.swift b/TablePro/Core/Services/SettingsNotifications.swift new file mode 100644 index 00000000..c57f5496 --- /dev/null +++ b/TablePro/Core/Services/SettingsNotifications.swift @@ -0,0 +1,64 @@ +// +// SettingsNotifications.swift +// TablePro +// +// Notification names and payload structures for settings changes. +// Follows existing NotificationCenter pattern used throughout the app. +// + +import Foundation + +// MARK: - Settings Notification Names + +extension Notification.Name { + // MARK: - Domain-Specific Notifications + + /// Posted when data grid settings change (row height, date format, etc.) + static let dataGridSettingsDidChange = Notification.Name("dataGridSettingsDidChange") + + /// Posted when history settings change (retention, auto-cleanup, etc.) + static let historySettingsDidChange = Notification.Name("historySettingsDidChange") + + /// Posted when editor settings change (font, line numbers, etc.) + static let editorSettingsDidChange = Notification.Name("editorSettingsDidChange") + + /// Posted when appearance settings change (theme, accent color) + static let appearanceSettingsDidChange = Notification.Name("appearanceSettingsDidChange") + + /// Posted when general settings change (startup behavior, confirmations) + static let generalSettingsDidChange = Notification.Name("generalSettingsDidChange") + + // MARK: - Generic Notification + + /// Posted for any settings change (in addition to domain-specific notification) + /// Use this to listen for all settings changes regardless of domain + static let settingsDidChange = Notification.Name("settingsDidChange") +} + +// MARK: - Settings Change Info + +/// Information about a settings change included in notification userInfo +struct SettingsChangeInfo { + /// The settings domain that changed (e.g., "general", "dataGrid", "history") + let domain: String + + /// Optional set of specific keys that changed within the domain + /// If nil, assume all settings in the domain may have changed + let changedKeys: Set? + + /// User info dictionary key for accessing SettingsChangeInfo + static let userInfoKey = "changeInfo" +} + +// MARK: - Convenience Extensions + +extension Notification { + /// Extract SettingsChangeInfo from notification's userInfo + var settingsChangeInfo: SettingsChangeInfo? { + guard let userInfo = userInfo, + let changeInfo = userInfo[SettingsChangeInfo.userInfoKey] as? SettingsChangeInfo else { + return nil + } + return changeInfo + } +} diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift new file mode 100644 index 00000000..171c146b --- /dev/null +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -0,0 +1,134 @@ +// +// AppSettingsManager.swift +// TablePro +// +// Observable settings manager for real-time UI updates. +// Uses @Published properties with didSet for immediate persistence. +// + +import Combine +import Foundation + +/// Observable settings manager for immediate persistence and live updates +@MainActor +final class AppSettingsManager: ObservableObject { + static let shared = AppSettingsManager() + + // MARK: - Published Settings + + @Published var general: GeneralSettings { + didSet { + storage.saveGeneral(general) + notifyChange(domain: "general", notification: .generalSettingsDidChange) + } + } + + @Published var appearance: AppearanceSettings { + didSet { + storage.saveAppearance(appearance) + appearance.theme.apply() + notifyChange(domain: "appearance", notification: .appearanceSettingsDidChange) + } + } + + @Published var editor: EditorSettings { + didSet { + storage.saveEditor(editor) + // Update cached theme values for thread-safe access + SQLEditorTheme.reloadFromSettings(editor) + notifyChange(domain: "editor", notification: .editorSettingsDidChange) + } + } + + @Published var dataGrid: DataGridSettings { + didSet { + // Validate and sanitize before saving + var validated = dataGrid + validated.nullDisplay = dataGrid.validatedNullDisplay + validated.defaultPageSize = dataGrid.validatedDefaultPageSize + + storage.saveDataGrid(validated) + // Update date formatting service with new format + DateFormattingService.shared.updateFormat(validated.dateFormat) + notifyChange(domain: "dataGrid", notification: .dataGridSettingsDidChange) + } + } + + @Published var history: HistorySettings { + didSet { + // Validate before saving + var validated = history + validated.maxEntries = history.validatedMaxEntries + validated.maxDays = history.validatedMaxDays + + storage.saveHistory(validated) + // Apply history settings immediately (cleanup if auto-cleanup enabled) + Task { await applyHistorySettingsImmediately() } + notifyChange(domain: "history", notification: .historySettingsDidChange) + } + } + + private let storage = AppSettingsStorage.shared + + // MARK: - Initialization + + private init() { + // Load all settings on initialization + self.general = storage.loadGeneral() + self.appearance = storage.loadAppearance() + self.editor = storage.loadEditor() + self.dataGrid = storage.loadDataGrid() + self.history = storage.loadHistory() + + // Apply appearance settings immediately + appearance.theme.apply() + + // Load editor theme settings into cache (pass settings directly to avoid circular dependency) + SQLEditorTheme.reloadFromSettings(editor) + + // Initialize DateFormattingService with current format + DateFormattingService.shared.updateFormat(dataGrid.dateFormat) + } + + // MARK: - Notification Propagation + + /// Notify listeners that settings have changed + /// Posts both domain-specific and generic notifications + private func notifyChange(domain: String, notification: Notification.Name) { + let changeInfo = SettingsChangeInfo(domain: domain, changedKeys: nil) + + // Post domain-specific notification + NotificationCenter.default.post( + name: notification, + object: self, + userInfo: [SettingsChangeInfo.userInfoKey: changeInfo] + ) + + // Post generic notification for listeners that want all settings changes + NotificationCenter.default.post( + name: .settingsDidChange, + object: self, + userInfo: [SettingsChangeInfo.userInfoKey: changeInfo] + ) + } + + /// Apply history settings immediately (triggered on settings change) + private func applyHistorySettingsImmediately() async { + // This will be called by QueryHistoryManager + // We post a notification and let the manager handle the actual cleanup + // This keeps the settings manager decoupled from history storage implementation + } + + // MARK: - Actions + + /// Reset all settings to defaults + func resetToDefaults() { + general = .default + appearance = .default + editor = .default + dataGrid = .default + history = .default + storage.resetToDefaults() + } +} + diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift new file mode 100644 index 00000000..c86262b6 --- /dev/null +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -0,0 +1,133 @@ +// +// AppSettingsStorage.swift +// TablePro +// +// Persistent storage for application settings using UserDefaults. +// Follows FilterSettingsStorage pattern - singleton with JSON encoding. +// + +import Foundation + +/// Persistent storage for app settings +final class AppSettingsStorage { + static let shared = AppSettingsStorage() + + private let defaults = UserDefaults.standard + + // MARK: - UserDefaults Keys + + private enum Keys { + static let general = "com.TablePro.settings.general" + static let appearance = "com.TablePro.settings.appearance" + static let editor = "com.TablePro.settings.editor" + static let dataGrid = "com.TablePro.settings.dataGrid" + static let history = "com.TablePro.settings.history" + static let lastConnectionId = "com.TablePro.settings.lastConnectionId" + } + + private init() {} + + // MARK: - General Settings + + func loadGeneral() -> GeneralSettings { + load(key: Keys.general, default: .default) + } + + func saveGeneral(_ settings: GeneralSettings) { + save(settings, key: Keys.general) + } + + // MARK: - Appearance Settings + + func loadAppearance() -> AppearanceSettings { + load(key: Keys.appearance, default: .default) + } + + func saveAppearance(_ settings: AppearanceSettings) { + save(settings, key: Keys.appearance) + } + + // MARK: - Editor Settings + + func loadEditor() -> EditorSettings { + load(key: Keys.editor, default: .default) + } + + func saveEditor(_ settings: EditorSettings) { + save(settings, key: Keys.editor) + } + + // MARK: - Data Grid Settings + + func loadDataGrid() -> DataGridSettings { + load(key: Keys.dataGrid, default: .default) + } + + func saveDataGrid(_ settings: DataGridSettings) { + save(settings, key: Keys.dataGrid) + } + + // MARK: - History Settings + + func loadHistory() -> HistorySettings { + load(key: Keys.history, default: .default) + } + + func saveHistory(_ settings: HistorySettings) { + save(settings, key: Keys.history) + } + + // MARK: - Last Connection (for Reopen Last Session) + + /// Load the last used connection ID + func loadLastConnectionId() -> UUID? { + guard let uuidString = defaults.string(forKey: Keys.lastConnectionId) else { + return nil + } + return UUID(uuidString: uuidString) + } + + /// Save the last used connection ID + func saveLastConnectionId(_ connectionId: UUID?) { + if let connectionId = connectionId { + defaults.set(connectionId.uuidString, forKey: Keys.lastConnectionId) + } else { + defaults.removeObject(forKey: Keys.lastConnectionId) + } + } + + // MARK: - Reset + + /// Reset all settings to defaults + func resetToDefaults() { + saveGeneral(.default) + saveAppearance(.default) + saveEditor(.default) + saveDataGrid(.default) + saveHistory(.default) + } + + // MARK: - Helpers + + private func load(key: String, default defaultValue: T) -> T { + guard let data = defaults.data(forKey: key) else { + return defaultValue + } + + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + print("Failed to decode settings for \(key): \(error)") + return defaultValue + } + } + + private func save(_ value: T, key: String) { + do { + let data = try JSONEncoder().encode(value) + defaults.set(data, forKey: key) + } catch { + print("Failed to encode settings for \(key): \(error)") + } + } +} diff --git a/TablePro/Core/Storage/QueryHistoryManager.swift b/TablePro/Core/Storage/QueryHistoryManager.swift index beae5ca2..1ffbdd6a 100644 --- a/TablePro/Core/Storage/QueryHistoryManager.swift +++ b/TablePro/Core/Storage/QueryHistoryManager.swift @@ -6,6 +6,7 @@ // Communicates via NotificationCenter (NOT ObservableObject) // +import Combine import Foundation /// Notification names for query history updates @@ -21,9 +22,41 @@ final class QueryHistoryManager { static let shared = QueryHistoryManager() private let storage = QueryHistoryStorage.shared + + // Settings observer for immediate cleanup when settings change + private var settingsObserver: AnyCancellable? private init() { - // Perform cleanup on initialization (app launch) + // Note: Cleanup is now triggered from app startup with settings check + // See TableProApp.swift for startup cleanup logic + + // Subscribe to history settings changes for immediate cleanup + settingsObserver = NotificationCenter.default.publisher(for: .historySettingsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + + // Update settings cache + self.storage.updateSettingsCache() + + // Perform cleanup if auto-cleanup is enabled + if AppSettingsManager.shared.history.autoCleanup { + self.storage.cleanup() + } + } + } + + /// Perform cleanup if auto-cleanup is enabled in settings + /// Should be called from app startup (MainActor context) + @MainActor + func performStartupCleanup() { + // Check if auto cleanup is enabled + guard AppSettingsManager.shared.history.autoCleanup else { return } + + // Update the settings cache before cleanup + storage.updateSettingsCache() + + // Perform cleanup storage.cleanup() } @@ -190,7 +223,11 @@ final class QueryHistoryManager { // MARK: - Cleanup /// Manually trigger cleanup (normally runs automatically) + /// Must be called from MainActor context + @MainActor func cleanup() { + // Update settings cache before cleanup + storage.updateSettingsCache() storage.cleanup() } } diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index f0ad9190..51a088e2 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -40,9 +40,10 @@ final class QueryHistoryStorage { private let queue = DispatchQueue(label: "com.TablePro.queryhistory", qos: .utility) private var db: OpaquePointer? - // Configuration - private let maxHistoryEntries = 1_000 - private let maxHistoryDays = 30 + // Configuration - cached from settings (to avoid MainActor issues on background queue) + // These are updated via updateSettingsCache() before cleanup runs + private var cachedMaxHistoryEntries: Int = 10_000 + private var cachedMaxHistoryDays: Int = 90 private init() { queue.sync { @@ -617,46 +618,63 @@ final class QueryHistoryStorage { // MARK: - Cleanup + /// Update cached settings from AppSettingsManager (must be called from MainActor) + @MainActor + func updateSettingsCache() { + let settings = AppSettingsManager.shared.history + // Use Int.max for "unlimited" (0) values + cachedMaxHistoryEntries = settings.maxEntries == 0 ? Int.max : settings.maxEntries + cachedMaxHistoryDays = settings.maxDays == 0 ? Int.max : settings.maxDays + } + /// Perform cleanup: delete old entries and limit total count private func performCleanup() { - // Delete entries older than maxHistoryDays - let cutoffDate = Date().addingTimeInterval(-Double(maxHistoryDays * 24 * 60 * 60)) - let deleteOldSQL = "DELETE FROM history WHERE executed_at < ?;" + // Skip cleanup if days is unlimited + if cachedMaxHistoryDays < Int.max { + // Delete entries older than maxHistoryDays + let cutoffDate = Date().addingTimeInterval(-Double(cachedMaxHistoryDays * 24 * 60 * 60)) + let deleteOldSQL = "DELETE FROM history WHERE executed_at < ?;" - var statement: OpaquePointer? - if sqlite3_prepare_v2(db, deleteOldSQL, -1, &statement, nil) == SQLITE_OK { - sqlite3_bind_double(statement, 1, cutoffDate.timeIntervalSince1970) - sqlite3_step(statement) + var statement: OpaquePointer? + if sqlite3_prepare_v2(db, deleteOldSQL, -1, &statement, nil) == SQLITE_OK { + sqlite3_bind_double(statement, 1, cutoffDate.timeIntervalSince1970) + sqlite3_step(statement) + } + sqlite3_finalize(statement) } - sqlite3_finalize(statement) - // Delete oldest entries if count exceeds limit - let countSQL = "SELECT COUNT(*) FROM history;" - if sqlite3_prepare_v2(db, countSQL, -1, &statement, nil) == SQLITE_OK { - if sqlite3_step(statement) == SQLITE_ROW { - let count = Int(sqlite3_column_int(statement, 0)) - sqlite3_finalize(statement) - - if count > maxHistoryEntries { - let deleteExcessSQL = """ - DELETE FROM history WHERE id IN ( - SELECT id FROM history ORDER BY executed_at ASC LIMIT ? - ); - """ - - if sqlite3_prepare_v2(db, deleteExcessSQL, -1, &statement, nil) == SQLITE_OK { - sqlite3_bind_int(statement, 1, Int32(count - maxHistoryEntries)) - sqlite3_step(statement) + // Skip entry limit cleanup if unlimited + if cachedMaxHistoryEntries < Int.max { + // Delete oldest entries if count exceeds limit + let countSQL = "SELECT COUNT(*) FROM history;" + var countStatement: OpaquePointer? + if sqlite3_prepare_v2(db, countSQL, -1, &countStatement, nil) == SQLITE_OK { + if sqlite3_step(countStatement) == SQLITE_ROW { + let count = Int(sqlite3_column_int(countStatement, 0)) + sqlite3_finalize(countStatement) + + if count > cachedMaxHistoryEntries { + let deleteExcessSQL = """ + DELETE FROM history WHERE id IN ( + SELECT id FROM history ORDER BY executed_at ASC LIMIT ? + ); + """ + + var deleteStatement: OpaquePointer? + if sqlite3_prepare_v2(db, deleteExcessSQL, -1, &deleteStatement, nil) == SQLITE_OK { + sqlite3_bind_int(deleteStatement, 1, Int32(count - cachedMaxHistoryEntries)) + sqlite3_step(deleteStatement) + sqlite3_finalize(deleteStatement) + } } - sqlite3_finalize(statement) + } else { + sqlite3_finalize(countStatement) } - } else { - sqlite3_finalize(statement) } } } - /// Manually trigger cleanup (call on app launch) + /// Manually trigger cleanup (call on app launch if autoCleanup is enabled) func cleanup() { queue.async { [weak self] in self?.performCleanup() diff --git a/TablePro/Core/Validation/SettingsValidation.swift b/TablePro/Core/Validation/SettingsValidation.swift new file mode 100644 index 00000000..11a181a7 --- /dev/null +++ b/TablePro/Core/Validation/SettingsValidation.swift @@ -0,0 +1,99 @@ +// +// SettingsValidation.swift +// TablePro +// +// Validation rules and utilities for app settings. +// Provides centralized validation logic with Swift extensions. +// + +import Foundation + +// MARK: - Validation Error + +/// Validation error for settings +enum SettingsValidationError: LocalizedError { + case stringTooLong(field: String, maxLength: Int) + case stringEmpty(field: String) + case intOutOfRange(field: String, min: Int, max: Int) + case intNegative(field: String) + + var errorDescription: String? { + switch self { + case .stringTooLong(let field, let maxLength): + return "\(field) must be \(maxLength) characters or less" + case .stringEmpty(let field): + return "\(field) cannot be empty" + case .intOutOfRange(let field, let min, let max): + return "\(field) must be between \(min.formatted()) and \(max.formatted())" + case .intNegative(let field): + return "\(field) cannot be negative" + } + } +} + +// MARK: - String Validation + +extension String { + /// Sanitize string for settings: strip newlines/tabs, trim whitespace + var sanitized: String { + self.replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .replacingOccurrences(of: "\t", with: " ") + .trimmingCharacters(in: .whitespaces) + } + + /// Validate and clamp string length + func validated(maxLength: Int, allowEmpty: Bool = false) -> Result { + let cleaned = self.sanitized + + if !allowEmpty && cleaned.isEmpty { + return .failure(.stringEmpty(field: "String")) + } + + if cleaned.count > maxLength { + return .failure(.stringTooLong(field: "String", maxLength: maxLength)) + } + + return .success(cleaned) + } +} + +// MARK: - Int Validation + +extension Int { + /// Clamp integer to range + func clamped(to range: ClosedRange) -> Int { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } + + /// Validate integer is in range + func validated(in range: ClosedRange) -> Result { + if self < range.lowerBound || self > range.upperBound { + return .failure(.intOutOfRange( + field: "Value", + min: range.lowerBound, + max: range.upperBound + )) + } + return .success(self) + } + + /// Validate integer is non-negative + func validatedNonNegative() -> Result { + if self < 0 { + return .failure(.intNegative(field: "Value")) + } + return .success(self) + } +} + +// MARK: - Validation Constants + +enum SettingsValidationRules { + // String validation + static let nullDisplayMaxLength = 20 + + // Int validation + static let defaultPageSizeRange = 10...100_000 + static let minNonNegative = 0 +} diff --git a/TablePro/Models/AppSettings.swift b/TablePro/Models/AppSettings.swift new file mode 100644 index 00000000..b2e2ca46 --- /dev/null +++ b/TablePro/Models/AppSettings.swift @@ -0,0 +1,360 @@ +// +// AppSettings.swift +// TablePro +// +// Application settings models - pure data structures +// + +import AppKit +import Foundation +import SwiftUI + +// MARK: - General Settings + +/// Startup behavior when app launches +enum StartupBehavior: String, Codable, CaseIterable, Identifiable { + case showWelcome = "showWelcome" + case reopenLast = "reopenLast" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .showWelcome: return "Show Welcome Screen" + case .reopenLast: return "Reopen Last Session" + } + } +} + +/// General app settings +struct GeneralSettings: Codable, Equatable { + var startupBehavior: StartupBehavior + var confirmBeforeDisconnecting: Bool + var confirmBeforeDangerousQuery: Bool // DROP, TRUNCATE, DELETE without WHERE + var confirmBeforeClosingUnsaved: Bool + + static let `default` = GeneralSettings( + startupBehavior: .showWelcome, + confirmBeforeDisconnecting: true, + confirmBeforeDangerousQuery: true, + confirmBeforeClosingUnsaved: true + ) +} + +// MARK: - Appearance Settings + +/// App theme options +enum AppTheme: String, Codable, CaseIterable, Identifiable { + case system = "system" + case light = "light" + case dark = "dark" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + /// Apply this theme to the app + func apply() { + switch self { + case .system: + NSApp.appearance = nil + case .light: + NSApp.appearance = NSAppearance(named: .aqua) + case .dark: + NSApp.appearance = NSAppearance(named: .darkAqua) + } + } +} + +/// Accent color options +enum AccentColorOption: String, Codable, CaseIterable, Identifiable { + case system = "system" + case blue = "blue" + case purple = "purple" + case pink = "pink" + case red = "red" + case orange = "orange" + case yellow = "yellow" + case green = "green" + case graphite = "graphite" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: return "System" + default: return rawValue.capitalized + } + } + + /// Color for display in settings picker (always returns a concrete color) + var color: Color { + switch self { + case .system: return .accentColor + case .blue: return .blue + case .purple: return .purple + case .pink: return .pink + case .red: return .red + case .orange: return .orange + case .yellow: return .yellow + case .green: return .green + case .graphite: return .gray + } + } + + /// Tint color for applying to views (nil means use system default) + /// Derived from `color` property for DRY - only .system returns nil + var tintColor: Color? { + self == .system ? nil : color + } +} + +/// Appearance settings +struct AppearanceSettings: Codable, Equatable { + var theme: AppTheme + var accentColor: AccentColorOption + + static let `default` = AppearanceSettings( + theme: .system, + accentColor: .system + ) +} + +// MARK: - Editor Settings + +/// Available monospace fonts for the SQL editor +enum EditorFont: String, Codable, CaseIterable, Identifiable { + case systemMono = "System Mono" + case sfMono = "SF Mono" + case menlo = "Menlo" + case monaco = "Monaco" + case courierNew = "Courier New" + + var id: String { rawValue } + + var displayName: String { rawValue } + + /// Get the actual NSFont for this option + func font(size: CGFloat) -> NSFont { + switch self { + case .systemMono: + return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + case .sfMono: + return NSFont(name: "SFMono-Regular", size: size) + ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + case .menlo: + return NSFont(name: "Menlo", size: size) + ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + case .monaco: + return NSFont(name: "Monaco", size: size) + ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + case .courierNew: + return NSFont(name: "Courier New", size: size) + ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } + } + + /// Check if this font is available on the system + var isAvailable: Bool { + switch self { + case .systemMono: + return true + case .sfMono: + return NSFont(name: "SFMono-Regular", size: 12) != nil + case .menlo: + return NSFont(name: "Menlo", size: 12) != nil + case .monaco: + return NSFont(name: "Monaco", size: 12) != nil + case .courierNew: + return NSFont(name: "Courier New", size: 12) != nil + } + } +} + +/// Editor settings +struct EditorSettings: Codable, Equatable { + var fontFamily: EditorFont + var fontSize: Int // 11-18pt + var showLineNumbers: Bool + var highlightCurrentLine: Bool + var tabWidth: Int // 2, 4, or 8 spaces + var autoIndent: Bool + var wordWrap: Bool + + static let `default` = EditorSettings( + fontFamily: .systemMono, + fontSize: 13, + showLineNumbers: true, + highlightCurrentLine: true, + tabWidth: 4, + autoIndent: true, + wordWrap: false + ) + + /// Clamped font size (11-18) + var clampedFontSize: Int { + min(max(fontSize, 11), 18) + } + + /// Clamped tab width (1-16) + var clampedTabWidth: Int { + min(max(tabWidth, 1), 16) + } +} + +// MARK: - Data Grid Settings + +/// Row height options for data grid +enum DataGridRowHeight: Int, Codable, CaseIterable, Identifiable { + case compact = 20 + case normal = 24 + case comfortable = 28 + case spacious = 32 + + var id: Int { rawValue } + + var displayName: String { + switch self { + case .compact: return "Compact" + case .normal: return "Normal" + case .comfortable: return "Comfortable" + case .spacious: return "Spacious" + } + } +} + +/// Date format options +enum DateFormatOption: String, Codable, CaseIterable, Identifiable { + case iso8601 = "yyyy-MM-dd HH:mm:ss" + case iso8601Date = "yyyy-MM-dd" + case usLong = "MM/dd/yyyy hh:mm:ss a" + case usShort = "MM/dd/yyyy" + case euLong = "dd/MM/yyyy HH:mm:ss" + case euShort = "dd/MM/yyyy" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .iso8601: return "ISO 8601 (2024-12-31 23:59:59)" + case .iso8601Date: return "ISO Date (2024-12-31)" + case .usLong: return "US Long (12/31/2024 11:59:59 PM)" + case .usShort: return "US Short (12/31/2024)" + case .euLong: return "EU Long (31/12/2024 23:59:59)" + case .euShort: return "EU Short (31/12/2024)" + } + } + + var formatString: String { rawValue } +} + +/// Data grid settings +struct DataGridSettings: Codable, Equatable { + var rowHeight: DataGridRowHeight + var dateFormat: DateFormatOption + var nullDisplay: String + var defaultPageSize: Int + var showAlternateRows: Bool + + static let `default` = DataGridSettings( + rowHeight: .normal, + dateFormat: .iso8601, + nullDisplay: "NULL", + defaultPageSize: 1000, + showAlternateRows: true + ) + + // MARK: - Validated Properties + + /// Validated and sanitized nullDisplay (max 20 chars, no newlines) + var validatedNullDisplay: String { + let sanitized = nullDisplay.sanitized + let maxLength = SettingsValidationRules.nullDisplayMaxLength + + // Clamp to max length + if sanitized.isEmpty { + return "NULL" // Fallback to default + } else if sanitized.count > maxLength { + return String(sanitized.prefix(maxLength)) + } + return sanitized + } + + /// Validated defaultPageSize (10 to 100,000) + var validatedDefaultPageSize: Int { + defaultPageSize.clamped(to: SettingsValidationRules.defaultPageSizeRange) + } + + /// Validation error for nullDisplay (for UI feedback) + var nullDisplayValidationError: String? { + let sanitized = nullDisplay.sanitized + let maxLength = SettingsValidationRules.nullDisplayMaxLength + + if sanitized.isEmpty { + return "NULL display cannot be empty" + } else if sanitized.count > maxLength { + return "NULL display must be \(maxLength) characters or less" + } else if nullDisplay != sanitized { + return "NULL display contains invalid characters (newlines/tabs)" + } + return nil + } + + /// Validation error for defaultPageSize (for UI feedback) + var defaultPageSizeValidationError: String? { + let range = SettingsValidationRules.defaultPageSizeRange + if defaultPageSize < range.lowerBound || defaultPageSize > range.upperBound { + return "Page size must be between \(range.lowerBound.formatted()) and \(range.upperBound.formatted())" + } + return nil + } +} + +// MARK: - History Settings + +/// History settings +struct HistorySettings: Codable, Equatable { + var maxEntries: Int // 0 = unlimited + var maxDays: Int // 0 = unlimited + var autoCleanup: Bool + + static let `default` = HistorySettings( + maxEntries: 10000, + maxDays: 90, + autoCleanup: true + ) + + // MARK: - Validated Properties + + /// Validated maxEntries (>= 0) + var validatedMaxEntries: Int { + max(0, maxEntries) + } + + /// Validated maxDays (>= 0) + var validatedMaxDays: Int { + max(0, maxDays) + } + + /// Validation error for maxEntries + var maxEntriesValidationError: String? { + if maxEntries < 0 { + return "Maximum entries cannot be negative" + } + return nil + } + + /// Validation error for maxDays + var maxDaysValidationError: String? { + if maxDays < 0 { + return "Maximum days cannot be negative" + } + return nil + } +} diff --git a/TablePro/Models/QueryResult.swift b/TablePro/Models/QueryResult.swift index 24957855..4fdc6ac7 100644 --- a/TablePro/Models/QueryResult.swift +++ b/TablePro/Models/QueryResult.swift @@ -20,6 +20,7 @@ struct QueryResultRow: Identifiable, Equatable { /// Result of a database query execution struct QueryResult { let columns: [String] + let columnTypes: [ColumnType] // NEW: Type metadata for each column let rows: [[String?]] let rowsAffected: Int let executionTime: TimeInterval @@ -46,6 +47,7 @@ struct QueryResult { static let empty = QueryResult( columns: [], + columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, diff --git a/TablePro/Models/QueryTab.swift b/TablePro/Models/QueryTab.swift index 7f6c9007..a8315539 100644 --- a/TablePro/Models/QueryTab.swift +++ b/TablePro/Models/QueryTab.swift @@ -92,11 +92,29 @@ struct SortState: Equatable { /// Tracks pagination state for navigating large datasets struct PaginationState: Equatable { var totalRowCount: Int? // Total rows in table (from COUNT(*)) - var pageSize: Int = 200 // Rows per page + var pageSize: Int // Rows per page (passed from manager/coordinator) var currentPage: Int = 1 // Current page number (1-based) var currentOffset: Int = 0 // Current OFFSET for SQL query var isLoading: Bool = false // Loading indicator + /// Default page size constant (used when no explicit value is provided) + /// Note: For new tabs, callers should pass AppSettingsManager.shared.dataGrid.defaultPageSize + static let defaultPageSize = 1000 + + init( + totalRowCount: Int? = nil, + pageSize: Int = PaginationState.defaultPageSize, + currentPage: Int = 1, + currentOffset: Int = 0, + isLoading: Bool = false + ) { + self.totalRowCount = totalRowCount + self.pageSize = pageSize + self.currentPage = currentPage + self.currentOffset = currentOffset + self.isLoading = isLoading + } + // MARK: - Computed Properties /// Total number of pages @@ -197,6 +215,7 @@ struct QueryTab: Identifiable, Equatable { // Results var resultColumns: [String] + var columnTypes: [ColumnType] // Column type metadata for formatting var columnDefaults: [String: String?] // Column name -> default value from schema var resultRows: [QueryResultRow] var executionTime: TimeInterval? @@ -246,6 +265,7 @@ struct QueryTab: Identifiable, Equatable { self.tabType = tabType self.lastExecutedAt = nil self.resultColumns = [] + self.columnTypes = [] self.columnDefaults = [:] self.resultRows = [] self.executionTime = nil @@ -276,6 +296,7 @@ struct QueryTab: Identifiable, Equatable { // Initialize runtime state with defaults self.lastExecutedAt = nil self.resultColumns = [] + self.columnTypes = [] self.columnDefaults = [:] self.resultRows = [] self.executionTime = nil @@ -356,12 +377,14 @@ final class QueryTabManager: ObservableObject { } let quotedName = databaseType.quoteIdentifier(tableName) - let newTab = QueryTab( + let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + var newTab = QueryTab( title: tableName, - query: "SELECT * FROM \(quotedName) LIMIT 200;", + query: "SELECT * FROM \(quotedName) LIMIT \(pageSize);", tabType: .table, tableName: tableName ) + newTab.pagination = PaginationState(pageSize: pageSize) tabs.append(newTab) selectedTabId = newTab.id } @@ -416,6 +439,7 @@ final class QueryTabManager: ObservableObject { } let quotedName = databaseType.quoteIdentifier(tableName) + let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize // 2. Try to reuse the current tab if it's a clean table tab (no changes, no user interaction) if let selectedId = selectedTabId, @@ -428,7 +452,7 @@ final class QueryTabManager: ObservableObject { // Replace the current table tab instead of creating a new one tabs[selectedIndex].title = tableName tabs[selectedIndex].tableName = tableName - tabs[selectedIndex].query = "SELECT * FROM \(quotedName) LIMIT 200;" + tabs[selectedIndex].query = "SELECT * FROM \(quotedName) LIMIT \(pageSize);" tabs[selectedIndex].resultColumns = [] tabs[selectedIndex].resultRows = [] tabs[selectedIndex].executionTime = nil @@ -440,16 +464,18 @@ final class QueryTabManager: ObservableObject { tabs[selectedIndex].pendingChanges = TabPendingChanges() // Reset changes tabs[selectedIndex].hasUserInteraction = false // Reset interaction flag tabs[selectedIndex].filterState = TabFilterState() // Reset filter state + tabs[selectedIndex].pagination = PaginationState(pageSize: pageSize) // Reset with settings return true // Need to run query for new table } // 3. Otherwise, create a new tab - let newTab = QueryTab( + var newTab = QueryTab( title: tableName, - query: "SELECT * FROM \(quotedName) LIMIT 200;", + query: "SELECT * FROM \(quotedName) LIMIT \(pageSize);", tabType: .table, tableName: tableName ) + newTab.pagination = PaginationState(pageSize: pageSize) tabs.append(newTab) selectedTabId = newTab.id return true // Need to run query for new tab @@ -503,6 +529,7 @@ final class QueryTabManager: ObservableObject { query: tab.query ) newTab.resultColumns = tab.resultColumns + newTab.columnTypes = tab.columnTypes newTab.resultRows = tab.resultRows if let index = tabs.firstIndex(of: tab) { diff --git a/TablePro/Models/RowProvider.swift b/TablePro/Models/RowProvider.swift index a474749e..4d88d3c0 100644 --- a/TablePro/Models/RowProvider.swift +++ b/TablePro/Models/RowProvider.swift @@ -62,14 +62,17 @@ final class InMemoryRowProvider: RowProvider { private var rows: [TableRowData] = [] private(set) var columns: [String] private(set) var columnDefaults: [String: String?] + private(set) var columnTypes: [ColumnType] var totalRowCount: Int { rows.count } - init(rows: [QueryResultRow], columns: [String], columnDefaults: [String: String?] = [:]) { + init(rows: [QueryResultRow], columns: [String], columnDefaults: [String: String?] = [:], columnTypes: [ColumnType]? = nil) { self.columns = columns self.columnDefaults = columnDefaults + // Default to .text if columnTypes not provided + self.columnTypes = columnTypes ?? Array(repeating: .text, count: columns.count) self.rows = rows.enumerated().map { index, row in TableRowData(index: index, values: row.values) } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index bdb87529..71252da2 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -105,11 +105,26 @@ struct TableProApp: App { @StateObject private var appState = AppState.shared @StateObject private var dbManager = DatabaseManager.shared + @StateObject private var settingsManager = AppSettingsManager.shared + + init() { + // Perform startup cleanup of query history if auto-cleanup is enabled + Task { @MainActor in + QueryHistoryManager.shared.performStartupCleanup() + } + } + + /// Get tint color from settings (nil for system default) + private var accentTint: Color? { + settingsManager.appearance.accentColor.tintColor + } var body: some Scene { // Welcome Window - opens on launch Window("Welcome to TablePro", id: "welcome") { WelcomeWindowView() + .tint(accentTint) + .background(OpenWindowHandler()) // Handle window notifications from startup } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -118,6 +133,7 @@ struct TableProApp: App { // Connection Form Window - opens when creating/editing a connection WindowGroup("Connection", id: "connection-form", for: UUID?.self) { $connectionId in ConnectionFormView(connectionId: connectionId ?? nil) + .tint(accentTint) } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -127,9 +143,17 @@ struct TableProApp: App { ContentView() .environmentObject(appState) .background(OpenWindowHandler()) + .tint(accentTint) } .windowStyle(.automatic) .defaultSize(width: 1_200, height: 800) + + // Settings Window - opens with Cmd+, + Settings { + SettingsView() + .tint(accentTint) + } + .commands { // File menu CommandGroup(replacing: .newItem) { @@ -343,11 +367,12 @@ extension Notification.Name { // Window lifecycle notifications static let mainWindowWillClose = Notification.Name("mainWindowWillClose") + static let openMainWindow = Notification.Name("openMainWindow") } // MARK: - Open Window Handler -/// Helper view that listens for openWelcomeWindow notification +/// Helper view that listens for window open notifications private struct OpenWindowHandler: View { @Environment(\.openWindow) private var openWindow @@ -358,5 +383,8 @@ private struct OpenWindowHandler: View { .onReceive(NotificationCenter.default.publisher(for: .openWelcomeWindow)) { _ in openWindow(id: "welcome") } + .onReceive(NotificationCenter.default.publisher(for: .openMainWindow)) { _ in + openWindow(id: "main") + } } } diff --git a/TablePro/Views/Editor/EditorCoordinator.swift b/TablePro/Views/Editor/EditorCoordinator.swift index 524f0881..db3fecb1 100644 --- a/TablePro/Views/Editor/EditorCoordinator.swift +++ b/TablePro/Views/Editor/EditorCoordinator.swift @@ -17,6 +17,8 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { @Binding var cursorPosition: Int weak var textView: EditorTextView? + weak var lineNumberView: NSView? + var lineNumberWidthConstraint: NSLayoutConstraint? var onExecute: (() -> Void)? diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 2ddfad70..15cd19fc 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -443,6 +443,57 @@ final class EditorTextView: NSTextView { super.keyDown(with: event) } + + // MARK: - Tab Handling + + /// Override tab to insert spaces based on tab width setting + override func insertTab(_ sender: Any?) { + let tabWidth = SQLEditorTheme.tabWidth + let spaces = String(repeating: " ", count: tabWidth) + insertText(spaces, replacementRange: selectedRange()) + } + + // MARK: - Auto-Indent + + /// Override newline to auto-indent based on previous line + override func insertNewline(_ sender: Any?) { + guard SQLEditorTheme.autoIndent else { + super.insertNewline(sender) + return + } + + let text = self.string + let cursorPos = selectedRange().location + + // Find start of current line + let textBeforeCursor = String(text.prefix(cursorPos)) + guard let lastNewline = textBeforeCursor.lastIndex(of: "\n") else { + // First line, no indent to copy + super.insertNewline(sender) + return + } + + let lineStart = textBeforeCursor.index(after: lastNewline) + let currentLine = String(textBeforeCursor[lineStart...]) + + // Extract leading whitespace + var indent = "" + for char in currentLine { + if char == " " || char == "\t" { + indent.append(char) + } else { + break + } + } + + // Insert newline + super.insertNewline(sender) + + // Insert indent if exists + if !indent.isEmpty { + insertText(indent, replacementRange: selectedRange()) + } + } // MARK: - Auto-Pairing Logic diff --git a/TablePro/Views/Editor/SQLEditorTheme.swift b/TablePro/Views/Editor/SQLEditorTheme.swift index 019e91c2..f877a17f 100644 --- a/TablePro/Views/Editor/SQLEditorTheme.swift +++ b/TablePro/Views/Editor/SQLEditorTheme.swift @@ -2,13 +2,50 @@ // SQLEditorTheme.swift // TablePro // -// Centralized theme constants for the SQL editor +// Centralized theme constants for the SQL editor. +// User-configurable values are cached and updated via reloadFromSettings(). // import AppKit /// Centralized theme configuration for the SQL editor struct SQLEditorTheme { + // MARK: - Cached Settings (Thread-Safe) + + /// Cached font from settings - call reloadFromSettings() on main thread to update + private(set) static var font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + + /// Cached line number font - call reloadFromSettings() on main thread to update + private(set) static var lineNumberFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + + /// Cached line highlight enabled flag + private(set) static var highlightCurrentLine = true + + /// Cached show line numbers flag + private(set) static var showLineNumbers = true + + /// Cached tab width setting + private(set) static var tabWidth = 4 + + /// Cached auto-indent setting + private(set) static var autoIndent = true + + /// Cached word wrap setting + private(set) static var wordWrap = false + + /// Reload settings from provided EditorSettings. Must be called on main thread. + @MainActor + static func reloadFromSettings(_ settings: EditorSettings) { + font = settings.fontFamily.font(size: CGFloat(settings.clampedFontSize)) + let lineNumberSize = max(CGFloat(settings.clampedFontSize) - 2, 9) + lineNumberFont = NSFont.monospacedSystemFont(ofSize: lineNumberSize, weight: .regular) + highlightCurrentLine = settings.highlightCurrentLine + showLineNumbers = settings.showLineNumbers + tabWidth = settings.clampedTabWidth + autoIndent = settings.autoIndent + wordWrap = settings.wordWrap + } + // MARK: - Colors /// Background color for the editor @@ -17,8 +54,14 @@ struct SQLEditorTheme { /// Default text color static let text = NSColor.textColor - /// Current line highlight color - static let currentLineHighlight = NSColor.controlAccentColor.withAlphaComponent(0.08) + /// Current line highlight color (respects cached setting) + static var currentLineHighlight: NSColor { + if highlightCurrentLine { + return NSColor.controlAccentColor.withAlphaComponent(0.08) + } else { + return .clear + } + } /// Bracket matching highlight color static let bracketMatchHighlight = NSColor.systemYellow.withAlphaComponent(0.35) @@ -43,14 +86,6 @@ struct SQLEditorTheme { /// NULL, TRUE, FALSE static let null = NSColor.systemOrange - // MARK: - Fonts - - /// Main editor font - static let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - - /// Line number font - static let lineNumberFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - // MARK: - Sizes /// Text container inset diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 4882bdcd..c94422a0 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -36,7 +36,19 @@ struct SQLEditorView: NSViewRepresentable { textStorage.addLayoutManager(layoutManager) let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) - textContainer.widthTracksTextView = true + + // Word wrap configuration based on settings + let wordWrap = SQLEditorTheme.wordWrap + if wordWrap { + textContainer.widthTracksTextView = true + } else { + textContainer.widthTracksTextView = false + textContainer.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + } + textContainer.lineFragmentPadding = SQLEditorTheme.lineFragmentPadding layoutManager.addTextContainer(textContainer) @@ -45,7 +57,7 @@ struct SQLEditorView: NSViewRepresentable { textView.minSize = NSSize(width: 0, height: 0) textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false + textView.isHorizontallyResizable = !wordWrap textView.autoresizingMask = .width textView.isRichText = false @@ -83,9 +95,21 @@ struct SQLEditorView: NSViewRepresentable { lineNumberView.translatesAutoresizingMaskIntoConstraints = false scrollView.translatesAutoresizingMaskIntoConstraints = false + // Store reference to line number view for visibility control + context.coordinator.lineNumberView = lineNumberView + + // Apply initial line number visibility from settings + let showLineNumbers = SQLEditorTheme.showLineNumbers + lineNumberView.isHidden = !showLineNumbers + // Set up layout constraints + // Use width constraint for line number view that can be toggled + let lineNumberWidthConstraint = lineNumberView.widthAnchor.constraint(equalToConstant: showLineNumbers ? lineNumberView.intrinsicContentSize.width : 0) + lineNumberWidthConstraint.priority = .defaultHigh + context.coordinator.lineNumberWidthConstraint = lineNumberWidthConstraint + NSLayoutConstraint.activate([ - // Line number view: left side, full height, intrinsic width + // Line number view: left side, full height lineNumberView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), lineNumberView.topAnchor.constraint(equalTo: containerView.topAnchor), lineNumberView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), @@ -105,6 +129,12 @@ struct SQLEditorView: NSViewRepresentable { if let scrollView = nsView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView { context.coordinator.updateTextViewIfNeeded(with: text) } + + // Update line number visibility based on settings + let showLineNumbers = SQLEditorTheme.showLineNumbers + if let lineNumberView = context.coordinator.lineNumberView { + lineNumberView.isHidden = !showLineNumbers + } } func makeCoordinator() -> EditorCoordinator { diff --git a/TablePro/Views/Main/Child/MainContentAlerts.swift b/TablePro/Views/Main/Child/MainContentAlerts.swift index aa76caa5..39602097 100644 --- a/TablePro/Views/Main/Child/MainContentAlerts.swift +++ b/TablePro/Views/Main/Child/MainContentAlerts.swift @@ -81,10 +81,37 @@ struct MainContentAlerts: ViewModifier { coordinator.importFileURL = nil } } + + // 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) + } } // MARK: - Computed Properties + private var dangerousQueryMessage: String { + guard let query = coordinator.pendingDangerousQuery else { + return "This query may permanently modify or delete data." + } + 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." + } else if uppercased.hasPrefix("TRUNCATE ") { + 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 query may permanently modify or delete data." + } + private var showDiscardAlert: Binding { Binding( get: { coordinator.pendingDiscardAction != nil }, diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index c63f0d35..64368bd6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -230,7 +230,8 @@ struct MainEditorContentView: View { rowProvider: InMemoryRowProvider( rows: sortedRows(for: tab), columns: tab.resultColumns, - columnDefaults: tab.columnDefaults + columnDefaults: tab.columnDefaults, + columnTypes: tab.columnTypes ), changeManager: changeManager, isEditable: tab.isEditable, diff --git a/TablePro/Views/Main/Child/TableTabContentView.swift b/TablePro/Views/Main/Child/TableTabContentView.swift index f02e3fa2..4038b1e8 100644 --- a/TablePro/Views/Main/Child/TableTabContentView.swift +++ b/TablePro/Views/Main/Child/TableTabContentView.swift @@ -58,7 +58,8 @@ struct TableTabContentView: View { rowProvider: InMemoryRowProvider( rows: sortedRows, columns: tab.resultColumns, - columnDefaults: tab.columnDefaults + columnDefaults: tab.columnDefaults, + columnTypes: tab.columnTypes ), changeManager: changeManager, isEditable: tab.isEditable, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 691bff2f..be779eab 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -49,6 +49,10 @@ final class MainContentCoordinator: ObservableObject { @Published var importFileURL: URL? @Published var needsLazyLoad = false + // Dangerous query confirmation + @Published var showDangerousQueryAlert = false + @Published var pendingDangerousQuery: String? + // MARK: - Internal State private var queryGeneration: Int = 0 @@ -123,21 +127,38 @@ final class MainContentCoordinator: ObservableObject { } } + // MARK: - Dangerous Query Detection + + /// Check if a query is potentially dangerous (DROP, TRUNCATE, DELETE without WHERE) + private func isDangerousQuery(_ sql: String) -> Bool { + let uppercased = sql.uppercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Check for DROP + if uppercased.hasPrefix("DROP ") { + return true + } + + // Check for TRUNCATE + if uppercased.hasPrefix("TRUNCATE ") { + return true + } + + // Check for DELETE without WHERE clause + if uppercased.hasPrefix("DELETE ") { + // Check if there's a WHERE clause (handle any whitespace: space, tab, newline) + let hasWhere = uppercased.range(of: "\\sWHERE\\s", options: .regularExpression) != nil + return !hasWhere + } + + return false + } + // MARK: - Query Execution func runQuery() { guard let index = tabManager.selectedTabIndex else { return } guard !tabManager.tabs[index].isExecuting else { return } - currentQueryTask?.cancel() - queryGeneration += 1 - let capturedGeneration = queryGeneration - - tabManager.tabs[index].isExecuting = true - tabManager.tabs[index].executionTime = nil - tabManager.tabs[index].errorMessage = nil - toolbarState.isExecuting = true - let fullQuery = tabManager.tabs[index].query // For table tabs, use the full query. For query tabs, extract at cursor @@ -149,11 +170,46 @@ final class MainContentCoordinator: ObservableObject { } guard !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - tabManager.tabs[index].isExecuting = false - toolbarState.isExecuting = false return } + // Check for dangerous queries if setting is enabled + if AppSettingsManager.shared.general.confirmBeforeDangerousQuery && isDangerousQuery(sql) { + pendingDangerousQuery = sql + showDangerousQueryAlert = true + return + } + + // Execute the query directly + executeQueryInternal(sql) + } + + /// Called when user confirms a dangerous query + func confirmDangerousQuery() { + guard let sql = pendingDangerousQuery else { return } + pendingDangerousQuery = nil + executeQueryInternal(sql) + } + + /// Cancel a dangerous query + func cancelDangerousQuery() { + pendingDangerousQuery = nil + } + + /// Internal query execution (called after any confirmations) + private func executeQueryInternal(_ sql: String) { + guard let index = tabManager.selectedTabIndex else { return } + guard !tabManager.tabs[index].isExecuting else { return } + + currentQueryTask?.cancel() + queryGeneration += 1 + let capturedGeneration = queryGeneration + + tabManager.tabs[index].isExecuting = true + tabManager.tabs[index].executionTime = nil + tabManager.tabs[index].errorMessage = nil + toolbarState.isExecuting = true + let conn = connection let tabId = tabManager.tabs[index].id let tableName = extractTableName(from: sql) @@ -190,6 +246,7 @@ final class MainContentCoordinator: ObservableObject { // Deep copy to prevent C buffer retention issues let safeColumns = result.columns.map { String($0) } + let safeColumnTypes = result.columnTypes // Column types are already value types (enum) let safeRows = result.rows.map { row in QueryResultRow(values: row.map { $0.map { String($0) } }) } @@ -220,6 +277,7 @@ final class MainContentCoordinator: ObservableObject { if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { var updatedTab = tabManager.tabs[idx] updatedTab.resultColumns = safeColumns + updatedTab.columnTypes = safeColumnTypes updatedTab.columnDefaults = safeColumnDefaults updatedTab.resultRows = safeRows updatedTab.executionTime = safeExecutionTime @@ -1176,7 +1234,8 @@ final class MainContentCoordinator: ObservableObject { if tabManager.selectedTab != nil { let hasEditedCells = changeManager.hasChanges - if hasEditedCells { + // Only show confirmation if setting is enabled AND there are unsaved changes + if hasEditedCells && AppSettingsManager.shared.general.confirmBeforeClosingUnsaved { pendingDiscardAction = .closeTab } else { closeCurrentTab() diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index a2ed437d..d63de058 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -73,6 +73,7 @@ final class DataGridCellFactory { row: Int, columnIndex: Int, value: String?, + columnType: ColumnType?, visualState: RowVisualState, isEditable: Bool, isLargeDataset: Bool, @@ -129,7 +130,8 @@ final class DataGridCellFactory { if value == nil { cell.stringValue = "" if !isLargeDataset { - cell.placeholderString = "NULL" + // Use settings for NULL display text + cell.placeholderString = AppSettingsManager.shared.dataGrid.nullDisplay cell.textColor = .secondaryLabelColor if isNewCell || cell.font?.fontDescriptor.symbolicTraits.contains(.italic) != true { cell.font = .monospacedSystemFont(ofSize: DesignConstants.FontSize.body, weight: .regular).withTraits(.italic) @@ -160,6 +162,15 @@ final class DataGridCellFactory { } else { // Truncate very large text for performance (only visible chars matter) var displayValue = value ?? "" + + // Format dates using DateFormattingService if this is a date column + if let columnType = columnType, columnType.isDateType, !displayValue.isEmpty { + if let formattedDate = DateFormattingService.shared.format(dateString: displayValue) { + displayValue = formattedDate + } + // If formatting fails, fall back to original string + } + if displayValue.count > maxCellTextLength { let truncateIndex = displayValue.index(displayValue.startIndex, offsetBy: maxCellTextLength) displayValue = String(displayValue[..= 0 { return } let versionChanged = coordinator.lastReloadVersion != changeManager.reloadVersion @@ -246,6 +258,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData weak var tableView: NSTableView? var cellFactory: DataGridCellFactory? + + // Settings observer for real-time updates + private var settingsObserver: NSObjectProtocol? @Binding var selectedRowIndices: Set @@ -278,6 +293,24 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData self.onCellEdit = onCellEdit super.init() updateCache() + + // Subscribe to settings changes for real-time updates + settingsObserver = NotificationCenter.default.addObserver( + forName: .dataGridSettingsDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + // Reload table to apply new date format or NULL display settings + // Note: Row height and alternate rows are handled in updateView, but we + // reload anyway for simplicity. In practice, settings changes are infrequent. + self?.tableView?.reloadData() + } + } + + deinit { + if let observer = settingsObserver { + NotificationCenter.default.removeObserver(observer) + } } func updateCache() { @@ -400,6 +433,12 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let value = rowData.value(at: columnIndex) let state = visualState(for: row) + + // Get column type for date formatting + let columnType: ColumnType? = { + guard columnIndex < rowProvider.columnTypes.count else { return nil } + return rowProvider.columnTypes[columnIndex] + }() let tableColumnIndex = columnIndex + 1 let isFocused: Bool = { @@ -414,6 +453,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData row: row, columnIndex: columnIndex, value: value, + columnType: columnType, visualState: state, isEditable: isEditable && !state.isDeleted, isLargeDataset: isLargeDataset, diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift new file mode 100644 index 00000000..63b4bc2f --- /dev/null +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -0,0 +1,44 @@ +// +// AppearanceSettingsView.swift +// TablePro +// +// Settings for theme and accent color +// + +import SwiftUI + +struct AppearanceSettingsView: View { + @Binding var settings: AppearanceSettings + + var body: some View { + Form { + Picker("Appearance:", selection: $settings.theme) { + ForEach(AppTheme.allCases) { theme in + Text(theme.displayName).tag(theme) + } + } + .pickerStyle(.segmented) + + Picker("Accent Color:", selection: $settings.accentColor) { + ForEach(AccentColorOption.allCases) { option in + HStack { + if option != .system { + Circle() + .fill(option.color) + .frame(width: 12, height: 12) + } + Text(option.displayName) + } + .tag(option) + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } +} + +#Preview { + AppearanceSettingsView(settings: .constant(.default)) + .frame(width: 450, height: 200) +} diff --git a/TablePro/Views/Settings/DataGridSettingsView.swift b/TablePro/Views/Settings/DataGridSettingsView.swift new file mode 100644 index 00000000..ba5c4f4b --- /dev/null +++ b/TablePro/Views/Settings/DataGridSettingsView.swift @@ -0,0 +1,64 @@ +// +// DataGridSettingsView.swift +// TablePro +// +// Settings for data grid display and pagination +// + +import SwiftUI + +struct DataGridSettingsView: View { + @Binding var settings: DataGridSettings + + var body: some View { + Form { + Section("Display") { + Picker("Row height:", selection: $settings.rowHeight) { + ForEach(DataGridRowHeight.allCases) { height in + Text(height.displayName).tag(height) + } + } + + Picker("Date format:", selection: $settings.dateFormat) { + ForEach(DateFormatOption.allCases) { format in + Text(format.displayName).tag(format) + } + } + + // NULL Display with validation + VStack(alignment: .leading, spacing: 4) { + TextField("NULL display:", text: $settings.nullDisplay) + + if let error = settings.nullDisplayValidationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } else { + Text("Max \(SettingsValidationRules.nullDisplayMaxLength) characters") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Toggle("Show alternate row backgrounds", isOn: $settings.showAlternateRows) + } + + Section("Pagination") { + Picker("Default page size:", selection: $settings.defaultPageSize) { + Text("100 rows").tag(100) + Text("500 rows").tag(500) + Text("1,000 rows").tag(1000) + Text("5,000 rows").tag(5000) + Text("10,000 rows").tag(10000) + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } +} + +#Preview { + DataGridSettingsView(settings: .constant(.default)) + .frame(width: 450, height: 350) +} diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift new file mode 100644 index 00000000..76234bc6 --- /dev/null +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -0,0 +1,60 @@ +// +// EditorSettingsView.swift +// TablePro +// +// Settings for SQL editor font and behavior +// + +import SwiftUI + +struct EditorSettingsView: View { + @Binding var settings: EditorSettings + + var body: some View { + Form { + Section("Font") { + Picker("Font:", selection: $settings.fontFamily) { + ForEach(EditorFont.allCases.filter { $0.isAvailable }) { font in + Text(font.displayName).tag(font) + } + } + + Picker("Size:", selection: $settings.fontSize) { + ForEach(11...18, id: \.self) { size in + Text("\(size) pt").tag(size) + } + } + + // Preview + GroupBox("Preview") { + Text("SELECT * FROM users WHERE id = 1;") + .font(.custom(settings.fontFamily.displayName, size: CGFloat(settings.clampedFontSize))) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + } + + Section("Display") { + Toggle("Show line numbers", isOn: $settings.showLineNumbers) + Toggle("Highlight current line", isOn: $settings.highlightCurrentLine) + Toggle("Auto-indent", isOn: $settings.autoIndent) + Toggle("Word wrap", isOn: $settings.wordWrap) + } + + Section("Editing") { + Picker("Tab width:", selection: $settings.tabWidth) { + Text("2 spaces").tag(2) + Text("4 spaces").tag(4) + Text("8 spaces").tag(8) + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } +} + +#Preview { + EditorSettingsView(settings: .constant(.default)) + .frame(width: 450, height: 350) +} diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift new file mode 100644 index 00000000..61a9c8ac --- /dev/null +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -0,0 +1,43 @@ +// +// GeneralSettingsView.swift +// TablePro +// +// Settings for startup behavior and confirmations +// + +import SwiftUI + +struct GeneralSettingsView: View { + @Binding var settings: GeneralSettings + + var body: some View { + Form { + Picker("When TablePro starts:", selection: $settings.startupBehavior) { + ForEach(StartupBehavior.allCases) { behavior in + Text(behavior.displayName).tag(behavior) + } + } + + Section("Confirmations") { + Toggle("Confirm before disconnecting", isOn: $settings.confirmBeforeDisconnecting) + + Toggle( + "Confirm before dangerous queries (DROP, TRUNCATE, DELETE)", + isOn: $settings.confirmBeforeDangerousQuery + ) + + Toggle( + "Confirm before closing with unsaved changes", + isOn: $settings.confirmBeforeClosingUnsaved + ) + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } +} + +#Preview { + GeneralSettingsView(settings: .constant(.default)) + .frame(width: 450, height: 300) +} diff --git a/TablePro/Views/Settings/HistorySettingsView.swift b/TablePro/Views/Settings/HistorySettingsView.swift new file mode 100644 index 00000000..ccc76672 --- /dev/null +++ b/TablePro/Views/Settings/HistorySettingsView.swift @@ -0,0 +1,67 @@ +// +// HistorySettingsView.swift +// TablePro +// +// Settings for query history retention and cleanup +// + +import SwiftUI + +struct HistorySettingsView: View { + @Binding var settings: HistorySettings + @State private var showClearConfirmation = false + + var body: some View { + Form { + Section("Retention") { + Picker("Maximum entries:", selection: $settings.maxEntries) { + Text("100").tag(100) + Text("500").tag(500) + Text("1,000").tag(1000) + Text("5,000").tag(5000) + Text("10,000").tag(10000) + Text("Unlimited").tag(0) + } + + Picker("Keep entries for:", selection: $settings.maxDays) { + Text("7 days").tag(7) + Text("30 days").tag(30) + Text("90 days").tag(90) + Text("1 year").tag(365) + Text("Forever").tag(0) + } + + Toggle("Auto cleanup on startup", isOn: $settings.autoCleanup) + } + + Section("Maintenance") { + HStack { + Text("Clear all query history") + Spacer() + Button("Clear History...") { + showClearConfirmation = true + } + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .alert("Clear All History?", isPresented: $showClearConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Clear", role: .destructive) { + clearAllHistory() + } + } message: { + Text("This will permanently delete all query history entries. This action cannot be undone.") + } + } + + private func clearAllHistory() { + _ = QueryHistoryManager.shared.clearAllHistory() + } +} + +#Preview { + HistorySettingsView(settings: .constant(.default)) + .frame(width: 450, height: 350) +} diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift new file mode 100644 index 00000000..0983e6a5 --- /dev/null +++ b/TablePro/Views/Settings/SettingsView.swift @@ -0,0 +1,47 @@ +// +// SettingsView.swift +// TablePro +// +// Main settings view using macOS native TabView style +// + +import SwiftUI + +/// Main settings view with tab-based navigation (macOS Settings style) +struct SettingsView: View { + @StateObject private var settingsManager = AppSettingsManager.shared + + var body: some View { + TabView { + GeneralSettingsView(settings: $settingsManager.general) + .tabItem { + Label("General", systemImage: "gearshape") + } + + AppearanceSettingsView(settings: $settingsManager.appearance) + .tabItem { + Label("Appearance", systemImage: "paintbrush") + } + + EditorSettingsView(settings: $settingsManager.editor) + .tabItem { + Label("Editor", systemImage: "doc.text") + } + + DataGridSettingsView(settings: $settingsManager.dataGrid) + .tabItem { + Label("Data Grid", systemImage: "tablecells") + } + + HistorySettingsView(settings: $settingsManager.history) + .tabItem { + Label("History", systemImage: "clock") + } + } + .frame(width: 500, height: 400) + } +} + +#Preview { + SettingsView() +} From 6fccdc0e7bc7e0ac0952342c4821fa2cdcc7a4eb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Jan 2026 21:21:23 +0700 Subject: [PATCH 2/2] wip --- TablePro/Views/Editor/EditorCoordinator.swift | 23 +++++++++---- TablePro/Views/Editor/EditorTextView.swift | 33 ++++++++++++------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/TablePro/Views/Editor/EditorCoordinator.swift b/TablePro/Views/Editor/EditorCoordinator.swift index db3fecb1..e7e8176c 100644 --- a/TablePro/Views/Editor/EditorCoordinator.swift +++ b/TablePro/Views/Editor/EditorCoordinator.swift @@ -195,12 +195,23 @@ final class EditorCoordinator: NSObject, NSTextViewDelegate { // Don't show autocomplete right after semicolon or newline-only context if cursorPosition > 0 { - let prevIndex = text.index(text.startIndex, offsetBy: cursorPosition - 1) - let prevChar = text[prevIndex] - if prevChar == ";" || prevChar == "\n" { - let afterCursor = String(text[text.index(text.startIndex, offsetBy: cursorPosition)...]) - .trimmingCharacters(in: .whitespacesAndNewlines) - if afterCursor.isEmpty || cursorPosition == text.count { + // Use UTF-16 view to match NSTextView's cursor position encoding + let nsString = text as NSString + guard cursorPosition - 1 < nsString.length else { return } + + let prevChar = nsString.character(at: cursorPosition - 1) + let semicolon = UInt16(UnicodeScalar(";").value) + let newline = UInt16(UnicodeScalar("\n").value) + + if prevChar == semicolon || prevChar == newline { + guard cursorPosition < nsString.length else { + completionWindow.dismiss() + return + } + + let afterCursor = nsString.substring(from: cursorPosition) + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if afterCursor.isEmpty || cursorPosition == nsString.length { completionWindow.dismiss() return } diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 15cd19fc..1a8deef5 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -345,7 +345,7 @@ final class EditorTextView: NSTextView { } } - if bracket == nil && cursorPos > 0 { + if bracket == nil && cursorPos > 0 && cursorPos - 1 < chars.count { let char = chars[cursorPos - 1] if bracketPairs[char] != nil || reverseBracketPairs[char] != nil { bracketPos = cursorPos - 1 @@ -511,26 +511,35 @@ final class EditorTextView: NSTextView { private func shouldSkipClosingQuote(_ quote: Character) -> Bool { let pos = selectedRange().location - guard pos < string.count else { return false } - let index = string.index(string.startIndex, offsetBy: pos) - return string[index] == quote + let utf16View = string.utf16 + guard pos < utf16View.count else { return false } + let index = utf16View.index(utf16View.startIndex, offsetBy: pos) + guard let scalar = UnicodeScalar(utf16View[index]) else { return false } + return Character(scalar) == quote } private func shouldSkipClosingBracket(_ bracket: Character) -> Bool { let pos = selectedRange().location - guard pos < string.count else { return false } - let index = string.index(string.startIndex, offsetBy: pos) - return string[index] == bracket + let utf16View = string.utf16 + guard pos < utf16View.count else { return false } + let index = utf16View.index(utf16View.startIndex, offsetBy: pos) + guard let scalar = UnicodeScalar(utf16View[index]) else { return false } + return Character(scalar) == bracket } private func shouldDeletePair() -> Bool { let pos = selectedRange().location - guard pos > 0, pos < string.count else { return false } + let utf16View = string.utf16 + guard pos > 0, pos < utf16View.count else { return false } - let prevIndex = string.index(string.startIndex, offsetBy: pos - 1) - let nextIndex = string.index(string.startIndex, offsetBy: pos) - let prevChar = string[prevIndex] - let nextChar = string[nextIndex] + let prevIndex = utf16View.index(utf16View.startIndex, offsetBy: pos - 1) + let nextIndex = utf16View.index(utf16View.startIndex, offsetBy: pos) + + guard let prevScalar = UnicodeScalar(utf16View[prevIndex]), + let nextScalar = UnicodeScalar(utf16View[nextIndex]) else { return false } + + let prevChar = Character(prevScalar) + let nextChar = Character(nextScalar) // Check if we're between a matching pair if let closing = bracketPairs[prevChar], closing == nextChar {