feature/search #1
@ -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()
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user