Compare commits

..

5 Commits

Author SHA1 Message Date
Hayden Hargreaves
ddbc860530 doc: updated README.md
All checks were successful
Run Test Suite / test (push) Successful in 51s
2026-03-30 23:14:01 -07:00
Hayden Hargreaves
066b817200 test: added some more tests to confirm the undo tree is "good"
Yay! These are from Sonnet 4.0, hope theyre good.
2026-03-30 23:10:18 -07:00
Hayden Hargreaves
4dedb15a36 test: initial tests are complete!
Claude says they are "production ready"
2026-03-30 23:01:46 -07:00
Hayden Hargreaves
98e02553b1 feat: working on the undo stack! Huge progress, not tested
Tests are coming, but there are some infrastructure issues with the
tests
2026-03-30 22:38:33 -07:00
Hayden Hargreaves
1e2f1b147b fix: added scroll hint to message on command output 2026-03-30 22:37:02 -07:00
13 changed files with 1485 additions and 36 deletions

View File

@ -127,8 +127,8 @@
- [ ] Last search register (`/`) - [ ] Last search register (`/`)
### Undo/Redo ### Undo/Redo
- [ ] `u` - Undo - [x] `u` - Undo
- [ ] `ctrl+r` - Redo - [x] `ctrl+r` - Redo
- [ ] `.` - Repeat last change - [ ] `.` - Repeat last change
- [ ] `U` - Undo all changes on line - [ ] `U` - Undo all changes on line
@ -228,18 +228,20 @@
## Text Objects ## Text Objects
### Implemented
- [x] `iw` / `aw` - Inner/around word
- [x] `iW` / `aW` - Inner/around WORD
- [x] `is` / `as` - Inner/around sentence
- [x] `ip` / `ap` - Inner/around paragraph
- [x] `i"` / `a"` - Inner/around double quotes
- [x] `i'` / `a'` - Inner/around single quotes
- [x] `` i` `` / `` a` `` - Inner/around backticks
- [x] `i(` / `a(` - Inner/around parentheses
- [x] `i[` / `a[` - Inner/around brackets
- [x] `i{` / `a{` - Inner/around braces
- [x] `i<` / `a<` - Inner/around angle brackets
### Not Implemented ### Not Implemented
- [ ] `iw` / `aw` - Inner/around word
- [ ] `iW` / `aW` - Inner/around WORD
- [ ] `is` / `as` - Inner/around sentence
- [ ] `ip` / `ap` - Inner/around paragraph
- [ ] `i"` / `a"` - Inner/around double quotes
- [ ] `i'` / `a'` - Inner/around single quotes
- [ ] `` i` `` / `` a` `` - Inner/around backticks
- [ ] `i(` / `a(` - Inner/around parentheses
- [ ] `i[` / `a[` - Inner/around brackets
- [ ] `i{` / `a{` - Inner/around braces
- [ ] `i<` / `a<` - Inner/around angle brackets
- [ ] `it` / `at` - Inner/around tag - [ ] `it` / `at` - Inner/around tag
--- ---
@ -371,7 +373,8 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
### Display ### Display
- [x] Line numbers - [x] Line numbers
- [x] Cursor position tracking - [x] Cursor position tracking
- [x] Viewport/scrolling - [x] Viewport/scrolling (Y)
- [ ] Viewport/scrolling (X)
- [x] ScrollOff setting - [x] ScrollOff setting
- [x] Relative line numbers - [x] Relative line numbers
- [ ] Cursor line highlight - [ ] Cursor line highlight

View File

@ -60,3 +60,27 @@ func (a EnterVisualBlockMode) Execute(m Model) tea.Cmd {
m.SetMode(core.VisualBlockMode) m.SetMode(core.VisualBlockMode)
return nil return nil
} }
// TODO: Implement count?
type Undo struct{}
func (a Undo) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack.CanUndo() {
buf.Undo(win)
}
return nil
}
// TODO: Implement count?
type Redo struct{}
func (a Redo) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack.CanRedo() {
buf.Redo(win)
}
return nil
}

