Skip to content

Commit 151a673

Browse files
authored
Merge pull request #4 from datlechin/feat/history
Fix data grid undo/redo and multi-row deletion bugs
2 parents 8fe9ed9 + 312a6c2 commit 151a673

5 files changed

Lines changed: 268 additions & 50 deletions

File tree

OpenTable.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@
253253
AUTOMATION_APPLE_EVENTS = NO;
254254
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
255255
CODE_SIGN_STYLE = Automatic;
256-
CURRENT_PROJECT_VERSION = 3;
256+
CURRENT_PROJECT_VERSION = 4;
257257
DEVELOPMENT_TEAM = D7HJ5TFYCU;
258258
ENABLE_APP_SANDBOX = NO;
259259
ENABLE_HARDENED_RUNTIME = YES;
@@ -286,7 +286,7 @@
286286
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
287287
LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs";
288288
MACOSX_DEPLOYMENT_TARGET = 14.6;
289-
MARKETING_VERSION = 0.1.3;
289+
MARKETING_VERSION = 0.1.4;
290290
OTHER_LDFLAGS = (
291291
"-force_load",
292292
"$(PROJECT_DIR)/Libs/libmariadb.a",
@@ -331,7 +331,7 @@
331331
AUTOMATION_APPLE_EVENTS = NO;
332332
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
333333
CODE_SIGN_STYLE = Automatic;
334-
CURRENT_PROJECT_VERSION = 3;
334+
CURRENT_PROJECT_VERSION = 4;
335335
DEVELOPMENT_TEAM = D7HJ5TFYCU;
336336
ENABLE_APP_SANDBOX = NO;
337337
ENABLE_HARDENED_RUNTIME = YES;
@@ -364,7 +364,7 @@
364364
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
365365
LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs";
366366
MACOSX_DEPLOYMENT_TARGET = 14.6;
367-
MARKETING_VERSION = 0.1.3;
367+
MARKETING_VERSION = 0.1.4;
368368
OTHER_LDFLAGS = (
369369
"-force_load",
370370
"$(PROJECT_DIR)/Libs/libmariadb.a",

OpenTable/Models/DataChange.swift

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)