From 699a551549f65338381adbc92cec24a949c1cb5e Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 1 May 2026 15:00:33 -0700 Subject: [PATCH] fix: resolved some search related issues --- internal/tui/search_test.go | 106 ++++++++++++++++++++++++++++++++++++ internal/tui/update.go | 57 +++++++++++++++++-- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go index 47839cf..eedab32 100644 --- a/internal/tui/search_test.go +++ b/internal/tui/search_test.go @@ -88,6 +88,14 @@ func TestParseRequestSearchQuery_Table(t *testing.T) { wantStatuses: nil, wantClasses: nil, }, + { + name: "supports spaced method and status filters", + input: "method: get status: 5xx", + wantTerms: nil, + wantMethods: []string{"get"}, + wantStatuses: nil, + wantClasses: []int{5}, + }, } for _, tt := range tests { @@ -234,6 +242,104 @@ func TestSearchEscClearsAndResetsSelectionState(t *testing.T) { } } +func TestSearchBackspaceHandlesUnicodeRune(t *testing.T) { + t.Parallel() + + m := NewModel(make(chan model.Event), Controls{}) + m.showSearch = true + m.searchQuery = "a\u00e9" + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + got := next.(Model) + if got.searchQuery != "a" { + t.Fatalf("searchQuery after unicode backspace = %q, want %q", got.searchQuery, "a") + } +} + +func TestCreateRequestKeepsSelectionInFilteredView(t *testing.T) { + t.Parallel() + + selectedID := uuid.New() + m := NewModel(make(chan model.Event), Controls{}) + m.requests = []model.Request{ + {ID: uuid.New(), Method: "GET", Host: "api", URL: "/ok", Status: 200}, + {ID: selectedID, Method: "GET", Host: "api", URL: "/err", Status: 500}, + } + m.searchQuery = "status:5xx" + m.requestCursor = 0 + + before, ok := m.selectedRequest() + if !ok || before.ID != selectedID { + t.Fatal("expected selected filtered request before createRequest") + } + + m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new-ok", Status: 201}) + after, ok := m.selectedRequest() + if !ok || after.ID != selectedID { + t.Fatal("selection should stay on same request when new request does not match filter") + } + if m.requestCursor != 0 { + t.Fatalf("requestCursor = %d, want 0 with single visible row", m.requestCursor) + } + + m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new-err", Status: 502}) + afterMatch, ok := m.selectedRequest() + if !ok || afterMatch.ID != selectedID { + t.Fatal("selection should stay on same request when matching request arrives") + } + if m.requestCursor != 1 { + t.Fatalf("requestCursor = %d, want 1 to keep selected row anchored", m.requestCursor) + } +} + +func TestCreateRequestWhenSelectedEntryEvictedByTrim(t *testing.T) { + t.Parallel() + + m := NewModel(make(chan model.Event), Controls{}) + m.searchQuery = "status:5xx" + + for i := 0; i < maxRequests; i++ { + status := 200 + if i%2 == 0 { + status = 500 + } + m.requests = append(m.requests, model.Request{ + ID: uuid.New(), + Method: "GET", + Host: "api", + URL: "/r", + Status: status, + }) + } + + visible := m.filteredRequestIndices() + if len(visible) == 0 { + t.Fatal("expected non-empty visible filtered set") + } + + // Pick the oldest visible row so it gets evicted first. + m.requestCursor = len(visible) - 1 + selectedBefore, ok := m.selectedRequest() + if !ok { + t.Fatal("expected selected request before trim") + } + + for { + m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new", Status: 201}) + selectedAfter, ok := m.selectedRequest() + if !ok { + t.Fatal("expected selection after trim") + } + if selectedAfter.ID != selectedBefore.ID { + break + } + } + + if m.requestCursor < 0 || m.requestCursor >= m.visibleRequestCount() { + t.Fatalf("requestCursor out of bounds after eviction: %d of %d", m.requestCursor, m.visibleRequestCount()) + } +} + func TestSearchPaneUsesThemedBackgroundFill(t *testing.T) { t.Parallel() diff --git a/internal/tui/update.go b/internal/tui/update.go index 8670eb0..da51da7 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -5,8 +5,10 @@ import ( "strconv" "strings" "time" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" + "github.com/google/uuid" "termtap.dev/internal/model" ) @@ -133,9 +135,7 @@ func (m *Model) createRequest(req model.Request) { return } - if len(m.requests) > 0 && m.requestCursor > 0 { - m.requestCursor++ - } + selectedReq, hadSelectedReq := m.selectedRequest() m.requests = append(m.requests, req) @@ -145,6 +145,12 @@ func (m *Model) createRequest(req model.Request) { m.requests = m.requests[1:] } + if hadSelectedReq { + if cursor, ok := m.cursorForRequestID(selectedReq.ID); ok { + m.requestCursor = cursor + } + } + m.clampRequestCursor() } @@ -230,7 +236,8 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) bool { return true case tea.KeyBackspace, tea.KeyCtrlH: if len(m.searchQuery) > 0 { - m.searchQuery = m.searchQuery[:len(m.searchQuery)-1] + _, size := utf8.DecodeLastRuneInString(m.searchQuery) + m.searchQuery = m.searchQuery[:len(m.searchQuery)-size] } m.requestCursor = 0 m.requestScroll = 0 @@ -275,6 +282,17 @@ func (m Model) filteredRequestIndices() []int { return indices } +func (m Model) cursorForRequestID(id uuid.UUID) (int, bool) { + visible := m.filteredRequestIndices() + for row := len(visible) - 1; row >= 0; row-- { + if m.requests[visible[row]].ID == id { + return len(visible) - 1 - row, true + } + } + + return 0, false +} + type requestSearchQuery struct { terms []string methods []string @@ -288,7 +306,20 @@ func parseRequestSearchQuery(input string) requestSearchQuery { statusHuns: make(map[int]struct{}), } - for _, token := range strings.Fields(strings.ToLower(strings.TrimSpace(input))) { + tokens := strings.Fields(strings.ToLower(strings.TrimSpace(input))) + for i := 0; i < len(tokens); i++ { + token := tokens[i] + + if token == "method:" { + if i+1 < len(tokens) && tokens[i+1] != "" { + q.methods = append(q.methods, tokens[i+1]) + i++ + continue + } + q.terms = append(q.terms, token) + continue + } + if value, ok := strings.CutPrefix(token, "method:"); ok { if value != "" { q.methods = append(q.methods, value) @@ -296,6 +327,22 @@ func parseRequestSearchQuery(input string) requestSearchQuery { } } + if token == "status:" { + if i+1 < len(tokens) { + if status, ok := parseStatusToken(tokens[i+1]); ok { + if status >= 1000 { + q.statusHuns[status/1000] = struct{}{} + } else { + q.statuses[status] = struct{}{} + } + i++ + continue + } + } + q.terms = append(q.terms, token) + continue + } + if value, ok := strings.CutPrefix(token, "status:"); ok { if status, ok := parseStatusToken(value); ok { if status >= 1000 {