@@ -66,6 +66,8 @@ enum UndoAction {
6666 case rowDeletion( rowIndex: Int , originalRow: [ String ? ] )
6767 /// Batch deletion of multiple rows (for undo as a single action)
6868 case batchRowDeletion( rows: [ ( rowIndex: Int , originalRow: [ String ? ] ) ] )
69+ /// Batch insertion undo - when user deletes multiple inserted rows at once
70+ case batchRowInsertion( rowIndices: [ Int ] , rowValues: [ [ String ? ] ] )
6971}
7072
7173@MainActor
@@ -101,6 +103,11 @@ final class DataChangeManager: ObservableObject {
101103 /// Set of "rowIndex-colIndex" strings for modified cells - O(1) lookup
102104 private var modifiedCells : Set < String > = [ ]
103105
106+ /// Lazy storage for inserted row values - avoids creating CellChange objects until needed
107+ /// Maps rowIndex -> column values array for newly inserted rows
108+ /// This dramatically improves add row performance for tables with many columns
109+ private var insertedRowData : [ Int : [ String ? ] ] = [ : ]
110+
104111 /// Undo stack for reversing changes (LIFO)
105112 private var undoStack : [ UndoAction ] = [ ]
106113
@@ -326,6 +333,12 @@ final class DataChangeManager: ObservableObject {
326333
327334 /// Undo a pending row deletion
328335 func undoRowDeletion( rowIndex: Int ) {
336+ // SAFETY: Only process if this row is actually marked as deleted
337+ guard deletedRowIndices. contains ( rowIndex) else {
338+ print ( " ⚠️ undoRowDeletion called for row \( rowIndex) but it's not in deletedRowIndices " )
339+ return
340+ }
341+
329342 changes. removeAll { $0. rowIndex == rowIndex && $0. type == . delete }
330343 deletedRowIndices. remove ( rowIndex)
331344 hasChanges = !changes. isEmpty
@@ -334,11 +347,17 @@ final class DataChangeManager: ObservableObject {
334347
335348 /// Undo a pending row insertion
336349 func undoRowInsertion( rowIndex: Int ) {
350+ // SAFETY: Only process if this row is actually marked as inserted
351+ guard insertedRowIndices. contains ( rowIndex) else {
352+ print ( " ⚠️ undoRowInsertion: row \( rowIndex) not in insertedRowIndices " )
353+ return
354+ }
355+
356+ // Remove the INSERT change from the changes array
337357 changes. removeAll { $0. rowIndex == rowIndex && $0. type == . insert }
338358 insertedRowIndices. remove ( rowIndex)
339359
340360 // Shift down indices for rows after the removed row
341- // This is necessary because when a row is removed, all subsequent rows shift down
342361 var shiftedInsertedIndices = Set < Int > ( )
343362 for idx in insertedRowIndices {
344363 if idx > rowIndex {
@@ -349,7 +368,7 @@ final class DataChangeManager: ObservableObject {
349368 }
350369 insertedRowIndices = shiftedInsertedIndices
351370
352- // Also update row indices in changes array
371+ // Also update row indices in changes array for all changes after this row
353372 for i in 0 ..< changes. count {
354373 if changes [ i] . rowIndex > rowIndex {
355374 changes [ i] . rowIndex -= 1
@@ -358,6 +377,65 @@ final class DataChangeManager: ObservableObject {
358377
359378 hasChanges = !changes. isEmpty
360379 }
380+
381+ /// Undo multiple row insertions at once (for batch deletion)
382+ /// This is more efficient than calling undoRowInsertion multiple times
383+ /// - Parameter rowIndices: Array of row indices to undo, MUST be sorted in descending order
384+ func undoBatchRowInsertion( rowIndices: [ Int ] ) {
385+ guard !rowIndices. isEmpty else { return }
386+
387+ // Verify all rows are inserted
388+ let validRows = rowIndices. filter { insertedRowIndices. contains ( $0) }
389+
390+ if validRows. count != rowIndices. count {
391+ let invalidRows = Set ( rowIndices) . subtracting ( validRows)
392+ print ( " ⚠️ undoBatchRowInsertion: rows \( invalidRows) not in insertedRowIndices " )
393+ }
394+
395+ guard !validRows. isEmpty else { return }
396+
397+ // Collect row values BEFORE removing changes (for undo/redo)
398+ var rowValues : [ [ String ? ] ] = [ ]
399+ for rowIndex in validRows {
400+ if let insertChange = changes. first ( where: { $0. rowIndex == rowIndex && $0. type == . insert } ) {
401+ let values = insertChange. cellChanges. sorted { $0. columnIndex < $1. columnIndex }
402+ . map { $0. newValue }
403+ rowValues. append ( values)
404+ } else {
405+ rowValues. append ( Array ( repeating: nil , count: columns. count) )
406+ }
407+ }
408+
409+ // Remove all INSERT changes for these rows
410+ for rowIndex in validRows {
411+ changes. removeAll { $0. rowIndex == rowIndex && $0. type == . insert }
412+ insertedRowIndices. remove ( rowIndex)
413+ }
414+
415+ // Push undo action so user can undo this deletion
416+ pushUndo ( . batchRowInsertion( rowIndices: validRows, rowValues: rowValues) )
417+
418+ // Shift indices for all remaining rows
419+ for deletedIndex in validRows. reversed ( ) {
420+ var shiftedIndices = Set < Int > ( )
421+ for idx in insertedRowIndices {
422+ if idx > deletedIndex {
423+ shiftedIndices. insert ( idx - 1 )
424+ } else {
425+ shiftedIndices. insert ( idx)
426+ }
427+ }
428+ insertedRowIndices = shiftedIndices
429+
430+ for i in 0 ..< changes. count {
431+ if changes [ i] . rowIndex > deletedIndex {
432+ changes [ i] . rowIndex -= 1
433+ }
434+ }
435+ }
436+
437+ hasChanges = !changes. isEmpty
438+ }
361439
362440 // MARK: - Undo Stack Management
363441
@@ -462,6 +540,33 @@ final class DataChangeManager: ObservableObject {
462540 undoRowDeletion ( rowIndex: rowIndex)
463541 }
464542 return ( action, false , true , nil )
543+
544+ case . batchRowInsertion( let rowIndices, let rowValues) :
545+ // Undo the deletion of inserted rows - restore them as INSERT changes
546+ // Process in reverse order (ascending) to maintain correct indices when re-inserting
547+ for (index, rowIndex) in rowIndices. enumerated ( ) . reversed ( ) {
548+ guard index < rowValues. count else { continue }
549+ let values = rowValues [ index]
550+
551+ // Re-create INSERT change
552+ let cellChanges = values. enumerated ( ) . map { colIndex, value in
553+ CellChange (
554+ rowIndex: rowIndex,
555+ columnIndex: colIndex,
556+ columnName: columns [ safe: colIndex] ?? " " ,
557+ oldValue: nil ,
558+ newValue: value
559+ )
560+ }
561+ let rowChange = RowChange ( rowIndex: rowIndex, type: . insert, cellChanges: cellChanges)
562+ changes. append ( rowChange)
563+ insertedRowIndices. insert ( rowIndex)
564+ }
565+
566+ hasChanges = !changes. isEmpty
567+ reloadVersion += 1
568+ // Return true for needsRowInsert so MainContentView knows to restore to resultRows
569+ return ( action, true , false , nil )
465570 }
466571 }
467572
@@ -484,8 +589,26 @@ final class DataChangeManager: ObservableObject {
484589 return ( action, false , false )
485590
486591 case . rowInsertion( let rowIndex) :
487- // Re-apply the row insertion - mark as inserted
592+ // Re-apply the row insertion - we need to restore the full INSERT change
593+ // Note: We don't have the original cell values in the UndoAction,
594+ // so we need the caller (MainContentView) to provide them when re-inserting the row
595+ // For now, just mark as inserted and let the caller handle cell values
488596 insertedRowIndices. insert ( rowIndex)
597+
598+ // Create empty INSERT change - caller should update with actual values
599+ // The row should already exist in resultRows from the redo handler in MainContentView
600+ let cellChanges = columns. enumerated ( ) . map { index, columnName in
601+ CellChange (
602+ rowIndex: rowIndex,
603+ columnIndex: index,
604+ columnName: columnName,
605+ oldValue: nil ,
606+ newValue: nil // Will be updated by caller
607+ )
608+ }
609+ let rowChange = RowChange ( rowIndex: rowIndex, type: . insert, cellChanges: cellChanges)
610+ changes. append ( rowChange)
611+
489612 hasChanges = true
490613 reloadVersion += 1
491614 return ( action, true , false )
@@ -505,6 +628,20 @@ final class DataChangeManager: ObservableObject {
505628 _ = undoStack. popLast ( )
506629 }
507630 return ( action, false , true )
631+
632+ case . batchRowInsertion( let rowIndices, _) :
633+ // Redo the deletion of inserted rows - remove them again
634+ // This is called when user: delete inserted rows -> undo -> redo
635+ // We need to remove the rows from changes and insertedRowIndices again
636+ for rowIndex in rowIndices {
637+ changes. removeAll { $0. rowIndex == rowIndex && $0. type == . insert }
638+ insertedRowIndices. remove ( rowIndex)
639+ }
640+ hasChanges = !changes. isEmpty
641+ reloadVersion += 1
642+ // Return true for needsRowInsert to signal MainContentView to remove from resultRows
643+ // (We repurpose this flag since the logic is similar - rows need to be removed)
644+ return ( action, true , false )
508645 }
509646 }
510647
@@ -520,10 +657,20 @@ final class DataChangeManager: ObservableObject {
520657 statements. append ( sql)
521658 }
522659 case . insert:
660+ // SAFETY: Verify the row is still marked as inserted
661+ guard insertedRowIndices. contains ( change. rowIndex) else {
662+ print ( " ⚠️ Skipping INSERT for row \( change. rowIndex) - not in insertedRowIndices " )
663+ continue
664+ }
523665 if let sql = generateInsertSQL ( for: change) {
524666 statements. append ( sql)
525667 }
526668 case . delete:
669+ // SAFETY: Verify the row is still marked as deleted
670+ guard deletedRowIndices. contains ( change. rowIndex) else {
671+ print ( " ⚠️ Skipping DELETE for row \( change. rowIndex) - not in deletedRowIndices " )
672+ continue
673+ }
527674 if let sql = generateDeleteSQL ( for: change) {
528675 statements. append ( sql)
529676 }
0 commit comments