diff --git a/internal/action/interface.go b/internal/action/interface.go index e50032d..e1e78d6 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -64,6 +64,12 @@ type Model interface { GetRegister(name rune) (core.Register, bool) SetRegister(name rune, t core.RegisterType, cnt []string) error UpdateDefaultRegister(t core.RegisterType, cnt []string) + + // Dot operator - accumulate keys for repeat + SetLastChangeKeys(keys []string) + LastChangeKeys() []string + ClearLastChangeKeys() + HandleKey(key string) tea.Cmd } // Action is the base interface - anything executable diff --git a/internal/action/mock.go b/internal/action/mock.go index 97a316d..5b2afa3 100644 --- a/internal/action/mock.go +++ b/internal/action/mock.go @@ -3,6 +3,7 @@ package action import ( "git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/style" + tea "github.com/charmbracelet/bubbletea" ) // MockModel is a shared test implementation of the Model interface. @@ -23,6 +24,7 @@ type MockModel struct { CommandHistoryCur int LastFindVal core.LastFindCommand StylesVal style.Styles + LastChangeKeysList []string } // NewMockModel creates a mock with an empty buffer and 24x80 window. @@ -131,3 +133,9 @@ func (m *MockModel) SetRegister(name rune, t core.RegisterType, cnt []string) er func (m *MockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) { m.RegistersMap['"'] = core.Register{Type: t, Content: cnt} } + +// Dot operator +func (m *MockModel) SetLastChangeKeys(keys []string) { m.LastChangeKeysList = keys } +func (m *MockModel) LastChangeKeys() []string { return m.LastChangeKeysList } +func (m *MockModel) ClearLastChangeKeys() { m.LastChangeKeysList = []string{} } +func (m *MockModel) HandleKey(key string) tea.Cmd { return nil } diff --git a/internal/action/repeat.go b/internal/action/repeat.go new file mode 100644 index 0000000..1d595ae --- /dev/null +++ b/internal/action/repeat.go @@ -0,0 +1,41 @@ +package action + +import ( + "git.gophernest.net/azpect/TextEditor/internal/core" + tea "github.com/charmbracelet/bubbletea" +) + +// Repeat implements Action (.) - repeat last input +type Repeat struct { + Count int +} + +func (a Repeat) Execute(m Model) tea.Cmd { + keys := m.LastChangeKeys() + + if len(keys) == 1 && keys[0] == "." { + m.SetCommandOutput(&core.CommandOutput{ + Lines: []string{"Cannot repeat '.'"}, + Inline: true, + IsError: true, + }) + + return nil + } + + var cmds []tea.Cmd + for _, key := range keys { + cmd := m.HandleKey(key) + cmds = append(cmds, cmd) + } + + return tea.Batch(cmds...) +} + +// Ensure Repeat implements Repeatable +var _ Repeatable = Repeat{} + +// Repeat.WithCount: Returns a new Repeat with the given count. +func (a Repeat) WithCount(n int) Action { + return Repeat{Count: n} +} diff --git a/internal/core/register.go b/internal/core/register.go index fb223d8..f644e04 100644 --- a/internal/core/register.go +++ b/internal/core/register.go @@ -82,7 +82,8 @@ func addSpecialRegisters(reg map[rune]Register) { // Small delete? Expression? - // Last inserted text (readonly) + // VIM: Last inserted text (readonly) + // GIM: Content stored for the '.' operator (for debugging) reg['.'] = emptyRegister() // Current file name (readonly) diff --git a/internal/editor/integration_repeat_test.go b/internal/editor/integration_repeat_test.go new file mode 100644 index 0000000..e60b3ff --- /dev/null +++ b/internal/editor/integration_repeat_test.go @@ -0,0 +1,361 @@ +package editor + +import ( + "testing" +) + +// ================================================== +// P0: Basic Recording Tests +// ================================================== + +func TestDotOperatorRecording(t *testing.T) { + t.Run("records simple delete", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, "x") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + if len(keys) != 1 || keys[0] != "x" { + t.Errorf("LastChangeKeys() = %v, want [\"x\"]", keys) + } + + // Also verify . register + reg, ok := m.GetRegister('.') + if !ok { + t.Fatal("dot register not found") + } + if reg.Content[0] != "x" { + t.Errorf("dot register = %q, want \"x\"", reg.Content[0]) + } + }) + + t.Run("records operator motion", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello world"})) + sendKeys(tm, "d", "w") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + if len(keys) != 2 || keys[0] != "d" || keys[1] != "w" { + t.Errorf("LastChangeKeys() = %v, want [\"d\", \"w\"]", keys) + } + }) + + t.Run("records double press operator", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello", "world"})) + sendKeys(tm, "d", "d") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + if len(keys) != 2 || keys[0] != "d" || keys[1] != "d" { + t.Errorf("LastChangeKeys() = %v, want [\"d\", \"d\"]", keys) + } + }) + + t.Run("records visual operation", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"})) + sendKeys(tm, "V", "j", "x") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + if len(keys) != 3 || keys[0] != "V" || keys[1] != "j" || keys[2] != "x" { + t.Errorf("LastChangeKeys() = %v, want [\"V\", \"j\", \"x\"]", keys) + } + }) + + t.Run("records insert mode", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, "i", "X", "Y", "Z", "esc") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + if len(keys) != 5 || keys[0] != "i" || keys[1] != "X" || keys[2] != "Y" || keys[3] != "Z" || keys[4] != "esc" { + t.Errorf("LastChangeKeys() = %v, want [\"i\", \"X\", \"Y\", \"Z\", \"esc\"]", keys) + } + }) +} + +// ================================================== +// P0: Non-Recording Tests +// ================================================== + +func TestDotOperatorNonRecording(t *testing.T) { + t.Run("does not record pure motions", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3", "line4"})) + sendKeys(tm, "k", "k", "k") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + // Pure motions should result in empty recording + if len(keys) != 0 { + t.Errorf("LastChangeKeys() = %v, want []", keys) + } + }) + + t.Run("does not record dot operator itself", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, "x", ".", ".") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + // Should still be just ["x"], not ["x", ".", "."] + if len(keys) != 1 || keys[0] != "x" { + t.Errorf("LastChangeKeys() = %v, want [\"x\"]", keys) + } + }) + + t.Run("does not record command mode entry", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, ":") + sendKeys(tm, "esc") // Exit command mode + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + // Command mode entry should not record + if len(keys) != 0 { + t.Errorf("LastChangeKeys() = %v, want []", keys) + } + }) + + t.Run("does not record visual mode entry without action", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, "v", "esc") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + // Just entering and exiting visual mode should not record + if len(keys) != 0 { + t.Errorf("LastChangeKeys() = %v, want []", keys) + } + }) +} + +// ================================================== +// P0: Basic Replay Tests +// ================================================== + +func TestDotOperatorReplay(t *testing.T) { + t.Run("repeats simple delete", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, "x") // "ello" + sendKeys(tm, ".") // "llo" + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Line(0) != "llo" { + t.Errorf("buffer = %q, want \"llo\"", m.ActiveBuffer().Line(0)) + } + }) + + t.Run("repeats operator motion", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"one two three"})) + sendKeys(tm, "d", "w") // "two three" + sendKeys(tm, ".") // "three" + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Line(0) != "three" { + t.Errorf("buffer = %q, want \"three\"", m.ActiveBuffer().Line(0)) + } + }) + + t.Run("repeats double press operator", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"})) + sendKeys(tm, "d", "d") // Delete first line -> "line2", "line3" + sendKeys(tm, ".") // Delete next line -> "line3" + + m := getFinalModel(t, tm) + if m.ActiveBuffer().LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) + } + if m.ActiveBuffer().Line(0) != "line3" { + t.Errorf("buffer = %q, want \"line3\"", m.ActiveBuffer().Line(0)) + } + }) +} + +// ================================================== +// P1: Recording Replacement Tests +// ================================================== + +func TestDotOperatorRecordingReplacement(t *testing.T) { + t.Run("new action replaces old recording", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello world"})) + sendKeys(tm, "x") // Record ["x"] + sendKeys(tm, "l", "l") // Motions clear recording buffer but don't save + sendKeys(tm, "d", "w") // Record ["d", "w"] + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + // Should be the latest action ["d", "w"], not ["x"] + if len(keys) != 2 || keys[0] != "d" || keys[1] != "w" { + t.Errorf("LastChangeKeys() = %v, want [\"d\", \"w\"]", keys) + } + }) + + t.Run("motions clear recording without saving", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3", "line4"})) + sendKeys(tm, "x") // Record ["x"] + sendKeys(tm, "j", "j", "j") // Motions don't overwrite saved recording + sendKeys(tm, "d", "d") // New action replaces + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + // Should be ["d", "d"] from the last modifying action + if len(keys) != 2 || keys[0] != "d" || keys[1] != "d" { + t.Errorf("LastChangeKeys() = %v, want [\"d\", \"d\"]", keys) + } + }) +} + +// ================================================== +// P1: Visual Mode Tests +// ================================================== + +func TestDotOperatorVisualMode(t *testing.T) { + t.Run("repeats visual line operation", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"a", "b", "c", "d", "e"})) + sendKeys(tm, "V", "j", "x") // Delete lines 0-1 -> "c", "d", "e" + // Cursor should be at line 0 after deletion + sendKeys(tm, ".") // Repeat -> delete next 2 lines -> "e" + + m := getFinalModel(t, tm) + if m.ActiveBuffer().LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) + } + if m.ActiveBuffer().Line(0) != "e" { + t.Errorf("buffer = %q, want \"e\"", m.ActiveBuffer().Line(0)) + } + }) +} + +// ================================================== +// P1: Insert Mode Tests +// ================================================== + +func TestDotOperatorInsertMode(t *testing.T) { + t.Run("repeats insert mode operation", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, "i", "X", "Y", "Z", "esc") // "XYZhello", cursor at col 2 (on Z) + // Move cursor after XYZ (to col 3, between Z and h) + sendKeys(tm, "l") + sendKeys(tm, ".") // Should insert XYZ again at col 3 + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Line(0) != "XYZXYZhello" { + t.Errorf("buffer = %q, want \"XYZXYZhello\"", m.ActiveBuffer().Line(0)) + } + }) +} + +// ================================================== +// P1: Multiple Repeat Tests +// ================================================== + +func TestDotOperatorMultipleRepeats(t *testing.T) { + t.Run("dot can be used multiple times", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello world foo bar"})) + sendKeys(tm, "d", "w") // "world foo bar" + sendKeys(tm, ".") // "foo bar" + sendKeys(tm, ".") // "bar" + sendKeys(tm, ".") // "" + + m := getFinalModel(t, tm) + line := m.ActiveBuffer().Line(0) + // After deleting 4 words, should be empty or just whitespace + if line != "" && line != " " { + t.Errorf("buffer = %q, want empty or space", line) + } + }) +} + +// ================================================== +// P2: Edge Cases +// ================================================== + +func TestDotOperatorEdgeCases(t *testing.T) { + t.Run("repeat at start of file", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, "x") // "ello" + sendKeys(tm, ".") // "llo" + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Line(0) != "llo" { + t.Errorf("buffer = %q, want \"llo\"", m.ActiveBuffer().Line(0)) + } + }) + + t.Run("repeat after undo", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello world"})) + sendKeys(tm, "x") // "ello world" + sendKeys(tm, "u") // Undo -> "hello world" + sendKeys(tm, ".") // Repeat should still work -> "ello world" + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Line(0) != "ello world" { + t.Errorf("buffer = %q, want \"ello world\"", m.ActiveBuffer().Line(0)) + } + }) + + t.Run("repeat with no recorded change", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, ".") // Dot with nothing recorded + + m := getFinalModel(t, tm) + // Should not crash, buffer should be unchanged + if m.ActiveBuffer().Line(0) != "hello" { + t.Errorf("buffer = %q, want \"hello\"", m.ActiveBuffer().Line(0)) + } + }) +} + +// ================================================== +// P2: Integration with Counts +// ================================================== + +func TestDotOperatorWithCounts(t *testing.T) { + t.Run("recording includes count", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello world"})) + sendKeys(tm, "3", "x") + + m := getFinalModel(t, tm) + keys := m.LastChangeKeys() + // Should record both the count and the action + if len(keys) != 2 || keys[0] != "3" || keys[1] != "x" { + t.Errorf("LastChangeKeys() = %v, want [\"3\", \"x\"]", keys) + } + }) + + t.Run("repeat preserves count", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"hello"})) + sendKeys(tm, "3", "x") // Delete 3 chars -> "lo" + sendKeys(tm, ".") // Should delete 3 more (or try to) + + m := getFinalModel(t, tm) + // After deleting 3 + 3 = 6 chars, should be empty or have no chars left + if m.ActiveBuffer().Line(0) != "" { + t.Errorf("buffer = %q, want empty", m.ActiveBuffer().Line(0)) + } + }) +} + +// ================================================== +// P2: Complex Sequences +// ================================================== + +func TestDotOperatorComplexSequences(t *testing.T) { + t.Run("complex sequence of operations", func(t *testing.T) { + tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"})) + sendKeys(tm, "x") // Delete 'l' from line1 -> ["ine1", "line2", "line3"] + sendKeys(tm, "j", "j") // Move to line 2 + sendKeys(tm, "d", "d") // Delete line 2 (line3) -> ["ine1", "line2"], cursor at line 1 + sendKeys(tm, "k") // Move up to line 0 + sendKeys(tm, ".") // Repeat dd - deletes line 0 (ine1) -> ["line2"] + + m := getFinalModel(t, tm) + // After the sequence, dd was recorded and repeated at line 0, deleting it + if m.ActiveBuffer().LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) + } + if m.ActiveBuffer().Line(0) != "line2" { + t.Errorf("buffer = %q, want \"line2\"", m.ActiveBuffer().Line(0)) + } + }) +} diff --git a/internal/editor/model.go b/internal/editor/model.go index 3a57e7b..15dfe99 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -51,6 +51,9 @@ type Model struct { // Visual styles styles style.Styles + + // Dot operator state + lastChangeKeys []string } // Model.Init: Initialize the model and start any commands that may need to run. Required @@ -120,6 +123,25 @@ func (m *Model) GetLastFind() *core.LastFindCommand { return &m.lastFind } +// Does update the '.' register +func (m *Model) SetLastChangeKeys(keys []string) { + m.lastChangeKeys = keys + + m.SetRegister('.', core.CharwiseRegister, []string{strings.Join(keys, "")}) +} + +func (m *Model) LastChangeKeys() []string { + return m.lastChangeKeys +} + +func (m *Model) ClearLastChangeKeys() { + m.lastChangeKeys = []string{} +} + +func (m *Model) HandleKey(key string) tea.Cmd { + return m.input.Handle(m, key) +} + func (m *Model) ExitInsertMode() { win := m.ActiveWindow() if m.insertCount > 1 { diff --git a/internal/input/handler.go b/internal/input/handler.go index 102baac..24e8161 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -1,6 +1,8 @@ package input import ( + "slices" + "git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/operator" @@ -32,6 +34,9 @@ type Handler struct { charMotionType string // which char motion is waiting: "f", "t", "F", or "T" modifier string // which modifier used for text object: "i" or "a" + // Dot operator - accumulate keys for current operation + recordingKeys []string + // Keymaps normalKeymap *Keymap visualKeymap *Keymap @@ -56,8 +61,21 @@ func NewHandler() *Handler { // Handler.Handle: Main entry point for processing a keypress. Routes to appropriate // handler based on current mode and state. func (h *Handler) Handle(m action.Model, key string) tea.Cmd { + ignoreKeys := []string{".", "u", "ctrl+r"} + + // Record key for dot operator (except in insert/command mode which handle separately) + if m.Mode() != core.InsertMode && m.Mode() != core.CommandMode && !slices.Contains(ignoreKeys, key) { + h.recordingKeys = append(h.recordingKeys, key) + } + // ESC always resets everything if key == "esc" { + // If insert mode, keep the escape + if m.Mode() == core.InsertMode { + m.SetLastChangeKeys(append(m.LastChangeKeys(), key)) + } + + h.recordingKeys = []string{} // Clear recording on ESC h.Reset() if m.Mode() == core.InsertMode { // Before exiting insert mode, end the block in the undo stack @@ -188,6 +206,12 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st mot = res.Resolve(m) } cmd := h.executeMotion(m, mot) + + // Only clear recording for pure motions in normal mode + // In visual mode, motions are part of building the selection + if !m.Mode().IsVisualMode() { + h.recordingKeys = []string{} + } h.Reset() return cmd @@ -206,7 +230,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st if m.Mode() != core.InsertMode { m.SetMode(core.NormalMode) } - h.Reset() + h.RecordAndReset(m) return cmd } // In normal mode, wait for a motion to define the range @@ -221,7 +245,12 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st act = r.WithCount(count) } cmd := h.executeAction(m, act) - h.Reset() + // Only record if we're not entering visual mode (visual ops record when they complete) + if m.Mode().IsVisualMode() { + h.Reset() // In visual mode now, don't save yet + } else { + h.RecordAndReset(m) + } return cmd } @@ -240,7 +269,7 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, // Only call DoublePress if the operator supports it if dp, ok := h.operator.(action.DoublePresser); ok { cmd := h.executeDoublePress(m, dp, count) - h.Reset() + h.RecordAndReset(m) return cmd } h.Reset() @@ -268,7 +297,7 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, h.executeMotion(m, mot) end := win.Cursor cmd := h.executeOperator(m, h.operator, start, end, mot.Type()) - h.Reset() + h.RecordAndReset(m) return cmd } @@ -343,7 +372,7 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd { h.executeMotion(m, mot) end := win.Cursor cmd := h.executeOperator(m, h.operator, start, end, mot.Type()) - h.Reset() + h.RecordAndReset(m) return cmd } @@ -374,7 +403,7 @@ func (h *Handler) handleTextObject(m action.Model, kind string, binding any, key // If we have an operator pending (e.g., "diw") if h.operator != nil { cmd := h.executeOperator(m, h.operator, start, end, mtype) - h.Reset() + h.RecordAndReset(m) return cmd } @@ -456,6 +485,7 @@ func (h *Handler) effectiveCount() int { } // Handler.Reset: Clears all handler state including counts, operators, and buffers. +// Does NOT clear recordingKeys - those accumulate across an operation. func (h *Handler) Reset() { h.state = StateReady h.count1 = 0 @@ -466,6 +496,28 @@ func (h *Handler) Reset() { h.pending = "" h.charMotionType = "" h.modifier = "" + // NOTE: recordingKeys is NOT cleared here - it accumulates across the operation +} + +func (h *Handler) RecordAndReset(m action.Model) { + // Save the recorded keys to the model for dot operator + // Filter out mode-switch keys that don't modify the buffer + ignoreStates := []string{":", "v", "V", "."} + + if len(h.recordingKeys) > 0 { + // Check if the entire sequence is just a mode switch + shouldRecord := true + if len(h.recordingKeys) == 1 && slices.Contains(ignoreStates, h.recordingKeys[0]) { + shouldRecord = false + } + + if shouldRecord { + m.SetLastChangeKeys(h.recordingKeys) + } + } + + h.recordingKeys = []string{} // Clear recording after saving + h.Reset() } // Handler.Pending: Returns the accumulated input buffer for display. @@ -489,6 +541,7 @@ func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd { // Record the key for count replay (e.g. 5i...) m.SetInsertKeys(append(m.InsertKeys(), key)) + m.SetLastChangeKeys(append(m.LastChangeKeys(), key)) // Check the insert keymap first kind, binding := h.insertKeymap.Lookup(key) diff --git a/internal/input/keymap.go b/internal/input/keymap.go index b955b44..688aab0 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -41,7 +41,7 @@ func NewNormalKeymap() *Keymap { "ctrl+u": motion.ScrollUpHalfPage{}, "ctrl+d": motion.ScrollDownHalfPage{}, ";": action.RepeatFind{Count: 1, Reverse: false}, - ".": action.RepeatFind{Count: 1, Reverse: true}, + ",": action.RepeatFind{Count: 1, Reverse: true}, }, operators: map[string]action.Operator{ "d": operator.DeleteOperator{}, @@ -71,6 +71,7 @@ func NewNormalKeymap() *Keymap { "P": action.PasteBefore{Count: 1}, "u": action.Undo{}, "ctrl+r": action.Redo{}, + ".": action.Repeat{Count: 1}, }, charMotions: map[string]action.Motion{ "f": action.FindChar{Forward: true, Inclusive: true, Repeated: false}, @@ -134,6 +135,7 @@ func NewVisualKeymap() *Keymap { }, actions: map[string]action.Action{ "p": action.VisualPaste{Count: 1}, + ".": action.Repeat{Count: 1}, // ":": action.EnterComandMode{}, // Different OP }, charMotions: map[string]action.Motion{