diff --git a/internal/action/misc.go b/internal/action/misc.go index 891743d..231e492 100644 --- a/internal/action/misc.go +++ b/internal/action/misc.go @@ -67,7 +67,9 @@ type Undo struct{} func (a Undo) Execute(m Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() - buf.Undo(win) + if buf.UndoStack.CanUndo() { + buf.Undo(win) + } return nil } @@ -77,6 +79,8 @@ type Redo struct{} func (a Redo) Execute(m Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() - buf.Redo(win) + if buf.UndoStack.CanRedo() { + buf.Redo(win) + } return nil } diff --git a/internal/editor/integration_undo_test.go b/internal/editor/integration_undo_test.go new file mode 100644 index 0000000..9fb98f1 --- /dev/null +++ b/internal/editor/integration_undo_test.go @@ -0,0 +1,749 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/core" +) + +// ============================================================================ +// BASIC UNDO/REDO TESTS +// ============================================================================ + +func TestUndoBasicOperations(t *testing.T) { + t.Run("undo single character delete with x", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x") // Delete 'h' + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + + // Verify undo stack is empty + if m.ActiveBuffer().UndoStack.CanUndo() { + t.Error("Expected undo stack to be empty after undoing all changes") + } + }) + + t.Run("undo and redo single character delete", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x") // Delete 'h' + sendKeys(tm, "u") // Undo + sendKeys(tm, "ctrl+r") // Redo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "ello" { + t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0]) + } + + // Verify redo stack is empty + if m.ActiveBuffer().UndoStack.CanRedo() { + t.Error("Expected redo stack to be empty after redoing all changes") + } + }) + + t.Run("undo multiple x operations creates separate undo blocks", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x") // Delete 'h' -> "ello" + sendKeys(tm, "x") // Delete 'e' -> "llo" + sendKeys(tm, "x") // Delete first 'l' -> "lo" + sendKeys(tm, "u") // Undo last x -> "llo" + sendKeys(tm, "u") // Undo second x -> "ello" + sendKeys(tm, "u") // Undo first x -> "hello" + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("After 3 undos: lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + + // Verify undo stack is empty + if m.ActiveBuffer().UndoStack.CanUndo() { + t.Error("Expected undo stack to be empty after undoing all changes") + } + }) + + t.Run("undo single X (delete backward)", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "X") // Delete 'e' + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestUndoCursorRestoration(t *testing.T) { + t.Run("undo restores cursor position after x", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0}) + sendKeys(tm, "x") // Delete 'w' at position 6 + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col) + } + if m.ActiveBuffer().Lines[0] != "hello world" { + t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("redo restores cursor position after operation", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x") // Delete at position 0 + sendKeys(tm, "u") // Undo + sendKeys(tm, "ctrl+r") // Redo + + m := getFinalModel(t, tm) + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ============================================================================ +// INSERT MODE UNDO TESTS +// ============================================================================ + +func TestUndoInsertMode(t *testing.T) { + t.Run("insert mode groups all characters into one undo", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "i") // Enter insert mode + sendKeyString(tm, "hello") // Type 5 characters + sendKeys(tm, "esc") // Exit insert mode + sendKeys(tm, "u") // Undo once + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + + // Verify only one undo was needed + if m.ActiveBuffer().UndoStack.CanUndo() { + t.Error("Expected undo stack to be empty after single undo of insert session") + } + }) + + t.Run("multiple insert sessions create separate undo blocks", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + + // First insert session + sendKeys(tm, "i") + sendKeyString(tm, "hello") + sendKeys(tm, "esc") + + // Second insert session + sendKeys(tm, "a") // Append + sendKeyString(tm, " world") + sendKeys(tm, "esc") + + // Undo second session + sendKeys(tm, "u") + // Undo first session + sendKeys(tm, "u") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("After 2 undos: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + + // Verify undo stack is empty + if m.ActiveBuffer().UndoStack.CanUndo() { + t.Error("Expected undo stack to be empty after undoing all changes") + } + }) + + t.Run("insert with newlines groups everything into one undo", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "i") + sendKeyString(tm, "line1") + sendKeys(tm, "enter") + sendKeyString(tm, "line2") + sendKeys(tm, "enter") + sendKeyString(tm, "line3") + sendKeys(tm, "esc") + // Single undo should remove everything + sendKeys(tm, "u") + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 1 || m.ActiveBuffer().Lines[0] != "" { + t.Errorf("After undo: got %d lines with content %q, want 1 empty line", + len(m.ActiveBuffer().Lines), m.ActiveBuffer().Lines) + } + }) + + t.Run("insert mode with backspace is grouped into one undo", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "i") + sendKeyString(tm, "hello") + sendKeys(tm, "backspace", "backspace") // Delete "lo" + sendKeyString(tm, "y") // Type "y" + sendKeys(tm, "esc") + // Single undo should remove entire insert session + sendKeys(tm, "u") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// OPERATOR UNDO TESTS (dd, cc, etc.) +// ============================================================================ + +func TestUndoDeleteOperator(t *testing.T) { + t.Run("dd creates one undo block", func(t *testing.T) { + lines := []string{"line1", "line2", "line3"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "d") // Delete line1 + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 3 { + t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines)) + } + if m.ActiveBuffer().Lines[0] != "line1" { + t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("3dd creates one undo block for all 3 lines", func(t *testing.T) { + lines := []string{"line1", "line2", "line3", "line4"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "3", "d", "d") // Delete 3 lines + sendKeys(tm, "u") // Undo once + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 4 { + t.Errorf("line count = %d, want 4", len(m.ActiveBuffer().Lines)) + } + if m.ActiveBuffer().Lines[0] != "line1" { + t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0]) + } + if m.ActiveBuffer().Lines[2] != "line3" { + t.Errorf("lines[2] = %q, want 'line3'", m.ActiveBuffer().Lines[2]) + } + }) + + t.Run("dw (delete word) creates one undo block", func(t *testing.T) { + lines := []string{"hello world foo"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "w") // Delete "hello " + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello world foo" { + t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("D (delete to end of line) undoes correctly", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0}) + sendKeys(tm, "D") // Delete "world" + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello world" { + t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0]) + } + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestUndoChangeOperator(t *testing.T) { + t.Run("cc (change line) undoes correctly", func(t *testing.T) { + lines := []string{"original line", "line2"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "c", "c") // Change line (deletes and enters insert) + sendKeyString(tm, "new line") // Type new content + sendKeys(tm, "esc") // Exit insert mode + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "original line" { + t.Errorf("lines[0] = %q, want 'original line'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("cw (change word) undoes correctly", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "c", "w") // Change word + sendKeyString(tm, "hi") // Type replacement + sendKeys(tm, "esc") + sendKeys(tm, "u") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello world" { + t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("s (substitute char) undoes correctly", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "s") // Substitute character + sendKeyString(tm, "H") // Type replacement + sendKeys(tm, "esc") + sendKeys(tm, "u") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("S (substitute line) undoes correctly", func(t *testing.T) { + lines := []string{"original", "line2"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "S") // Substitute line + sendKeyString(tm, "replaced") // Type replacement + sendKeys(tm, "esc") + sendKeys(tm, "u") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "original" { + t.Errorf("lines[0] = %q, want 'original'", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// VISUAL MODE UNDO TESTS +// ============================================================================ + +func TestUndoVisualMode(t *testing.T) { + t.Run("visual char mode delete undoes correctly", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v") // Enter visual char mode + sendKeys(tm, "l", "l", "l", "l") // Select "hello" + sendKeys(tm, "d") // Delete selection + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello world" { + t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("visual line mode delete undoes correctly", func(t *testing.T) { + lines := []string{"line1", "line2", "line3"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "V") // Enter visual line mode + sendKeys(tm, "j") // Select 2 lines + sendKeys(tm, "d") // Delete + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 3 { + t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines)) + } + if m.ActiveBuffer().Lines[0] != "line1" { + t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("visual block mode delete undoes correctly", func(t *testing.T) { + lines := []string{"hello", "world", "test"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "ctrl+v") // Enter visual block mode + sendKeys(tm, "j", "j") // Select 3 lines + sendKeys(tm, "l", "l") // Select 3 columns + sendKeys(tm, "d") // Delete block + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + if m.ActiveBuffer().Lines[1] != "world" { + t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1]) + } + }) + + t.Run("visual char mode change undoes correctly", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v") // Visual char mode + sendKeys(tm, "l", "l", "l", "l") // Select "hello" + sendKeys(tm, "c") // Change + sendKeyString(tm, "hi") // Type replacement + sendKeys(tm, "esc") + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello world" { + t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// TEXT OBJECT UNDO TESTS +// ============================================================================ + +func TestUndoTextObjects(t *testing.T) { + t.Run("diw (delete inner word) undoes correctly", func(t *testing.T) { + lines := []string{"hello world foo"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "d", "i", "w") // Delete inner word + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello world foo" { + t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("daw (delete a word) undoes correctly", func(t *testing.T) { + lines := []string{"hello world foo"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0}) + sendKeys(tm, "d", "a", "w") // Delete a word + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello world foo" { + t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("ci( changes inside parens undoes correctly", func(t *testing.T) { + lines := []string{"before (hello) after"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 9, Line: 0}) + sendKeys(tm, "c", "i", "(") // Change inside parens + sendKeyString(tm, "world") // Type replacement + sendKeys(tm, "esc") + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "before (hello) after" { + t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// UNDO/REDO SEQUENCE TESTS +// ============================================================================ + +func TestUndoRedoSequences(t *testing.T) { + t.Run("undo then redo multiple times", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x") // Delete 'h' -> "ello" + sendKeys(tm, "x") // Delete 'e' -> "llo" + // Undo twice + sendKeys(tm, "u", "u") + // Redo twice + sendKeys(tm, "ctrl+r", "ctrl+r") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "llo" { + t.Errorf("After 2 redos: lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("new change after undo clears redo stack", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x") // Delete 'h' -> "ello" + sendKeys(tm, "x") // Delete 'e' -> "llo" + sendKeys(tm, "u") // Undo -> "ello" + sendKeys(tm, "x") // New change -> "llo" + + m := getFinalModel(t, tm) + // Verify redo is not possible + if m.ActiveBuffer().UndoStack.CanRedo() { + t.Error("Expected redo stack to be cleared after new change") + } + + // Verify content + if m.ActiveBuffer().Lines[0] != "llo" { + t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("complex sequence: operations, undo, redo, more operations", func(t *testing.T) { + lines := []string{"line1", "line2", "line3"} + tm := newTestModelWithLines(t, lines) + + // Do operations + sendKeys(tm, "d", "d") // Delete line1 + sendKeys(tm, "d", "d") // Delete line2 + // Undo once + sendKeys(tm, "u") + // Redo + sendKeys(tm, "ctrl+r") + // New operation + sendKeys(tm, "i") + sendKeyString(tm, "new") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "newline3" { + t.Errorf("After insert: lines[0] = %q, want 'newline3'", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// EDGE CASE TESTS +// ============================================================================ + +func TestUndoEdgeCases(t *testing.T) { + t.Run("undo on empty undo stack does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "u") // Undo when nothing to undo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("redo on empty redo stack does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "ctrl+r") // Redo when nothing to redo + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("undo after exhausting redo stack", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x") // Delete + sendKeys(tm, "u") // Undo + sendKeys(tm, "ctrl+r") // Redo + sendKeys(tm, "ctrl+r") // Try redo again (should do nothing) + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "ello" { + t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("undo operation that left buffer empty", func(t *testing.T) { + lines := []string{"only line"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "d") // Delete only line (buffer should have empty line) + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 1 { + t.Errorf("line count = %d, want 1", len(m.ActiveBuffer().Lines)) + } + if m.ActiveBuffer().Lines[0] != "only line" { + t.Errorf("lines[0] = %q, want 'only line'", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// MULTI-LINE OPERATION TESTS +// ============================================================================ + +func TestUndoMultiLineOperations(t *testing.T) { + t.Run("undo multi-line delete from visual mode", func(t *testing.T) { + lines := []string{"line1", "line2", "line3", "line4", "line5"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "V") // Visual line mode + sendKeys(tm, "j", "j") // Select 3 lines + sendKeys(tm, "d") // Delete + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 5 { + t.Errorf("line count = %d, want 5", len(m.ActiveBuffer().Lines)) + } + for i := 0; i < 5; i++ { + expected := "line" + string(rune('1'+i)) + if m.ActiveBuffer().Lines[i] != expected { + t.Errorf("lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], expected) + } + } + }) + + t.Run("undo delete spanning multiple lines with motion", func(t *testing.T) { + lines := []string{"line1", "line2", "line3"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "j") // Delete current line and line below + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 3 { + t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines)) + } + if m.ActiveBuffer().Lines[0] != "line1" { + t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("undo o (open line below) operation", func(t *testing.T) { + lines := []string{"line1", "line2"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "o") // Open line below + sendKeyString(tm, "new line") // Type content + sendKeys(tm, "esc") // Exit insert + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 2 { + t.Errorf("line count = %d, want 2", len(m.ActiveBuffer().Lines)) + } + if m.ActiveBuffer().Lines[0] != "line1" { + t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("undo O (open line above) operation", func(t *testing.T) { + lines := []string{"line1", "line2"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 1, Col: 0}) + sendKeys(tm, "O") // Open line above + sendKeyString(tm, "new line") // Type content + sendKeys(tm, "esc") // Exit insert + sendKeys(tm, "u") // Undo + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 2 { + t.Errorf("line count = %d, want 2", len(m.ActiveBuffer().Lines)) + } + if m.ActiveBuffer().Lines[1] != "line2" { + t.Errorf("lines[1] = %q, want 'line2'", m.ActiveBuffer().Lines[1]) + } + }) +} + +// ============================================================================ +// UNDO STACK INSPECTION TESTS +// ============================================================================ + +func TestUndoStackStructure(t *testing.T) { + t.Run("verify undo stack has correct number of blocks", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + // Perform 3 separate operations + sendKeys(tm, "x") // Op 1 + sendKeys(tm, "x") // Op 2 + sendKeys(tm, "x") // Op 3 + + m := getFinalModel(t, tm) + + // Should have 3 undo blocks + undoCount := 0 + for m.ActiveBuffer().UndoStack.CanUndo() { + m.ActiveBuffer().UndoStack.Undo() + undoCount++ + } + + if undoCount != 3 { + t.Errorf("undo block count = %d, want 3", undoCount) + } + }) + + t.Run("verify insert mode creates single block with multiple changes", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "i") + sendKeyString(tm, "hello") + sendKeys(tm, "esc") + // Verify single undo removes everything + sendKeys(tm, "u") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + if m.ActiveBuffer().UndoStack.CanUndo() { + t.Error("Expected empty undo stack after single undo") + } + }) + + t.Run("verify dd creates single block with correct change types", func(t *testing.T) { + lines := []string{"line1", "line2"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "d") + // Verify undo restores correctly + sendKeys(tm, "u") + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 2 { + t.Errorf("line count after undo = %d, want 2", len(m.ActiveBuffer().Lines)) + } + // Verify undo stack is empty after undo + if m.ActiveBuffer().UndoStack.CanUndo() { + t.Error("Expected empty undo stack after undoing all changes") + } + }) +} + +// ============================================================================ +// COMPLEX SCENARIO TESTS +// ============================================================================ + +func TestUndoComplexScenarios(t *testing.T) { + t.Run("realistic editing session with multiple undo/redo", func(t *testing.T) { + lines := []string{"func main() {", "}", ""} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 1, Col: 0}) + + // Insert a line + sendKeys(tm, "O") + sendKeyString(tm, "\tfmt.Println(\"hello\")") + sendKeys(tm, "esc") + // Delete a word + sendKeys(tm, "d", "i", "w") + // Undo delete + sendKeys(tm, "u") + // Undo insert + sendKeys(tm, "u") + // Redo both + sendKeys(tm, "ctrl+r", "ctrl+r") + + m := getFinalModel(t, tm) + if len(m.ActiveBuffer().Lines) != 4 { + t.Errorf("After 2 redos: line count = %d, want 4", len(m.ActiveBuffer().Lines)) + } + }) + + t.Run("alternating operations and undos", func(t *testing.T) { + lines := []string{"abc"} + tm := newTestModelWithLines(t, lines) + + sendKeys(tm, "x") // Delete 'a' -> "bc" + sendKeys(tm, "u") // Undo -> "abc" + sendKeys(tm, "$") // Move to end + sendKeys(tm, "x") // Delete 'c' -> "ab" + sendKeys(tm, "u") // Undo -> "abc" + sendKeys(tm, "0") // Move to start + sendKeys(tm, "x") // Delete 'a' -> "bc" + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "bc" { + t.Errorf("lines[0] = %q, want 'bc'", m.ActiveBuffer().Lines[0]) + } + }) +} diff --git a/internal/input/handler.go b/internal/input/handler.go index afcf32c..102baac 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -3,6 +3,7 @@ package input import ( "git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core" + "git.gophernest.net/azpect/TextEditor/internal/operator" tea "github.com/charmbracelet/bubbletea" ) @@ -540,6 +541,17 @@ func (h *Handler) executeAction(m action.Model, act action.Action) tea.Cmd { cmd := act.Execute(m) + // If the action one that includes insert mode, we should not end the block, we want to + // include the text from the insert mode in the block. + _, O := act.(action.OpenLineAbove) + _, o := act.(action.OpenLineBelow) + _, s := act.(action.SubstituteChar) + _, S := act.(action.SubstituteLine) + _, C := act.(action.ChangeToEndOfLine) + if o || O || s || S || C { + return nil + } + if buf.UndoStack != nil { buf.UndoStack.EndBlock(win.Cursor) } @@ -562,6 +574,12 @@ func (h *Handler) executeOperator(m action.Model, op action.Operator, start, end cmd := op.Operate(m, start, end, mtype) + // If operator is one that enters insert mode, we do not want to end the block. + _, c := op.(operator.ChangeOperator) + if c { + return cmd + } + if buf.UndoStack != nil { buf.UndoStack.EndBlock(win.Cursor) } @@ -579,6 +597,13 @@ func (h *Handler) executeDoublePress(m action.Model, dp action.DoublePresser, co cmd := dp.DoublePress(m, count) + // If operator being double pressed is one that enters insert mode, we do not + // want to end the block. + _, c := dp.(operator.ChangeOperator) + if c { + return cmd + } + if buf.UndoStack != nil { buf.UndoStack.EndBlock(win.Cursor) }