feature/search #1
@ -88,6 +88,14 @@ func TestParseRequestSearchQuery_Table(t *testing.T) {
|
|||||||
wantStatuses: nil,
|
wantStatuses: nil,
|
||||||
wantClasses: 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 {
|
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) {
|
func TestSearchPaneUsesThemedBackgroundFill(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/google/uuid"
|
||||||
"termtap.dev/internal/model"
|
"termtap.dev/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -133,9 +135,7 @@ func (m *Model) createRequest(req model.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.requests) > 0 && m.requestCursor > 0 {
|
selectedReq, hadSelectedReq := m.selectedRequest()
|
||||||
m.requestCursor++
|
|
||||||
}
|
|
||||||
|
|
||||||
m.requests = append(m.requests, req)
|
m.requests = append(m.requests, req)
|
||||||
|
|
||||||
@ -145,6 +145,12 @@ func (m *Model) createRequest(req model.Request) {
|
|||||||
m.requests = m.requests[1:]
|
m.requests = m.requests[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hadSelectedReq {
|
||||||
|
if cursor, ok := m.cursorForRequestID(selectedReq.ID); ok {
|
||||||
|
m.requestCursor = cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m.clampRequestCursor()
|
m.clampRequestCursor()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,7 +236,8 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) bool {
|
|||||||
return true
|
return true
|
||||||
case tea.KeyBackspace, tea.KeyCtrlH:
|
case tea.KeyBackspace, tea.KeyCtrlH:
|
||||||
if len(m.searchQuery) > 0 {
|
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.requestCursor = 0
|
||||||
m.requestScroll = 0
|
m.requestScroll = 0
|
||||||
@ -275,6 +282,17 @@ func (m Model) filteredRequestIndices() []int {
|
|||||||
return indices
|
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 {
|
type requestSearchQuery struct {
|
||||||
terms []string
|
terms []string
|
||||||
methods []string
|
methods []string
|
||||||
@ -288,7 +306,20 @@ func parseRequestSearchQuery(input string) requestSearchQuery {
|
|||||||
statusHuns: make(map[int]struct{}),
|
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, ok := strings.CutPrefix(token, "method:"); ok {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
q.methods = append(q.methods, 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 value, ok := strings.CutPrefix(token, "status:"); ok {
|
||||||
if status, ok := parseStatusToken(value); ok {
|
if status, ok := parseStatusToken(value); ok {
|
||||||
if status >= 1000 {
|
if status >= 1000 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user