diff --git a/internal/ui/search_test.go b/internal/ui/search_test.go new file mode 100644 index 0000000..bd5b4e2 --- /dev/null +++ b/internal/ui/search_test.go @@ -0,0 +1,305 @@ +package ui + +import ( + "testing" + + "github.com/Gu1llaum-3/sshm/internal/config" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// createTestModel creates a model with test data for testing +func createTestModel() Model { + hosts := []config.SSHHost{ + {Name: "server1", Hostname: "server1.example.com", User: "user1"}, + {Name: "server2", Hostname: "server2.example.com", User: "user2"}, + {Name: "server3", Hostname: "server3.example.com", User: "user3"}, + {Name: "web-server", Hostname: "web.example.com", User: "webuser"}, + {Name: "db-server", Hostname: "db.example.com", User: "dbuser"}, + } + + m := Model{ + hosts: hosts, + filteredHosts: hosts, + searchInput: textinput.New(), + table: table.New(), + searchMode: false, + ready: true, + width: 80, + height: 24, + styles: NewStyles(80), + } + + // Initialize table with test data + m.updateTableColumns() + m.updateTableRows() + + return m +} + +func TestSearchModeToggle(t *testing.T) { + m := createTestModel() + + // Initially should not be in search mode + if m.searchMode { + t.Error("Model should not start in search mode") + } + + // Simulate pressing "/" to enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + // Should now be in search mode + if !m.searchMode { + t.Error("Model should be in search mode after pressing '/'") + } + + // The search input should be focused + if !m.searchInput.Focused() { + t.Error("Search input should be focused in search mode") + } +} + +func TestSearchFiltering(t *testing.T) { + m := createTestModel() + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + // Type "server" in search + for _, char := range "server" { + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + } + + // Should filter to only hosts containing "server" + expectedHosts := []string{"server1", "server2", "server3", "web-server", "db-server"} + if len(m.filteredHosts) != len(expectedHosts) { + t.Errorf("Expected %d filtered hosts, got %d", len(expectedHosts), len(m.filteredHosts)) + } + + // Check that all filtered hosts contain "server" + for _, host := range m.filteredHosts { + found := false + for _, expected := range expectedHosts { + if host.Name == expected { + found = true + break + } + } + if !found { + t.Errorf("Unexpected host in filtered results: %s", host.Name) + } + } +} + +func TestSearchFilteringSpecific(t *testing.T) { + m := createTestModel() + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + // Type "web" in search + for _, char := range "web" { + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + } + + // Should filter to only hosts containing "web" + if len(m.filteredHosts) != 1 { + t.Errorf("Expected 1 filtered host, got %d", len(m.filteredHosts)) + } + + if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "web-server" { + t.Errorf("Expected 'web-server', got '%s'", m.filteredHosts[0].Name) + } +} + +func TestSearchClearReturnToOriginal(t *testing.T) { + m := createTestModel() + originalHostCount := len(m.hosts) + + // Enter search mode and type something + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + // Type "web" in search + for _, char := range "web" { + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + } + + // Should have filtered results + if len(m.filteredHosts) >= originalHostCount { + t.Error("Search should have filtered down the results") + } + + // Clear the search by simulating backspace + for i := 0; i < 3; i++ { // "web" is 3 characters + keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + } + + // Should return to all hosts + if len(m.filteredHosts) != originalHostCount { + t.Errorf("Expected %d hosts after clearing search, got %d", originalHostCount, len(m.filteredHosts)) + } +} + +func TestCursorPositionAfterFiltering(t *testing.T) { + m := createTestModel() + + // Move cursor down to position 2 (third item) + m.table.SetCursor(2) + initialCursor := m.table.Cursor() + + if initialCursor != 2 { + t.Errorf("Expected cursor at position 2, got %d", initialCursor) + } + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + // Type "web" - this will filter to only 1 result + for _, char := range "web" { + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + } + + // Cursor should be reset to 0 since filtered results has only 1 item + // and cursor position 2 would be out of bounds + if len(m.filteredHosts) == 1 && m.table.Cursor() != 0 { + t.Errorf("Expected cursor to be reset to 0 when filtered results are smaller, got %d", m.table.Cursor()) + } +} + +func TestTabSwitchBetweenSearchAndTable(t *testing.T) { + m := createTestModel() + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + if !m.searchMode { + t.Error("Should be in search mode") + } + + // Press Tab to switch to table + keyMsg = tea.KeyMsg{Type: tea.KeyTab} + newModel, _ = m.Update(keyMsg) + m = newModel.(Model) + + if m.searchMode { + t.Error("Should not be in search mode after Tab") + } + + // Press Tab again to switch back to search + keyMsg = tea.KeyMsg{Type: tea.KeyTab} + newModel, _ = m.Update(keyMsg) + m = newModel.(Model) + + if !m.searchMode { + t.Error("Should be in search mode after second Tab") + } +} + +func TestEnterExitsSearchMode(t *testing.T) { + m := createTestModel() + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + if !m.searchMode { + t.Error("Should be in search mode") + } + + // Press Enter to exit search mode + keyMsg = tea.KeyMsg{Type: tea.KeyEnter} + newModel, _ = m.Update(keyMsg) + m = newModel.(Model) + + if m.searchMode { + t.Error("Should not be in search mode after Enter") + } +} + +func TestSearchModeDoesNotTriggerOnEmptyInput(t *testing.T) { + m := createTestModel() + originalHostCount := len(m.hosts) + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + // At this point, filteredHosts should still be the same as the original hosts + // because entering search mode should not trigger filtering with empty input + if len(m.filteredHosts) != originalHostCount { + t.Errorf("Expected %d hosts when entering search mode, got %d", originalHostCount, len(m.filteredHosts)) + } +} + +func TestSearchByHostname(t *testing.T) { + m := createTestModel() + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + // Search by hostname part "example.com" + searchTerm := "example.com" + for _, char := range searchTerm { + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + } + + // All hosts should match since they all have "example.com" in hostname + if len(m.filteredHosts) != len(m.hosts) { + t.Errorf("Expected all %d hosts to match hostname search, got %d", len(m.hosts), len(m.filteredHosts)) + } +} + +func TestSearchByUser(t *testing.T) { + m := createTestModel() + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + + // Search by user "user1" + searchTerm := "user1" + for _, char := range searchTerm { + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} + newModel, _ := m.Update(keyMsg) + m = newModel.(Model) + } + + // Only server1 should match + if len(m.filteredHosts) != 1 { + t.Errorf("Expected 1 host to match user search, got %d", len(m.filteredHosts)) + } + + if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "server1" { + t.Errorf("Expected 'server1' to match user search, got '%s'", m.filteredHosts[0].Name) + } +} diff --git a/internal/ui/sort.go b/internal/ui/sort.go index dcd7647..2c4cb6e 100644 --- a/internal/ui/sort.go +++ b/internal/ui/sort.go @@ -57,6 +57,12 @@ func (m Model) filterHosts(query string) []config.SSHHost { continue } + // Check the user + if strings.Contains(strings.ToLower(host.User), query) { + filtered = append(filtered, host) + continue + } + // Check the tags for _, tag := range host.Tags { if strings.Contains(strings.ToLower(tag), query) { diff --git a/internal/ui/update.go b/internal/ui/update.go index d066fa4..c3a0f94 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -364,6 +364,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updateTableStyles() m.table.Blur() m.searchInput.Focus() + // Don't trigger filtering when entering search mode - wait for user input return m, textinput.Blink } case "tab": @@ -381,6 +382,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updateTableStyles() m.table.Blur() m.searchInput.Focus() + // Don't trigger filtering when switching to search mode return m, textinput.Blink } return m, nil @@ -619,12 +621,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searchInput, cmd = m.searchInput.Update(msg) // Update filtered hosts only if the search value has changed if m.searchInput.Value() != oldValue { + currentCursor := m.table.Cursor() if m.searchInput.Value() != "" { m.filteredHosts = m.filterHosts(m.searchInput.Value()) } else { m.filteredHosts = m.sortHosts(m.hosts) } m.updateTableRows() + // If the current cursor position is beyond the filtered results, reset to 0 + if currentCursor >= len(m.filteredHosts) && len(m.filteredHosts) > 0 { + m.table.SetCursor(0) + } } } else { m.table, cmd = m.table.Update(msg)