View File

@ -933,3 +933,29 @@ func cmdListColorschemes(m action.Model, args []string, force bool) tea.Cmd {
return nil return nil
} }
func cmdUndoList(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
lines := m.ActiveBuffer().UndoStack.List()
// For now, display an error when empty
if len(lines) == 0 {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{"Undo stack is empty"},
Inline: true,
IsError: true,
})
return nil
}
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":undo",
Lines: lines,
Inline: false,
IsError: false,
})
return nil
}

View File

@ -237,4 +237,11 @@ func (r *Registry) registerDefaults() {
ShortForm: "colorschemes", ShortForm: "colorschemes",
Handler: cmdListColorschemes, Handler: cmdListColorschemes,
}) })
// Undo stack commands
r.Register(Command{
Name: "undo",
ShortForm: "u",
Handler: cmdUndoList,
})
} }

View File

@ -29,7 +29,7 @@ type Buffer struct {
ReadOnly bool ReadOnly bool
// Options BufferOptions // Options BufferOptions
// UndoTree TODO: This will be big UndoStack *UndoStack
} }
// ================================================== // ==================================================
@ -49,6 +49,10 @@ func (b *Buffer) Line(idx int) string {
// index is out of bounds. This function sets the modified flag. // index is out of bounds. This function sets the modified flag.
func (b *Buffer) SetLine(idx int, content string) { func (b *Buffer) SetLine(idx int, content string) {
if idx >= 0 && idx < len(b.Lines) { if idx >= 0 && idx < len(b.Lines) {
// Record set line in undo stack
if b.UndoStack != nil {
b.UndoStack.RecordSetLine(idx, b.Lines[idx], content)
}
b.Lines[idx] = content b.Lines[idx] = content
} }
b.Modified = true b.Modified = true
@ -64,6 +68,12 @@ func (b *Buffer) InsertLine(idx int, content string) {
if idx > len(b.Lines) { if idx > len(b.Lines) {
idx = len(b.Lines) idx = len(b.Lines)
} }
// Record insert line in undo stack
if b.UndoStack != nil {
b.UndoStack.RecordInsertLine(idx, content)
}
b.Lines = append(b.Lines[:idx], append([]string{content}, b.Lines[idx:]...)...) b.Lines = append(b.Lines[:idx], append([]string{content}, b.Lines[idx:]...)...)
b.Modified = true b.Modified = true
} }
@ -72,6 +82,10 @@ func (b *Buffer) InsertLine(idx int, content string) {
// of bounds. This function sets the modified flag. // of bounds. This function sets the modified flag.
func (b *Buffer) DeleteLine(idx int) { func (b *Buffer) DeleteLine(idx int) {
if idx >= 0 && idx < len(b.Lines) { if idx >= 0 && idx < len(b.Lines) {
// Record delete line in undo stack
if b.UndoStack != nil {
b.UndoStack.RecordDeleteLine(idx, b.Lines[idx])
}
b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...) b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...)
} }
b.Modified = true b.Modified = true
@ -82,6 +96,99 @@ func (b *Buffer) LineCount() int {
return len(b.Lines) return len(b.Lines)
} }
// ==================================================
// Undo Stack
// ==================================================
func (b *Buffer) Undo(w *Window) bool {
if b.UndoStack == nil {
return false
}
block := b.UndoStack.Undo()
if block == nil {
return false
}
// Apply changes in REVERSE order
for i := len(block.Changes) - 1; i >= 0; i-- {
change := block.Changes[i]
// Temporarily disable recording while we undo
wasRecording := b.UndoStack.recording
b.UndoStack.recording = false
switch change.Type {
case SetLineChange:
// Restore old data
if change.Line >= 0 && change.Line < len(b.Lines) {
b.Lines[change.Line] = change.OldData
}
case InsertLineChange:
// Remove the inserted line
if change.Line >= 0 && change.Line < len(b.Lines) {
b.Lines = append(b.Lines[:change.Line], b.Lines[change.Line+1:]...)
}
case DeleteLineChange:
// Re-insert the deleted line
if change.Line <= len(b.Lines) {
b.Lines = append(b.Lines[:change.Line], append([]string{change.OldData}, b.Lines[change.Line:]...)...)
}
}
b.UndoStack.recording = wasRecording
}
// Restore cursor position
w.SetCursorLine(block.OldCursor.Line)
w.SetCursorCol(block.OldCursor.Col)
return true
}
func (b *Buffer) Redo(w *Window) bool {
if b.UndoStack == nil {
return false
}
block := b.UndoStack.Redo()
if block == nil {
return false
}
// Apply changes in FORWARD order
for _, change := range block.Changes {
// Temporarily disable recording while we redo
wasRecording := b.UndoStack.recording
b.UndoStack.recording = false
switch change.Type {
case SetLineChange:
// Apply new data
if change.Line >= 0 && change.Line < len(b.Lines) {
b.Lines[change.Line] = change.NewData
}
case InsertLineChange:
// Re-insert the line
if change.Line <= len(b.Lines) {
b.Lines = append(b.Lines[:change.Line], append([]string{change.NewData}, b.Lines[change.Line:]...)...)
}
case DeleteLineChange:
// Re-delete the line
if change.Line >= 0 && change.Line < len(b.Lines) {
b.Lines = append(b.Lines[:change.Line], b.Lines[change.Line+1:]...)
}
}
b.UndoStack.recording = wasRecording
}
// Restore cursor position
w.SetCursorLine(block.NewCursor.Line)
w.SetCursorCol(block.NewCursor.Col)
return true
}
// ================================================== // ==================================================
// Setters // Setters
// ================================================== // ==================================================

View File

@ -12,15 +12,16 @@ type BufferBuilder struct {
func NewBufferBuilder() *BufferBuilder { func NewBufferBuilder() *BufferBuilder {
return &BufferBuilder{ return &BufferBuilder{
buffer: Buffer{ buffer: Buffer{
Id: 0, // This is set when built Id: 0, // This is set when built
Type: ScatchBuffer, // Default buffer type Type: ScatchBuffer, // Default buffer type
Filename: "", Filename: "",
Filetype: "", Filetype: "",
Lines: []string{""}, Lines: []string{""},
Modified: false, Modified: false,
Loaded: false, Loaded: false,
Listed: false, Listed: false,
ReadOnly: false, ReadOnly: false,
UndoStack: NewUndoStack(), // Empty undo stack
}, },
} }
} }

View File

@ -5,6 +5,7 @@ import (
) )
const CommandOutputExitMessage = "Press ENTER to continue" const CommandOutputExitMessage = "Press ENTER to continue"
const CommandOutputScrollMessage = "Use j/k to scroll"
type CommandOutput struct { type CommandOutput struct {
Title string Title string

180
internal/core/undo.go Normal file
View File

@ -0,0 +1,180 @@
package core
import (
"fmt"
"slices"
)
type ChangeType string
const (
SetLineChange ChangeType = "SetLine"
InsertLineChange ChangeType = "InsertLine"
DeleteLineChange ChangeType = "DeleteLine"
)
type Change struct {
Type ChangeType
Line int
OldData string
NewData string
}
type ChangeBlock struct {
Changes []Change
OldCursor Position // Before OP
NewCursor Position // After OP
}
type UndoStack struct {
undoStack []ChangeBlock
redoStack []ChangeBlock
current []Change
recording bool
oldCursor Position
}
func NewUndoStack() *UndoStack {
return &UndoStack{
undoStack: []ChangeBlock{},
redoStack: []ChangeBlock{},
current: []Change{},
recording: false,
oldCursor: Position{},
}
}
func (u *UndoStack) BeginBlock(cursor Position) {
u.current = []Change{}
u.recording = true
u.oldCursor = cursor
}
func (u *UndoStack) EndBlock(cursor Position) {
// If not recording or nothing changed, we can exit safely
if !u.recording || len(u.current) == 0 {
return
}
block := ChangeBlock{
Changes: u.current,
OldCursor: u.oldCursor,
NewCursor: cursor,
}
u.undoStack = append(u.undoStack, block)
u.redoStack = []ChangeBlock{} // Reset old changes, can no longer redo
u.recording = false
u.current = []Change{}
}
func (u *UndoStack) RecordSetLine(line int, oldData, newData string) {
if !u.recording {
return
}
change := Change{
Type: SetLineChange,
Line: line,
OldData: oldData,
NewData: newData,
}
u.current = append(u.current, change)
}
func (u *UndoStack) RecordInsertLine(line int, newData string) {
if !u.recording {
return
}
change := Change{
Type: InsertLineChange,
Line: line,
NewData: newData,
}
u.current = append(u.current, change)
}
func (u *UndoStack) RecordDeleteLine(line int, oldData string) {
if !u.recording {
return
}
change := Change{
Type: DeleteLineChange,
Line: line,
OldData: oldData,
}
u.current = append(u.current, change)
}
func (u *UndoStack) Undo() *ChangeBlock {
if len(u.undoStack) == 0 {
return nil
}
// Pop from undo stack
size := len(u.undoStack)
block := u.undoStack[size-1]
u.undoStack = u.undoStack[:size-1]
// Push to redo stack
u.redoStack = append(u.redoStack, block)
return &block
}
func (u *UndoStack) Redo() *ChangeBlock {
if len(u.redoStack) == 0 {
return nil
}
// Pop from redo stack
size := len(u.redoStack)
block := u.redoStack[size-1]
u.redoStack = u.redoStack[:size-1]
// Push to undo stack
u.undoStack = append(u.undoStack, block)
return &block
}
func (u *UndoStack) CanUndo() bool {
return len(u.undoStack) > 0
}
func (u *UndoStack) CanRedo() bool {
return len(u.redoStack) > 0
}
func (u *UndoStack) Recording() bool {
return u.recording
}
func (u *UndoStack) List() []string {
var lines []string
stack := slices.Clone(u.undoStack)
slices.Reverse(stack)
for _, b := range stack {
lines = append(lines, fmt.Sprintf(
"block (%d:%d) -> (%d:%d)",
b.OldCursor.Line,
b.OldCursor.Col,
b.NewCursor.Line,
b.NewCursor.Col,
))
for _, c := range b.Changes {
lines = append(lines, fmt.Sprintf(
"\t%q #%d (%s) -> (%s)",
c.Type,
c.Line,
c.OldData,
c.NewData,
))
}
}
return lines
}

View File

@ -30,6 +30,8 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU})
case "ctrl+v": case "ctrl+v":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV}) tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV})
case "ctrl+r":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlR})
case "ctrl+w": case "ctrl+w":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW}) tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW})
default: default:

