diff --git a/internal/action/command.go b/internal/action/command.go index 969e340..ef9c16d 100644 --- a/internal/action/command.go +++ b/internal/action/command.go @@ -11,6 +11,7 @@ type ExitCommandMode struct{} // ExitCommandMode.Execute: Exits command mode and returns to normal mode (Esc key). func (a ExitCommandMode) Execute(m Model) tea.Cmd { m.SetCommandCursor(0) + m.SetCommandHistoryCursor(0) m.SetCommand("") m.SetCommandOutput(&core.CommandOutput{}) m.SetMode(core.NormalMode) @@ -127,12 +128,17 @@ func (a CommandExecute) Execute(m Model) tea.Cmd { // Clear command state and return to normal mode m.SetCommandCursor(0) + m.SetCommandHistoryCursor(0) m.SetMode(core.NormalMode) if a.Registry == nil || cmdLine == "" { return nil } + history := append([]string{cmdLine}, m.CommandHistory()...) + // history = append([]string{cmdLine}, history...) + m.SetCommandHistory(history) + cmd, err := a.Registry.Execute(m, cmdLine) if err != nil { out := core.CommandOutput{ diff --git a/internal/action/command_history_test.go b/internal/action/command_history_test.go new file mode 100644 index 0000000..277a48d --- /dev/null +++ b/internal/action/command_history_test.go @@ -0,0 +1,171 @@ +package action + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/core" + tea "github.com/charmbracelet/bubbletea" +) + +// mockModelForHistory is a minimal mock for testing command history +type mockModelForHistory struct { + mockModel + commandHistory []string + commandHistoryCursor int +} + +func (m *mockModelForHistory) CommandHistory() []string { + return m.commandHistory +} + +func (m *mockModelForHistory) SetCommandHistory(history []string) { + m.commandHistory = history +} + +func (m *mockModelForHistory) CommandHistoryCursor() int { + return m.commandHistoryCursor +} + +func (m *mockModelForHistory) SetCommandHistoryCursor(cur int) { + m.commandHistoryCursor = cur +} + +// mockRegistry for testing command execution without actual command handlers +type mockRegistry struct{} + +func (r *mockRegistry) Execute(m Model, cmdLine string) (tea.Cmd, error) { + // Mock implementation - just returns nil + return nil, nil +} + +// TestCommandExecuteUpdatesHistory tests that executing commands adds them to history +func TestCommandExecuteUpdatesHistory(t *testing.T) { + t.Run("first command added to empty history", func(t *testing.T) { + m := &mockModelForHistory{ + mockModel: mockModel{ + mode: core.CommandMode, + command: "w test.txt", + }, + commandHistory: []string{}, + commandHistoryCursor: 0, + } + + registry := &mockRegistry{} + action := CommandExecute{Registry: registry} + action.Execute(m) + + history := m.CommandHistory() + if len(history) != 1 { + t.Errorf("History length = %d, want 1", len(history)) + } + + if history[0] != "w test.txt" { + t.Errorf("History[0] = %q, want %q", history[0], "w test.txt") + } + }) + + t.Run("multiple commands prepended to history", func(t *testing.T) { + m := &mockModelForHistory{ + mockModel: mockModel{ + mode: core.CommandMode, + command: "w file1.txt", + }, + commandHistory: []string{}, + commandHistoryCursor: 0, + } + + registry := &mockRegistry{} + action := CommandExecute{Registry: registry} + + // Execute first command + action.Execute(m) + + // Execute second command + m.command = "q" + action.Execute(m) + + // Execute third command + m.command = "set number" + action.Execute(m) + + history := m.CommandHistory() + if len(history) != 3 { + t.Errorf("History length = %d, want 3", len(history)) + } + + // Most recent should be first (prepended) + want := []string{"set number", "q", "w file1.txt"} + for i, cmd := range want { + if history[i] != cmd { + t.Errorf("History[%d] = %q, want %q", i, history[i], cmd) + } + } + }) + + t.Run("empty command not added to history", func(t *testing.T) { + m := &mockModelForHistory{ + mockModel: mockModel{ + mode: core.CommandMode, + command: "", + }, + commandHistory: []string{"previous command"}, + commandHistoryCursor: 0, + } + + registry := &mockRegistry{} + action := CommandExecute{Registry: registry} + action.Execute(m) + + history := m.CommandHistory() + if len(history) != 1 { + t.Errorf("History length = %d, want 1 (empty command should not be added)", len(history)) + } + + if history[0] != "previous command" { + t.Errorf("History[0] = %q, want %q", history[0], "previous command") + } + }) + + t.Run("history cursor resets on execute", func(t *testing.T) { + m := &mockModelForHistory{ + mockModel: mockModel{ + mode: core.CommandMode, + command: "w", + }, + commandHistory: []string{}, + commandHistoryCursor: 5, // Set to non-zero + } + + registry := &mockRegistry{} + action := CommandExecute{Registry: registry} + action.Execute(m) + + if m.CommandHistoryCursor() != 0 { + t.Errorf("CommandHistoryCursor = %d, want 0 after execute", m.CommandHistoryCursor()) + } + }) + + t.Run("duplicate commands are added to history", func(t *testing.T) { + m := &mockModelForHistory{ + mockModel: mockModel{ + mode: core.CommandMode, + command: "w", + }, + commandHistory: []string{"w"}, + commandHistoryCursor: 0, + } + + registry := &mockRegistry{} + action := CommandExecute{Registry: registry} + action.Execute(m) + + history := m.CommandHistory() + if len(history) != 2 { + t.Errorf("History length = %d, want 2 (duplicates should be added)", len(history)) + } + + if history[0] != "w" || history[1] != "w" { + t.Errorf("History = %v, want ['w', 'w']", history) + } + }) +} diff --git a/internal/action/find_test.go b/internal/action/find_test.go index bfbbb67..e7adb6f 100644 --- a/internal/action/find_test.go +++ b/internal/action/find_test.go @@ -12,18 +12,20 @@ import ( // ================================================== type mockModel struct { - windows []*core.Window - activeWindow *core.Window - buffers []*core.Buffer - settings core.EditorSettings - mode core.Mode - registers map[rune]core.Register - insertKeys []string - command string - commandCursor int - commandOutput *core.CommandOutput - lastFind core.LastFindCommand - styles style.Styles + windows []*core.Window + activeWindow *core.Window + buffers []*core.Buffer + settings core.EditorSettings + mode core.Mode + registers map[rune]core.Register + insertKeys []string + command string + commandCursor int + commandOutput *core.CommandOutput + commandHistory []string + commandHistoryCursor int + lastFind core.LastFindCommand + styles style.Styles } func newMockModel() *mockModel { @@ -99,6 +101,10 @@ func (m *mockModel) CommandCursor() int { return m.command func (m *mockModel) SetCommandCursor(cur int) { m.commandCursor = cur } func (m *mockModel) CommandOutput() *core.CommandOutput { return m.commandOutput } func (m *mockModel) SetCommandOutput(out *core.CommandOutput) { m.commandOutput = out } +func (m *mockModel) CommandHistory() []string { return m.commandHistory } +func (m *mockModel) SetCommandHistory(history []string) { m.commandHistory = history } +func (m *mockModel) CommandHistoryCursor() int { return m.commandHistoryCursor } +func (m *mockModel) SetCommandHistoryCursor(cur int) { m.commandHistoryCursor = cur } // Editor-wide State func (m *mockModel) Mode() core.Mode { return m.mode } diff --git a/internal/action/interface.go b/internal/action/interface.go index c032631..b676228 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -41,6 +41,10 @@ type Model interface { CommandOutput() *core.CommandOutput // DO NOT FORGET TO CALL SetMode() SetCommandOutput(out *core.CommandOutput) + CommandHistory() []string + SetCommandHistory(history []string) + CommandHistoryCursor() int + SetCommandHistoryCursor(cur int) // ================================================== // Editor-wide State diff --git a/internal/command/handlers.go b/internal/command/handlers.go index da041d7..07be644 100644 --- a/internal/command/handlers.go +++ b/internal/command/handlers.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strconv" "strings" @@ -309,6 +310,27 @@ func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd { return nil } +// -------------------------------------------------- +// History Commands +// -------------------------------------------------- +func cmdHistory(m action.Model, args []string, force bool) tea.Cmd { + _, _ = args, force + + history := m.CommandHistory() + reversed := slices.Clone(history) + slices.Reverse(reversed) + + m.SetMode(core.CommandOutputMode) + m.SetCommandOutput(&core.CommandOutput{ + Title: ":history", + Lines: reversed, + Inline: false, + IsError: false, + }) + + return nil +} + // -------------------------------------------------- // Buffer Commands // -------------------------------------------------- diff --git a/internal/command/handlers_test.go b/internal/command/handlers_test.go index 59669a7..89eaa30 100644 --- a/internal/command/handlers_test.go +++ b/internal/command/handlers_test.go @@ -19,18 +19,20 @@ import ( // ================================================== type mockModel struct { - windows []*core.Window - activeWindow *core.Window - buffers []*core.Buffer - settings core.EditorSettings - mode core.Mode - registers map[rune]core.Register - insertKeys []string - command string - commandCursor int - commandOutput *core.CommandOutput - lastFind core.LastFindCommand - styles style.Styles + windows []*core.Window + activeWindow *core.Window + buffers []*core.Buffer + settings core.EditorSettings + mode core.Mode + registers map[rune]core.Register + insertKeys []string + command string + commandCursor int + commandOutput *core.CommandOutput + commandHistory []string + commandHistoryCursor int + lastFind core.LastFindCommand + styles style.Styles } func newMockModel() *mockModel { @@ -95,6 +97,10 @@ func (m *mockModel) CommandCursor() int { return m.command func (m *mockModel) SetCommandCursor(cur int) { m.commandCursor = cur } func (m *mockModel) CommandOutput() *core.CommandOutput { return m.commandOutput } func (m *mockModel) SetCommandOutput(out *core.CommandOutput) { m.commandOutput = out } +func (m *mockModel) CommandHistory() []string { return m.commandHistory } +func (m *mockModel) SetCommandHistory(history []string) { m.commandHistory = history } +func (m *mockModel) CommandHistoryCursor() int { return m.commandHistoryCursor } +func (m *mockModel) SetCommandHistoryCursor(cur int) { m.commandHistoryCursor = cur } // Editor-wide State func (m *mockModel) Mode() core.Mode { return m.mode } diff --git a/internal/command/registry.go b/internal/command/registry.go index 5c49b05..1fb7c5d 100644 --- a/internal/command/registry.go +++ b/internal/command/registry.go @@ -162,6 +162,13 @@ func (r *Registry) registerDefaults() { Handler: cmdRegisters, }) + // History commands + r.Register(Command{ + Name: "history", + ShortForm: "his", + Handler: cmdHistory, + }) + // Buffer commands r.Register(Command{ Name: "buffers", @@ -224,6 +231,7 @@ func (r *Registry) registerDefaults() { ShortForm: "colo", Handler: cmdColorscheme, }) + r.Register(Command{ Name: "colorschemes", ShortForm: "colorschemes", diff --git a/internal/editor/model.go b/internal/editor/model.go index 69cb41a..3a57e7b 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -37,9 +37,11 @@ type Model struct { lastFind core.LastFindCommand // Command line state - command string - commandCursor int - commandOutput *core.CommandOutput + command string + commandCursor int + commandOutput *core.CommandOutput + commandHistory []string + commandHistoryCursor int // Global settings settings core.EditorSettings @@ -279,6 +281,22 @@ func (m *Model) SetCommandOutput(out *core.CommandOutput) { m.commandOutput = out } +func (m *Model) CommandHistory() []string { + return m.commandHistory +} + +func (m *Model) SetCommandHistory(history []string) { + m.commandHistory = history +} + +func (m *Model) CommandHistoryCursor() int { + return m.commandHistoryCursor +} + +func (m *Model) SetCommandHistoryCursor(cur int) { + m.commandHistoryCursor = cur +} + // ================================================== // Editor-wide State // ================================================== diff --git a/internal/editor/update.go b/internal/editor/update.go index 54d9f5a..305a5aa 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -60,6 +60,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // TODO: Any vim action should exit also // Simple override for command output mode for now if m.Mode() == core.CommandOutputMode { + // TODO: Implement g/G/d/u switch msg.String() { case "enter": m.SetMode(core.NormalMode) diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 3b4252f..28510b3 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -142,6 +142,8 @@ func NewCommandKeymap() *Keymap { motions: map[string]action.Motion{ "left": motion.MoveCommandLeft{}, "right": motion.MoveCommandRight{}, + "up": motion.MoveCommandHistoryUp{}, + "down": motion.MoveCommandHistoryDown{}, }, operators: map[string]action.Operator{}, // this will likely be empty actions: map[string]action.Action{ diff --git a/internal/motion/command.go b/internal/motion/command.go index 05d5fc8..6db4b83 100644 --- a/internal/motion/command.go +++ b/internal/motion/command.go @@ -31,3 +31,54 @@ func (a MoveCommandRight) Execute(m action.Model) tea.Cmd { // MoveCommandRight.Type: Returns CharwiseExclusive for command line motion. func (a MoveCommandRight) Type() core.MotionType { return core.CharwiseExclusive } + +// ================================================== +// Command History Motions +// ================================================== + +// MoveCommandHistoryUp implements Motion - moves cursor right in command line. +type MoveCommandHistoryUp struct{} + +// MoveCommandHistoryUp.Execute: Moves the command history cursor up (if possible). +func (a MoveCommandHistoryUp) Execute(m action.Model) tea.Cmd { + cur := m.CommandHistoryCursor() // always +1 (bascially indexed at 1) + history := m.CommandHistory() + + if cur < len(history) { + cmd := history[cur] + m.SetCommand(cmd) + m.SetCommandCursor(len(cmd)) + + // Only go up if we can + m.SetCommandHistoryCursor(cur + 1) + } + + return nil +} + +// MoveCommandHistoryUp.Type: Returns Linewise for command line motion. +func (a MoveCommandHistoryUp) Type() core.MotionType { return core.Linewise } + +// MoveCommandHistoryDown implements Motion - moves cursor right in command line. +type MoveCommandHistoryDown struct{} + +// MoveCommandHistoryDown.Execute: Moves the command history cursor down (if possible). +func (a MoveCommandHistoryDown) Execute(m action.Model) tea.Cmd { + cur := m.CommandHistoryCursor() // always +1 (bascially indexed at 1) + history := m.CommandHistory() + + if cur > 1 { + cmd := history[cur-2] + m.SetCommand(cmd) + m.SetCommandCursor(len(cmd)) + } else { + m.SetCommand("") // BUG: We should probably keep the original in state + m.SetCommandCursor(0) + } + + m.SetCommandHistoryCursor(max(0, cur-1)) + return nil +} + +// MoveCommandHistoryDown.Type: Returns Linewise for command line motion. +func (a MoveCommandHistoryDown) Type() core.MotionType { return core.Linewise } diff --git a/internal/motion/command_test.go b/internal/motion/command_test.go new file mode 100644 index 0000000..2bcf740 --- /dev/null +++ b/internal/motion/command_test.go @@ -0,0 +1,449 @@ +package motion + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/core" + "git.gophernest.net/azpect/TextEditor/internal/style" +) + +// ================================================== +// Mock Model Implementation +// ================================================== + +type mockModel struct { + windows []*core.Window + activeWindow *core.Window + buffers []*core.Buffer + settings core.EditorSettings + mode core.Mode + registers map[rune]core.Register + insertKeys []string + command string + commandCursor int + commandOutput *core.CommandOutput + commandHistory []string + commandHistoryCursor int + lastFind core.LastFindCommand + styles style.Styles +} + +// Core Data Access +func (m *mockModel) Windows() []*core.Window { return m.windows } +func (m *mockModel) ActiveWindow() *core.Window { return m.activeWindow } +func (m *mockModel) Buffers() []*core.Buffer { return m.buffers } +func (m *mockModel) SetBuffers(bufs []*core.Buffer) { m.buffers = bufs } +func (m *mockModel) ActiveBuffer() *core.Buffer { return m.activeWindow.Buffer } + +// Insert Mode State +func (m *mockModel) InsertKeys() []string { return m.insertKeys } +func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys } +func (m *mockModel) SetInsertRecording(count int, action action.Action) {} +func (m *mockModel) ExitInsertMode() {} +func (m *mockModel) SetLastFind(char string, forward, inclusive bool) { + m.lastFind = core.LastFindCommand{Char: char, Forward: forward, Inclusive: inclusive} +} +func (m *mockModel) GetLastFind() *core.LastFindCommand { return &m.lastFind } + +// Command Mode State +func (m *mockModel) Command() string { return m.command } +func (m *mockModel) SetCommand(cmd string) { m.command = cmd } +func (m *mockModel) CommandCursor() int { return m.commandCursor } +func (m *mockModel) SetCommandCursor(cur int) { m.commandCursor = cur } +func (m *mockModel) CommandOutput() *core.CommandOutput { return m.commandOutput } +func (m *mockModel) SetCommandOutput(out *core.CommandOutput) { m.commandOutput = out } +func (m *mockModel) CommandHistory() []string { return m.commandHistory } +func (m *mockModel) SetCommandHistory(history []string) { m.commandHistory = history } +func (m *mockModel) CommandHistoryCursor() int { return m.commandHistoryCursor } +func (m *mockModel) SetCommandHistoryCursor(cur int) { m.commandHistoryCursor = cur } + +// Editor-wide State +func (m *mockModel) Mode() core.Mode { return m.mode } +func (m *mockModel) SetMode(mode core.Mode) { m.mode = mode } +func (m *mockModel) Settings() core.EditorSettings { return m.settings } +func (m *mockModel) SetSettings(s core.EditorSettings) { m.settings = s } +func (m *mockModel) Styles() style.Styles { return m.styles } +func (m *mockModel) SetStyles(s style.Styles) { m.styles = s } + +// Registers +func (m *mockModel) Registers() map[rune]core.Register { return m.registers } +func (m *mockModel) GetRegister(name rune) (core.Register, bool) { + reg, ok := m.registers[name] + return reg, ok +} +func (m *mockModel) SetRegister(name rune, t core.RegisterType, cnt []string) error { + m.registers[name] = core.Register{Type: t, Content: cnt} + return nil +} +func (m *mockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) { + m.registers['"'] = core.Register{Type: t, Content: cnt} +} + +// ================================================== +// MoveCommandHistoryUp Tests +// ================================================== + +func TestMoveCommandHistoryUp(t *testing.T) { + t.Run("navigate up from start", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: []string{"cmd3", "cmd2", "cmd1"}, + commandHistoryCursor: 0, + } + + action := MoveCommandHistoryUp{} + action.Execute(m) + + // Should load first command in history + if m.Command() != "cmd3" { + t.Errorf("Command = %q, want %q", m.Command(), "cmd3") + } + + // Cursor should move to end of command + if m.CommandCursor() != len("cmd3") { + t.Errorf("CommandCursor = %d, want %d", m.CommandCursor(), len("cmd3")) + } + + // History cursor should advance + if m.CommandHistoryCursor() != 1 { + t.Errorf("CommandHistoryCursor = %d, want 1", m.CommandHistoryCursor()) + } + }) + + t.Run("navigate up multiple times", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: []string{"cmd3", "cmd2", "cmd1"}, + commandHistoryCursor: 0, + } + + action := MoveCommandHistoryUp{} + + // First up + action.Execute(m) + if m.Command() != "cmd3" { + t.Errorf("After 1st up: Command = %q, want 'cmd3'", m.Command()) + } + + // Second up + action.Execute(m) + if m.Command() != "cmd2" { + t.Errorf("After 2nd up: Command = %q, want 'cmd2'", m.Command()) + } + + // Third up + action.Execute(m) + if m.Command() != "cmd1" { + t.Errorf("After 3rd up: Command = %q, want 'cmd1'", m.Command()) + } + + if m.CommandHistoryCursor() != 3 { + t.Errorf("CommandHistoryCursor = %d, want 3", m.CommandHistoryCursor()) + } + }) + + t.Run("cannot navigate past end of history", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: []string{"cmd3", "cmd2", "cmd1"}, + commandHistoryCursor: 3, // Already at end + } + + action := MoveCommandHistoryUp{} + action.Execute(m) + + // Should not change command (still showing cmd1) + if m.Command() != "" { + t.Errorf("Command = %q, want empty (should not go past end)", m.Command()) + } + + // Cursor should not advance + if m.CommandHistoryCursor() != 3 { + t.Errorf("CommandHistoryCursor = %d, want 3 (should not advance)", m.CommandHistoryCursor()) + } + }) + + t.Run("empty history does nothing", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "current", + commandHistory: []string{}, + commandHistoryCursor: 0, + } + + action := MoveCommandHistoryUp{} + action.Execute(m) + + // Command should not change + if m.Command() != "current" { + t.Errorf("Command = %q, want 'current' (no history to navigate)", m.Command()) + } + + if m.CommandHistoryCursor() != 0 { + t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor()) + } + }) + + t.Run("command cursor moves to end", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandCursor: 0, + commandHistory: []string{"long command here"}, + commandHistoryCursor: 0, + } + + action := MoveCommandHistoryUp{} + action.Execute(m) + + expectedLen := len("long command here") + if m.CommandCursor() != expectedLen { + t.Errorf("CommandCursor = %d, want %d (end of command)", m.CommandCursor(), expectedLen) + } + }) +} + +// ================================================== +// MoveCommandHistoryDown Tests +// ================================================== + +func TestMoveCommandHistoryDown(t *testing.T) { + t.Run("navigate down from middle of history", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "cmd2", + commandHistory: []string{"cmd3", "cmd2", "cmd1"}, + commandHistoryCursor: 2, // At cmd2 + } + + action := MoveCommandHistoryDown{} + action.Execute(m) + + // Should load cmd3 (more recent) + if m.Command() != "cmd3" { + t.Errorf("Command = %q, want 'cmd3'", m.Command()) + } + + // Cursor should be at 1 + if m.CommandHistoryCursor() != 1 { + t.Errorf("CommandHistoryCursor = %d, want 1", m.CommandHistoryCursor()) + } + }) + + t.Run("navigate down to empty command", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "cmd3", + commandHistory: []string{"cmd3", "cmd2", "cmd1"}, + commandHistoryCursor: 1, // At first command + } + + action := MoveCommandHistoryDown{} + action.Execute(m) + + // Should clear command (back to current input) + if m.Command() != "" { + t.Errorf("Command = %q, want empty (back to current)", m.Command()) + } + + if m.CommandCursor() != 0 { + t.Errorf("CommandCursor = %d, want 0", m.CommandCursor()) + } + + if m.CommandHistoryCursor() != 0 { + t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor()) + } + }) + + t.Run("navigate down from cursor 0 does nothing", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: []string{"cmd3", "cmd2", "cmd1"}, + commandHistoryCursor: 0, // Already at bottom + } + + action := MoveCommandHistoryDown{} + action.Execute(m) + + // Should clear command + if m.Command() != "" { + t.Errorf("Command = %q, want empty", m.Command()) + } + + if m.CommandHistoryCursor() != 0 { + t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor()) + } + }) + + t.Run("command cursor moves to end", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandCursor: 0, + commandHistory: []string{"cmd3", "long command here"}, + commandHistoryCursor: 2, + } + + action := MoveCommandHistoryDown{} + action.Execute(m) + + expectedLen := len("cmd3") + if m.CommandCursor() != expectedLen { + t.Errorf("CommandCursor = %d, want %d (end of command)", m.CommandCursor(), expectedLen) + } + }) +} + +// ================================================== +// Integration Tests +// ================================================== + +func TestCommandHistoryIntegration(t *testing.T) { + t.Run("up down up sequence", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: []string{"cmd3", "cmd2", "cmd1"}, + commandHistoryCursor: 0, + } + + upAction := MoveCommandHistoryUp{} + downAction := MoveCommandHistoryDown{} + + // Up twice + upAction.Execute(m) // cursor=1, cmd=cmd3 + upAction.Execute(m) // cursor=2, cmd=cmd2 + + // Down once + downAction.Execute(m) // cursor=1, cmd=cmd3 + + // Up again + upAction.Execute(m) // cursor=2, cmd=cmd2 + + if m.Command() != "cmd2" { + t.Errorf("Command = %q, want 'cmd2'", m.Command()) + } + if m.CommandHistoryCursor() != 2 { + t.Errorf("CommandHistoryCursor = %d, want 2", m.CommandHistoryCursor()) + } + }) + + t.Run("navigate up past end does not crash", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: []string{"cmd1", "cmd2"}, + commandHistoryCursor: 0, + } + + upAction := MoveCommandHistoryUp{} + + // Navigate to end + upAction.Execute(m) // cursor = 1, cmd = cmd1 + upAction.Execute(m) // cursor = 2, cmd = cmd2 + + // Try to go past end + upAction.Execute(m) // Should do nothing + + if m.CommandHistoryCursor() != 2 { + t.Errorf("CommandHistoryCursor = %d, want 2 (should stay at end)", m.CommandHistoryCursor()) + } + }) + + t.Run("navigate down past bottom does not crash", func(t *testing.T) { + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: []string{"cmd1"}, + commandHistoryCursor: 0, + } + + downAction := MoveCommandHistoryDown{} + + // Try to go down from bottom + downAction.Execute(m) + + if m.CommandHistoryCursor() != 0 { + t.Errorf("CommandHistoryCursor = %d, want 0 (should stay at bottom)", m.CommandHistoryCursor()) + } + + // Try again + downAction.Execute(m) + + if m.CommandHistoryCursor() != 0 { + t.Errorf("CommandHistoryCursor = %d, want 0 (should stay at bottom)", m.CommandHistoryCursor()) + } + }) +} + +// ================================================== +// Long History Tests +// ================================================== + +func TestCommandHistoryWithLongHistory(t *testing.T) { + t.Run("navigate through 20 commands", func(t *testing.T) { + history := make([]string, 20) + for i := 0; i < 20; i++ { + history[i] = string(rune('A' + i)) + } + + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: history, + commandHistoryCursor: 0, + } + + upAction := MoveCommandHistoryUp{} + + // Navigate to 10th command + for i := 0; i < 10; i++ { + upAction.Execute(m) + } + + want := string(rune('A' + 9)) + if m.Command() != want { + t.Errorf("Command after 10 ups = %q, want %q", m.Command(), want) + } + if m.CommandHistoryCursor() != 10 { + t.Errorf("CommandHistoryCursor = %d, want 10", m.CommandHistoryCursor()) + } + }) + + t.Run("navigate to very end of long history", func(t *testing.T) { + history := make([]string, 50) + for i := 0; i < 50; i++ { + history[i] = string(rune('0' + (i % 10))) + } + + m := &mockModel{ + mode: core.CommandMode, + command: "", + commandHistory: history, + commandHistoryCursor: 0, + } + + upAction := MoveCommandHistoryUp{} + + // Navigate all the way to the end + for i := 0; i < 100; i++ { // Try to go past end + upAction.Execute(m) + } + + // Should be at position 50 (end of 50-item array) + if m.CommandHistoryCursor() != 50 { + t.Errorf("CommandHistoryCursor = %d, want 50 (at end)", m.CommandHistoryCursor()) + } + + // Should be showing last command + want := string(rune('0' + 49%10)) + if m.Command() != want { + t.Errorf("Command = %q, want %q (last in history)", m.Command(), want) + } + }) +}