fix: resolved some search related issues

This commit is contained in:
Hayden Hargreaves 2026-05-01 15:00:33 -07:00
parent 8c399b6754
commit 699a551549
2 changed files with 158 additions and 5 deletions

View File

@ -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()

View File

@ -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 {