View File

@ -0,0 +1,992 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// equalStringSlices compares two string slices for equality
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// ============================================================================
// 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])
}
})
}
// =================================================================
// PASTE OPERATIONS TESTS
// =================================================================
func TestUndoPasteOperations(t *testing.T) {
t.Run("basic p (paste after) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"line1", "line2"})
// Yank first line and paste after second line
sendKeys(tm, "y", "y") // yank current line (line1)
sendKeys(tm, "j") // move to line2
sendKeys(tm, "p") // paste after line2
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"line1", "line2"}
if len(m.ActiveBuffer().Lines) != len(expected) {
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
}
for i, exp := range expected {
if m.ActiveBuffer().Lines[i] != exp {
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], exp)
}
}
// Cursor should be back at line2
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
}
})
t.Run("basic P (paste before) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"line1", "line2"})
// Yank first line and paste before second line
sendKeys(tm, "y", "y") // yank current line (line1)
sendKeys(tm, "j") // move to line2
sendKeys(tm, "P") // paste before line2
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"line1", "line2"}
if len(m.ActiveBuffer().Lines) != len(expected) {
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
}
for i, exp := range expected {
if m.ActiveBuffer().Lines[i] != exp {
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], exp)
}
}
// Cursor should be back at line2
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
}
})
t.Run("charwise paste undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"hello world"})
// Yank "hello" and paste after "world"
sendKeys(tm, "y", "w") // yank word "hello"
sendKeys(tm, "$") // move to end
sendKeys(tm, "p") // paste after cursor
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"hello world"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("visual mode paste undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"hello world", "foo bar"})
// Yank "hello" then select "world" and paste over it
sendKeys(tm, "y", "w") // yank "hello"
sendKeys(tm, "w") // move to "world"
sendKeys(tm, "v", "e") // select "world"
sendKeys(tm, "p") // paste over selection
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"hello world", "foo bar"}
if len(m.ActiveBuffer().Lines) != len(expected) {
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
}
for i, exp := range expected {
if m.ActiveBuffer().Lines[i] != exp {
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], exp)
}
}
})
t.Run("multiple paste operations undo separately", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"base"})
sendKeys(tm, "y", "y") // yank "base"
sendKeys(tm, "p") // paste: "base\nbase"
sendKeys(tm, "p") // paste: "base\nbase\nbase"
sendKeys(tm, "u") // undo last paste: "base\nbase"
sendKeys(tm, "u") // undo first paste: "base"
m := getFinalModel(t, tm)
expected := []string{"base"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("paste with count undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"test"})
sendKeys(tm, "y", "y") // yank "test"
sendKeyString(tm, "3p") // paste 3 times
sendKeys(tm, "u") // undo (should undo all 3 pastes as one block)
m := getFinalModel(t, tm)
expected := []string{"test"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
}
// =================================================================
// COMPLEX COUNT OPERATIONS TESTS
// =================================================================
func TestUndoComplexCountOperations(t *testing.T) {
t.Run("5dd undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"1", "2", "3", "4", "5", "6", "7"})
sendKeys(tm, "j", "j") // move to line 3
sendKeyString(tm, "5dd") // delete 5 lines (3,4,5,6,7)
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"1", "2", "3", "4", "5", "6", "7"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
// Cursor should be back at line 3 (index 2)
if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("Cursor.Line = %d, want 2", m.ActiveWindow().Cursor.Line)
}
})
t.Run("3cw (change 3 words) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"one two three four five"})
sendKeys(tm, "3", "c", "w") // change 3 words
sendKeys(tm, "CHANGED") // type replacement
sendKeys(tm, "esc") // exit insert mode
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"one two three four five"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("10x (delete 10 chars) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"abcdefghijklmnopqrstuvwxyz"})
sendKeys(tm, "5", "|") // move to column 5 (f)
sendKeyString(tm, "10x") // delete 10 chars (fghijklmno)
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"abcdefghijklmnopqrstuvwxyz"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
// Cursor should be back at column 4 (index of 'e', 0-based)
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("Cursor.Col = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("2cc (change 2 lines) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"line1", "line2", "line3", "line4"})
sendKeys(tm, "j") // move to line2
sendKeys(tm, "2", "c", "c") // change 2 lines (line2, line3)
sendKeys(tm, "NEW", "LINE") // type replacement
sendKeys(tm, "esc") // exit insert mode
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"line1", "line2", "line3", "line4"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("4diw (delete 4 words) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"word1 word2 word3 word4 word5"})
sendKeys(tm, "w") // move to word2
sendKeyString(tm, "4diw") // delete 4 words (word2, word3, word4, word5)
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"word1 word2 word3 word4 word5"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("complex count with paste: 3p after 2yy", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"A", "B", "C", "D"})
sendKeyString(tm, "2yy") // yank 2 lines (A, B)
sendKeys(tm, "j", "j") // move to line C
sendKeyString(tm, "3p") // paste 3 times
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"A", "B", "C", "D"}
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
// Cursor should be back at line C (index 2)
if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("Cursor.Line = %d, want 2", m.ActiveWindow().Cursor.Line)
}
})
}

