Compare commits
5 Commits
402c93db50
...
ddbc860530
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddbc860530 | ||
|
|
066b817200 | ||
|
|
4dedb15a36 | ||
|
|
98e02553b1 | ||
|
|
1e2f1b147b |
31
FEATURES.md
31
FEATURES.md
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
// ==================================================
|
// ==================================================
|
||||||
|
|||||||
@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
180
internal/core/undo.go
Normal 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
|
||||||
|
}
|
||||||
@ -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:
|
||||||
|
|||||||
992
internal/editor/integration_undo_test.go
Normal file
992
internal/editor/integration_undo_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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{},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user