Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> = [],
insertedRowIndices: Set<Int> = []
) 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(
Expand Down
125 changes: 17 additions & 108 deletions TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//

import Foundation
import TableProPluginKit

extension MainContentCoordinator {
// MARK: - Sidebar Save
Expand All @@ -17,129 +16,39 @@ extension MainContentCoordinator {
) async throws {
guard let tab = tabManager.selectedTab,
!selectedRowIndices.isEmpty,
let tableName = tab.tableName
tab.tableName != nil
else {
return
}

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,
columnName: field.columnName,
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()
}
}
14 changes: 9 additions & 5 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?()
}
}

Expand Down
15 changes: 15 additions & 0 deletions TablePro/Views/Results/CellTextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = ["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)

Expand Down
Loading