View File

@ -341,7 +341,11 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty
content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n")) content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n"))
overlay = append(overlay, content) overlay = append(overlay, content)
} }
overlay = append(overlay, styles.CommandContinueMessage.Render(core.CommandOutputExitMessage)) msg := core.CommandOutputExitMessage
if len(cmd.Lines) > len(cmd.Viewport(termHeight)) {
msg += ". " + core.CommandOutputScrollMessage
}
overlay = append(overlay, styles.CommandContinueMessage.Render(msg))
// NOTE: strings.Split on "\n" is safe as long as no style uses .Width()/.Height()/.Padding()/.Margin(), // NOTE: strings.Split on "\n" is safe as long as no style uses .Width()/.Height()/.Padding()/.Margin(),
// which would cause Lipgloss to embed newlines internally and corrupt the line count. // which would cause Lipgloss to embed newlines internally and corrupt the line count.

View File

@ -3,6 +3,7 @@ package input
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/operator"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -59,6 +60,12 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
if key == "esc" { if key == "esc" {
h.Reset() h.Reset()
if m.Mode() == core.InsertMode { if m.Mode() == core.InsertMode {
// Before exiting insert mode, end the block in the undo stack
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
m.ExitInsertMode() m.ExitInsertMode()
} else { } else {
m.SetMode(core.NormalMode) m.SetMode(core.NormalMode)
@ -180,7 +187,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
if res, ok := mot.(action.Resolvable); ok { if res, ok := mot.(action.Resolvable); ok {
mot = res.Resolve(m) mot = res.Resolve(m)
} }
cmd := mot.Execute(m) cmd := h.executeMotion(m, mot)
h.Reset() h.Reset()
return cmd return cmd
@ -194,7 +201,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
if m.Mode() == core.VisualLineMode { if m.Mode() == core.VisualLineMode {
mtype = core.Linewise mtype = core.Linewise
} }
cmd := op.Operate(m, start, end, mtype) cmd := h.executeOperator(m, op, start, end, mtype)
// Only reset to normal mode if operator didn't enter insert mode // Only reset to normal mode if operator didn't enter insert mode
if m.Mode() != core.InsertMode { if m.Mode() != core.InsertMode {
m.SetMode(core.NormalMode) m.SetMode(core.NormalMode)
@ -213,7 +220,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
if r, ok := act.(action.Repeatable); ok { if r, ok := act.(action.Repeatable); ok {
act = r.WithCount(count) act = r.WithCount(count)
} }
cmd := act.Execute(m) cmd := h.executeAction(m, act)
h.Reset() h.Reset()
return cmd return cmd
} }
@ -232,7 +239,7 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
if kind == "operator" && key == h.operatorKey { if kind == "operator" && key == h.operatorKey {
// Only call DoublePress if the operator supports it // Only call DoublePress if the operator supports it
if dp, ok := h.operator.(action.DoublePresser); ok { if dp, ok := h.operator.(action.DoublePresser); ok {
cmd := dp.DoublePress(m, count) cmd := h.executeDoublePress(m, dp, count)
h.Reset() h.Reset()
return cmd return cmd
} }
@ -258,9 +265,9 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
} }
// Get range and motion type // Get range and motion type
start := win.Cursor start := win.Cursor
mot.Execute(m) h.executeMotion(m, mot)
end := win.Cursor end := win.Cursor
cmd := h.operator.Operate(m, start, end, mot.Type()) cmd := h.executeOperator(m, h.operator, start, end, mot.Type())
h.Reset() h.Reset()
return cmd return cmd
} }
@ -333,15 +340,15 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
if h.operator != nil { if h.operator != nil {
win := m.ActiveWindow() win := m.ActiveWindow()
start := win.Cursor start := win.Cursor
mot.Execute(m) h.executeMotion(m, mot)
end := win.Cursor end := win.Cursor
cmd := h.operator.Operate(m, start, end, mot.Type()) cmd := h.executeOperator(m, h.operator, start, end, mot.Type())
h.Reset() h.Reset()
return cmd return cmd
} }
// Otherwise just execute the motion // Otherwise just execute the motion
cmd := mot.Execute(m) cmd := h.executeMotion(m, mot)
h.Reset() h.Reset()
return cmd return cmd
} }
@ -366,7 +373,7 @@ func (h *Handler) handleTextObject(m action.Model, kind string, binding any, key
// If we have an operator pending (e.g., "diw") // If we have an operator pending (e.g., "diw")
if h.operator != nil { if h.operator != nil {
cmd := h.operator.Operate(m, start, end, mtype) cmd := h.executeOperator(m, h.operator, start, end, mtype)
h.Reset() h.Reset()
return cmd return cmd
} }
@ -468,7 +475,18 @@ func (h *Handler) Pending() string {
// Handler.handleInsertKey: Processes a keypress in insert mode, recording it // Handler.handleInsertKey: Processes a keypress in insert mode, recording it
// for count replay and executing it as an action or character insertion. // for count replay and executing it as an action or character insertion.
//
// This function does not make use of the execute abstractions, to prevent each
// key inserted from creating a new block in the undo stack.
func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd { func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
buf := m.ActiveBuffer()
win := m.ActiveWindow()
// Start undo block on first insert key
if buf.UndoStack != nil && !buf.UndoStack.Recording() {
buf.UndoStack.BeginBlock(win.Cursor)
}
// Record the key for count replay (e.g. 5i...) // Record the key for count replay (e.g. 5i...)
m.SetInsertKeys(append(m.InsertKeys(), key)) m.SetInsertKeys(append(m.InsertKeys(), key))
@ -486,7 +504,8 @@ func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
} }
// Handler.handleCommandKey: Processes a keypress in command mode, executing // Handler.handleCommandKey: Processes a keypress in command mode, executing
// it as an action or inserting it into the command line. // it as an action or inserting it into the command line. This does not record
// anything into the undo stack.
func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd { func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd {
kind, binding := h.commandKeymap.Lookup(key) kind, binding := h.commandKeymap.Lookup(key)
switch kind { switch kind {
@ -511,3 +530,83 @@ func normalizeVisualSelection(m action.Model) (core.Position, core.Position) {
} }
return c, a return c, a
} }
func (h *Handler) executeAction(m action.Model, act action.Action) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack != nil {
buf.UndoStack.BeginBlock(win.Cursor)
}
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)
}
return cmd
}
func (h *Handler) executeMotion(m action.Model, mot action.Motion) tea.Cmd {
// These do not change the buffer, so no need to record anything
return mot.Execute(m)
}
func (h *Handler) executeOperator(m action.Model, op action.Operator, start, end core.Position, mtype core.MotionType) tea.Cmd {
buf := m.ActiveBuffer()
win := m.ActiveWindow()
if buf.UndoStack != nil {
buf.UndoStack.BeginBlock(win.Cursor)
}
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)
}
return cmd
}
func (h *Handler) executeDoublePress(m action.Model, dp action.DoublePresser, count int) tea.Cmd {
buf := m.ActiveBuffer()
win := m.ActiveWindow()
if buf.UndoStack != nil {
buf.UndoStack.BeginBlock(win.Cursor)
}
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)
}
return cmd
}

View File

@ -69,6 +69,8 @@ func NewNormalKeymap() *Keymap {
"S": action.SubstituteLine{Count: 1}, "S": action.SubstituteLine{Count: 1},
"p": action.Paste{Count: 1}, "p": action.Paste{Count: 1},
"P": action.PasteBefore{Count: 1}, "P": action.PasteBefore{Count: 1},
"u": action.Undo{},
"ctrl+r": action.Redo{},
}, },
charMotions: map[string]action.Motion{ charMotions: map[string]action.Motion{
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false}, "f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
@ -121,6 +123,7 @@ func NewVisualKeymap() *Keymap {
"e": motion.MoveForwardWordEnd{Count: 1}, "e": motion.MoveForwardWordEnd{Count: 1},
"E": motion.MoveForwardWORDEnd{Count: 1}, "E": motion.MoveForwardWORDEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1}, "b": motion.MoveBackwardWord{Count: 1},
// TODO: O and o. These are fun ones! Should be simple too
}, },
operators: map[string]action.Operator{ operators: map[string]action.Operator{
"d": operator.DeleteOperator{}, "d": operator.DeleteOperator{},