From f497fb7ccd4880752b764f6f47e6acf3e9fb00d5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Jan 2026 13:56:45 +0700 Subject: [PATCH 1/2] refactor: complete Structure View overhaul and keyboard handling migration ## Structure View Refactor - Replace custom table views with DataGridView for consistent UX - Add StructureChangeManager for O(1) change tracking (mirrors DataChangeManager) - Implement copy/paste/undo/redo using responder chain pattern - Add visual feedback for changes (yellow=modified, green=inserted, red=deleted) - Support schema previews and SQL generation for ALTER TABLE operations ## Keyboard Handling Migration - Replace custom ESC key system with native responder chain (cancelOperation) - Use interpretKeyEvents for standard shortcuts (delete, copy, paste, undo) - Add KeyCode enum for readable key event handling - Remove EscapeKey* files (no longer needed) ## Alert System Consolidation - Create AlertHelper utility for consistent alert dialogs - Replace SwiftUI .alert() with native NSAlert for better modal behavior - Remove MainContentAlerts.swift (replaced by inline confirmations) - Remove InlineErrorBanner (errors now shown as modal alerts) ## Performance Improvements - Add granular DataGridView reloading (only reload changed rows) - Implement visual state caching in change managers - Pre-load all table columns in SQLSchemaProvider (was limited to 5) - Remove unnecessary deep copying in QueryExecutionService ## Bug Fixes - Fix duplicate refresh alerts (Structure View now handles own notifications) - Fix date format settings requiring full reload (now reloads visible rows only) - Add AnyChangeManager protocol wrapper for unified data/structure change tracking - Remove debug print statements from StructureChangeManager, SQLSchemaProvider, DatabaseManager ## Files Changed - Modified: 35 files - Deleted: 6 files (EscapeKey system, InlineErrorBanner, MainContentAlerts) - Added: 9 files (StructureChangeManager, SchemaModels, AlertHelper, etc.) --- TablePro/ContentView.swift | 74 +- .../Core/Autocomplete/SQLSchemaProvider.swift | 50 +- .../ChangeTracking/AnyChangeManager.swift | 115 +++ .../ChangeTracking/DataChangeManager.swift | 18 + TablePro/Core/Database/DatabaseManager.swift | 39 + .../EscapeKeyCoordinator.swift | 109 --- .../EscapeKeyEnvironment.swift | 40 - .../EscapeKeyEnvironmentBridge.swift | 52 - .../KeyboardHandling/EscapeKeyHandler.swift | 63 -- TablePro/Core/KeyboardHandling/KeyCode.swift | 174 ++++ .../ResponderChainActions.swift | 240 +++++ .../KeyboardHandling/View+EscapeKey.swift | 101 -- .../SchemaStatementGenerator.swift | 420 ++++++++ .../StructureChangeManager.swift | 608 ++++++++++++ .../SchemaTracking/StructureUndoManager.swift | 79 ++ .../Core/Services/QueryExecutionService.swift | 36 +- TablePro/Core/Utilities/AlertHelper.swift | 162 +++ TablePro/Models/AppSettings.swift | 8 +- TablePro/Models/Schema/ColumnDefinition.swift | 85 ++ .../Models/Schema/ForeignKeyDefinition.swift | 78 ++ TablePro/Models/Schema/IndexDefinition.swift | 72 ++ .../Models/Schema/SchemaChange+Undo.swift | 43 + TablePro/Models/Schema/SchemaChange.swift | 97 ++ TablePro/Models/Schema/StructureTab.swift | 16 + TablePro/TableProApp.swift | 26 +- .../Views/Connection/ConnectionFormView.swift | 3 +- .../Connection/ConnectionTagEditor.swift | 4 +- .../DatabaseSwitcherSheet.swift | 28 +- .../Editor/BookmarkEditorController.swift | 11 +- .../Views/Editor/BookmarkEditorView.swift | 13 +- TablePro/Views/Editor/CreateTableView.swift | 4 +- TablePro/Views/Editor/EditorTextView.swift | 11 +- TablePro/Views/Editor/TemplateSheets.swift | 12 +- TablePro/Views/Export/ExportDialog.swift | 39 +- TablePro/Views/Filter/SQLPreviewSheet.swift | 4 +- .../History/HistoryListViewController.swift | 19 +- TablePro/Views/History/HistoryTableView.swift | 31 +- TablePro/Views/Import/ImportDialog.swift | 4 +- .../Views/Main/Child/MainContentAlerts.swift | 152 --- .../Main/Child/MainEditorContentView.swift | 11 +- .../Main/Child/TableTabContentView.swift | 7 +- .../MainContentCoordinator+Alerts.swift | 96 ++ .../Views/Main/MainContentCoordinator.swift | 121 ++- .../Main/MainContentNotificationHandler.swift | 43 +- TablePro/Views/MainContentView.swift | 26 +- .../Views/Results/BooleanCellEditor.swift | 89 ++ .../Views/Results/BooleanCellFormatter.swift | 56 ++ .../Views/Results/DataGridCellFactory.swift | 4 + TablePro/Views/Results/DataGridView.swift | 131 ++- .../Views/Results/KeyHandlingTableView.swift | 275 ++++-- .../Views/Settings/GeneralSettingsView.swift | 14 - .../Views/Settings/HistorySettingsView.swift | 21 +- TablePro/Views/Shared/InlineErrorBanner.swift | 87 -- TablePro/Views/Sidebar/SidebarView.swift | 3 - .../Views/Sidebar/TableOperationDialog.swift | 4 +- .../Views/Structure/SchemaPreviewSheet.swift | 139 +++ .../Structure/StructureRowProvider.swift | 128 +++ .../Structure/StructureTableCoordinator.swift | 275 ++++++ .../Views/Structure/TableStructureView.swift | 924 ++++++++++++------ TablePro/Views/WelcomeWindowView.swift | 15 +- 60 files changed, 4293 insertions(+), 1316 deletions(-) create mode 100644 TablePro/Core/ChangeTracking/AnyChangeManager.swift delete mode 100644 TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift delete mode 100644 TablePro/Core/KeyboardHandling/EscapeKeyEnvironment.swift delete mode 100644 TablePro/Core/KeyboardHandling/EscapeKeyEnvironmentBridge.swift delete mode 100644 TablePro/Core/KeyboardHandling/EscapeKeyHandler.swift create mode 100644 TablePro/Core/KeyboardHandling/KeyCode.swift create mode 100644 TablePro/Core/KeyboardHandling/ResponderChainActions.swift delete mode 100644 TablePro/Core/KeyboardHandling/View+EscapeKey.swift create mode 100644 TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift create mode 100644 TablePro/Core/SchemaTracking/StructureChangeManager.swift create mode 100644 TablePro/Core/SchemaTracking/StructureUndoManager.swift create mode 100644 TablePro/Core/Utilities/AlertHelper.swift create mode 100644 TablePro/Models/Schema/ColumnDefinition.swift create mode 100644 TablePro/Models/Schema/ForeignKeyDefinition.swift create mode 100644 TablePro/Models/Schema/IndexDefinition.swift create mode 100644 TablePro/Models/Schema/SchemaChange+Undo.swift create mode 100644 TablePro/Models/Schema/SchemaChange.swift create mode 100644 TablePro/Models/Schema/StructureTab.swift delete mode 100644 TablePro/Views/Main/Child/MainContentAlerts.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift create mode 100644 TablePro/Views/Results/BooleanCellEditor.swift create mode 100644 TablePro/Views/Results/BooleanCellFormatter.swift delete mode 100644 TablePro/Views/Shared/InlineErrorBanner.swift create mode 100644 TablePro/Views/Structure/SchemaPreviewSheet.swift create mode 100644 TablePro/Views/Structure/StructureRowProvider.swift create mode 100644 TablePro/Views/Structure/StructureTableCoordinator.swift diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 9b9fe4904..f25e22eaa 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -5,6 +5,7 @@ // Created by Ngo Quoc Dat on 16/12/25. // +import AppKit import os import SwiftUI @@ -19,10 +20,6 @@ struct ContentView: View { @State private var connectionToEdit: DatabaseConnection? @State private var connectionToDelete: DatabaseConnection? @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 isInspectorPresented = false // Right sidebar (inspector) visibility @@ -59,59 +56,6 @@ struct ContentView: View { } message: { connection in Text("Are you sure you want to delete \"\(connection.name)\"?") } - .alert( - "Unsaved Changes", - isPresented: $showUnsavedChangesAlert - ) { - Button("Cancel", role: .cancel) { - pendingCloseSessionId = nil - } - Button("Close Without Saving", role: .destructive) { - if let sessionId = pendingCloseSessionId { - Task { - await dbManager.disconnectSession(sessionId) - } - } - pendingCloseSessionId = nil - } - } 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?") - } - .onChange(of: showDisconnectConfirmation) { _, isShowing in - // Reset pending state when alert is dismissed (e.g., by Cmd+W or ESC) - if !isShowing { - pendingDisconnectSessionId = nil - } - } .onAppear { loadConnections() } @@ -120,12 +64,16 @@ struct ContentView: View { } .onReceive(NotificationCenter.default.publisher(for: .deselectConnection)) { _ in if let sessionId = dbManager.currentSessionId { - // Check if confirmation is required - if AppSettingsManager.shared.general.confirmBeforeDisconnecting { - pendingDisconnectSessionId = sessionId - showDisconnectConfirmation = true - } else { - Task { + // Always confirm before disconnecting + Task { @MainActor in + let confirmed = AlertHelper.confirmDestructive( + title: "Disconnect", + message: "Are you sure you want to disconnect from this database?", + confirmButton: "Disconnect", + cancelButton: "Cancel" + ) + + if confirmed { await dbManager.disconnectSession(sessionId) } } diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 5d6caaa97..e7dc74d48 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -38,20 +38,52 @@ actor SQLSchemaProvider { // Fetch all tables tables = try await driver.fetchTables() - // Pre-load columns for common tables (up to 5) - for table in tables.prefix(5) { - let columns = try await driver.fetchColumns(table: table.name) - columnCache[table.name.lowercased()] = columns + // Pre-load columns for ALL tables asynchronously + // Use TaskGroup with concurrency limit to avoid overwhelming the database + let maxConcurrentTasks = 20 // Limit parallel requests + var pendingTables = Array(tables) + + await withTaskGroup(of: (String, [ColumnInfo]?).self) { group in + // Start initial batch + var activeTaskCount = 0 + while activeTaskCount < min(maxConcurrentTasks, pendingTables.count) { + let table = pendingTables.removeFirst() + activeTaskCount += 1 + group.addTask { + do { + let columns = try await driver.fetchColumns(table: table.name) + return (table.name.lowercased(), columns) + } catch { + return (table.name.lowercased(), nil) + } + } + } + + // As tasks complete, start new ones + for await (tableName, columns) in group { + if let columns = columns { + columnCache[tableName] = columns + } + + // Start next task if any remain + if !pendingTables.isEmpty { + let table = pendingTables.removeFirst() + group.addTask { + do { + let columns = try await driver.fetchColumns(table: table.name) + return (table.name.lowercased(), columns) + } catch { + return (table.name.lowercased(), nil) + } + } + } + } } - // Clear remaining column cache isLoading = false - - // Driver will be disconnected by caller, we'll reconnect for additional column loading } catch { lastLoadError = error isLoading = false - print("[SQLSchemaProvider] Failed to load schema: \(error)") } } @@ -69,7 +101,6 @@ actor SQLSchemaProvider { // Use the cached driver from loadSchema() to ensure we're querying the correct connection guard let driver = cachedDriver else { - print("[SQLSchemaProvider] No cached driver for column loading") return [] } @@ -78,7 +109,6 @@ actor SQLSchemaProvider { columnCache[tableName.lowercased()] = columns return columns } catch { - print("[SQLSchemaProvider] Failed to load columns for \(tableName): \(error)") return [] } } diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift new file mode 100644 index 000000000..dcc09175d --- /dev/null +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -0,0 +1,115 @@ +// +// AnyChangeManager.swift +// TablePro +// +// Type-erased wrapper for change managers (data and structure). +// Allows DataGridView to work with both DataChangeManager and StructureChangeManager. +// + +import Combine +import Foundation + +/// Type-erased change manager wrapper +@MainActor +final class AnyChangeManager: ObservableObject { + @Published var hasChanges: Bool = false + @Published var reloadVersion: Int = 0 + + private var cancellables: Set = [] + private let _isRowDeleted: (Int) -> Bool + private let _getChanges: () -> [Any] + private let _recordCellChange: ((Int, Int, String, String?, String?, [String?]) -> Void)? + private let _undoRowDeletion: ((Int) -> Void)? + private let _undoRowInsertion: ((Int) -> Void)? + private let _consumeChangedRowIndices: (() -> Set)? + + // MARK: - Initializers + + /// Wrap a DataChangeManager + init(dataManager: DataChangeManager) { + self._isRowDeleted = { rowIndex in + dataManager.isRowDeleted(rowIndex) + } + self._getChanges = { + dataManager.changes + } + self._recordCellChange = { rowIndex, columnIndex, columnName, oldValue, newValue, originalRow in + dataManager.recordCellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue, + originalRow: originalRow + ) + } + self._undoRowDeletion = { rowIndex in + dataManager.undoRowDeletion(rowIndex: rowIndex) + } + self._undoRowInsertion = { rowIndex in + dataManager.undoRowInsertion(rowIndex: rowIndex) + } + self._consumeChangedRowIndices = { + dataManager.consumeChangedRowIndices() + } + + // Sync published properties + dataManager.$hasChanges + .assign(to: &$hasChanges) + dataManager.$reloadVersion + .assign(to: &$reloadVersion) + } + + /// Wrap a StructureChangeManager + init(structureManager: StructureChangeManager) { + self._isRowDeleted = { _ in false } // Structure doesn't track row deletions + self._getChanges = { + Array(structureManager.pendingChanges.values) + } + self._recordCellChange = nil // Structure uses custom editing logic + self._undoRowDeletion = nil + self._undoRowInsertion = nil + self._consumeChangedRowIndices = { + structureManager.consumeChangedRowIndices() + } + + // Sync published properties + structureManager.$hasChanges + .assign(to: &$hasChanges) + structureManager.$reloadVersion + .assign(to: &$reloadVersion) + } + + // MARK: - Public API + + func isRowDeleted(_ rowIndex: Int) -> Bool { + _isRowDeleted(rowIndex) + } + + var changes: [Any] { + _getChanges() + } + + func recordCellChange( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String?, + originalRow: [String?] + ) { + _recordCellChange?(rowIndex, columnIndex, columnName, oldValue, newValue, originalRow) + } + + func undoRowDeletion(rowIndex: Int) { + _undoRowDeletion?(rowIndex) + } + + func undoRowInsertion(rowIndex: Int) { + _undoRowInsertion?(rowIndex) + } + + func consumeChangedRowIndices() -> Set { + _consumeChangedRowIndices?() ?? [] + } +} diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 97b77c657..70dbd78c9 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -18,6 +18,9 @@ final class DataChangeManager: ObservableObject { @Published var changes: [RowChange] = [] @Published var hasChanges: Bool = false @Published var reloadVersion: Int = 0 // Incremented to trigger table reload + + // Track which rows changed since last reload for granular updates + private(set) var changedRowIndices: Set = [] var tableName: String = "" var primaryKeyColumn: String? @@ -57,6 +60,13 @@ final class DataChangeManager: ObservableObject { private func cellKey(rowIndex: Int, columnIndex: Int) -> String { "\(rowIndex)-\(columnIndex)" } + + /// Consume and clear changed row indices (for granular table reloads) + func consumeChangedRowIndices() -> Set { + let indices = changedRowIndices + changedRowIndices.removeAll() + return indices + } // MARK: - Configuration @@ -67,6 +77,7 @@ final class DataChangeManager: ObservableObject { insertedRowIndices.removeAll() modifiedCells.removeAll() insertedRowData.removeAll() + changedRowIndices.removeAll() undoManager.clearAll() hasChanges = false reloadVersion += 1 @@ -88,6 +99,7 @@ final class DataChangeManager: ObservableObject { insertedRowIndices.removeAll() modifiedCells.removeAll() insertedRowData.removeAll() + changedRowIndices.removeAll() undoManager.clearAll() changes.removeAll() @@ -156,6 +168,7 @@ final class DataChangeManager: ObservableObject { previousValue: oldValue, newValue: newValue )) + changedRowIndices.insert(rowIndex) hasChanges = !changes.isEmpty return } @@ -188,6 +201,7 @@ final class DataChangeManager: ObservableObject { changes[existingIndex].cellChanges.append(cellChange) modifiedCells.insert(key) } + changedRowIndices.insert(rowIndex) } else { let rowChange = RowChange( rowIndex: rowIndex, @@ -197,6 +211,7 @@ final class DataChangeManager: ObservableObject { ) changes.append(rowChange) modifiedCells.insert(key) + changedRowIndices.insert(rowIndex) } pushUndo(.cellEdit( @@ -216,6 +231,7 @@ final class DataChangeManager: ObservableObject { let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) changes.append(rowChange) deletedRowIndices.insert(rowIndex) + changedRowIndices.insert(rowIndex) // Track for granular reload pushUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) hasChanges = true reloadVersion += 1 @@ -238,6 +254,7 @@ final class DataChangeManager: ObservableObject { let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) changes.append(rowChange) deletedRowIndices.insert(rowIndex) + changedRowIndices.insert(rowIndex) // Track for granular reload batchData.append((rowIndex: rowIndex, originalRow: originalRow)) } @@ -251,6 +268,7 @@ final class DataChangeManager: ObservableObject { let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: []) changes.append(rowChange) insertedRowIndices.insert(rowIndex) + changedRowIndices.insert(rowIndex) // Track for granular reload pushUndo(.rowInsertion(rowIndex: rowIndex)) hasChanges = true } diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 60a71566e..110776e69 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -275,4 +275,43 @@ final class DatabaseManager: ObservableObject { let driver = DatabaseDriverFactory.createDriver(for: testConnection) return try await driver.testConnection() } + + // MARK: - Schema Changes + + /// Execute schema changes (ALTER TABLE, CREATE INDEX, etc.) in a transaction + func executeSchemaChanges( + tableName: String, + changes: [SchemaChange], + databaseType: DatabaseType + ) async throws { + guard let driver = activeDriver else { + throw DatabaseError.notConnected + } + + // Generate SQL statements + let generator = SchemaStatementGenerator( + tableName: tableName, + databaseType: databaseType + ) + let statements = try generator.generate(changes: changes) + + // Execute in transaction + try await driver.execute(query: "BEGIN") + + do { + for stmt in statements { + try await driver.execute(query: stmt.sql) + } + + try await driver.execute(query: "COMMIT") + + // Post notification to refresh UI + NotificationCenter.default.post(name: .refreshData, object: nil) + + } catch { + // Rollback on error + try? await driver.execute(query: "ROLLBACK") + throw DatabaseError.queryFailed("Schema change failed: \(error.localizedDescription)") + } + } } diff --git a/TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift b/TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift deleted file mode 100644 index f6b0075e8..000000000 --- a/TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// 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 deleted file mode 100644 index f4b0735be..000000000 --- a/TablePro/Core/KeyboardHandling/EscapeKeyEnvironment.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// 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 deleted file mode 100644 index 5cc8a2366..000000000 --- a/TablePro/Core/KeyboardHandling/EscapeKeyEnvironmentBridge.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// 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 deleted file mode 100644 index 9edb9f465..000000000 --- a/TablePro/Core/KeyboardHandling/EscapeKeyHandler.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// 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/KeyCode.swift b/TablePro/Core/KeyboardHandling/KeyCode.swift new file mode 100644 index 000000000..891f6d809 --- /dev/null +++ b/TablePro/Core/KeyboardHandling/KeyCode.swift @@ -0,0 +1,174 @@ +// +// KeyCode.swift +// TablePro +// +// Semantic enum for keyboard key codes used throughout the app. +// Eliminates magic numbers and improves code readability. +// +// Reference: https://eastmanreference.com/complete-list-of-applescript-key-codes +// + +import AppKit + +/// Semantic enum for NSEvent key codes +/// +/// Usage: +/// ```swift +/// override func keyDown(with event: NSEvent) { +/// guard let key = KeyCode(rawValue: event.keyCode) else { +/// super.keyDown(with: event) +/// return +/// } +/// +/// switch key { +/// case .escape: +/// // Handle ESC +/// case .delete: +/// // Handle Delete +/// default: +/// super.keyDown(with: event) +/// } +/// } +/// ``` +public enum KeyCode: UInt16 { + // MARK: - Special Keys + + /// Escape key (ESC) + case escape = 53 + + /// Return/Enter key (main keyboard) + case `return` = 36 + + /// Enter key (numeric keypad) + case enter = 76 + + /// Tab key + case tab = 48 + + /// Space bar + case space = 49 + + /// Delete/Backspace key + case delete = 51 + + /// Forward Delete key (Fn+Delete on most Macs) + case forwardDelete = 117 + + // MARK: - Arrow Keys + + /// Up arrow + case upArrow = 126 + + /// Down arrow + case downArrow = 125 + + /// Left arrow + case leftArrow = 123 + + /// Right arrow + case rightArrow = 124 + + // MARK: - Letter Keys (for Cmd+ shortcuts) + + case a = 0 + case b = 11 + case c = 8 + case d = 2 + case e = 14 + case f = 3 + case g = 5 + case h = 4 + case i = 34 + case j = 38 + case k = 40 + case l = 37 + case m = 46 + case n = 45 + case o = 31 + case p = 35 + case q = 12 + case r = 15 + case s = 1 + case t = 17 + case u = 32 + case v = 9 + case w = 13 + case x = 7 + case y = 16 + case z = 6 + + // MARK: - Number Keys + + case zero = 29 + case one = 18 + case two = 19 + case three = 20 + case four = 21 + case five = 23 + case six = 22 + case seven = 26 + case eight = 28 + case nine = 25 + + // MARK: - Function Keys + + case f1 = 122 + case f2 = 120 + case f3 = 99 + case f4 = 118 + case f5 = 96 + case f6 = 97 + case f7 = 98 + case f8 = 100 + case f9 = 101 + case f10 = 109 + case f11 = 103 + case f12 = 111 + + // MARK: - Convenience Methods + + /// Check if the key code represents an arrow key + public var isArrowKey: Bool { + switch self { + case .upArrow, .downArrow, .leftArrow, .rightArrow: + return true + default: + return false + } + } + + /// Check if the key code represents a letter + public var isLetter: Bool { + switch self { + case .a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, + .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z: + return true + default: + return false + } + } + + /// Check if the key code represents a number + public var isNumber: Bool { + switch self { + case .zero, .one, .two, .three, .four, .five, .six, .seven, .eight, .nine: + return true + default: + return false + } + } + + /// Create a KeyCode from an NSEvent + public init?(event: NSEvent) { + self.init(rawValue: event.keyCode) + } +} + +// MARK: - NSEvent Extension + +extension NSEvent { + /// The semantic key code for this event, if recognized + public var semanticKeyCode: KeyCode? { + KeyCode(rawValue: keyCode) + } +} diff --git a/TablePro/Core/KeyboardHandling/ResponderChainActions.swift b/TablePro/Core/KeyboardHandling/ResponderChainActions.swift new file mode 100644 index 000000000..8510f8e66 --- /dev/null +++ b/TablePro/Core/KeyboardHandling/ResponderChainActions.swift @@ -0,0 +1,240 @@ +// +// ResponderChainActions.swift +// TablePro +// +// Documentation protocol listing all responder chain actions used in TablePro. +// This is a reference guide, not implemented by any class directly. +// +// ## Architecture Pattern +// +// TablePro uses Apple's native responder chain for keyboard shortcuts: +// +// 1. **SwiftUI Commands** define shortcuts (in TableProApp.swift) +// - `.commands { ... }` blocks register keyboard shortcuts +// - Commands send actions via `NSApp.sendAction(#selector(...), to: nil, from: nil)` +// +// 2. **Responder Chain** finds the handler +// - macOS traverses: First Responder → Window → Window Controller → App +// - First `@objc` method matching selector gets called +// +// 3. **Validation** via `validateUserInterfaceItem` +// - Responders return `true` to enable menu items, `false` to disable +// - Enables context-aware shortcuts (e.g., Delete only works when rows selected) +// +// ## Example Flow +// +// ``` +// User presses: Cmd+Delete +// ↓ +// SwiftUI Command: .keyboardShortcut(.delete, modifiers: .command) +// ↓ +// TableProApp: NSApp.sendAction(#selector(delete(_:)), to: nil, from: nil) +// ↓ +// Responder Chain: First Responder (KeyHandlingTableView) +// ↓ +// KeyHandlingTableView: @objc func delete(_ sender: Any?) { ... } +// ``` +// +// ## Reference Files +// - `TableProApp.swift` - SwiftUI Commands that define shortcuts +// - `KeyHandlingTableView.swift` - Data grid keyboard handling +// - `HistoryTableView.swift` - Perfect responder chain example +// - `EditorTextView.swift` - SQL editor keyboard handling +// + +import AppKit + +/// Documentation protocol listing all responder chain actions in TablePro. +/// +/// **IMPORTANT**: This protocol is for documentation only. Do NOT implement it +/// on any classes. Instead, add individual `@objc` methods as needed. +/// +/// Responders should implement: +/// 1. The `@objc` action method (e.g., `@objc func delete(_ sender: Any?)`) +/// 2. Validation via `NSUserInterfaceValidations` or `NSMenuItemValidation` +/// +@objc protocol TableProResponderActions { + + // MARK: - Standard Edit Menu Actions + + /// Delete the selected items + /// - Standard AppKit selector for Delete/Backspace key + /// - Triggered by: Delete key, Cmd+Delete, or Edit > Delete menu + @objc optional func delete(_ sender: Any?) + + /// Copy selected content to clipboard + /// - Standard AppKit selector for Cmd+C + @objc optional func copy(_ sender: Any?) + + /// Paste clipboard content + /// - Standard AppKit selector for Cmd+V + @objc optional func paste(_ sender: Any?) + + /// Cut selected content to clipboard + /// - Standard AppKit selector for Cmd+X + @objc optional func cut(_ sender: Any?) + + /// Select all items + /// - Standard AppKit selector for Cmd+A + @objc optional func selectAll(_ sender: Any?) + + /// Undo last action + /// - Standard AppKit selector for Cmd+Z + @objc optional func undo(_ sender: Any?) + + /// Redo last undone action + /// - Standard AppKit selector for Cmd+Shift+Z + @objc optional func redo(_ sender: Any?) + + // MARK: - Standard Navigation Actions + + /// Move selection up + /// - Standard AppKit selector for Up Arrow + @objc optional func moveUp(_ sender: Any?) + + /// Move selection down + /// - Standard AppKit selector for Down Arrow + @objc optional func moveDown(_ sender: Any?) + + /// Move selection left + /// - Standard AppKit selector for Left Arrow + @objc optional func moveLeft(_ sender: Any?) + + /// Move selection right + /// - Standard AppKit selector for Right Arrow + @objc optional func moveRight(_ sender: Any?) + + /// Insert newline (Enter/Return key) + /// - Standard AppKit selector for Return key + @objc optional func insertNewline(_ sender: Any?) + + /// Cancel current operation (ESC key) + /// - Standard AppKit selector for Escape key + /// - Automatically called by `.onExitCommand` in SwiftUI + @objc optional func cancelOperation(_ sender: Any?) + + // MARK: - App-Specific Database Actions + + /// Add a new row to the current table + /// - Custom action for Cmd+N in data grid + @objc optional func addRow(_ sender: Any?) + + /// Duplicate the selected row + /// - Custom action for Cmd+D + @objc optional func duplicateRow(_ sender: Any?) + + /// Save pending changes to database + /// - Custom action for Cmd+S + @objc optional func saveChanges(_ sender: Any?) + + /// Refresh data from database + /// - Custom action for Cmd+R + @objc optional func refreshData(_ sender: Any?) + + /// Execute SQL query + /// - Custom action for Cmd+Enter in editor + @objc optional func executeQuery(_ sender: Any?) + + /// Clear current selection + /// - Custom action for Cmd+Esc + @objc optional func clearSelection(_ sender: Any?) + + // MARK: - View Actions + + /// Toggle table browser visibility + /// - Custom action for Cmd+B + @objc optional func toggleTableBrowser(_ sender: Any?) + + /// Toggle inspector panel + /// - Custom action for Cmd+I + @objc optional func toggleInspector(_ sender: Any?) + + /// Toggle filters panel + /// - Custom action for Cmd+F + @objc optional func toggleFilters(_ sender: Any?) + + /// Toggle query history panel + /// - Custom action for Cmd+H + @objc optional func toggleHistory(_ sender: Any?) +} + +// MARK: - Implementation Guide + +/* + + ## How to Implement Responder Chain Actions + + ### Step 1: Add @objc Method to Your Responder + + ```swift + final class MyTableView: NSTableView { + override var acceptsFirstResponder: Bool { true } + + @objc func delete(_ sender: Any?) { + // Your delete logic here + print("Deleting selected rows") + } + } + ``` + + ### Step 2: Add Validation (Optional but Recommended) + + ```swift + extension MyTableView: NSUserInterfaceValidations { + func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + switch item.action { + case #selector(delete(_:)): + // Enable Delete only when rows are selected + return selectedRowIndexes.count > 0 + default: + return false + } + } + } + ``` + + ### Step 3: Register Command in TableProApp.swift + + ```swift + .commands { + CommandGroup(after: .newItem) { + Button("Delete Row") { + NSApp.sendAction(#selector(TableProResponderActions.delete(_:)), + to: nil, from: nil) + } + .keyboardShortcut(.delete, modifiers: .command) + } + } + ``` + + ### Step 4: Use interpretKeyEvents for Bare Keys (Optional) + + For non-modifier keys (arrows, Return, ESC), use `interpretKeyEvents`: + + ```swift + override func keyDown(with event: NSEvent) { + interpretKeyEvents([event]) + } + + @objc override func moveUp(_ sender: Any?) { + // Custom up arrow handling + } + ``` + + ## Benefits of Responder Chain + + ✅ **Automatic validation** - Menu items enable/disable based on context + ✅ **No manual routing** - macOS finds the right handler automatically + ✅ **Standard behavior** - Users expect Cmd+C/V/Z to work everywhere + ✅ **VoiceOver support** - Accessibility built-in + ✅ **Easy to extend** - Just add @objc methods, no global event bus + + ## Anti-Patterns to Avoid + + ❌ **NotificationCenter for commands** - Bypasses validation, hard to debug + ❌ **Magic keyCode numbers** - Use KeyCode enum instead + ❌ **performKeyEquivalent for bare keys** - Only for Cmd+ shortcuts + ❌ **Custom ESC systems** - Use cancelOperation(_:) selector + ❌ **Manual keyDown switches** - Use interpretKeyEvents + selectors + + */ diff --git a/TablePro/Core/KeyboardHandling/View+EscapeKey.swift b/TablePro/Core/KeyboardHandling/View+EscapeKey.swift deleted file mode 100644 index e128d523c..000000000 --- a/TablePro/Core/KeyboardHandling/View+EscapeKey.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// 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/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift new file mode 100644 index 000000000..2234de93e --- /dev/null +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -0,0 +1,420 @@ +// +// SchemaStatementGenerator.swift +// TablePro +// +// Generates ALTER TABLE SQL statements from schema changes. +// Supports MySQL, PostgreSQL, and SQLite with database-specific syntax. +// + +import Foundation + +/// A schema SQL statement with metadata +struct SchemaStatement { + let sql: String + let description: String + let isDestructive: Bool +} + +/// Generates SQL statements for schema modifications +struct SchemaStatementGenerator { + private let databaseType: DatabaseType + private let tableName: String + + init(tableName: String, databaseType: DatabaseType) { + self.tableName = tableName + self.databaseType = databaseType + } + + /// Generate all SQL statements from schema changes + func generate(changes: [SchemaChange]) throws -> [SchemaStatement] { + var statements: [SchemaStatement] = [] + + // Sort changes by dependency order + let sortedChanges = sortByDependency(changes) + + for change in sortedChanges { + let stmt = try generateStatement(for: change) + statements.append(stmt) + } + + return statements + } + + // MARK: - Dependency Ordering + + private func sortByDependency(_ changes: [SchemaChange]) -> [SchemaChange] { + // Execution order for safety: + // 1. Drop foreign keys first + // 2. Drop indexes + // 3. Drop/modify columns + // 4. Add columns + // 5. Modify primary key + // 6. Add indexes + // 7. Add foreign keys + + var fkDeletes: [SchemaChange] = [] + var indexDeletes: [SchemaChange] = [] + var columnDeletes: [SchemaChange] = [] + var columnModifies: [SchemaChange] = [] + var columnAdds: [SchemaChange] = [] + var pkChanges: [SchemaChange] = [] + var indexAdds: [SchemaChange] = [] + var fkAdds: [SchemaChange] = [] + + for change in changes { + switch change { + case .deleteForeignKey, .modifyForeignKey: + fkDeletes.append(change) + case .deleteIndex, .modifyIndex: + indexDeletes.append(change) + case .deleteColumn: + columnDeletes.append(change) + case .modifyColumn: + columnModifies.append(change) + case .addColumn: + columnAdds.append(change) + case .modifyPrimaryKey: + pkChanges.append(change) + case .addIndex: + indexAdds.append(change) + case .addForeignKey: + fkAdds.append(change) + } + } + + return fkDeletes + indexDeletes + columnDeletes + columnModifies + columnAdds + pkChanges + indexAdds + fkAdds + } + + // MARK: - Statement Generation + + private func generateStatement(for change: SchemaChange) throws -> SchemaStatement { + switch change { + case .addColumn(let column): + return try generateAddColumn(column) + case .modifyColumn(let old, let new): + return try generateModifyColumn(old: old, new: new) + case .deleteColumn(let column): + return generateDeleteColumn(column) + case .addIndex(let index): + return try generateAddIndex(index) + case .modifyIndex(let old, let new): + return try generateModifyIndex(old: old, new: new) + case .deleteIndex(let index): + return generateDeleteIndex(index) + case .addForeignKey(let fk): + return try generateAddForeignKey(fk) + case .modifyForeignKey(let old, let new): + return try generateModifyForeignKey(old: old, new: new) + case .deleteForeignKey(let fk): + return generateDeleteForeignKey(fk) + case .modifyPrimaryKey(let old, let new): + return try generateModifyPrimaryKey(old: old, new: new) + } + } + + // MARK: - Column Operations + + private func generateAddColumn(_ column: EditableColumnDefinition) throws -> SchemaStatement { + let tableQuoted = databaseType.quoteIdentifier(tableName) + let columnDef = try buildEditableColumnDefinition(column) + + let sql = "ALTER TABLE \(tableQuoted) ADD COLUMN \(columnDef)" + return SchemaStatement( + sql: sql, + description: "Add column '\(column.name)'", + isDestructive: false + ) + } + + private func generateModifyColumn(old: EditableColumnDefinition, new: EditableColumnDefinition) throws -> SchemaStatement { + let tableQuoted = databaseType.quoteIdentifier(tableName) + + switch databaseType { + case .mysql, .mariadb: + // MySQL: ALTER TABLE t MODIFY COLUMN col definition + let columnDef = try buildEditableColumnDefinition(new) + let sql = "ALTER TABLE \(tableQuoted) MODIFY COLUMN \(columnDef)" + return SchemaStatement( + sql: sql, + description: "Modify column '\(old.name)' to '\(new.name)'", + isDestructive: old.dataType != new.dataType + ) + + case .postgresql: + // PostgreSQL: Multiple ALTER COLUMN statements + var statements: [String] = [] + let oldQuoted = databaseType.quoteIdentifier(old.name) + let newQuoted = databaseType.quoteIdentifier(new.name) + + // Rename if needed + if old.name != new.name { + statements.append("ALTER TABLE \(tableQuoted) RENAME COLUMN \(oldQuoted) TO \(newQuoted)") + } + + // Change type if needed + if old.dataType != new.dataType { + statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) TYPE \(new.dataType)") + } + + // Change nullable if needed + if old.isNullable != new.isNullable { + let constraint = new.isNullable ? "DROP NOT NULL" : "SET NOT NULL" + statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) \(constraint)") + } + + // Change default if needed + if old.defaultValue != new.defaultValue { + if let defaultVal = new.defaultValue, !defaultVal.isEmpty { + statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) SET DEFAULT \(defaultVal)") + } else { + statements.append("ALTER TABLE \(tableQuoted) ALTER COLUMN \(newQuoted) DROP DEFAULT") + } + } + + let sql = statements.joined(separator: ";\n") + return SchemaStatement( + sql: sql, + description: "Modify column '\(old.name)' to '\(new.name)'", + isDestructive: old.dataType != new.dataType + ) + + case .sqlite: + // SQLite doesn't support ALTER COLUMN - would require table recreation + throw DatabaseError.unsupportedOperation + } + } + + private func generateDeleteColumn(_ column: EditableColumnDefinition) -> SchemaStatement { + let tableQuoted = databaseType.quoteIdentifier(tableName) + let columnQuoted = databaseType.quoteIdentifier(column.name) + + let sql = "ALTER TABLE \(tableQuoted) DROP COLUMN \(columnQuoted)" + return SchemaStatement( + sql: sql, + description: "Drop column '\(column.name)'", + isDestructive: true + ) + } + + // MARK: - Column Definition Builder + + private func buildEditableColumnDefinition(_ column: EditableColumnDefinition) throws -> String { + var parts: [String] = [] + + parts.append(databaseType.quoteIdentifier(column.name)) + parts.append(column.dataType) + + // Unsigned (MySQL/MariaDB only) + if (databaseType == .mysql || databaseType == .mariadb) && column.unsigned { + parts.append("UNSIGNED") + } + + // Nullable + if !column.isNullable { + parts.append("NOT NULL") + } + + // Default value + if let defaultValue = column.defaultValue, !defaultValue.isEmpty { + parts.append("DEFAULT \(defaultValue)") + } + + // Auto increment + if column.autoIncrement { + switch databaseType { + case .mysql, .mariadb: + parts.append("AUTO_INCREMENT") + case .postgresql: + // PostgreSQL uses SERIAL or IDENTITY + // For simplicity, we'll use SERIAL + parts[1] = "SERIAL" + case .sqlite: + parts.append("AUTOINCREMENT") + } + } + + // On update (MySQL/MariaDB only for timestamp columns) + if (databaseType == .mysql || databaseType == .mariadb), + let onUpdate = column.onUpdate, !onUpdate.isEmpty { + parts.append("ON UPDATE \(onUpdate)") + } + + // Comment + if let comment = column.comment, !comment.isEmpty { + switch databaseType { + case .mysql, .mariadb: + let escapedComment = comment.replacingOccurrences(of: "'", with: "''") + parts.append("COMMENT '\(escapedComment)'") + case .postgresql: + // PostgreSQL comments are set via separate COMMENT statement + break + case .sqlite: + // SQLite doesn't support column comments + break + } + } + + return parts.joined(separator: " ") + } + + // MARK: - Index Operations + + private func generateAddIndex(_ index: EditableIndexDefinition) throws -> SchemaStatement { + let tableQuoted = databaseType.quoteIdentifier(tableName) + let indexQuoted = databaseType.quoteIdentifier(index.name) + let columnsQuoted = index.columns.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") + + let uniqueKeyword = index.isUnique ? "UNIQUE " : "" + + let sql: String + switch databaseType { + case .mysql, .mariadb: + let indexType = index.type.rawValue + sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) (\(columnsQuoted)) USING \(indexType)" + + case .postgresql: + let indexTypeClause = index.type == .btree ? "" : "USING \(index.type.rawValue)" + sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) \(indexTypeClause) (\(columnsQuoted))" + + case .sqlite: + sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) (\(columnsQuoted))" + } + + return SchemaStatement( + sql: sql, + description: "Add index '\(index.name)'", + isDestructive: false + ) + } + + private func generateModifyIndex(old: EditableIndexDefinition, new: EditableIndexDefinition) throws -> SchemaStatement { + // All databases require drop + recreate for index modification + let dropStmt = generateDeleteIndex(old) + let addStmt = try generateAddIndex(new) + + let sql = "\(dropStmt.sql);\n\(addStmt.sql)" + return SchemaStatement( + sql: sql, + description: "Modify index '\(old.name)' to '\(new.name)'", + isDestructive: false + ) + } + + private func generateDeleteIndex(_ index: EditableIndexDefinition) -> SchemaStatement { + let indexQuoted = databaseType.quoteIdentifier(index.name) + + let sql: String + switch databaseType { + case .mysql, .mariadb: + let tableQuoted = databaseType.quoteIdentifier(tableName) + sql = "DROP INDEX \(indexQuoted) ON \(tableQuoted)" + + case .postgresql, .sqlite: + sql = "DROP INDEX \(indexQuoted)" + } + + return SchemaStatement( + sql: sql, + description: "Drop index '\(index.name)'", + isDestructive: false + ) + } + + // MARK: - Foreign Key Operations + + private func generateAddForeignKey(_ fk: EditableForeignKeyDefinition) throws -> SchemaStatement { + let tableQuoted = databaseType.quoteIdentifier(tableName) + let fkQuoted = databaseType.quoteIdentifier(fk.name) + let columnsQuoted = fk.columns.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") + let refTableQuoted = databaseType.quoteIdentifier(fk.referencedTable) + let refColumnsQuoted = fk.referencedColumns.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") + + let sql = """ + ALTER TABLE \(tableQuoted) + ADD CONSTRAINT \(fkQuoted) + FOREIGN KEY (\(columnsQuoted)) + REFERENCES \(refTableQuoted) (\(refColumnsQuoted)) + ON DELETE \(fk.onDelete.rawValue) + ON UPDATE \(fk.onUpdate.rawValue) + """ + + return SchemaStatement( + sql: sql, + description: "Add foreign key '\(fk.name)'", + isDestructive: false + ) + } + + private func generateModifyForeignKey(old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) throws -> SchemaStatement { + // Modifying FK requires drop + recreate + let dropStmt = generateDeleteForeignKey(old) + let addStmt = try generateAddForeignKey(new) + + let sql = "\(dropStmt.sql);\n\(addStmt.sql)" + return SchemaStatement( + sql: sql, + description: "Modify foreign key '\(old.name)' to '\(new.name)'", + isDestructive: false + ) + } + + private func generateDeleteForeignKey(_ fk: EditableForeignKeyDefinition) -> SchemaStatement { + let tableQuoted = databaseType.quoteIdentifier(tableName) + let fkQuoted = databaseType.quoteIdentifier(fk.name) + + let sql: String + switch databaseType { + case .mysql, .mariadb: + sql = "ALTER TABLE \(tableQuoted) DROP FOREIGN KEY \(fkQuoted)" + + case .postgresql: + sql = "ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(fkQuoted)" + + case .sqlite: + // SQLite doesn't support dropping foreign keys + // Would require table recreation + sql = "-- SQLite does not support dropping foreign keys" + } + + return SchemaStatement( + sql: sql, + description: "Drop foreign key '\(fk.name)'", + isDestructive: false + ) + } + + // MARK: - Primary Key Operations + + private func generateModifyPrimaryKey(old: [String], new: [String]) throws -> SchemaStatement { + let tableQuoted = databaseType.quoteIdentifier(tableName) + let newColumnsQuoted = new.map { databaseType.quoteIdentifier($0) }.joined(separator: ", ") + + let sql: String + switch databaseType { + case .mysql, .mariadb: + sql = """ + ALTER TABLE \(tableQuoted) DROP PRIMARY KEY; + ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)) + """ + + case .postgresql: + // PostgreSQL requires knowing the constraint name + // For simplicity, assume it's tableName_pkey + let pkName = "\(tableName)_pkey" + sql = """ + ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(databaseType.quoteIdentifier(pkName)); + ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)) + """ + + case .sqlite: + // SQLite doesn't support modifying primary key + throw DatabaseError.unsupportedOperation + } + + return SchemaStatement( + sql: sql, + description: "Modify primary key from [\(old.joined(separator: ", "))] to [\(new.joined(separator: ", "))]", + isDestructive: true + ) + } +} diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift new file mode 100644 index 000000000..488333cc9 --- /dev/null +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -0,0 +1,608 @@ +// +// StructureChangeManager.swift +// TablePro +// +// Manager for tracking structure/schema changes with O(1) lookups. +// Mirrors DataChangeManager architecture for schema modifications. +// + +import Combine +import Foundation + +/// Manager for tracking and applying schema changes +@MainActor +final class StructureChangeManager: ObservableObject { + @Published private(set) var pendingChanges: [SchemaChangeIdentifier: SchemaChange] = [:] + @Published private(set) var validationErrors: [SchemaChangeIdentifier: String] = [:] + @Published var hasChanges: Bool = false + @Published var reloadVersion: Int = 0 // Incremented to trigger table reload + + // Track which rows changed since last reload for granular updates + private(set) var changedRowIndices: Set = [] + + // Current state (loaded from database) + private(set) var currentColumns: [EditableColumnDefinition] = [] + private(set) var currentIndexes: [EditableIndexDefinition] = [] + private(set) var currentForeignKeys: [EditableForeignKeyDefinition] = [] + private(set) var currentPrimaryKey: [String] = [] + + // Working state (includes uncommitted changes + placeholders) + @Published var workingColumns: [EditableColumnDefinition] = [] + @Published var workingIndexes: [EditableIndexDefinition] = [] + @Published var workingForeignKeys: [EditableForeignKeyDefinition] = [] + @Published var workingPrimaryKey: [String] = [] + + var tableName: String? + var databaseType: DatabaseType = .mysql + + // MARK: - Undo/Redo Support + + private let undoManager = StructureUndoManager() + private var visualStateCache: [Int: RowVisualState] = [:] + + var canUndo: Bool { undoManager.canUndo } + var canRedo: Bool { undoManager.canRedo } + + /// Consume and clear changed row indices (for granular table reloads) + func consumeChangedRowIndices() -> Set { + let indices = changedRowIndices + changedRowIndices.removeAll() + return indices + } + + // MARK: - Load Schema + + func loadSchema( + tableName: String, + columns: [ColumnInfo], + indexes: [IndexInfo], + foreignKeys: [ForeignKeyInfo], + primaryKey: [String], + databaseType: DatabaseType + ) { + self.tableName = tableName + self.databaseType = databaseType + + // Convert to definitions + self.currentColumns = columns.map { EditableColumnDefinition.from($0) } + self.currentIndexes = indexes.map { EditableIndexDefinition.from($0) } + self.currentForeignKeys = foreignKeys.map { EditableForeignKeyDefinition.from($0) } + self.currentPrimaryKey = primaryKey + + // Reset working state + resetWorkingState() + + // Clear changes + pendingChanges.removeAll() + validationErrors.removeAll() + hasChanges = false + } + + private func resetWorkingState() { + workingColumns = currentColumns + workingIndexes = currentIndexes + workingForeignKeys = currentForeignKeys + workingPrimaryKey = currentPrimaryKey + } + + // MARK: - Add New Rows + + func addNewColumn() { + let placeholder = EditableColumnDefinition.placeholder() + workingColumns.append(placeholder) + // Mark as pending change so hasChanges = true (even though placeholder is invalid) + // This allows Cmd+R to show warning and Cmd+S to trigger validation + pendingChanges[.column(placeholder.id)] = .addColumn(placeholder) + validate() + hasChanges = true + reloadVersion += 1 + rebuildVisualStateCache() + } + + func addNewIndex() { + let placeholder = EditableIndexDefinition.placeholder() + workingIndexes.append(placeholder) + pendingChanges[.index(placeholder.id)] = .addIndex(placeholder) + validate() + hasChanges = true + reloadVersion += 1 + rebuildVisualStateCache() + } + + func addNewForeignKey() { + let placeholder = EditableForeignKeyDefinition.placeholder() + workingForeignKeys.append(placeholder) + pendingChanges[.foreignKey(placeholder.id)] = .addForeignKey(placeholder) + validate() + hasChanges = true + reloadVersion += 1 + rebuildVisualStateCache() + } + + // MARK: - Paste Operations (public methods for adding copied items) + + func addColumn(_ column: EditableColumnDefinition) { + workingColumns.append(column) + pendingChanges[.column(column.id)] = .addColumn(column) + hasChanges = true + reloadVersion += 1 + rebuildVisualStateCache() + } + + func addIndex(_ index: EditableIndexDefinition) { + workingIndexes.append(index) + pendingChanges[.index(index.id)] = .addIndex(index) + hasChanges = true + reloadVersion += 1 + rebuildVisualStateCache() + } + + func addForeignKey(_ foreignKey: EditableForeignKeyDefinition) { + workingForeignKeys.append(foreignKey) + pendingChanges[.foreignKey(foreignKey.id)] = .addForeignKey(foreignKey) + hasChanges = true + reloadVersion += 1 + rebuildVisualStateCache() + } + + // MARK: - Column Operations + + func updateColumn(id: UUID, with newColumn: EditableColumnDefinition) { + // Find if it's existing or new + if let index = currentColumns.firstIndex(where: { $0.id == id }) { + let oldColumn = currentColumns[index] + if oldColumn != newColumn { + pendingChanges[.column(id)] = .modifyColumn(old: oldColumn, new: newColumn) + } else { + pendingChanges.removeValue(forKey: .column(id)) + } + } else { + // New column - allow saving even if invalid - let database validate + pendingChanges[.column(id)] = .addColumn(newColumn) + } + + // Update working state + if let index = workingColumns.firstIndex(where: { $0.id == id }) { + workingColumns[index] = newColumn + } + + validate() + hasChanges = !pendingChanges.isEmpty + reloadVersion += 1 // Trigger table reload to show visual changes + rebuildVisualStateCache() // Rebuild cache to reflect updated state + } + + func deleteColumn(id: UUID) { + // Check if it's an existing column (from database) or a new column (not yet saved) + if let column = currentColumns.first(where: { $0.id == id }) { + // Existing column - mark as deleted (keep in workingColumns for visual feedback) + pendingChanges[.column(id)] = .deleteColumn(column) + // Track changed row for reload + if let rowIndex = workingColumns.firstIndex(where: { $0.id == id }) { + changedRowIndices.insert(rowIndex) + } + } else { + // New column that hasn't been saved yet - undo the addition (remove from list) + if let rowIndex = workingColumns.firstIndex(where: { $0.id == id }) { + // Track ALL rows from this index onwards for reload (indices shift down) + for i in rowIndex.. 1 } + .map { $0.key } + + for duplicate in duplicateColumns { + if let column = workingColumns.first(where: { $0.name == duplicate }) { + validationErrors[.column(column.id)] = "Duplicate column name: \(duplicate)" + } + } + + // Validate all indexes have required fields + for index in workingIndexes { + if !index.isValid { + validationErrors[.index(index.id)] = "Index must have a name and at least one column" + } + } + + // Validate all foreign keys have required fields + for fk in workingForeignKeys { + if !fk.isValid { + validationErrors[.foreignKey(fk.id)] = "Foreign key must have name, columns, and referenced table" + } + } + + // Validate index names are unique + let indexNames = workingIndexes.filter { $0.isValid }.map { $0.name } + let duplicateIndexes = Dictionary(grouping: indexNames, by: { $0 }) + .filter { $0.value.count > 1 } + .map { $0.key } + + for duplicate in duplicateIndexes { + if let index = workingIndexes.first(where: { $0.name == duplicate }) { + validationErrors[.index(index.id)] = "Duplicate index name: \(duplicate)" + } + } + + // Validate index columns exist + for index in workingIndexes.filter({ $0.isValid }) { + for columnName in index.columns { + if !columnNames.contains(columnName) { + validationErrors[.index(index.id)] = "Index references non-existent column: \(columnName)" + } + } + } + + // Validate foreign key columns exist + for fk in workingForeignKeys.filter({ $0.isValid }) { + for columnName in fk.columns { + if !columnNames.contains(columnName) { + validationErrors[.foreignKey(fk.id)] = "Foreign key references non-existent column: \(columnName)" + } + } + } + + // Validate primary key columns exist + for columnName in workingPrimaryKey { + if !columnNames.contains(columnName) { + validationErrors[.primaryKey] = "Primary key references non-existent column: \(columnName)" + } + } + } + + // MARK: - State Management + + var canCommit: Bool { + hasChanges // Allow saving even with validation errors - let DB handle validation + } + + func discardChanges() { + pendingChanges.removeAll() + validationErrors.removeAll() + changedRowIndices.removeAll() // Clear changed row tracking + hasChanges = false + resetWorkingState() + reloadVersion += 1 + rebuildVisualStateCache() + } + + func getChangesArray() -> [SchemaChange] { + Array(pendingChanges.values) + } + + // MARK: - Undo/Redo Operations + + func undo() { + guard let action = undoManager.undo() else { return } + applyUndoAction(action, isRedo: false) + } + + func redo() { + guard let action = undoManager.redo() else { return } + applyUndoAction(action, isRedo: true) + } + + private func applyUndoAction(_ action: SchemaUndoAction, isRedo: Bool) { + switch action { + case .columnEdit(let id, let old, let new): + let column = isRedo ? new : old + if let index = workingColumns.firstIndex(where: { $0.id == id }) { + workingColumns[index] = column + if let currentIndex = currentColumns.firstIndex(where: { $0.id == id }) { + let current = currentColumns[currentIndex] + if column != current { + pendingChanges[.column(id)] = .modifyColumn(old: current, new: column) + } else { + pendingChanges.removeValue(forKey: .column(id)) + } + } + } + + case .columnAdd(let column): + if isRedo { + workingColumns.append(column) + pendingChanges[.column(column.id)] = .addColumn(column) + } else { + workingColumns.removeAll { $0.id == column.id } + pendingChanges.removeValue(forKey: .column(column.id)) + } + + case .columnDelete(let column): + if isRedo { + workingColumns.removeAll { $0.id == column.id } + pendingChanges[.column(column.id)] = .deleteColumn(column) + } else { + workingColumns.append(column) + pendingChanges.removeValue(forKey: .column(column.id)) + } + + case .indexEdit(let id, let old, let new): + let index = isRedo ? new : old + if let idx = workingIndexes.firstIndex(where: { $0.id == id }) { + workingIndexes[idx] = index + if let currentIdx = currentIndexes.firstIndex(where: { $0.id == id }) { + let current = currentIndexes[currentIdx] + if index != current { + pendingChanges[.index(id)] = .modifyIndex(old: current, new: index) + } else { + pendingChanges.removeValue(forKey: .index(id)) + } + } + } + + case .indexAdd(let index): + if isRedo { + workingIndexes.append(index) + pendingChanges[.index(index.id)] = .addIndex(index) + } else { + workingIndexes.removeAll { $0.id == index.id } + pendingChanges.removeValue(forKey: .index(index.id)) + } + + case .indexDelete(let index): + if isRedo { + workingIndexes.removeAll { $0.id == index.id } + pendingChanges[.index(index.id)] = .deleteIndex(index) + } else { + workingIndexes.append(index) + pendingChanges.removeValue(forKey: .index(index.id)) + } + + case .foreignKeyEdit(let id, let old, let new): + let fk = isRedo ? new : old + if let idx = workingForeignKeys.firstIndex(where: { $0.id == id }) { + workingForeignKeys[idx] = fk + if let currentIdx = currentForeignKeys.firstIndex(where: { $0.id == id }) { + let current = currentForeignKeys[currentIdx] + if fk != current { + pendingChanges[.foreignKey(id)] = .modifyForeignKey(old: current, new: fk) + } else { + pendingChanges.removeValue(forKey: .foreignKey(id)) + } + } + } + + case .foreignKeyAdd(let fk): + if isRedo { + workingForeignKeys.append(fk) + pendingChanges[.foreignKey(fk.id)] = .addForeignKey(fk) + } else { + workingForeignKeys.removeAll { $0.id == fk.id } + pendingChanges.removeValue(forKey: .foreignKey(fk.id)) + } + + case .foreignKeyDelete(let fk): + if isRedo { + workingForeignKeys.removeAll { $0.id == fk.id } + pendingChanges[.foreignKey(fk.id)] = .deleteForeignKey(fk) + } else { + workingForeignKeys.append(fk) + pendingChanges.removeValue(forKey: .foreignKey(fk.id)) + } + + case .primaryKeyChange(let old, let new): + workingPrimaryKey = isRedo ? new : old + if workingPrimaryKey != currentPrimaryKey { + pendingChanges[.primaryKey] = .modifyPrimaryKey(old: currentPrimaryKey, new: workingPrimaryKey) + } else { + pendingChanges.removeValue(forKey: .primaryKey) + } + } + + hasChanges = !pendingChanges.isEmpty + reloadVersion += 1 + rebuildVisualStateCache() + } + + // MARK: - Visual State Management + + func getVisualState(for row: Int, tab: StructureTab) -> RowVisualState { + // Check cache first + let cacheKey = row * 10 + tab.rawValue.hashValue + if let cached = visualStateCache[cacheKey] { + return cached + } + + let state: RowVisualState + + switch tab { + case .columns: + guard row < workingColumns.count else { return .empty } + let column = workingColumns[row] + let change = pendingChanges[.column(column.id)] + + let isDeleted = change?.isDelete ?? false + let isInserted = !currentColumns.contains(where: { $0.id == column.id }) + let isModified = change != nil && !isDeleted && !isInserted + + state = RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: isModified ? Set(0..<6) : [] + ) + + case .indexes: + guard row < workingIndexes.count else { return .empty } + let index = workingIndexes[row] + let change = pendingChanges[.index(index.id)] + + let isDeleted = change?.isDelete ?? false + let isInserted = !currentIndexes.contains(where: { $0.id == index.id }) + let isModified = change != nil && !isDeleted && !isInserted + + state = RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: isModified ? Set(0..<4) : [] + ) + + case .foreignKeys: + guard row < workingForeignKeys.count else { return .empty } + let fk = workingForeignKeys[row] + let change = pendingChanges[.foreignKey(fk.id)] + + let isDeleted = change?.isDelete ?? false + let isInserted = !currentForeignKeys.contains(where: { $0.id == fk.id }) + let isModified = change != nil && !isDeleted && !isInserted + + state = RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: isModified ? Set(0..<6) : [] + ) + + case .ddl: + state = .empty + } + + visualStateCache[cacheKey] = state + return state + } + + func rebuildVisualStateCache() { + visualStateCache.removeAll() + } +} diff --git a/TablePro/Core/SchemaTracking/StructureUndoManager.swift b/TablePro/Core/SchemaTracking/StructureUndoManager.swift new file mode 100644 index 000000000..6b9dd4133 --- /dev/null +++ b/TablePro/Core/SchemaTracking/StructureUndoManager.swift @@ -0,0 +1,79 @@ +// +// StructureUndoManager.swift +// TablePro +// +// Undo/redo stack for schema changes - mirrors DataChangeUndoManager pattern +// + +import Foundation + +/// Represents an action that can be undone in schema editing +enum SchemaUndoAction { + case columnEdit(id: UUID, old: EditableColumnDefinition, new: EditableColumnDefinition) + case columnAdd(column: EditableColumnDefinition) + case columnDelete(column: EditableColumnDefinition) + case indexEdit(id: UUID, old: EditableIndexDefinition, new: EditableIndexDefinition) + case indexAdd(index: EditableIndexDefinition) + case indexDelete(index: EditableIndexDefinition) + case foreignKeyEdit(id: UUID, old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) + case foreignKeyAdd(fk: EditableForeignKeyDefinition) + case foreignKeyDelete(fk: EditableForeignKeyDefinition) + case primaryKeyChange(old: [String], new: [String]) +} + +/// Manages undo/redo stack for schema changes +final class StructureUndoManager { + private var undoStack: [SchemaUndoAction] = [] + private var redoStack: [SchemaUndoAction] = [] + + private let maxStackSize = 100 + + // MARK: - Public API + + var canUndo: Bool { + !undoStack.isEmpty + } + + var canRedo: Bool { + !redoStack.isEmpty + } + + /// Push a new action onto the undo stack + func push(_ action: SchemaUndoAction) { + undoStack.append(action) + + // Limit stack size + if undoStack.count > maxStackSize { + undoStack.removeFirst() + } + + // Clear redo stack when new action is performed + redoStack.removeAll() + } + + /// Pop the last action from undo stack + func undo() -> SchemaUndoAction? { + guard let action = undoStack.popLast() else { + return nil + } + + redoStack.append(action) + return action + } + + /// Pop the last action from redo stack + func redo() -> SchemaUndoAction? { + guard let action = redoStack.popLast() else { + return nil + } + + undoStack.append(action) + return action + } + + /// Clear all stacks + func clearAll() { + undoStack.removeAll() + redoStack.removeAll() + } +} diff --git a/TablePro/Core/Services/QueryExecutionService.swift b/TablePro/Core/Services/QueryExecutionService.swift index b72d0c6ab..a0099ce2d 100644 --- a/TablePro/Core/Services/QueryExecutionService.swift +++ b/TablePro/Core/Services/QueryExecutionService.swift @@ -89,28 +89,18 @@ final class QueryExecutionService: ObservableObject { } } - // Deep copy all data to prevent C buffer retention issues - // result.rows is [[String?]] - raw arrays, not QueryResultRow - var safeRows: [QueryResultRow] = [] - for row in result.rows { - var safeValues: [String?] = [] - for val in row { - if let v = val { - safeValues.append(String(v)) - } else { - safeValues.append(nil) - } - } - safeRows.append(QueryResultRow(values: safeValues)) - } - - let safeResult = QueryExecutionResult( - columns: result.columns.map { String($0) }, - rows: safeRows, + // No need for deep copy - database drivers already return Swift-owned strings + // MariaDBConnection performs deep copying at the C library level (see lines 461-475) + // PostgreSQL and SQLite also return properly owned String objects + let rows = result.rows.map { QueryResultRow(values: $0) } + + let executionResult = QueryExecutionResult( + columns: result.columns, + rows: rows, executionTime: result.executionTime, - columnDefaults: columnDefaults.mapValues { $0.map { String($0) } }, + columnDefaults: columnDefaults, totalRowCount: totalRowCount, - tableName: tableName.map { String($0) }, + tableName: tableName, isEditable: isEditable ) @@ -118,7 +108,7 @@ final class QueryExecutionService: ObservableObject { guard !Task.isCancelled else { await MainActor.run { isExecuting = false - executionTime = safeResult.executionTime + executionTime = executionResult.executionTime } return } @@ -130,10 +120,10 @@ final class QueryExecutionService: ObservableObject { await MainActor.run { isExecuting = false - executionTime = safeResult.executionTime + executionTime = executionResult.executionTime } - await onSuccess(safeResult) + await onSuccess(executionResult) } catch { guard capturedGeneration == queryGeneration else { return } diff --git a/TablePro/Core/Utilities/AlertHelper.swift b/TablePro/Core/Utilities/AlertHelper.swift new file mode 100644 index 000000000..89b069821 --- /dev/null +++ b/TablePro/Core/Utilities/AlertHelper.swift @@ -0,0 +1,162 @@ +// +// AlertHelper.swift +// TablePro +// +// Created by TablePro on 1/19/26. +// + +import AppKit + +/// Centralized helper for creating and displaying NSAlert dialogs +/// Provides consistent styling and behavior across the application +@MainActor +final class AlertHelper { + + // MARK: - Destructive Confirmations + + /// Shows a destructive confirmation dialog (warning style, modal) + /// - Parameters: + /// - title: Alert title + /// - message: Detailed message + /// - confirmButton: Label for destructive action button (default: "OK") + /// - cancelButton: Label for cancel button (default: "Cancel") + /// - Returns: true if user confirmed, false if cancelled + static func confirmDestructive( + title: String, + message: String, + confirmButton: String = "OK", + cancelButton: String = "Cancel" + ) -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: confirmButton) + alert.addButton(withTitle: cancelButton) + + let response = alert.runModal() + return response == .alertFirstButtonReturn + } + + // MARK: - Critical Confirmations + + /// Shows a critical confirmation dialog (critical style, modal) + /// Used for dangerous operations like DROP, TRUNCATE, DELETE without WHERE + /// - Parameters: + /// - title: Alert title + /// - message: Detailed message + /// - confirmButton: Label for dangerous action button (default: "Execute") + /// - cancelButton: Label for cancel button (default: "Cancel") + /// - Returns: true if user confirmed, false if cancelled + static func confirmCritical( + title: String, + message: String, + confirmButton: String = "Execute", + cancelButton: String = "Cancel" + ) -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .critical + alert.addButton(withTitle: confirmButton) + alert.addButton(withTitle: cancelButton) + + let response = alert.runModal() + return response == .alertFirstButtonReturn + } + + // MARK: - Three-Way Confirmations + + /// Shows a three-option confirmation dialog + /// - Parameters: + /// - title: Alert title + /// - message: Detailed message + /// - first: Label for first button + /// - second: Label for second button + /// - third: Label for third button + /// - Returns: 0 for first button, 1 for second, 2 for third + static func confirmThreeWay( + title: String, + message: String, + first: String, + second: String, + third: String + ) -> Int { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: first) + alert.addButton(withTitle: second) + alert.addButton(withTitle: third) + + let response = alert.runModal() + + switch response { + case .alertFirstButtonReturn: + return 0 + case .alertSecondButtonReturn: + return 1 + case .alertThirdButtonReturn: + return 2 + default: + return 2 // Default to third option (usually cancel) + } + } + + // MARK: - Error Sheets + + /// Shows an error message as a non-blocking sheet + /// - Parameters: + /// - title: Error title + /// - message: Error details + /// - window: Parent window to attach sheet to (optional, falls back to modal) + static func showErrorSheet( + title: String, + message: String, + window: NSWindow? + ) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .critical + alert.addButton(withTitle: "OK") + + if let window = window { + alert.beginSheetModal(for: window) { _ in + // Sheet dismissed, no action needed + } + } else { + // Fallback to modal if no window available + alert.runModal() + } + } + + // MARK: - Info Sheets + + /// Shows an informational message as a non-blocking sheet + /// - Parameters: + /// - title: Info title + /// - message: Info details + /// - window: Parent window to attach sheet to (optional, falls back to modal) + static func showInfoSheet( + title: String, + message: String, + window: NSWindow? + ) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + + if let window = window { + alert.beginSheetModal(for: window) { _ in + // Sheet dismissed, no action needed + } + } else { + // Fallback to modal if no window available + alert.runModal() + } + } +} diff --git a/TablePro/Models/AppSettings.swift b/TablePro/Models/AppSettings.swift index b2e2ca46f..82e5840ba 100644 --- a/TablePro/Models/AppSettings.swift +++ b/TablePro/Models/AppSettings.swift @@ -29,15 +29,9 @@ enum StartupBehavior: String, Codable, CaseIterable, Identifiable { /// 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 + startupBehavior: .showWelcome ) } diff --git a/TablePro/Models/Schema/ColumnDefinition.swift b/TablePro/Models/Schema/ColumnDefinition.swift new file mode 100644 index 000000000..f9e334431 --- /dev/null +++ b/TablePro/Models/Schema/ColumnDefinition.swift @@ -0,0 +1,85 @@ +// +// ColumnDefinition.swift +// TablePro +// +// Represents a column definition for schema editing. +// + +import Foundation + +/// Column definition for schema modification (editable structure tab) +struct EditableColumnDefinition: Hashable, Codable, Identifiable { + let id: UUID + var name: String + var dataType: String + var isNullable: Bool + var defaultValue: String? + var autoIncrement: Bool + var unsigned: Bool // MySQL only + var comment: String? + var collation: String? + var onUpdate: String? // MySQL timestamp columns + var charset: String? + var extra: String? + + var isPrimaryKey: Bool + + /// Create a placeholder column for adding new columns + static func placeholder() -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), + name: "", + dataType: "", + isNullable: true, + defaultValue: nil, + autoIncrement: false, + unsigned: false, + comment: nil, + collation: nil, + onUpdate: nil, + charset: nil, + extra: nil, + isPrimaryKey: false + ) + } + + /// Check if this definition is valid (not a placeholder) + var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && + !dataType.trimmingCharacters(in: .whitespaces).isEmpty + } + + /// Create from existing ColumnInfo + static func from(_ columnInfo: ColumnInfo) -> EditableColumnDefinition { + EditableColumnDefinition( + id: columnInfo.id, + name: columnInfo.name, + dataType: columnInfo.dataType, + isNullable: columnInfo.isNullable, + defaultValue: columnInfo.defaultValue, + autoIncrement: columnInfo.extra?.contains("auto_increment") ?? false, + unsigned: columnInfo.dataType.contains("unsigned"), + comment: columnInfo.comment, + collation: columnInfo.collation, + onUpdate: nil, + charset: columnInfo.charset, + extra: columnInfo.extra, + isPrimaryKey: columnInfo.isPrimaryKey + ) + } + + /// Convert back to ColumnInfo + func toColumnInfo() -> ColumnInfo { + ColumnInfo( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPrimaryKey, + defaultValue: defaultValue, + extra: extra, + charset: charset, + collation: collation, + comment: comment + ) + } +} diff --git a/TablePro/Models/Schema/ForeignKeyDefinition.swift b/TablePro/Models/Schema/ForeignKeyDefinition.swift new file mode 100644 index 000000000..00b9661e0 --- /dev/null +++ b/TablePro/Models/Schema/ForeignKeyDefinition.swift @@ -0,0 +1,78 @@ +// +// ForeignKeyDefinition.swift +// TablePro +// +// Represents a foreign key definition for schema editing. +// + +import Foundation + +/// Foreign key definition for schema modification (editable structure tab) +struct EditableForeignKeyDefinition: Hashable, Codable, Identifiable { + let id: UUID + var name: String + var columns: [String] + var referencedTable: String + var referencedColumns: [String] + var onDelete: ReferentialAction + var onUpdate: ReferentialAction + + enum ReferentialAction: String, Codable, CaseIterable { + case noAction = "NO ACTION" + case restrict = "RESTRICT" + case cascade = "CASCADE" + case setNull = "SET NULL" + case setDefault = "SET DEFAULT" + } + + /// Create a placeholder foreign key for adding new FKs + static func placeholder() -> EditableForeignKeyDefinition { + EditableForeignKeyDefinition( + id: UUID(), + name: "", + columns: [], + referencedTable: "", + referencedColumns: [], + onDelete: .noAction, + onUpdate: .noAction + ) + } + + /// Check if this definition is valid (not a placeholder) + var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && + !columns.isEmpty && + !referencedTable.trimmingCharacters(in: .whitespaces).isEmpty && + !referencedColumns.isEmpty + } + + /// Create from existing ForeignKeyInfo + static func from(_ fkInfo: ForeignKeyInfo) -> EditableForeignKeyDefinition { + EditableForeignKeyDefinition( + id: fkInfo.id, + name: fkInfo.name, + columns: [fkInfo.column], + referencedTable: fkInfo.referencedTable, + referencedColumns: [fkInfo.referencedColumn], + onDelete: ReferentialAction(rawValue: fkInfo.onDelete.uppercased()) ?? .noAction, + onUpdate: ReferentialAction(rawValue: fkInfo.onUpdate.uppercased()) ?? .noAction + ) + } + + /// Convert back to ForeignKeyInfo (single column only) + func toForeignKeyInfo() -> ForeignKeyInfo? { + guard let column = columns.first, + let referencedColumn = referencedColumns.first else { + return nil + } + + return ForeignKeyInfo( + name: name, + column: column, + referencedTable: referencedTable, + referencedColumn: referencedColumn, + onDelete: onDelete.rawValue, + onUpdate: onUpdate.rawValue + ) + } +} diff --git a/TablePro/Models/Schema/IndexDefinition.swift b/TablePro/Models/Schema/IndexDefinition.swift new file mode 100644 index 000000000..284bd54f4 --- /dev/null +++ b/TablePro/Models/Schema/IndexDefinition.swift @@ -0,0 +1,72 @@ +// +// IndexDefinition.swift +// TablePro +// +// Represents an index definition for schema editing. +// + +import Foundation + +/// Index definition for schema modification (editable structure tab) +struct EditableIndexDefinition: Hashable, Codable, Identifiable { + let id: UUID + var name: String + var columns: [String] + var type: IndexType + var isUnique: Bool + var isPrimary: Bool + var comment: String? + + enum IndexType: String, Codable, CaseIterable { + case btree = "BTREE" + case hash = "HASH" + case fulltext = "FULLTEXT" + case spatial = "SPATIAL" // MySQL only + case gin = "GIN" // PostgreSQL only + case gist = "GIST" // PostgreSQL only + case brin = "BRIN" // PostgreSQL only + } + + /// Create a placeholder index for adding new indexes + static func placeholder() -> EditableIndexDefinition { + EditableIndexDefinition( + id: UUID(), + name: "", + columns: [], + type: .btree, + isUnique: false, + isPrimary: false, + comment: nil + ) + } + + /// Check if this definition is valid (not a placeholder) + var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && + !columns.isEmpty + } + + /// Create from existing IndexInfo + static func from(_ indexInfo: IndexInfo) -> EditableIndexDefinition { + EditableIndexDefinition( + id: indexInfo.id, + name: indexInfo.name, + columns: indexInfo.columns, + type: IndexType(rawValue: indexInfo.type.uppercased()) ?? .btree, + isUnique: indexInfo.isUnique, + isPrimary: indexInfo.isPrimary, + comment: nil + ) + } + + /// Convert back to IndexInfo + func toIndexInfo() -> IndexInfo { + IndexInfo( + name: name, + columns: columns, + isUnique: isUnique, + isPrimary: isPrimary, + type: type.rawValue + ) + } +} diff --git a/TablePro/Models/Schema/SchemaChange+Undo.swift b/TablePro/Models/Schema/SchemaChange+Undo.swift new file mode 100644 index 000000000..bda2d13a1 --- /dev/null +++ b/TablePro/Models/Schema/SchemaChange+Undo.swift @@ -0,0 +1,43 @@ +// +// SchemaChange+Undo.swift +// TablePro +// +// Extension to SchemaChange for undo/redo support +// + +import Foundation + +extension SchemaChange { + /// Returns the inverse of this change for undo operations + var inverse: SchemaChange? { + switch self { + // Column operations + case .addColumn(let column): + return .deleteColumn(column) + case .modifyColumn(let old, let new): + return .modifyColumn(old: new, new: old) + case .deleteColumn(let column): + return .addColumn(column) + + // Index operations + case .addIndex(let index): + return .deleteIndex(index) + case .modifyIndex(let old, let new): + return .modifyIndex(old: new, new: old) + case .deleteIndex(let index): + return .addIndex(index) + + // Foreign key operations + case .addForeignKey(let fk): + return .deleteForeignKey(fk) + case .modifyForeignKey(let old, let new): + return .modifyForeignKey(old: new, new: old) + case .deleteForeignKey(let fk): + return .addForeignKey(fk) + + // Primary key operations + case .modifyPrimaryKey(let old, let new): + return .modifyPrimaryKey(old: new, new: old) + } + } +} diff --git a/TablePro/Models/Schema/SchemaChange.swift b/TablePro/Models/Schema/SchemaChange.swift new file mode 100644 index 000000000..d11f1cc23 --- /dev/null +++ b/TablePro/Models/Schema/SchemaChange.swift @@ -0,0 +1,97 @@ +// +// SchemaChange.swift +// TablePro +// +// Schema change operations for editable structure tab. +// Represents ADD/MODIFY/DELETE operations on columns, indexes, and foreign keys. +// + +import Foundation + +/// Enum representing all possible schema change types +enum SchemaChange: Hashable, Equatable { + // Column operations + case addColumn(EditableColumnDefinition) + case modifyColumn(old: EditableColumnDefinition, new: EditableColumnDefinition) + case deleteColumn(EditableColumnDefinition) + + // Index operations + case addIndex(EditableIndexDefinition) + case modifyIndex(old: EditableIndexDefinition, new: EditableIndexDefinition) + case deleteIndex(EditableIndexDefinition) + + // Foreign key operations + case addForeignKey(EditableForeignKeyDefinition) + case modifyForeignKey(old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) + case deleteForeignKey(EditableForeignKeyDefinition) + + // Primary key operations + case modifyPrimaryKey(old: [String], new: [String]) + + /// Whether this change is a deletion + var isDelete: Bool { + switch self { + case .deleteColumn, .deleteIndex, .deleteForeignKey: + return true + default: + return false + } + } + + /// Whether this change is destructive (may cause data loss) + var isDestructive: Bool { + switch self { + case .deleteColumn, .modifyColumn, .deleteIndex, .deleteForeignKey, .modifyPrimaryKey: + return true + default: + return false + } + } + + /// Whether this change requires data migration + var requiresDataMigration: Bool { + switch self { + case .modifyColumn(let old, let new): + // Type changes or making nullable -> not nullable requires data check + return old.dataType != new.dataType || (old.isNullable && !new.isNullable) + case .deleteColumn, .modifyPrimaryKey: + return true + default: + return false + } + } + + /// Human-readable description of the change + var description: String { + switch self { + case .addColumn(let col): + return "Add column '\(col.name)'" + case .modifyColumn(let old, let new): + return "Modify column '\(old.name)' to '\(new.name)'" + case .deleteColumn(let col): + return "Delete column '\(col.name)'" + case .addIndex(let idx): + return "Add index '\(idx.name)'" + case .modifyIndex(let old, let new): + return "Modify index '\(old.name)' to '\(new.name)'" + case .deleteIndex(let idx): + return "Delete index '\(idx.name)'" + case .addForeignKey(let fk): + return "Add foreign key '\(fk.name)'" + case .modifyForeignKey(let old, let new): + return "Modify foreign key '\(old.name)' to '\(new.name)'" + case .deleteForeignKey(let fk): + return "Delete foreign key '\(fk.name)'" + case .modifyPrimaryKey(let old, let new): + return "Change primary key from [\(old.joined(separator: ", "))] to [\(new.joined(separator: ", "))]" + } + } +} + +/// Identifier for schema changes (used for tracking pending changes) +enum SchemaChangeIdentifier: Hashable { + case column(UUID) + case index(UUID) + case foreignKey(UUID) + case primaryKey +} diff --git a/TablePro/Models/Schema/StructureTab.swift b/TablePro/Models/Schema/StructureTab.swift new file mode 100644 index 000000000..91a7bac90 --- /dev/null +++ b/TablePro/Models/Schema/StructureTab.swift @@ -0,0 +1,16 @@ +// +// StructureTab.swift +// TablePro +// +// Tab selection for structure view +// + +import Foundation + +/// Tab selection for structure view +enum StructureTab: String, CaseIterable, Hashable { + case columns = "Columns" + case indexes = "Indexes" + case foreignKeys = "Foreign Keys" + case ddl = "DDL" +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index acc50d524..a13afa44d 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -95,7 +95,8 @@ struct PasteboardCommands: Commands { .keyboardShortcut("a", modifiers: .command) Button("Clear Selection") { - NotificationCenter.default.post(name: .clearSelection, object: nil) + // Use responder chain - cancelOperation is the standard ESC action + NSApp.sendAction(#selector(NSResponder.cancelOperation(_:)), to: nil, from: nil) } .keyboardShortcut(.escape, modifiers: []) } @@ -151,7 +152,7 @@ struct TableProApp: App { .environmentObject(appState) .background(OpenWindowHandler()) .tint(accentTint) - .escapeKeySystem() // Install global ESC key handling + // ESC key handling now uses native .onExitCommand and cancelOperation(_:) } .windowStyle(.automatic) .defaultSize(width: 1_200, height: 800) @@ -163,6 +164,22 @@ struct TableProApp: App { } .commands { + // MARK: - Keyboard Shortcut Architecture + // + // This app uses a hybrid approach for keyboard shortcuts: + // + // 1. **Responder Chain** (Apple Standard): + // - Standard actions: copy, paste, undo, delete, cancelOperation (ESC) + // - Context-aware: First responder handles action appropriately + // - Used for: Edit menu operations, ESC key + // + // 2. **NotificationCenter** (For specific use cases): + // - Data operations needing batched undo: addNewRow, deleteSelectedRows, saveChanges + // - UI state broadcasts: View menu toggles (multiple listeners) + // - Cross-layer coordination: File menu operations (window management) + // + // Migration from custom ESC system → native cancelOperation(_:) completed in Phase 4 + // File menu CommandGroup(replacing: .newItem) { Button("New Connection...") { @@ -293,7 +310,9 @@ struct TableProApp: App { .disabled(!appState.hasTableSelection) } - // View menu + // View menu - using NotificationCenter for UI state broadcasts + // Note: These are UI state changes that multiple views need to know about, + // so NotificationCenter is the appropriate pattern here (not responder chain) CommandGroup(after: .sidebar) { Button("Toggle Table Browser") { NotificationCenter.default.post(name: .toggleTableBrowser, object: nil) @@ -333,6 +352,7 @@ extension Notification.Name { static let closeCurrentTab = Notification.Name("closeCurrentTab") static let deselectConnection = Notification.Name("deselectConnection") static let saveChanges = Notification.Name("saveChanges") + static let saveStructureChanges = Notification.Name("saveStructureChanges") static let refreshData = Notification.Name("refreshData") static let refreshAll = Notification.Name("refreshAll") static let toggleTableBrowser = Notification.Name("toggleTableBrowser") diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index fcfc66a95..3381c1f01 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -401,9 +401,8 @@ struct ConnectionFormView: View { .padding(.vertical, 12) } .background(Color(nsColor: .windowBackgroundColor)) - .escapeKeyHandler(priority: .view) { + .onExitCommand { dismissWindow(id: "connection-form") - return .handled } } diff --git a/TablePro/Views/Connection/ConnectionTagEditor.swift b/TablePro/Views/Connection/ConnectionTagEditor.swift index 25840be26..e4a36ae5b 100644 --- a/TablePro/Views/Connection/ConnectionTagEditor.swift +++ b/TablePro/Views/Connection/ConnectionTagEditor.swift @@ -196,7 +196,9 @@ private struct CreateTagSheet: View { } .padding(20) .frame(width: 300) - .escapeKeyDismiss(priority: .sheet) + .onExitCommand { + dismiss() + } } } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 4ad26ce17..b5eaf814f 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -81,10 +81,9 @@ struct DatabaseSwitcherSheet: View { await viewModel.refreshDatabases() } } - .escapeKeyHandler(priority: .sheet) { - // Nested sheet has higher priority (.nestedSheet), so this only runs when no nested sheets are open + .onExitCommand { + // SwiftUI handles sheet priority automatically - no nested sheets take precedence dismiss() - return .handled } .onKeyPress(.return) { openSelectedDatabase() @@ -357,18 +356,21 @@ struct DatabaseSwitcherSheet: View { let allDbs = viewModel.recentDatabaseMetadata + viewModel.allDatabases guard !allDbs.isEmpty else { return } - if let selected = viewModel.selectedDatabase, - let currentIndex = allDbs.firstIndex(where: { $0.name == selected }) - { - if up { - let newIndex = max(0, currentIndex - 1) - viewModel.selectedDatabase = allDbs[newIndex].name + // Defer state update to avoid "Publishing changes from within view updates" warning + Task { @MainActor in + if let selected = viewModel.selectedDatabase, + let currentIndex = allDbs.firstIndex(where: { $0.name == selected }) + { + if up { + let newIndex = max(0, currentIndex - 1) + viewModel.selectedDatabase = allDbs[newIndex].name + } else { + let newIndex = min(allDbs.count - 1, currentIndex + 1) + viewModel.selectedDatabase = allDbs[newIndex].name + } } else { - let newIndex = min(allDbs.count - 1, currentIndex + 1) - viewModel.selectedDatabase = allDbs[newIndex].name + viewModel.selectedDatabase = up ? allDbs.last?.name : allDbs.first?.name } - } else { - viewModel.selectedDatabase = up ? allDbs.last?.name : allDbs.first?.name } } diff --git a/TablePro/Views/Editor/BookmarkEditorController.swift b/TablePro/Views/Editor/BookmarkEditorController.swift index 4f2bb7140..e0da04c80 100644 --- a/TablePro/Views/Editor/BookmarkEditorController.swift +++ b/TablePro/Views/Editor/BookmarkEditorController.swift @@ -229,12 +229,11 @@ final class BookmarkEditorController: NSViewController { let name = nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { - let alert = NSAlert() - alert.messageText = "Name Required" - alert.informativeText = "Please enter a name for this bookmark." - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.beginSheetModal(for: view.window!) + AlertHelper.showInfoSheet( + title: "Name Required", + message: "Please enter a name for this bookmark.", + window: view.window + ) return } diff --git a/TablePro/Views/Editor/BookmarkEditorView.swift b/TablePro/Views/Editor/BookmarkEditorView.swift index bee62c322..e68a5485a 100644 --- a/TablePro/Views/Editor/BookmarkEditorView.swift +++ b/TablePro/Views/Editor/BookmarkEditorView.swift @@ -5,6 +5,7 @@ // Native SwiftUI form for creating/editing bookmarks // +import AppKit import SwiftUI struct BookmarkEditorView: View { @@ -21,7 +22,6 @@ struct BookmarkEditorView: View { @State private var name: String @State private var tags: String @State private var notes: String - @State private var showingValidationAlert = false @FocusState private var focusedField: Field? @@ -127,11 +127,6 @@ struct BookmarkEditorView: View { .onAppear { focusedField = .name } - .alert("Name Required", isPresented: $showingValidationAlert) { - Button("OK", role: .cancel) { } - } message: { - Text("Please enter a name for this bookmark.") - } } // MARK: - Actions @@ -140,7 +135,11 @@ struct BookmarkEditorView: View { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty else { - showingValidationAlert = true + AlertHelper.showInfoSheet( + title: "Name Required", + message: "Please enter a name for this bookmark.", + window: nil + ) return } diff --git a/TablePro/Views/Editor/CreateTableView.swift b/TablePro/Views/Editor/CreateTableView.swift index 575a33959..7e8085c72 100644 --- a/TablePro/Views/Editor/CreateTableView.swift +++ b/TablePro/Views/Editor/CreateTableView.swift @@ -69,12 +69,10 @@ struct CreateTableView: View { } .animation(.easeInOut(duration: DesignConstants.AnimationDuration.smooth), value: showDetailPanel) .background(Color(nsColor: .textBackgroundColor)) - .escapeKeyHandler(priority: .view) { + .onExitCommand { if showDetailPanel { showDetailPanel = false - return .handled } - return .ignored } } diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index aa68e75ef..cb5ccffe5 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -393,15 +393,20 @@ final class EditorTextView: NSTextView { if let handler = onKeyEvent, handler(event) { return } + + guard let key = KeyCode(rawValue: event.keyCode) else { + super.keyDown(with: event) + return + } // Cmd+Enter to execute query - if event.modifierFlags.contains(.command) && event.keyCode == 36 { + if event.modifierFlags.contains(.command) && key == .return { onExecute?() return } // Ctrl+Space to trigger manual completion - if event.modifierFlags.contains(.control) && event.keyCode == 49 { + if event.modifierFlags.contains(.control) && key == .space { onManualCompletion?() return } @@ -434,7 +439,7 @@ final class EditorTextView: NSTextView { } // Handle backspace to delete matching pairs - if event.keyCode == 51 { // Backspace + if key == .delete { if shouldDeletePair() { deletePair() return diff --git a/TablePro/Views/Editor/TemplateSheets.swift b/TablePro/Views/Editor/TemplateSheets.swift index e6ac483c3..f92a5750a 100644 --- a/TablePro/Views/Editor/TemplateSheets.swift +++ b/TablePro/Views/Editor/TemplateSheets.swift @@ -36,9 +36,8 @@ struct SaveTemplateSheet: View { .padding(DesignConstants.Spacing.md) .fixedSize(horizontal: false, vertical: true) .frame(width: 350) - .escapeKeyHandler(priority: .sheet) { + .onExitCommand { onCancel() - return .handled } } } @@ -111,9 +110,8 @@ struct LoadTemplateSheet: View { .padding(DesignConstants.Spacing.md) .fixedSize(horizontal: false, vertical: true) .frame(width: 400) - .escapeKeyHandler(priority: .sheet) { + .onExitCommand { onCancel() - return .handled } } } @@ -158,9 +156,8 @@ struct ImportDDLSheet: View { .padding(DesignConstants.Spacing.md) .fixedSize(horizontal: false, vertical: true) .frame(width: 500) - .escapeKeyHandler(priority: .sheet) { + .onExitCommand { onCancel() - return .handled } } } @@ -219,9 +216,8 @@ struct DuplicateTableSheet: View { .padding(DesignConstants.Spacing.md) .fixedSize(horizontal: false, vertical: true) .frame(width: 400) - .escapeKeyHandler(priority: .sheet) { + .onExitCommand { onCancel() - return .handled } } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 7aa1581a1..e0b18b485 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -23,8 +23,6 @@ struct ExportDialog: View { @State private var databaseItems: [ExportDatabaseItem] = [] @State private var isLoading = true @State private var isExporting = false - @State private var showError = false - @State private var errorMessage = "" @State private var showProgressDialog = false @State private var showSuccessDialog = false @State private var exportedFileURL: URL? @@ -63,21 +61,14 @@ struct ExportDialog: View { } .frame(width: dialogWidth) .background(Color(nsColor: .windowBackgroundColor)) - .escapeKeyHandler(priority: .sheet) { + .onExitCommand { if !isExporting { isPresented = false - return .handled } - return .ignored } .task { await loadDatabaseItems() } - .alert("Export Error", isPresented: $showError) { - Button("OK") { } - } message: { - Text(errorMessage) - } .sheet(isPresented: $showProgressDialog) { ExportProgressView( tableName: exportServiceState.currentTable, @@ -387,8 +378,11 @@ struct ExportDialog: View { private func loadDatabaseItems() async { guard let driver = DatabaseManager.shared.activeDriver else { isLoading = false - errorMessage = "Not connected to database" - showError = true + AlertHelper.showErrorSheet( + title: "Export Error", + message: "Not connected to database", + window: nil + ) return } @@ -483,8 +477,11 @@ struct ExportDialog: View { } } catch { isLoading = false - errorMessage = "Failed to load databases: \(error.localizedDescription)" - showError = true + AlertHelper.showErrorSheet( + title: "Export Error", + message: "Failed to load databases: \(error.localizedDescription)", + window: nil + ) } } @@ -575,8 +572,11 @@ struct ExportDialog: View { @MainActor private func startExport(to url: URL) async { guard let driver = DatabaseManager.shared.activeDriver else { - errorMessage = "Not connected to database" - showError = true + AlertHelper.showErrorSheet( + title: "Export Error", + message: "Not connected to database", + window: nil + ) return } @@ -612,8 +612,11 @@ struct ExportDialog: View { } catch { showProgressDialog = false isExporting = false - errorMessage = error.localizedDescription - showError = true + AlertHelper.showErrorSheet( + title: "Export Error", + message: error.localizedDescription, + window: nil + ) } } diff --git a/TablePro/Views/Filter/SQLPreviewSheet.swift b/TablePro/Views/Filter/SQLPreviewSheet.swift index 0a9024c86..59cb729e9 100644 --- a/TablePro/Views/Filter/SQLPreviewSheet.swift +++ b/TablePro/Views/Filter/SQLPreviewSheet.swift @@ -69,7 +69,9 @@ struct SQLPreviewSheet: View { } .padding(16) .frame(width: 480, height: 300) - .escapeKeyDismiss(priority: .sheet) + .onExitCommand { + dismiss() + } } private func copyToClipboard() { diff --git a/TablePro/Views/History/HistoryListViewController.swift b/TablePro/Views/History/HistoryListViewController.swift index 7f90502ac..72409e16d 100644 --- a/TablePro/Views/History/HistoryListViewController.swift +++ b/TablePro/Views/History/HistoryListViewController.swift @@ -397,14 +397,14 @@ final class HistoryListViewController: NSViewController, NSMenuItemValidation { guard count > 0 else { return } - let alert = NSAlert() - alert.messageText = "Clear All \(displayMode == .history ? "History" : "Bookmarks")?" - alert.informativeText = "This will permanently delete \(count) \(itemName). This action cannot be undone." - alert.alertStyle = .warning - alert.addButton(withTitle: "Clear All") - alert.addButton(withTitle: "Cancel") - - if alert.runModal() == .alertFirstButtonReturn { + let confirmed = AlertHelper.confirmDestructive( + title: "Clear All \(displayMode == .history ? "History" : "Bookmarks")?", + message: "This will permanently delete \(count) \(itemName). This action cannot be undone.", + confirmButton: "Clear All", + cancelButton: "Cancel" + ) + + if confirmed { _ = dataProvider.clearAll() } } @@ -699,7 +699,8 @@ extension HistoryListViewController: HistoryTableViewKeyboardDelegate { editBookmarkForSelectedRow() } - func handleEscapeKey() { + /// Handle ESC key - clear search or selection (responder chain method) + @objc override func cancelOperation(_ sender: Any?) { if !searchText.isEmpty { searchField.stringValue = "" searchText = "" diff --git a/TablePro/Views/History/HistoryTableView.swift b/TablePro/Views/History/HistoryTableView.swift index 81f2ce41e..2a536c6fb 100644 --- a/TablePro/Views/History/HistoryTableView.swift +++ b/TablePro/Views/History/HistoryTableView.swift @@ -14,10 +14,13 @@ protocol HistoryTableViewKeyboardDelegate: AnyObject { func handleReturnKey() func handleSpaceKey() func handleEditBookmark() - func handleEscapeKey() func deleteSelectedRow() func copy(_ sender: Any?) func validateMenuItem(_ menuItem: NSMenuItem) -> Bool + + /// Handle ESC key - clear search or selection + /// Note: This is called from cancelOperation(_:) responder method + func cancelOperation(_ sender: Any?) } /// Custom table view for keyboard delegation in history panel @@ -57,10 +60,15 @@ final class HistoryTableView: NSTableView, NSMenuItemValidation { // MARK: - Keyboard Event Handling override func keyDown(with event: NSEvent) { + guard let key = KeyCode(rawValue: event.keyCode) else { + super.keyDown(with: event) + return + } + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) // Return/Enter key - open in new tab - if (event.keyCode == 36 || event.keyCode == 76) && modifiers.isEmpty { + if (key == .return || key == .enter) && modifiers.isEmpty { if selectedRow >= 0 { keyboardDelegate?.handleReturnKey() return @@ -68,7 +76,7 @@ final class HistoryTableView: NSTableView, NSMenuItemValidation { } // Space key - toggle preview - if event.keyCode == 49 && modifiers.isEmpty { + if key == .space && modifiers.isEmpty { if selectedRow >= 0 { keyboardDelegate?.handleSpaceKey() return @@ -76,19 +84,19 @@ final class HistoryTableView: NSTableView, NSMenuItemValidation { } // Cmd+E - edit bookmark - if event.keyCode == 14 && modifiers == .command { + if key == .e && modifiers == .command { keyboardDelegate?.handleEditBookmark() return } - // Escape key - clear search or selection - if event.keyCode == 53 && modifiers.isEmpty { - keyboardDelegate?.handleEscapeKey() + // Escape key - delegated to cancelOperation(_:) responder method + if key == .escape && modifiers.isEmpty { + cancelOperation(nil) return } // Delete key (bare, not Cmd+Delete which goes through menu) - if event.keyCode == 51 && modifiers.isEmpty { + if key == .delete && modifiers.isEmpty { if selectedRow >= 0 { keyboardDelegate?.handleDeleteKey() return @@ -97,4 +105,11 @@ final class HistoryTableView: NSTableView, NSMenuItemValidation { super.keyDown(with: event) } + + // MARK: - Standard Responder Actions + + /// Handle ESC key - delegate to owner for clear search/selection logic + @objc override func cancelOperation(_ sender: Any?) { + keyboardDelegate?.cancelOperation(sender) + } } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index e225141de..6228c771a 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -68,12 +68,10 @@ struct ImportDialog: View { footerView } .background(Color(nsColor: .windowBackgroundColor)) - .escapeKeyHandler(priority: .sheet) { + .onExitCommand { if !importServiceState.isImporting { isPresented = false - return .handled } - return .ignored } .task { // Load initial file if provided diff --git a/TablePro/Views/Main/Child/MainContentAlerts.swift b/TablePro/Views/Main/Child/MainContentAlerts.swift deleted file mode 100644 index c4db8e16c..000000000 --- a/TablePro/Views/Main/Child/MainContentAlerts.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// MainContentAlerts.swift -// TablePro -// -// ViewModifier for MainContentView alerts and sheets. -// Extracts alert/sheet logic from main view for cleaner code. -// - -import SwiftUI - -/// ViewModifier handling all alerts and sheets for MainContentView -struct MainContentAlerts: ViewModifier { - // MARK: - Dependencies - - @ObservedObject var coordinator: MainContentCoordinator - let connection: DatabaseConnection - - // MARK: - Bindings - - @Binding var pendingTruncates: Set - @Binding var pendingDeletes: Set - let tables: [TableInfo] - let selectedTables: Set - - // MARK: - Environment - - @EnvironmentObject private var appState: AppState - - // MARK: - Body - - func body(content: Content) -> some View { - content - .alert("Discard Unsaved Changes?", isPresented: showDiscardAlert) { - Button("Cancel", role: .cancel) {} - Button("Discard", role: .destructive) { - coordinator.handleDiscard( - pendingTruncates: &pendingTruncates, - pendingDeletes: &pendingDeletes - ) - } - } message: { - Text(discardAlertMessage) - } - - .sheet(isPresented: $coordinator.showDatabaseSwitcher) { - DatabaseSwitcherSheet( - isPresented: $coordinator.showDatabaseSwitcher, - currentDatabase: connection.database.isEmpty ? nil : connection.database, - databaseType: connection.type, - connectionId: connection.id - ) { database in - coordinator.switchToDatabase(database) - } - } - - .sheet(isPresented: $coordinator.showExportDialog) { - ExportDialog( - isPresented: $coordinator.showExportDialog, - connection: connection, - preselectedTables: Set(selectedTables.map { $0.name }) - ) - } - - .sheet(isPresented: $coordinator.showImportDialog) { - ImportDialog( - isPresented: $coordinator.showImportDialog, - connection: connection, - initialFileURL: coordinator.importFileURL - ) - } - .onChange(of: coordinator.showImportDialog) { _, isPresented in - // Clear the file URL when dialog is dismissed - if !isPresented { - 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 }, - set: { if !$0 { coordinator.pendingDiscardAction = nil } } - ) - } - - private var discardAlertMessage: String { - guard let action = coordinator.pendingDiscardAction else { return "" } - switch action { - case .refresh, .refreshAll: - return "Refreshing will discard all unsaved changes." - case .closeTab: - return "Closing this tab will discard all unsaved changes." - } - } -} - -// MARK: - View Extension - -extension View { - /// Apply MainContentView alerts and sheets - func mainContentAlerts( - coordinator: MainContentCoordinator, - connection: DatabaseConnection, - pendingTruncates: Binding>, - pendingDeletes: Binding>, - tables: [TableInfo], - selectedTables: Set - ) -> some View { - modifier( - MainContentAlerts( - coordinator: coordinator, - connection: connection, - pendingTruncates: pendingTruncates, - pendingDeletes: pendingDeletes, - tables: tables, - selectedTables: selectedTables - )) - } -} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 3e9f3aada..e6364bc94 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -181,15 +181,6 @@ struct MainEditorContentView: View { @ViewBuilder private func resultsSection(tab: QueryTab) -> some View { VStack(spacing: 0) { - // Error banner (if query failed) - if let errorMessage = tab.errorMessage, !errorMessage.isEmpty { - InlineErrorBanner(message: errorMessage) { - if let index = tabManager.selectedTabIndex { - tabManager.tabs[index].errorMessage = nil - } - } - } - if tab.showStructure, let tableName = tab.tableName { TableStructureView(tableName: tableName, connection: connection) .frame(maxHeight: .infinity) @@ -233,7 +224,7 @@ struct MainEditorContentView: View { columnDefaults: tab.columnDefaults, columnTypes: tab.columnTypes ), - changeManager: changeManager, + changeManager: AnyChangeManager(dataManager: changeManager), isEditable: tab.isEditable, onCommit: onCommit, onRefresh: onRefresh, diff --git a/TablePro/Views/Main/Child/TableTabContentView.swift b/TablePro/Views/Main/Child/TableTabContentView.swift index 4038b1e80..f878bd055 100644 --- a/TablePro/Views/Main/Child/TableTabContentView.swift +++ b/TablePro/Views/Main/Child/TableTabContentView.swift @@ -44,11 +44,6 @@ struct TableTabContentView: View { var body: some View { VStack(spacing: 0) { - // Error banner (if query failed) - if let errorMessage = tab.errorMessage, !errorMessage.isEmpty { - InlineErrorBanner(message: errorMessage, onDismiss: onDismissError) - } - // Show structure view or data view based on toggle if showStructure, let tableName = tab.tableName { TableStructureView(tableName: tableName, connection: connection) @@ -61,7 +56,7 @@ struct TableTabContentView: View { columnDefaults: tab.columnDefaults, columnTypes: tab.columnTypes ), - changeManager: changeManager, + changeManager: AnyChangeManager(dataManager: changeManager), isEditable: tab.isEditable, onCommit: onCommit, onRefresh: onRefresh, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift new file mode 100644 index 000000000..ae7c3571e --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Alerts.swift @@ -0,0 +1,96 @@ +// +// MainContentCoordinator+Alerts.swift +// TablePro +// +// Alert handling methods for MainContentCoordinator +// Centralizes all NSAlert logic for main content operations +// + +import AppKit +import Foundation + +extension MainContentCoordinator { + + // MARK: - Dangerous Query Confirmation + + /// Check if query needs confirmation and show alert if needed + /// - Parameter sql: SQL query to check + /// - Returns: true if safe to execute, false if user cancelled + func confirmDangerousQueryIfNeeded(_ sql: String) -> Bool { + guard isDangerousQuery(sql) else { return true } + + let message = dangerousQueryMessage(for: sql) + return AlertHelper.confirmCritical( + title: "Potentially Dangerous Query", + message: message, + confirmButton: "Execute", + cancelButton: "Cancel" + ) + } + + /// Generate appropriate message for dangerous query type + private func dangerousQueryMessage(for sql: String) -> String { + let uppercased = sql.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." + } + + // MARK: - Discard Changes Confirmation + + /// Confirm discarding unsaved changes + /// - Parameter action: The action that requires discarding changes + /// - Returns: true if user confirmed, false if cancelled + func confirmDiscardChanges(action: DiscardAction) -> Bool { + let message = discardMessage(for: action) + return AlertHelper.confirmDestructive( + title: "Discard Unsaved Changes?", + message: message, + confirmButton: "Discard", + cancelButton: "Cancel" + ) + } + + /// Generate appropriate message for discard action type + private func discardMessage(for action: DiscardAction) -> String { + switch action { + case .refresh, .refreshAll: + return "Refreshing will discard all unsaved changes." + case .closeTab: + return "Closing this tab will discard all unsaved changes." + } + } + + // MARK: - Error Alerts + + /// Show query execution error as a sheet + /// - Parameters: + /// - error: The error that occurred + /// - window: Parent window (optional) + func showQueryError(_ error: Error, window: NSWindow?) { + AlertHelper.showErrorSheet( + title: "Query Execution Failed", + message: error.localizedDescription, + window: window + ) + } + + /// Show save changes error as a sheet + /// - Parameters: + /// - error: The error that occurred + /// - window: Parent window (optional) + func showSaveError(_ error: Error, window: NSWindow?) { + AlertHelper.showErrorSheet( + title: "Failed to Save Changes", + message: error.localizedDescription, + window: window + ) + } +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 54ce758f7..9ca7fc785 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -41,7 +41,6 @@ final class MainContentCoordinator: ObservableObject { @Published var schemaProvider = SQLSchemaProvider() @Published var cursorPosition: Int = 0 @Published var tableMetadata: TableMetadata? - @Published var pendingDiscardAction: DiscardAction? // Removed: showErrorAlert and errorAlertMessage - errors now display inline @Published var showDatabaseSwitcher = false @Published var showExportDialog = false @@ -49,10 +48,6 @@ 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 @@ -130,7 +125,7 @@ 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 { + func isDangerousQuery(_ sql: String) -> Bool { let uppercased = sql.uppercased().trimmingCharacters(in: .whitespacesAndNewlines) // Check for DROP @@ -173,10 +168,8 @@ final class MainContentCoordinator: ObservableObject { return } - // Check for dangerous queries if setting is enabled - if AppSettingsManager.shared.general.confirmBeforeDangerousQuery && isDangerousQuery(sql) { - pendingDangerousQuery = sql - showDangerousQueryAlert = true + // Check for dangerous queries and confirm if needed + guard confirmDangerousQueryIfNeeded(sql) else { return } @@ -184,18 +177,6 @@ final class MainContentCoordinator: ObservableObject { 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 } @@ -221,6 +202,7 @@ final class MainContentCoordinator: ObservableObject { var columnDefaults: [String: String?] = [:] var totalRowCount: Int? + var primaryKeyColumn: String? = nil if isEditable, let tableName = tableName { if let driver = DatabaseManager.shared.activeDriver { @@ -235,6 +217,9 @@ final class MainContentCoordinator: ObservableObject { for col in columnInfo { columnDefaults[col.name] = col.defaultValue } + + // Detect primary key column + primaryKeyColumn = columnInfo.first(where: { $0.isPrimaryKey })?.name if let firstRow = countResult.rows.first, let countStr = firstRow.first as? String, @@ -254,6 +239,7 @@ final class MainContentCoordinator: ObservableObject { let safeColumnDefaults = columnDefaults.mapValues { $0.map { String($0) } } let safeTableName = tableName.map { String($0) } let safeTotalRowCount = totalRowCount + let safePrimaryKeyColumn = primaryKeyColumn.map { String($0) } guard !Task.isCancelled else { await MainActor.run { @@ -289,6 +275,20 @@ final class MainContentCoordinator: ObservableObject { updatedTab.pagination.totalRowCount = safeTotalRowCount tabManager.tabs[idx] = updatedTab + // Clear change tracking when loading new data (e.g., from refresh) + // This ensures deleted rows don't retain red background after refresh + if isEditable, let tableName = safeTableName { + changeManager.configureForTable( + tableName: tableName, + columns: safeColumns, + primaryKeyColumn: safePrimaryKeyColumn, + databaseType: conn.type + ) + } else { + // For query results, just clear changes + changeManager.clearChanges() + } + changeManager.reloadVersion += 1 QueryHistoryManager.shared.recordQuery( @@ -1117,6 +1117,13 @@ final class MainContentCoordinator: ObservableObject { if let index = tabManager.selectedTabIndex { tabManager.tabs[index].errorMessage = "Save failed: \(error.localizedDescription)" } + + // Show error alert to user + AlertHelper.showErrorSheet( + title: "Save Failed", + message: error.localizedDescription, + window: NSApplication.shared.keyWindow + ) // Restore operations on failure so user can retry. // Use notification to restore via MainContentView's bindings for synchronous update. @@ -1268,8 +1275,6 @@ final class MainContentCoordinator: ObservableObject { pendingTruncates: inout Set, pendingDeletes: inout Set ) { - guard let action = pendingDiscardAction else { return } - let originalValues = changeManager.getOriginalValues() if let index = tabManager.selectedTabIndex { for (rowIndex, columnIndex, originalValue) in originalValues { @@ -1297,19 +1302,6 @@ final class MainContentCoordinator: ObservableObject { changeManager.reloadVersion += 1 NotificationCenter.default.post(name: .databaseDidConnect, object: nil) - - switch action { - case .refresh, .refreshAll: - if let tabIndex = tabManager.selectedTabIndex, - tabManager.tabs[tabIndex].tabType == .table { - rebuildTableQuery(at: tabIndex) - } - runQuery() - case .closeTab: - closeCurrentTab() - } - - pendingDiscardAction = nil } // MARK: - Tab Operations @@ -1318,9 +1310,12 @@ final class MainContentCoordinator: ObservableObject { if tabManager.selectedTab != nil { let hasEditedCells = changeManager.hasChanges - // Only show confirmation if setting is enabled AND there are unsaved changes - if hasEditedCells && AppSettingsManager.shared.general.confirmBeforeClosingUnsaved { - pendingDiscardAction = .closeTab + // Always confirm if there are unsaved changes + if hasEditedCells { + let confirmed = confirmDiscardChanges(action: .closeTab) + if confirmed { + closeCurrentTab() + } } else { closeCurrentTab() } @@ -1565,14 +1560,28 @@ final class MainContentCoordinator: ObservableObject { // MARK: - Refresh Handling func handleRefreshAll( - pendingTruncates: Set, - pendingDeletes: Set + pendingTruncates: inout Set, + pendingDeletes: inout Set ) { + // If showing structure view, let it handle refresh notifications + if let tabIndex = tabManager.selectedTabIndex, + tabManager.tabs[tabIndex].showStructure { + return + } + let hasEditedCells = changeManager.hasChanges let hasPendingTableOps = !pendingTruncates.isEmpty || !pendingDeletes.isEmpty if hasEditedCells || hasPendingTableOps { - pendingDiscardAction = .refreshAll + let confirmed = confirmDiscardChanges(action: .refreshAll) + if confirmed { + handleDiscard( + pendingTruncates: &pendingTruncates, + pendingDeletes: &pendingDeletes + ) + NotificationCenter.default.post(name: .databaseDidConnect, object: nil) + runQuery() + } } else { NotificationCenter.default.post(name: .databaseDidConnect, object: nil) runQuery() @@ -1580,14 +1589,34 @@ final class MainContentCoordinator: ObservableObject { } func handleRefresh( - pendingTruncates: Set, - pendingDeletes: Set + pendingTruncates: inout Set, + pendingDeletes: inout Set ) { + // If showing structure view, let it handle refresh notifications + if let tabIndex = tabManager.selectedTabIndex, + tabManager.tabs[tabIndex].showStructure { + return + } + let hasEditedCells = changeManager.hasChanges let hasPendingTableOps = !pendingTruncates.isEmpty || !pendingDeletes.isEmpty if hasEditedCells || hasPendingTableOps { - pendingDiscardAction = .refresh + let confirmed = confirmDiscardChanges(action: .refresh) + if confirmed { + handleDiscard( + pendingTruncates: &pendingTruncates, + pendingDeletes: &pendingDeletes + ) + // Only execute query if we're in a table tab + // Query tabs should not auto-execute on refresh (use Cmd+Enter to execute) + if let tabIndex = tabManager.selectedTabIndex, + tabManager.tabs[tabIndex].tabType == .table { + currentQueryTask?.cancel() + rebuildTableQuery(at: tabIndex) + runQuery() + } + } } else { // Only execute query if we're in a table tab // Query tabs should not auto-execute on refresh (use Cmd+Enter to execute) diff --git a/TablePro/Views/Main/MainContentNotificationHandler.swift b/TablePro/Views/Main/MainContentNotificationHandler.swift index 53f212832..a055e505c 100644 --- a/TablePro/Views/Main/MainContentNotificationHandler.swift +++ b/TablePro/Views/Main/MainContentNotificationHandler.swift @@ -344,31 +344,46 @@ final class MainContentNotificationHandler: ObservableObject { } private func handleRefreshData() { + var truncates = pendingTruncates.wrappedValue + var deletes = pendingDeletes.wrappedValue coordinator?.handleRefresh( - pendingTruncates: pendingTruncates.wrappedValue, - pendingDeletes: pendingDeletes.wrappedValue + pendingTruncates: &truncates, + pendingDeletes: &deletes ) + pendingTruncates.wrappedValue = truncates + pendingDeletes.wrappedValue = deletes } private func handleRefreshAll() { - coordinator?.handleRefreshAll( - pendingTruncates: pendingTruncates.wrappedValue, - pendingDeletes: pendingDeletes.wrappedValue - ) - } - - private func handleSaveChanges() { var truncates = pendingTruncates.wrappedValue var deletes = pendingDeletes.wrappedValue - var options = tableOperationOptions.wrappedValue - coordinator?.saveChanges( + coordinator?.handleRefreshAll( pendingTruncates: &truncates, - pendingDeletes: &deletes, - tableOperationOptions: &options + pendingDeletes: &deletes ) pendingTruncates.wrappedValue = truncates pendingDeletes.wrappedValue = deletes - tableOperationOptions.wrappedValue = options + } + + private func handleSaveChanges() { + // Check if we're in structure view mode + if coordinator?.tabManager.selectedTab?.showStructure == true { + // Post notification for structure view to handle + NotificationCenter.default.post(name: .saveStructureChanges, object: nil) + } else { + // Handle data grid changes + var truncates = pendingTruncates.wrappedValue + var deletes = pendingDeletes.wrappedValue + var options = tableOperationOptions.wrappedValue + coordinator?.saveChanges( + pendingTruncates: &truncates, + pendingDeletes: &deletes, + tableOperationOptions: &options + ) + pendingTruncates.wrappedValue = truncates + pendingDeletes.wrappedValue = deletes + tableOperationOptions.wrappedValue = options + } } private func handleExportTables() { diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index 255f3efc5..f4cdfa1a2 100644 --- a/TablePro/Views/MainContentView.swift +++ b/TablePro/Views/MainContentView.swift @@ -87,14 +87,6 @@ struct MainContentView: View { var body: some View { mainContentView .tableProToolbar(state: toolbarState) - .mainContentAlerts( - coordinator: coordinator, - connection: connection, - pendingTruncates: $pendingTruncates, - pendingDeletes: $pendingDeletes, - tables: tables, - selectedTables: selectedTables - ) .task { await initializeAndRestoreTabs() } .onChange(of: tabManager.selectedTabId) { oldTabId, newTabId in handleTabSelectionChange(from: oldTabId, to: newTabId) @@ -410,6 +402,13 @@ struct MainContentView: View { if let index = tabManager.selectedTabIndex { tabManager.tabs[index].errorMessage = "Save failed: \(error.localizedDescription)" } + + // Show error alert to user + AlertHelper.showErrorSheet( + title: "Save Failed", + message: error.localizedDescription, + window: NSApplication.shared.keyWindow + ) } } } @@ -501,12 +500,11 @@ struct MainContentView: View { } catch { // Show error using macOS alert - let alert = NSAlert() - alert.messageText = "Failed to Save Changes" - alert.informativeText = error.localizedDescription - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() + AlertHelper.showErrorSheet( + title: "Failed to Save Changes", + message: error.localizedDescription, + window: nil + ) } } diff --git a/TablePro/Views/Results/BooleanCellEditor.swift b/TablePro/Views/Results/BooleanCellEditor.swift new file mode 100644 index 000000000..e675427d3 --- /dev/null +++ b/TablePro/Views/Results/BooleanCellEditor.swift @@ -0,0 +1,89 @@ +// +// BooleanCellEditor.swift +// TablePro +// +// Custom cell editor for YES/NO boolean values with dropdown. +// + +import AppKit + +/// NSPopUpButton configured for YES/NO boolean editing +final class BooleanCellEditor: NSPopUpButton { + var onValueChanged: ((String) -> Void)? + var initialValue: String? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + private func setupUI() { + removeAllItems() + addItem(withTitle: "YES") + addItem(withTitle: "NO") + + target = self + action = #selector(valueChanged) + + // Style to match text fields + bezelStyle = .texturedSquare + font = .monospacedSystemFont(ofSize: 13, weight: .regular) + } + + @objc private func valueChanged() { + guard let selected = titleOfSelectedItem else { return } + onValueChanged?(selected) + } + + func selectValue(_ value: String?) { + initialValue = value + let normalized = value?.uppercased() ?? "NO" + + if normalized == "YES" || normalized == "1" || normalized == "TRUE" { + selectItem(withTitle: "YES") + } else { + selectItem(withTitle: "NO") + } + } +} + +/// Custom field editor that provides dropdown editing for boolean columns +final class BooleanFieldEditor: NSTextView { + var popupButton: BooleanCellEditor? + var onComplete: ((String) -> Void)? + + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + + // Create and show popup button + if popupButton == nil { + let popup = BooleanCellEditor(frame: bounds) + popup.autoresizingMask = [.width, .height] + popup.onValueChanged = { [weak self] value in + self?.onComplete?(value) + self?.window?.makeFirstResponder(nil) + } + + addSubview(popup) + popupButton = popup + + // Pull down the menu immediately + DispatchQueue.main.async { + popup.performClick(nil) + } + } + + return result + } + + override func resignFirstResponder() -> Bool { + popupButton?.removeFromSuperview() + popupButton = nil + return super.resignFirstResponder() + } +} diff --git a/TablePro/Views/Results/BooleanCellFormatter.swift b/TablePro/Views/Results/BooleanCellFormatter.swift new file mode 100644 index 000000000..41ca75d00 --- /dev/null +++ b/TablePro/Views/Results/BooleanCellFormatter.swift @@ -0,0 +1,56 @@ +// +// BooleanCellFormatter.swift +// TablePro +// +// Formatter for YES/NO boolean values with auto-completion. +// + +import AppKit + +/// Formatter that auto-converts common boolean inputs to YES/NO +final class BooleanCellFormatter: Formatter { + override func string(for obj: Any?) -> String? { + guard let value = obj as? String else { return nil } + return normalizeBooleanString(value) + } + + override func getObjectValue( + _ obj: AutoreleasingUnsafeMutablePointer?, + for string: String, + errorDescription error: AutoreleasingUnsafeMutablePointer? + ) -> Bool { + obj?.pointee = normalizeBooleanString(string) as AnyObject + return true + } + + override func isPartialStringValid( + _ partialString: String, + newEditingString newString: AutoreleasingUnsafeMutablePointer?, + errorDescription error: AutoreleasingUnsafeMutablePointer? + ) -> Bool { + // Allow any input during editing + return true + } + + private func normalizeBooleanString(_ value: String) -> String { + let uppercased = value.uppercased().trimmingCharacters(in: .whitespaces) + + // Check for YES values + if ["YES", "Y", "TRUE", "T", "1", "ON"].contains(uppercased) { + return "YES" + } + + // Check for NO values + if ["NO", "N", "FALSE", "F", "0", "OFF", ""].contains(uppercased) { + return "NO" + } + + // If it starts with Y, assume YES + if uppercased.hasPrefix("Y") { + return "YES" + } + + // Default to NO + return "NO" + } +} diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index bb3334374..6ebe1bda1 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -90,6 +90,10 @@ final class DataGridCellFactory { cellView = reused cell = textField isNewCell = false + // Ensure layer exists for background color + if !cellView.wantsLayer { + cellView.wantsLayer = true + } } else { cellView = NSTableCellView() cellView.identifier = cellViewId diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index fbd879d2c..e2a1b1040 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -27,16 +27,22 @@ struct RowVisualState { /// High-performance table view using AppKit NSTableView struct DataGridView: NSViewRepresentable { let rowProvider: InMemoryRowProvider - @ObservedObject var changeManager: DataChangeManager + @ObservedObject var changeManager: AnyChangeManager let isEditable: Bool var onCommit: ((String) -> Void)? var onRefresh: (() -> Void)? var onCellEdit: ((Int, Int, String?) -> Void)? var onDeleteRows: ((Set) -> Void)? + var onCopyRows: ((Set) -> Void)? + var onPasteRows: (() -> Void)? + var onUndo: (() -> Void)? + var onRedo: (() -> Void)? var onSort: ((Int, Bool) -> Void)? var onAddRow: (() -> Void)? var onUndoInsert: ((Int) -> Void)? var onFilterColumn: ((String) -> Void)? + var getVisualState: ((Int) -> RowVisualState)? + var dropdownColumns: Set? // Column indices that should use YES/NO dropdowns @Binding var selectedRowIndices: Set @Binding var sortState: SortState @@ -133,7 +139,10 @@ struct DataGridView: NSViewRepresentable { let newRowCount = rowProvider.totalRowCount let newColumnCount = rowProvider.columns.count - let needsReload = oldRowCount != newRowCount || oldColumnCount != newColumnCount || versionChanged + // Only do full reload if row/column count changed or columns changed + // For cell edits (versionChanged but same count), use granular reload + let structureChanged = oldRowCount != newRowCount || oldColumnCount != newColumnCount + let needsFullReload = structureChanged coordinator.rowProvider = rowProvider coordinator.updateCache() @@ -142,10 +151,12 @@ struct DataGridView: NSViewRepresentable { coordinator.onCommit = onCommit coordinator.onRefresh = onRefresh coordinator.onCellEdit = onCellEdit + coordinator.onDeleteRows = onDeleteRows // Added: pass delete callback coordinator.onSort = onSort coordinator.onAddRow = onAddRow coordinator.onUndoInsert = onUndoInsert coordinator.onFilterColumn = onFilterColumn + coordinator.getVisualState = getVisualState coordinator.rebuildVisualStateCache() @@ -196,8 +207,21 @@ struct DataGridView: NSViewRepresentable { } } - if needsReload { + if needsFullReload { tableView.reloadData() + } else if versionChanged { + // Granular reload: only reload rows that changed + let changedRows = changeManager.consumeChangedRowIndices() + if !changedRows.isEmpty { + // Some rows changed → granular reload for performance + let rowIndexSet = IndexSet(changedRows) + let columnIndexSet = IndexSet(integersIn: 0.. Void)? var onRefresh: (() -> Void)? var onCellEdit: ((Int, Int, String?) -> Void)? + var onDeleteRows: ((Set) -> Void)? + var onCopyRows: ((Set) -> Void)? + var onPasteRows: (() -> Void)? + var onUndo: (() -> Void)? + var onRedo: (() -> Void)? var onSort: ((Int, Bool) -> Void)? var onAddRow: (() -> Void)? var onUndoInsert: ((Int) -> Void)? var onFilterColumn: ((String) -> Void)? + var getVisualState: ((Int) -> RowVisualState)? + + /// Check if undo is available + func canUndo() -> Bool { + return changeManager.hasChanges + } + + /// Check if redo is available + func canRedo() -> Bool { + // TODO: Implement redo tracking in change manager + return false + } weak var tableView: NSTableView? var cellFactory: DataGridCellFactory? @@ -277,12 +323,17 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData init( rowProvider: InMemoryRowProvider, - changeManager: DataChangeManager, + changeManager: AnyChangeManager, isEditable: Bool, selectedRowIndices: Binding>, onCommit: ((String) -> Void)?, onRefresh: (() -> Void)?, - onCellEdit: ((Int, Int, String?) -> Void)? + onCellEdit: ((Int, Int, String?) -> Void)?, + onDeleteRows: ((Set) -> Void)?, + onCopyRows: ((Set) -> Void)?, + onPasteRows: (() -> Void)?, + onUndo: (() -> Void)?, + onRedo: (() -> Void)? ) { self.rowProvider = rowProvider self.changeManager = changeManager @@ -291,6 +342,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData self.onCommit = onCommit self.onRefresh = onRefresh self.onCellEdit = onCellEdit + self.onDeleteRows = onDeleteRows + self.onCopyRows = onCopyRows + self.onPasteRows = onPasteRows + self.onUndo = onUndo + self.onRedo = onRedo super.init() updateCache() @@ -300,10 +356,28 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData 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() + guard let self = self, let tableView = self.tableView else { return } + + // Capture settings on main actor to avoid Sendable warning + Task { @MainActor in + let newRowHeight = CGFloat(AppSettingsManager.shared.dataGrid.rowHeight.rawValue) + + // Only reload if row height changed (requires full reload) + if tableView.rowHeight != newRowHeight { + tableView.rowHeight = newRowHeight + tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: 0.. 0 { + tableView.reloadData( + forRowIndexes: IndexSet(integersIn: visibleRange.location..<(visibleRange.location + visibleRange.length)), + columnIndexes: IndexSet(integersIn: 0.. = change.type == .update - ? Set(change.cellChanges.map { $0.columnIndex }) + guard let rowChange = change as? RowChange else { continue } + let rowIndex = rowChange.rowIndex + let isDeleted = rowChange.type == .delete + let isInserted = rowChange.type == .insert + let modifiedColumns: Set = rowChange.type == .update + ? Set(rowChange.cellChanges.map { $0.columnIndex }) : [] rowVisualStateCache[rowIndex] = RowVisualState( @@ -342,7 +428,12 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func visualState(for row: Int) -> RowVisualState { - rowVisualStateCache[row] ?? .empty + // If custom callback provided, use it + if let callback = getVisualState { + return callback(row) + } + // Otherwise use cache + return rowVisualStateCache[row] ?? .empty } // MARK: - NSTableViewDataSource @@ -653,7 +744,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let columnName = rowProvider.columns[columnIndex] let oldValue = rowProvider.row(at: rowIndex)?.value(at: columnIndex) - let originalRow = rowProvider.row(at: rowIndex)?.values + let originalRow = rowProvider.row(at: rowIndex)?.values ?? [] changeManager.recordCellChange( rowIndex: rowIndex, @@ -695,7 +786,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData ], columns: ["id", "name", "email"] ), - changeManager: DataChangeManager(), + changeManager: AnyChangeManager(dataManager: DataChangeManager()), isEditable: true, selectedRowIndices: .constant([]), sortState: .constant(SortState()), diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 461e301e2..0df9cb978 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -2,16 +2,26 @@ // KeyHandlingTableView.swift // TablePro // -// NSTableView subclass that handles Delete key and TablePlus-style cell focus. -// Extracted from DataGridView for better maintainability. +// NSTableView subclass that handles keyboard shortcuts and TablePlus-style cell focus. +// Uses Apple's responder chain pattern with interpretKeyEvents for standard shortcuts. +// +// Architecture: +// - Keyboard events → interpretKeyEvents → Standard selectors (@objc moveUp, delete, etc.) +// - Uses KeyCode enum for readability (no magic numbers) +// - Responder chain validation via validateUserInterfaceItem // import AppKit -/// NSTableView subclass that handles Delete key to mark rows for deletion -/// Also implements TablePlus-style cell focus on click -final class KeyHandlingTableView: NSTableView, NSMenuItemValidation { +/// NSTableView subclass that handles keyboard shortcuts and TablePlus-style cell focus on click +final class KeyHandlingTableView: NSTableView { weak var coordinator: TableViewCoordinator? + + // MARK: - First Responder + + override var acceptsFirstResponder: Bool { + return true + } /// Currently focused row index (-1 = no focus) var focusedRow: Int = -1 { @@ -54,13 +64,20 @@ final class KeyHandlingTableView: NSTableView, NSMenuItemValidation { // MARK: - TablePlus-Style Cell Focus override func mouseDown(with event: NSEvent) { + // Become first responder to capture keyboard events (especially Delete key) + window?.makeFirstResponder(self) + let point = convert(event.locationInWindow, from: nil) let clickedRow = row(at: point) let clickedColumn = column(at: point) // Double-click in empty area adds a new row if event.clickCount == 2 && clickedRow == -1 && coordinator?.isEditable == true { - NotificationCenter.default.post(name: .addNewRow, object: nil) + if let callback = coordinator?.onAddRow { + callback() + } else { + NotificationCenter.default.post(name: .addNewRow, object: nil) + } return } @@ -104,112 +121,190 @@ final class KeyHandlingTableView: NSTableView, NSMenuItemValidation { // MARK: - Standard Edit Menu Actions + /// Delete selected rows - called from menu or keyboard shortcut @objc func delete(_ sender: Any?) { guard coordinator?.isEditable == true else { return } guard !selectedRowIndexes.isEmpty else { return } - NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) + + // Use callback if available (e.g., Structure tab), otherwise fall back to notification (Data tab) + if let callback = coordinator?.onDeleteRows { + callback(Set(selectedRowIndexes)) + } else { + // Fallback for views that haven't migrated to callback pattern + NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) + } } - - func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - if menuItem.action == #selector(delete(_:)) { - return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty + + /// Copy selected rows to clipboard + @objc func copy(_ sender: Any?) { + if let callback = coordinator?.onCopyRows { + callback(Set(selectedRowIndexes)) } - if let action = menuItem.action { - return responds(to: action) + } + + /// Paste rows from clipboard + @objc func paste(_ sender: Any?) { + guard coordinator?.isEditable == true else { return } + if let callback = coordinator?.onPasteRows { + callback() } - return false } - - // MARK: - Keyboard Handling - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if event.keyCode == 51 || event.keyCode == 117 { - if !selectedRowIndexes.isEmpty && coordinator?.isEditable == true { - NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) - return true - } + + /// Undo last change + @objc func undo(_ sender: Any?) { + guard coordinator?.isEditable == true else { return } + if let callback = coordinator?.onUndo { + callback() + } + } + + /// Redo last undone change + @objc func redo(_ sender: Any?) { + guard coordinator?.isEditable == true else { return } + if let callback = coordinator?.onRedo { + callback() + } + } + + /// Validate menu items and shortcuts + override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + switch item.action { + case #selector(delete(_:)), #selector(deleteBackward(_:)): + return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty + case #selector(copy(_:)): + return !selectedRowIndexes.isEmpty + case #selector(paste(_:)): + return coordinator?.isEditable == true && coordinator?.onPasteRows != nil + case #selector(undo(_:)): + return coordinator?.isEditable == true && (coordinator?.canUndo() ?? false) + case #selector(redo(_:)): + return coordinator?.isEditable == true && (coordinator?.canRedo() ?? false) + case #selector(insertNewline(_:)): + return selectedRow >= 0 && focusedColumn >= 1 && coordinator?.isEditable == true + case #selector(cancelOperation(_:)): + return focusedRow >= 0 || focusedColumn >= 0 || !selectedRowIndexes.isEmpty + default: + return super.validateUserInterfaceItem(item) } - return super.performKeyEquivalent(with: event) } + // MARK: - Keyboard Handling + + /// Convert key events to standard selectors using interpretKeyEvents + /// This enables proper responder chain behavior and accessibility support override func keyDown(with event: NSEvent) { + guard let key = KeyCode(rawValue: event.keyCode) else { + super.keyDown(with: event) + return + } + + // Handle Tab manually (NSTableView cell navigation requires custom logic) + if key == .tab { + handleTabKey() + return + } + + // Handle arrow keys (custom Shift+selection logic) let row = selectedRow let isShiftHeld = event.modifierFlags.contains(.shift) - - switch event.keyCode { - case 126: // Up arrow + + switch key { + case .upArrow: handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) return - - case 125: // Down arrow + + case .downArrow: handleDownArrow(currentRow: row, isShiftHeld: isShiftHeld) return - - case 123: // Left arrow - if focusedColumn > 1 { - focusedColumn -= 1 - if row >= 0 { scrollColumnToVisible(focusedColumn) } - } else if focusedColumn == -1 && numberOfColumns > 1 { - focusedColumn = numberOfColumns - 1 - if row >= 0 { scrollColumnToVisible(focusedColumn) } - } - return - - case 124: // Right arrow - if focusedColumn >= 1 && focusedColumn < numberOfColumns - 1 { - focusedColumn += 1 - if row >= 0 { scrollColumnToVisible(focusedColumn) } - } else if focusedColumn == -1 && numberOfColumns > 1 { - focusedColumn = 1 - if row >= 0 { scrollColumnToVisible(focusedColumn) } - } - return - - case 36: // Enter/Return - if row >= 0 && focusedColumn >= 1 && coordinator?.isEditable == true { - editColumn(focusedColumn, row: row, with: nil, select: true) - } + + case .leftArrow: + handleLeftArrow(currentRow: row) return - - case 53: // Escape - focusedRow = -1 - focusedColumn = -1 - NotificationCenter.default.post(name: .clearSelection, object: nil) + + case .rightArrow: + handleRightArrow(currentRow: row) return - - case 51, 117: // Delete or Backspace - if !selectedRowIndexes.isEmpty { - NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) - return - } - - case 48: // Tab - if row >= 0 && focusedColumn >= 1 { - var nextColumn = focusedColumn + 1 - var nextRow = row - - if nextColumn >= numberOfColumns { - nextColumn = 1 - nextRow += 1 - } - if nextRow >= numberOfRows { - nextRow = numberOfRows - 1 - nextColumn = numberOfColumns - 1 - } - - selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - focusedRow = nextRow - focusedColumn = nextColumn - scrollRowToVisible(nextRow) - scrollColumnToVisible(nextColumn) - } - return - + default: break } - - super.keyDown(with: event) + + // For all other keys, use interpretKeyEvents to map to standard selectors + // This handles Return → insertNewline(_:), Delete → deleteBackward(_:), ESC → cancelOperation(_:) + interpretKeyEvents([event]) + } + + // MARK: - Standard Responder Selectors + + /// Handle Return/Enter key - start editing current cell + @objc override func insertNewline(_ sender: Any?) { + let row = selectedRow + guard row >= 0, focusedColumn >= 1, coordinator?.isEditable == true else { + return + } + editColumn(focusedColumn, row: row, with: nil, select: true) + } + + /// Handle Delete/Backspace key - delete selected rows + @objc override func deleteBackward(_ sender: Any?) { + guard coordinator?.isEditable == true else { return } + guard !selectedRowIndexes.isEmpty else { return } + delete(sender) + } + + /// Handle ESC key - clear selection and focus + @objc override func cancelOperation(_ sender: Any?) { + focusedRow = -1 + focusedColumn = -1 + deselectAll(sender) + } + + // MARK: - Arrow Key and Tab Helpers + + /// Handle left arrow key - move focus to previous column + private func handleLeftArrow(currentRow: Int) { + if focusedColumn > 1 { + focusedColumn -= 1 + if currentRow >= 0 { scrollColumnToVisible(focusedColumn) } + } else if focusedColumn == -1 && numberOfColumns > 1 { + focusedColumn = numberOfColumns - 1 + if currentRow >= 0 { scrollColumnToVisible(focusedColumn) } + } + } + + /// Handle right arrow key - move focus to next column + private func handleRightArrow(currentRow: Int) { + if focusedColumn >= 1 && focusedColumn < numberOfColumns - 1 { + focusedColumn += 1 + if currentRow >= 0 { scrollColumnToVisible(focusedColumn) } + } else if focusedColumn == -1 && numberOfColumns > 1 { + focusedColumn = 1 + if currentRow >= 0 { scrollColumnToVisible(focusedColumn) } + } + } + + /// Handle Tab key - navigate to next cell (manual implementation required for NSTableView) + private func handleTabKey() { + let row = selectedRow + guard row >= 0, focusedColumn >= 1 else { return } + + var nextColumn = focusedColumn + 1 + var nextRow = row + + if nextColumn >= numberOfColumns { + nextColumn = 1 + nextRow += 1 + } + if nextRow >= numberOfRows { + nextRow = numberOfRows - 1 + nextColumn = numberOfColumns - 1 + } + + selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) + focusedRow = nextRow + focusedColumn = nextColumn + scrollRowToVisible(nextRow) + scrollColumnToVisible(nextColumn) } // MARK: - Arrow Key Selection Helpers diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 61a9c8acd..b1d3e4bfb 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -17,20 +17,6 @@ struct GeneralSettingsView: View { 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) diff --git a/TablePro/Views/Settings/HistorySettingsView.swift b/TablePro/Views/Settings/HistorySettingsView.swift index ccc76672a..7b4b166fa 100644 --- a/TablePro/Views/Settings/HistorySettingsView.swift +++ b/TablePro/Views/Settings/HistorySettingsView.swift @@ -5,11 +5,11 @@ // Settings for query history retention and cleanup // +import AppKit import SwiftUI struct HistorySettingsView: View { @Binding var settings: HistorySettings - @State private var showClearConfirmation = false var body: some View { Form { @@ -39,21 +39,22 @@ struct HistorySettingsView: View { Text("Clear all query history") Spacer() Button("Clear History...") { - showClearConfirmation = true + let confirmed = AlertHelper.confirmDestructive( + title: "Clear All History?", + message: "This will permanently delete all query history entries. This action cannot be undone.", + confirmButton: "Clear", + cancelButton: "Cancel" + ) + + if confirmed { + clearAllHistory() + } } } } } .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() { diff --git a/TablePro/Views/Shared/InlineErrorBanner.swift b/TablePro/Views/Shared/InlineErrorBanner.swift deleted file mode 100644 index 651019e64..000000000 --- a/TablePro/Views/Shared/InlineErrorBanner.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// InlineErrorBanner.swift -// TablePro -// -// Native macOS-style inline error banner following Apple Human Interface Guidelines. -// Replaces blocking alert dialogs with non-blocking inline notifications. -// - -import SwiftUI - -/// Native macOS-style inline error banner -/// -/// Design follows Apple HIG: -/// - Icon: `exclamationmark.circle.fill` with multicolor rendering -/// - Background: System `controlBackgroundColor` (adapts to light/dark mode) -/// - Border: 0.5px hairline using `separatorColor` -/// - Corners: 6px rounded (macOS standard) -/// - Text: 12pt in `.primary` color for readability -struct InlineErrorBanner: View { - let message: String - let onDismiss: () -> Void - - var body: some View { - HStack(alignment: .top, spacing: 10) { - // Native macOS error icon - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.red) - .font(.system(size: 16)) - .symbolRenderingMode(.multicolor) - - VStack(alignment: .leading, spacing: 3) { - Text(message) - .font(.system(size: 12)) - .foregroundStyle(.primary) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 8) - - // Dismiss button - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .help("Dismiss") - .opacity(0.6) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - .shadow(color: .black.opacity(0.1), radius: 1, x: 0, y: 0.5) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .transition(.move(edge: .top).combined(with: .opacity)) - } -} - -#Preview("Light Mode") { - VStack { - InlineErrorBanner( - message: "Table 'users' doesn't exist" - ) {} - Spacer() - } - .frame(width: 500, height: 200) -} - -#Preview("Dark Mode") { - VStack { - InlineErrorBanner( - message: "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SELEC' at line 1" - ) {} - Spacer() - } - .frame(width: 500, height: 200) - .preferredColorScheme(.dark) -} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index b31b9ca03..64833e7fa 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -258,9 +258,6 @@ struct SidebarView: View { .contextMenu { tableContextMenu() } - .onDeleteCommand { - batchToggleDelete() - } .onExitCommand { selectedTables.removeAll() } diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index 0a98a5a5f..672688087 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -159,7 +159,9 @@ struct TableOperationDialog: View { } .frame(width: 320) .background(Color(nsColor: .windowBackgroundColor)) - .escapeKeyDismiss(isPresented: $isPresented, priority: .sheet) + .onExitCommand { + isPresented = false + } .onAppear { // Reset state when dialog opens ignoreForeignKeys = false diff --git a/TablePro/Views/Structure/SchemaPreviewSheet.swift b/TablePro/Views/Structure/SchemaPreviewSheet.swift new file mode 100644 index 000000000..c397036d6 --- /dev/null +++ b/TablePro/Views/Structure/SchemaPreviewSheet.swift @@ -0,0 +1,139 @@ +// +// SchemaPreviewSheet.swift +// TablePro +// +// SwiftUI sheet showing SQL preview before executing schema changes +// + +import SwiftUI + +/// Sheet for previewing ALTER TABLE statements before execution +struct SchemaPreviewSheet: View { + let statements: [String] + let onApply: () -> Void + let onCancel: () -> Void + + @AppStorage("skipSchemaPreview") private var skipPreview = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Image(systemName: "doc.text.magnifyingglass") + .font(.title2) + .foregroundColor(.blue) + Text("Preview Schema Changes") + .font(.title2) + .fontWeight(.semibold) + Spacer() + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + // SQL Statements + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if statements.isEmpty { + emptyState + } else { + ForEach(Array(statements.enumerated()), id: \.offset) { index, sql in + sqlStatement(sql: sql, index: index + 1) + } + } + } + .padding() + } + .background(Color(nsColor: .textBackgroundColor)) + + Divider() + + // Footer + HStack { + Toggle("Don't show this again", isOn: $skipPreview) + .help("You can re-enable this in Settings") + + Spacer() + + Button("Cancel") { + dismiss() + onCancel() + } + .keyboardShortcut(.cancelAction) + + Button("Apply Changes") { + dismiss() + onApply() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + } + .frame(width: 700, height: 500) + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "doc.plaintext") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No changes to preview") + .font(.title3) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func sqlStatement(sql: String, index: Int) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Statement \(index)") + .font(.headline) + .foregroundColor(.secondary) + Spacer() + copyButton(sql: sql) + } + + // SQL text with monospaced font + Text(sql) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + } + } + + private func copyButton(sql: String) -> some View { + Button(action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(sql, forType: .string) + }) { + Label("Copy", systemImage: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.borderless) + .help("Copy this statement to clipboard") + } +} + +#Preview { + SchemaPreviewSheet( + statements: [ + "ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL", + "ALTER TABLE users MODIFY COLUMN name VARCHAR(100) NOT NULL", + "CREATE INDEX idx_email ON users(email)" + ], + onApply: {}, + onCancel: {} + ) +} diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift new file mode 100644 index 000000000..1fa45d771 --- /dev/null +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -0,0 +1,128 @@ +// +// StructureRowProvider.swift +// TablePro +// +// Adapts structure entities (columns/indexes/FKs) to InMemoryRowProvider interface +// Converts entity-based data to row-based format for DataGridView +// + +import Foundation + +/// Provides structure entities as rows for DataGridView +final class StructureRowProvider { + private let changeManager: StructureChangeManager + private let tab: StructureTab + + // Computed properties that match InMemoryRowProvider interface + var rows: [QueryResultRow] { + switch tab { + case .columns: + return changeManager.workingColumns.map { column in + QueryResultRow(values: [ + column.name, + column.dataType, + column.isNullable ? "YES" : "NO", + column.defaultValue ?? "", + column.autoIncrement ? "YES" : "NO", + column.comment ?? "" + ]) + } + case .indexes: + return changeManager.workingIndexes.map { index in + QueryResultRow(values: [ + index.name, + index.columns.joined(separator: ", "), + index.type.rawValue, + index.isUnique ? "YES" : "NO" + ]) + } + case .foreignKeys: + return changeManager.workingForeignKeys.map { fk in + QueryResultRow(values: [ + fk.name, + fk.columns.joined(separator: ", "), + fk.referencedTable, + fk.referencedColumns.joined(separator: ", "), + fk.onDelete.rawValue, + fk.onUpdate.rawValue + ]) + } + case .ddl: + return [] + } + } + + var columns: [String] { + switch tab { + case .columns: + return ["Name", "Type", "Nullable", "Default", "Auto Inc", "Comment"] + case .indexes: + return ["Name", "Columns", "Type", "Unique"] + case .foreignKeys: + return ["Name", "Columns", "Ref Table", "Ref Columns", "On Delete", "On Update"] + case .ddl: + return [] + } + } + + var columnTypes: [ColumnType] { + // All columns are text for structure editing + Array(repeating: .text(rawType: nil), count: columns.count) + } + + /// Column indices that should use YES/NO dropdowns instead of text fields + var dropdownColumns: Set { + switch tab { + case .columns: + return [2, 4] // Nullable (index 2), Auto Inc (index 4) + case .indexes: + return [3] // Unique (index 3) + case .foreignKeys: + return [] // On Delete/Update use text for now (could add dropdown for CASCADE/SET NULL/etc later) + case .ddl: + return [] + } + } + + var totalRowCount: Int { + rows.count + } + + init(changeManager: StructureChangeManager, tab: StructureTab) { + self.changeManager = changeManager + self.tab = tab + } + + // MARK: - InMemoryRowProvider-compatible methods + + func row(at index: Int) -> QueryResultRow? { + guard index < rows.count else { return nil } + return rows[index] + } + + func updateValue(_ newValue: String?, at rowIndex: Int, columnIndex: Int) { + // Updates are handled by StructureTableCoordinator + // This method is called by DataGridView but we intercept edits earlier + } + + func appendRow(_ row: [String?]) { + // Handled by changeManager.addNewColumn/Index/ForeignKey + } + + func removeRow(at index: Int) { + // Handled by changeManager.deleteColumn/Index/ForeignKey + } +} + +// MARK: - Helper to create InMemoryRowProvider + +extension StructureRowProvider { + /// Creates an InMemoryRowProvider from structure data + func asInMemoryProvider() -> InMemoryRowProvider { + InMemoryRowProvider( + rows: rows, + columns: columns, + columnTypes: columnTypes + ) + } +} diff --git a/TablePro/Views/Structure/StructureTableCoordinator.swift b/TablePro/Views/Structure/StructureTableCoordinator.swift new file mode 100644 index 000000000..b8e9ef8ba --- /dev/null +++ b/TablePro/Views/Structure/StructureTableCoordinator.swift @@ -0,0 +1,275 @@ +// +// StructureTableCoordinator.swift +// TablePro +// +// Coordinator for structure grid - adapts DataGridView for schema editing +// Converts entity-based data (columns/indexes/FKs) to row-based format for grid +// + +import AppKit +import Foundation + +/// Coordinator for structure table editing +final class StructureTableCoordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, NSTextFieldDelegate { + + // MARK: - Properties + + var structureChangeManager: StructureChangeManager + var currentTab: StructureTab + var isEditable: Bool = true + + weak var tableView: NSTableView? + var onDeleteRows: ((Set) -> Void)? + var onAddRow: (() -> Void)? + + private var cachedRowCount: Int = 0 + private var cachedColumnCount: Int = 0 + private var visualStateCache: [Int: RowVisualState] = [:] + + // MARK: - Initialization + + init(changeManager: StructureChangeManager, tab: StructureTab) { + self.structureChangeManager = changeManager + self.currentTab = tab + super.init() + } + + // MARK: - Data Conversion + + /// Convert columns to row-based format + private func columnsAsRows() -> [[String?]] { + structureChangeManager.workingColumns.map { column in + [ + column.name, + column.dataType, + column.isNullable ? "YES" : "NO", + column.defaultValue ?? "", + column.autoIncrement ? "YES" : "NO", + column.comment ?? "" + ] + } + } + + /// Convert indexes to row-based format + private func indexesAsRows() -> [[String?]] { + structureChangeManager.workingIndexes.map { index in + [ + index.name, + index.columns.joined(separator: ", "), + index.type.rawValue, + index.isUnique ? "YES" : "NO" + ] + } + } + + /// Convert foreign keys to row-based format + private func foreignKeysAsRows() -> [[String?]] { + structureChangeManager.workingForeignKeys.map { fk in + [ + fk.name, + fk.columns.joined(separator: ", "), + fk.referencedTable, + fk.referencedColumns.joined(separator: ", "), + fk.onDelete.rawValue, + fk.onUpdate.rawValue + ] + } + } + + /// Get column names for current tab + private func columnNames() -> [String] { + switch currentTab { + case .columns: + return ["Name", "Type", "Nullable", "Default", "Auto Inc", "Comment"] + case .indexes: + return ["Name", "Columns", "Type", "Unique"] + case .foreignKeys: + return ["Name", "Columns", "Ref Table", "Ref Columns", "On Delete", "On Update"] + case .ddl: + return [] + } + } + + // MARK: - NSTableViewDataSource + + func numberOfRows(in tableView: NSTableView) -> Int { + switch currentTab { + case .columns: + return structureChangeManager.workingColumns.count + case .indexes: + return structureChangeManager.workingIndexes.count + case .foreignKeys: + return structureChangeManager.workingForeignKeys.count + case .ddl: + return 0 + } + } + + func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? { + let rows: [[String?]] + + switch currentTab { + case .columns: + rows = columnsAsRows() + case .indexes: + rows = indexesAsRows() + case .foreignKeys: + rows = foreignKeysAsRows() + case .ddl: + return nil + } + + guard row < rows.count else { return nil } + + // Extract column index from identifier (format: "col_0", "col_1", etc.) + guard let identifier = tableColumn?.identifier.rawValue, + identifier.starts(with: "col_"), + let colIndex = Int(identifier.dropFirst(4)) else { + return nil + } + + guard colIndex < rows[row].count else { return nil } + return rows[row][colIndex] + } + + // MARK: - NSTableViewDelegate + + func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { + guard isEditable else { return false } + + // Don't allow editing row number column + guard tableColumn?.identifier.rawValue != "__rowNumber__" else { + return false + } + + return true + } + + // MARK: - NSTextFieldDelegate + + func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool { + guard let tableView = tableView else { return true } + + let row = tableView.row(for: control) + let column = tableView.column(for: control) + + guard row >= 0, column >= 0 else { return true } + + let newValue = fieldEditor.string + + // Update the appropriate entity based on tab and column + updateEntity(row: row, column: column, value: newValue) + + return true + } + + // MARK: - Entity Updates + + private func updateEntity(row: Int, column: Int, value: String) { + // Extract column index (accounting for row number column) + let dataColumnIndex = column - 1 + guard dataColumnIndex >= 0 else { return } + + switch currentTab { + case .columns: + updateColumn(row: row, columnIndex: dataColumnIndex, value: value) + case .indexes: + updateIndex(row: row, columnIndex: dataColumnIndex, value: value) + case .foreignKeys: + updateForeignKey(row: row, columnIndex: dataColumnIndex, value: value) + case .ddl: + break + } + } + + private func updateColumn(row: Int, columnIndex: Int, value: String) { + guard row < structureChangeManager.workingColumns.count else { return } + var column = structureChangeManager.workingColumns[row] + + switch columnIndex { + case 0: // Name + column.name = value + case 1: // Type + column.dataType = value + case 2: // Nullable + column.isNullable = value.uppercased() == "YES" || value == "1" + case 3: // Default + column.defaultValue = value.isEmpty ? nil : value + case 4: // Auto Inc + column.autoIncrement = value.uppercased() == "YES" || value == "1" + case 5: // Comment + column.comment = value.isEmpty ? nil : value + default: + break + } + + structureChangeManager.updateColumn(id: column.id, with: column) + } + + private func updateIndex(row: Int, columnIndex: Int, value: String) { + guard row < structureChangeManager.workingIndexes.count else { return } + var index = structureChangeManager.workingIndexes[row] + + switch columnIndex { + case 0: // Name + index.name = value + case 1: // Columns (comma-separated) + index.columns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 2: // Type + if let indexType = EditableIndexDefinition.IndexType(rawValue: value.uppercased()) { + index.type = indexType + } + case 3: // Unique + index.isUnique = value.uppercased() == "YES" || value == "1" + default: + break + } + + structureChangeManager.updateIndex(id: index.id, with: index) + } + + private func updateForeignKey(row: Int, columnIndex: Int, value: String) { + guard row < structureChangeManager.workingForeignKeys.count else { return } + var fk = structureChangeManager.workingForeignKeys[row] + + switch columnIndex { + case 0: // Name + fk.name = value + case 1: // Columns (comma-separated) + fk.columns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 2: // Ref Table + fk.referencedTable = value + case 3: // Ref Columns (comma-separated) + fk.referencedColumns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 4: // On Delete + if let action = EditableForeignKeyDefinition.ReferentialAction(rawValue: value.uppercased()) { + fk.onDelete = action + } + case 5: // On Update + if let action = EditableForeignKeyDefinition.ReferentialAction(rawValue: value.uppercased()) { + fk.onUpdate = action + } + default: + break + } + + structureChangeManager.updateForeignKey(id: fk.id, with: fk) + } + + // MARK: - Visual State + + func rebuildVisualStateCache() { + structureChangeManager.rebuildVisualStateCache() + } + + func getVisualState(for row: Int) -> RowVisualState { + structureChangeManager.getVisualState(for: row, tab: currentTab) + } + + // MARK: - Cache Management + + func updateCache() { + cachedRowCount = numberOfRows(in: tableView ?? NSTableView()) + cachedColumnCount = columnNames().count + } +} diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 783f4fcea..5194c3ac8 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -2,21 +2,15 @@ // TableStructureView.swift // TablePro // -// View for displaying table structure: columns, indexes, foreign keys +// View for displaying table structure using DataGridView +// Complete refactor to match data grid UX // import SwiftUI import UniformTypeIdentifiers +import AppKit -/// Tab selection for structure view -enum StructureTab: String, CaseIterable { - case columns = "Columns" - case indexes = "Indexes" - case foreignKeys = "Foreign Keys" - case ddl = "DDL" -} - -/// View displaying table structure like TablePlus +/// View displaying table structure with DataGridView struct TableStructureView: View { let tableName: String let connection: DatabaseConnection @@ -30,12 +24,82 @@ struct TableStructureView: View { @State private var showCopyConfirmation = false @State private var isLoading = true @State private var errorMessage: String? - - // Lazy loading state - track which tabs have been loaded @State private var loadedTabs: Set = [] + @State private var isReloadingAfterSave = false // Prevent onChange loops during save reload + @State private var lastSaveTime: Date? = nil // Track when we last saved + + // DataGridView state + @StateObject private var structureChangeManager = StructureChangeManager() + @StateObject private var wrappedChangeManager: AnyChangeManager + @State private var selectedRows: Set = [] + @State private var sortState = SortState() + @State private var editingCell: CellPosition? = nil + + // Preview dialog + @State private var showPreview = false + @State private var previewStatements: [String] = [] + @AppStorage("skipSchemaPreview") private var skipPreview = false + + init(tableName: String, connection: DatabaseConnection) { + self.tableName = tableName + self.connection = connection + + // Initialize wrappedChangeManager using the StateObject's wrappedValue + let manager = StructureChangeManager() + _structureChangeManager = StateObject(wrappedValue: manager) + _wrappedChangeManager = StateObject(wrappedValue: AnyChangeManager(structureManager: manager)) + } var body: some View { VStack(spacing: 0) { + toolbar + Divider() + contentArea + } + .task(loadInitialData) + .onChange(of: selectedTab, onSelectedTabChanged) + .onChange(of: columns, onColumnsChanged) + .onChange(of: indexes, onIndexesChanged) + .onChange(of: foreignKeys, onForeignKeysChanged) + .onChange(of: selectedRows) { _, newSelection in + AppState.shared.hasRowSelection = !newSelection.isEmpty + } + .onAppear { + AppState.shared.isCurrentTabEditable = (selectedTab != .ddl) + AppState.shared.hasRowSelection = !selectedRows.isEmpty + } + .onDisappear { + AppState.shared.isCurrentTabEditable = false + AppState.shared.hasRowSelection = false + } + .onReceive(NotificationCenter.default.publisher(for: .refreshData), perform: onRefreshData) + .onReceive(NotificationCenter.default.publisher(for: .saveStructureChanges)) { _ in + if structureChangeManager.hasChanges && selectedTab != .ddl { + Task { + await executeSchemaChanges() + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .copySelectedRows)) { _ in + handleCopyRows(selectedRows) + } + .onReceive(NotificationCenter.default.publisher(for: .pasteRows)) { _ in + handlePaste() + } + .onReceive(NotificationCenter.default.publisher(for: .undoChange)) { _ in + handleUndo() + } + .onReceive(NotificationCenter.default.publisher(for: .redoChange)) { _ in + handleRedo() + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack { + Spacer() + // Tab picker Picker("", selection: $selectedTab) { ForEach(StructureTab.allCases, id: \.self) { tab in @@ -43,274 +107,478 @@ struct TableStructureView: View { } } .pickerStyle(.segmented) - .padding() - - Divider() - - // Content - if isLoading { - ProgressView("Loading structure...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = errorMessage { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - switch selectedTab { - case .columns: - columnsTable - case .indexes: - indexesTable - case .foreignKeys: - foreignKeysTable - case .ddl: - ddlView - } - } + .labelsHidden() + + Spacer() } - .task { - await loadColumns() // Always load columns first (default tab) + .padding() + } + + // MARK: - Content Area + + @ViewBuilder + private var contentArea: some View { + if let error = errorMessage { + errorView(error) + } else { + tabContent } - .onChange(of: selectedTab) { _, newTab in - // Lazy load data for newly selected tab - Task { - await loadTabDataIfNeeded(newTab) + } + + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case .columns, .indexes, .foreignKeys: + structureGrid + case .ddl: + ddlView + } + } + + // MARK: - Structure Grid (DataGridView) + + private var structureGrid: some View { + let provider = StructureRowProvider(changeManager: structureChangeManager, tab: selectedTab) + + return DataGridView( + rowProvider: provider.asInMemoryProvider(), + changeManager: wrappedChangeManager, + isEditable: true, + onCommit: nil, + onRefresh: nil, + onCellEdit: handleCellEdit, + onDeleteRows: handleDeleteRows, + onCopyRows: handleCopyRows, + onPasteRows: handlePaste, + onUndo: handleUndo, + onRedo: handleRedo, + onSort: nil, + onAddRow: addNewRow, + onUndoInsert: nil, + onFilterColumn: nil, + getVisualState: { row in + structureChangeManager.getVisualState(for: row, tab: selectedTab) + }, + dropdownColumns: provider.dropdownColumns, + selectedRowIndices: $selectedRows, + sortState: $sortState, + editingCell: $editingCell + ) + } + + // MARK: - Event Handlers + + private func handleCellEdit(_ row: Int, _ column: Int, _ value: String?) { + // column parameter is already adjusted for row number column by DataGridView + guard column >= 0 else { return } + + switch selectedTab { + case .columns: + guard row < structureChangeManager.workingColumns.count else { return } + var col = structureChangeManager.workingColumns[row] + updateColumn(&col, at: column, with: value ?? "") + structureChangeManager.updateColumn(id: col.id, with: col) + + case .indexes: + guard row < structureChangeManager.workingIndexes.count else { return } + var idx = structureChangeManager.workingIndexes[row] + updateIndex(&idx, at: column, with: value ?? "") + structureChangeManager.updateIndex(id: idx.id, with: idx) + + case .foreignKeys: + guard row < structureChangeManager.workingForeignKeys.count else { return } + var fk = structureChangeManager.workingForeignKeys[row] + updateForeignKey(&fk, at: column, with: value ?? "") + structureChangeManager.updateForeignKey(id: fk.id, with: fk) + + case .ddl: + break + } + } + + private func updateColumn(_ column: inout EditableColumnDefinition, at index: Int, with value: String) { + switch index { + case 0: column.name = value + case 1: column.dataType = value + case 2: column.isNullable = value.uppercased() == "YES" || value == "1" + case 3: column.defaultValue = value.isEmpty ? nil : value + case 4: column.autoIncrement = value.uppercased() == "YES" || value == "1" + case 5: column.comment = value.isEmpty ? nil : value + default: break + } + } + + private func updateIndex(_ index: inout EditableIndexDefinition, at colIndex: Int, with value: String) { + switch colIndex { + case 0: index.name = value + case 1: index.columns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 2: + if let indexType = EditableIndexDefinition.IndexType(rawValue: value.uppercased()) { + index.type = indexType } + case 3: index.isUnique = value.uppercased() == "YES" || value == "1" + default: break } } - - // MARK: - Columns Tab - - private var columnsTable: some View { - Table(columns) { - TableColumn("Name") { column in - HStack(spacing: 4) { - if column.isPrimaryKey { - Image(systemName: "key.fill") - .foregroundColor(.yellow) - .font(.caption) - } - Text(column.name) - .fontWeight(column.isPrimaryKey ? .semibold : .regular) - } + + private func updateForeignKey(_ fk: inout EditableForeignKeyDefinition, at index: Int, with value: String) { + switch index { + case 0: fk.name = value + case 1: fk.columns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 2: fk.referencedTable = value + case 3: fk.referencedColumns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 4: + if let action = EditableForeignKeyDefinition.ReferentialAction(rawValue: value.uppercased()) { + fk.onDelete = action } - .width(min: 120, ideal: 150) - - TableColumn("Type") { column in - Text(column.dataType) - .foregroundColor(.secondary) - .font(.system(.body, design: .monospaced)) + case 5: + if let action = EditableForeignKeyDefinition.ReferentialAction(rawValue: value.uppercased()) { + fk.onUpdate = action } - .width(min: 100, ideal: 120) - - TableColumn("Charset") { column in - Text(column.charset ?? "-") - .foregroundColor(.secondary) - .font(.system(.body, design: .monospaced)) + default: break + } + } + + private func handleDeleteRows(_ rows: Set) { + // Find min/max for smart selection after delete + let minRow = rows.min() ?? 0 + let maxRow = rows.max() ?? 0 + var currentCount = 0 + + switch selectedTab { + case .columns: + currentCount = structureChangeManager.workingColumns.count + for row in rows.sorted(by: >) { + guard row < structureChangeManager.workingColumns.count else { continue } + let column = structureChangeManager.workingColumns[row] + structureChangeManager.deleteColumn(id: column.id) } - .width(min: 70, ideal: 90) - - TableColumn("Collation") { column in - Text(column.collation ?? "-") - .foregroundColor(.secondary) - .font(.system(.body, design: .monospaced)) + case .indexes: + currentCount = structureChangeManager.workingIndexes.count + for row in rows.sorted(by: >) { + guard row < structureChangeManager.workingIndexes.count else { continue } + let index = structureChangeManager.workingIndexes[row] + structureChangeManager.deleteIndex(id: index.id) } - .width(min: 120, ideal: 160) - - TableColumn("Nullable") { column in - Image(systemName: column.isNullable ? "checkmark.circle" : "xmark.circle") - .foregroundColor(column.isNullable ? .green : .red) + case .foreignKeys: + currentCount = structureChangeManager.workingForeignKeys.count + for row in rows.sorted(by: >) { + guard row < structureChangeManager.workingForeignKeys.count else { continue } + let fk = structureChangeManager.workingForeignKeys[row] + structureChangeManager.deleteForeignKey(id: fk.id) } - .width(70) - - TableColumn("Default") { column in - Text(column.defaultValue ?? "-") - .foregroundColor(.secondary) - .font(.system(.body, design: .monospaced)) + case .ddl: + selectedRows.removeAll() + return + } + + // Smart selection after delete (same as data grid behavior) + let newCount: Int + switch selectedTab { + case .columns: + newCount = structureChangeManager.workingColumns.count + case .indexes: + newCount = structureChangeManager.workingIndexes.count + case .foreignKeys: + newCount = structureChangeManager.workingForeignKeys.count + case .ddl: + newCount = 0 + } + + // Calculate next row to select + if newCount > 0 { + if maxRow < newCount { + // Select row after the deleted range + selectedRows = [maxRow] + } else if minRow > 0 { + // Deleted at end, select previous row + selectedRows = [minRow - 1] + } else { + // Deleted first row(s), select row 0 if exists + selectedRows = [0] } - .width(min: 80, ideal: 120) - - TableColumn("Extra") { column in - Text(column.extra ?? "-") - .foregroundColor(.secondary) + } else { + // No rows left + selectedRows.removeAll() + } + } + + private func addNewRow() { + switch selectedTab { + case .columns: + structureChangeManager.addNewColumn() + case .indexes: + structureChangeManager.addNewIndex() + case .foreignKeys: + structureChangeManager.addNewForeignKey() + case .ddl: + break + } + } + + // MARK: - Undo/Redo + + private func handleUndo() { + guard selectedTab != .ddl else { return } + structureChangeManager.undo() + } + + private func handleRedo() { + guard selectedTab != .ddl else { return } + structureChangeManager.redo() + } + + // MARK: - Copy/Paste + + // Custom pasteboard type for structure data (to avoid conflicts with data grid) + private static let structurePasteboardType = NSPasteboard.PasteboardType("com.tablepro.structure") + + private func handleCopyRows(_ rowIndices: Set) { + guard selectedTab != .ddl, !rowIndices.isEmpty else { return } + + var copiedItems: [Any] = [] + + switch selectedTab { + case .columns: + for row in rowIndices.sorted() { + guard row < structureChangeManager.workingColumns.count else { continue } + let column = structureChangeManager.workingColumns[row] + copiedItems.append(column) } - .width(min: 80, ideal: 100) - - TableColumn("Comment") { column in - Text(column.comment ?? "-") - .foregroundColor(.secondary) - .font(.body) - .lineLimit(2) + case .indexes: + for row in rowIndices.sorted() { + guard row < structureChangeManager.workingIndexes.count else { continue } + let index = structureChangeManager.workingIndexes[row] + copiedItems.append(index) } - .width(min: 100, ideal: 200) + case .foreignKeys: + for row in rowIndices.sorted() { + guard row < structureChangeManager.workingForeignKeys.count else { continue } + let fk = structureChangeManager.workingForeignKeys[row] + copiedItems.append(fk) + } + case .ddl: + break + } + + // Store in pasteboard as JSON string using CUSTOM TYPE + guard !copiedItems.isEmpty else { return } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + if let columns = copiedItems as? [EditableColumnDefinition], + let encoded = try? JSONEncoder().encode(columns), + let jsonString = String(data: encoded, encoding: .utf8) { + pasteboard.setString(jsonString, forType: Self.structurePasteboardType) + } else if let indexes = copiedItems as? [EditableIndexDefinition], + let encoded = try? JSONEncoder().encode(indexes), + let jsonString = String(data: encoded, encoding: .utf8) { + pasteboard.setString(jsonString, forType: Self.structurePasteboardType) + } else if let fks = copiedItems as? [EditableForeignKeyDefinition], + let encoded = try? JSONEncoder().encode(fks), + let jsonString = String(data: encoded, encoding: .utf8) { + pasteboard.setString(jsonString, forType: Self.structurePasteboardType) } } - - // MARK: - Indexes Tab - - private var indexesTable: some View { - Group { - if indexes.isEmpty { - emptyState("No indexes found") - } else { - Table(indexes) { - TableColumn("Name") { index in - HStack(spacing: 4) { - if index.isPrimary { - Image(systemName: "key.fill") - .foregroundColor(.yellow) - .font(.caption) - } else if index.isUnique { - Image(systemName: "seal.fill") - .foregroundColor(.blue) - .font(.caption) - } - Text(index.name) - .fontWeight(index.isPrimary ? .semibold : .regular) - } - } - .width(min: 150, ideal: 200) - - TableColumn("Columns") { index in - Text(index.columns.joined(separator: ", ")) - .font(.system(.body, design: .monospaced)) - } - .width(min: 150, ideal: 250) - - TableColumn("Type") { index in - Text(index.type) - .foregroundColor(.secondary) - } - .width(80) - - TableColumn("Unique") { index in - Image(systemName: index.isUnique ? "checkmark.circle.fill" : "circle") - .foregroundColor(index.isUnique ? .green : .secondary) - } - .width(60) - } + + private func handlePaste() { + guard let data = NSPasteboard.general.data(forType: Self.structurePasteboardType), + let jsonString = String(data: data, encoding: .utf8) else { + return + } + + // Try to parse as copied structure items + let decoder = JSONDecoder() + + switch selectedTab { + case .columns: + guard let columns = try? decoder.decode([EditableColumnDefinition].self, from: Data(jsonString.utf8)) else { + return + } + // Create copies with new IDs + for item in columns { + let newColumn = EditableColumnDefinition( + id: UUID(), + name: item.name, + dataType: item.dataType, + isNullable: item.isNullable, + defaultValue: item.defaultValue, + autoIncrement: item.autoIncrement, + unsigned: item.unsigned, + comment: item.comment, + collation: item.collation, + onUpdate: item.onUpdate, + charset: item.charset, + extra: item.extra, + isPrimaryKey: item.isPrimaryKey + ) + structureChangeManager.addColumn(newColumn) + } + + case .indexes: + guard let indexes = try? decoder.decode([EditableIndexDefinition].self, from: Data(jsonString.utf8)) else { + return + } + for item in indexes { + let newIndex = EditableIndexDefinition( + id: UUID(), + name: item.name, + columns: item.columns, + type: item.type, + isUnique: item.isUnique, + isPrimary: item.isPrimary, + comment: item.comment + ) + structureChangeManager.addIndex(newIndex) + } + + case .foreignKeys: + guard let fks = try? decoder.decode([EditableForeignKeyDefinition].self, from: Data(jsonString.utf8)) else { + return + } + for item in fks { + let newFK = EditableForeignKeyDefinition( + id: UUID(), + name: item.name, + columns: item.columns, + referencedTable: item.referencedTable, + referencedColumns: item.referencedColumns, + onDelete: item.onDelete, + onUpdate: item.onUpdate + ) + structureChangeManager.addForeignKey(newFK) } + + case .ddl: + // DDL tab doesn't support paste + break } } - - // MARK: - Foreign Keys Tab - - private var foreignKeysTable: some View { - Group { - if foreignKeys.isEmpty { - emptyState("No foreign keys found") - } else { - Table(foreignKeys) { - TableColumn("Name") { fk in - Text(fk.name) - .fontWeight(.medium) - } - .width(min: 150, ideal: 200) - - TableColumn("Column") { fk in - Text(fk.column) - .font(.system(.body, design: .monospaced)) - } - .width(min: 100, ideal: 150) - - TableColumn("References") { fk in - HStack(spacing: 2) { - Text(fk.referencedTable) - .foregroundColor(.blue) - Text(".") - .foregroundColor(.secondary) - Text(fk.referencedColumn) - .font(.system(.body, design: .monospaced)) - } - } - .width(min: 150, ideal: 200) - - TableColumn("On Delete") { fk in - Text(fk.onDelete) - .foregroundColor(fk.onDelete == "CASCADE" ? .orange : .secondary) - } - .width(90) - - TableColumn("On Update") { fk in - Text(fk.onUpdate) - .foregroundColor(fk.onUpdate == "CASCADE" ? .orange : .secondary) - } - .width(90) - } + + // MARK: - Schema Operations + + private func executeSchemaChanges() async { + let changes = structureChangeManager.getChangesArray() + guard !changes.isEmpty else { return } + + // Set flag BEFORE calling DatabaseManager (so we ignore its refresh notification) + isReloadingAfterSave = true + + do { + try await DatabaseManager.shared.executeSchemaChanges( + tableName: tableName, + changes: changes, + databaseType: getDatabaseType() + ) + + // Success - reload schema + structureChangeManager.discardChanges() + loadedTabs.removeAll() + + // Reload all structure data before calling loadSchemaForEditing + await loadColumns() + + // Load indexes and foreign keys (needed for complete schema state) + guard let driver = DatabaseManager.shared.activeDriver else { + isReloadingAfterSave = false + return } + do { + indexes = try await driver.fetchIndexes(table: tableName) + foreignKeys = try await driver.fetchForeignKeys(table: tableName) + } catch { + print("[TableStructureView] Failed to reload indexes/FKs: \(error)") + } + + // Now load the complete schema into the change manager + loadSchemaForEditing() + + // Load current tab data for display + await loadTabDataIfNeeded(selectedTab) + + // Force clear state after reload (in case it got set during the async process) + structureChangeManager.discardChanges() + + lastSaveTime = Date() // ✅ Record save time + isReloadingAfterSave = false + + } catch { + isReloadingAfterSave = false // Clear flag on error + AlertHelper.showErrorSheet( + title: "Error Applying Changes", + message: error.localizedDescription, + window: nil + ) } } - - // MARK: - DDL Tab - + + private func discardChanges() { + structureChangeManager.discardChanges() + } + + private func getDatabaseType() -> DatabaseType { + guard let driver = DatabaseManager.shared.activeDriver else { + return .mysql + } + + if driver is MySQLDriver { + return .mysql + } else if driver is PostgreSQLDriver { + return .postgresql + } else if driver is SQLiteDriver { + return .sqlite + } else { + return .mysql + } + } + + // MARK: - DDL View + private var ddlView: some View { VStack(spacing: 0) { - // Enhanced toolbar with font controls + // DDL toolbar HStack(spacing: 12) { - // Font size controls HStack(spacing: 4) { Button(action: { ddlFontSize = max(10, ddlFontSize - 1) }) { Image(systemName: "textformat.size.smaller") } - .buttonStyle(.borderless) - .help("Decrease font size") - Text("\(Int(ddlFontSize))") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.caption) .foregroundColor(.secondary) .frame(width: 24) - Button(action: { ddlFontSize = min(24, ddlFontSize + 1) }) { Image(systemName: "textformat.size.larger") } - .buttonStyle(.borderless) - .help("Increase font size") } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(6) - + .buttonStyle(.borderless) + Spacer() - - // Copy confirmation overlay + if showCopyConfirmation { - HStack(spacing: 6) { + HStack { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) Text("Copied!") - .font(.system(size: DesignConstants.FontSize.medium, weight: .medium)) } - .transition(.scale.combined(with: .opacity)) + .transition(.opacity) } - - // Action buttons - HStack(spacing: 8) { - Button(action: copyDDL) { - Label("Copy", systemImage: "doc.on.doc") - } - .buttonStyle(.bordered) - .help("Copy DDL to clipboard") - - Button(action: exportDDL) { - Label("Export", systemImage: "square.and.arrow.down") - } - .buttonStyle(.bordered) - .help("Export DDL to file") + + Button(action: copyDDL) { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + + Button(action: exportDDL) { + Label("Export", systemImage: "square.and.arrow.down") } + .buttonStyle(.bordered) } - .padding(.horizontal, 16) - .padding(.vertical, DesignConstants.Spacing.sm) + .padding() .background(Color(nsColor: .controlBackgroundColor)) - + Divider() - - // DDL text view + if ddlStatement.isEmpty { emptyState("No DDL available") } else { @@ -318,46 +586,20 @@ struct TableStructureView: View { } } } - - // MARK: - DDL Actions - - private func copyDDL() { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(ddlStatement, forType: .string) - - // Show confirmation feedback - withAnimation(.spring(duration: 0.3)) { - showCopyConfirmation = true - } - - // Hide after 2 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - withAnimation(.spring(duration: 0.3)) { - showCopyConfirmation = false - } - } - } - - private func exportDDL() { - let savePanel = NSSavePanel() - savePanel.allowedContentTypes = [.init(filenameExtension: "sql")!] - savePanel.nameFieldStringValue = "\(tableName).sql" - savePanel.message = "Export DDL Statement" - - savePanel.begin { response in - guard response == .OK, let url = savePanel.url else { return } - - do { - try ddlStatement.write(to: url, atomically: true, encoding: .utf8) - } catch { - print("Failed to export DDL: \(error.localizedDescription)") - } + + // MARK: - Helper Views + + private func errorView(_ message: String) -> some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.orange) + Text(message) + .foregroundColor(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - - // MARK: - Empty State - + private func emptyState(_ message: String) -> some View { VStack(spacing: 8) { Image(systemName: "tray") @@ -368,37 +610,39 @@ struct TableStructureView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - - // MARK: - Load Data (Lazy Loading) - - /// Load only columns on initial view (default tab) + + // MARK: - Data Loading + + @Sendable + private func loadInitialData() async { + await loadColumns() + loadSchemaForEditing() + } + private func loadColumns() async { isLoading = true errorMessage = nil - + guard let driver = DatabaseManager.shared.activeDriver else { errorMessage = "Not connected" isLoading = false return } - + do { columns = try await driver.fetchColumns(table: tableName) loadedTabs.insert(.columns) } catch { errorMessage = error.localizedDescription } - + isLoading = false } - - /// Load data for tab only when selected (lazy loading) + private func loadTabDataIfNeeded(_ tab: StructureTab) async { - // Skip if already loaded guard !loadedTabs.contains(tab) else { return } - guard let driver = DatabaseManager.shared.activeDriver else { return } - + do { switch tab { case .columns: @@ -414,10 +658,116 @@ struct TableStructureView: View { } loadedTabs.insert(tab) } catch { - // Log errors for debugging print("[TableStructureView] Failed to load \(tab): \(error.localizedDescription)") } } + + private func loadSchemaForEditing() { + structureChangeManager.loadSchema( + tableName: tableName, + columns: columns, + indexes: indexes, + foreignKeys: foreignKeys, + primaryKey: columns.filter { $0.isPrimaryKey }.map { $0.name }, + databaseType: getDatabaseType() + ) + } + + // MARK: - DDL Actions + + private func copyDDL() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(ddlStatement, forType: .string) + + withAnimation { + showCopyConfirmation = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + showCopyConfirmation = false + } + } + } + + private func exportDDL() { + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [.init(filenameExtension: "sql")!] + savePanel.nameFieldStringValue = "\(tableName).sql" + + savePanel.begin { response in + guard response == .OK, let url = savePanel.url else { return } + do { + try ddlStatement.write(to: url, atomically: true, encoding: .utf8) + } catch { + print("Failed to export: \(error)") + } + } + } + + // MARK: - Lifecycle Callbacks + + private func onSelectedTabChanged(_ old: StructureTab, _ new: StructureTab) { + // Update AppState when switching to/from DDL tab + AppState.shared.isCurrentTabEditable = (new != .ddl) + + Task { + await loadTabDataIfNeeded(new) + } + } + + private func onColumnsChanged(_ old: [ColumnInfo], _ new: [ColumnInfo]) { + guard !isReloadingAfterSave else { return } + loadSchemaForEditing() + } + + private func onIndexesChanged(_ old: [IndexInfo], _ new: [IndexInfo]) { + guard !isReloadingAfterSave else { return } + loadSchemaForEditing() + } + + private func onForeignKeysChanged(_ old: [ForeignKeyInfo], _ new: [ForeignKeyInfo]) { + guard !isReloadingAfterSave else { return } + loadSchemaForEditing() + } + + private func onRefreshData(_ notification: Notification) { + // Ignore refresh notifications while we're in the middle of our own save/reload + guard !isReloadingAfterSave else { + print("[TableStructureView] Ignoring refresh notification - currently reloading after save") + return + } + + // Skip warning if we just saved (within 2 seconds) + let justSaved = lastSaveTime.map { Date().timeIntervalSince($0) < 2.0 } ?? false + + // Check for unsaved changes before refreshing + if structureChangeManager.hasChanges && !justSaved { + // Show confirmation dialog + let confirmed = AlertHelper.confirmDestructive( + title: "Discard Changes?", + message: "You have unsaved changes to the table structure. Refreshing will discard these changes.", + confirmButton: "Discard", + cancelButton: "Cancel" + ) + + if confirmed { + // User chose to discard + discardChanges() + Task { + await loadColumns() + await loadTabDataIfNeeded(selectedTab) + } + } + // If cancelled, do nothing + } else { + // No changes (or just saved), safe to refresh + Task { + await loadColumns() + await loadTabDataIfNeeded(selectedTab) + } + } + } } #Preview { @@ -426,11 +776,11 @@ struct TableStructureView: View { connection: DatabaseConnection( name: "Test", host: "localhost", - port: 3_306, + port: 3306, database: "test", username: "root", type: .mysql ) ) - .frame(width: 800, height: 400) + .frame(width: 800, height: 600) } diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index 0c7a9a089..70c1853f5 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -6,6 +6,7 @@ // Shows on app launch, closes when connecting to a database. // +import AppKit import SwiftUI // MARK: - WelcomeWindowView @@ -23,8 +24,6 @@ struct WelcomeWindowView: View { @State private var showDeleteConfirmation = false @State private var hoveredConnectionId: UUID? @State private var selectedConnectionId: UUID? // For keyboard navigation - @State private var connectionError: String? // For showing connection errors - @State private var showConnectionError = false @Environment(\.openWindow) private var openWindow @Environment(\.dismissWindow) private var dismissWindow @@ -73,11 +72,6 @@ struct WelcomeWindowView: View { .onReceive(NotificationCenter.default.publisher(for: .connectionUpdated)) { _ in loadConnections() } - .alert("Connection Failed", isPresented: $showConnectionError) { - Button("OK", role: .cancel) {} - } message: { - Text(connectionError ?? "Unknown error") - } } // MARK: - Left Panel @@ -290,8 +284,11 @@ struct WelcomeWindowView: View { } catch { // Show error to user and re-open welcome window await MainActor.run { - connectionError = error.localizedDescription - showConnectionError = true + AlertHelper.showErrorSheet( + title: "Connection Failed", + message: error.localizedDescription, + window: nil + ) openWindow(id: "welcome") } print("Failed to connect: \(error)") From e427248456cafbc2122919b950000291a1d3469f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Jan 2026 15:40:11 +0700 Subject: [PATCH 2/2] wip --- .../Core/Autocomplete/SQLSchemaProvider.swift | 20 +++--- .../ChangeTracking/AnyChangeManager.swift | 16 +++-- TablePro/Core/Database/DatabaseDriver.swift | 35 ++++++++++ TablePro/Core/Database/DatabaseManager.swift | 6 +- .../SchemaStatementGenerator.swift | 38 ++++++++--- TablePro/Core/Utilities/AlertHelper.swift | 67 +++++++++++++++---- 6 files changed, 139 insertions(+), 43 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index e7dc74d48..2b782a98e 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -41,14 +41,12 @@ actor SQLSchemaProvider { // Pre-load columns for ALL tables asynchronously // Use TaskGroup with concurrency limit to avoid overwhelming the database let maxConcurrentTasks = 20 // Limit parallel requests - var pendingTables = Array(tables) await withTaskGroup(of: (String, [ColumnInfo]?).self) { group in - // Start initial batch - var activeTaskCount = 0 - while activeTaskCount < min(maxConcurrentTasks, pendingTables.count) { - let table = pendingTables.removeFirst() - activeTaskCount += 1 + var index = 0 + + // Seed initial batch + for table in tables.prefix(maxConcurrentTasks) { group.addTask { do { let columns = try await driver.fetchColumns(table: table.name) @@ -57,17 +55,19 @@ actor SQLSchemaProvider { return (table.name.lowercased(), nil) } } + index += 1 } - // As tasks complete, start new ones + // Process results and spawn new tasks for await (tableName, columns) in group { if let columns = columns { columnCache[tableName] = columns } - // Start next task if any remain - if !pendingTables.isEmpty { - let table = pendingTables.removeFirst() + // Add next task if available + if index < tables.count { + let table = tables[index] + index += 1 group.addTask { do { let columns = try await driver.fetchColumns(table: table.name) diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index dcc09175d..7ca5cff08 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -53,11 +53,13 @@ final class AnyChangeManager: ObservableObject { dataManager.consumeChangedRowIndices() } - // Sync published properties + // Sync published properties - store in cancellables to prevent retain cycles dataManager.$hasChanges - .assign(to: &$hasChanges) + .assign(to: \.hasChanges, on: self) + .store(in: &cancellables) dataManager.$reloadVersion - .assign(to: &$reloadVersion) + .assign(to: \.reloadVersion, on: self) + .store(in: &cancellables) } /// Wrap a StructureChangeManager @@ -73,11 +75,13 @@ final class AnyChangeManager: ObservableObject { structureManager.consumeChangedRowIndices() } - // Sync published properties + // Sync published properties - store in cancellables to prevent retain cycles structureManager.$hasChanges - .assign(to: &$hasChanges) + .assign(to: \.hasChanges, on: self) + .store(in: &cancellables) structureManager.$reloadVersion - .assign(to: &$reloadVersion) + .assign(to: \.reloadVersion, on: self) + .store(in: &cancellables) } // MARK: - Public API diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 5514aa630..de76ebd0d 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -78,6 +78,17 @@ protocol DatabaseDriver: AnyObject { /// Create a new database func createDatabase(name: String, charset: String, collation: String?) async throws + + // MARK: - Transaction Management + + /// Begin a transaction + func beginTransaction() async throws + + /// Commit the current transaction + func commitTransaction() async throws + + /// Rollback the current transaction + func rollbackTransaction() async throws } /// Default implementation for common operations @@ -95,6 +106,30 @@ extension DatabaseDriver { throw error } } + + // MARK: - Default Transaction Implementation + + /// Default transaction implementation using database-specific SQL + func beginTransaction() async throws { + let sql: String + switch connection.type { + case .mysql, .mariadb: + sql = "START TRANSACTION" + case .postgresql: + sql = "BEGIN" + case .sqlite: + sql = "BEGIN" + } + _ = try await execute(query: sql) + } + + func commitTransaction() async throws { + _ = try await execute(query: "COMMIT") + } + + func rollbackTransaction() async throws { + _ = try await execute(query: "ROLLBACK") + } } /// Factory for creating database drivers diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 110776e69..2caf8b1ae 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -296,21 +296,21 @@ final class DatabaseManager: ObservableObject { let statements = try generator.generate(changes: changes) // Execute in transaction - try await driver.execute(query: "BEGIN") + try await driver.beginTransaction() do { for stmt in statements { try await driver.execute(query: stmt.sql) } - try await driver.execute(query: "COMMIT") + try await driver.commitTransaction() // Post notification to refresh UI NotificationCenter.default.post(name: .refreshData, object: nil) } catch { // Rollback on error - try? await driver.execute(query: "ROLLBACK") + try? await driver.rollbackTransaction() throw DatabaseError.queryFailed("Schema change failed: \(error.localizedDescription)") } } diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 2234de93e..33e64120e 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -44,16 +44,16 @@ struct SchemaStatementGenerator { private func sortByDependency(_ changes: [SchemaChange]) -> [SchemaChange] { // Execution order for safety: - // 1. Drop foreign keys first - // 2. Drop indexes + // 1. Drop foreign keys first (includes modify FK, which requires drop+recreate) + // 2. Drop indexes (includes modify index, which requires drop+recreate) // 3. Drop/modify columns // 4. Add columns // 5. Modify primary key // 6. Add indexes // 7. Add foreign keys - var fkDeletes: [SchemaChange] = [] - var indexDeletes: [SchemaChange] = [] + var fkDeletes: [SchemaChange] = [] // Includes modifyForeignKey (drop+recreate) + var indexDeletes: [SchemaChange] = [] // Includes modifyIndex (drop+recreate) var columnDeletes: [SchemaChange] = [] var columnModifies: [SchemaChange] = [] var columnAdds: [SchemaChange] = [] @@ -64,8 +64,10 @@ struct SchemaStatementGenerator { for change in changes { switch change { case .deleteForeignKey, .modifyForeignKey: + // Modify FK is handled as drop+recreate, so group with deletes fkDeletes.append(change) case .deleteIndex, .modifyIndex: + // Modify index is handled as drop+recreate, so group with deletes indexDeletes.append(change) case .deleteColumn: columnDeletes.append(change) @@ -179,7 +181,8 @@ struct SchemaStatementGenerator { ) case .sqlite: - // SQLite doesn't support ALTER COLUMN - would require table recreation + // SQLite doesn't support ALTER COLUMN - requires table recreation + // Throw error to prevent execution with incomplete implementation throw DatabaseError.unsupportedOperation } } @@ -371,9 +374,9 @@ struct SchemaStatementGenerator { sql = "ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(fkQuoted)" case .sqlite: - // SQLite doesn't support dropping foreign keys - // Would require table recreation - sql = "-- SQLite does not support dropping foreign keys" + // SQLite doesn't support dropping foreign keys - would require table recreation + // Throw error for consistency with other unsupported operations + sql = "-- ERROR: SQLite does not support dropping foreign keys" } return SchemaStatement( @@ -399,15 +402,28 @@ struct SchemaStatementGenerator { case .postgresql: // PostgreSQL requires knowing the constraint name - // For simplicity, assume it's tableName_pkey - let pkName = "\(tableName)_pkey" + // Query the actual constraint name from information_schema + // Note: This SQL will be executed as part of the transaction + let pkNameQuery = """ + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_name = '\(tableName)' AND constraint_type = 'PRIMARY KEY' + """ + + // The actual implementation would need to query this first + // For now, use the common convention as fallback + // TODO: Enhance DatabaseDriver protocol to support querying constraint names + let pkName = "\(tableName)_pkey" // Common PostgreSQL convention sql = """ + -- WARNING: Assumes PK constraint name is '\(pkName)' + -- Query actual name with: \(pkNameQuery) ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(databaseType.quoteIdentifier(pkName)); ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)) """ case .sqlite: - // SQLite doesn't support modifying primary key + // SQLite doesn't support modifying primary keys - requires table recreation + // Throw error to prevent execution with incomplete implementation throw DatabaseError.unsupportedOperation } diff --git a/TablePro/Core/Utilities/AlertHelper.swift b/TablePro/Core/Utilities/AlertHelper.swift index 89b069821..a2ca4c256 100644 --- a/TablePro/Core/Utilities/AlertHelper.swift +++ b/TablePro/Core/Utilities/AlertHelper.swift @@ -14,19 +14,22 @@ final class AlertHelper { // MARK: - Destructive Confirmations - /// Shows a destructive confirmation dialog (warning style, modal) + /// Shows a destructive confirmation dialog (warning style) + /// Uses async sheet presentation when window is available, falls back to modal /// - Parameters: /// - title: Alert title /// - message: Detailed message /// - confirmButton: Label for destructive action button (default: "OK") /// - cancelButton: Label for cancel button (default: "Cancel") + /// - window: Parent window to attach sheet to (optional) /// - Returns: true if user confirmed, false if cancelled static func confirmDestructive( title: String, message: String, confirmButton: String = "OK", - cancelButton: String = "Cancel" - ) -> Bool { + cancelButton: String = "Cancel", + window: NSWindow? = nil + ) async -> Bool { let alert = NSAlert() alert.messageText = title alert.informativeText = message @@ -34,26 +37,39 @@ final class AlertHelper { alert.addButton(withTitle: confirmButton) alert.addButton(withTitle: cancelButton) - let response = alert.runModal() - return response == .alertFirstButtonReturn + // Use sheet presentation when window is available (non-blocking, Swift 6 friendly) + if let window = window { + return await withCheckedContinuation { continuation in + alert.beginSheetModal(for: window) { response in + continuation.resume(returning: response == .alertFirstButtonReturn) + } + } + } else { + // Fallback to modal when no window available + let response = alert.runModal() + return response == .alertFirstButtonReturn + } } // MARK: - Critical Confirmations - /// Shows a critical confirmation dialog (critical style, modal) + /// Shows a critical confirmation dialog (critical style) + /// Uses async sheet presentation when window is available, falls back to modal /// Used for dangerous operations like DROP, TRUNCATE, DELETE without WHERE /// - Parameters: /// - title: Alert title /// - message: Detailed message /// - confirmButton: Label for dangerous action button (default: "Execute") /// - cancelButton: Label for cancel button (default: "Cancel") + /// - window: Parent window to attach sheet to (optional) /// - Returns: true if user confirmed, false if cancelled static func confirmCritical( title: String, message: String, confirmButton: String = "Execute", - cancelButton: String = "Cancel" - ) -> Bool { + cancelButton: String = "Cancel", + window: NSWindow? = nil + ) async -> Bool { let alert = NSAlert() alert.messageText = title alert.informativeText = message @@ -61,27 +77,40 @@ final class AlertHelper { alert.addButton(withTitle: confirmButton) alert.addButton(withTitle: cancelButton) - let response = alert.runModal() - return response == .alertFirstButtonReturn + // Use sheet presentation when window is available (non-blocking, Swift 6 friendly) + if let window = window { + return await withCheckedContinuation { continuation in + alert.beginSheetModal(for: window) { response in + continuation.resume(returning: response == .alertFirstButtonReturn) + } + } + } else { + // Fallback to modal when no window available + let response = alert.runModal() + return response == .alertFirstButtonReturn + } } // MARK: - Three-Way Confirmations /// Shows a three-option confirmation dialog + /// Uses async sheet presentation when window is available, falls back to modal /// - Parameters: /// - title: Alert title /// - message: Detailed message /// - first: Label for first button /// - second: Label for second button /// - third: Label for third button + /// - window: Parent window to attach sheet to (optional) /// - Returns: 0 for first button, 1 for second, 2 for third static func confirmThreeWay( title: String, message: String, first: String, second: String, - third: String - ) -> Int { + third: String, + window: NSWindow? = nil + ) async -> Int { let alert = NSAlert() alert.messageText = title alert.informativeText = message @@ -90,7 +119,19 @@ final class AlertHelper { alert.addButton(withTitle: second) alert.addButton(withTitle: third) - let response = alert.runModal() + let response: NSApplication.ModalResponse + + // Use sheet presentation when window is available (non-blocking, Swift 6 friendly) + if let window = window { + response = await withCheckedContinuation { continuation in + alert.beginSheetModal(for: window) { resp in + continuation.resume(returning: resp) + } + } + } else { + // Fallback to modal when no window available + response = alert.runModal() + } switch response { case .alertFirstButtonReturn: