diff --git a/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate b/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate index 589dc62c..039de63f 100644 Binary files a/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate and b/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/OpenTable/AppDelegate.swift b/OpenTable/AppDelegate.swift index 6e27799f..e75fd937 100644 --- a/OpenTable/AppDelegate.swift +++ b/OpenTable/AppDelegate.swift @@ -61,11 +61,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Check if main window is being closed if isMainWindow(window) { - // CRITICAL: Save tab state SYNCHRONOUSLY before any async operations - // Otherwise sessions might be cleared before we save - saveAllTabStates() - - // NOW disconnect sessions asynchronously (after save is complete) + // CRITICAL: Post notification FIRST to allow MainContentView to flush pending saves + // This ensures query text is saved before SwiftUI tears down the view + NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) + + // Allow run loop to process notification handlers synchronously + // This is more elegant than Thread.sleep as it processes pending events + // rather than blocking the main thread entirely + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) + + // NOTE: We do NOT call saveAllTabStates() here because: + // 1. MainContentView already flushed the correct state via the notification above + // 2. By this point, SwiftUI may have torn down views and session.tabs could be stale/empty + // 3. Saving again would risk overwriting the good state with bad/empty state + + // Disconnect sessions asynchronously (after save is complete) Task { @MainActor in await DatabaseManager.shared.disconnectAll() } diff --git a/OpenTable/Core/ChangeTracking/DataChangeManager.swift b/OpenTable/Core/ChangeTracking/DataChangeManager.swift new file mode 100644 index 00000000..38f29947 --- /dev/null +++ b/OpenTable/Core/ChangeTracking/DataChangeManager.swift @@ -0,0 +1,596 @@ +// +// DataChangeManager.swift +// OpenTable +// +// Manager for tracking data changes with O(1) lookups. +// Delegates SQL generation to SQLStatementGenerator. +// Delegates undo/redo stack management to DataChangeUndoManager. +// + +import Combine +import Foundation + +/// Manager for tracking and applying data changes +/// @MainActor ensures thread-safe access - critical for avoiding EXC_BAD_ACCESS +/// when multiple queries complete simultaneously (e.g., rapid sorting over SSH tunnel) +@MainActor +final class DataChangeManager: ObservableObject { + @Published var changes: [RowChange] = [] + @Published var hasChanges: Bool = false + @Published var reloadVersion: Int = 0 // Incremented to trigger table reload + + var tableName: String = "" + var primaryKeyColumn: String? + var databaseType: DatabaseType = .mysql + + // Simple storage with explicit deep copy to avoid memory corruption + private var _columnsStorage: [String] = [] + var columns: [String] { + get { _columnsStorage } + set { _columnsStorage = newValue.map { String($0) } } + } + + // MARK: - Cached Lookups for O(1) Performance + + /// Set of row indices that are marked for deletion - O(1) lookup + private var deletedRowIndices: Set = [] + + /// Set of row indices that are newly inserted - O(1) lookup + private(set) var insertedRowIndices: Set = [] + + /// Set of "rowIndex-colIndex" strings for modified cells - O(1) lookup + private var modifiedCells: Set = [] + + /// Lazy storage for inserted row values - avoids creating CellChange objects until needed + private var insertedRowData: [Int: [String?]] = [:] + + /// Undo/redo manager + private let undoManager = DataChangeUndoManager() + + // MARK: - Undo/Redo Properties + + var canUndo: Bool { undoManager.canUndo } + var canRedo: Bool { undoManager.canRedo } + + // MARK: - Helper Methods + + private func cellKey(rowIndex: Int, columnIndex: Int) -> String { + "\(rowIndex)-\(columnIndex)" + } + + // MARK: - Configuration + + /// Clear all changes (called after successful save) + func clearChanges() { + changes.removeAll() + deletedRowIndices.removeAll() + insertedRowIndices.removeAll() + modifiedCells.removeAll() + insertedRowData.removeAll() + undoManager.clearAll() + hasChanges = false + reloadVersion += 1 + } + + /// Atomically configure the manager for a new table + func configureForTable( + tableName: String, + columns: [String], + primaryKeyColumn: String?, + databaseType: DatabaseType = .mysql + ) { + self.tableName = tableName + self.columns = columns + self.primaryKeyColumn = primaryKeyColumn + self.databaseType = databaseType + + deletedRowIndices.removeAll() + insertedRowIndices.removeAll() + modifiedCells.removeAll() + insertedRowData.removeAll() + undoManager.clearAll() + + changes.removeAll() + hasChanges = false + reloadVersion += 1 + } + + // MARK: - Change Tracking + + func recordCellChange( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String?, + originalRow: [String?]? = nil + ) { + guard oldValue != newValue else { return } + + let cellChange = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue + ) + + let key = cellKey(rowIndex: rowIndex, columnIndex: columnIndex) + + // Check if this is an edit to an INSERTED row + if let insertIndex = changes.firstIndex(where: { + $0.rowIndex == rowIndex && $0.type == .insert + }) { + // Update stored values directly + if var storedValues = insertedRowData[rowIndex] { + if columnIndex < storedValues.count { + storedValues[columnIndex] = newValue + insertedRowData[rowIndex] = storedValues + } + } + + // Update/create CellChange for this column + if let cellIndex = changes[insertIndex].cellChanges.firstIndex(where: { + $0.columnIndex == columnIndex + }) { + changes[insertIndex].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: nil, + newValue: newValue + ) + } else { + changes[insertIndex].cellChanges.append(CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: nil, + newValue: newValue + )) + } + pushUndo(.cellEdit( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + previousValue: oldValue, + newValue: newValue + )) + hasChanges = !changes.isEmpty + return + } + + // Find existing UPDATE row change or create new one + if let existingIndex = changes.firstIndex(where: { + $0.rowIndex == rowIndex && $0.type == .update + }) { + if let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { + $0.columnIndex == columnIndex + }) { + let originalOldValue = changes[existingIndex].cellChanges[cellIndex].oldValue + changes[existingIndex].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: originalOldValue, + newValue: newValue + ) + + // If value is back to original, remove the change + if originalOldValue == newValue { + changes[existingIndex].cellChanges.remove(at: cellIndex) + modifiedCells.remove(key) + if changes[existingIndex].cellChanges.isEmpty { + changes.remove(at: existingIndex) + } + } + } else { + changes[existingIndex].cellChanges.append(cellChange) + modifiedCells.insert(key) + } + } else { + let rowChange = RowChange( + rowIndex: rowIndex, + type: .update, + cellChanges: [cellChange], + originalRow: originalRow + ) + changes.append(rowChange) + modifiedCells.insert(key) + } + + pushUndo(.cellEdit( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + previousValue: oldValue, + newValue: newValue + )) + hasChanges = !changes.isEmpty + } + + func recordRowDeletion(rowIndex: Int, originalRow: [String?]) { + changes.removeAll { $0.rowIndex == rowIndex && $0.type == .update } + modifiedCells = modifiedCells.filter { !$0.hasPrefix("\(rowIndex)-") } + + let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) + changes.append(rowChange) + deletedRowIndices.insert(rowIndex) + pushUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + hasChanges = true + reloadVersion += 1 + } + + func recordBatchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) { + guard rows.count > 1 else { + if let row = rows.first { + recordRowDeletion(rowIndex: row.rowIndex, originalRow: row.originalRow) + } + return + } + + var batchData: [(rowIndex: Int, originalRow: [String?])] = [] + + for (rowIndex, originalRow) in rows { + changes.removeAll { $0.rowIndex == rowIndex && $0.type == .update } + modifiedCells = modifiedCells.filter { !$0.hasPrefix("\(rowIndex)-") } + + let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) + changes.append(rowChange) + deletedRowIndices.insert(rowIndex) + batchData.append((rowIndex: rowIndex, originalRow: originalRow)) + } + + pushUndo(.batchRowDeletion(rows: batchData)) + hasChanges = true + reloadVersion += 1 + } + + func recordRowInsertion(rowIndex: Int, values: [String?]) { + insertedRowData[rowIndex] = values + let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: []) + changes.append(rowChange) + insertedRowIndices.insert(rowIndex) + pushUndo(.rowInsertion(rowIndex: rowIndex)) + hasChanges = true + } + + // MARK: - Undo Operations + + func undoRowDeletion(rowIndex: Int) { + guard deletedRowIndices.contains(rowIndex) else { return } + changes.removeAll { $0.rowIndex == rowIndex && $0.type == .delete } + deletedRowIndices.remove(rowIndex) + hasChanges = !changes.isEmpty + reloadVersion += 1 + } + + func undoRowInsertion(rowIndex: Int) { + guard insertedRowIndices.contains(rowIndex) else { return } + + changes.removeAll { $0.rowIndex == rowIndex && $0.type == .insert } + insertedRowIndices.remove(rowIndex) + insertedRowData.removeValue(forKey: rowIndex) + + // Shift down indices for rows after the removed row + var shiftedInsertedIndices = Set() + for idx in insertedRowIndices { + shiftedInsertedIndices.insert(idx > rowIndex ? idx - 1 : idx) + } + insertedRowIndices = shiftedInsertedIndices + + for i in 0.. rowIndex { + changes[i].rowIndex -= 1 + } + } + + hasChanges = !changes.isEmpty + } + + func undoBatchRowInsertion(rowIndices: [Int]) { + guard !rowIndices.isEmpty else { return } + + let validRows = rowIndices.filter { insertedRowIndices.contains($0) } + guard !validRows.isEmpty else { return } + + // Collect row values for undo/redo + var rowValues: [[String?]] = [] + for rowIndex in validRows { + if let insertChange = changes.first(where: { $0.rowIndex == rowIndex && $0.type == .insert }) { + let values = insertChange.cellChanges.sorted { $0.columnIndex < $1.columnIndex } + .map { $0.newValue } + rowValues.append(values) + } else { + rowValues.append(Array(repeating: nil, count: columns.count)) + } + } + + for rowIndex in validRows { + changes.removeAll { $0.rowIndex == rowIndex && $0.type == .insert } + insertedRowIndices.remove(rowIndex) + insertedRowData.removeValue(forKey: rowIndex) + } + + pushUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) + + for deletedIndex in validRows.reversed() { + var shiftedIndices = Set() + for idx in insertedRowIndices { + shiftedIndices.insert(idx > deletedIndex ? idx - 1 : idx) + } + insertedRowIndices = shiftedIndices + + for i in 0.. deletedIndex { + changes[i].rowIndex -= 1 + } + } + } + + hasChanges = !changes.isEmpty + } + + // MARK: - Undo/Redo Stack Management + + func pushUndo(_ action: UndoAction) { + undoManager.push(action) + } + + func popUndo() -> UndoAction? { + undoManager.popUndo() + } + + func clearUndoStack() { + undoManager.clearUndo() + } + + func clearRedoStack() { + undoManager.clearRedo() + } + + /// Undo the last change and return details needed to update the UI + func undoLastChange() -> (action: UndoAction, needsRowRemoval: Bool, needsRowRestore: Bool, restoreRow: [String?]?)? { + guard let action = popUndo() else { return nil } + + undoManager.moveToRedo(action) + + switch action { + case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, _): + if let changeIndex = changes.firstIndex(where: { + $0.rowIndex == rowIndex && ($0.type == .update || $0.type == .insert) + }) { + if let cellIndex = changes[changeIndex].cellChanges.firstIndex(where: { + $0.columnIndex == columnIndex + }) { + if changes[changeIndex].type == .update { + let originalValue = changes[changeIndex].cellChanges[cellIndex].oldValue + if previousValue == originalValue { + changes[changeIndex].cellChanges.remove(at: cellIndex) + modifiedCells.remove(cellKey(rowIndex: rowIndex, columnIndex: columnIndex)) + if changes[changeIndex].cellChanges.isEmpty { + changes.remove(at: changeIndex) + } + } else { + let originalOldValue = changes[changeIndex].cellChanges[cellIndex].oldValue + changes[changeIndex].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: originalOldValue, + newValue: previousValue + ) + } + } else if changes[changeIndex].type == .insert { + changes[changeIndex].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: nil, + newValue: previousValue + ) + } + } + } + hasChanges = !changes.isEmpty + reloadVersion += 1 + return (action, false, false, nil) + + case .rowInsertion(let rowIndex): + undoRowInsertion(rowIndex: rowIndex) + return (action, true, false, nil) + + case .rowDeletion(let rowIndex, let originalRow): + undoRowDeletion(rowIndex: rowIndex) + return (action, false, true, originalRow) + + case .batchRowDeletion(let rows): + for (rowIndex, _) in rows.reversed() { + undoRowDeletion(rowIndex: rowIndex) + } + return (action, false, true, nil) + + case .batchRowInsertion(let rowIndices, let rowValues): + for (index, rowIndex) in rowIndices.enumerated().reversed() { + guard index < rowValues.count else { continue } + let values = rowValues[index] + + let cellChanges = values.enumerated().map { colIndex, value in + CellChange( + rowIndex: rowIndex, + columnIndex: colIndex, + columnName: columns[safe: colIndex] ?? "", + oldValue: nil, + newValue: value + ) + } + let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) + changes.append(rowChange) + insertedRowIndices.insert(rowIndex) + } + + hasChanges = !changes.isEmpty + reloadVersion += 1 + return (action, true, false, nil) + } + } + + /// Redo the last undone change + func redoLastChange() -> (action: UndoAction, needsRowInsert: Bool, needsRowDelete: Bool)? { + guard let action = undoManager.popRedo() else { return nil } + + undoManager.moveToUndo(action) + + switch action { + case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue): + recordCellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: previousValue, + newValue: newValue + ) + _ = undoManager.popUndo() // Remove extra undo + reloadVersion += 1 + return (action, false, false) + + case .rowInsertion(let rowIndex): + insertedRowIndices.insert(rowIndex) + let cellChanges = columns.enumerated().map { index, columnName in + CellChange( + rowIndex: rowIndex, + columnIndex: index, + columnName: columnName, + oldValue: nil, + newValue: nil + ) + } + let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) + changes.append(rowChange) + hasChanges = true + reloadVersion += 1 + return (action, true, false) + + case .rowDeletion(let rowIndex, let originalRow): + recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + _ = undoManager.popUndo() + return (action, false, true) + + case .batchRowDeletion(let rows): + for (rowIndex, originalRow) in rows { + recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + _ = undoManager.popUndo() + } + return (action, false, true) + + case .batchRowInsertion(let rowIndices, _): + for rowIndex in rowIndices { + changes.removeAll { $0.rowIndex == rowIndex && $0.type == .insert } + insertedRowIndices.remove(rowIndex) + } + hasChanges = !changes.isEmpty + reloadVersion += 1 + return (action, true, false) + } + } + + // MARK: - SQL Generation + + func generateSQL() -> [String] { + let generator = SQLStatementGenerator( + tableName: tableName, + columns: columns, + primaryKeyColumn: primaryKeyColumn, + databaseType: databaseType + ) + return generator.generateStatements( + from: changes, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + + // MARK: - Actions + + func getOriginalValues() -> [(rowIndex: Int, columnIndex: Int, value: String?)] { + var originals: [(rowIndex: Int, columnIndex: Int, value: String?)] = [] + + for change in changes { + if change.type == .update { + for cellChange in change.cellChanges { + originals.append(( + rowIndex: change.rowIndex, + columnIndex: cellChange.columnIndex, + value: cellChange.oldValue + )) + } + } + } + + return originals + } + + func discardChanges() { + changes.removeAll() + deletedRowIndices.removeAll() + insertedRowIndices.removeAll() + modifiedCells.removeAll() + insertedRowData.removeAll() + hasChanges = false + reloadVersion += 1 + } + + // MARK: - Per-Tab State Management + + func saveState() -> TabPendingChanges { + var state = TabPendingChanges() + state.changes = changes + state.deletedRowIndices = deletedRowIndices + state.insertedRowIndices = insertedRowIndices + state.modifiedCells = modifiedCells + state.insertedRowData = insertedRowData + state.primaryKeyColumn = primaryKeyColumn + state.columns = columns + return state + } + + func restoreState(from state: TabPendingChanges, tableName: String) { + self.tableName = tableName + self.changes = state.changes + self.deletedRowIndices = state.deletedRowIndices + self.insertedRowIndices = state.insertedRowIndices + self.modifiedCells = state.modifiedCells + self.insertedRowData = state.insertedRowData + self.primaryKeyColumn = state.primaryKeyColumn + self.columns = state.columns + self.hasChanges = !state.changes.isEmpty + } + + // MARK: - O(1) Lookups + + func isRowDeleted(_ rowIndex: Int) -> Bool { + deletedRowIndices.contains(rowIndex) + } + + func isRowInserted(_ rowIndex: Int) -> Bool { + insertedRowIndices.contains(rowIndex) + } + + func isCellModified(rowIndex: Int, columnIndex: Int) -> Bool { + modifiedCells.contains(cellKey(rowIndex: rowIndex, columnIndex: columnIndex)) + } + + func getModifiedColumnsForRow(_ rowIndex: Int) -> Set { + var result: Set = [] + let prefix = "\(rowIndex)-" + for key in modifiedCells { + if key.hasPrefix(prefix) { + if let colIndex = Int(key.dropFirst(prefix.count)) { + result.insert(colIndex) + } + } + } + return result + } +} diff --git a/OpenTable/Core/ChangeTracking/DataChangeModels.swift b/OpenTable/Core/ChangeTracking/DataChangeModels.swift new file mode 100644 index 00000000..c8af3050 --- /dev/null +++ b/OpenTable/Core/ChangeTracking/DataChangeModels.swift @@ -0,0 +1,90 @@ +// +// DataChangeModels.swift +// OpenTable +// +// Pure data models for tracking data changes. +// No business logic - just structures for representing change state. +// + +import Foundation + +/// Represents a type of data change +enum ChangeType: Equatable { + case update + case insert + case delete +} + +/// Represents a single cell change +struct CellChange: Identifiable, Equatable { + let id: UUID + let rowIndex: Int + let columnIndex: Int + let columnName: String + let oldValue: String? + let newValue: String? + + init( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String? + ) { + self.id = UUID() + self.rowIndex = rowIndex + self.columnIndex = columnIndex + self.columnName = columnName + self.oldValue = oldValue + self.newValue = newValue + } +} + +/// Represents a row-level change +struct RowChange: Identifiable, Equatable { + let id: UUID + var rowIndex: Int + let type: ChangeType + var cellChanges: [CellChange] + let originalRow: [String?]? + + init( + rowIndex: Int, + type: ChangeType, + cellChanges: [CellChange] = [], + originalRow: [String?]? = nil + ) { + self.id = UUID() + self.rowIndex = rowIndex + self.type = type + self.cellChanges = cellChanges + self.originalRow = originalRow + } +} + +/// Represents an action that can be undone +enum UndoAction { + case cellEdit( + rowIndex: Int, + columnIndex: Int, + columnName: String, + previousValue: String?, + newValue: String? + ) + case rowInsertion(rowIndex: Int) + case rowDeletion(rowIndex: Int, originalRow: [String?]) + /// Batch deletion of multiple rows (for undo as a single action) + case batchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) + /// Batch insertion undo - when user deletes multiple inserted rows at once + case batchRowInsertion(rowIndices: [Int], rowValues: [[String?]]) +} + +// Note: TabPendingChanges is defined in QueryTab.swift + +// MARK: - Array Extension + +extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/OpenTable/Core/ChangeTracking/DataChangeUndoManager.swift b/OpenTable/Core/ChangeTracking/DataChangeUndoManager.swift new file mode 100644 index 00000000..afcd1aff --- /dev/null +++ b/OpenTable/Core/ChangeTracking/DataChangeUndoManager.swift @@ -0,0 +1,83 @@ +// +// DataChangeUndoManager.swift +// OpenTable +// +// Manages undo/redo stacks for data changes. +// Extracted from DataChangeManager to improve separation of concerns. +// + +import Foundation + +/// Manages undo/redo stacks for data changes +final class DataChangeUndoManager { + /// Undo stack for reversing changes (LIFO) + private var undoStack: [UndoAction] = [] + + /// Redo stack for re-applying undone changes (LIFO) + private var redoStack: [UndoAction] = [] + + // MARK: - Public API + + /// Check if there are any undo actions available + var canUndo: Bool { + !undoStack.isEmpty + } + + /// Check if there are any redo actions available + var canRedo: Bool { + !redoStack.isEmpty + } + + /// Push an undo action onto the stack + /// Clears the redo stack since new changes invalidate redo history + func push(_ action: UndoAction) { + undoStack.append(action) + // Don't clear redo here - let caller decide when to clear + } + + /// Pop the last undo action from the stack + func popUndo() -> UndoAction? { + undoStack.popLast() + } + + /// Pop the last redo action from the stack + func popRedo() -> UndoAction? { + redoStack.popLast() + } + + /// Move an action from undo to redo stack + func moveToRedo(_ action: UndoAction) { + redoStack.append(action) + } + + /// Move an action from redo to undo stack + func moveToUndo(_ action: UndoAction) { + undoStack.append(action) + } + + /// Clear the undo stack + func clearUndo() { + undoStack.removeAll() + } + + /// Clear the redo stack (called when new changes are made) + func clearRedo() { + redoStack.removeAll() + } + + /// Clear both stacks + func clearAll() { + undoStack.removeAll() + redoStack.removeAll() + } + + /// Get the count of undo actions + var undoCount: Int { + undoStack.count + } + + /// Get the count of redo actions + var redoCount: Int { + redoStack.count + } +} diff --git a/OpenTable/Core/ChangeTracking/SQLStatementGenerator.swift b/OpenTable/Core/ChangeTracking/SQLStatementGenerator.swift new file mode 100644 index 00000000..7c0f75b5 --- /dev/null +++ b/OpenTable/Core/ChangeTracking/SQLStatementGenerator.swift @@ -0,0 +1,345 @@ +// +// SQLStatementGenerator.swift +// OpenTable +// +// Generates SQL statements (INSERT, UPDATE, DELETE) from tracked changes. +// Extracted from DataChangeManager to improve separation of concerns. +// + +import Foundation + +/// Generates SQL statements from data changes +struct SQLStatementGenerator { + let tableName: String + let columns: [String] + let primaryKeyColumn: String? + let databaseType: DatabaseType + + // MARK: - Public API + + /// Generate all SQL statements from changes + /// - Parameters: + /// - changes: Array of row changes to process + /// - insertedRowData: Lazy storage for inserted row values + /// - deletedRowIndices: Set of deleted row indices for validation + /// - insertedRowIndices: Set of inserted row indices for validation + /// - Returns: Array of SQL statement strings + func generateStatements( + from changes: [RowChange], + insertedRowData: [Int: [String?]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [String] { + var statements: [String] = [] + + // Collect UPDATE and DELETE changes to batch them + var updateChanges: [RowChange] = [] + var deleteChanges: [RowChange] = [] + + for change in changes { + switch change.type { + case .update: + updateChanges.append(change) + case .insert: + // SAFETY: Verify the row is still marked as inserted + guard insertedRowIndices.contains(change.rowIndex) else { + continue + } + if let sql = generateInsertSQL(for: change, insertedRowData: insertedRowData) { + statements.append(sql) + } + case .delete: + // SAFETY: Verify the row is still marked as deleted + guard deletedRowIndices.contains(change.rowIndex) else { + continue + } + deleteChanges.append(change) + } + } + + // Generate batched UPDATE statements (group by same columns being updated) + if !updateChanges.isEmpty { + let batchedUpdates = generateBatchUpdateSQL(for: updateChanges) + statements.append(contentsOf: batchedUpdates) + } + + // Generate batched DELETE statement (single DELETE with OR conditions) + if !deleteChanges.isEmpty { + if let sql = generateBatchDeleteSQL(for: deleteChanges) { + statements.append(sql) + } + } + + return statements + } + + // MARK: - INSERT Generation + + private func generateInsertSQL(for change: RowChange, insertedRowData: [Int: [String?]]) -> String? { + // OPTIMIZATION: Get values from lazy storage instead of cellChanges + if let values = insertedRowData[change.rowIndex] { + return generateInsertSQLFromStoredData(rowIndex: change.rowIndex, values: values) + } + + // Fallback: use cellChanges if stored data not available (backward compatibility) + return generateInsertSQLFromCellChanges(for: change) + } + + /// Generate INSERT SQL from lazy-stored row data (optimized path) + private func generateInsertSQLFromStoredData(rowIndex: Int, values: [String?]) -> String? { + var nonDefaultColumns: [String] = [] + var nonDefaultValues: [String] = [] + + for (index, value) in values.enumerated() { + // Skip DEFAULT columns - let DB handle them + if value == "__DEFAULT__" { continue } + + guard index < columns.count else { continue } + let columnName = columns[index] + + nonDefaultColumns.append(databaseType.quoteIdentifier(columnName)) + + if let val = value { + if isSQLFunctionExpression(val) { + nonDefaultValues.append(val.trimmingCharacters(in: .whitespaces).uppercased()) + } else { + nonDefaultValues.append("'\(escapeSQLString(val))'") + } + } else { + nonDefaultValues.append("NULL") + } + } + + // If all columns are DEFAULT, don't generate INSERT + guard !nonDefaultColumns.isEmpty else { return nil } + + let columnList = nonDefaultColumns.joined(separator: ", ") + let valueList = nonDefaultValues.joined(separator: ", ") + + return "INSERT INTO \(databaseType.quoteIdentifier(tableName)) (\(columnList)) VALUES (\(valueList))" + } + + /// Generate INSERT SQL from cellChanges (fallback for backward compatibility) + private func generateInsertSQLFromCellChanges(for change: RowChange) -> String? { + guard !change.cellChanges.isEmpty else { return nil } + + // Filter out DEFAULT columns - let DB handle them + let nonDefaultChanges = change.cellChanges.filter { + $0.newValue != "__DEFAULT__" + } + + // If all columns are DEFAULT, don't generate INSERT + guard !nonDefaultChanges.isEmpty else { return nil } + + let columnNames = nonDefaultChanges.map { + databaseType.quoteIdentifier($0.columnName) + }.joined(separator: ", ") + + let values = nonDefaultChanges.map { cellChange -> String in + if let newValue = cellChange.newValue { + if isSQLFunctionExpression(newValue) { + return newValue.trimmingCharacters(in: .whitespaces).uppercased() + } + return "'\(escapeSQLString(newValue))'" + } + return "NULL" + }.joined(separator: ", ") + + return "INSERT INTO \(databaseType.quoteIdentifier(tableName)) (\(columnNames)) VALUES (\(values))" + } + + // MARK: - UPDATE Generation + + /// Generate batched UPDATE statements grouped by columns being updated + /// Example: UPDATE table SET col1 = CASE WHEN id=1 THEN 'val1' WHEN id=2 THEN 'val2' END WHERE id IN (1,2) + private func generateBatchUpdateSQL(for changes: [RowChange]) -> [String] { + guard !changes.isEmpty else { return [] } + guard let pkColumn = primaryKeyColumn else { + // Fallback to individual UPDATEs if no PK + return changes.compactMap { generateUpdateSQL(for: $0) } + } + guard let pkIndex = columns.firstIndex(of: pkColumn) else { + return changes.compactMap { generateUpdateSQL(for: $0) } + } + + // Group changes by set of columns being updated + var grouped: [[String]: [RowChange]] = [:] + for change in changes { + let columnNames = change.cellChanges.map { $0.columnName }.sorted() + grouped[columnNames, default: []].append(change) + } + + var statements: [String] = [] + + for (columnNames, groupedChanges) in grouped { + // Build CASE statements for each column + var caseClauses: [String] = [] + + for columnName in columnNames { + var whenClauses: [String] = [] + + for change in groupedChanges { + guard let originalRow = change.originalRow, + pkIndex < originalRow.count, + let cellChange = change.cellChanges.first(where: { $0.columnName == columnName }) else { + continue + } + + let pkValue = originalRow[pkIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" + + // Generate value + let value: String + if cellChange.newValue == "__DEFAULT__" { + value = "DEFAULT" + } else if let newValue = cellChange.newValue { + if isSQLFunctionExpression(newValue) { + value = newValue.trimmingCharacters(in: .whitespaces).uppercased() + } else { + value = "'\(escapeSQLString(newValue))'" + } + } else { + value = "NULL" + } + + whenClauses.append("WHEN \(databaseType.quoteIdentifier(pkColumn)) = \(pkValue) THEN \(value)") + } + + if !whenClauses.isEmpty { + let caseExpr = "CASE \(whenClauses.joined(separator: " ")) END" + caseClauses.append("\(databaseType.quoteIdentifier(columnName)) = \(caseExpr)") + } + } + + // Build WHERE IN clause with all PKs + var pkValues: [String] = [] + for change in groupedChanges { + guard let originalRow = change.originalRow, + pkIndex < originalRow.count else { + continue + } + let pkValue = originalRow[pkIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" + pkValues.append(pkValue) + } + + if !caseClauses.isEmpty && !pkValues.isEmpty { + let whereClause = "\(databaseType.quoteIdentifier(pkColumn)) IN (\(pkValues.joined(separator: ", ")))" + let sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(caseClauses.joined(separator: ", ")) WHERE \(whereClause)" + statements.append(sql) + } + } + + return statements + } + + /// Generate individual UPDATE statement for a single row (fallback) + private func generateUpdateSQL(for change: RowChange) -> String? { + guard !change.cellChanges.isEmpty else { return nil } + + let setClauses = change.cellChanges.map { cellChange -> String in + let value: String + if cellChange.newValue == "__DEFAULT__" { + value = "DEFAULT" + } else if let newValue = cellChange.newValue { + if isSQLFunctionExpression(newValue) { + value = newValue.trimmingCharacters(in: .whitespaces).uppercased() + } else { + value = "'\(escapeSQLString(newValue))'" + } + } else { + value = "NULL" + } + return "\(databaseType.quoteIdentifier(cellChange.columnName)) = \(value)" + }.joined(separator: ", ") + + // Use primary key for WHERE clause + var whereClause = "1=1" // Fallback - dangerous but necessary without PK + + if let pkColumn = primaryKeyColumn, + let pkColumnIndex = columns.firstIndex(of: pkColumn) { + // Try to get PK value from originalRow first + if let originalRow = change.originalRow, pkColumnIndex < originalRow.count { + let pkValue = originalRow[pkColumnIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" + whereClause = "\(databaseType.quoteIdentifier(pkColumn)) = \(pkValue)" + } + // Otherwise try from cellChanges (if PK column was edited) + else if let pkChange = change.cellChanges.first(where: { $0.columnName == pkColumn }) { + let pkValue = pkChange.oldValue.map { "'\(escapeSQLString($0))'" } ?? "NULL" + whereClause = "\(databaseType.quoteIdentifier(pkColumn)) = \(pkValue)" + } + } + + return "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" + } + + // MARK: - DELETE Generation + + /// Generate a batched DELETE statement combining multiple rows with OR conditions + /// Example: DELETE FROM table WHERE id = 1 OR id = 2 OR id = 3 + private func generateBatchDeleteSQL(for changes: [RowChange]) -> String? { + guard !changes.isEmpty else { return nil } + guard let pkColumn = primaryKeyColumn else { return nil } + guard let pkIndex = columns.firstIndex(of: pkColumn) else { return nil } + + // Build OR conditions for all rows + var conditions: [String] = [] + + for change in changes { + guard let originalRow = change.originalRow, + pkIndex < originalRow.count else { + continue + } + + let pkValue = originalRow[pkIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" + conditions.append("\(databaseType.quoteIdentifier(pkColumn)) = \(pkValue)") + } + + guard !conditions.isEmpty else { return nil } + + // Combine all conditions with OR + let whereClause = conditions.joined(separator: " OR ") + return "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" + } + + // MARK: - Helper Functions + + /// Check if a string is a SQL function expression that should not be quoted + private func isSQLFunctionExpression(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespaces).uppercased() + + // Common SQL functions for datetime/timestamps + let sqlFunctions = [ + "NOW()", + "CURRENT_TIMESTAMP()", + "CURRENT_TIMESTAMP", + "CURDATE()", + "CURTIME()", + "UTC_TIMESTAMP()", + "UTC_DATE()", + "UTC_TIME()", + "LOCALTIME()", + "LOCALTIME", + "LOCALTIMESTAMP()", + "LOCALTIMESTAMP", + "SYSDATE()", + "UNIX_TIMESTAMP()", + "CURRENT_DATE()", + "CURRENT_DATE", + "CURRENT_TIME()", + "CURRENT_TIME", + ] + + return sqlFunctions.contains(trimmed) + } + + /// Escape characters that can break SQL strings + private func escapeSQLString(_ str: String) -> String { + var result = str + result = result.replacingOccurrences(of: "\\", with: "\\\\") // Backslash first + result = result.replacingOccurrences(of: "'", with: "''") // Single quote + result = result.replacingOccurrences(of: "\n", with: "\\n") // Newline + result = result.replacingOccurrences(of: "\r", with: "\\r") // Carriage return + result = result.replacingOccurrences(of: "\t", with: "\\t") // Tab + result = result.replacingOccurrences(of: "\0", with: "\\0") // Null byte + return result + } +} diff --git a/OpenTable/Core/Services/QueryExecutionService.swift b/OpenTable/Core/Services/QueryExecutionService.swift new file mode 100644 index 00000000..cea85a1e --- /dev/null +++ b/OpenTable/Core/Services/QueryExecutionService.swift @@ -0,0 +1,251 @@ +// +// QueryExecutionService.swift +// OpenTable +// +// Service responsible for query execution, parsing, and SQL statement extraction. +// Extracted from MainContentView for better separation of concerns. +// + +import Combine +import Foundation + +/// Service for executing database queries and parsing SQL +@MainActor +final class QueryExecutionService: ObservableObject { + + // MARK: - Published State + + @Published var isExecuting: Bool = false + @Published var executionTime: TimeInterval? + @Published var errorMessage: String? + + // MARK: - Private State + + private var currentTask: Task? + private var queryGeneration: Int = 0 + + // MARK: - Query Execution + + /// Execute a query and return results via callbacks + /// - Parameters: + /// - sql: The SQL query to execute + /// - connection: Database connection configuration + /// - tableName: Optional table name for editable queries + /// - onSuccess: Callback with query result + /// - onError: Callback with error + func execute( + sql: String, + connection: DatabaseConnection, + tableName: String?, + onSuccess: @escaping (QueryExecutionResult) async -> Void, + onError: @escaping (Error) async -> Void + ) { + // Don't execute empty queries + guard !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + isExecuting = false + return + } + + // Cancel any previous running query + currentTask?.cancel() + + // Increment generation for race condition prevention + queryGeneration += 1 + let capturedGeneration = queryGeneration + + isExecuting = true + executionTime = nil + errorMessage = nil + + let isEditable = tableName != nil + + currentTask = Task { + do { + let result = try await DatabaseManager.shared.execute(query: sql) + + // Fetch column defaults and total row count if editable table + var columnDefaults: [String: String?] = [:] + var totalRowCount: Int? = nil + + if isEditable, let tableName = tableName { + if let driver = DatabaseManager.shared.activeDriver { + // Execute both queries in parallel for better performance + async let columnInfoTask = driver.fetchColumns(table: tableName) + async let countTask: QueryResult = { + let quotedTable = connection.type.quoteIdentifier(tableName) + return try await DatabaseManager.shared.execute(query: "SELECT COUNT(*) FROM \(quotedTable)") + }() + + let (columnInfo, countResult) = try await (columnInfoTask, countTask) + + for col in columnInfo { + columnDefaults[col.name] = col.defaultValue + } + + if let firstRow = countResult.rows.first, + let countStr = firstRow.first as? String, + let count = Int(countStr) { + totalRowCount = count + } + } + } + + // 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, + executionTime: result.executionTime, + columnDefaults: columnDefaults.mapValues { $0.map { String($0) } }, + totalRowCount: totalRowCount, + tableName: tableName.map { String($0) }, + isEditable: isEditable + ) + + // Check for cancellation + guard !Task.isCancelled else { + await MainActor.run { + isExecuting = false + executionTime = safeResult.executionTime + } + return + } + + // Check generation for race conditions + guard capturedGeneration == queryGeneration else { + return + } + + await MainActor.run { + isExecuting = false + executionTime = safeResult.executionTime + } + + await onSuccess(safeResult) + + } catch { + guard capturedGeneration == queryGeneration else { return } + + await MainActor.run { + isExecuting = false + errorMessage = error.localizedDescription + } + + await onError(error) + } + } + } + + /// Cancel any running query + func cancel() { + currentTask?.cancel() + currentTask = nil + isExecuting = false + } + + // MARK: - SQL Parsing + + /// Extract the SQL statement at the cursor position (semicolon-delimited) + /// Enables TablePlus-like behavior: execute only the current query + func extractQueryAtCursor(from fullQuery: String, at position: Int) -> String { + let trimmed = fullQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + + // If no semicolons, return the entire query + guard trimmed.contains(";") else { return trimmed } + + // Split by semicolon but keep track of positions + var statements: [(text: String, range: Range)] = [] + var currentStart = 0 + var inString = false + var stringChar: Character = "\"" + + for (i, char) in fullQuery.enumerated() { + // Track string literals to avoid splitting on semicolons inside strings + if char == "'" || char == "\"" { + if !inString { + inString = true + stringChar = char + } else if char == stringChar { + inString = false + } + } + + // Found a statement delimiter + if char == ";" && !inString { + let statement = String( + fullQuery[ + fullQuery.index(fullQuery.startIndex, offsetBy: currentStart).. String? { + let pattern = #"(?i)^\s*SELECT\s+.+?\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE|ORDER|LIMIT|GROUP|HAVING|$|;)"# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + let match = regex.firstMatch(in: sql, options: [], range: NSRange(sql.startIndex..., in: sql)), + let range = Range(match.range(at: 1), in: sql) + else { + return nil + } + + return String(sql[range]) + } +} + +// MARK: - Query Execution Result + +/// Result of a query execution with all necessary data +struct QueryExecutionResult { + let columns: [String] + let rows: [QueryResultRow] + let executionTime: TimeInterval + let columnDefaults: [String: String?] + let totalRowCount: Int? + let tableName: String? + let isEditable: Bool +} diff --git a/OpenTable/Core/Services/RowOperationsManager.swift b/OpenTable/Core/Services/RowOperationsManager.swift new file mode 100644 index 00000000..5acfea58 --- /dev/null +++ b/OpenTable/Core/Services/RowOperationsManager.swift @@ -0,0 +1,312 @@ +// +// RowOperationsManager.swift +// OpenTable +// +// Service responsible for row operations: add, delete, duplicate, undo/redo. +// Extracted from MainContentView for better separation of concerns. +// + +import AppKit +import Foundation + +/// Manager for row operations in the data grid +@MainActor +final class RowOperationsManager { + + // MARK: - Dependencies + + private let changeManager: DataChangeManager + + // MARK: - Initialization + + init(changeManager: DataChangeManager) { + self.changeManager = changeManager + } + + // MARK: - Add Row + + /// Add a new row to a table tab + /// - Parameters: + /// - columns: Column names + /// - columnDefaults: Column default values + /// - resultRows: Current rows (will be mutated) + /// - Returns: Tuple of (newRowIndex, newRowValues) or nil if failed + func addNewRow( + columns: [String], + columnDefaults: [String: String?], + resultRows: inout [QueryResultRow] + ) -> (rowIndex: Int, values: [String?])? { + // Create new row values with DEFAULT markers + var newRowValues: [String?] = [] + for column in columns { + if let defaultValue = columnDefaults[column], defaultValue != nil { + // Use __DEFAULT__ marker so generateInsertSQL skips this column + newRowValues.append("__DEFAULT__") + } else { + // NULL for columns without defaults + newRowValues.append(nil) + } + } + + // Add to resultRows + let newRow = QueryResultRow(values: newRowValues) + resultRows.append(newRow) + + // Get the new row index + let newRowIndex = resultRows.count - 1 + + // Record in change manager as pending INSERT + changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newRowValues) + + return (newRowIndex, newRowValues) + } + + // MARK: - Duplicate Row + + /// Duplicate a row with new primary key + /// - Parameters: + /// - sourceRowIndex: Index of row to duplicate + /// - columns: Column names + /// - resultRows: Current rows (will be mutated) + /// - Returns: Tuple of (newRowIndex, newRowValues) or nil if failed + func duplicateRow( + sourceRowIndex: Int, + columns: [String], + resultRows: inout [QueryResultRow] + ) -> (rowIndex: Int, values: [String?])? { + guard sourceRowIndex < resultRows.count else { return nil } + + // Copy values from selected row + let sourceRow = resultRows[sourceRowIndex] + var newValues = sourceRow.values + + // Set primary key column to DEFAULT so DB auto-generates + if let pkColumn = changeManager.primaryKeyColumn, + let pkIndex = columns.firstIndex(of: pkColumn) { + newValues[pkIndex] = "__DEFAULT__" + } + + // Add the duplicated row + let newRow = QueryResultRow(values: newValues) + resultRows.append(newRow) + + // Get the new row index + let newRowIndex = resultRows.count - 1 + + // Record in change manager as pending INSERT + changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newValues) + + return (newRowIndex, newValues) + } + + // MARK: - Delete Rows + + /// Delete selected rows + /// - Parameters: + /// - selectedIndices: Indices of rows to delete + /// - resultRows: Current rows (will be mutated) + /// - Returns: Next row index to select after deletion, or -1 if no rows left + func deleteSelectedRows( + selectedIndices: Set, + resultRows: inout [QueryResultRow] + ) -> Int { + guard !selectedIndices.isEmpty else { return -1 } + + // Separate inserted rows from existing rows + var insertedRowsToDelete: [Int] = [] + var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = [] + + // Find the lowest selected row index for selection movement + let minSelectedRow = selectedIndices.min() ?? 0 + let maxSelectedRow = selectedIndices.max() ?? 0 + + // Categorize rows (process in descending order to maintain correct indices) + for rowIndex in selectedIndices.sorted(by: >) { + if changeManager.isRowInserted(rowIndex) { + insertedRowsToDelete.append(rowIndex) + } else if !changeManager.isRowDeleted(rowIndex) { + if rowIndex < resultRows.count { + let originalRow = resultRows[rowIndex].values + existingRowsToDelete.append((rowIndex: rowIndex, originalRow: originalRow)) + } + } + } + + // Process inserted rows deletion + if !insertedRowsToDelete.isEmpty { + let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) + + // Remove from resultRows first (descending order) + for rowIndex in sortedInsertedRows { + guard rowIndex < resultRows.count else { continue } + resultRows.remove(at: rowIndex) + } + + // Update changeManager for ALL deleted inserted rows at once + changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) + } + + // Record batch deletion for existing rows (single undo action for all rows) + if !existingRowsToDelete.isEmpty { + changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) + } + + // Calculate next row selection, accounting for deleted inserted rows + let totalRows = resultRows.count + let rowsDeleted = insertedRowsToDelete.count + let adjustedMaxRow = maxSelectedRow - rowsDeleted + let adjustedMinRow = minSelectedRow - insertedRowsToDelete.filter { $0 < minSelectedRow }.count + + if adjustedMaxRow + 1 < totalRows { + return min(adjustedMaxRow + 1, totalRows - 1) + } else if adjustedMinRow > 0 { + return adjustedMinRow - 1 + } else if totalRows > 0 { + return 0 + } else { + return -1 + } + } + + // MARK: - Undo/Redo + + /// Undo the last change + /// - Parameter resultRows: Current rows (will be mutated) + /// - Returns: Updated selection indices + func undoLastChange(resultRows: inout [QueryResultRow]) -> Set? { + guard let result = changeManager.undoLastChange() else { return nil } + + var adjustedSelection: Set? = nil + + switch result.action { + case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _): + if rowIndex < resultRows.count { + resultRows[rowIndex].values[columnIndex] = previousValue + } + + case .rowInsertion(let rowIndex): + if rowIndex < resultRows.count { + resultRows.remove(at: rowIndex) + adjustedSelection = Set() + } + + case .rowDeletion(_, _): + // Row is restored in changeManager - visual indicator will be removed + break + + case .batchRowDeletion(_): + // All rows are restored in changeManager + break + + case .batchRowInsertion(let rowIndices, let rowValues): + // Restore deleted inserted rows - add them back to resultRows + for (index, rowIndex) in rowIndices.enumerated().reversed() { + guard index < rowValues.count else { continue } + guard rowIndex <= resultRows.count else { continue } + + let values = rowValues[index] + let newRow = QueryResultRow(values: values) + resultRows.insert(newRow, at: rowIndex) + } + } + + return adjustedSelection + } + + /// Redo the last undone change + /// - Parameters: + /// - resultRows: Current rows (will be mutated) + /// - columns: Column names for new row creation + /// - Returns: Updated selection indices + func redoLastChange(resultRows: inout [QueryResultRow], columns: [String]) -> Set? { + guard let result = changeManager.redoLastChange() else { return nil } + + switch result.action { + case .cellEdit(let rowIndex, let columnIndex, _, _, let newValue): + if rowIndex < resultRows.count { + resultRows[rowIndex].values[columnIndex] = newValue + } + + case .rowInsertion(let rowIndex): + let newValues = [String?](repeating: nil, count: columns.count) + let newRow = QueryResultRow(values: newValues) + if rowIndex <= resultRows.count { + resultRows.insert(newRow, at: rowIndex) + } + + case .rowDeletion(_, _): + // Row is re-marked as deleted in changeManager + break + + case .batchRowDeletion(_): + // Rows are re-marked as deleted + break + + case .batchRowInsertion(let rowIndices, _): + // Redo the deletion - remove the rows from resultRows again + for rowIndex in rowIndices.sorted(by: >) { + guard rowIndex < resultRows.count else { continue } + resultRows.remove(at: rowIndex) + } + } + + return nil + } + + // MARK: - Undo Insert Row + + /// Remove a row that was inserted (called by undo context menu) + /// - Parameters: + /// - rowIndex: Index of the inserted row + /// - resultRows: Current rows (will be mutated) + /// - selectedIndices: Current selection (will be adjusted) + /// - Returns: Adjusted selection indices + func undoInsertRow( + at rowIndex: Int, + resultRows: inout [QueryResultRow], + selectedIndices: Set + ) -> Set { + guard rowIndex >= 0 && rowIndex < resultRows.count else { return selectedIndices } + + // Remove the row from resultRows + resultRows.remove(at: rowIndex) + + // Adjust selection indices + var adjustedSelection = Set() + for idx in selectedIndices { + if idx == rowIndex { + continue // Skip the removed row + } else if idx > rowIndex { + adjustedSelection.insert(idx - 1) + } else { + adjustedSelection.insert(idx) + } + } + + return adjustedSelection + } + + // MARK: - Copy Rows + + /// Copy selected rows to clipboard as tab-separated values + /// - Parameters: + /// - selectedIndices: Indices of rows to copy + /// - resultRows: Current rows + func copySelectedRowsToClipboard(selectedIndices: Set, resultRows: [QueryResultRow]) { + guard !selectedIndices.isEmpty else { return } + + let sortedIndices = selectedIndices.sorted() + var lines: [String] = [] + + for rowIndex in sortedIndices { + guard rowIndex < resultRows.count else { continue } + let row = resultRows[rowIndex] + let line = row.values.map { $0 ?? "NULL" }.joined(separator: "\t") + lines.append(line) + } + + let text = lines.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} diff --git a/OpenTable/Core/Storage/TabStateStorage.swift b/OpenTable/Core/Storage/TabStateStorage.swift index 76618ce2..16a79a17 100644 --- a/OpenTable/Core/Storage/TabStateStorage.swift +++ b/OpenTable/Core/Storage/TabStateStorage.swift @@ -35,9 +35,7 @@ final class TabStateStorage { let key = tabStateKey(for: connectionId) defaults.set(data, forKey: key) } catch { - #if DEBUG - print("[TabStateStorage] Failed to encode tab state: \(error.localizedDescription)") - #endif + // Silent failure - encoding errors are rare and non-critical } } @@ -53,9 +51,7 @@ final class TabStateStorage { let decoder = JSONDecoder() return try decoder.decode(TabState.self, from: data) } catch { - #if DEBUG - print("[TabStateStorage] Failed to decode tab state: \(error.localizedDescription)") - #endif + // Silent failure - decoding errors return nil return nil } } diff --git a/OpenTable/Models/DataChange.swift b/OpenTable/Models/DataChange.swift deleted file mode 100644 index 11af1af2..00000000 --- a/OpenTable/Models/DataChange.swift +++ /dev/null @@ -1,1095 +0,0 @@ -// -// DataChange.swift -// OpenTable -// -// Models for tracking data changes -// - -import Combine -import Foundation - -/// Represents a type of data change -enum ChangeType: Equatable { - case update - case insert - case delete -} - -/// Represents a single cell change -struct CellChange: Identifiable, Equatable { - let id: UUID - let rowIndex: Int - let columnIndex: Int - let columnName: String - let oldValue: String? - let newValue: String? - - init(rowIndex: Int, columnIndex: Int, columnName: String, oldValue: String?, newValue: String?) - { - self.id = UUID() - self.rowIndex = rowIndex - self.columnIndex = columnIndex - self.columnName = columnName - self.oldValue = oldValue - self.newValue = newValue - } -} - -/// Represents a row-level change -struct RowChange: Identifiable, Equatable { - let id: UUID - var rowIndex: Int - let type: ChangeType - var cellChanges: [CellChange] - let originalRow: [String?]? - - init( - rowIndex: Int, type: ChangeType, cellChanges: [CellChange] = [], - originalRow: [String?]? = nil - ) { - self.id = UUID() - self.rowIndex = rowIndex - self.type = type - self.cellChanges = cellChanges - self.originalRow = originalRow - } -} - -/// Manager for tracking and applying data changes -/// @MainActor ensures thread-safe access to most properties - critical for avoiding EXC_BAD_ACCESS -/// when multiple queries complete simultaneously (e.g., rapid sorting over SSH tunnel) - -/// Represents an action that can be undone -enum UndoAction { - case cellEdit(rowIndex: Int, columnIndex: Int, columnName: String, previousValue: String?, newValue: String?) - case rowInsertion(rowIndex: Int) - case rowDeletion(rowIndex: Int, originalRow: [String?]) - /// Batch deletion of multiple rows (for undo as a single action) - case batchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) - /// Batch insertion undo - when user deletes multiple inserted rows at once - case batchRowInsertion(rowIndices: [Int], rowValues: [[String?]]) -} - -@MainActor -final class DataChangeManager: ObservableObject { - @Published var changes: [RowChange] = [] - @Published var hasChanges: Bool = false - @Published var reloadVersion: Int = 0 // Incremented to trigger table reload - - var tableName: String = "" - var primaryKeyColumn: String? - var databaseType: DatabaseType = .mysql // For database-specific SQL generation - - // Simple storage with explicit deep copy to avoid memory corruption - private var _columnsStorage: [String] = [] - var columns: [String] { - get { - return _columnsStorage - } - set { - // Create explicit deep copy to ensure independence - _columnsStorage = newValue.map { String($0) } - } - } - - // MARK: - Cached Lookups for O(1) Performance - - /// Set of row indices that are marked for deletion - O(1) lookup - private var deletedRowIndices: Set = [] - - /// Set of row indices that are newly inserted - O(1) lookup - private(set) var insertedRowIndices: Set = [] - - /// Set of "rowIndex-colIndex" strings for modified cells - O(1) lookup - private var modifiedCells: Set = [] - - /// Lazy storage for inserted row values - avoids creating CellChange objects until needed - /// Maps rowIndex -> column values array for newly inserted rows - /// This dramatically improves add row performance for tables with many columns - private var insertedRowData: [Int: [String?]] = [:] - - /// Undo stack for reversing changes (LIFO) - private var undoStack: [UndoAction] = [] - - /// Redo stack for re-applying undone changes (LIFO) - private var redoStack: [UndoAction] = [] - - /// Helper to create a cache key for modified cells - private func cellKey(rowIndex: Int, columnIndex: Int) -> String { - "\(rowIndex)-\(columnIndex)" - } - - /// Clear all changes (called after successful save) - func clearChanges() { - changes.removeAll() - deletedRowIndices.removeAll() - insertedRowIndices.removeAll() - modifiedCells.removeAll() - insertedRowData.removeAll() // Clear lazy storage - undoStack.removeAll() // Clear undo stack too - hasChanges = false - reloadVersion += 1 // Trigger table reload - } - - /// Atomically configure the manager for a new table - /// This batches all updates and only triggers @Published changes at the end - /// to prevent race conditions where SwiftUI reads properties mid-update - func configureForTable( - tableName: String, columns: [String], primaryKeyColumn: String?, - databaseType: DatabaseType = .mysql - ) { - // First, update non-published properties (no SwiftUI notifications) - self.tableName = tableName - self.columns = columns // Uses deep copy setter to avoid memory corruption - self.primaryKeyColumn = primaryKeyColumn - self.databaseType = databaseType - - // Clear caches - deletedRowIndices.removeAll() - insertedRowIndices.removeAll() - modifiedCells.removeAll() - insertedRowData.removeAll() // Clear lazy storage - - // Now update @Published properties - triggers ONE view update - changes.removeAll() - hasChanges = false - reloadVersion += 1 - } - - /// Rebuilds the caches from the changes array (used after complex modifications) - private func rebuildCaches() { - deletedRowIndices.removeAll() - insertedRowIndices.removeAll() - modifiedCells.removeAll() - - for change in changes { - if change.type == .delete { - deletedRowIndices.insert(change.rowIndex) - } else if change.type == .insert { - insertedRowIndices.insert(change.rowIndex) - } else if change.type == .update { - for cellChange in change.cellChanges { - modifiedCells.insert( - cellKey(rowIndex: change.rowIndex, columnIndex: cellChange.columnIndex)) - } - } - } - } - - // MARK: - Change Tracking - - func recordCellChange( - rowIndex: Int, columnIndex: Int, columnName: String, oldValue: String?, newValue: String?, - originalRow: [String?]? = nil - ) { - guard oldValue != newValue else { return } - - let cellChange = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: oldValue, - newValue: newValue - ) - - let key = cellKey(rowIndex: rowIndex, columnIndex: columnIndex) - - // Check if this is an edit to an INSERTED row - // If so, update the INSERT record's cell values instead of creating UPDATE - if let insertIndex = changes.firstIndex(where: { - $0.rowIndex == rowIndex && $0.type == .insert - }) { - // OPTIMIZATION: Update the stored values directly first - if var storedValues = insertedRowData[rowIndex] { - if columnIndex < storedValues.count { - storedValues[columnIndex] = newValue - insertedRowData[rowIndex] = storedValues - } - } - - // Also update/create CellChange for this specific column - // (Lazy build - only for edited columns, not all columns) - if let cellIndex = changes[insertIndex].cellChanges.firstIndex(where: { - $0.columnIndex == columnIndex - }) { - // Update existing cell in INSERT - changes[insertIndex].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: nil, // INSERT doesn't have oldValue - newValue: newValue - ) - } else { - // Add new cell to INSERT (lazy - only for this edited column) - changes[insertIndex].cellChanges.append(CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: nil, - newValue: newValue - )) - } - // Push undo action for inserted row cell edit - pushUndo(.cellEdit(rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue)) - hasChanges = !changes.isEmpty - return - } - - // Find existing UPDATE row change or create new one - if let existingIndex = changes.firstIndex(where: { - $0.rowIndex == rowIndex && $0.type == .update - }) { - // Check if this column was already changed - if let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { - $0.columnIndex == columnIndex - }) { - // Update existing cell change, keeping original oldValue - let originalOldValue = changes[existingIndex].cellChanges[cellIndex].oldValue - changes[existingIndex].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: originalOldValue, - newValue: newValue - ) - - // If value is back to original, remove the change - if originalOldValue == newValue { - changes[existingIndex].cellChanges.remove(at: cellIndex) - modifiedCells.remove(key) // Remove from cache - if changes[existingIndex].cellChanges.isEmpty { - changes.remove(at: existingIndex) - } - } - } else { - changes[existingIndex].cellChanges.append(cellChange) - modifiedCells.insert(key) // Add to cache - } - } else { - // Create new RowChange with originalRow for WHERE clause PK lookup - let rowChange = RowChange( - rowIndex: rowIndex, type: .update, cellChanges: [cellChange], - originalRow: originalRow) - changes.append(rowChange) - modifiedCells.insert(key) // Add to cache - } - - // Push undo action for cell edit - pushUndo(.cellEdit(rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue)) - hasChanges = !changes.isEmpty - } - - func recordRowDeletion(rowIndex: Int, originalRow: [String?]) { - // Remove any pending updates for this row - changes.removeAll { $0.rowIndex == rowIndex && $0.type == .update } - - // Clear modified cells cache for this row - modifiedCells = modifiedCells.filter { !$0.hasPrefix("\(rowIndex)-") } - - let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) - changes.append(rowChange) - deletedRowIndices.insert(rowIndex) // Add to cache - pushUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) // Push undo action - hasChanges = true - reloadVersion += 1 // Trigger table reload to show red background - } - - /// Record multiple row deletions as a single undo action - /// - Parameter rows: Array of (rowIndex, originalRow) tuples, sorted by row index descending - func recordBatchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) { - guard rows.count > 1 else { - // Single row, use normal method - if let row = rows.first { - recordRowDeletion(rowIndex: row.rowIndex, originalRow: row.originalRow) - } - return - } - - // Collect data for batch undo before modifying state - var batchData: [(rowIndex: Int, originalRow: [String?])] = [] - - for (rowIndex, originalRow) in rows { - // Remove any pending updates for this row - changes.removeAll { $0.rowIndex == rowIndex && $0.type == .update } - - // Clear modified cells cache for this row - modifiedCells = modifiedCells.filter { !$0.hasPrefix("\(rowIndex)-") } - - let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) - changes.append(rowChange) - deletedRowIndices.insert(rowIndex) - - batchData.append((rowIndex: rowIndex, originalRow: originalRow)) - } - - // Push a single batch undo action - pushUndo(.batchRowDeletion(rows: batchData)) - hasChanges = true - reloadVersion += 1 - } - - func recordRowInsertion(rowIndex: Int, values: [String?]) { - // OPTIMIZATION: Store row data directly without creating CellChange objects - // This eliminates expensive enumerated().map() for every column - // CellChanges will be built lazily only when needed (SQL generation or cell edits) - insertedRowData[rowIndex] = values - - // Lightweight RowChange marker with empty cellChanges array - let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: []) - changes.append(rowChange) - insertedRowIndices.insert(rowIndex) - pushUndo(.rowInsertion(rowIndex: rowIndex)) - hasChanges = true - } - - /// Undo a pending row deletion - func undoRowDeletion(rowIndex: Int) { - // SAFETY: Only process if this row is actually marked as deleted - guard deletedRowIndices.contains(rowIndex) else { - print("⚠️ undoRowDeletion called for row \(rowIndex) but it's not in deletedRowIndices") - return - } - - changes.removeAll { $0.rowIndex == rowIndex && $0.type == .delete } - deletedRowIndices.remove(rowIndex) - hasChanges = !changes.isEmpty - reloadVersion += 1 // Trigger table reload to remove red background - } - - /// Undo a pending row insertion - func undoRowInsertion(rowIndex: Int) { - // SAFETY: Only process if this row is actually marked as inserted - guard insertedRowIndices.contains(rowIndex) else { - print("⚠️ undoRowInsertion: row \(rowIndex) not in insertedRowIndices") - return - } - - // Remove the INSERT change from the changes array - changes.removeAll { $0.rowIndex == rowIndex && $0.type == .insert } - insertedRowIndices.remove(rowIndex) - insertedRowData.removeValue(forKey: rowIndex) // Clear lazy storage - - // Shift down indices for rows after the removed row - var shiftedInsertedIndices = Set() - for idx in insertedRowIndices { - if idx > rowIndex { - shiftedInsertedIndices.insert(idx - 1) - } else { - shiftedInsertedIndices.insert(idx) - } - } - insertedRowIndices = shiftedInsertedIndices - - // Also update row indices in changes array for all changes after this row - for i in 0.. rowIndex { - changes[i].rowIndex -= 1 - } - } - - hasChanges = !changes.isEmpty - } - - /// Undo multiple row insertions at once (for batch deletion) - /// This is more efficient than calling undoRowInsertion multiple times - /// - Parameter rowIndices: Array of row indices to undo, MUST be sorted in descending order - func undoBatchRowInsertion(rowIndices: [Int]) { - guard !rowIndices.isEmpty else { return } - - // Verify all rows are inserted - let validRows = rowIndices.filter { insertedRowIndices.contains($0) } - - if validRows.count != rowIndices.count { - let invalidRows = Set(rowIndices).subtracting(validRows) - print("⚠️ undoBatchRowInsertion: rows \(invalidRows) not in insertedRowIndices") - } - - guard !validRows.isEmpty else { return } - - // Collect row values BEFORE removing changes (for undo/redo) - var rowValues: [[String?]] = [] - for rowIndex in validRows { - if let insertChange = changes.first(where: { $0.rowIndex == rowIndex && $0.type == .insert }) { - let values = insertChange.cellChanges.sorted { $0.columnIndex < $1.columnIndex } - .map { $0.newValue } - rowValues.append(values) - } else { - rowValues.append(Array(repeating: nil, count: columns.count)) - } - } - - // Remove all INSERT changes for these rows - for rowIndex in validRows { - changes.removeAll { $0.rowIndex == rowIndex && $0.type == .insert } - insertedRowIndices.remove(rowIndex) - insertedRowData.removeValue(forKey: rowIndex) // Clear lazy storage - } - - // Push undo action so user can undo this deletion - pushUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) - - // Shift indices for all remaining rows - for deletedIndex in validRows.reversed() { - var shiftedIndices = Set() - for idx in insertedRowIndices { - if idx > deletedIndex { - shiftedIndices.insert(idx - 1) - } else { - shiftedIndices.insert(idx) - } - } - insertedRowIndices = shiftedIndices - - for i in 0.. deletedIndex { - changes[i].rowIndex -= 1 - } - } - } - - hasChanges = !changes.isEmpty - } - - // MARK: - Undo Stack Management - - /// Push an undo action onto the stack - func pushUndo(_ action: UndoAction) { - undoStack.append(action) - } - - /// Pop the last undo action from the stack - func popUndo() -> UndoAction? { - undoStack.popLast() - } - - /// Clear the undo stack (called after save or discard) - func clearUndoStack() { - undoStack.removeAll() - } - - /// Clear the redo stack (called when new changes are made) - func clearRedoStack() { - redoStack.removeAll() - } - - /// Check if there are any undo actions available - var canUndo: Bool { - !undoStack.isEmpty - } - - /// Check if there are any redo actions available - var canRedo: Bool { - !redoStack.isEmpty - } - - /// Undo the last change and return details needed to update the UI - /// Returns: (action, needsRowRemoval, needsRowRestore, restoreRow) - func undoLastChange() -> (action: UndoAction, needsRowRemoval: Bool, needsRowRestore: Bool, restoreRow: [String?]?)? { - guard let action = popUndo() else { return nil } - - // Push to redo stack so we can redo this action - redoStack.append(action) - - switch action { - case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, _): - // Find and revert the cell change - if let changeIndex = changes.firstIndex(where: { - $0.rowIndex == rowIndex && ($0.type == .update || $0.type == .insert) - }) { - if let cellIndex = changes[changeIndex].cellChanges.firstIndex(where: { - $0.columnIndex == columnIndex - }) { - // For updates, restore the original value - if changes[changeIndex].type == .update { - let originalValue = changes[changeIndex].cellChanges[cellIndex].oldValue - if previousValue == originalValue { - // Value is back to original, remove the cell change - changes[changeIndex].cellChanges.remove(at: cellIndex) - modifiedCells.remove(cellKey(rowIndex: rowIndex, columnIndex: columnIndex)) - if changes[changeIndex].cellChanges.isEmpty { - changes.remove(at: changeIndex) - } - } else { - // Update cell change with previous value - let originalOldValue = changes[changeIndex].cellChanges[cellIndex].oldValue - changes[changeIndex].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: originalOldValue, - newValue: previousValue - ) - } - } else if changes[changeIndex].type == .insert { - // For inserts, just update the cell value - changes[changeIndex].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: nil, - newValue: previousValue - ) - } - } - } - hasChanges = !changes.isEmpty - reloadVersion += 1 - return (action, false, false, nil) - - case .rowInsertion(let rowIndex): - // Undo the insertion by removing the row - undoRowInsertion(rowIndex: rowIndex) - return (action, true, false, nil) - - case .rowDeletion(let rowIndex, let originalRow): - // Undo the deletion by restoring the row - undoRowDeletion(rowIndex: rowIndex) - return (action, false, true, originalRow) - - case .batchRowDeletion(let rows): - // Undo all deletions in the batch (restore all rows) - // Process in reverse order to maintain correct indices - for (rowIndex, _) in rows.reversed() { - undoRowDeletion(rowIndex: rowIndex) - } - return (action, false, true, nil) - - case .batchRowInsertion(let rowIndices, let rowValues): - // Undo the deletion of inserted rows - restore them as INSERT changes - // Process in reverse order (ascending) to maintain correct indices when re-inserting - for (index, rowIndex) in rowIndices.enumerated().reversed() { - guard index < rowValues.count else { continue } - let values = rowValues[index] - - // Re-create INSERT change - let cellChanges = values.enumerated().map { colIndex, value in - CellChange( - rowIndex: rowIndex, - columnIndex: colIndex, - columnName: columns[safe: colIndex] ?? "", - oldValue: nil, - newValue: value - ) - } - let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) - changes.append(rowChange) - insertedRowIndices.insert(rowIndex) - } - - hasChanges = !changes.isEmpty - reloadVersion += 1 - // Return true for needsRowInsert so MainContentView knows to restore to resultRows - return (action, true, false, nil) - } - } - - /// Redo the last undone change and return details needed to update the UI - /// Returns: (action, needsRowInsert, needsRowDelete) - func redoLastChange() -> (action: UndoAction, needsRowInsert: Bool, needsRowDelete: Bool)? { - guard !redoStack.isEmpty else { return nil } - let action = redoStack.removeLast() - - // Push back to undo stack so we can undo again - undoStack.append(action) - - switch action { - case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue): - // Re-apply the cell edit (previousValue becomes oldValue for the new edit) - recordCellChange(rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, oldValue: previousValue, newValue: newValue) - // Remove the extra undo action that recordCellChange pushed - _ = undoStack.popLast() - reloadVersion += 1 - return (action, false, false) - - case .rowInsertion(let rowIndex): - // Re-apply the row insertion - we need to restore the full INSERT change - // Note: We don't have the original cell values in the UndoAction, - // so we need the caller (MainContentView) to provide them when re-inserting the row - // For now, just mark as inserted and let the caller handle cell values - insertedRowIndices.insert(rowIndex) - - // Create empty INSERT change - caller should update with actual values - // The row should already exist in resultRows from the redo handler in MainContentView - let cellChanges = columns.enumerated().map { index, columnName in - CellChange( - rowIndex: rowIndex, - columnIndex: index, - columnName: columnName, - oldValue: nil, - newValue: nil // Will be updated by caller - ) - } - let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) - changes.append(rowChange) - - hasChanges = true - reloadVersion += 1 - return (action, true, false) - - case .rowDeletion(let rowIndex, let originalRow): - // Re-apply the deletion - recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) - // Remove the extra undo action that recordRowDeletion pushed - _ = undoStack.popLast() - return (action, false, true) - - case .batchRowDeletion(let rows): - // Re-apply all deletions in the batch - for (rowIndex, originalRow) in rows { - recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) - // Remove the extra undo action - _ = undoStack.popLast() - } - return (action, false, true) - - case .batchRowInsertion(let rowIndices, _): - // Redo the deletion of inserted rows - remove them again - // This is called when user: delete inserted rows -> undo -> redo - // We need to remove the rows from changes and insertedRowIndices again - for rowIndex in rowIndices { - changes.removeAll { $0.rowIndex == rowIndex && $0.type == .insert } - insertedRowIndices.remove(rowIndex) - } - hasChanges = !changes.isEmpty - reloadVersion += 1 - // Return true for needsRowInsert to signal MainContentView to remove from resultRows - // (We repurpose this flag since the logic is similar - rows need to be removed) - return (action, true, false) - } - } - - // MARK: - SQL Generation - - func generateSQL() -> [String] { - var statements: [String] = [] - - // Collect UPDATE and DELETE changes to batch them - var updateChanges: [RowChange] = [] - var deleteChanges: [RowChange] = [] - - for change in changes { - switch change.type { - case .update: - updateChanges.append(change) - case .insert: - // SAFETY: Verify the row is still marked as inserted - guard insertedRowIndices.contains(change.rowIndex) else { - print("⚠️ Skipping INSERT for row \(change.rowIndex) - not in insertedRowIndices") - continue - } - if let sql = generateInsertSQL(for: change) { - statements.append(sql) - } - case .delete: - // SAFETY: Verify the row is still marked as deleted - guard deletedRowIndices.contains(change.rowIndex) else { - print("⚠️ Skipping DELETE for row \(change.rowIndex) - not in deletedRowIndices") - continue - } - deleteChanges.append(change) - } - } - - // Generate batched UPDATE statements (group by same columns being updated) - if !updateChanges.isEmpty { - let batchedUpdates = generateBatchUpdateSQL(for: updateChanges) - statements.append(contentsOf: batchedUpdates) - } - - // Generate batched DELETE statement (TablePlus style: single DELETE with OR conditions) - if !deleteChanges.isEmpty { - if let sql = generateBatchDeleteSQL(for: deleteChanges) { - statements.append(sql) - } - } - - return statements - } - - /// Check if a string is a SQL function expression that should not be quoted - private func isSQLFunctionExpression(_ value: String) -> Bool { - let trimmed = value.trimmingCharacters(in: .whitespaces).uppercased() - - // Common SQL functions for datetime/timestamps - let sqlFunctions = [ - "NOW()", - "CURRENT_TIMESTAMP()", - "CURRENT_TIMESTAMP", - "CURDATE()", - "CURTIME()", - "UTC_TIMESTAMP()", - "UTC_DATE()", - "UTC_TIME()", - "LOCALTIME()", - "LOCALTIME", - "LOCALTIMESTAMP()", - "LOCALTIMESTAMP", - "SYSDATE()", - "UNIX_TIMESTAMP()", - "CURRENT_DATE()", - "CURRENT_DATE", - "CURRENT_TIME()", - "CURRENT_TIME", - ] - - return sqlFunctions.contains(trimmed) - } - - /// Generate batched UPDATE statements grouped by columns being updated - /// Example: UPDATE table SET col1 = CASE WHEN id=1 THEN 'val1' WHEN id=2 THEN 'val2' END WHERE id IN (1,2) - /// This is much more efficient than individual UPDATE statements - private func generateBatchUpdateSQL(for changes: [RowChange]) -> [String] { - guard !changes.isEmpty else { return [] } - guard let pkColumn = primaryKeyColumn else { - // Fallback to individual UPDATEs if no PK - return changes.compactMap { generateUpdateSQL(for: $0) } - } - guard let pkIndex = columns.firstIndex(of: pkColumn) else { - return changes.compactMap { generateUpdateSQL(for: $0) } - } - - // Group changes by set of columns being updated - var grouped: [[String]: [RowChange]] = [:] - for change in changes { - let columnNames = change.cellChanges.map { $0.columnName }.sorted() - grouped[columnNames, default: []].append(change) - } - - var statements: [String] = [] - - for (columnNames, groupedChanges) in grouped { - // Build CASE statements for each column - var caseClauses: [String] = [] - - for columnName in columnNames { - var whenClauses: [String] = [] - - for change in groupedChanges { - guard let originalRow = change.originalRow, - pkIndex < originalRow.count, - let cellChange = change.cellChanges.first(where: { $0.columnName == columnName }) else { - continue - } - - let pkValue = originalRow[pkIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" - - // Generate value - let value: String - if cellChange.newValue == "__DEFAULT__" { - value = "DEFAULT" - } else if let newValue = cellChange.newValue { - if isSQLFunctionExpression(newValue) { - value = newValue.trimmingCharacters(in: .whitespaces).uppercased() - } else { - value = "'\(escapeSQLString(newValue))'" - } - } else { - value = "NULL" - } - - whenClauses.append("WHEN \(databaseType.quoteIdentifier(pkColumn)) = \(pkValue) THEN \(value)") - } - - if !whenClauses.isEmpty { - let caseExpr = "CASE \(whenClauses.joined(separator: " ")) END" - caseClauses.append("\(databaseType.quoteIdentifier(columnName)) = \(caseExpr)") - } - } - - // Build WHERE IN clause with all PKs - var pkValues: [String] = [] - for change in groupedChanges { - guard let originalRow = change.originalRow, - pkIndex < originalRow.count else { - continue - } - let pkValue = originalRow[pkIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" - pkValues.append(pkValue) - } - - if !caseClauses.isEmpty && !pkValues.isEmpty { - let whereClause = "\(databaseType.quoteIdentifier(pkColumn)) IN (\(pkValues.joined(separator: ", ")))" - let sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(caseClauses.joined(separator: ", ")) WHERE \(whereClause)" - statements.append(sql) - } - } - - return statements - } - - /// Generate individual UPDATE statement for a single row (fallback) - private func generateUpdateSQL(for change: RowChange) -> String? { - guard !change.cellChanges.isEmpty else { return nil } - - let setClauses = change.cellChanges.map { cellChange -> String in - let value: String - if cellChange.newValue == "__DEFAULT__" { - value = "DEFAULT" // SQL DEFAULT keyword - } else if let newValue = cellChange.newValue { - // Check if it's a SQL function expression - if isSQLFunctionExpression(newValue) { - value = newValue.trimmingCharacters(in: .whitespaces).uppercased() - } else { - value = "'\(escapeSQLString(newValue))'" - } - } else { - value = "NULL" - } - return "\(databaseType.quoteIdentifier(cellChange.columnName)) = \(value)" - }.joined(separator: ", ") - - // Use primary key for WHERE clause - var whereClause = "1=1" // Fallback - dangerous but necessary without PK - - if let pkColumn = primaryKeyColumn, - let pkColumnIndex = columns.firstIndex(of: pkColumn) - { - // Try to get PK value from originalRow first - if let originalRow = change.originalRow, pkColumnIndex < originalRow.count { - let pkValue = - originalRow[pkColumnIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" - whereClause = "\(databaseType.quoteIdentifier(pkColumn)) = \(pkValue)" - } - // Otherwise try from cellChanges (if PK column was edited) - else if let pkChange = change.cellChanges.first(where: { $0.columnName == pkColumn }) { - let pkValue = pkChange.oldValue.map { "'\(escapeSQLString($0))'" } ?? "NULL" - whereClause = "\(databaseType.quoteIdentifier(pkColumn)) = \(pkValue)" - } - } - - return - "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" - } - - private func generateInsertSQL(for change: RowChange) -> String? { - // OPTIMIZATION: Get values from lazy storage instead of cellChanges - if let values = insertedRowData[change.rowIndex] { - return generateInsertSQLFromStoredData(rowIndex: change.rowIndex, values: values) - } - - // Fallback: use cellChanges if stored data not available (backward compatibility) - return generateInsertSQLFromCellChanges(for: change) - } - - /// Generate INSERT SQL from lazy-stored row data (new optimized path) - private func generateInsertSQLFromStoredData(rowIndex: Int, values: [String?]) -> String? { - var nonDefaultColumns: [String] = [] - var nonDefaultValues: [String] = [] - - for (index, value) in values.enumerated() { - // Skip DEFAULT columns - let DB handle them - if value == "__DEFAULT__" { continue } - - guard index < columns.count else { continue } - let columnName = columns[index] - - nonDefaultColumns.append(databaseType.quoteIdentifier(columnName)) - - if let val = value { - // Check if it's a SQL function expression - if isSQLFunctionExpression(val) { - nonDefaultValues.append(val.trimmingCharacters(in: .whitespaces).uppercased()) - } else { - nonDefaultValues.append("'\(escapeSQLString(val))'") - } - } else { - nonDefaultValues.append("NULL") - } - } - - // If all columns are DEFAULT, don't generate INSERT - guard !nonDefaultColumns.isEmpty else { return nil } - - let columnList = nonDefaultColumns.joined(separator: ", ") - let valueList = nonDefaultValues.joined(separator: ", ") - - return "INSERT INTO \(databaseType.quoteIdentifier(tableName)) (\(columnList)) VALUES (\(valueList))" - } - - /// Generate INSERT SQL from cellChanges (fallback for backward compatibility) - private func generateInsertSQLFromCellChanges(for change: RowChange) -> String? { - guard !change.cellChanges.isEmpty else { return nil } - - // Filter out DEFAULT columns - let DB handle them - let nonDefaultChanges = change.cellChanges.filter { - $0.newValue != "__DEFAULT__" - } - - // If all columns are DEFAULT, don't generate INSERT - // (user hasn't modified anything - just added empty row) - guard !nonDefaultChanges.isEmpty else { return nil } - - let columnNames = nonDefaultChanges.map { - databaseType.quoteIdentifier($0.columnName) - }.joined(separator: ", ") - - let values = nonDefaultChanges.map { cellChange -> String in - if let newValue = cellChange.newValue { - // Check if it's a SQL function expression - if isSQLFunctionExpression(newValue) { - return newValue.trimmingCharacters(in: .whitespaces).uppercased() - } - return "'\(escapeSQLString(newValue))'" - } - return "NULL" - }.joined(separator: ", ") - - return - "INSERT INTO \(databaseType.quoteIdentifier(tableName)) (\(columnNames)) VALUES (\(values))" - } - - /// Generate a batched DELETE statement combining multiple rows with OR conditions - /// Example: DELETE FROM table WHERE id = 1 OR id = 2 OR id = 3 - /// This is much more efficient than individual DELETE statements - private func generateBatchDeleteSQL(for changes: [RowChange]) -> String? { - guard !changes.isEmpty else { return nil } - guard let pkColumn = primaryKeyColumn else { return nil } - guard let pkIndex = columns.firstIndex(of: pkColumn) else { return nil } - - // Build OR conditions for all rows - var conditions: [String] = [] - - for change in changes { - guard let originalRow = change.originalRow, - pkIndex < originalRow.count else { - continue - } - - let pkValue = originalRow[pkIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" - conditions.append("\(databaseType.quoteIdentifier(pkColumn)) = \(pkValue)") - } - - guard !conditions.isEmpty else { return nil } - - // Combine all conditions with OR - let whereClause = conditions.joined(separator: " OR ") - return "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" - } - - /// Generate individual DELETE statement for a single row (kept for compatibility) - private func generateDeleteSQL(for change: RowChange) -> String? { - guard let pkColumn = primaryKeyColumn, - let originalRow = change.originalRow, - let pkIndex = columns.firstIndex(of: pkColumn), - pkIndex < originalRow.count - else { - return nil - } - - let pkValue = originalRow[pkIndex].map { "'\(escapeSQLString($0))'" } ?? "NULL" - return - "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(databaseType.quoteIdentifier(pkColumn)) = \(pkValue)" - } - - private func escapeSQLString(_ str: String) -> String { - // Escape characters that can break SQL strings - var result = str - result = result.replacingOccurrences(of: "\\", with: "\\\\") // Backslash first - result = result.replacingOccurrences(of: "'", with: "''") // Single quote - result = result.replacingOccurrences(of: "\n", with: "\\n") // Newline - result = result.replacingOccurrences(of: "\r", with: "\\r") // Carriage return - result = result.replacingOccurrences(of: "\t", with: "\\t") // Tab - result = result.replacingOccurrences(of: "\0", with: "\\0") // Null byte - return result - } - - // MARK: - Actions - - /// Returns all original cell values that need to be restored - /// Format: [(rowIndex, columnIndex, originalValue)] - func getOriginalValues() -> [(rowIndex: Int, columnIndex: Int, value: String?)] { - var originals: [(rowIndex: Int, columnIndex: Int, value: String?)] = [] - - for change in changes { - if change.type == .update { - for cellChange in change.cellChanges { - originals.append( - ( - rowIndex: change.rowIndex, - columnIndex: cellChange.columnIndex, - value: cellChange.oldValue - )) - } - } - } - - return originals - } - - func discardChanges() { - changes.removeAll() - deletedRowIndices.removeAll() // Clear cache - insertedRowIndices.removeAll() // Clear cache - modifiedCells.removeAll() // Clear cache - insertedRowData.removeAll() // Clear lazy storage - hasChanges = false - reloadVersion += 1 // Trigger table reload - } - - // MARK: - Per-Tab State Management - - /// Save current state to a TabPendingChanges struct for storage in a tab - func saveState() -> TabPendingChanges { - var state = TabPendingChanges() - state.changes = changes - state.deletedRowIndices = deletedRowIndices - state.insertedRowIndices = insertedRowIndices - state.modifiedCells = modifiedCells - state.insertedRowData = insertedRowData // Save lazy storage - state.primaryKeyColumn = primaryKeyColumn - state.columns = columns - return state - } - - /// Restore state from a TabPendingChanges struct - func restoreState(from state: TabPendingChanges, tableName: String) { - self.tableName = tableName - self.changes = state.changes - self.deletedRowIndices = state.deletedRowIndices - self.insertedRowIndices = state.insertedRowIndices - self.modifiedCells = state.modifiedCells - self.insertedRowData = state.insertedRowData // Restore lazy storage - self.primaryKeyColumn = state.primaryKeyColumn - self.columns = state.columns - self.hasChanges = !state.changes.isEmpty - } - - /// O(1) lookup for deleted rows using cached Set - func isRowDeleted(_ rowIndex: Int) -> Bool { - deletedRowIndices.contains(rowIndex) - } - - /// O(1) lookup for inserted rows using cached Set - func isRowInserted(_ rowIndex: Int) -> Bool { - insertedRowIndices.contains(rowIndex) - } - - /// O(1) lookup for modified cells using cached Set - func isCellModified(rowIndex: Int, columnIndex: Int) -> Bool { - modifiedCells.contains(cellKey(rowIndex: rowIndex, columnIndex: columnIndex)) - } - - /// Returns a Set of column indices that are modified for a given row - /// Used for efficient batch lookup in TableRowView - func getModifiedColumnsForRow(_ rowIndex: Int) -> Set { - var result: Set = [] - let prefix = "\(rowIndex)-" - for key in modifiedCells { - if key.hasPrefix(prefix) { - if let colIndex = Int(key.dropFirst(prefix.count)) { - result.insert(colIndex) - } - } - } - return result - } -} - -// MARK: - Array Extension - -extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} diff --git a/OpenTable/OpenTableApp.swift b/OpenTable/OpenTableApp.swift index 55adceb5..0247b81b 100644 --- a/OpenTable/OpenTableApp.swift +++ b/OpenTable/OpenTableApp.swift @@ -270,6 +270,9 @@ extension Notification.Name { // History panel notifications static let toggleHistoryPanel = Notification.Name("toggleHistoryPanel") + + // Window lifecycle notifications + static let mainWindowWillClose = Notification.Name("mainWindowWillClose") } // MARK: - Open Window Handler diff --git a/OpenTable/Views/Editor/HistoryListViewController.swift b/OpenTable/Views/Editor/HistoryListViewController.swift deleted file mode 100644 index fee62ead..00000000 --- a/OpenTable/Views/Editor/HistoryListViewController.swift +++ /dev/null @@ -1,1325 +0,0 @@ -// -// HistoryListViewController.swift -// OpenTable -// -// Left pane controller for history/bookmark list with search and filtering -// - -import AppKit - -// MARK: - Delegate Protocol - -protocol HistoryListViewControllerDelegate: AnyObject { - func historyListViewController(_ controller: HistoryListViewController, didSelectHistoryEntry entry: QueryHistoryEntry) - func historyListViewController(_ controller: HistoryListViewController, didSelectBookmark bookmark: QueryBookmark) - func historyListViewController(_ controller: HistoryListViewController, didDoubleClickHistoryEntry entry: QueryHistoryEntry) - func historyListViewController(_ controller: HistoryListViewController, didDoubleClickBookmark bookmark: QueryBookmark) - func historyListViewControllerDidClearSelection(_ controller: HistoryListViewController) -} - -// MARK: - Display Mode - -enum HistoryDisplayMode: Int { - case history = 0 - case bookmarks = 1 -} - -// MARK: - UI Date Filter (maps to DateFilter from QueryHistoryStorage) - -enum UIDateFilter: Int { - case today = 0 - case week = 1 - case month = 2 - case all = 3 - - var title: String { - switch self { - case .today: return "Today" - case .week: return "This Week" - case .month: return "This Month" - case .all: return "All Time" - } - } - - /// Convert to storage DateFilter - var toDateFilter: DateFilter { - switch self { - case .today: return .today - case .week: return .thisWeek - case .month: return .thisMonth - case .all: return .all - } - } -} - -// MARK: - HistoryListViewController - -final class HistoryListViewController: NSViewController, NSMenuItemValidation { - - // MARK: - Properties - - weak var delegate: HistoryListViewControllerDelegate? - - private var displayMode: HistoryDisplayMode = .history { - didSet { - if oldValue != displayMode { - updateFilterVisibility() - loadData() - } - } - } - - private var dateFilter: UIDateFilter = .all { - didSet { - if oldValue != dateFilter { - loadData() - } - } - } - - private var searchText: String = "" { - didSet { - scheduleSearch() - } - } - - private var historyEntries: [QueryHistoryEntry] = [] - private var bookmarks: [QueryBookmark] = [] - - private var searchTask: DispatchWorkItem? - private let searchDebounceInterval: TimeInterval = 0.15 - - // Track pending deletion for smart selection - private var pendingDeletionRow: Int? - private var pendingDeletionCount: Int? - - // MARK: - UI Components - - private let headerView: NSVisualEffectView = { - let view = NSVisualEffectView() - view.material = .headerView - view.blendingMode = .withinWindow - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private lazy var modeSegment: NSSegmentedControl = { - let segment = NSSegmentedControl(labels: ["History", "Bookmarks"], trackingMode: .selectOne, target: self, action: #selector(modeChanged(_:))) - segment.selectedSegment = 0 - segment.translatesAutoresizingMaskIntoConstraints = false - segment.controlSize = .small - return segment - }() - - private lazy var searchField: NSSearchField = { - let field = NSSearchField() - field.placeholderString = "Search queries..." - field.delegate = self - field.translatesAutoresizingMaskIntoConstraints = false - field.controlSize = .small - return field - }() - - private lazy var filterButton: NSPopUpButton = { - let button = NSPopUpButton(frame: .zero, pullsDown: false) - button.controlSize = .small - button.translatesAutoresizingMaskIntoConstraints = false - - for filter in [UIDateFilter.today, .week, .month, .all] { - button.addItem(withTitle: filter.title) - } - button.selectItem(at: UIDateFilter.all.rawValue) - button.target = self - button.action = #selector(filterChanged(_:)) - return button - }() - - private lazy var clearAllButton: NSButton = { - let button = NSButton() - button.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Clear All") - button.bezelStyle = .shadowlessSquare - button.isBordered = false - button.imagePosition = .imageOnly - button.translatesAutoresizingMaskIntoConstraints = false - button.target = self - button.action = #selector(clearAllClicked(_:)) - button.toolTip = "Clear all \(displayMode == .history ? "history" : "bookmarks")" - return button - }() - - - private let scrollView: NSScrollView = { - let scroll = NSScrollView() - scroll.hasVerticalScroller = true - scroll.hasHorizontalScroller = false - scroll.autohidesScrollers = true - scroll.borderType = .noBorder - scroll.translatesAutoresizingMaskIntoConstraints = false - scroll.drawsBackground = false - return scroll - }() - - private lazy var tableView: HistoryTableView = { - let table = HistoryTableView() - table.style = .plain - table.headerView = nil - table.rowHeight = 56 - table.intercellSpacing = NSSize(width: 0, height: 1) - table.backgroundColor = .clear - table.usesAlternatingRowBackgroundColors = false - table.allowsMultipleSelection = false - table.delegate = self - table.dataSource = self - table.doubleAction = #selector(tableViewDoubleClick(_:)) - table.target = self - table.keyboardDelegate = self // Set keyboard delegate - - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("MainColumn")) - column.width = 300 - table.addTableColumn(column) - - return table - }() - - private lazy var emptyStateView: NSView = { - let container = NSView() - container.translatesAutoresizingMaskIntoConstraints = false - container.isHidden = true - - let stackView = NSStackView() - stackView.orientation = .vertical - stackView.alignment = .centerX - stackView.spacing = 8 - stackView.translatesAutoresizingMaskIntoConstraints = false - - let imageView = NSImageView() - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: 48), - imageView.heightAnchor.constraint(equalToConstant: 48) - ]) - imageView.contentTintColor = .tertiaryLabelColor - self.emptyImageView = imageView - - let titleLabel = NSTextField(labelWithString: "") - titleLabel.font = .systemFont(ofSize: 14, weight: .medium) - titleLabel.textColor = .secondaryLabelColor - titleLabel.alignment = .center - self.emptyTitleLabel = titleLabel - - let subtitleLabel = NSTextField(labelWithString: "") - subtitleLabel.font = .systemFont(ofSize: 12) - subtitleLabel.textColor = .tertiaryLabelColor - subtitleLabel.alignment = .center - subtitleLabel.maximumNumberOfLines = 2 - subtitleLabel.lineBreakMode = .byWordWrapping - subtitleLabel.preferredMaxLayoutWidth = 200 - self.emptySubtitleLabel = subtitleLabel - - stackView.addArrangedSubview(imageView) - stackView.addArrangedSubview(titleLabel) - stackView.addArrangedSubview(subtitleLabel) - - container.addSubview(stackView) - NSLayoutConstraint.activate([ - stackView.centerXAnchor.constraint(equalTo: container.centerXAnchor), - stackView.centerYAnchor.constraint(equalTo: container.centerYAnchor) - ]) - - return container - }() - - private weak var emptyImageView: NSImageView? - private weak var emptyTitleLabel: NSTextField? - private weak var emptySubtitleLabel: NSTextField? - - // MARK: - Lifecycle - - override func loadView() { - view = NSView() - view.wantsLayer = true - } - - override func viewDidLoad() { - super.viewDidLoad() - setupUI() - setupNotifications() - restoreState() - loadData() - } - - // MARK: - Setup - - private func setupUI() { - // Header - view.addSubview(headerView) - - let headerStack = NSStackView() - headerStack.orientation = .vertical - headerStack.spacing = 8 - headerStack.translatesAutoresizingMaskIntoConstraints = false - headerStack.edgeInsets = NSEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) - - let topRow = NSStackView(views: [modeSegment, NSView(), clearAllButton, filterButton]) - topRow.distribution = .fill - topRow.spacing = 8 - - headerStack.addArrangedSubview(topRow) - headerStack.addArrangedSubview(searchField) - - headerView.addSubview(headerStack) - - // Divider - let divider = NSBox() - divider.boxType = .separator - divider.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(divider) - - // Scroll view with table - scrollView.documentView = tableView - view.addSubview(scrollView) - - // Empty state (overlays scroll view) - view.addSubview(emptyStateView) - - NSLayoutConstraint.activate([ - // Header - headerView.topAnchor.constraint(equalTo: view.topAnchor), - headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - - headerStack.topAnchor.constraint(equalTo: headerView.topAnchor), - headerStack.leadingAnchor.constraint(equalTo: headerView.leadingAnchor), - headerStack.trailingAnchor.constraint(equalTo: headerView.trailingAnchor), - headerStack.bottomAnchor.constraint(equalTo: headerView.bottomAnchor), - - // Divider - divider.topAnchor.constraint(equalTo: headerView.bottomAnchor), - divider.leadingAnchor.constraint(equalTo: view.leadingAnchor), - divider.trailingAnchor.constraint(equalTo: view.trailingAnchor), - - // Scroll view - scrollView.topAnchor.constraint(equalTo: divider.bottomAnchor), - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - // Empty state - emptyStateView.topAnchor.constraint(equalTo: scrollView.topAnchor), - emptyStateView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), - emptyStateView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), - emptyStateView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor) - ]) - - updateFilterVisibility() - } - - private func setupNotifications() { - NotificationCenter.default.addObserver( - self, - selector: #selector(historyDidUpdate), - name: .queryHistoryDidUpdate, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(bookmarksDidUpdate), - name: .queryBookmarksDidUpdate, - object: nil - ) - } - - // MARK: - State Persistence - - private func restoreState() { - let savedMode = UserDefaults.standard.integer(forKey: "HistoryPanel.displayMode") - let savedFilter = UserDefaults.standard.integer(forKey: "HistoryPanel.dateFilter") - - if let mode = HistoryDisplayMode(rawValue: savedMode) { - displayMode = mode - modeSegment.selectedSegment = mode.rawValue - } - - if let filter = UIDateFilter(rawValue: savedFilter) { - dateFilter = filter - filterButton.selectItem(at: filter.rawValue) - } - } - - private func saveState() { - UserDefaults.standard.set(displayMode.rawValue, forKey: "HistoryPanel.displayMode") - UserDefaults.standard.set(dateFilter.rawValue, forKey: "HistoryPanel.dateFilter") - } - - // MARK: - Data Loading - - private func loadData() { - switch displayMode { - case .history: - loadHistory() - case .bookmarks: - loadBookmarks() - } - } - - private func loadHistory() { - historyEntries = QueryHistoryManager.shared.fetchHistory( - limit: 500, - offset: 0, - connectionId: nil, - searchText: searchText.isEmpty ? nil : searchText, - dateFilter: dateFilter.toDateFilter - ) - - - tableView.reloadData() - updateEmptyState() - - // Handle pending deletion selection - if let deletedRow = pendingDeletionRow, let countBefore = pendingDeletionCount { - selectRowAfterDeletion(deletedRow: deletedRow, countBefore: countBefore) - pendingDeletionRow = nil - pendingDeletionCount = nil - } else if tableView.selectedRow < 0 { - // Clear preview if no selection - delegate?.historyListViewControllerDidClearSelection(self) - } - } - - private func loadBookmarks() { - bookmarks = QueryHistoryManager.shared.fetchBookmarks( - searchText: searchText.isEmpty ? nil : searchText, - tag: nil - ) - - - tableView.reloadData() - updateEmptyState() - - // Handle pending deletion selection - if let deletedRow = pendingDeletionRow, let countBefore = pendingDeletionCount { - selectRowAfterDeletion(deletedRow: deletedRow, countBefore: countBefore) - pendingDeletionRow = nil - pendingDeletionCount = nil - } else if tableView.selectedRow < 0 { - // Clear preview if no selection - delegate?.historyListViewControllerDidClearSelection(self) - } - } - - // MARK: - Search - - private func scheduleSearch() { - searchTask?.cancel() - - let task = DispatchWorkItem { [weak self] in - self?.loadData() - } - searchTask = task - - DispatchQueue.main.asyncAfter(deadline: .now() + searchDebounceInterval, execute: task) - } - - // MARK: - Actions - - @objc private func modeChanged(_ sender: NSSegmentedControl) { - if let mode = HistoryDisplayMode(rawValue: sender.selectedSegment) { - displayMode = mode - saveState() - } - } - - @objc private func filterChanged(_ sender: NSPopUpButton) { - if let filter = UIDateFilter(rawValue: sender.indexOfSelectedItem) { - dateFilter = filter - saveState() - } - } - - @objc private func tableViewDoubleClick(_ sender: Any) { - let row = tableView.clickedRow - guard row >= 0 else { return } - - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - delegate?.historyListViewController(self, didDoubleClickHistoryEntry: historyEntries[row]) - case .bookmarks: - guard row < bookmarks.count else { return } - delegate?.historyListViewController(self, didDoubleClickBookmark: bookmarks[row]) - } - } - - @objc private func historyDidUpdate() { - if displayMode == .history { - loadData() - } - } - - @objc private func bookmarksDidUpdate() { - if displayMode == .bookmarks { - loadData() - } - } - - // MARK: - UI Updates - - private func updateFilterVisibility() { - filterButton.isHidden = displayMode == .bookmarks - searchField.placeholderString = displayMode == .history ? "Search queries..." : "Search bookmarks..." - } - - private func updateEmptyState() { - let isEmpty: Bool - switch displayMode { - case .history: - isEmpty = historyEntries.isEmpty - case .bookmarks: - isEmpty = bookmarks.isEmpty - } - - emptyStateView.isHidden = !isEmpty - scrollView.isHidden = isEmpty - - guard isEmpty else { return } - - let isSearching = !searchText.isEmpty - - if isSearching { - emptyImageView?.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "No results") - emptyTitleLabel?.stringValue = "No Matching Queries" - emptySubtitleLabel?.stringValue = "Try adjusting your search terms\nor date filter." - } else { - switch displayMode { - case .history: - emptyImageView?.image = NSImage(systemSymbolName: "clock.arrow.circlepath", accessibilityDescription: "No history") - emptyTitleLabel?.stringValue = "No Query History Yet" - emptySubtitleLabel?.stringValue = "Your executed queries will\nappear here for quick access." - case .bookmarks: - emptyImageView?.image = NSImage(systemSymbolName: "bookmark", accessibilityDescription: "No bookmarks") - emptyTitleLabel?.stringValue = "No Bookmarks Yet" - emptySubtitleLabel?.stringValue = "Save frequently used queries\nusing Cmd+Shift+B." - } - } - } - - // MARK: - Context Menu - - private func buildContextMenu(for row: Int) -> NSMenu { - let menu = NSMenu() - - let copyItem = NSMenuItem(title: "Copy Query", action: #selector(copyQuery(_:)), keyEquivalent: "c") - copyItem.keyEquivalentModifierMask = .command - copyItem.tag = row - menu.addItem(copyItem) - - let runItem = NSMenuItem(title: "Run in New Tab", action: #selector(runInNewTab(_:)), keyEquivalent: "\r") - runItem.tag = row - menu.addItem(runItem) - - menu.addItem(NSMenuItem.separator()) - - switch displayMode { - case .history: - let bookmarkItem = NSMenuItem(title: "Save as Bookmark...", action: #selector(saveAsBookmark(_:)), keyEquivalent: "") - bookmarkItem.tag = row - menu.addItem(bookmarkItem) - case .bookmarks: - let editItem = NSMenuItem(title: "Edit Bookmark...", action: #selector(editBookmark(_:)), keyEquivalent: "e") - editItem.keyEquivalentModifierMask = .command - editItem.tag = row - menu.addItem(editItem) - } - - menu.addItem(NSMenuItem.separator()) - - let deleteItem = NSMenuItem(title: "Delete", action: #selector(deleteEntry(_:)), keyEquivalent: "\u{8}") - deleteItem.keyEquivalentModifierMask = [] - deleteItem.tag = row - menu.addItem(deleteItem) - - return menu - } - - @objc private func copyQuery(_ sender: NSMenuItem) { - let row = sender.tag - let query: String - - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - query = historyEntries[row].query - case .bookmarks: - guard row < bookmarks.count else { return } - query = bookmarks[row].query - } - - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(query, forType: .string) - } - - @objc private func runInNewTab(_ sender: NSMenuItem) { - let row = sender.tag - let query: String - - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - query = historyEntries[row].query - case .bookmarks: - guard row < bookmarks.count else { return } - query = bookmarks[row].query - QueryHistoryManager.shared.markBookmarkUsed(id: bookmarks[row].id) - } - - NotificationCenter.default.post(name: .newTab, object: nil) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - NotificationCenter.default.post( - name: .loadQueryIntoEditor, - object: nil, - userInfo: ["query": query] - ) - } - } - - @objc private func saveAsBookmark(_ sender: NSMenuItem) { - let row = sender.tag - guard row < historyEntries.count else { return } - let entry = historyEntries[row] - - let editor = BookmarkEditorController( - bookmark: nil, - query: entry.query, - connectionId: entry.connectionId - ) - - editor.onSave = { [weak self] bookmark in - let success = QueryHistoryManager.shared.saveBookmark( - name: bookmark.name, - query: bookmark.query, - connectionId: bookmark.connectionId, - tags: bookmark.tags, - notes: bookmark.notes - ) - - if success { - } else { - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "Failed to Save Bookmark" - alert.informativeText = "Could not save the bookmark to storage. Please try again." - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() - } - } - } - - view.window?.contentViewController?.presentAsSheet(editor) - } - - @objc private func editBookmark(_ sender: NSMenuItem) { - let row = sender.tag - guard row < bookmarks.count else { return } - let bookmark = bookmarks[row] - - let editorView = BookmarkEditorView( - bookmark: bookmark, - query: bookmark.query, - connectionId: bookmark.connectionId - ) { [weak self] updatedBookmark in - let success = QueryHistoryManager.shared.updateBookmark(updatedBookmark) - - if success { - } else { - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "Failed to Update Bookmark" - alert.informativeText = "Could not save changes to the bookmark. Please try again." - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() - } - } - } - - presentAsSheet(editorView) - } - - @objc private func deleteEntry(_ sender: NSMenuItem) { - let row = sender.tag - - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - let entry = historyEntries[row] - QueryHistoryManager.shared.deleteHistory(id: entry.id) - case .bookmarks: - guard row < bookmarks.count else { return } - let bookmark = bookmarks[row] - QueryHistoryManager.shared.deleteBookmark(id: bookmark.id) - } - } - - @objc private func clearAllClicked(_ sender: Any?) { - let count: Int - let itemName: String - - switch displayMode { - case .history: - count = historyEntries.count - itemName = count == 1 ? "history entry" : "history entries" - case .bookmarks: - count = bookmarks.count - itemName = count == 1 ? "bookmark" : "bookmarks" - } - - 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") - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - switch displayMode { - case .history: - _ = QueryHistoryManager.shared.clearAllHistory() - case .bookmarks: - _ = QueryHistoryManager.shared.clearAllBookmarks() - } - } - } -} - -// MARK: - NSTableViewDataSource - -extension HistoryListViewController: NSTableViewDataSource { - - func numberOfRows(in tableView: NSTableView) -> Int { - switch displayMode { - case .history: - return historyEntries.count - case .bookmarks: - return bookmarks.count - } - } -} - -// MARK: - NSTableViewDelegate - -extension HistoryListViewController: NSTableViewDelegate { - - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - switch displayMode { - case .history: - return historyCell(for: row) - case .bookmarks: - return bookmarkCell(for: row) - } - } - - private func historyCell(for row: Int) -> NSView? { - guard row < historyEntries.count else { return nil } - let entry = historyEntries[row] - - let identifier = NSUserInterfaceItemIdentifier("HistoryCell") - let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? HistoryRowView - ?? HistoryRowView() - cell.identifier = identifier - cell.configureForHistory(entry) - return cell - } - - private func bookmarkCell(for row: Int) -> NSView? { - guard row < bookmarks.count else { return nil } - let bookmark = bookmarks[row] - - let identifier = NSUserInterfaceItemIdentifier("BookmarkCell") - let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? HistoryRowView - ?? HistoryRowView() - cell.identifier = identifier - cell.configureForBookmark(bookmark) - return cell - } - - func tableViewSelectionDidChange(_ notification: Notification) { - let row = tableView.selectedRow - guard row >= 0 else { - delegate?.historyListViewControllerDidClearSelection(self) - return - } - - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - delegate?.historyListViewController(self, didSelectHistoryEntry: historyEntries[row]) - case .bookmarks: - guard row < bookmarks.count else { return } - delegate?.historyListViewController(self, didSelectBookmark: bookmarks[row]) - } - } - - func tableView(_ tableView: NSTableView, rowActionsForRow row: Int, edge: NSTableView.RowActionEdge) -> [NSTableViewRowAction] { - if edge == .trailing { - let delete = NSTableViewRowAction(style: .destructive, title: "Delete") { [weak self] _, row in - self?.deleteEntryAtRow(row) - } - return [delete] - } - return [] - } - - private func deleteEntryAtRow(_ row: Int) { - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - QueryHistoryManager.shared.deleteHistory(id: historyEntries[row].id) - case .bookmarks: - guard row < bookmarks.count else { return } - QueryHistoryManager.shared.deleteBookmark(id: bookmarks[row].id) - } - } -} - -// MARK: - NSSearchFieldDelegate - -extension HistoryListViewController: NSSearchFieldDelegate { - - func controlTextDidChange(_ obj: Notification) { - if let field = obj.object as? NSSearchField { - searchText = field.stringValue - } - } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if commandSelector == #selector(cancelOperation(_:)) { - if !searchText.isEmpty { - searchField.stringValue = "" - searchText = "" - return true - } - } - return false - } -} - -// MARK: - NSMenuDelegate (Context Menu) - -extension HistoryListViewController { - - override func rightMouseDown(with event: NSEvent) { - let point = tableView.convert(event.locationInWindow, from: nil) - let row = tableView.row(at: point) - - if row >= 0 { - tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - let menu = buildContextMenu(for: row) - NSMenu.popUpContextMenu(menu, with: event, for: tableView) - } - } - - // MARK: - Helper Methods - - func handleDeleteKey() { - deleteSelectedRow() - } - - /// Standard delete action for menu integration - @objc func delete(_ sender: Any?) { - deleteSelectedRow() - } - - /// Standard copy action for menu integration (Cmd+C) - @objc func copy(_ sender: Any?) { - copyQueryForSelectedRow() - } - - /// Validate menu items - func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - if menuItem.action == #selector(delete(_:)) { - let hasSelection = tableView.selectedRow >= 0 - let hasItems = displayMode == .history ? historyEntries.count > 0 : bookmarks.count > 0 - return hasSelection && hasItems - } - if menuItem.action == #selector(copy(_:)) { - return tableView.selectedRow >= 0 - } - return true - } - - // MARK: - Keyboard Actions - - /// Handle Return/Enter key - open selected item in new tab - func handleReturnKey() { - runInNewTabForSelectedRow() - } - - /// Handle Space key - toggle preview (currently just copies to show it's working) - func handleSpaceKey() { - // TODO: Implement preview panel toggle - // For now, just show the query text in a temporary way - let row = tableView.selectedRow - guard row >= 0 else { return } - - let query: String - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - query = historyEntries[row].query - case .bookmarks: - guard row < bookmarks.count else { return } - query = bookmarks[row].query - } - - // Preview panel will be implemented in a future update - } - - /// Handle Cmd+E - edit bookmark - func handleEditBookmark() { - guard displayMode == .bookmarks else { return } - editBookmarkForSelectedRow() - } - - /// Handle Escape key - clear search or selection - func handleEscapeKey() { - // If search field has text, clear it - if !searchText.isEmpty { - searchField.stringValue = "" - searchText = "" - searchField.window?.makeFirstResponder(tableView) - } else if tableView.selectedRow >= 0 { - // Otherwise clear selection - tableView.deselectAll(nil) - } - } - - // MARK: - Keyboard Shortcut Helpers - - private func copyQueryForSelectedRow() { - let row = tableView.selectedRow - guard row >= 0 else { return } - - - let query: String - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - query = historyEntries[row].query - case .bookmarks: - guard row < bookmarks.count else { return } - query = bookmarks[row].query - } - - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(query, forType: .string) - } - - private func runInNewTabForSelectedRow() { - let row = tableView.selectedRow - guard row >= 0 else { - return - } - - - let query: String - switch displayMode { - case .history: - guard row < historyEntries.count else { - return - } - query = historyEntries[row].query - case .bookmarks: - guard row < bookmarks.count else { - return - } - query = bookmarks[row].query - QueryHistoryManager.shared.markBookmarkUsed(id: bookmarks[row].id) - } - - NotificationCenter.default.post(name: .newTab, object: nil) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - NotificationCenter.default.post( - name: .loadQueryIntoEditor, - object: nil, - userInfo: ["query": query] - ) - } - } - - private func saveAsBookmarkForSelectedRow() { - let row = tableView.selectedRow - guard displayMode == .history, row >= 0, row < historyEntries.count else { return } - - let entry = historyEntries[row] - let editorView = BookmarkEditorView( - query: entry.query, - connectionId: entry.connectionId - ) { [weak self] bookmark in - let success = QueryHistoryManager.shared.saveBookmark( - name: bookmark.name, - query: bookmark.query, - connectionId: bookmark.connectionId, - tags: bookmark.tags, - notes: bookmark.notes - ) - - if success { - } else { - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "Failed to Save Bookmark" - alert.informativeText = "Could not save the bookmark to storage. Please try again." - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() - } - } - } - - presentAsSheet(editorView) - } - - private func editBookmarkForSelectedRow() { - let row = tableView.selectedRow - guard displayMode == .bookmarks, row >= 0, row < bookmarks.count else { return } - - let bookmark = bookmarks[row] - let editorView = BookmarkEditorView( - bookmark: bookmark, - query: bookmark.query, - connectionId: bookmark.connectionId - ) { [weak self] updatedBookmark in - let success = QueryHistoryManager.shared.updateBookmark(updatedBookmark) - - if success { - } else { - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "Failed to Update Bookmark" - alert.informativeText = "Could not save changes to the bookmark. Please try again." - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() - } - } - } - - presentAsSheet(editorView) - } - - func deleteSelectedRow() { - let row = tableView.selectedRow - guard row >= 0 else { return } - - - // Store the count before deletion and row for smart selection - let countBeforeDeletion: Int - switch displayMode { - case .history: - countBeforeDeletion = historyEntries.count - case .bookmarks: - countBeforeDeletion = bookmarks.count - } - - // Store for selection after reload - pendingDeletionRow = row - pendingDeletionCount = countBeforeDeletion - - // Perform deletion - switch displayMode { - case .history: - guard row < historyEntries.count else { return } - let entryId = historyEntries[row].id - QueryHistoryManager.shared.deleteHistory(id: entryId) - // Selection will happen in loadHistory() after notification - - case .bookmarks: - guard row < bookmarks.count else { return } - let bookmarkId = bookmarks[row].id - let bookmarkName = bookmarks[row].name - QueryHistoryManager.shared.deleteBookmark(id: bookmarkId) - // Selection will happen in loadBookmarks() if notification triggers, - // otherwise do it manually - } - } - - /// Select an appropriate row after deletion - /// Selects the next row, or the previous row if the last item was deleted - private func selectRowAfterDeletion(deletedRow: Int, countBefore: Int) { - let currentCount: Int - switch displayMode { - case .history: - currentCount = historyEntries.count - case .bookmarks: - currentCount = bookmarks.count - } - - // If list is now empty, clear selection and delegate - guard currentCount > 0 else { - tableView.deselectAll(nil) - delegate?.historyListViewControllerDidClearSelection(self) - return - } - - // Select next item if available, otherwise select previous - let newSelection: Int - if deletedRow < currentCount { - // Next item moved into this position - newSelection = deletedRow - } else { - // Deleted last item, select new last item - newSelection = currentCount - 1 - } - - tableView.selectRowIndexes(IndexSet(integer: newSelection), byExtendingSelection: false) - tableView.scrollRowToVisible(newSelection) - - // Notify delegate of new selection - switch displayMode { - case .history: - if newSelection < historyEntries.count { - delegate?.historyListViewController(self, didSelectHistoryEntry: historyEntries[newSelection]) - } - case .bookmarks: - if newSelection < bookmarks.count { - delegate?.historyListViewController(self, didSelectBookmark: bookmarks[newSelection]) - } - } - } -} - -// MARK: - HistoryRowView - -final class HistoryRowView: NSTableCellView { - - private let statusIcon: NSImageView = { - let imageView = NSImageView() - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.translatesAutoresizingMaskIntoConstraints = false - return imageView - }() - - private let queryLabel: NSTextField = { - let label = NSTextField(labelWithString: "") - label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) - label.textColor = .labelColor - label.lineBreakMode = .byTruncatingTail - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private let secondaryLabel: NSTextField = { - let label = NSTextField(labelWithString: "") - label.font = .systemFont(ofSize: 10) - label.textColor = .secondaryLabelColor - label.lineBreakMode = .byTruncatingTail - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private let timeLabel: NSTextField = { - let label = NSTextField(labelWithString: "") - label.font = .systemFont(ofSize: 10) - label.textColor = .tertiaryLabelColor - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private let durationLabel: NSTextField = { - let label = NSTextField(labelWithString: "") - label.font = .systemFont(ofSize: 10) - label.textColor = .tertiaryLabelColor - label.alignment = .right - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private var isSetup = false - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setupViews() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupViews() - } - - private func setupViews() { - guard !isSetup else { return } - isSetup = true - - addSubview(statusIcon) - addSubview(queryLabel) - addSubview(secondaryLabel) - addSubview(timeLabel) - addSubview(durationLabel) - - NSLayoutConstraint.activate([ - // Status icon - statusIcon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - statusIcon.topAnchor.constraint(equalTo: topAnchor, constant: 10), - statusIcon.widthAnchor.constraint(equalToConstant: 14), - statusIcon.heightAnchor.constraint(equalToConstant: 14), - - // Query label (first line) - queryLabel.leadingAnchor.constraint(equalTo: statusIcon.trailingAnchor, constant: 8), - queryLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - queryLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8), - - // Secondary label (second line - database/tags) - secondaryLabel.leadingAnchor.constraint(equalTo: queryLabel.leadingAnchor), - secondaryLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - secondaryLabel.topAnchor.constraint(equalTo: queryLabel.bottomAnchor, constant: 2), - - // Time label (third line left) - timeLabel.leadingAnchor.constraint(equalTo: queryLabel.leadingAnchor), - timeLabel.topAnchor.constraint(equalTo: secondaryLabel.bottomAnchor, constant: 2), - - // Duration label (third line right) - durationLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - durationLabel.centerYAnchor.constraint(equalTo: timeLabel.centerYAnchor), - durationLabel.leadingAnchor.constraint(greaterThanOrEqualTo: timeLabel.trailingAnchor, constant: 8) - ]) - } - - func configureForHistory(_ entry: QueryHistoryEntry) { - // Status icon - let imageName = entry.wasSuccessful ? "checkmark.circle.fill" : "xmark.circle.fill" - statusIcon.image = NSImage(systemSymbolName: imageName, accessibilityDescription: entry.wasSuccessful ? "Success" : "Error") - statusIcon.contentTintColor = entry.wasSuccessful ? .systemGreen : .systemRed - - // Query preview - queryLabel.stringValue = entry.queryPreview - - // Database - secondaryLabel.stringValue = entry.databaseName - - // Relative time - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - timeLabel.stringValue = formatter.localizedString(for: entry.executedAt, relativeTo: Date()) - - // Duration - durationLabel.stringValue = entry.formattedExecutionTime - } - - func configureForBookmark(_ bookmark: QueryBookmark) { - // Bookmark icon - statusIcon.image = NSImage(systemSymbolName: "bookmark.fill", accessibilityDescription: "Bookmark") - statusIcon.contentTintColor = .systemYellow - - // Bookmark name - queryLabel.stringValue = bookmark.name - queryLabel.font = .systemFont(ofSize: 12, weight: .medium) - - // Tags - secondaryLabel.stringValue = bookmark.hasTags ? bookmark.formattedTags : "No tags" - - // Created date - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .medium - dateFormatter.timeStyle = .none - timeLabel.stringValue = dateFormatter.string(from: bookmark.createdAt) - - // Clear duration - durationLabel.stringValue = "" - } - - override func prepareForReuse() { - super.prepareForReuse() - queryLabel.font = .monospacedSystemFont(ofSize: 11, weight: .regular) - statusIcon.image = nil - queryLabel.stringValue = "" - secondaryLabel.stringValue = "" - timeLabel.stringValue = "" - durationLabel.stringValue = "" - } -} - -// MARK: - Custom TableView for Keyboard Handling - -/// Custom table view for keyboard delegation -private class HistoryTableView: NSTableView, NSMenuItemValidation { - weak var keyboardDelegate: HistoryListViewController? - - override var acceptsFirstResponder: Bool { - return true - } - - override func mouseDown(with event: NSEvent) { - super.mouseDown(with: event) - // Ensure we become first responder for keyboard shortcuts - window?.makeFirstResponder(self) - } - - // MARK: - Standard Responder Actions - - @objc func delete(_ sender: Any?) { - keyboardDelegate?.deleteSelectedRow() - } - - @objc func copy(_ sender: Any?) { - keyboardDelegate?.copy(sender) - } - - func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - if menuItem.action == #selector(delete(_:)) { - return keyboardDelegate?.validateMenuItem(menuItem) ?? false - } - if menuItem.action == #selector(copy(_:)) { - return selectedRow >= 0 - } - return false - } - - // MARK: - Keyboard Event Handling - - override func keyDown(with event: NSEvent) { - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - - // Return/Enter key - open in new tab - if (event.keyCode == 36 || event.keyCode == 76) && modifiers.isEmpty { - if selectedRow >= 0 { - keyboardDelegate?.handleReturnKey() - return - } - } - - // Space key - toggle preview - if event.keyCode == 49 && modifiers.isEmpty { - if selectedRow >= 0 { - keyboardDelegate?.handleSpaceKey() - return - } - } - - // Cmd+E - edit bookmark - if event.keyCode == 14 && modifiers == .command { - keyboardDelegate?.handleEditBookmark() - return - } - - // Escape key - clear search or selection - if event.keyCode == 53 && modifiers.isEmpty { - keyboardDelegate?.handleEscapeKey() - return - } - - // Delete key (bare, not Cmd+Delete which goes through menu) - if event.keyCode == 51 && modifiers.isEmpty { - if selectedRow >= 0 { - keyboardDelegate?.handleDeleteKey() - return - } - } - - super.keyDown(with: event) - } -} diff --git a/OpenTable/Views/Filter/FilterPanelView.swift b/OpenTable/Views/Filter/FilterPanelView.swift new file mode 100644 index 00000000..c95bd0ce --- /dev/null +++ b/OpenTable/Views/Filter/FilterPanelView.swift @@ -0,0 +1,325 @@ +// +// FilterPanelView.swift +// OpenTable +// +// Bottom filter panel for table data filtering. +// Child views extracted to separate files for maintainability. +// + +import SwiftUI + +/// Bottom filter panel for table data filtering +struct FilterPanelView: View { + @ObservedObject var filterState: FilterStateManager + let columns: [String] + let primaryKeyColumn: String? + let databaseType: DatabaseType + let onApply: ([TableFilter]) -> Void + let onUnset: () -> Void + let onQuickSearch: ((String) -> Void)? + + @State private var showSQLSheet = false + @State private var showSettingsPopover = false + @State private var generatedSQL = "" + @State private var showSavePresetAlert = false + @State private var newPresetName = "" + @State private var savedPresets: [FilterPreset] = [] + + var body: some View { + VStack(spacing: 0) { + filterHeader + + Divider() + .foregroundStyle(Color(nsColor: .separatorColor)) + + // Quick Search field (when no filters or alongside filters) + if filterState.hasActiveQuickSearch || filterState.filters.isEmpty { + QuickSearchField( + searchText: $filterState.quickSearchText, + hasActiveSearch: filterState.hasActiveQuickSearch, + onSubmit: { onQuickSearch?(filterState.quickSearchText) }, + onClear: { filterState.clearQuickSearch() } + ) + Divider() + .foregroundStyle(Color(nsColor: .separatorColor)) + } + + // Filter rows + if filterState.filters.isEmpty { + if !filterState.hasActiveQuickSearch { + emptyState + } + } else { + filterList + } + + Divider() + .foregroundStyle(Color(nsColor: .separatorColor)) + + filterFooter + } + .background(Color(nsColor: .windowBackgroundColor)) + .sheet(isPresented: $showSQLSheet) { + SQLPreviewSheet(sql: generatedSQL, tableName: "", databaseType: databaseType) + } + } + + // MARK: - Header + + private var filterHeader: some View { + HStack(spacing: 8) { + Text("Filters") + .font(.system(size: 12, weight: .medium)) + + if filterState.hasAppliedFilters { + Text("(\(filterState.appliedFilters.count) active)") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer() + + // AND/OR Logic Toggle + Picker("", selection: $filterState.filterLogicMode) { + Text("AND").tag(FilterLogicMode.and) + Text("OR").tag(FilterLogicMode.or) + } + .pickerStyle(.segmented) + .frame(width: 80) + .help("Match ALL filters (AND) or ANY filter (OR)") + + presetsMenu + + // Settings button + Button(action: { showSettingsPopover.toggle() }) { + Image(systemName: "gearshape") + .font(.system(size: 12)) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Filter Settings") + .popover(isPresented: $showSettingsPopover, arrowEdge: .bottom) { + FilterSettingsPopover() + } + + // Add filter button + Button(action: { + filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) + }) { + Image(systemName: "plus") + .font(.system(size: 12)) + } + .buttonStyle(.borderless) + .foregroundColor(.accentColor) + .help("Add Filter (Cmd+Shift+F)") + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(nsColor: .controlBackgroundColor)) + .contentShape(Rectangle()) + .onTapGesture { filterState.focusedFilterId = nil } + .alert("Save Filter Preset", isPresented: $showSavePresetAlert) { + TextField("Preset Name", text: $newPresetName) + Button("Cancel", role: .cancel) {} + Button("Save") { + if !newPresetName.isEmpty { + filterState.saveAsPreset(name: newPresetName) + loadPresets() + } + } + } message: { + Text("Enter a name for this filter preset") + } + } + + // MARK: - Presets Menu + + private var presetsMenu: some View { + Menu { + if !savedPresets.isEmpty { + ForEach(savedPresets) { preset in + Button(preset.name) { + filterState.loadPreset(preset) + } + } + Divider() + } + + Button("Save as Preset...") { + newPresetName = "" + showSavePresetAlert = true + } + .disabled(filterState.filters.isEmpty) + + if !savedPresets.isEmpty { + Menu("Delete Preset") { + ForEach(savedPresets) { preset in + Button(preset.name, role: .destructive) { + filterState.deletePreset(preset) + loadPresets() + } + } + } + } + } label: { + Image(systemName: "folder") + .font(.system(size: 12)) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Save and load filter presets") + .onAppear { + loadPresets() + } + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 32)) + .foregroundStyle(.tertiary) + + Text("No filters active") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button("Add Filter") { + filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) + } + .buttonStyle(.bordered) + .controlSize(.small) + + Text("or use Quick Search above") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + // MARK: - Filter List + + private var filterList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + ForEach(filterState.filters) { filter in + FilterRowView( + filter: filterState.binding(for: filter), + columns: columns, + isFocused: filterState.focusedFilterId == filter.id, + onDuplicate: { filterState.duplicateFilter(filter) }, + onRemove: { filterState.removeFilter(filter) }, + onApply: { applySingleFilter(filter) }, + onFocus: { filterState.focusedFilterId = filter.id } + ) + .id(filter.id) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .frame(maxHeight: min(CGFloat(filterState.filters.count) * 40 + 8, 160)) + .onChange(of: filterState.focusedFilterId) { _, newFocusedId in + if let focusedId = newFocusedId { + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(focusedId, anchor: .bottom) + } + } + } + } + } + + // MARK: - Footer + + private var filterFooter: some View { + HStack(spacing: 8) { + Toggle("Select All", isOn: selectAllBinding) + .toggleStyle(.checkbox) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .disabled(filterState.filters.isEmpty) + + Spacer() + + Button("Unset") { + filterState.clearAppliedFilters() + onUnset() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!filterState.hasAppliedFilters) + + Button("SQL") { + generatedSQL = filterState.generatePreviewSQL(databaseType: databaseType) + showSQLSheet = true + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(filterState.filters.isEmpty) + + Button("Apply All") { + applySelectedFilters() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!filterState.hasSelectedFilters) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { filterState.focusedFilterId = nil } + } + + // MARK: - Helpers + + private var selectAllBinding: Binding { + Binding( + get: { filterState.allFiltersSelected }, + set: { filterState.selectAll($0) } + ) + } + + private func applySingleFilter(_ filter: TableFilter) { + guard filter.isValid else { return } + filterState.applySingleFilter(filter) + onApply([filter]) + } + + private func applySelectedFilters() { + filterState.applySelectedFilters() + onApply(filterState.appliedFilters) + } + + private func loadPresets() { + savedPresets = filterState.loadAllPresets() + } +} + +// MARK: - Preview + +#Preview("Filter Panel") { + FilterPanelView( + filterState: { + let state = FilterStateManager() + Task { @MainActor in + state.filters = [ + TableFilter(columnName: "name", filterOperator: .contains, value: "John"), + TableFilter(columnName: "age", filterOperator: .greaterThan, value: "18") + ] + } + return state + }(), + columns: ["id", "name", "age", "email"], + primaryKeyColumn: "id", + databaseType: .mysql, + onApply: { _ in }, + onUnset: { }, + onQuickSearch: { _ in } + ) + .frame(width: 600) +} diff --git a/OpenTable/Views/Filter/FilterRowView.swift b/OpenTable/Views/Filter/FilterRowView.swift new file mode 100644 index 00000000..78eb8408 --- /dev/null +++ b/OpenTable/Views/Filter/FilterRowView.swift @@ -0,0 +1,290 @@ +// +// FilterRowView.swift +// OpenTable +// +// Single filter row view with native macOS styling. +// Extracted from FilterPanelView for better maintainability. +// + +import SwiftUI + +/// Single filter row view with native macOS styling +struct FilterRowView: View { + @Binding var filter: TableFilter + let columns: [String] + let isFocused: Bool + let onDuplicate: () -> Void + let onRemove: () -> Void + let onApply: () -> Void + let onFocus: () -> Void + + @State private var isHovered: Bool = false + + /// Display name for the column (handles raw SQL and empty) + private var displayColumnName: String { + if filter.columnName == TableFilter.rawSQLColumn { + return "Raw SQL" + } else if filter.columnName.isEmpty { + return "Column" + } else { + return filter.columnName + } + } + + /// Dynamic background color based on state + private var backgroundFillColor: Color { + if isFocused { + return Color.accentColor.opacity(0.06) + } else if isHovered { + return Color(nsColor: .controlBackgroundColor) + } else { + return Color.clear + } + } + + /// Dynamic border color based on state + private var borderColor: Color { + if isFocused { + return Color.accentColor.opacity(0.3) + } else if isHovered { + return Color(nsColor: .separatorColor).opacity(0.5) + } else { + return Color.clear + } + } + + var body: some View { + HStack(spacing: 8) { + // Checkbox for multi-select + Toggle("", isOn: $filter.isSelected) + .toggleStyle(.checkbox) + .labelsHidden() + + // Column dropdown - native Menu style + columnMenu + .frame(width: 120) + + // Operator dropdown (hidden for raw SQL) + if !filter.isRawSQL { + operatorMenu + .frame(width: 110) + } + + // Value field(s) + valueFields + + Spacer(minLength: 0) + + // Action buttons + actionButtons + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(backgroundFillColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(borderColor, lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovered = hovering + } + } + .animation(.easeInOut(duration: 0.2), value: isFocused) + } + + // MARK: - Column Menu + + private var columnMenu: some View { + Menu { + Button(action: { filter.columnName = TableFilter.rawSQLColumn }) { + if filter.columnName == TableFilter.rawSQLColumn { + Label("Raw SQL", systemImage: "checkmark") + } else { + Text("Raw SQL") + } + } + + if !columns.isEmpty { + Divider() + ForEach(columns, id: \.self) { column in + Button(action: { filter.columnName = column }) { + if filter.columnName == column { + Label(column, systemImage: "checkmark") + } else { + Text(column) + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(displayColumnName) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .simultaneousGesture(TapGesture().onEnded { onFocus() }) + } + + // MARK: - Operator Menu + + private var operatorMenu: some View { + Menu { + ForEach(FilterOperator.allCases) { op in + Button(action: { filter.filterOperator = op }) { + if filter.filterOperator == op { + Label(op.displayName, systemImage: "checkmark") + } else { + Text(op.displayName) + } + } + } + } label: { + HStack(spacing: 4) { + Text(filter.filterOperator.displayName) + .font(.system(size: 12)) + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .simultaneousGesture(TapGesture().onEnded { onFocus() }) + } + + // MARK: - Value Fields + + @ViewBuilder + private var valueFields: some View { + if filter.isRawSQL { + // Raw SQL input + TextField("WHERE clause...", text: Binding( + get: { filter.rawSQL ?? "" }, + set: { filter.rawSQL = $0 } + )) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .onSubmit { onApply() } + .simultaneousGesture(TapGesture().onEnded { onFocus() }) + } else if filter.filterOperator.requiresValue { + // Standard value input + TextField("Value", text: $filter.value) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .frame(minWidth: 80) + .onSubmit { onApply() } + .simultaneousGesture(TapGesture().onEnded { onFocus() }) + + // Second value for BETWEEN + if filter.filterOperator.requiresSecondValue { + Text("and") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + TextField("Value", text: Binding( + get: { filter.secondValue ?? "" }, + set: { filter.secondValue = $0 } + )) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .frame(minWidth: 80) + .onSubmit { onApply() } + .simultaneousGesture(TapGesture().onEnded { onFocus() }) + } + } else { + // No value needed (IS NULL, etc.) - show indicator + Text("—") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + .frame(minWidth: 80, alignment: .leading) + } + } + + // MARK: - Action Buttons + + private var actionButtons: some View { + HStack(spacing: 8) { + // Apply single filter + Button(action: onApply) { + Image(systemName: "play.fill") + .font(.system(size: 11)) + } + .buttonStyle(.borderless) + .foregroundStyle(filter.isValid ? Color(nsColor: .systemGreen) : Color.secondary) + .disabled(!filter.isValid) + .help("Apply This Filter") + + // Duplicate + Button(action: onDuplicate) { + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Duplicate Filter") + + // Remove + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.system(size: 11)) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Remove Filter") + } + } +} diff --git a/OpenTable/Views/Filter/FilterSettingsPopover.swift b/OpenTable/Views/Filter/FilterSettingsPopover.swift new file mode 100644 index 00000000..42a9f1f3 --- /dev/null +++ b/OpenTable/Views/Filter/FilterSettingsPopover.swift @@ -0,0 +1,45 @@ +// +// FilterSettingsPopover.swift +// OpenTable +// +// Popover for filter default settings. +// Extracted from FilterPanelView for better maintainability. +// + +import SwiftUI + +/// Popover for filter default settings +struct FilterSettingsPopover: View { + @State private var settings: FilterSettings + + init() { + _settings = State(initialValue: FilterSettingsStorage.shared.loadSettings()) + } + + var body: some View { + Form { + Picker("Default Column", selection: $settings.defaultColumn) { + ForEach(FilterDefaultColumn.allCases) { option in + Text(option.displayName).tag(option) + } + } + + Picker("Default Operator", selection: $settings.defaultOperator) { + ForEach(FilterDefaultOperator.allCases) { option in + Text(option.displayName).tag(option) + } + } + + Picker("Panel State", selection: $settings.panelState) { + ForEach(FilterPanelDefaultState.allCases) { option in + Text(option.displayName).tag(option) + } + } + } + .formStyle(.grouped) + .frame(width: 280) + .onChange(of: settings) { _, newValue in + FilterSettingsStorage.shared.saveSettings(newValue) + } + } +} diff --git a/OpenTable/Views/Filter/QuickSearchField.swift b/OpenTable/Views/Filter/QuickSearchField.swift new file mode 100644 index 00000000..a366eddf --- /dev/null +++ b/OpenTable/Views/Filter/QuickSearchField.swift @@ -0,0 +1,47 @@ +// +// QuickSearchField.swift +// OpenTable +// +// Quick search field component for filtering across all columns. +// Extracted from FilterPanelView for better maintainability. +// + +import SwiftUI + +/// Quick search field for filtering across all columns +struct QuickSearchField: View { + @Binding var searchText: String + let hasActiveSearch: Bool + let onSubmit: () -> Void + let onClear: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + TextField("Quick search across all columns...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .onSubmit { + if !searchText.isEmpty { + onSubmit() + } + } + + if hasActiveSearch { + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help("Clear Search") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .textBackgroundColor)) + } +} diff --git a/OpenTable/Views/Filter/SQLPreviewSheet.swift b/OpenTable/Views/Filter/SQLPreviewSheet.swift new file mode 100644 index 00000000..93355439 --- /dev/null +++ b/OpenTable/Views/Filter/SQLPreviewSheet.swift @@ -0,0 +1,85 @@ +// +// SQLPreviewSheet.swift +// OpenTable +// +// Modal sheet to display generated SQL from filters. +// Extracted from FilterPanelView for better maintainability. +// + +import SwiftUI + +/// Modal sheet to display generated SQL +struct SQLPreviewSheet: View { + let sql: String + let tableName: String + let databaseType: DatabaseType + @Environment(\.dismiss) private var dismiss + @State private var copied = false + + var body: some View { + VStack(spacing: 16) { + HStack { + Text("Generated WHERE Clause") + .font(.system(size: 13, weight: .semibold)) + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(.tertiary) + } + .buttonStyle(.borderless) + } + + ScrollView { + Text(sql.isEmpty ? "(no conditions)" : sql) + .font(.system(size: 12, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .frame(maxHeight: 180) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + + HStack { + Button(action: copyToClipboard) { + HStack(spacing: 4) { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 11)) + Text(copied ? "Copied!" : "Copy") + .font(.system(size: 12)) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(sql.isEmpty) + + Spacer() + + Button("Close") { + dismiss() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .keyboardShortcut(.escape) + } + } + .padding(16) + .frame(width: 480, height: 300) + } + + private func copyToClipboard() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(sql, forType: .string) + copied = true + + // Reset after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copied = false + } + } +} diff --git a/OpenTable/Views/History/HistoryDataProvider.swift b/OpenTable/Views/History/HistoryDataProvider.swift new file mode 100644 index 00000000..e72b5cfc --- /dev/null +++ b/OpenTable/Views/History/HistoryDataProvider.swift @@ -0,0 +1,138 @@ +// +// HistoryDataProvider.swift +// OpenTable +// +// Data provider for history and bookmark entries. +// Extracted from HistoryListViewController for better separation of concerns. +// + +import Foundation + +/// Data provider for history and bookmark entries +final class HistoryDataProvider { + + // MARK: - Properties + + private(set) var historyEntries: [QueryHistoryEntry] = [] + private(set) var bookmarks: [QueryBookmark] = [] + + var displayMode: HistoryDisplayMode = .history + var dateFilter: UIDateFilter = .all + var searchText: String = "" + + private var searchTask: DispatchWorkItem? + private let searchDebounceInterval: TimeInterval = 0.15 + + /// Callback when data changes + var onDataChanged: (() -> Void)? + + // MARK: - Computed Properties + + var count: Int { + switch displayMode { + case .history: + return historyEntries.count + case .bookmarks: + return bookmarks.count + } + } + + var isEmpty: Bool { + count == 0 + } + + // MARK: - Data Loading + + func loadData() { + switch displayMode { + case .history: + loadHistory() + case .bookmarks: + loadBookmarks() + } + } + + private func loadHistory() { + historyEntries = QueryHistoryManager.shared.fetchHistory( + limit: 500, + offset: 0, + connectionId: nil, + searchText: searchText.isEmpty ? nil : searchText, + dateFilter: dateFilter.toDateFilter + ) + } + + private func loadBookmarks() { + bookmarks = QueryHistoryManager.shared.fetchBookmarks( + searchText: searchText.isEmpty ? nil : searchText, + tag: nil + ) + } + + // MARK: - Search + + func scheduleSearch(completion: @escaping () -> Void) { + searchTask?.cancel() + + let task = DispatchWorkItem { [weak self] in + self?.loadData() + completion() + } + searchTask = task + + DispatchQueue.main.asyncAfter(deadline: .now() + searchDebounceInterval, execute: task) + } + + // MARK: - Item Access + + func historyEntry(at index: Int) -> QueryHistoryEntry? { + guard index >= 0 && index < historyEntries.count else { return nil } + return historyEntries[index] + } + + func bookmark(at index: Int) -> QueryBookmark? { + guard index >= 0 && index < bookmarks.count else { return nil } + return bookmarks[index] + } + + func query(at index: Int) -> String? { + switch displayMode { + case .history: + return historyEntry(at: index)?.query + case .bookmarks: + return bookmark(at: index)?.query + } + } + + // MARK: - Deletion + + func deleteItem(at index: Int) -> Bool { + switch displayMode { + case .history: + guard let entry = historyEntry(at: index) else { return false } + QueryHistoryManager.shared.deleteHistory(id: entry.id) + return true + case .bookmarks: + guard let bookmark = bookmark(at: index) else { return false } + QueryHistoryManager.shared.deleteBookmark(id: bookmark.id) + return true + } + } + + func clearAll() -> Bool { + switch displayMode { + case .history: + return QueryHistoryManager.shared.clearAllHistory() + case .bookmarks: + return QueryHistoryManager.shared.clearAllBookmarks() + } + } + + // MARK: - Bookmark Operations + + func markBookmarkUsed(at index: Int) { + guard displayMode == .bookmarks, + let bookmark = bookmark(at: index) else { return } + QueryHistoryManager.shared.markBookmarkUsed(id: bookmark.id) + } +} diff --git a/OpenTable/Views/History/HistoryListViewController.swift b/OpenTable/Views/History/HistoryListViewController.swift new file mode 100644 index 00000000..4484a7c6 --- /dev/null +++ b/OpenTable/Views/History/HistoryListViewController.swift @@ -0,0 +1,756 @@ +// +// HistoryListViewController.swift +// OpenTable +// +// Left pane controller for history/bookmark list with search and filtering. +// Child views and data provider extracted to separate files. +// + +import AppKit + +// MARK: - Delegate Protocol + +protocol HistoryListViewControllerDelegate: AnyObject { + func historyListViewController(_ controller: HistoryListViewController, didSelectHistoryEntry entry: QueryHistoryEntry) + func historyListViewController(_ controller: HistoryListViewController, didSelectBookmark bookmark: QueryBookmark) + func historyListViewController(_ controller: HistoryListViewController, didDoubleClickHistoryEntry entry: QueryHistoryEntry) + func historyListViewController(_ controller: HistoryListViewController, didDoubleClickBookmark bookmark: QueryBookmark) + func historyListViewControllerDidClearSelection(_ controller: HistoryListViewController) +} + +// MARK: - Display Mode + +enum HistoryDisplayMode: Int { + case history = 0 + case bookmarks = 1 +} + +// MARK: - UI Date Filter + +enum UIDateFilter: Int { + case today = 0 + case week = 1 + case month = 2 + case all = 3 + + var title: String { + switch self { + case .today: return "Today" + case .week: return "This Week" + case .month: return "This Month" + case .all: return "All Time" + } + } + + var toDateFilter: DateFilter { + switch self { + case .today: return .today + case .week: return .thisWeek + case .month: return .thisMonth + case .all: return .all + } + } +} + +// MARK: - HistoryListViewController + +final class HistoryListViewController: NSViewController, NSMenuItemValidation { + + // MARK: - Properties + + weak var delegate: HistoryListViewControllerDelegate? + + private let dataProvider = HistoryDataProvider() + + private var displayMode: HistoryDisplayMode = .history { + didSet { + if oldValue != displayMode { + dataProvider.displayMode = displayMode + updateFilterVisibility() + loadData() + } + } + } + + private var dateFilter: UIDateFilter = .all { + didSet { + if oldValue != dateFilter { + dataProvider.dateFilter = dateFilter + loadData() + } + } + } + + private var searchText: String = "" { + didSet { + dataProvider.searchText = searchText + scheduleSearch() + } + } + + private var pendingDeletionRow: Int? + private var pendingDeletionCount: Int? + + // MARK: - UI Components + + private let headerView: NSVisualEffectView = { + let view = NSVisualEffectView() + view.material = .headerView + view.blendingMode = .withinWindow + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var modeSegment: NSSegmentedControl = { + let segment = NSSegmentedControl(labels: ["History", "Bookmarks"], trackingMode: .selectOne, target: self, action: #selector(modeChanged(_:))) + segment.selectedSegment = 0 + segment.translatesAutoresizingMaskIntoConstraints = false + segment.controlSize = .small + return segment + }() + + private lazy var searchField: NSSearchField = { + let field = NSSearchField() + field.placeholderString = "Search queries..." + field.delegate = self + field.translatesAutoresizingMaskIntoConstraints = false + field.controlSize = .small + return field + }() + + private lazy var filterButton: NSPopUpButton = { + let button = NSPopUpButton(frame: .zero, pullsDown: false) + button.controlSize = .small + button.translatesAutoresizingMaskIntoConstraints = false + for filter in [UIDateFilter.today, .week, .month, .all] { + button.addItem(withTitle: filter.title) + } + button.selectItem(at: UIDateFilter.all.rawValue) + button.target = self + button.action = #selector(filterChanged(_:)) + return button + }() + + private lazy var clearAllButton: NSButton = { + let button = NSButton() + button.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Clear All") + button.bezelStyle = .shadowlessSquare + button.isBordered = false + button.imagePosition = .imageOnly + button.translatesAutoresizingMaskIntoConstraints = false + button.target = self + button.action = #selector(clearAllClicked(_:)) + button.toolTip = "Clear all \(displayMode == .history ? "history" : "bookmarks")" + return button + }() + + private let scrollView: NSScrollView = { + let scroll = NSScrollView() + scroll.hasVerticalScroller = true + scroll.hasHorizontalScroller = false + scroll.autohidesScrollers = true + scroll.borderType = .noBorder + scroll.translatesAutoresizingMaskIntoConstraints = false + scroll.drawsBackground = false + return scroll + }() + + private lazy var tableView: HistoryTableView = { + let table = HistoryTableView() + table.style = .plain + table.headerView = nil + table.rowHeight = 56 + table.intercellSpacing = NSSize(width: 0, height: 1) + table.backgroundColor = .clear + table.usesAlternatingRowBackgroundColors = false + table.allowsMultipleSelection = false + table.delegate = self + table.dataSource = self + table.doubleAction = #selector(tableViewDoubleClick(_:)) + table.target = self + table.keyboardDelegate = self + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("MainColumn")) + column.width = 300 + table.addTableColumn(column) + + return table + }() + + private lazy var emptyStateView: NSView = { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + container.isHidden = true + + let stackView = NSStackView() + stackView.orientation = .vertical + stackView.alignment = .centerX + stackView.spacing = 8 + stackView.translatesAutoresizingMaskIntoConstraints = false + + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 48), + imageView.heightAnchor.constraint(equalToConstant: 48) + ]) + imageView.contentTintColor = .tertiaryLabelColor + self.emptyImageView = imageView + + let titleLabel = NSTextField(labelWithString: "") + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = .secondaryLabelColor + titleLabel.alignment = .center + self.emptyTitleLabel = titleLabel + + let subtitleLabel = NSTextField(labelWithString: "") + subtitleLabel.font = .systemFont(ofSize: 12) + subtitleLabel.textColor = .tertiaryLabelColor + subtitleLabel.alignment = .center + subtitleLabel.maximumNumberOfLines = 2 + subtitleLabel.lineBreakMode = .byWordWrapping + subtitleLabel.preferredMaxLayoutWidth = 200 + self.emptySubtitleLabel = subtitleLabel + + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(subtitleLabel) + + container.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.centerXAnchor.constraint(equalTo: container.centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: container.centerYAnchor) + ]) + + return container + }() + + private weak var emptyImageView: NSImageView? + private weak var emptyTitleLabel: NSTextField? + private weak var emptySubtitleLabel: NSTextField? + + // MARK: - Lifecycle + + override func loadView() { + view = NSView() + view.wantsLayer = true + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupNotifications() + restoreState() + loadData() + } + + // MARK: - Setup + + private func setupUI() { + view.addSubview(headerView) + + let headerStack = NSStackView() + headerStack.orientation = .vertical + headerStack.spacing = 8 + headerStack.translatesAutoresizingMaskIntoConstraints = false + headerStack.edgeInsets = NSEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + + let topRow = NSStackView(views: [modeSegment, NSView(), clearAllButton, filterButton]) + topRow.distribution = .fill + topRow.spacing = 8 + + headerStack.addArrangedSubview(topRow) + headerStack.addArrangedSubview(searchField) + + headerView.addSubview(headerStack) + + let divider = NSBox() + divider.boxType = .separator + divider.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(divider) + + scrollView.documentView = tableView + view.addSubview(scrollView) + view.addSubview(emptyStateView) + + NSLayoutConstraint.activate([ + headerView.topAnchor.constraint(equalTo: view.topAnchor), + headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + headerStack.topAnchor.constraint(equalTo: headerView.topAnchor), + headerStack.leadingAnchor.constraint(equalTo: headerView.leadingAnchor), + headerStack.trailingAnchor.constraint(equalTo: headerView.trailingAnchor), + headerStack.bottomAnchor.constraint(equalTo: headerView.bottomAnchor), + + divider.topAnchor.constraint(equalTo: headerView.bottomAnchor), + divider.leadingAnchor.constraint(equalTo: view.leadingAnchor), + divider.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + scrollView.topAnchor.constraint(equalTo: divider.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + emptyStateView.topAnchor.constraint(equalTo: scrollView.topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + emptyStateView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor) + ]) + + updateFilterVisibility() + } + + private func setupNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(historyDidUpdate), name: .queryHistoryDidUpdate, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(bookmarksDidUpdate), name: .queryBookmarksDidUpdate, object: nil) + } + + // MARK: - State Persistence + + private func restoreState() { + let savedMode = UserDefaults.standard.integer(forKey: "HistoryPanel.displayMode") + let savedFilter = UserDefaults.standard.integer(forKey: "HistoryPanel.dateFilter") + + if let mode = HistoryDisplayMode(rawValue: savedMode) { + displayMode = mode + modeSegment.selectedSegment = mode.rawValue + } + + if let filter = UIDateFilter(rawValue: savedFilter) { + dateFilter = filter + filterButton.selectItem(at: filter.rawValue) + } + } + + private func saveState() { + UserDefaults.standard.set(displayMode.rawValue, forKey: "HistoryPanel.displayMode") + UserDefaults.standard.set(dateFilter.rawValue, forKey: "HistoryPanel.dateFilter") + } + + // MARK: - Data Loading + + private func loadData() { + dataProvider.loadData() + tableView.reloadData() + updateEmptyState() + + if let deletedRow = pendingDeletionRow, let countBefore = pendingDeletionCount { + selectRowAfterDeletion(deletedRow: deletedRow, countBefore: countBefore) + pendingDeletionRow = nil + pendingDeletionCount = nil + } else if tableView.selectedRow < 0 { + delegate?.historyListViewControllerDidClearSelection(self) + } + } + + // MARK: - Search + + private func scheduleSearch() { + dataProvider.scheduleSearch { [weak self] in + self?.tableView.reloadData() + self?.updateEmptyState() + } + } + + // MARK: - Actions + + @objc private func modeChanged(_ sender: NSSegmentedControl) { + if let mode = HistoryDisplayMode(rawValue: sender.selectedSegment) { + displayMode = mode + saveState() + } + } + + @objc private func filterChanged(_ sender: NSPopUpButton) { + if let filter = UIDateFilter(rawValue: sender.indexOfSelectedItem) { + dateFilter = filter + saveState() + } + } + + @objc private func tableViewDoubleClick(_ sender: Any) { + let row = tableView.clickedRow + guard row >= 0 else { return } + + switch displayMode { + case .history: + guard let entry = dataProvider.historyEntry(at: row) else { return } + delegate?.historyListViewController(self, didDoubleClickHistoryEntry: entry) + case .bookmarks: + guard let bookmark = dataProvider.bookmark(at: row) else { return } + delegate?.historyListViewController(self, didDoubleClickBookmark: bookmark) + } + } + + @objc private func historyDidUpdate() { + if displayMode == .history { loadData() } + } + + @objc private func bookmarksDidUpdate() { + if displayMode == .bookmarks { loadData() } + } + + @objc private func clearAllClicked(_ sender: Any?) { + let count = dataProvider.count + let itemName = count == 1 ? (displayMode == .history ? "history entry" : "bookmark") : (displayMode == .history ? "history entries" : "bookmarks") + + 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 { + _ = dataProvider.clearAll() + } + } + + // MARK: - UI Updates + + private func updateFilterVisibility() { + filterButton.isHidden = displayMode == .bookmarks + searchField.placeholderString = displayMode == .history ? "Search queries..." : "Search bookmarks..." + } + + private func updateEmptyState() { + let isEmpty = dataProvider.isEmpty + emptyStateView.isHidden = !isEmpty + scrollView.isHidden = isEmpty + + guard isEmpty else { return } + + let isSearching = !searchText.isEmpty + + if isSearching { + emptyImageView?.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "No results") + emptyTitleLabel?.stringValue = "No Matching Queries" + emptySubtitleLabel?.stringValue = "Try adjusting your search terms\nor date filter." + } else { + switch displayMode { + case .history: + emptyImageView?.image = NSImage(systemSymbolName: "clock.arrow.circlepath", accessibilityDescription: "No history") + emptyTitleLabel?.stringValue = "No Query History Yet" + emptySubtitleLabel?.stringValue = "Your executed queries will\nappear here for quick access." + case .bookmarks: + emptyImageView?.image = NSImage(systemSymbolName: "bookmark", accessibilityDescription: "No bookmarks") + emptyTitleLabel?.stringValue = "No Bookmarks Yet" + emptySubtitleLabel?.stringValue = "Save frequently used queries\nusing Cmd+Shift+B." + } + } + } + + // MARK: - Context Menu + + private func buildContextMenu(for row: Int) -> NSMenu { + let menu = NSMenu() + + let copyItem = NSMenuItem(title: "Copy Query", action: #selector(copyQuery(_:)), keyEquivalent: "c") + copyItem.keyEquivalentModifierMask = .command + copyItem.tag = row + menu.addItem(copyItem) + + let runItem = NSMenuItem(title: "Run in New Tab", action: #selector(runInNewTab(_:)), keyEquivalent: "\r") + runItem.tag = row + menu.addItem(runItem) + + menu.addItem(NSMenuItem.separator()) + + switch displayMode { + case .history: + let bookmarkItem = NSMenuItem(title: "Save as Bookmark...", action: #selector(saveAsBookmark(_:)), keyEquivalent: "") + bookmarkItem.tag = row + menu.addItem(bookmarkItem) + case .bookmarks: + let editItem = NSMenuItem(title: "Edit Bookmark...", action: #selector(editBookmark(_:)), keyEquivalent: "e") + editItem.keyEquivalentModifierMask = .command + editItem.tag = row + menu.addItem(editItem) + } + + menu.addItem(NSMenuItem.separator()) + + let deleteItem = NSMenuItem(title: "Delete", action: #selector(deleteEntry(_:)), keyEquivalent: "\u{8}") + deleteItem.keyEquivalentModifierMask = [] + deleteItem.tag = row + menu.addItem(deleteItem) + + return menu + } + + @objc private func copyQuery(_ sender: NSMenuItem) { + guard let query = dataProvider.query(at: sender.tag) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(query, forType: .string) + } + + @objc private func runInNewTab(_ sender: NSMenuItem) { + guard let query = dataProvider.query(at: sender.tag) else { return } + + if displayMode == .bookmarks { + dataProvider.markBookmarkUsed(at: sender.tag) + } + + NotificationCenter.default.post(name: .newTab, object: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + NotificationCenter.default.post(name: .loadQueryIntoEditor, object: query) + } + } + + @objc private func saveAsBookmark(_ sender: NSMenuItem) { + guard let entry = dataProvider.historyEntry(at: sender.tag) else { return } + + let editor = BookmarkEditorController(bookmark: nil, query: entry.query, connectionId: entry.connectionId) + editor.onSave = { bookmark in + _ = QueryHistoryManager.shared.saveBookmark( + name: bookmark.name, + query: bookmark.query, + connectionId: bookmark.connectionId, + tags: bookmark.tags, + notes: bookmark.notes + ) + } + view.window?.contentViewController?.presentAsSheet(editor) + } + + @objc private func editBookmark(_ sender: NSMenuItem) { + guard let bookmark = dataProvider.bookmark(at: sender.tag) else { return } + + let editorView = BookmarkEditorView(bookmark: bookmark, query: bookmark.query, connectionId: bookmark.connectionId) { updatedBookmark in + _ = QueryHistoryManager.shared.updateBookmark(updatedBookmark) + } + presentAsSheet(editorView) + } + + @objc private func deleteEntry(_ sender: NSMenuItem) { + _ = dataProvider.deleteItem(at: sender.tag) + } + + // MARK: - Selection After Deletion + + private func selectRowAfterDeletion(deletedRow: Int, countBefore: Int) { + let currentCount = dataProvider.count + + guard currentCount > 0 else { + tableView.deselectAll(nil) + delegate?.historyListViewControllerDidClearSelection(self) + return + } + + let newSelection = deletedRow < currentCount ? deletedRow : currentCount - 1 + tableView.selectRowIndexes(IndexSet(integer: newSelection), byExtendingSelection: false) + tableView.scrollRowToVisible(newSelection) + + switch displayMode { + case .history: + if let entry = dataProvider.historyEntry(at: newSelection) { + delegate?.historyListViewController(self, didSelectHistoryEntry: entry) + } + case .bookmarks: + if let bookmark = dataProvider.bookmark(at: newSelection) { + delegate?.historyListViewController(self, didSelectBookmark: bookmark) + } + } + } +} + +// MARK: - NSTableViewDataSource + +extension HistoryListViewController: NSTableViewDataSource { + func numberOfRows(in tableView: NSTableView) -> Int { + dataProvider.count + } +} + +// MARK: - NSTableViewDelegate + +extension HistoryListViewController: NSTableViewDelegate { + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + switch displayMode { + case .history: + return historyCell(for: row) + case .bookmarks: + return bookmarkCell(for: row) + } + } + + private func historyCell(for row: Int) -> NSView? { + guard let entry = dataProvider.historyEntry(at: row) else { return nil } + let identifier = NSUserInterfaceItemIdentifier("HistoryCell") + let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? HistoryRowView ?? HistoryRowView() + cell.identifier = identifier + cell.configureForHistory(entry) + return cell + } + + private func bookmarkCell(for row: Int) -> NSView? { + guard let bookmark = dataProvider.bookmark(at: row) else { return nil } + let identifier = NSUserInterfaceItemIdentifier("BookmarkCell") + let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? HistoryRowView ?? HistoryRowView() + cell.identifier = identifier + cell.configureForBookmark(bookmark) + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + let row = tableView.selectedRow + guard row >= 0 else { + delegate?.historyListViewControllerDidClearSelection(self) + return + } + + switch displayMode { + case .history: + if let entry = dataProvider.historyEntry(at: row) { + delegate?.historyListViewController(self, didSelectHistoryEntry: entry) + } + case .bookmarks: + if let bookmark = dataProvider.bookmark(at: row) { + delegate?.historyListViewController(self, didSelectBookmark: bookmark) + } + } + } + + func tableView(_ tableView: NSTableView, rowActionsForRow row: Int, edge: NSTableView.RowActionEdge) -> [NSTableViewRowAction] { + if edge == .trailing { + let delete = NSTableViewRowAction(style: .destructive, title: "Delete") { [weak self] _, row in + _ = self?.dataProvider.deleteItem(at: row) + } + return [delete] + } + return [] + } +} + +// MARK: - NSSearchFieldDelegate + +extension HistoryListViewController: NSSearchFieldDelegate { + + func controlTextDidChange(_ obj: Notification) { + if let field = obj.object as? NSSearchField { + searchText = field.stringValue + } + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(cancelOperation(_:)) { + if !searchText.isEmpty { + searchField.stringValue = "" + searchText = "" + return true + } + } + return false + } +} + +// MARK: - Context Menu + +extension HistoryListViewController { + + override func rightMouseDown(with event: NSEvent) { + let point = tableView.convert(event.locationInWindow, from: nil) + let row = tableView.row(at: point) + + if row >= 0 { + tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + let menu = buildContextMenu(for: row) + NSMenu.popUpContextMenu(menu, with: event, for: tableView) + } + } +} + +// MARK: - HistoryTableViewKeyboardDelegate + +extension HistoryListViewController: HistoryTableViewKeyboardDelegate { + + func handleDeleteKey() { + deleteSelectedRow() + } + + @objc func delete(_ sender: Any?) { + deleteSelectedRow() + } + + @objc func copy(_ sender: Any?) { + copyQueryForSelectedRow() + } + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + if menuItem.action == #selector(delete(_:)) { + return tableView.selectedRow >= 0 && dataProvider.count > 0 + } + if menuItem.action == #selector(copy(_:)) { + return tableView.selectedRow >= 0 + } + return true + } + + func handleReturnKey() { + runInNewTabForSelectedRow() + } + + func handleSpaceKey() { + // Preview panel - future implementation + } + + func handleEditBookmark() { + guard displayMode == .bookmarks else { return } + editBookmarkForSelectedRow() + } + + func handleEscapeKey() { + if !searchText.isEmpty { + searchField.stringValue = "" + searchText = "" + searchField.window?.makeFirstResponder(tableView) + } else if tableView.selectedRow >= 0 { + tableView.deselectAll(nil) + } + } + + func deleteSelectedRow() { + let row = tableView.selectedRow + guard row >= 0 else { return } + + pendingDeletionRow = row + pendingDeletionCount = dataProvider.count + _ = dataProvider.deleteItem(at: row) + } + + private func copyQueryForSelectedRow() { + let row = tableView.selectedRow + guard row >= 0, let query = dataProvider.query(at: row) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(query, forType: .string) + } + + private func runInNewTabForSelectedRow() { + let row = tableView.selectedRow + guard row >= 0, let query = dataProvider.query(at: row) else { return } + + if displayMode == .bookmarks { + dataProvider.markBookmarkUsed(at: row) + } + + NotificationCenter.default.post(name: .newTab, object: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + NotificationCenter.default.post(name: .loadQueryIntoEditor, object: query) + } + } + + private func editBookmarkForSelectedRow() { + let row = tableView.selectedRow + guard let bookmark = dataProvider.bookmark(at: row) else { return } + + let editorView = BookmarkEditorView(bookmark: bookmark, query: bookmark.query, connectionId: bookmark.connectionId) { updatedBookmark in + _ = QueryHistoryManager.shared.updateBookmark(updatedBookmark) + } + presentAsSheet(editorView) + } +} diff --git a/OpenTable/Views/History/HistoryRowView.swift b/OpenTable/Views/History/HistoryRowView.swift new file mode 100644 index 00000000..357ba97d --- /dev/null +++ b/OpenTable/Views/History/HistoryRowView.swift @@ -0,0 +1,158 @@ +// +// HistoryRowView.swift +// OpenTable +// +// Table cell view for history and bookmark entries. +// Extracted from HistoryListViewController for better maintainability. +// + +import AppKit + +/// Table cell view for history and bookmark entries +final class HistoryRowView: NSTableCellView { + + private let statusIcon: NSImageView = { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let queryLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + label.textColor = .labelColor + label.lineBreakMode = .byTruncatingTail + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let secondaryLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.font = .systemFont(ofSize: 10) + label.textColor = .secondaryLabelColor + label.lineBreakMode = .byTruncatingTail + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let timeLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.font = .systemFont(ofSize: 10) + label.textColor = .tertiaryLabelColor + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let durationLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.font = .systemFont(ofSize: 10) + label.textColor = .tertiaryLabelColor + label.alignment = .right + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var isSetup = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + } + + private func setupViews() { + guard !isSetup else { return } + isSetup = true + + addSubview(statusIcon) + addSubview(queryLabel) + addSubview(secondaryLabel) + addSubview(timeLabel) + addSubview(durationLabel) + + NSLayoutConstraint.activate([ + // Status icon + statusIcon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + statusIcon.topAnchor.constraint(equalTo: topAnchor, constant: 10), + statusIcon.widthAnchor.constraint(equalToConstant: 14), + statusIcon.heightAnchor.constraint(equalToConstant: 14), + + // Query label (first line) + queryLabel.leadingAnchor.constraint(equalTo: statusIcon.trailingAnchor, constant: 8), + queryLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + queryLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8), + + // Secondary label (second line - database/tags) + secondaryLabel.leadingAnchor.constraint(equalTo: queryLabel.leadingAnchor), + secondaryLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + secondaryLabel.topAnchor.constraint(equalTo: queryLabel.bottomAnchor, constant: 2), + + // Time label (third line left) + timeLabel.leadingAnchor.constraint(equalTo: queryLabel.leadingAnchor), + timeLabel.topAnchor.constraint(equalTo: secondaryLabel.bottomAnchor, constant: 2), + + // Duration label (third line right) + durationLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + durationLabel.centerYAnchor.constraint(equalTo: timeLabel.centerYAnchor), + durationLabel.leadingAnchor.constraint(greaterThanOrEqualTo: timeLabel.trailingAnchor, constant: 8) + ]) + } + + func configureForHistory(_ entry: QueryHistoryEntry) { + // Status icon + let imageName = entry.wasSuccessful ? "checkmark.circle.fill" : "xmark.circle.fill" + statusIcon.image = NSImage(systemSymbolName: imageName, accessibilityDescription: entry.wasSuccessful ? "Success" : "Error") + statusIcon.contentTintColor = entry.wasSuccessful ? .systemGreen : .systemRed + + // Query preview + queryLabel.stringValue = entry.queryPreview + + // Database + secondaryLabel.stringValue = entry.databaseName + + // Relative time + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + timeLabel.stringValue = formatter.localizedString(for: entry.executedAt, relativeTo: Date()) + + // Duration + durationLabel.stringValue = entry.formattedExecutionTime + } + + func configureForBookmark(_ bookmark: QueryBookmark) { + // Bookmark icon + statusIcon.image = NSImage(systemSymbolName: "bookmark.fill", accessibilityDescription: "Bookmark") + statusIcon.contentTintColor = .systemYellow + + // Bookmark name + queryLabel.stringValue = bookmark.name + queryLabel.font = .systemFont(ofSize: 12, weight: .medium) + + // Tags + secondaryLabel.stringValue = bookmark.hasTags ? bookmark.formattedTags : "No tags" + + // Created date + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .none + timeLabel.stringValue = dateFormatter.string(from: bookmark.createdAt) + + // Clear duration + durationLabel.stringValue = "" + } + + override func prepareForReuse() { + super.prepareForReuse() + queryLabel.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + statusIcon.image = nil + queryLabel.stringValue = "" + secondaryLabel.stringValue = "" + timeLabel.stringValue = "" + durationLabel.stringValue = "" + } +} diff --git a/OpenTable/Views/History/HistoryTableView.swift b/OpenTable/Views/History/HistoryTableView.swift new file mode 100644 index 00000000..120088b8 --- /dev/null +++ b/OpenTable/Views/History/HistoryTableView.swift @@ -0,0 +1,100 @@ +// +// HistoryTableView.swift +// OpenTable +// +// Custom NSTableView with keyboard handling for history panel. +// Extracted from HistoryListViewController for better maintainability. +// + +import AppKit + +/// Protocol for keyboard event delegation +protocol HistoryTableViewKeyboardDelegate: AnyObject { + func handleDeleteKey() + func handleReturnKey() + func handleSpaceKey() + func handleEditBookmark() + func handleEscapeKey() + func deleteSelectedRow() + func copy(_ sender: Any?) + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool +} + +/// Custom table view for keyboard delegation in history panel +final class HistoryTableView: NSTableView, NSMenuItemValidation { + weak var keyboardDelegate: HistoryTableViewKeyboardDelegate? + + override var acceptsFirstResponder: Bool { + return true + } + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + // Ensure we become first responder for keyboard shortcuts + window?.makeFirstResponder(self) + } + + // MARK: - Standard Responder Actions + + @objc func delete(_ sender: Any?) { + keyboardDelegate?.deleteSelectedRow() + } + + @objc func copy(_ sender: Any?) { + keyboardDelegate?.copy(sender) + } + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + if menuItem.action == #selector(delete(_:)) { + return keyboardDelegate?.validateMenuItem(menuItem) ?? false + } + if menuItem.action == #selector(copy(_:)) { + return selectedRow >= 0 + } + return false + } + + // MARK: - Keyboard Event Handling + + override func keyDown(with event: NSEvent) { + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + + // Return/Enter key - open in new tab + if (event.keyCode == 36 || event.keyCode == 76) && modifiers.isEmpty { + if selectedRow >= 0 { + keyboardDelegate?.handleReturnKey() + return + } + } + + // Space key - toggle preview + if event.keyCode == 49 && modifiers.isEmpty { + if selectedRow >= 0 { + keyboardDelegate?.handleSpaceKey() + return + } + } + + // Cmd+E - edit bookmark + if event.keyCode == 14 && modifiers == .command { + keyboardDelegate?.handleEditBookmark() + return + } + + // Escape key - clear search or selection + if event.keyCode == 53 && modifiers.isEmpty { + keyboardDelegate?.handleEscapeKey() + return + } + + // Delete key (bare, not Cmd+Delete which goes through menu) + if event.keyCode == 51 && modifiers.isEmpty { + if selectedRow >= 0 { + keyboardDelegate?.handleDeleteKey() + return + } + } + + super.keyDown(with: event) + } +} diff --git a/OpenTable/Views/Main/Child/MainStatusBarView.swift b/OpenTable/Views/Main/Child/MainStatusBarView.swift new file mode 100644 index 00000000..6cdb37fb --- /dev/null +++ b/OpenTable/Views/Main/Child/MainStatusBarView.swift @@ -0,0 +1,91 @@ +// +// MainStatusBarView.swift +// OpenTable +// +// Created by Ngo Quoc Dat on 24/12/25. +// + +import SwiftUI + +/// Status bar at the bottom of the results section +struct MainStatusBarView: View { + let tab: QueryTab? + let filterStateManager: FilterStateManager + let selectedRowIndices: Set + @Binding var showStructure: Bool + + var body: some View { + HStack { + // Left: Data/Structure toggle for table tabs + if let tab = tab, tab.tabType == .table, tab.tableName != nil { + Picker("", selection: $showStructure) { + Label("Data", systemImage: "tablecells").tag(false) + Label("Structure", systemImage: "list.bullet.rectangle").tag(true) + } + .pickerStyle(.segmented) + .frame(width: 180) + .controlSize(.small) + .offset(x: -26) + } + + Spacer() + + // Center: Row info (pagination/selection) + if let tab = tab, !tab.resultRows.isEmpty { + Text(rowInfoText(for: tab)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Right: Filters toggle button + if let tab = tab, tab.tabType == .table, tab.tableName != nil { + Toggle(isOn: Binding( + get: { filterStateManager.isVisible }, + set: { _ in filterStateManager.toggle() } + )) { + HStack(spacing: 4) { + Image(systemName: filterStateManager.hasAppliedFilters + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + Text("Filters") + if filterStateManager.hasAppliedFilters { + Text("(\(filterStateManager.appliedFilters.count))") + .foregroundStyle(.secondary) + } + } + } + .toggleStyle(.button) + .controlSize(.small) + .help("Toggle Filters (Cmd+F)") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(Color(nsColor: .controlBackgroundColor)) + } + + /// Generate row info text based on selection and pagination state + private func rowInfoText(for tab: QueryTab) -> String { + let loadedCount = tab.resultRows.count + // Use selectedRowIndices parameter instead of tab.selectedRowIndices + let selectedCount = selectedRowIndices.count + let total = tab.pagination.totalRowCount + + if selectedCount > 0 { + // Selection mode + if selectedCount == loadedCount { + return "All \(loadedCount) rows selected" + } else { + return "\(selectedCount) of \(loadedCount) rows selected" + } + } else if let total = total, total > loadedCount { + // Pagination mode: "1-100 of 5000 rows" + return "1-\(loadedCount) of \(total) rows" + } else { + // Simple mode: "100 rows" + return "\(loadedCount) rows" + } + } +} diff --git a/OpenTable/Views/Main/Child/QueryTabContentView.swift b/OpenTable/Views/Main/Child/QueryTabContentView.swift new file mode 100644 index 00000000..4cd1841a --- /dev/null +++ b/OpenTable/Views/Main/Child/QueryTabContentView.swift @@ -0,0 +1,78 @@ +// +// QueryTabContentView.swift +// OpenTable +// +// Created by Ngo Quoc Dat on 24/12/25. +// + +import SwiftUI + +/// Content view for query tabs (editor + results split) +struct QueryTabContentView: View { + let tab: QueryTab + let connection: DatabaseConnection + let changeManager: DataChangeManager + let filterStateManager: FilterStateManager + @Binding var queryText: String + @Binding var cursorPosition: Int + @Binding var selectedRowIndices: Set + @Binding var editingCell: CellPosition? + + let schemaProvider: SQLSchemaProvider + let onExecute: () -> Void + + // Callbacks + let onCommit: (String) -> Void + let onRefresh: () -> Void + let onCellEdit: (Int, Int, String?) -> Void + let onSort: (Int, Bool) -> Void + let onAddRow: () -> Void + let onUndoInsert: (Int) -> Void + let onFilterColumn: (String) -> Void + let onApplyFilters: ([TableFilter]) -> Void + let onClearFilters: () -> Void + let onQuickSearch: (String) -> Void + let sortedRows: [QueryResultRow] + + @Binding var sortState: SortState + @Binding var showStructure: Bool + + var body: some View { + VSplitView { + // Query Editor (top) + VStack(spacing: 0) { + QueryEditorView( + queryText: $queryText, + cursorPosition: $cursorPosition, + onExecute: onExecute, + schemaProvider: schemaProvider + ) + } + .frame(minHeight: 100, idealHeight: 200) + + // Results Table (bottom) + TableTabContentView( + tab: tab, + connection: connection, + changeManager: changeManager, + filterStateManager: filterStateManager, + selectedRowIndices: $selectedRowIndices, + editingCell: $editingCell, + onCommit: onCommit, + onRefresh: onRefresh, + onCellEdit: onCellEdit, + onSort: onSort, + onAddRow: onAddRow, + onUndoInsert: onUndoInsert, + onFilterColumn: onFilterColumn, + onApplyFilters: onApplyFilters, + onClearFilters: onClearFilters, + onQuickSearch: onQuickSearch, + sortedRows: sortedRows, + sortState: $sortState, + showStructure: $showStructure + ) + .frame(minHeight: 150) + } + } +} diff --git a/OpenTable/Views/Main/Child/TableTabContentView.swift b/OpenTable/Views/Main/Child/TableTabContentView.swift new file mode 100644 index 00000000..e7eab5b0 --- /dev/null +++ b/OpenTable/Views/Main/Child/TableTabContentView.swift @@ -0,0 +1,90 @@ +// +// TableTabContentView.swift +// OpenTable +// +// Created by Ngo Quoc Dat on 24/12/25. +// + +import SwiftUI + +/// Content view for table tabs (results only, no editor) +struct TableTabContentView: View { + let tab: QueryTab + let connection: DatabaseConnection + let changeManager: DataChangeManager + let filterStateManager: FilterStateManager + @Binding var selectedRowIndices: Set + @Binding var editingCell: CellPosition? + + // Callbacks + let onCommit: (String) -> Void + let onRefresh: () -> Void + let onCellEdit: (Int, Int, String?) -> Void + let onSort: (Int, Bool) -> Void + let onAddRow: () -> Void + let onUndoInsert: (Int) -> Void + let onFilterColumn: (String) -> Void + let onApplyFilters: ([TableFilter]) -> Void + let onClearFilters: () -> Void + let onQuickSearch: (String) -> Void + let sortedRows: [QueryResultRow] + + @Binding var sortState: SortState + @Binding var showStructure: Bool + + var body: some View { + VStack(spacing: 0) { + // Show structure view or data view based on toggle + if showStructure, let tableName = tab.tableName { + TableStructureView(tableName: tableName, connection: connection) + .frame(maxHeight: .infinity) + } else { + DataGridView( + rowProvider: InMemoryRowProvider( + rows: sortedRows, + columns: tab.resultColumns, + columnDefaults: tab.columnDefaults + ), + changeManager: changeManager, + isEditable: tab.isEditable, + onCommit: onCommit, + onRefresh: onRefresh, + onCellEdit: onCellEdit, + onSort: onSort, + onAddRow: onAddRow, + onUndoInsert: onUndoInsert, + onFilterColumn: onFilterColumn, + selectedRowIndices: $selectedRowIndices, + sortState: $sortState, + editingCell: $editingCell + ) + .frame(maxHeight: .infinity, alignment: .top) + } + + // Filter panel (collapsible, at bottom) + if filterStateManager.isVisible && tab.tabType == .table { + Divider() + FilterPanelView( + filterState: filterStateManager, + columns: tab.resultColumns, + primaryKeyColumn: changeManager.primaryKeyColumn, + databaseType: connection.type, + onApply: onApplyFilters, + onUnset: onClearFilters, + onQuickSearch: onQuickSearch + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Status bar + MainStatusBarView( + tab: tab, + filterStateManager: filterStateManager, + selectedRowIndices: selectedRowIndices, + showStructure: $showStructure + ) + } + .frame(minHeight: 150) + .animation(.easeInOut(duration: 0.2), value: filterStateManager.isVisible) + } +} diff --git a/OpenTable/Views/MainContentView.swift b/OpenTable/Views/MainContentView.swift index 8a6842f5..9cb1cd36 100644 --- a/OpenTable/Views/MainContentView.swift +++ b/OpenTable/Views/MainContentView.swift @@ -21,6 +21,12 @@ struct MainContentView: View { @StateObject private var tabManager = QueryTabManager() @StateObject private var changeManager = DataChangeManager() @StateObject private var filterStateManager = FilterStateManager() + @StateObject private var queryService = QueryExecutionService() + + // Lazy-initialized row operations manager + private var rowOperationsManager: RowOperationsManager { + RowOperationsManager(changeManager: changeManager) + } @State private var selectedRowIndices: Set = [] @State private var editingCell: CellPosition? = nil @@ -39,6 +45,16 @@ struct MainContentView: View { @State private var queryGeneration: Int = 0 @State private var changeManagerUpdateTask: Task? @State private var isRestoringTabs = false // Prevent circular sync during restoration + @State private var needsLazyLoad = false // Flag to trigger lazy load when connection becomes ready + @State private var saveDebounceTask: Task? // Debounce task for saving tabs + @State private var isDismissing = false // Prevent saving when view is being destroyed + @State private var justRestoredTab = false // Prevent lazy load duplicate execution after restore + + // MARK: - Constants + + private static let tabSaveDebounceDelay: UInt64 = 500_000_000 // 500ms in nanoseconds + private static let connectionCheckDelay: UInt64 = 100_000_000 // 100ms in nanoseconds + private static let maxConnectionRetries = 50 // Max retries for connection check (5 seconds total) // Error alert state @State private var showErrorAlert = false @@ -201,23 +217,51 @@ struct MainContentView: View { // Dismiss all autocomplete windows to prevent duplicates NotificationCenter.default.post(name: NSNotification.Name("QueryTabDidChange"), object: nil) + // Skip save during restoration + guard !isRestoringTabs else { return } + + // Skip save if view is being dismissed + guard !isDismissing else { + return + } + // Sync selected tab ID to session for persistence if let sessionId = DatabaseManager.shared.currentSessionId { DatabaseManager.shared.updateSession(sessionId) { session in session.selectedTabId = newTabId } + + // CRITICAL: Also persist to disk for restoration + TabStateStorage.shared.saveTabState( + connectionId: connection.id, + tabs: tabManager.tabs, + selectedTabId: newTabId + ) } } .onChange(of: tabManager.tabs) { _, newTabs in // Skip sync if we're currently restoring tabs from session (prevents circular updates) guard !isRestoringTabs else { return } + // CRITICAL: Skip save if view is being dismissed to prevent saving empty query + // When SwiftUI tears down the view, bindings may be reset causing empty saves + guard !isDismissing else { + return + } + // Sync tabs array to session for persistence if let sessionId = DatabaseManager.shared.currentSessionId { DatabaseManager.shared.updateSession(sessionId) { session in session.tabs = newTabs } + // CRITICAL: Persist tabs to disk so they can be restored when connection reopens + TabStateStorage.shared.saveTabState( + connectionId: connection.id, + tabs: newTabs, + selectedTabId: tabManager.selectedTabId + ) + // Clear saved state immediately when all tabs are closed if newTabs.isEmpty { TabStateStorage.shared.clearTabState(connectionId: connection.id) @@ -225,9 +269,7 @@ struct MainContentView: View { } } .onChange(of: currentTab?.resultColumns) { _, newColumns in - Task { @MainActor in - handleColumnsChange(newColumns: newColumns) - } + handleColumnsChange(newColumns: newColumns) } .onChange(of: currentTab?.errorMessage) { _, newError in // Show error alert when errorMessage is set @@ -236,6 +278,13 @@ struct MainContentView: View { showErrorAlert = true } } + .onChange(of: DatabaseManager.shared.currentSession?.isConnected) { _, isConnected in + // Auto-execute query when connection becomes ready and tab needs data + if isConnected == true && needsLazyLoad { + needsLazyLoad = false + runQuery() + } + } } /// Separated to reduce type-checker complexity @@ -302,25 +351,145 @@ struct MainContentView: View { redoLastChange() } } + .onReceive(NotificationCenter.default.publisher(for: .mainWindowWillClose)) { _ in + // CRITICAL: Window is about to close - flush pending saves immediately + // This prevents query text from being lost when SwiftUI tears down the view + + // Set flag to prevent further saves (view is being destroyed) + isDismissing = true + + // Cancel debounce task and save immediately + saveDebounceTask?.cancel() + + // Immediately save current state before view is destroyed + if let sessionId = DatabaseManager.shared.currentSessionId { + TabStateStorage.shared.saveTabState( + connectionId: connection.id, + tabs: tabManager.tabs, + selectedTabId: tabManager.selectedTabId + ) + } + } } /// First part of notifications - reduces type-checker complexity @ViewBuilder private var bodyContentPart1: some View { + bodyContentPart2 + .onReceive(NotificationCenter.default.publisher(for: .deleteSelectedRows)) { _ in + // Delete rows or mark table for deletion + Task { @MainActor in + // First check if we have row selection in data grid + if !selectedRowIndices.isEmpty { + deleteSelectedRows() + } + // Otherwise check if tables are selected in sidebar + else if !selectedTables.isEmpty { + // Batch update to avoid stale copy issues with @Binding + var updatedDeletes = pendingDeletes + var updatedTruncates = pendingTruncates + + for table in selectedTables { + updatedTruncates.remove(table.name) + if updatedDeletes.contains(table.name) { + updatedDeletes.remove(table.name) + } else { + updatedDeletes.insert(table.name) + } + } + + pendingTruncates = updatedTruncates + pendingDeletes = updatedDeletes + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .databaseDidConnect)) { _ in + // Load schema and update toolbar when connection is established (fixes race condition) + Task { @MainActor in + await loadSchema() + // Update version after connection is fully established + if let driver = DatabaseManager.shared.activeDriver { + toolbarState.databaseVersion = driver.serverVersion + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .showAllTables)) { _ in + // Show all tables metadata when user clicks "Tables" heading in sidebar + Task { @MainActor in + showAllTablesMetadata() + } + } + .onReceive(NotificationCenter.default.publisher(for: .addNewRow)) { _ in + // Add row menu item (Cmd+I) + Task { @MainActor in + addNewRow() + } + } + } + + /// Second part of notifications - further reduces type-checker complexity + @ViewBuilder + private var bodyContentPart2: some View { viewWithToolbar .task { await initializeView() - - // Restore tabs from session if available (after DatabaseManager has loaded them) - if let sessionId = DatabaseManager.shared.currentSessionId, - let session = DatabaseManager.shared.activeSessions[sessionId], - !session.tabs.isEmpty { - // Set flag to prevent onChange(tabManager.tabs) from syncing back - // Use defer to ensure flag is always reset even if an error occurs + + // Restore tabs from disk first (persists across app restarts) + // Fallback to session tabs (persists during app session only) + var didRestoreTabs = false + if let savedState = TabStateStorage.shared.loadTabState(connectionId: connection.id), + !savedState.tabs.isEmpty { + // Restore from disk isRestoringTabs = true defer { isRestoringTabs = false } + + let restoredTabs = savedState.tabs.map { QueryTab(from: $0) } + tabManager.tabs = restoredTabs + tabManager.selectedTabId = savedState.selectedTabId + didRestoreTabs = true + } else if let sessionId = DatabaseManager.shared.currentSessionId, + let session = DatabaseManager.shared.activeSessions[sessionId], + !session.tabs.isEmpty { + // Fallback: Restore from session (for backward compatibility) + isRestoringTabs = true + defer { isRestoringTabs = false } + tabManager.tabs = session.tabs tabManager.selectedTabId = session.selectedTabId + didRestoreTabs = true + } + // Execute query for table tabs to load data + if didRestoreTabs { + if let selectedTab = tabManager.selectedTab, + selectedTab.tabType == .table, + !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + + // Wait for connection to be established + var retryCount = 0 + while retryCount < Self.maxConnectionRetries { + // Stop waiting if view is being dismissed + guard !isDismissing else { break } + + if let session = DatabaseManager.shared.currentSession, + session.isConnected { + // Small delay to ensure everything is initialized + try? await Task.sleep(nanoseconds: Self.connectionCheckDelay) + await MainActor.run { + justRestoredTab = true // Prevent lazy load from executing again + runQuery() + } + break + } + + // Wait 100ms and retry + try? await Task.sleep(nanoseconds: 100_000_000) + retryCount += 1 + } + + if retryCount >= Self.maxConnectionRetries { + print("[MainContentView] ⚠️ Connection timeout, query not executed") + } + } } } .onChange(of: selectedTables) { oldTables, newTables in @@ -350,12 +519,11 @@ struct MainContentView: View { tabManager.addTab(initialQuery: lastQuery) } } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("loadQueryIntoEditor"))) { notification in + .onReceive(NotificationCenter.default.publisher(for: .loadQueryIntoEditor)) { notification in // Load query from history/bookmark panel into current tab Task { @MainActor in - guard let query = notification.userInfo?["query"] as? String else { return } - print("[MainContentView] Received loadQueryIntoEditor with query: \(query.prefix(50))...") - + guard let query = notification.object as? String else { return } + // Load into the current tab (which was just created by .newTab) if let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count { @@ -386,67 +554,18 @@ struct MainContentView: View { } else { // Cancel any running query to prevent race conditions currentQueryTask?.cancel() - + // Rebuild query for table tabs to ensure fresh data if let tabIndex = tabManager.selectedTabIndex, tabManager.tabs[tabIndex].tabType == .table { rebuildTableQuery(at: tabIndex) } - + // Fetch fresh data from database runQuery() } } } - .onReceive(NotificationCenter.default.publisher(for: .deleteSelectedRows)) { _ in - // Delete rows or mark table for deletion - Task { @MainActor in - // First check if we have row selection in data grid - if !selectedRowIndices.isEmpty { - deleteSelectedRows() - } - // Otherwise check if tables are selected in sidebar - else if !selectedTables.isEmpty { - // Batch update to avoid stale copy issues with @Binding - var updatedDeletes = pendingDeletes - var updatedTruncates = pendingTruncates - - for table in selectedTables { - updatedTruncates.remove(table.name) - if updatedDeletes.contains(table.name) { - updatedDeletes.remove(table.name) - } else { - updatedDeletes.insert(table.name) - } - } - - pendingTruncates = updatedTruncates - pendingDeletes = updatedDeletes - } - } - } - .onReceive(NotificationCenter.default.publisher(for: .databaseDidConnect)) { _ in - // Load schema and update toolbar when connection is established (fixes race condition) - Task { @MainActor in - await loadSchema() - // Update version after connection is fully established - if let driver = DatabaseManager.shared.activeDriver { - toolbarState.databaseVersion = driver.serverVersion - } - } - } - .onReceive(NotificationCenter.default.publisher(for: .showAllTables)) { _ in - // Show all tables metadata when user clicks "Tables" heading in sidebar - Task { @MainActor in - showAllTablesMetadata() - } - } - .onReceive(NotificationCenter.default.publisher(for: .addNewRow)) { _ in - // Add row menu item (Cmd+I) - Task { @MainActor in - addNewRow() - } - } } // MARK: - Query Tab Content @@ -459,11 +578,42 @@ struct MainContentView: View { queryText: Binding( get: { tab.query }, set: { newValue in - if let index = tabManager.selectedTabIndex { - tabManager.tabs[index].query = newValue + // CRITICAL: Bounds check to prevent crash on paste + guard let index = tabManager.selectedTabIndex, + index < tabManager.tabs.count else { + return + } + + tabManager.tabs[index].query = newValue + + // Save as last query for this connection (TablePlus-style) + TabStateStorage.shared.saveLastQuery(newValue, for: connection.id) + + // CRITICAL: Debounce save to prevent race conditions + // Only save 500ms after user stops typing + // SKIP save during restoration or dismissal to prevent overwriting with empty values + if !isRestoringTabs && !isDismissing { + // Cancel previous debounce task + saveDebounceTask?.cancel() + + // CRITICAL: Capture current tabs STATE to prevent stale data + let tabsToSave = tabManager.tabs + let selectedId = tabManager.selectedTabId - // Save as last query for this connection (TablePlus-style) - TabStateStorage.shared.saveLastQuery(newValue, for: connection.id) + // Create new debounce task + saveDebounceTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: Self.tabSaveDebounceDelay) + + // Only save if not cancelled and view not being dismissed + guard !Task.isCancelled && !isDismissing else { return } + + // Save the captured tabs state (NOT current state which may have changed) + TabStateStorage.shared.saveTabState( + connectionId: connection.id, + tabs: tabsToSave, + selectedTabId: selectedId + ) + } } } ), @@ -598,90 +748,24 @@ struct MainContentView: View { // MARK: - Status Bar private var statusBar: some View { - HStack { - // Left: Data/Structure toggle for table tabs - if let tab = currentTab, tab.tabType == .table, tab.tableName != nil { - Picker( - "", - selection: Binding( - get: { tab.showStructure ? "structure" : "data" }, - set: { newValue in - DispatchQueue.main.async { - if let index = tabManager.selectedTabIndex { - tabManager.tabs[index].showStructure = (newValue == "structure") - } - } - } - ) - ) { - Label("Data", systemImage: "tablecells").tag("data") - Label("Structure", systemImage: "list.bullet.rectangle").tag("structure") - } - .pickerStyle(.segmented) - .frame(width: 180) - .controlSize(.small) - .offset(x: -26) - } - - Spacer() - - // Center: Row info (pagination/selection) - if let tab = currentTab, !tab.resultRows.isEmpty { - Text(rowInfoText(for: tab)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - // Right: Filters toggle button - if let tab = currentTab, tab.tabType == .table, tab.tableName != nil { - Toggle(isOn: Binding( - get: { filterStateManager.isVisible }, - set: { _ in filterStateManager.toggle() } - )) { - HStack(spacing: 4) { - Image(systemName: filterStateManager.hasAppliedFilters - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") - Text("Filters") - if filterStateManager.hasAppliedFilters { - Text("(\(filterStateManager.appliedFilters.count))") - .foregroundStyle(.secondary) - } - } - } - .toggleStyle(.button) - .controlSize(.small) - .help("Toggle Filters (Cmd+F)") - } - } - .padding(.horizontal, 12) - .padding(.vertical, 4) - .background(Color(nsColor: .controlBackgroundColor)) + MainStatusBarView( + tab: currentTab, + filterStateManager: filterStateManager, + selectedRowIndices: selectedRowIndices, + showStructure: showStructureBinding + ) } - /// Generate row info text based on selection and pagination state - private func rowInfoText(for tab: QueryTab) -> String { - let loadedCount = tab.resultRows.count - // Use local selectedRowIndices state (not tab.selectedRowIndices which is only synced on tab switch) - let selectedCount = selectedRowIndices.count - let total = tab.pagination.totalRowCount - - if selectedCount > 0 { - // Selection mode - if selectedCount == loadedCount { - return "All \(loadedCount) rows selected" - } else { - return "\(selectedCount) of \(loadedCount) rows selected" + /// Binding for showStructure state + private var showStructureBinding: Binding { + Binding( + get: { currentTab?.showStructure ?? false }, + set: { newValue in + if let index = tabManager.selectedTabIndex { + tabManager.tabs[index].showStructure = newValue + } } - } else if let total = total, total > loadedCount { - // Pagination mode: "1-100 of 5000 rows" - return "1-\(loadedCount) of \(total) rows" - } else { - // Simple mode: "100 rows" - return "\(loadedCount) rows" - } + ) } // MARK: - Actions @@ -733,19 +817,24 @@ struct MainContentView: View { } private func runQuery() { - guard let index = tabManager.selectedTabIndex else { return } + guard let index = tabManager.selectedTabIndex else { + print("[MainContentView] ⚠️ runQuery() called but selectedTabIndex is nil!") + return + } + + guard !tabManager.tabs[index].isExecuting else { + return + } // Cancel any previous running query to prevent race conditions - // This is critical for SSH connections where rapid sorting can cause - // multiple queries to return out of order, leading to EXC_BAD_ACCESS + // IMPORTANT: Only cancel AFTER checking isExecuting, otherwise we cancel + // a valid running query without starting a new one currentQueryTask?.cancel() // Increment generation - any query with a different generation will be ignored queryGeneration += 1 let capturedGeneration = queryGeneration - guard !tabManager.tabs[index].isExecuting else { return } - tabManager.tabs[index].isExecuting = true tabManager.tabs[index].executionTime = nil tabManager.tabs[index].errorMessage = nil @@ -858,13 +947,18 @@ struct MainContentView: View { // Find tab by ID (index may have changed) - must update on main thread await MainActor.run { + // Clear task reference to avoid stale references + currentQueryTask = nil + // ALWAYS update toolbar state first - user should see query completion toolbarState.isExecuting = false toolbarState.lastQueryDuration = safeExecutionTime // Only update tab if this is still the most recent query // This prevents race conditions when navigating quickly between tables - guard capturedGeneration == queryGeneration else { return } + guard capturedGeneration == queryGeneration else { + return + } guard !Task.isCancelled else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { @@ -916,6 +1010,9 @@ struct MainContentView: View { // MUST run on MainActor for SwiftUI onChange to fire await MainActor.run { + // Clear task reference + currentQueryTask = nil + if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { tabManager.tabs[idx].errorMessage = error.localizedDescription tabManager.tabs[idx].isExecuting = false @@ -1045,73 +1142,14 @@ struct MainContentView: View { !selectedRowIndices.isEmpty else { return } - // Separate inserted rows from existing rows - var insertedRowsToDelete: [Int] = [] - var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = [] - - // Find the lowest selected row index for selection movement - let minSelectedRow = selectedRowIndices.min() ?? 0 - let maxSelectedRow = selectedRowIndices.max() ?? 0 - - // Categorize rows (process in descending order to maintain correct indices) - for rowIndex in selectedRowIndices.sorted(by: >) { - if changeManager.isRowInserted(rowIndex) { - // Collect inserted rows to delete - insertedRowsToDelete.append(rowIndex) - } else if !changeManager.isRowDeleted(rowIndex) { - // Collect existing rows for batch deletion - if rowIndex < tabManager.tabs[tabIndex].resultRows.count { - let originalRow = tabManager.tabs[tabIndex].resultRows[rowIndex].values - existingRowsToDelete.append((rowIndex: rowIndex, originalRow: originalRow)) - } - } - } - - // Process inserted rows deletion - if !insertedRowsToDelete.isEmpty { - // Sort descending so removing higher indices first doesn't affect lower indices - let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) - - // Remove from resultRows first (descending order) - for rowIndex in sortedInsertedRows { - guard rowIndex < tabManager.tabs[tabIndex].resultRows.count else { continue } - tabManager.tabs[tabIndex].resultRows.remove(at: rowIndex) - } - - // Update changeManager for ALL deleted inserted rows at once - // This prevents index shifting issues from calling undoRowInsertion multiple times - changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) - } - - // Record batch deletion for existing rows (single undo action for all rows) - if !existingRowsToDelete.isEmpty { - changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) - } + // Use RowOperationsManager to delete rows + let nextRow = rowOperationsManager.deleteSelectedRows( + selectedIndices: selectedRowIndices, + resultRows: &tabManager.tabs[tabIndex].resultRows + ) - // Move selection to next available row after deletion - let totalRows = tabManager.tabs[tabIndex].resultRows.count - - // Calculate next row selection, accounting for deleted inserted rows - let rowsDeleted = insertedRowsToDelete.count - let adjustedMaxRow = maxSelectedRow - rowsDeleted - let adjustedMinRow = minSelectedRow - insertedRowsToDelete.filter { $0 < minSelectedRow }.count - - let nextRow: Int - if adjustedMaxRow + 1 < totalRows { - // Select row after the deleted range - nextRow = min(adjustedMaxRow + 1, totalRows - 1) - } else if adjustedMinRow > 0 { - // Deleted rows at end, select previous row - nextRow = adjustedMinRow - 1 - } else if totalRows > 0 { - // Select first row if available - nextRow = 0 - } else { - // All rows deleted - nextRow = -1 - } - - if nextRow >= 0 && nextRow < totalRows { + // Update selection + if nextRow >= 0 && nextRow < tabManager.tabs[tabIndex].resultRows.count { selectedRowIndices = [nextRow] } else { selectedRowIndices.removeAll() @@ -1135,21 +1173,12 @@ struct MainContentView: View { private func copySelectedRowsToClipboard() { guard let index = tabManager.selectedTabIndex, !selectedRowIndices.isEmpty else { return } - + let tab = tabManager.tabs[index] - let sortedIndices = selectedRowIndices.sorted() - var lines: [String] = [] - - for rowIndex in sortedIndices { - guard rowIndex < tab.resultRows.count else { continue } - let row = tab.resultRows[rowIndex] - let line = row.values.map { $0 ?? "NULL" }.joined(separator: "\t") - lines.append(line) - } - - let text = lines.joined(separator: "\n") - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + rowOperationsManager.copySelectedRowsToClipboard( + selectedIndices: selectedRowIndices, + resultRows: tab.resultRows + ) } // MARK: - Filters @@ -1462,45 +1491,25 @@ struct MainContentView: View { private func addNewRow() { guard let tabIndex = tabManager.selectedTabIndex else { return } guard tabIndex < tabManager.tabs.count else { return } - + let tab = tabManager.tabs[tabIndex] - + // Only add rows to editable table tabs guard tab.isEditable, tab.tableName != nil else { return } - - let columns = tab.resultColumns - let columnDefaults = tab.columnDefaults - - // Create new row values with DEFAULT markers - // These will be filtered out during INSERT generation, - // letting the database use actual defaults - var newRowValues: [String?] = [] - for column in columns { - if let defaultValue = columnDefaults[column], defaultValue != nil { - // Use __DEFAULT__ marker so generateInsertSQL skips this column - newRowValues.append("__DEFAULT__") - } else { - // NULL for columns without defaults - newRowValues.append(nil) - } - } - - // Add to tab's resultRows - let newRow = QueryResultRow(values: newRowValues) - tabManager.tabs[tabIndex].resultRows.append(newRow) - - // Get the new row index - let newRowIndex = tabManager.tabs[tabIndex].resultRows.count - 1 - - // Record in change manager as pending INSERT - changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newRowValues) - + + // Use RowOperationsManager to add the row + guard let result = rowOperationsManager.addNewRow( + columns: tab.resultColumns, + columnDefaults: tab.columnDefaults, + resultRows: &tabManager.tabs[tabIndex].resultRows + ) else { return } + // Select the new row (scrolls to it) - selectedRowIndices = [newRowIndex] - + selectedRowIndices = [result.rowIndex] + // Auto-focus first cell instantly (TablePlus behavior) - editingCell = CellPosition(row: newRowIndex, column: 0) - + editingCell = CellPosition(row: result.rowIndex, column: 0) + // Mark tab as having user interaction tabManager.tabs[tabIndex].hasUserInteraction = true } @@ -1522,31 +1531,18 @@ struct MainContentView: View { selectedRowIndices.count == 1, selectedIndex < tab.resultRows.count else { return } - // Copy values from selected row - let sourceRow = tab.resultRows[selectedIndex] - var newValues = sourceRow.values - - // Set primary key column to DEFAULT so DB auto-generates - if let pkColumn = changeManager.primaryKeyColumn, - let pkIndex = tab.resultColumns.firstIndex(of: pkColumn) { - newValues[pkIndex] = "__DEFAULT__" - } - - // Add the duplicated row - let newRow = QueryResultRow(values: newValues) - tabManager.tabs[tabIndex].resultRows.append(newRow) - - // Get the new row index - let newRowIndex = tabManager.tabs[tabIndex].resultRows.count - 1 - - // Record in change manager as pending INSERT - changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newValues) + // Use RowOperationsManager to duplicate the row + guard let result = rowOperationsManager.duplicateRow( + sourceRowIndex: selectedIndex, + columns: tab.resultColumns, + resultRows: &tabManager.tabs[tabIndex].resultRows + ) else { return } // Select the new row (scrolls to it) - selectedRowIndices = [newRowIndex] + selectedRowIndices = [result.rowIndex] // Auto-focus first cell (TablePlus behavior) - editingCell = CellPosition(row: newRowIndex, column: 0) + editingCell = CellPosition(row: result.rowIndex, column: 0) // Mark tab as having user interaction tabManager.tabs[tabIndex].hasUserInteraction = true @@ -1556,26 +1552,13 @@ struct MainContentView: View { private func undoInsertRow(at rowIndex: Int) { guard let tabIndex = tabManager.selectedTabIndex else { return } guard tabIndex < tabManager.tabs.count else { return } - guard rowIndex >= 0 && rowIndex < tabManager.tabs[tabIndex].resultRows.count else { return } - - // Remove the row from resultRows - tabManager.tabs[tabIndex].resultRows.remove(at: rowIndex) - - // Clear selection since the row no longer exists - if selectedRowIndices.contains(rowIndex) { - selectedRowIndices.remove(rowIndex) - } - - // Adjust selection indices for rows that shifted down - var adjustedSelection = Set() - for idx in selectedRowIndices { - if idx > rowIndex { - adjustedSelection.insert(idx - 1) - } else { - adjustedSelection.insert(idx) - } - } - selectedRowIndices = adjustedSelection + + // Use RowOperationsManager to undo the insertion + selectedRowIndices = rowOperationsManager.undoInsertRow( + at: rowIndex, + resultRows: &tabManager.tabs[tabIndex].resultRows, + selectedIndices: selectedRowIndices + ) } /// Undo the last change (Cmd+Z) @@ -1583,62 +1566,14 @@ struct MainContentView: View { private func undoLastChange() { guard let tabIndex = tabManager.selectedTabIndex else { return } guard tabIndex < tabManager.tabs.count else { return } - - // Get the undo result from changeManager - guard let result = changeManager.undoLastChange() else { return } - - switch result.action { - case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _): - // Restore the cell value in resultRows - if rowIndex < tabManager.tabs[tabIndex].resultRows.count { - tabManager.tabs[tabIndex].resultRows[rowIndex].values[columnIndex] = previousValue - } - - case .rowInsertion(let rowIndex): - // Remove the inserted row from resultRows - if rowIndex < tabManager.tabs[tabIndex].resultRows.count { - tabManager.tabs[tabIndex].resultRows.remove(at: rowIndex) - - // Clear selection if it was on the removed row - if selectedRowIndices.contains(rowIndex) { - selectedRowIndices.remove(rowIndex) - } - - // Adjust selection indices for rows that shifted down - var adjustedSelection = Set() - for idx in selectedRowIndices { - if idx > rowIndex { - adjustedSelection.insert(idx - 1) - } else { - adjustedSelection.insert(idx) - } - } - selectedRowIndices = adjustedSelection - } - - case .rowDeletion(_, _): - // Row is restored in changeManager - visual indicator will be removed - // No need to modify resultRows since deletion was just a visual indicator - break - - case .batchRowDeletion(_): - // All rows are restored in changeManager - visual indicators will be removed - // No need to modify resultRows since deletions were just visual indicators - break - - case .batchRowInsertion(let rowIndices, let rowValues): - // Restore deleted inserted rows - add them back to resultRows - // Process in reverse order (ascending) to maintain correct indices - for (index, rowIndex) in rowIndices.enumerated().reversed() { - guard index < rowValues.count else { continue } - guard rowIndex <= tabManager.tabs[tabIndex].resultRows.count else { continue } - - let values = rowValues[index] - let newRow = QueryResultRow(values: values) - tabManager.tabs[tabIndex].resultRows.insert(newRow, at: rowIndex) - } + + // Use RowOperationsManager to undo + if let adjustedSelection = rowOperationsManager.undoLastChange( + resultRows: &tabManager.tabs[tabIndex].resultRows + ) { + selectedRowIndices = adjustedSelection } - + // Mark tab as having user interaction tabManager.tabs[tabIndex].hasUserInteraction = true } @@ -1648,44 +1583,15 @@ struct MainContentView: View { private func redoLastChange() { guard let tabIndex = tabManager.selectedTabIndex else { return } guard tabIndex < tabManager.tabs.count else { return } - - // Get the redo result from changeManager - guard let result = changeManager.redoLastChange() else { return } - - switch result.action { - case .cellEdit(let rowIndex, let columnIndex, _, _, let newValue): - // Re-apply the cell value in resultRows - if rowIndex < tabManager.tabs[tabIndex].resultRows.count { - tabManager.tabs[tabIndex].resultRows[rowIndex].values[columnIndex] = newValue - } - - case .rowInsertion(let rowIndex): - // Re-insert the row into resultRows - var newValues = [String?](repeating: nil, count: changeManager.columns.count) - let newRow = QueryResultRow(values: newValues) - if rowIndex <= tabManager.tabs[tabIndex].resultRows.count { - tabManager.tabs[tabIndex].resultRows.insert(newRow, at: rowIndex) - } - - case .rowDeletion(_, _): - // Row is re-marked as deleted in changeManager - // No need to modify resultRows since deletion is just a visual indicator - break - - case .batchRowDeletion(_): - // Rows are re-marked as deleted in changeManager - // No need to modify resultRows since deletions are just visual indicators - break - - case .batchRowInsertion(let rowIndices, _): - // Redo the deletion - remove the rows from resultRows again - // Remove in descending order to avoid index shifting issues - for rowIndex in rowIndices.sorted(by: >) { - guard rowIndex < tabManager.tabs[tabIndex].resultRows.count else { continue } - tabManager.tabs[tabIndex].resultRows.remove(at: rowIndex) - } - } - + + let tab = tabManager.tabs[tabIndex] + + // Use RowOperationsManager to redo + _ = rowOperationsManager.redoLastChange( + resultRows: &tabManager.tabs[tabIndex].resultRows, + columns: tab.resultColumns + ) + // Mark tab as having user interaction tabManager.tabs[tabIndex].hasUserInteraction = true } @@ -1694,6 +1600,20 @@ struct MainContentView: View { /// Handle tab selection changes private func handleTabChange(oldTabId: UUID?, newTabId: UUID?) { + // CRITICAL: Flush pending debounced save to ensure last edit is saved + // Cancel and immediately execute if pending + if let task = saveDebounceTask, !task.isCancelled { + task.cancel() + // Immediately save current state before switching + if let sessionId = DatabaseManager.shared.currentSessionId, !isRestoringTabs, !isDismissing { + TabStateStorage.shared.saveTabState( + connectionId: connection.id, + tabs: tabManager.tabs, + selectedTabId: tabManager.selectedTabId + ) + } + } + // Save state to the old tab before switching if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) @@ -1705,32 +1625,51 @@ struct MainContentView: View { // sortState is already in tab, no need to save from local state } - // Restore state from the new tab + // Restore LIGHTWEIGHT state immediately (synchronous for instant UI update) if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { let newTab = tabManager.tabs[newIndex] - // Restore pending changes - if newTab.pendingChanges.hasChanges { - changeManager.restoreState( - from: newTab.pendingChanges, tableName: newTab.tableName ?? "") - } else { - // Clear changeManager for tabs without pending changes (atomically) - changeManager.configureForTable( - tableName: newTab.tableName ?? "", - columns: newTab.resultColumns, - primaryKeyColumn: newTab.resultColumns.first, - databaseType: connection.type - ) - } - - // Restore row selection + // CRITICAL: Update these immediately for UI consistency selectedRowIndices = newTab.selectedRowIndices - // sortState is accessed via binding, no need to restore to local state - - // Update app state for menu item enabled state AppState.shared.isCurrentTabEditable = newTab.isEditable && newTab.tableName != nil + + // DEFER heavy changeManager operations to next run loop + // This prevents blocking the UI thread and gives instant tab switching + Task { @MainActor in + // This runs after SwiftUI updates the view + if newTab.pendingChanges.hasChanges { + changeManager.restoreState( + from: newTab.pendingChanges, tableName: newTab.tableName ?? "") + } else { + // Clear changeManager for tabs without pending changes (atomically) + changeManager.configureForTable( + tableName: newTab.tableName ?? "", + columns: newTab.resultColumns, + primaryKeyColumn: newTab.resultColumns.first, + databaseType: connection.type + ) + } + + // Reset flag BEFORE checking lazy load to ensure it's always reset + // Otherwise, if lazy load is skipped due to flag=true, flag never resets! + let shouldSkipLazyLoad = justRestoredTab + justRestoredTab = false + + if !shouldSkipLazyLoad && + newTab.tabType == .table && // Only auto-execute for table tabs + newTab.resultRows.isEmpty && + newTab.lastExecutedAt == nil && + !newTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // Check connection before executing + if let session = DatabaseManager.shared.currentSession, session.isConnected { + runQuery() + } else { + needsLazyLoad = true + } + } + } } else { // No tab selected AppState.shared.isCurrentTabEditable = false @@ -1746,6 +1685,10 @@ struct MainContentView: View { // Only update if columns have actually changed guard changeManager.columns != newColumns else { return } + + // IMPORTANT: Skip if tableName or columns don't match current tab + // This prevents duplicate updates when switching tabs (handleTabChange already handles it) + guard changeManager.tableName == tab.tableName ?? "" else { return } changeManager.configureForTable( tableName: tab.tableName ?? "", @@ -2061,7 +2004,15 @@ struct MainContentView: View { // For existing tabs, onChange will restore their saved selection if needsQuery { selectedRowIndices = [] - runQuery() + + // Execute query for new/replaced tabs + // IMPORTANT: Wrapped in Task to ensure SwiftUI processes tab property updates first + // - For NEW tabs: selectedTabId changes → onChange fires → lazy load also triggers + // (both will try to run query, but the second will be blocked by isExecuting guard) + // - For REPLACED tabs: selectedTabId stays same → onChange doesn't fire → we MUST call runQuery + Task { @MainActor in + runQuery() + } } } diff --git a/OpenTable/Views/Results/CellTextField.swift b/OpenTable/Views/Results/CellTextField.swift new file mode 100644 index 00000000..b6db011f --- /dev/null +++ b/OpenTable/Views/Results/CellTextField.swift @@ -0,0 +1,82 @@ +// +// CellTextField.swift +// OpenTable +// +// Custom text field that delegates context menu to row view. +// Extracted from DataGridView for better maintainability. +// + +import AppKit + +/// NSTextField subclass that shows row context menu instead of text editing menu +final class CellTextField: NSTextField { + + override class var cellClass: AnyClass? { + get { CellTextFieldCell.self } + set { } + } + + /// Override right mouse down to end editing and show row context menu + override func rightMouseDown(with event: NSEvent) { + window?.makeFirstResponder(nil) + + var view: NSView? = self + while let parent = view?.superview { + if let rowView = parent as? TableRowViewWithMenu { + if let menu = rowView.menu(for: event) { + NSMenu.popUpContextMenu(menu, with: event, for: self) + } + return + } + view = parent + } + } + + override func menu(for event: NSEvent) -> NSMenu? { + window?.makeFirstResponder(nil) + + var view: NSView? = self + while let parent = view?.superview { + if let rowView = parent as? TableRowViewWithMenu { + return rowView.menu(for: event) + } + view = parent + } + + return nil + } +} + +/// Custom text field cell that provides a field editor with custom context menu behavior +final class CellTextFieldCell: NSTextFieldCell { + + private class CellFieldEditor: NSTextView { + + override func rightMouseDown(with event: NSEvent) { + window?.makeFirstResponder(nil) + + var view: NSView? = self + while let parent = view?.superview { + if let cellTextField = parent as? CellTextField { + cellTextField.rightMouseDown(with: event) + return + } + view = parent + } + } + + override func menu(for event: NSEvent) -> NSMenu? { + return nil + } + } + + private var customFieldEditor: CellFieldEditor? + + override func fieldEditor(for controlView: NSView) -> NSTextView? { + if customFieldEditor == nil { + customFieldEditor = CellFieldEditor() + customFieldEditor?.isFieldEditor = true + } + return customFieldEditor + } +} diff --git a/OpenTable/Views/Results/DataGridCellFactory.swift b/OpenTable/Views/Results/DataGridCellFactory.swift new file mode 100644 index 00000000..646b9823 --- /dev/null +++ b/OpenTable/Views/Results/DataGridCellFactory.swift @@ -0,0 +1,207 @@ +// +// DataGridCellFactory.swift +// OpenTable +// +// Factory for creating and configuring data grid cells. +// Extracted from DataGridView coordinator for better maintainability. +// + +import AppKit + +/// Factory for creating data grid cell views +final class DataGridCellFactory { + private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell") + private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCell") + + /// Large dataset threshold - above this, disable expensive visual features + private let largeDatasetThreshold = 5000 + + // MARK: - Row Number Cell + + func makeRowNumberCell( + tableView: NSTableView, + row: Int, + cachedRowCount: Int, + visualState: RowVisualState + ) -> NSView { + let cellViewId = NSUserInterfaceItemIdentifier("RowNumberCellView") + let cellView: NSTableCellView + let cell: NSTextField + + if let reused = tableView.makeView(withIdentifier: cellViewId, owner: nil) as? NSTableCellView, + let textField = reused.textField { + cellView = reused + cell = textField + } else { + cellView = NSTableCellView() + cellView.identifier = cellViewId + + cell = NSTextField(labelWithString: "") + cell.alignment = .right + cell.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + cell.textColor = .secondaryLabelColor + cell.translatesAutoresizingMaskIntoConstraints = false + + cellView.textField = cell + cellView.addSubview(cell) + + NSLayoutConstraint.activate([ + cell.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 4), + cell.trailingAnchor.constraint(equalTo: cellView.trailingAnchor, constant: -4), + cell.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + ]) + } + + guard row >= 0 && row < cachedRowCount else { + cell.stringValue = "" + return cellView + } + + cell.stringValue = "\(row + 1)" + cell.textColor = visualState.isDeleted ? .systemRed.withAlphaComponent(0.5) : .secondaryLabelColor + + return cellView + } + + // MARK: - Data Cell + + func makeDataCell( + tableView: NSTableView, + row: Int, + columnIndex: Int, + value: String?, + visualState: RowVisualState, + isEditable: Bool, + isLargeDataset: Bool, + isFocused: Bool, + delegate: NSTextFieldDelegate + ) -> NSView { + let cellViewId = NSUserInterfaceItemIdentifier("DataCellView") + let cellView: NSTableCellView + let cell: NSTextField + let isNewCell: Bool + + if let reused = tableView.makeView(withIdentifier: cellViewId, owner: nil) as? NSTableCellView, + let textField = reused.textField { + cellView = reused + cell = textField + isNewCell = false + } else { + cellView = NSTableCellView() + cellView.identifier = cellViewId + cellView.wantsLayer = true + + cell = CellTextField() + cell.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + cell.drawsBackground = false + cell.isBordered = false + cell.focusRingType = .none + cell.lineBreakMode = .byTruncatingTail + cell.cell?.truncatesLastVisibleLine = true + cell.translatesAutoresizingMaskIntoConstraints = false + + cellView.textField = cell + cellView.addSubview(cell) + + NSLayoutConstraint.activate([ + cell.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 4), + cell.trailingAnchor.constraint(equalTo: cellView.trailingAnchor, constant: -4), + cell.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + ]) + isNewCell = true + } + + cell.isEditable = isEditable + cell.delegate = delegate + cell.identifier = cellIdentifier + + let isDeleted = visualState.isDeleted + let isInserted = visualState.isInserted + let isModified = visualState.modifiedColumns.contains(columnIndex) + + // Update text content + cell.placeholderString = nil + + if value == nil { + cell.stringValue = "" + if !isLargeDataset { + cell.placeholderString = "NULL" + cell.textColor = .secondaryLabelColor + if isNewCell || cell.font?.fontDescriptor.symbolicTraits.contains(.italic) != true { + cell.font = .monospacedSystemFont(ofSize: 13, weight: .regular).withTraits(.italic) + } + } else { + cell.textColor = .secondaryLabelColor + } + } else if value == "__DEFAULT__" { + cell.stringValue = "" + if !isLargeDataset { + cell.placeholderString = "DEFAULT" + cell.textColor = .systemBlue + cell.font = .monospacedSystemFont(ofSize: 13, weight: .medium) + } else { + cell.textColor = .systemBlue + } + } else if value == "" { + cell.stringValue = "" + if !isLargeDataset { + cell.placeholderString = "Empty" + cell.textColor = .secondaryLabelColor + if isNewCell || cell.font?.fontDescriptor.symbolicTraits.contains(.italic) != true { + cell.font = .monospacedSystemFont(ofSize: 13, weight: .regular).withTraits(.italic) + } + } else { + cell.textColor = .secondaryLabelColor + } + } else { + cell.stringValue = value ?? "" + cell.textColor = .labelColor + if cell.font?.fontDescriptor.symbolicTraits.contains(.italic) == true || + cell.font?.fontDescriptor.symbolicTraits.contains(.bold) == true { + cell.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + } + } + + // Update background color + if isDeleted { + cellView.layer?.backgroundColor = NSColor.systemRed.withAlphaComponent(0.15).cgColor + } else if isInserted { + cellView.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.15).cgColor + } else if isModified && !isLargeDataset { + cellView.layer?.backgroundColor = NSColor.systemYellow.withAlphaComponent(0.3).cgColor + } else { + cellView.layer?.backgroundColor = nil + } + + // Focus ring + if isLargeDataset { + cellView.layer?.borderWidth = 0 + } else if isFocused { + cellView.layer?.borderWidth = 2 + cellView.layer?.borderColor = NSColor.selectedControlColor.cgColor + } else { + cellView.layer?.borderWidth = 0 + } + + return cellView + } + + // MARK: - Column Width Calculation + + func calculateColumnWidth(for columnName: String) -> CGFloat { + let font = NSFont.systemFont(ofSize: 13, weight: .semibold) + let attributes: [NSAttributedString.Key: Any] = [.font: font] + let size = (columnName as NSString).size(withAttributes: attributes) + let width = size.width + 48 + return max(width, 30) + } +} + +// MARK: - NSFont Extension + +extension NSFont { + func withTraits(_ traits: NSFontDescriptor.SymbolicTraits) -> NSFont { + let descriptor = fontDescriptor.withSymbolicTraits(traits) + return NSFont(descriptor: descriptor, size: pointSize) ?? self + } +} diff --git a/OpenTable/Views/Results/DataGridView.swift b/OpenTable/Views/Results/DataGridView.swift index ee791f97..ed2066bd 100644 --- a/OpenTable/Views/Results/DataGridView.swift +++ b/OpenTable/Views/Results/DataGridView.swift @@ -2,7 +2,8 @@ // DataGridView.swift // OpenTable // -// High-performance NSTableView wrapper for SwiftUI +// High-performance NSTableView wrapper for SwiftUI. +// Custom views extracted to separate files for maintainability. // import AppKit @@ -14,37 +15,34 @@ struct CellPosition: Equatable { let column: Int } -// MARK: - Row Visual State Cache - -/// Cached visual state for a row - avoids repeated changeManager lookups during cell rendering -/// Computed once per row, read by all cells in that row +/// Cached visual state for a row - avoids repeated changeManager lookups struct RowVisualState { let isDeleted: Bool let isInserted: Bool let modifiedColumns: Set - /// Empty state for rows with no changes static let empty = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: []) } /// High-performance table view using AppKit NSTableView -/// Wrapped for SwiftUI via NSViewRepresentable struct DataGridView: NSViewRepresentable { let rowProvider: InMemoryRowProvider @ObservedObject var changeManager: DataChangeManager let isEditable: Bool var onCommit: ((String) -> Void)? var onRefresh: (() -> Void)? - var onCellEdit: ((Int, Int, String?) -> Void)? // (rowIndex, columnIndex, newValue) - var onDeleteRows: ((Set) -> Void)? // Called when Delete key pressed - var onSort: ((Int, Bool) -> Void)? // Called when column header clicked (columnIndex, ascending) - var onAddRow: (() -> Void)? // Called when user triggers add row (Cmd+N) - var onUndoInsert: ((Int) -> Void)? // Called when user undoes row insertion (rowIndex) - var onFilterColumn: ((String) -> Void)? // Called when user selects "Filter with column" from header context menu + var onCellEdit: ((Int, Int, String?) -> Void)? + var onDeleteRows: ((Set) -> Void)? + var onSort: ((Int, Bool) -> Void)? + var onAddRow: (() -> Void)? + var onUndoInsert: ((Int) -> Void)? + var onFilterColumn: ((String) -> Void)? @Binding var selectedRowIndices: Set @Binding var sortState: SortState - @Binding var editingCell: CellPosition? // Triggers editing of specific cell + @Binding var editingCell: CellPosition? + + private let cellFactory = DataGridCellFactory() // MARK: - NSViewRepresentable @@ -55,7 +53,6 @@ struct DataGridView: NSViewRepresentable { scrollView.autohidesScrollers = true scrollView.borderType = .noBorder - // Use custom table view that handles Delete key let tableView = KeyHandlingTableView() tableView.coordinator = context.coordinator tableView.style = .plain @@ -68,42 +65,31 @@ struct DataGridView: NSViewRepresentable { tableView.intercellSpacing = NSSize(width: 1, height: 0) tableView.rowHeight = 24 - // Set delegate and data source tableView.delegate = context.coordinator tableView.dataSource = context.coordinator // Add row number column - let rowNumberColumn = NSTableColumn( - identifier: NSUserInterfaceItemIdentifier("__rowNumber__")) + let rowNumberColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("__rowNumber__")) rowNumberColumn.title = "#" rowNumberColumn.width = 40 rowNumberColumn.minWidth = 40 rowNumberColumn.maxWidth = 60 rowNumberColumn.isEditable = false - rowNumberColumn.resizingMask = [] // Disable resizing + rowNumberColumn.resizingMask = [] tableView.addTableColumn(rowNumberColumn) // Add data columns for (index, columnName) in rowProvider.columns.enumerated() { let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("col_\(index)")) column.title = columnName - - // Auto-size column width to fit header text - let calculatedWidth = calculateColumnWidth(for: columnName) - column.width = calculatedWidth + column.width = cellFactory.calculateColumnWidth(for: columnName) column.minWidth = 30 - // Don't set maxWidth - let column stay at calculated width column.resizingMask = .userResizingMask column.isEditable = isEditable - // Use stable key for native sort descriptors (not column title which may change) - // AppKit will automatically show native sort indicators when user clicks header column.sortDescriptorPrototype = NSSortDescriptor(key: "col_\(index)", ascending: true) - tableView.addTableColumn(column) } - - // Use default NSTableHeaderView - 100% native sorting behavior - // Set up context menu using NSMenuDelegate (no subclassing needed) + if let headerView = tableView.headerView { let headerMenu = NSMenu() headerMenu.delegate = context.coordinator @@ -112,55 +98,28 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView - + context.coordinator.cellFactory = cellFactory return scrollView } - - /// Calculate column width based on header text length - private func calculateColumnWidth(for columnName: String) -> CGFloat { - // Use header font (system default for table headers) - let font = NSFont.systemFont(ofSize: 13, weight: .semibold) - let attributes: [NSAttributedString.Key: Any] = [.font: font] - let size = (columnName as NSString).size(withAttributes: attributes) - - // Add generous padding: 12px left + text + 24px for sort indicator + 12px right - let width = size.width + 48 - - // Min 30px, no max (always fit full header text) - return max(width, 30) - } func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let tableView = scrollView.documentView as? NSTableView else { return } let coordinator = context.coordinator - // Don't update while editing - this would cancel the edit - if tableView.editedRow >= 0 { - return - } + if tableView.editedRow >= 0 { return } - // PERF: Version check for change tracking (increments on clear/save) let versionChanged = coordinator.lastReloadVersion != changeManager.reloadVersion - - // PERF: Use cached values - avoids potential issues with deallocated provider let oldRowCount = coordinator.cachedRowCount let oldColumnCount = coordinator.cachedColumnCount let newRowCount = rowProvider.totalRowCount let newColumnCount = rowProvider.columns.count - // PERF: Only reload on structural changes, NOT on sort - // Sorting changes row order but not count - NSTableView handles this internally - // Removed: rowDataChanged comparison that caused O(n) overhead per update - let needsReload = - oldRowCount != newRowCount - || oldColumnCount != newColumnCount - || versionChanged + let needsReload = oldRowCount != newRowCount || oldColumnCount != newColumnCount || versionChanged - // Update coordinator references (but not version tracker yet - see below) coordinator.rowProvider = rowProvider - coordinator.updateCache() // Update cached counts after provider change + coordinator.updateCache() coordinator.changeManager = changeManager coordinator.isEditable = isEditable coordinator.onCommit = onCommit @@ -171,57 +130,37 @@ struct DataGridView: NSViewRepresentable { coordinator.onUndoInsert = onUndoInsert coordinator.onFilterColumn = onFilterColumn - // PERF: Rebuild visual state cache once per update cycle - // Cells read from this cache instead of calling changeManager directly coordinator.rebuildVisualStateCache() - // Check if columns changed - compare actual column names, not just count - let currentDataColumns = tableView.tableColumns.dropFirst() // Skip row number column + // Check if columns changed + let currentDataColumns = tableView.tableColumns.dropFirst() let currentColumnNames = currentDataColumns.map { $0.title } - - // Only rebuild if columns actually changed AND we have columns to show let columnsChanged = !rowProvider.columns.isEmpty && (currentColumnNames != rowProvider.columns) - + if columnsChanged { - // Rebuild columns - remove ALL data columns (keep only row number column) - let columnsToRemove = tableView.tableColumns.filter { - $0.identifier.rawValue != "__rowNumber__" - } + let columnsToRemove = tableView.tableColumns.filter { $0.identifier.rawValue != "__rowNumber__" } for column in columnsToRemove { tableView.removeTableColumn(column) } for (index, columnName) in rowProvider.columns.enumerated() { - let column = NSTableColumn( - identifier: NSUserInterfaceItemIdentifier("col_\(index)")) - + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("col_\(index)")) column.title = columnName - let calculatedWidth = calculateColumnWidth(for: columnName) - column.width = calculatedWidth + column.width = cellFactory.calculateColumnWidth(for: columnName) column.minWidth = 30 - // Don't set maxWidth - let column stay at calculated width column.resizingMask = .userResizingMask column.isEditable = isEditable - - // Use stable key for native sort descriptors column.sortDescriptorPrototype = NSSortDescriptor(key: "col_\(index)", ascending: true) - tableView.addTableColumn(column) } - - // Force header to recalculate layout after column changes - // Default NSTableHeaderView handles native sort indicators automatically tableView.sizeToFit() } - // Sync SwiftUI sort state → NSTableView (one-way) - // AppKit handles drawing native sort indicators automatically - // Use flag to prevent delegate from triggering infinite loop + // Sync sort state coordinator.isSyncingSortDescriptors = true defer { coordinator.isSyncingSortDescriptors = false } - + if !sortState.isSorting { - // No sort active - clear sort descriptors if !tableView.sortDescriptors.isEmpty { tableView.sortDescriptors = [] } @@ -229,48 +168,36 @@ struct DataGridView: NSViewRepresentable { columnIndex >= 0 && columnIndex < rowProvider.columns.count { let key = "col_\(columnIndex)" let ascending = sortState.direction == .ascending - - // Only update if different to avoid unnecessary updates let currentDescriptor = tableView.sortDescriptors.first if currentDescriptor?.key != key || currentDescriptor?.ascending != ascending { tableView.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)] } } - // Only reload if data actually changed if needsReload { tableView.reloadData() } - - // CRITICAL: Update version tracker AFTER reload check - // This ensures versionChanged is true when changeManager.reloadVersion increments - // (e.g., when clearChanges() is called after discarding or saving changes) + coordinator.lastReloadVersion = changeManager.reloadVersion // Sync selection let currentSelection = tableView.selectedRowIndexes let targetSelection = IndexSet(selectedRowIndices) - if currentSelection != targetSelection { tableView.selectRowIndexes(targetSelection, byExtendingSelection: false) } - - // Handle editingCell - start editing the specified cell + + // Handle editingCell if let cell = editingCell { - let tableColumn = cell.column + 1 // +1 to skip row number column + let tableColumn = cell.column + 1 if cell.row < tableView.numberOfRows && tableColumn < tableView.numberOfColumns { - // Scroll to the row first tableView.scrollRowToVisible(cell.row) - - // Select the row and start editing after a brief delay (allows scroll to complete) DispatchQueue.main.async { [weak tableView] in guard let tableView = tableView else { return } tableView.selectRowIndexes(IndexSet(integer: cell.row), byExtendingSelection: false) tableView.editColumn(tableColumn, row: cell.row, with: nil, select: true) } } - - // Clear the binding after handling DispatchQueue.main.async { self.editingCell = nil } @@ -278,7 +205,7 @@ struct DataGridView: NSViewRepresentable { } func makeCoordinator() -> TableViewCoordinator { - let coordinator = TableViewCoordinator( + TableViewCoordinator( rowProvider: rowProvider, changeManager: changeManager, isEditable: isEditable, @@ -287,11 +214,6 @@ struct DataGridView: NSViewRepresentable { onRefresh: onRefresh, onCellEdit: onCellEdit ) - - // onColumnResize callback will be set by coordinator property directly - // Coordinator will update columnWidths via binding when column is resized - - return coordinator } } @@ -307,38 +229,26 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var onCommit: ((String) -> Void)? var onRefresh: (() -> Void)? var onCellEdit: ((Int, Int, String?) -> Void)? + var onSort: ((Int, Bool) -> Void)? + var onAddRow: (() -> Void)? + var onUndoInsert: ((Int) -> Void)? + var onFilterColumn: ((String) -> Void)? weak var tableView: NSTableView? + var cellFactory: DataGridCellFactory? @Binding var selectedRowIndices: Set - // Track reload version to detect changes cleared var lastReloadVersion: Int = 0 - - // Cache column count and row count to avoid accessing potentially invalid provider private(set) var cachedRowCount: Int = 0 private(set) var cachedColumnCount: Int = 0 - - // Guard flag to prevent infinite loop when syncing sort descriptors - // Set to true when programmatically setting sortDescriptors from updateNSView var isSyncingSortDescriptors: Bool = false - // Cell reuse identifiers private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell") - private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCell") - - // MARK: - Row Visual State Cache - // Caches per-row visual state (deleted/inserted/modified) to avoid repeated changeManager lookups - // Rebuilt on each updateNSView cycle, read during cell rendering private var rowVisualStateCache: [Int: RowVisualState] = [:] - - /// Large dataset threshold - above this, disable expensive visual features private let largeDatasetThreshold = 5000 - /// Whether current dataset is "large" and should use simplified rendering - var isLargeDataset: Bool { - cachedRowCount > largeDatasetThreshold - } + var isLargeDataset: Bool { cachedRowCount > largeDatasetThreshold } init( rowProvider: InMemoryRowProvider, @@ -360,7 +270,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData updateCache() } - /// Update cached counts from current rowProvider func updateCache() { cachedRowCount = rowProvider.totalRowCount cachedColumnCount = rowProvider.columns.count @@ -368,16 +277,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // MARK: - Row Visual State Cache - /// Rebuild visual state cache from changeManager - /// Called once per updateNSView cycle - O(changes) not O(rows) func rebuildVisualStateCache() { rowVisualStateCache.removeAll(keepingCapacity: true) - - // Skip cache building for large datasets with no changes guard changeManager.hasChanges else { return } - // Build cache from changeManager's efficient O(1) lookups - // Only cache rows that have changes (sparse cache) for change in changeManager.changes { let rowIndex = change.rowIndex let isDeleted = change.type == .delete @@ -394,115 +297,67 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } - /// Get cached visual state for a row - O(1) dictionary lookup - /// Returns .empty for rows without changes (no cache entry) func visualState(for row: Int) -> RowVisualState { rowVisualStateCache[row] ?? .empty } - /// Clear visual state cache (called when data changes significantly) - func clearVisualStateCache() { - rowVisualStateCache.removeAll() - } - - /// Callback when column header clicked for sorting: (columnIndex, ascending) - var onSort: ((Int, Bool) -> Void)? - - /// Callback when user triggers add row (Cmd+N) - var onAddRow: (() -> Void)? - - /// Callback when user undoes row insertion - var onUndoInsert: ((Int) -> Void)? - - /// Callback when user selects "Filter with column" from header context menu - var onFilterColumn: ((String) -> Void)? - // MARK: - NSTableViewDataSource func numberOfRows(in tableView: NSTableView) -> Int { - // Use cached count for safety - updated when provider changes - return cachedRowCount + cachedRowCount } - - // MARK: - Native Sorting via NSTableViewDelegate - - /// Called by AppKit when user clicks column header to sort - /// This is the native NSTableView sorting mechanism + + // MARK: - Native Sorting + func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { - // CRITICAL: Ignore if we're programmatically syncing from SwiftUI - // This prevents infinite loop: updateNSView → sortDescriptorsDidChange → onSort → updateNSView guard !isSyncingSortDescriptors else { return } - - // Get the new primary sort descriptor + guard let sortDescriptor = tableView.sortDescriptors.first, let key = sortDescriptor.key, key.hasPrefix("col_"), - let columnIndex = Int(key.dropFirst(4)) else { + let columnIndex = Int(key.dropFirst(4)), + columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } - - // Validate column index - guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { - return - } - - // Call parent's sort handler with column index AND direction from AppKit - // This ensures parent uses the exact direction AppKit determined + onSort?(columnIndex, sortDescriptor.ascending) } - + // MARK: - NSMenuDelegate (Header Context Menu) - - /// Dynamically populate header context menu based on clicked column + func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() - + guard let tableView = tableView, let headerView = tableView.headerView, - let window = tableView.window else { - return - } - - // Get mouse location in header coordinates + let window = tableView.window else { return } + let mouseLocation = window.mouseLocationOutsideOfEventStream let pointInHeader = headerView.convert(mouseLocation, from: nil) let columnIndex = headerView.column(at: pointInHeader) - - guard columnIndex >= 0 && columnIndex < tableView.tableColumns.count else { - return - } - + + guard columnIndex >= 0 && columnIndex < tableView.tableColumns.count else { return } + let column = tableView.tableColumns[columnIndex] - - // Skip row number column - if column.identifier.rawValue == "__rowNumber__" { - return - } - - let copyItem = NSMenuItem( - title: "Copy Column Name", - action: #selector(copyColumnName(_:)), - keyEquivalent: "") + if column.identifier.rawValue == "__rowNumber__" { return } + + let copyItem = NSMenuItem(title: "Copy Column Name", action: #selector(copyColumnName(_:)), keyEquivalent: "") copyItem.representedObject = column.title copyItem.target = self menu.addItem(copyItem) - - // Add "Filter with column" menu item - let filterItem = NSMenuItem( - title: "Filter with column", - action: #selector(filterWithColumn(_:)), - keyEquivalent: "") + + let filterItem = NSMenuItem(title: "Filter with column", action: #selector(filterWithColumn(_:)), keyEquivalent: "") filterItem.representedObject = column.title filterItem.target = self menu.addItem(filterItem) } - + @objc private func copyColumnName(_ sender: NSMenuItem) { guard let columnName = sender.representedObject as? String else { return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(columnName, forType: .string) } - + @objc private func filterWithColumn(_ sender: NSMenuItem) { guard let columnName = sender.representedObject as? String else { return } onFilterColumn?(columnName) @@ -510,228 +365,52 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // MARK: - NSTableViewDelegate - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) - -> NSView? - { + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard let column = tableColumn else { return nil } let columnId = column.identifier.rawValue - // Row number column if columnId == "__rowNumber__" { - return makeRowNumberCell(tableView: tableView, row: row) - } - - // Data column - guard columnId.hasPrefix("col_"), - let columnIndex = Int(columnId.dropFirst(4)) - else { - return nil - } - - return makeDataCell(tableView: tableView, row: row, columnIndex: columnIndex) - } - - private func makeRowNumberCell(tableView: NSTableView, row: Int) -> NSView { - // PERF: Reuse cell views, configure once - let cellViewId = NSUserInterfaceItemIdentifier("RowNumberCellView") - let cellView: NSTableCellView - let cell: NSTextField - - if let reused = tableView.makeView(withIdentifier: cellViewId, owner: nil) - as? NSTableCellView, - let textField = reused.textField - { - cellView = reused - cell = textField - } else { - // PERF: Configure once - font, alignment, constraints - cellView = NSTableCellView() - cellView.identifier = cellViewId - - cell = NSTextField(labelWithString: "") - cell.alignment = .right - cell.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) - cell.textColor = .secondaryLabelColor - cell.translatesAutoresizingMaskIntoConstraints = false - - cellView.textField = cell - cellView.addSubview(cell) - - NSLayoutConstraint.activate([ - cell.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 4), - cell.trailingAnchor.constraint(equalTo: cellView.trailingAnchor, constant: -4), - cell.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), - ]) - } - - // Boundary check - guard row >= 0 && row < cachedRowCount else { - cell.stringValue = "" - return cellView - } - - // PERF: Update only text and color on reuse - cell.stringValue = "\(row + 1)" - - // PERF: Read from cached visual state instead of changeManager - let state = visualState(for: row) - cell.textColor = state.isDeleted ? .systemRed.withAlphaComponent(0.5) : .secondaryLabelColor - - return cellView - } - - private func makeDataCell(tableView: NSTableView, row: Int, columnIndex: Int) -> NSView { - // PERF: Reuse cells - only configure once, update text+background on reuse - let cellViewId = NSUserInterfaceItemIdentifier("DataCellView") - let cellView: NSTableCellView - let cell: NSTextField - let isNewCell: Bool - - if let reused = tableView.makeView(withIdentifier: cellViewId, owner: nil) - as? NSTableCellView, - let textField = reused.textField - { - cellView = reused - cell = textField - isNewCell = false - } else { - // PERF: Configure once - fonts, layers, constraints are set only on creation - cellView = NSTableCellView() - cellView.identifier = cellViewId - cellView.wantsLayer = true // Set once, never toggle - - cell = CellTextField() - cell.font = .monospacedSystemFont(ofSize: 13, weight: .regular) - cell.drawsBackground = false // Set once - background via layer - cell.isBordered = false - cell.focusRingType = .none - cell.lineBreakMode = .byTruncatingTail - cell.cell?.truncatesLastVisibleLine = true - cell.translatesAutoresizingMaskIntoConstraints = false - - cellView.textField = cell - cellView.addSubview(cell) - - // PERF: Constraints set once, never modified - NSLayoutConstraint.activate([ - cell.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 4), - cell.trailingAnchor.constraint(equalTo: cellView.trailingAnchor, constant: -4), - cell.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), - ]) - isNewCell = true + return cellFactory?.makeRowNumberCell( + tableView: tableView, + row: row, + cachedRowCount: cachedRowCount, + visualState: visualState(for: row) + ) } - // Set editable/delegate on reuse (cheap operations) - cell.isEditable = isEditable - cell.delegate = self - cell.identifier = cellIdentifier + guard columnId.hasPrefix("col_"), let columnIndex = Int(columnId.dropFirst(4)) else { return nil } - // Boundary check - return empty cell if out of bounds guard row >= 0 && row < cachedRowCount, columnIndex >= 0 && columnIndex < cachedColumnCount, - let rowData = rowProvider.row(at: row) - else { - cell.stringValue = "" - cell.placeholderString = nil - cell.textColor = .labelColor - cellView.layer?.backgroundColor = nil - cellView.layer?.borderWidth = 0 - return cellView + let rowData = rowProvider.row(at: row) else { + return nil } let value = rowData.value(at: columnIndex) - - // PERF: Read from cached visual state instead of changeManager - // visualState is computed once per updateNSView cycle, shared by all cells in row let state = visualState(for: row) - let isDeleted = state.isDeleted - let isInserted = state.isInserted - let isModified = state.modifiedColumns.contains(columnIndex) - - // PERF: Update text content (always needed on reuse) - cell.placeholderString = nil - - if value == nil { - cell.stringValue = "" - // PERF: For large datasets, skip placeholder styling - if !isLargeDataset { - cell.placeholderString = "NULL" - cell.textColor = .secondaryLabelColor - if isNewCell || cell.font?.fontDescriptor.symbolicTraits.contains(.italic) != true { - cell.font = .monospacedSystemFont(ofSize: 13, weight: .regular).withTraits(.italic) - } - } else { - cell.textColor = .secondaryLabelColor - } - } else if value == "__DEFAULT__" { - cell.stringValue = "" - if !isLargeDataset { - cell.placeholderString = "DEFAULT" - cell.textColor = .systemBlue - cell.font = .monospacedSystemFont(ofSize: 13, weight: .medium) - } else { - cell.textColor = .systemBlue - } - } else if value == "" { - cell.stringValue = "" - if !isLargeDataset { - cell.placeholderString = "Empty" - cell.textColor = .secondaryLabelColor - if isNewCell || cell.font?.fontDescriptor.symbolicTraits.contains(.italic) != true { - cell.font = .monospacedSystemFont(ofSize: 13, weight: .regular).withTraits(.italic) - } - } else { - cell.textColor = .secondaryLabelColor - } - } else { - cell.stringValue = value ?? "" - cell.textColor = .labelColor - // Only reset font if it was changed (avoid font allocation) - if cell.font?.fontDescriptor.symbolicTraits.contains(.italic) == true || - cell.font?.fontDescriptor.symbolicTraits.contains(.bold) == true { - cell.font = .monospacedSystemFont(ofSize: 13, weight: .regular) - } - } - // PERF: Update background color (priority: deleted > inserted > modified) - // For large datasets, skip modified cell highlighting - if isDeleted { - cellView.layer?.backgroundColor = NSColor.systemRed.withAlphaComponent(0.15).cgColor - } else if isInserted { - cellView.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.15).cgColor - } else if isModified && !isLargeDataset { - cellView.layer?.backgroundColor = NSColor.systemYellow.withAlphaComponent(0.3).cgColor - } else { - cellView.layer?.backgroundColor = nil - } - - // PERF: Focus ring - skip for large datasets - if isLargeDataset { - cellView.layer?.borderWidth = 0 - } else { - let tableColumnIndex = columnIndex + 1 - let isFocused: Bool = { - guard let keyTableView = tableView as? KeyHandlingTableView, - keyTableView.focusedRow == row, // Use focusedRow, not selectedRow - keyTableView.focusedColumn == tableColumnIndex - else { return false } - return true - }() - - if isFocused { - cellView.layer?.borderWidth = 2 - cellView.layer?.borderColor = NSColor.selectedControlColor.cgColor - } else { - cellView.layer?.borderWidth = 0 - } - } + let tableColumnIndex = columnIndex + 1 + let isFocused: Bool = { + guard let keyTableView = tableView as? KeyHandlingTableView, + keyTableView.focusedRow == row, + keyTableView.focusedColumn == tableColumnIndex else { return false } + return true + }() - return cellView + return cellFactory?.makeDataCell( + tableView: tableView, + row: row, + columnIndex: columnIndex, + value: value, + visualState: state, + isEditable: isEditable && !state.isDeleted, + isLargeDataset: isLargeDataset, + isFocused: isFocused, + delegate: self + ) } - // MARK: - Row View (for context menu) - func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { let rowView = TableRowViewWithMenu() rowView.coordinator = self @@ -750,8 +429,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData self.selectedRowIndices = newSelection } } - - // Clear focus if selection is empty + if let keyTableView = tableView as? KeyHandlingTableView { if newSelection.isEmpty { keyTableView.focusedRow = -1 @@ -762,43 +440,30 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // MARK: - Editing - func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) - -> Bool - { + func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { guard isEditable, - let columnId = tableColumn?.identifier.rawValue, - columnId != "__rowNumber__", - !changeManager.isRowDeleted(row) - else { - return false - } + let columnId = tableColumn?.identifier.rawValue, + columnId != "__rowNumber__", + !changeManager.isRowDeleted(row) else { return false } return true } func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool { - guard let textField = control as? NSTextField, - let tableView = tableView - else { - return true - } + guard let textField = control as? NSTextField, let tableView = tableView else { return true } let row = tableView.row(for: textField) let column = tableView.column(for: textField) - guard row >= 0, column > 0 else { return true } // column 0 is row number + guard row >= 0, column > 0 else { return true } - let columnIndex = column - 1 // Adjust for row number column - // Keep empty string as empty (not NULL) - use context menu "Set NULL" for NULL + let columnIndex = column - 1 let newValue: String? = textField.stringValue - // Get old value guard let rowData = rowProvider.row(at: row) else { return true } let oldValue = rowData.value(at: columnIndex) - // Skip if no change guard oldValue != newValue else { return true } - // Record change with entire row for WHERE clause PK lookup let columnName = rowProvider.columns[columnIndex] changeManager.recordCellChange( rowIndex: row, @@ -809,125 +474,93 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData originalRow: rowData.values ) - // Update local data rowProvider.updateValue(newValue, at: row, columnIndex: columnIndex) - - // Notify parent view to update tab.resultRows onCellEdit?(row, columnIndex, newValue) - // Reload the edited cell to show yellow background DispatchQueue.main.async { - tableView.reloadData( - forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: column)) + tableView.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: column)) } return true } - - /// Handle Tab/Shift+Tab navigation between cells during editing + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { guard let tableView = tableView else { return false } - + let currentRow = tableView.row(for: control) let currentColumn = tableView.column(for: control) - + guard currentRow >= 0, currentColumn >= 0 else { return false } - - // Tab key - move to next cell + if commandSelector == #selector(NSResponder.insertTab(_:)) { - // End current editing first (value will be saved by textShouldEndEditing) tableView.window?.makeFirstResponder(tableView) - - // Calculate next cell position + var nextColumn = currentColumn + 1 var nextRow = currentRow - - // Skip to next row if at end of columns + if nextColumn >= tableView.numberOfColumns { - nextColumn = 1 // Skip row number column (column 0) + nextColumn = 1 nextRow += 1 } - - // If at end of table, stay on last cell if nextRow >= tableView.numberOfRows { nextRow = tableView.numberOfRows - 1 nextColumn = tableView.numberOfColumns - 1 } - - // Start editing next cell after a brief delay + DispatchQueue.main.async { tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) tableView.editColumn(nextColumn, row: nextRow, with: nil, select: true) } - return true } - - // Shift+Tab - move to previous cell + if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - // End current editing first tableView.window?.makeFirstResponder(tableView) - - // Calculate previous cell position + var prevColumn = currentColumn - 1 var prevRow = currentRow - - // Skip to previous row if at start of columns - if prevColumn < 1 { // Column 0 is row number + + if prevColumn < 1 { prevColumn = tableView.numberOfColumns - 1 prevRow -= 1 } - - // If at start of table, stay on first cell if prevRow < 0 { prevRow = 0 prevColumn = 1 } - - // Start editing previous cell after a brief delay + DispatchQueue.main.async { tableView.selectRowIndexes(IndexSet(integer: prevRow), byExtendingSelection: false) tableView.editColumn(prevColumn, row: prevRow, with: nil, select: true) } - return true } - - // Return key - end editing, stay on row (don't move to next row) + if commandSelector == #selector(NSResponder.insertNewline(_:)) { tableView.window?.makeFirstResponder(tableView) return true } - - // Escape key - cancel editing + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { tableView.window?.makeFirstResponder(tableView) return true } - + return false } // MARK: - Row Actions - /// Delete a single row (used from context menu) - /// For multiple rows, use deleteRows(at:) directly for better performance func deleteRow(at index: Int) { - // Reuse batch logic for consistency deleteRows(at: [index]) } - - /// Delete multiple rows at once (batch operation for performance) - /// This is much faster than calling deleteRow(at:) in a loop because it: - /// - Processes all deletions before UI updates - /// - Triggers a single reloadData() call instead of N calls + func deleteRows(at indices: Set) { guard !indices.isEmpty else { return } - - // Separate inserted rows from existing rows + var insertedRowsToDelete: [Int] = [] var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = [] - + for rowIndex in indices { if changeManager.isRowInserted(rowIndex) { insertedRowsToDelete.append(rowIndex) @@ -936,86 +569,57 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData existingRowsToDelete.append((rowIndex: rowIndex, originalRow: rowData.values)) } } - - // Process inserted rows deletion (removes from data) + if !insertedRowsToDelete.isEmpty { - // Sort descending so removing higher indices first doesn't affect lower indices let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) - - // Remove from rowProvider and changeManager for rowIndex in sortedInsertedRows { rowProvider.removeRow(at: rowIndex) } - - // Batch update changeManager changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) - - // Update cached counts updateCache() } - - // Record batch deletion for existing rows (single undo action) + if !existingRowsToDelete.isEmpty { changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) } - - // Calculate new selection based on deleted rows + let minSelectedRow = indices.min() ?? 0 let maxSelectedRow = indices.max() ?? 0 let totalRows = cachedRowCount let insertedRowsDeletedCount = insertedRowsToDelete.count - - // Adjust for inserted rows that were removed (they shift indices) + let adjustedMaxRow = maxSelectedRow - insertedRowsDeletedCount let adjustedMinRow = minSelectedRow - insertedRowsToDelete.filter { $0 < minSelectedRow }.count - + var newSelection = Set() if adjustedMaxRow + 1 < totalRows { - // Select row after the deleted range newSelection.insert(min(adjustedMaxRow + 1, totalRows - 1)) } else if adjustedMinRow > 0 { - // Deleted rows at end, select previous row newSelection.insert(adjustedMinRow - 1) } else if totalRows > 0 { - // Select first row if available newSelection.insert(0) } - - // Update selection + self.selectedRowIndices = newSelection - - // Single reload for all changes - massive performance improvement! tableView?.reloadData() } - func undoDeleteRow(at index: Int) { changeManager.undoRowDeletion(rowIndex: index) tableView?.reloadData( forRowIndexes: IndexSet(integer: index), columnIndexes: IndexSet(integersIn: 0..<(tableView?.numberOfColumns ?? 0))) } - - /// Trigger adding a new row (calls parent's onAddRow callback) + func addNewRow() { onAddRow?() } - - /// Undo a row insertion (remove from data and change tracking) + func undoInsertRow(at index: Int) { - // Notify parent to remove from resultRows FIRST (before indices change) onUndoInsert?(index) - - // Remove from change manager changeManager.undoRowInsertion(rowIndex: index) - - // Remove from row provider rowProvider.removeRow(at: index) - - // Update cached counts updateCache() - - // Reload entire table since row indices shifted tableView?.reloadData() } @@ -1034,49 +638,38 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData NSPasteboard.general.setString(text, forType: .string) } - /// Set a cell value (for Set NULL / Set Empty actions - legacy, uses selected column) func setCellValue(_ value: String?, at rowIndex: Int) { guard let tableView = tableView else { return } - - // Get selected column (default to first data column) var columnIndex = max(0, tableView.selectedColumn - 1) if columnIndex < 0 { columnIndex = 0 } - setCellValueAtColumn(value, at: rowIndex, columnIndex: columnIndex) } - /// Set a cell value at specific column func setCellValueAtColumn(_ value: String?, at rowIndex: Int, columnIndex: Int) { guard let tableView = tableView else { return } guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } let columnName = rowProvider.columns[columnIndex] let oldValue = rowProvider.row(at: rowIndex)?.value(at: columnIndex) - - // Get the full original row for WHERE clause generation let originalRow = rowProvider.row(at: rowIndex)?.values - // Record the change WITH original row for proper WHERE clause changeManager.recordCellChange( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, newValue: value, - originalRow: originalRow // CRITICAL: Pass original row to avoid WHERE 1=1 + originalRow: originalRow ) - // Update local data rowProvider.updateValue(value, at: rowIndex, columnIndex: columnIndex) - // Reload only the specific cell that was changed (columnIndex + 1 for row number column) let tableColumnIndex = columnIndex + 1 tableView.reloadData( forRowIndexes: IndexSet(integer: rowIndex), columnIndexes: IndexSet(integer: tableColumnIndex)) } - /// Copy cell value to clipboard func copyCellValue(at rowIndex: Int, columnIndex: Int) { guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } @@ -1088,257 +681,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } -// MARK: - Custom Row View with Context Menu - -final class TableRowViewWithMenu: NSTableRowView { - weak var coordinator: TableViewCoordinator? - var rowIndex: Int = 0 - - override func menu(for event: NSEvent) -> NSMenu? { - guard let coordinator = coordinator, - let tableView = coordinator.tableView - else { return nil } - - // Determine which column was clicked - let locationInRow = convert(event.locationInWindow, from: nil) - let locationInTable = tableView.convert(locationInRow, from: self) - let clickedColumn = tableView.column(at: locationInTable) - - // Adjust for row number column (index 0) - let dataColumnIndex = clickedColumn > 0 ? clickedColumn - 1 : -1 - - let menu = NSMenu() - - if coordinator.changeManager.isRowDeleted(rowIndex) { - menu.addItem( - withTitle: "Undo Delete", action: #selector(undoDeleteRow), keyEquivalent: "" - ).target = self - } - - // Normal row menu (or additional items for inserted rows) - if !coordinator.changeManager.isRowDeleted(rowIndex) { - // Edit actions (if editable) - if coordinator.isEditable && dataColumnIndex >= 0 { - let setValueMenu = NSMenu() - - let emptyItem = NSMenuItem( - title: "Empty", action: #selector(setEmptyValue(_:)), keyEquivalent: "") - emptyItem.representedObject = dataColumnIndex - emptyItem.target = self - setValueMenu.addItem(emptyItem) - - let nullItem = NSMenuItem( - title: "NULL", action: #selector(setNullValue(_:)), keyEquivalent: "") - nullItem.representedObject = dataColumnIndex - nullItem.target = self - setValueMenu.addItem(nullItem) - - let defaultItem = NSMenuItem( - title: "Default", action: #selector(setDefaultValue(_:)), keyEquivalent: "") - defaultItem.representedObject = dataColumnIndex - defaultItem.target = self - setValueMenu.addItem(defaultItem) - - let setValueItem = NSMenuItem(title: "Set Value", action: nil, keyEquivalent: "") - setValueItem.submenu = setValueMenu - menu.addItem(setValueItem) - - menu.addItem(NSMenuItem.separator()) - } - - // Copy actions - if dataColumnIndex >= 0 { - let copyCellItem = NSMenuItem( - title: "Copy Cell Value", action: #selector(copyCellValue(_:)), - keyEquivalent: "") - copyCellItem.representedObject = dataColumnIndex - copyCellItem.target = self - menu.addItem(copyCellItem) - } - - let copyItem = NSMenuItem( - title: "Copy", action: #selector(copySelectedOrCurrentRow), keyEquivalent: "c") - copyItem.keyEquivalentModifierMask = .command - copyItem.target = self - menu.addItem(copyItem) - - if coordinator.isEditable { - menu.addItem(NSMenuItem.separator()) - - let duplicateItem = NSMenuItem( - title: "Duplicate", action: #selector(duplicateRow), keyEquivalent: "d") - duplicateItem.keyEquivalentModifierMask = .command - duplicateItem.target = self - menu.addItem(duplicateItem) - - let deleteItem = NSMenuItem( - title: "Delete", action: #selector(deleteRow), keyEquivalent: String(Character(UnicodeScalar(NSBackspaceCharacter)!))) - deleteItem.keyEquivalentModifierMask = [] - deleteItem.target = self - menu.addItem(deleteItem) - } - } - - return menu - } - - @objc private func deleteRow() { - coordinator?.deleteRow(at: rowIndex) - } - - @objc private func duplicateRow() { - // Post notification to duplicate the selected row - NotificationCenter.default.post(name: .duplicateRow, object: nil) - } - - @objc private func undoDeleteRow() { - coordinator?.undoDeleteRow(at: rowIndex) - } - - @objc private func undoInsertRow() { - coordinator?.undoInsertRow(at: rowIndex) - } - - @objc private func copyRow() { - coordinator?.copyRows(at: [rowIndex]) - } - - @objc private func copySelectedRows() { - guard let selectedIndices = coordinator?.selectedRowIndices else { return } - coordinator?.copyRows(at: selectedIndices) - } - - @objc private func copySelectedOrCurrentRow() { - guard let coordinator = coordinator else { return } - // If rows are selected, copy all selected; otherwise copy current row - if !coordinator.selectedRowIndices.isEmpty { - coordinator.copyRows(at: coordinator.selectedRowIndices) - } else { - coordinator.copyRows(at: [rowIndex]) - } - } - - @objc private func copyCellValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.copyCellValue(at: rowIndex, columnIndex: columnIndex) - } - - @objc private func setNullValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.setCellValueAtColumn(nil, at: rowIndex, columnIndex: columnIndex) - } - - @objc private func setEmptyValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.setCellValueAtColumn("", at: rowIndex, columnIndex: columnIndex) - } - - @objc private func setDefaultValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.setCellValueAtColumn("__DEFAULT__", at: rowIndex, columnIndex: columnIndex) - } - - // Column resize tracking removed - too complex for current implementation -} - -// MARK: - Custom TextField that delegates context menu to row view - -/// NSTextField subclass that shows row context menu instead of text editing menu -/// This ensures our custom menu (Undo Insert, Set Value, etc.) works even when editing -final class CellTextField: NSTextField { - - /// Override to provide our custom cell that handles context menu - override class var cellClass: AnyClass? { - get { CellTextFieldCell.self } - set { } - } - - /// Override right mouse down to end editing and show row context menu - /// The field editor (NSTextView) normally handles right-click during editing, - /// so we intercept here before it gets to the field editor - override func rightMouseDown(with event: NSEvent) { - // End editing first - window?.makeFirstResponder(nil) - - // Find the row view and show its menu - var view: NSView? = self - while let parent = view?.superview { - if let rowView = parent as? TableRowViewWithMenu { - if let menu = rowView.menu(for: event) { - NSMenu.popUpContextMenu(menu, with: event, for: self) - } - return - } - view = parent - } - } - - override func menu(for event: NSEvent) -> NSMenu? { - // End editing first so the menu shows correctly - window?.makeFirstResponder(nil) - - // Find the row view and delegate to it - var view: NSView? = self - while let parent = view?.superview { - if let rowView = parent as? TableRowViewWithMenu { - return rowView.menu(for: event) - } - view = parent - } - - // Fallback to no menu (don't show system text editing menu) - return nil - } -} - -/// Custom text field cell that provides a field editor with custom context menu behavior -final class CellTextFieldCell: NSTextFieldCell { - - /// Custom field editor that forwards right-click to parent text field - private class CellFieldEditor: NSTextView { - - override func rightMouseDown(with event: NSEvent) { - // End editing and find parent CellTextField - window?.makeFirstResponder(nil) - - // Find the CellTextField and let it handle the menu - var view: NSView? = self - while let parent = view?.superview { - if let cellTextField = parent as? CellTextField { - cellTextField.rightMouseDown(with: event) - return - } - view = parent - } - } - - override func menu(for event: NSEvent) -> NSMenu? { - // Don't show system text editing menu - return nil - } - } - - /// Lazy field editor instance - private var customFieldEditor: CellFieldEditor? - - override func fieldEditor(for controlView: NSView) -> NSTextView? { - if customFieldEditor == nil { - customFieldEditor = CellFieldEditor() - customFieldEditor?.isFieldEditor = true - } - return customFieldEditor - } -} - -// MARK: - NSFont Extension - -extension NSFont { - func withTraits(_ traits: NSFontDescriptor.SymbolicTraits) -> NSFont { - let descriptor = fontDescriptor.withSymbolicTraits(traits) - return NSFont(descriptor: descriptor, size: pointSize) ?? self - } -} - // MARK: - Preview #Preview { @@ -1359,348 +701,3 @@ extension NSFont { ) .frame(width: 600, height: 400) } - -// MARK: - Custom TableView with Key Handling - -/// NSTableView subclass that handles Delete key to mark rows for deletion -/// Also implements TablePlus-style cell focus on click -final class KeyHandlingTableView: NSTableView, NSMenuItemValidation { - weak var coordinator: TableViewCoordinator? - - /// Currently focused row index (-1 = no focus) - /// Tracked separately from selectedRow to avoid async timing bugs - var focusedRow: Int = -1 { - didSet { - if oldValue != focusedRow && oldValue >= 0 { - // Clear focus border from old row - if focusedColumn >= 0 && focusedColumn < numberOfColumns && oldValue < numberOfRows { - reloadData(forRowIndexes: IndexSet(integer: oldValue), - columnIndexes: IndexSet(integer: focusedColumn)) - } - } - } - } - - /// Currently focused column index (-1 = no focus, 0 = row number column) - var focusedColumn: Int = -1 { - didSet { - if oldValue != focusedColumn { - // Capture current focusedRow to avoid async timing bug - let rowToUpdate = focusedRow - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - // Clear old column's border using captured row - if oldValue >= 0 && oldValue < self.numberOfColumns && rowToUpdate >= 0 && rowToUpdate < self.numberOfRows { - self.reloadData(forRowIndexes: IndexSet(integer: rowToUpdate), - columnIndexes: IndexSet(integer: oldValue)) - } - // Draw new column's border using current focusedRow - if self.focusedColumn >= 0 && self.focusedColumn < self.numberOfColumns && self.focusedRow >= 0 && self.focusedRow < self.numberOfRows { - self.reloadData(forRowIndexes: IndexSet(integer: self.focusedRow), - columnIndexes: IndexSet(integer: self.focusedColumn)) - } - } - } - } - } - - /// Anchor row for Shift+Arrow range selection (-1 = no anchor) - var selectionAnchor: Int = -1 - - /// Current pivot row for Shift+Arrow navigation (where the user is navigating to) - var selectionPivot: Int = -1 - - // MARK: - TablePlus-Style Cell Focus - - override func mouseDown(with event: NSEvent) { - // Capture clicked location before super changes selection - let point = convert(event.locationInWindow, from: nil) - let clickedRow = row(at: point) - let clickedColumn = column(at: point) - - // Double-click in empty area (no row) adds a new row (TablePlus behavior) - if event.clickCount == 2 && clickedRow == -1 && coordinator?.isEditable == true { - NotificationCenter.default.post(name: .addNewRow, object: nil) - return - } - - // Reset anchor/pivot when clicking without Shift (starting new selection) - if clickedRow >= 0 && !event.modifierFlags.contains(.shift) { - selectionAnchor = clickedRow - selectionPivot = clickedRow - } - - // Let super handle row selection - super.mouseDown(with: event) - - // After selection, focus the specific cell (TablePlus behavior) - // This makes Enter key edit that cell, not column 0 - guard clickedRow >= 0, - clickedColumn >= 0, - clickedColumn < numberOfColumns, - selectedRowIndexes.contains(clickedRow) else { - return - } - - // Skip row number column (not editable) - let column = tableColumns[clickedColumn] - if column.identifier.rawValue == "__rowNumber__" { - focusedRow = -1 - focusedColumn = -1 - return - } - - // Track focused row and column for keyboard navigation - focusedRow = clickedRow - focusedColumn = clickedColumn - - // Focus the cell without opening editor (select: false) - // This is the native AppKit way to set cell focus - // When user presses Enter, this cell will be edited - editColumn(clickedColumn, row: clickedRow, with: nil, select: false) - } - - // MARK: - Standard Edit Menu Actions - - /// Respond to Edit > Delete menu item - @objc func delete(_ sender: Any?) { - guard coordinator?.isEditable == true else { return } - let selectedIndices = Set(selectedRowIndexes.map { $0 }) - guard !selectedIndices.isEmpty else { return } - - // Batch delete all selected rows (single UI reload) - coordinator?.deleteRows(at: selectedIndices) - } - - /// Enable/disable Edit menu items based on state - func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - if menuItem.action == #selector(delete(_:)) { - // Enable Delete when rows are selected and table is editable - return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty - } - // For other items, check if we can respond to them - if let action = menuItem.action { - return responds(to: action) - } - return false - } - - // MARK: - Keyboard Handling - - /// Override to catch Delete/Backspace before menu items can intercept - override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Delete (keyCode 51) or Forward Delete (keyCode 117) - if event.keyCode == 51 || event.keyCode == 117 { - let selectedIndices = Set(selectedRowIndexes.map { $0 }) - if !selectedIndices.isEmpty && coordinator?.isEditable == true { - // Batch delete all selected rows (single UI reload) - coordinator?.deleteRows(at: selectedIndices) - return true // We handled it - } - } - return super.performKeyEquivalent(with: event) - } - - override func keyDown(with event: NSEvent) { - // Note: Cmd+N is captured by app menu (New Connection) - // Use File > Add Row (Cmd+I) for adding rows - - let row = selectedRow - let isShiftHeld = event.modifierFlags.contains(.shift) - - switch event.keyCode { - case 126: // Up arrow - move to previous row (Shift extends selection) - handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) - return - - case 125: // Down arrow - move to next row (Shift extends selection) - handleDownArrow(currentRow: row, isShiftHeld: isShiftHeld) - return - - case 123: // Left arrow - move to previous column - if focusedColumn > 1 { // Skip row number column (index 0) - focusedColumn -= 1 - if row >= 0 { - scrollColumnToVisible(focusedColumn) - } - } else if focusedColumn == -1 && numberOfColumns > 1 { - // No focus yet, start at last column - focusedColumn = numberOfColumns - 1 - if row >= 0 { - scrollColumnToVisible(focusedColumn) - } - } - return - - case 124: // Right arrow - move to next column - if focusedColumn >= 1 && focusedColumn < numberOfColumns - 1 { - focusedColumn += 1 - if row >= 0 { - scrollColumnToVisible(focusedColumn) - } - } else if focusedColumn == -1 && numberOfColumns > 1 { - // No focus yet, start at first data column - focusedColumn = 1 - if row >= 0 { - scrollColumnToVisible(focusedColumn) - } - } - return - - case 36: // Enter/Return - edit focused cell - if row >= 0 && focusedColumn >= 1 && coordinator?.isEditable == true { - editColumn(focusedColumn, row: row, with: nil, select: true) - } - return - - case 53: // Escape - clear focus and selection - focusedRow = -1 - focusedColumn = -1 - NotificationCenter.default.post(name: .clearSelection, object: nil) - return - - case 51, 117: // Delete or Backspace key - // Post notification to trigger batched deletion in MainContentView - // This enables undoing all deletions at once - if !selectedRowIndexes.isEmpty { - NotificationCenter.default.post(name: .deleteSelectedRows, object: nil) - return - } - - case 48: // Tab - move to next cell - if row >= 0 && focusedColumn >= 1 { - var nextColumn = focusedColumn + 1 - var nextRow = row - - if nextColumn >= numberOfColumns { - nextColumn = 1 // Skip row number column - nextRow += 1 - } - if nextRow >= numberOfRows { - nextRow = numberOfRows - 1 - nextColumn = numberOfColumns - 1 - } - - selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - focusedRow = nextRow // Update focusedRow when moving to next cell - focusedColumn = nextColumn - scrollRowToVisible(nextRow) - scrollColumnToVisible(nextColumn) - } - return - - default: - break - } - - super.keyDown(with: event) - } - - // MARK: - Arrow Key Selection Helpers - - /// Handle Up arrow key with optional Shift for range selection - private func handleUpArrow(currentRow: Int, isShiftHeld: Bool) { - guard numberOfRows > 0 else { return } - - if currentRow == -1 { - // No selection, select last row - let targetRow = numberOfRows - 1 - selectionAnchor = targetRow - selectionPivot = targetRow - focusedRow = targetRow // Track focused row - selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) - scrollRowToVisible(targetRow) - return - } - - if isShiftHeld { - // Shift+Up: extend/shrink selection - if selectionAnchor == -1 { - selectionAnchor = currentRow - selectionPivot = currentRow - } - - // Use pivot for navigation, not selectedRow - let currentPivot = selectionPivot >= 0 ? selectionPivot : currentRow - let targetRow = max(0, currentPivot - 1) - selectionPivot = targetRow - - // Select range from anchor to pivot - let startRow = min(selectionAnchor, selectionPivot) - let endRow = max(selectionAnchor, selectionPivot) - let range = IndexSet(integersIn: startRow...endRow) - selectRowIndexes(range, byExtendingSelection: false) - scrollRowToVisible(targetRow) - } else { - // Normal Up: move to previous row, single selection - let targetRow = max(0, currentRow - 1) - selectionAnchor = targetRow - selectionPivot = targetRow - focusedRow = targetRow // Track focused row - selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) - scrollRowToVisible(targetRow) - } - } - - /// Handle Down arrow key with optional Shift for range selection - private func handleDownArrow(currentRow: Int, isShiftHeld: Bool) { - guard numberOfRows > 0 else { return } - - if currentRow == -1 { - // No selection, select first row - selectionAnchor = 0 - selectionPivot = 0 - focusedRow = 0 // Track focused row - selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) - scrollRowToVisible(0) - return - } - - if isShiftHeld { - // Shift+Down: extend/shrink selection - if selectionAnchor == -1 { - selectionAnchor = currentRow - selectionPivot = currentRow - } - - // Use pivot for navigation, not selectedRow - let currentPivot = selectionPivot >= 0 ? selectionPivot : currentRow - let targetRow = min(numberOfRows - 1, currentPivot + 1) - selectionPivot = targetRow - - // Select range from anchor to pivot - let startRow = min(selectionAnchor, selectionPivot) - let endRow = max(selectionAnchor, selectionPivot) - let range = IndexSet(integersIn: startRow...endRow) - selectRowIndexes(range, byExtendingSelection: false) - scrollRowToVisible(targetRow) - } else { - // Normal Down: move to next row, single selection - let targetRow = min(numberOfRows - 1, currentRow + 1) - selectionAnchor = targetRow - selectionPivot = targetRow - focusedRow = targetRow // Track focused row - selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) - scrollRowToVisible(targetRow) - } - } - - override func menu(for event: NSEvent) -> NSMenu? { - let point = convert(event.locationInWindow, from: nil) - let clickedRow = row(at: point) - - // If clicked on a valid row, get its row view's menu - if clickedRow >= 0, - let rowView = rowView(atRow: clickedRow, makeIfNecessary: false) - as? TableRowViewWithMenu - { - // Select the row if not already selected - if !selectedRowIndexes.contains(clickedRow) { - selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) - } - return rowView.menu(for: event) - } - - return super.menu(for: event) - } -} diff --git a/OpenTable/Views/Results/FilterPanelView.swift b/OpenTable/Views/Results/FilterPanelView.swift deleted file mode 100644 index a006a96b..00000000 --- a/OpenTable/Views/Results/FilterPanelView.swift +++ /dev/null @@ -1,758 +0,0 @@ -// -// FilterPanelView.swift -// OpenTable -// -// Filter panel UI for table data filtering -// - -import SwiftUI - -/// Bottom filter panel for table data filtering -struct FilterPanelView: View { - @ObservedObject var filterState: FilterStateManager - let columns: [String] - let primaryKeyColumn: String? - let databaseType: DatabaseType - let onApply: ([TableFilter]) -> Void - let onUnset: () -> Void - let onQuickSearch: ((String) -> Void)? // New callback for Quick Search - - @State private var showSQLSheet = false - @State private var showSettingsPopover = false - @State private var generatedSQL = "" - @State private var showSavePresetAlert = false - @State private var newPresetName = "" - @State private var savedPresets: [FilterPreset] = [] - - var body: some View { - VStack(spacing: 0) { - // Header with title and action buttons - filterHeader - - Divider() - .foregroundStyle(Color(nsColor: .separatorColor)) - - // Quick Search field (when no filters or alongside filters) - if filterState.hasActiveQuickSearch || filterState.filters.isEmpty { - quickSearchField - Divider() - .foregroundStyle(Color(nsColor: .separatorColor)) - } - - // Filter rows - if filterState.filters.isEmpty { - if !filterState.hasActiveQuickSearch { - emptyState - } - } else { - filterList - } - - Divider() - .foregroundStyle(Color(nsColor: .separatorColor)) - - // Footer with Apply All, Unset, SQL buttons - filterFooter - } - .background(Color(nsColor: .windowBackgroundColor)) - .sheet(isPresented: $showSQLSheet) { - SQLPreviewSheet(sql: generatedSQL, tableName: "", databaseType: databaseType) - } - } - - // MARK: - Header - - private var filterHeader: some View { - HStack(spacing: 8) { - Text("Filters") - .font(.system(size: 12, weight: .medium)) - - if filterState.hasAppliedFilters { - Text("(\(filterState.appliedFilters.count) active)") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - - Spacer() - - // AND/OR Logic Toggle - Picker("", selection: $filterState.filterLogicMode) { - Text("AND").tag(FilterLogicMode.and) - Text("OR").tag(FilterLogicMode.or) - } - .pickerStyle(.segmented) - .frame(width: 80) - .help("Match ALL filters (AND) or ANY filter (OR)") - - // Presets Menu - Menu { - // Load preset section - if !savedPresets.isEmpty { - ForEach(savedPresets) { preset in - Button(preset.name) { - filterState.loadPreset(preset) - } - } - Divider() - } - - // Save current filters - Button("Save as Preset...") { - newPresetName = "" - showSavePresetAlert = true - } - .disabled(filterState.filters.isEmpty) - - // Delete presets - if !savedPresets.isEmpty { - Menu("Delete Preset") { - ForEach(savedPresets) { preset in - Button(preset.name, role: .destructive) { - filterState.deletePreset(preset) - loadPresets() - } - } - } - } - } label: { - Image(systemName: "folder") - .font(.system(size: 12)) - } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - .help("Save and load filter presets") - .onAppear { - loadPresets() - } - - // Settings button (gear icon) - Button(action: { showSettingsPopover.toggle() }) { - Image(systemName: "gearshape") - .font(.system(size: 12)) - } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - .help("Filter Settings") - .popover(isPresented: $showSettingsPopover, arrowEdge: .bottom) { - FilterSettingsPopover() - } - - // Add filter button - Button(action: { - filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) - }) { - Image(systemName: "plus") - .font(.system(size: 12)) - } - .buttonStyle(.borderless) - .foregroundColor(.accentColor) - .help("Add Filter (Cmd+Shift+F)") - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color(nsColor: .controlBackgroundColor)) - .contentShape(Rectangle()) - .onTapGesture { filterState.focusedFilterId = nil } - .alert("Save Filter Preset", isPresented: $showSavePresetAlert) { - TextField("Preset Name", text: $newPresetName) - Button("Cancel", role: .cancel) {} - Button("Save") { - if !newPresetName.isEmpty { - filterState.saveAsPreset(name: newPresetName) - loadPresets() - } - } - } message: { - Text("Enter a name for this filter preset") - } - } - - // MARK: - Quick Search - - private var quickSearchField: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - - TextField("Quick search across all columns...", text: $filterState.quickSearchText) - .textFieldStyle(.plain) - .font(.system(size: 12)) - .onSubmit { - // Apply quick search on Enter - if !filterState.quickSearchText.isEmpty { - onQuickSearch?(filterState.quickSearchText) - } - } - - if filterState.hasActiveQuickSearch { - Button(action: { filterState.clearQuickSearch() }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - .help("Clear Search") - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(nsColor: .textBackgroundColor)) - } - - // MARK: - Empty State - - private var emptyState: some View { - VStack(spacing: 12) { - Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 32)) - .foregroundStyle(.tertiary) - - Text("No filters active") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - Button("Add Filter") { - filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) - } - .buttonStyle(.bordered) - .controlSize(.small) - - Text("or use Quick Search above") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 24) - } - - // MARK: - Filter List - - private var filterList: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 2) { - ForEach(filterState.filters) { filter in - FilterRowView( - filter: filterState.binding(for: filter), - columns: columns, - isFocused: filterState.focusedFilterId == filter.id, - onDuplicate: { filterState.duplicateFilter(filter) }, - onRemove: { filterState.removeFilter(filter) }, - onApply: { applySingleFilter(filter) }, - onFocus: { filterState.focusedFilterId = filter.id } - ) - .id(filter.id) // Make each row identifiable for scrollTo - } - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - // Dynamic height: ~40pt per row, max 4 rows visible before scrolling - .frame(maxHeight: min(CGFloat(filterState.filters.count) * 40 + 8, 160)) - .onChange(of: filterState.focusedFilterId) { _, newFocusedId in - // Auto-scroll to the focused filter (newly added or explicitly focused) - if let focusedId = newFocusedId { - withAnimation(.easeInOut(duration: 0.25)) { - proxy.scrollTo(focusedId, anchor: .bottom) - } - } - } - } - } - - // MARK: - Footer - - private var filterFooter: some View { - HStack(spacing: 8) { - // Select all checkbox - Toggle("Select All", isOn: selectAllBinding) - .toggleStyle(.checkbox) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .disabled(filterState.filters.isEmpty) - - Spacer() - - // Unset button - Button("Unset") { - filterState.clearAppliedFilters() - onUnset() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!filterState.hasAppliedFilters) - - // SQL button - now uses extracted method - Button("SQL") { - generatedSQL = filterState.generatePreviewSQL(databaseType: databaseType) - showSQLSheet = true - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(filterState.filters.isEmpty) - - // Apply All button (for selected filters) - Button("Apply All") { - applySelectedFilters() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(!filterState.hasSelectedFilters) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(Rectangle()) - .onTapGesture { filterState.focusedFilterId = nil } - } - - // MARK: - Helpers - - private var selectAllBinding: Binding { - Binding( - get: { filterState.allFiltersSelected }, - set: { filterState.selectAll($0) } - ) - } - - private func applySingleFilter(_ filter: TableFilter) { - guard filter.isValid else { return } - filterState.applySingleFilter(filter) - onApply([filter]) - } - - private func applySelectedFilters() { - filterState.applySelectedFilters() - onApply(filterState.appliedFilters) - } - - private func loadPresets() { - savedPresets = filterState.loadAllPresets() - } -} - -// MARK: - Filter Row View - -/// Single filter row view with native macOS styling -struct FilterRowView: View { - @Binding var filter: TableFilter - let columns: [String] - let isFocused: Bool - let onDuplicate: () -> Void - let onRemove: () -> Void - let onApply: () -> Void - let onFocus: () -> Void - - @State private var isHovered: Bool = false - - - /// Display name for the column (handles raw SQL and empty) - private var displayColumnName: String { - if filter.columnName == TableFilter.rawSQLColumn { - return "Raw SQL" - } else if filter.columnName.isEmpty { - return "Column" - } else { - return filter.columnName - } - } - - /// Dynamic background color based on state - private var backgroundFillColor: Color { - if isFocused { - return Color.accentColor.opacity(0.06) - } else if isHovered { - return Color(nsColor: .controlBackgroundColor) - } else { - return Color.clear - } - } - - /// Dynamic border color based on state - private var borderColor: Color { - if isFocused { - return Color.accentColor.opacity(0.3) - } else if isHovered { - return Color(nsColor: .separatorColor).opacity(0.5) - } else { - return Color.clear - } - } - - var body: some View { - HStack(spacing: 8) { - // Checkbox for multi-select - Toggle("", isOn: $filter.isSelected) - .toggleStyle(.checkbox) - .labelsHidden() - - // Column dropdown - native Menu style - columnMenu - .frame(width: 120) - - // Operator dropdown (hidden for raw SQL) - if !filter.isRawSQL { - operatorMenu - .frame(width: 110) - } - - // Value field(s) - valueFields - - Spacer(minLength: 0) - - // Action buttons - actionButtons - } - .padding(.vertical, 6) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(backgroundFillColor) - ) - .overlay( - RoundedRectangle(cornerRadius: 4) - .strokeBorder(borderColor, lineWidth: 1) - ) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.15)) { - isHovered = hovering - } - } - .animation(.easeInOut(duration: 0.2), value: isFocused) - } - - // MARK: - Column Menu - - private var columnMenu: some View { - Menu { - Button(action: { filter.columnName = TableFilter.rawSQLColumn }) { - if filter.columnName == TableFilter.rawSQLColumn { - Label("Raw SQL", systemImage: "checkmark") - } else { - Text("Raw SQL") - } - } - - if !columns.isEmpty { - Divider() - ForEach(columns, id: \.self) { column in - Button(action: { filter.columnName = column }) { - if filter.columnName == column { - Label(column, systemImage: "checkmark") - } else { - Text(column) - } - } - } - } - } label: { - HStack(spacing: 4) { - Text(displayColumnName) - .font(.system(size: 12)) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 8)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - } - .menuStyle(.borderlessButton) - .simultaneousGesture(TapGesture().onEnded { onFocus() }) - } - - // MARK: - Operator Menu - - private var operatorMenu: some View { - Menu { - ForEach(FilterOperator.allCases) { op in - Button(action: { filter.filterOperator = op }) { - if filter.filterOperator == op { - Label(op.displayName, systemImage: "checkmark") - } else { - Text(op.displayName) - } - } - } - } label: { - HStack(spacing: 4) { - Text(filter.filterOperator.displayName) - .font(.system(size: 12)) - .lineLimit(1) - Spacer(minLength: 0) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 8)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - } - .menuStyle(.borderlessButton) - .simultaneousGesture(TapGesture().onEnded { onFocus() }) - } - - // MARK: - Value Fields - - @ViewBuilder - private var valueFields: some View { - if filter.isRawSQL { - // Raw SQL input - TextField("WHERE clause...", text: Binding( - get: { filter.rawSQL ?? "" }, - set: { filter.rawSQL = $0 } - )) - .textFieldStyle(.plain) - .font(.system(size: 12)) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Color(nsColor: .textBackgroundColor)) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .onSubmit { onApply() } - .simultaneousGesture(TapGesture().onEnded { onFocus() }) - } else if filter.filterOperator.requiresValue { - // Standard value input - TextField("Value", text: $filter.value) - .textFieldStyle(.plain) - .font(.system(size: 12)) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Color(nsColor: .textBackgroundColor)) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .frame(minWidth: 80) - .onSubmit { onApply() } - .simultaneousGesture(TapGesture().onEnded { onFocus() }) - - // Second value for BETWEEN - if filter.filterOperator.requiresSecondValue { - Text("and") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - - TextField("Value", text: Binding( - get: { filter.secondValue ?? "" }, - set: { filter.secondValue = $0 } - )) - .textFieldStyle(.plain) - .font(.system(size: 12)) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Color(nsColor: .textBackgroundColor)) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .frame(minWidth: 80) - .onSubmit { onApply() } - .simultaneousGesture(TapGesture().onEnded { onFocus() }) - } - } else { - // No value needed (IS NULL, etc.) - show indicator - Text("—") - .font(.system(size: 12)) - .foregroundStyle(.tertiary) - .frame(minWidth: 80, alignment: .leading) - } - } - - // MARK: - Action Buttons - - private var actionButtons: some View { - HStack(spacing: 8) { - // Apply single filter - Button(action: onApply) { - Image(systemName: "play.fill") - .font(.system(size: 11)) - } - .buttonStyle(.borderless) - .foregroundStyle(filter.isValid ? Color(nsColor: .systemGreen) : Color.secondary) - .disabled(!filter.isValid) - .help("Apply This Filter") - - // Duplicate - Button(action: onDuplicate) { - Image(systemName: "doc.on.doc") - .font(.system(size: 11)) - } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - .help("Duplicate Filter") - - // Remove - Button(action: onRemove) { - Image(systemName: "xmark") - .font(.system(size: 11)) - } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - .help("Remove Filter") - } - } -} - -// MARK: - SQL Preview Sheet - -/// Modal sheet to display generated SQL -struct SQLPreviewSheet: View { - let sql: String - let tableName: String - let databaseType: DatabaseType - @Environment(\.dismiss) private var dismiss - @State private var copied = false - - var body: some View { - VStack(spacing: 16) { - HStack { - Text("Generated WHERE Clause") - .font(.system(size: 13, weight: .semibold)) - Spacer() - Button(action: { dismiss() }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) - .foregroundStyle(.tertiary) - } - .buttonStyle(.borderless) - } - - ScrollView { - Text(sql.isEmpty ? "(no conditions)" : sql) - .font(.system(size: 12, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - } - .frame(maxHeight: 180) - .background(Color(nsColor: .textBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - - HStack { - Button(action: copyToClipboard) { - HStack(spacing: 4) { - Image(systemName: copied ? "checkmark" : "doc.on.doc") - .font(.system(size: 11)) - Text(copied ? "Copied!" : "Copy") - .font(.system(size: 12)) - } - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(sql.isEmpty) - - Spacer() - - Button("Close") { - dismiss() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .keyboardShortcut(.escape) - } - } - .padding(16) - .frame(width: 480, height: 300) - } - - private func copyToClipboard() { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(sql, forType: .string) - copied = true - - // Reset after delay - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - copied = false - } - } -} - -// MARK: - Filter Settings Popover - -/// Popover for filter default settings -struct FilterSettingsPopover: View { - @State private var settings: FilterSettings - - init() { - _settings = State(initialValue: FilterSettingsStorage.shared.loadSettings()) - } - - var body: some View { - Form { - Picker("Default Column", selection: $settings.defaultColumn) { - ForEach(FilterDefaultColumn.allCases) { option in - Text(option.displayName).tag(option) - } - } - - Picker("Default Operator", selection: $settings.defaultOperator) { - ForEach(FilterDefaultOperator.allCases) { option in - Text(option.displayName).tag(option) - } - } - - Picker("Panel State", selection: $settings.panelState) { - ForEach(FilterPanelDefaultState.allCases) { option in - Text(option.displayName).tag(option) - } - } - } - .formStyle(.grouped) - .frame(width: 280) - .onChange(of: settings) { _, newValue in - FilterSettingsStorage.shared.saveSettings(newValue) - } - } -} - -// MARK: - Preview - -#Preview("Filter Panel") { - FilterPanelView( - filterState: { - let state = FilterStateManager() - Task { @MainActor in - state.filters = [ - TableFilter(columnName: "name", filterOperator: .contains, value: "John"), - TableFilter(columnName: "age", filterOperator: .greaterThan, value: "18") - ] - } - return state - }(), - columns: ["id", "name", "age", "email"], - primaryKeyColumn: "id", - databaseType: .mysql, - onApply: { _ in }, - onUnset: { }, - onQuickSearch: { _ in } - ) - .frame(width: 600) -} diff --git a/OpenTable/Views/Results/KeyHandlingTableView.swift b/OpenTable/Views/Results/KeyHandlingTableView.swift new file mode 100644 index 00000000..7deb194e --- /dev/null +++ b/OpenTable/Views/Results/KeyHandlingTableView.swift @@ -0,0 +1,297 @@ +// +// KeyHandlingTableView.swift +// OpenTable +// +// NSTableView subclass that handles Delete key and TablePlus-style cell focus. +// Extracted from DataGridView for better maintainability. +// + +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 { + weak var coordinator: TableViewCoordinator? + + /// Currently focused row index (-1 = no focus) + var focusedRow: Int = -1 { + didSet { + if oldValue != focusedRow && oldValue >= 0 { + if focusedColumn >= 0 && focusedColumn < numberOfColumns && oldValue < numberOfRows { + reloadData(forRowIndexes: IndexSet(integer: oldValue), + columnIndexes: IndexSet(integer: focusedColumn)) + } + } + } + } + + /// Currently focused column index (-1 = no focus, 0 = row number column) + var focusedColumn: Int = -1 { + didSet { + if oldValue != focusedColumn { + let rowToUpdate = focusedRow + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if oldValue >= 0 && oldValue < self.numberOfColumns && rowToUpdate >= 0 && rowToUpdate < self.numberOfRows { + self.reloadData(forRowIndexes: IndexSet(integer: rowToUpdate), + columnIndexes: IndexSet(integer: oldValue)) + } + if self.focusedColumn >= 0 && self.focusedColumn < self.numberOfColumns && self.focusedRow >= 0 && self.focusedRow < self.numberOfRows { + self.reloadData(forRowIndexes: IndexSet(integer: self.focusedRow), + columnIndexes: IndexSet(integer: self.focusedColumn)) + } + } + } + } + } + + /// Anchor row for Shift+Arrow range selection (-1 = no anchor) + var selectionAnchor: Int = -1 + + /// Current pivot row for Shift+Arrow navigation + var selectionPivot: Int = -1 + + // MARK: - TablePlus-Style Cell Focus + + override func mouseDown(with event: NSEvent) { + 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) + return + } + + // Reset anchor/pivot when clicking without Shift + if clickedRow >= 0 && !event.modifierFlags.contains(.shift) { + selectionAnchor = clickedRow + selectionPivot = clickedRow + } + + super.mouseDown(with: event) + + guard clickedRow >= 0, + clickedColumn >= 0, + clickedColumn < numberOfColumns, + selectedRowIndexes.contains(clickedRow) else { + return + } + + let column = tableColumns[clickedColumn] + if column.identifier.rawValue == "__rowNumber__" { + focusedRow = -1 + focusedColumn = -1 + return + } + + focusedRow = clickedRow + focusedColumn = clickedColumn + editColumn(clickedColumn, row: clickedRow, with: nil, select: false) + } + + // MARK: - Standard Edit Menu Actions + + @objc func delete(_ sender: Any?) { + guard coordinator?.isEditable == true else { return } + let selectedIndices = Set(selectedRowIndexes.map { $0 }) + guard !selectedIndices.isEmpty else { return } + coordinator?.deleteRows(at: selectedIndices) + } + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + if menuItem.action == #selector(delete(_:)) { + return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty + } + if let action = menuItem.action { + return responds(to: action) + } + return false + } + + // MARK: - Keyboard Handling + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.keyCode == 51 || event.keyCode == 117 { + let selectedIndices = Set(selectedRowIndexes.map { $0 }) + if !selectedIndices.isEmpty && coordinator?.isEditable == true { + coordinator?.deleteRows(at: selectedIndices) + return true + } + } + return super.performKeyEquivalent(with: event) + } + + override func keyDown(with event: NSEvent) { + let row = selectedRow + let isShiftHeld = event.modifierFlags.contains(.shift) + + switch event.keyCode { + case 126: // Up arrow + handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) + return + + case 125: // Down arrow + 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) + } + return + + case 53: // Escape + focusedRow = -1 + focusedColumn = -1 + NotificationCenter.default.post(name: .clearSelection, object: nil) + 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) + } + + // MARK: - Arrow Key Selection Helpers + + private func handleUpArrow(currentRow: Int, isShiftHeld: Bool) { + guard numberOfRows > 0 else { return } + + if currentRow == -1 { + let targetRow = numberOfRows - 1 + selectionAnchor = targetRow + selectionPivot = targetRow + focusedRow = targetRow + selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) + scrollRowToVisible(targetRow) + return + } + + if isShiftHeld { + if selectionAnchor == -1 { + selectionAnchor = currentRow + selectionPivot = currentRow + } + + let currentPivot = selectionPivot >= 0 ? selectionPivot : currentRow + let targetRow = max(0, currentPivot - 1) + selectionPivot = targetRow + + let startRow = min(selectionAnchor, selectionPivot) + let endRow = max(selectionAnchor, selectionPivot) + let range = IndexSet(integersIn: startRow...endRow) + selectRowIndexes(range, byExtendingSelection: false) + scrollRowToVisible(targetRow) + } else { + let targetRow = max(0, currentRow - 1) + selectionAnchor = targetRow + selectionPivot = targetRow + focusedRow = targetRow + selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) + scrollRowToVisible(targetRow) + } + } + + private func handleDownArrow(currentRow: Int, isShiftHeld: Bool) { + guard numberOfRows > 0 else { return } + + if currentRow == -1 { + selectionAnchor = 0 + selectionPivot = 0 + focusedRow = 0 + selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + scrollRowToVisible(0) + return + } + + if isShiftHeld { + if selectionAnchor == -1 { + selectionAnchor = currentRow + selectionPivot = currentRow + } + + let currentPivot = selectionPivot >= 0 ? selectionPivot : currentRow + let targetRow = min(numberOfRows - 1, currentPivot + 1) + selectionPivot = targetRow + + let startRow = min(selectionAnchor, selectionPivot) + let endRow = max(selectionAnchor, selectionPivot) + let range = IndexSet(integersIn: startRow...endRow) + selectRowIndexes(range, byExtendingSelection: false) + scrollRowToVisible(targetRow) + } else { + let targetRow = min(numberOfRows - 1, currentRow + 1) + selectionAnchor = targetRow + selectionPivot = targetRow + focusedRow = targetRow + selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) + scrollRowToVisible(targetRow) + } + } + + override func menu(for event: NSEvent) -> NSMenu? { + let point = convert(event.locationInWindow, from: nil) + let clickedRow = row(at: point) + + if clickedRow >= 0, + let rowView = rowView(atRow: clickedRow, makeIfNecessary: false) as? TableRowViewWithMenu { + if !selectedRowIndexes.contains(clickedRow) { + selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) + } + return rowView.menu(for: event) + } + + return super.menu(for: event) + } +} diff --git a/OpenTable/Views/Results/TableRowViewWithMenu.swift b/OpenTable/Views/Results/TableRowViewWithMenu.swift new file mode 100644 index 00000000..d2286785 --- /dev/null +++ b/OpenTable/Views/Results/TableRowViewWithMenu.swift @@ -0,0 +1,156 @@ +// +// TableRowViewWithMenu.swift +// OpenTable +// +// Custom row view with context menu support. +// Extracted from DataGridView for better maintainability. +// + +import AppKit + +/// Custom row view that provides context menu for row operations +final class TableRowViewWithMenu: NSTableRowView { + weak var coordinator: TableViewCoordinator? + var rowIndex: Int = 0 + + override func menu(for event: NSEvent) -> NSMenu? { + guard let coordinator = coordinator, + let tableView = coordinator.tableView else { return nil } + + // Determine which column was clicked + let locationInRow = convert(event.locationInWindow, from: nil) + let locationInTable = tableView.convert(locationInRow, from: self) + let clickedColumn = tableView.column(at: locationInTable) + + // Adjust for row number column (index 0) + let dataColumnIndex = clickedColumn > 0 ? clickedColumn - 1 : -1 + + let menu = NSMenu() + + if coordinator.changeManager.isRowDeleted(rowIndex) { + menu.addItem( + withTitle: "Undo Delete", action: #selector(undoDeleteRow), keyEquivalent: "" + ).target = self + } + + // Normal row menu (or additional items for inserted rows) + if !coordinator.changeManager.isRowDeleted(rowIndex) { + // Edit actions (if editable) + if coordinator.isEditable && dataColumnIndex >= 0 { + let setValueMenu = NSMenu() + + let emptyItem = NSMenuItem( + title: "Empty", action: #selector(setEmptyValue(_:)), keyEquivalent: "") + emptyItem.representedObject = dataColumnIndex + emptyItem.target = self + setValueMenu.addItem(emptyItem) + + let nullItem = NSMenuItem( + title: "NULL", action: #selector(setNullValue(_:)), keyEquivalent: "") + nullItem.representedObject = dataColumnIndex + nullItem.target = self + setValueMenu.addItem(nullItem) + + let defaultItem = NSMenuItem( + title: "Default", action: #selector(setDefaultValue(_:)), keyEquivalent: "") + defaultItem.representedObject = dataColumnIndex + defaultItem.target = self + setValueMenu.addItem(defaultItem) + + let setValueItem = NSMenuItem(title: "Set Value", action: nil, keyEquivalent: "") + setValueItem.submenu = setValueMenu + menu.addItem(setValueItem) + + menu.addItem(NSMenuItem.separator()) + } + + // Copy actions + if dataColumnIndex >= 0 { + let copyCellItem = NSMenuItem( + title: "Copy Cell Value", action: #selector(copyCellValue(_:)), + keyEquivalent: "") + copyCellItem.representedObject = dataColumnIndex + copyCellItem.target = self + menu.addItem(copyCellItem) + } + + let copyItem = NSMenuItem( + title: "Copy", action: #selector(copySelectedOrCurrentRow), keyEquivalent: "c") + copyItem.keyEquivalentModifierMask = .command + copyItem.target = self + menu.addItem(copyItem) + + if coordinator.isEditable { + menu.addItem(NSMenuItem.separator()) + + let duplicateItem = NSMenuItem( + title: "Duplicate", action: #selector(duplicateRow), keyEquivalent: "d") + duplicateItem.keyEquivalentModifierMask = .command + duplicateItem.target = self + menu.addItem(duplicateItem) + + let deleteItem = NSMenuItem( + title: "Delete", action: #selector(deleteRow), keyEquivalent: String(Character(UnicodeScalar(NSBackspaceCharacter)!))) + deleteItem.keyEquivalentModifierMask = [] + deleteItem.target = self + menu.addItem(deleteItem) + } + } + + return menu + } + + @objc private func deleteRow() { + coordinator?.deleteRow(at: rowIndex) + } + + @objc private func duplicateRow() { + NotificationCenter.default.post(name: .duplicateRow, object: nil) + } + + @objc private func undoDeleteRow() { + coordinator?.undoDeleteRow(at: rowIndex) + } + + @objc private func undoInsertRow() { + coordinator?.undoInsertRow(at: rowIndex) + } + + @objc private func copyRow() { + coordinator?.copyRows(at: [rowIndex]) + } + + @objc private func copySelectedRows() { + guard let selectedIndices = coordinator?.selectedRowIndices else { return } + coordinator?.copyRows(at: selectedIndices) + } + + @objc private func copySelectedOrCurrentRow() { + guard let coordinator = coordinator else { return } + if !coordinator.selectedRowIndices.isEmpty { + coordinator.copyRows(at: coordinator.selectedRowIndices) + } else { + coordinator.copyRows(at: [rowIndex]) + } + } + + @objc private func copyCellValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + coordinator?.copyCellValue(at: rowIndex, columnIndex: columnIndex) + } + + @objc private func setNullValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + coordinator?.setCellValueAtColumn(nil, at: rowIndex, columnIndex: columnIndex) + } + + @objc private func setEmptyValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + coordinator?.setCellValueAtColumn("", at: rowIndex, columnIndex: columnIndex) + } + + @objc private func setDefaultValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + coordinator?.setCellValueAtColumn("__DEFAULT__", at: rowIndex, columnIndex: columnIndex) + } +}