From 83b91a7a90a05aa3da877221d54cba20cbe589b2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 16 Mar 2026 19:53:28 +0700 Subject: [PATCH] fix: unify sidebar and data grid save pipelines for all database drivers --- .../ChangeTracking/DataChangeManager.swift | 18 ++- .../MainContentCoordinator+SidebarSave.swift | 125 +++--------------- .../Main/MainContentCommandActions.swift | 14 +- TablePro/Views/Results/CellTextField.swift | 15 +++ 4 files changed, 58 insertions(+), 114 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 86a6ac8df..eb695df34 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -621,7 +621,23 @@ final class DataChangeManager { // MARK: - SQL Generation func generateSQL() throws -> [ParameterizedStatement] { - // Try plugin dispatch first (handles MongoDB, Redis, and future NoSQL plugins) + try generateSQL( + for: changes, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + + /// Unified statement generation for both data grid and sidebar edits. + /// Routes through plugin driver for NoSQL databases, falls back to SQLStatementGenerator for SQL. + func generateSQL( + for changes: [RowChange], + insertedRowData: [Int: [String?]] = [:], + deletedRowIndices: Set = [], + insertedRowIndices: Set = [] + ) throws -> [ParameterizedStatement] { + // Try plugin dispatch first (handles MongoDB, Redis, etcd, and future NoSQL plugins) if let pluginDriver { let pluginChanges = changes.map { change -> PluginRowChange in PluginRowChange( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index 978ddab87..00fca9c8d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -6,7 +6,6 @@ // import Foundation -import TableProPluginKit extension MainContentCoordinator { // MARK: - Sidebar Save @@ -17,7 +16,7 @@ extension MainContentCoordinator { ) async throws { guard let tab = tabManager.selectedTab, !selectedRowIndices.isEmpty, - let tableName = tab.tableName + tab.tableName != nil else { return } @@ -25,35 +24,14 @@ extension MainContentCoordinator { let editedFields = editState.getEditedFields() guard !editedFields.isEmpty else { return } - if connection.type == .redis { - var redisStatements: [ParameterizedStatement] = [] - for rowIndex in selectedRowIndices.sorted() { - guard rowIndex < tab.resultRows.count else { continue } - let row = tab.resultRows[rowIndex] - let commands = generateSidebarRedisCommands( - originalRow: row.values, - editedFields: editedFields, - columns: tab.resultColumns - ) - redisStatements += commands.map { ParameterizedStatement(sql: $0, parameters: []) } - } - guard !redisStatements.isEmpty else { return } - try await executeSidebarChanges(statements: redisStatements) - } else { - let generator = SQLStatementGenerator( - tableName: tableName, - columns: tab.resultColumns, - primaryKeyColumn: changeManager.primaryKeyColumn, - databaseType: connection.type, - quoteIdentifier: changeManager.pluginDriver?.quoteIdentifier - ) - - var statements: [ParameterizedStatement] = [] - for rowIndex in selectedRowIndices.sorted() { - guard rowIndex < tab.resultRows.count else { continue } - let originalRow = tab.resultRows[rowIndex].values - - let cellChanges = editedFields.map { field in + // Build RowChange array from sidebar edits + let changes: [RowChange] = selectedRowIndices.sorted().compactMap { rowIndex in + guard rowIndex < tab.resultRows.count else { return nil } + let originalRow = tab.resultRows[rowIndex].values + return RowChange( + rowIndex: rowIndex, + type: .update, + cellChanges: editedFields.map { field in CellChange( rowIndex: rowIndex, columnIndex: field.columnIndex, @@ -61,85 +39,16 @@ extension MainContentCoordinator { oldValue: originalRow[field.columnIndex], newValue: field.newValue ) - } - let change = RowChange( - rowIndex: rowIndex, - type: .update, - cellChanges: cellChanges, - originalRow: originalRow - ) - - if let stmt = generator.generateUpdateSQL(for: change) { - statements.append(stmt) - } - } - guard !statements.isEmpty else { return } - try await executeSidebarChanges(statements: statements) - } - - runQuery() - } - - private func generateSidebarRedisCommands( - originalRow: [String?], - editedFields: [(columnIndex: Int, columnName: String, newValue: String?)], - columns: [String] - ) -> [String] { - guard let keyIndex = columns.firstIndex(of: "Key"), - keyIndex < originalRow.count, - let originalKey = originalRow[keyIndex] - else { - return [] - } - - var commands: [String] = [] - var effectiveKey = originalKey - - for field in editedFields { - switch field.columnName { - case "Key": - if let newKey = field.newValue, newKey != originalKey { - commands.append("RENAME \(redisEscape(originalKey)) \(redisEscape(newKey))") - effectiveKey = newKey - } - case "Value": - if let newValue = field.newValue { - // Only use SET for string-type keys — other types need specific commands - let typeIndex = columns.firstIndex(of: "Type") - let keyType = typeIndex.flatMap { - $0 < originalRow.count ? originalRow[$0]?.uppercased() : nil - } - if keyType == nil || keyType == "STRING" || keyType == "NONE" { - commands.append("SET \(redisEscape(effectiveKey)) \(redisEscape(newValue))") - } - // Non-string types: skip (editing Value for complex types not supported via sidebar) - } - case "TTL": - if let ttlStr = field.newValue, let ttl = Int(ttlStr), ttl >= 0 { - commands.append("EXPIRE \(redisEscape(effectiveKey)) \(ttl)") - } else if field.newValue == nil || field.newValue == "-1" { - commands.append("PERSIST \(redisEscape(effectiveKey))") - } - default: - break - } + }, + originalRow: originalRow + ) } - return commands - } + // Route through the unified statement generation pipeline + let statements = try changeManager.generateSQL(for: changes) + guard !statements.isEmpty else { return } + try await executeSidebarChanges(statements: statements) - private func redisEscape(_ value: String) -> String { - let needsQuoting = - value.isEmpty || value.contains(where: { $0.isWhitespace || $0 == "\"" || $0 == "'" }) - if needsQuoting { - let escaped = - value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - return "\"\(escaped)\"" - } - return value + runQuery() } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index af9c38b41..e37fa14ef 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -417,11 +417,12 @@ final class MainContentCommandActions { // Check if we're in structure view mode if coordinator?.tabManager.selectedTab?.showStructure == true { coordinator?.structureActions?.saveChanges?() - } else if rightPanelState.editState.hasEdits { - // Save sidebar edits if the right panel has pending changes - rightPanelState.onSave?() - } else { - // Handle data grid changes + } else if coordinator?.changeManager.hasChanges == true + || !pendingTruncates.wrappedValue.isEmpty + || !pendingDeletes.wrappedValue.isEmpty { + // Handle data grid changes (prioritize over sidebar edits since + // data grid edits are synced to sidebar editState, and the data grid + // path uses the correct plugin driver for statement generation) var truncates = pendingTruncates.wrappedValue var deletes = pendingDeletes.wrappedValue var options = tableOperationOptions.wrappedValue @@ -433,6 +434,9 @@ final class MainContentCommandActions { pendingTruncates.wrappedValue = truncates pendingDeletes.wrappedValue = deletes tableOperationOptions.wrappedValue = options + } else if rightPanelState.editState.hasEdits { + // Save sidebar-only edits (edits made directly in the right panel) + rightPanelState.onSave?() } } diff --git a/TablePro/Views/Results/CellTextField.swift b/TablePro/Views/Results/CellTextField.swift index d742a0e0d..89499a04a 100644 --- a/TablePro/Views/Results/CellTextField.swift +++ b/TablePro/Views/Results/CellTextField.swift @@ -78,6 +78,21 @@ final class CellTextField: NSTextField { /// Custom text field cell that provides a field editor with custom context menu behavior final class CellTextFieldCell: NSTextFieldCell { private class CellFieldEditor: NSTextView { + /// Key equivalents that should commit the edit and bubble up to the menu bar. + private static let menuKeyEquivalents: Set = ["s"] + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.modifierFlags.contains(.command), + let chars = event.charactersIgnoringModifiers, + Self.menuKeyEquivalents.contains(chars) { + // Commit the inline edit so the change is recorded in DataChangeManager + // before the menu action (e.g. Cmd+S save) fires. + window?.makeFirstResponder(nil) + return false + } + return super.performKeyEquivalent(with: event) + } + override func rightMouseDown(with event: NSEvent) { window?.makeFirstResponder(nil)