feature/search #1
@ -38,11 +38,12 @@ type Model struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
||||||
theme Theme
|
theme Theme
|
||||||
showEvents bool
|
showEvents bool
|
||||||
showStd bool
|
showStd bool
|
||||||
showSearch bool
|
showSearch bool
|
||||||
restarting bool
|
searchQuery string
|
||||||
|
restarting bool
|
||||||
|
|
||||||
now time.Time
|
now time.Time
|
||||||
}
|
}
|
||||||
@ -69,6 +70,7 @@ func NewModel(ch <-chan model.Event, controls Controls) Model {
|
|||||||
showEvents: false,
|
showEvents: false,
|
||||||
showStd: false,
|
showStd: false,
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
|
searchQuery: "",
|
||||||
restarting: false,
|
restarting: false,
|
||||||
theme: newTheme(),
|
theme: newTheme(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,9 @@ func TestNewModelDefaults(t *testing.T) {
|
|||||||
if m.showEvents || m.showStd || m.showSearch || m.restarting {
|
if m.showEvents || m.showStd || m.showSearch || m.restarting {
|
||||||
t.Fatal("toggle flags should initialize false")
|
t.Fatal("toggle flags should initialize false")
|
||||||
}
|
}
|
||||||
|
if m.searchQuery != "" {
|
||||||
|
t.Fatal("search query should initialize empty")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitBatchesEventAndTick(t *testing.T) {
|
func TestInitBatchesEventAndTick(t *testing.T) {
|
||||||
@ -188,10 +191,33 @@ func TestUpdate(t *testing.T) {
|
|||||||
t.Fatal("/ should enable search")
|
t.Fatal("/ should enable search")
|
||||||
}
|
}
|
||||||
|
|
||||||
next5, _ := next4.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc})
|
nextSearch, _ := next4.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
||||||
|
if nextSearch.(Model).searchQuery != "x" {
|
||||||
|
t.Fatal("typed rune should update search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSearchSpace, _ := nextSearch.(Model).Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
if nextSearchSpace.(Model).searchQuery != "x " {
|
||||||
|
t.Fatal("space should update search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSearch2, _ := nextSearchSpace.(Model).Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||||
|
if nextSearch2.(Model).searchQuery != "x" {
|
||||||
|
t.Fatal("backspace should remove one character")
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSearch3, _ := nextSearch2.(Model).Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||||
|
if nextSearch3.(Model).searchQuery != "" {
|
||||||
|
t.Fatal("backspace should update search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
next5, _ := nextSearch3.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
if next5.(Model).showSearch {
|
if next5.(Model).showSearch {
|
||||||
t.Fatal("esc should disable search")
|
t.Fatal("esc should disable search")
|
||||||
}
|
}
|
||||||
|
if next5.(Model).searchQuery != "" {
|
||||||
|
t.Fatal("esc should clear search query")
|
||||||
|
}
|
||||||
|
|
||||||
next6, cmd6 := next5.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
next6, cmd6 := next5.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
||||||
if cmd6 != nil {
|
if cmd6 != nil {
|
||||||
@ -310,7 +336,7 @@ func TestModelHelpers(t *testing.T) {
|
|||||||
t.Run("pushEvent trims to maxEvents", func(t *testing.T) {
|
t.Run("pushEvent trims to maxEvents", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
m := NewModel(make(chan model.Event), Controls{})
|
m := NewModel(make(chan model.Event), Controls{})
|
||||||
for i := 0; i < maxEvents+5; i++ {
|
for range maxEvents + 5 {
|
||||||
m.pushEvent(model.Event{Body: "x"})
|
m.pushEvent(model.Event{Body: "x"})
|
||||||
}
|
}
|
||||||
if len(m.events) != maxEvents {
|
if len(m.events) != maxEvents {
|
||||||
@ -326,7 +352,7 @@ func TestModelHelpers(t *testing.T) {
|
|||||||
t.Fatal("CONNECT request should be ignored")
|
t.Fatal("CONNECT request should be ignored")
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < maxRequests+3; i++ {
|
for range maxRequests + 3 {
|
||||||
m.createRequest(model.Request{ID: uuid.New(), Method: http.MethodGet})
|
m.createRequest(model.Request{ID: uuid.New(), Method: http.MethodGet})
|
||||||
}
|
}
|
||||||
if len(m.requests) != maxRequests {
|
if len(m.requests) != maxRequests {
|
||||||
|
|||||||
@ -130,7 +130,7 @@ func (m Model) bottomStatusRight() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) requestSelectionStats() (selected int, total int) {
|
func (m Model) requestSelectionStats() (selected int, total int) {
|
||||||
total = len(m.requests)
|
total = m.visibleRequestCount()
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
@ -139,11 +139,27 @@ func (m Model) requestSelectionStats() (selected int, total int) {
|
|||||||
return selected, total
|
return selected, total
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
func (m Model) renderSearchPane(w, h int) []string {
|
func (m Model) renderSearchPane(w, h int) []string {
|
||||||
|
if h <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
left := m.theme.TextMuted.Render(" / SEARCH ")
|
||||||
|
hint := m.theme.TextMuted.Render(" host/path method:get status:5xx ")
|
||||||
|
if strings.TrimSpace(m.searchQuery) != "" {
|
||||||
|
hint = m.theme.Text.Render(" " + m.searchQuery + " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
line := left + hint
|
||||||
|
line = clampRendered(line, w)
|
||||||
|
if lipgloss.Width(line) < w {
|
||||||
|
line += m.theme.Text.Render(strings.Repeat(" ", w-lipgloss.Width(line)))
|
||||||
|
}
|
||||||
|
|
||||||
lines := make([]string, h)
|
lines := make([]string, h)
|
||||||
for y := range lines {
|
lines[0] = line
|
||||||
lines[y] = strings.Repeat(" ", w)
|
for y := 1; y < h; y++ {
|
||||||
|
lines[y] = m.theme.Text.Render(strings.Repeat(" ", w))
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
@ -165,9 +181,10 @@ func (m Model) renderRequestPane(w, h int) []string {
|
|||||||
header := m.theme.TextMuted.Render(headerLeft + headerSpace + headerRight)
|
header := m.theme.TextMuted.Render(headerLeft + headerSpace + headerRight)
|
||||||
lines = append(lines, header)
|
lines = append(lines, header)
|
||||||
|
|
||||||
bodyLines := make([]string, 0, len(m.requests))
|
visible := m.filteredRequestIndices()
|
||||||
for i, row := len(m.requests)-1, 0; i >= 0; i, row = i-1, row+1 {
|
bodyLines := make([]string, 0, len(visible))
|
||||||
req := m.requests[i]
|
for i, row := len(visible)-1, 0; i >= 0; i, row = i-1, row+1 {
|
||||||
|
req := m.requests[visible[i]]
|
||||||
duration := req.Duration
|
duration := req.Duration
|
||||||
if req.Pending && !req.StartTime.IsZero() {
|
if req.Pending && !req.StartTime.IsZero() {
|
||||||
duration = time.Since(req.StartTime)
|
duration = time.Since(req.StartTime)
|
||||||
@ -532,7 +549,8 @@ func (m Model) detailsContentLineCount(w int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) selectedRequest() (model.Request, bool) {
|
func (m Model) selectedRequest() (model.Request, bool) {
|
||||||
if len(m.requests) == 0 {
|
visible := m.filteredRequestIndices()
|
||||||
|
if len(visible) == 0 {
|
||||||
return model.Request{}, false
|
return model.Request{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -540,16 +558,16 @@ func (m Model) selectedRequest() (model.Request, bool) {
|
|||||||
if cursor < 0 {
|
if cursor < 0 {
|
||||||
cursor = 0
|
cursor = 0
|
||||||
}
|
}
|
||||||
if cursor >= len(m.requests) {
|
if cursor >= len(visible) {
|
||||||
cursor = len(m.requests) - 1
|
cursor = len(visible) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
idx := len(m.requests) - cursor - 1
|
idx := len(visible) - cursor - 1
|
||||||
if idx < 0 || idx >= len(m.requests) {
|
if idx < 0 || idx >= len(visible) {
|
||||||
return model.Request{}, false
|
return model.Request{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.requests[idx], true
|
return m.requests[visible[idx]], true
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatBodyLines(body []byte, width int) []string {
|
func formatBodyLines(body []byte, width int) []string {
|
||||||
|
|||||||
260
internal/tui/search_test.go
Normal file
260
internal/tui/search_test.go
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"termtap.dev/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseStatusToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{name: "exact code", in: "404", want: 404, ok: true},
|
||||||
|
{name: "class code", in: "5xx", want: 5000, ok: true},
|
||||||
|
{name: "invalid class", in: "9xx", want: 0, ok: false},
|
||||||
|
{name: "non-number", in: "abc", want: 0, ok: false},
|
||||||
|
{name: "too low", in: "99", want: 0, ok: false},
|
||||||
|
{name: "too high", in: "600", want: 0, ok: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, ok := parseStatusToken(tt.in)
|
||||||
|
if ok != tt.ok {
|
||||||
|
t.Fatalf("parseStatusToken(%q) ok = %v, want %v", tt.in, ok, tt.ok)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("parseStatusToken(%q) = %d, want %d", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRequestSearchQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
q := parseRequestSearchQuery("api method:post status:5xx status:404 foo:bar")
|
||||||
|
|
||||||
|
if len(q.methods) != 1 || q.methods[0] != "post" {
|
||||||
|
t.Fatalf("methods = %#v, want [post]", q.methods)
|
||||||
|
}
|
||||||
|
if _, ok := q.statusHuns[5]; !ok {
|
||||||
|
t.Fatal("expected status class 5xx to be parsed")
|
||||||
|
}
|
||||||
|
if _, ok := q.statuses[404]; !ok {
|
||||||
|
t.Fatal("expected status 404 to be parsed")
|
||||||
|
}
|
||||||
|
if len(q.terms) != 2 || q.terms[0] != "api" || q.terms[1] != "foo:bar" {
|
||||||
|
t.Fatalf("terms = %#v, want [api foo:bar]", q.terms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRequestSearchQuery_Table(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantTerms []string
|
||||||
|
wantMethods []string
|
||||||
|
wantStatuses []int
|
||||||
|
wantClasses []int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normalizes case and spaces",
|
||||||
|
input: " API.Example.Com METHOD:GET STATUS:2xx ",
|
||||||
|
wantTerms: []string{"api.example.com"},
|
||||||
|
wantMethods: []string{"get"},
|
||||||
|
wantStatuses: nil,
|
||||||
|
wantClasses: []int{2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid status token falls back to free text",
|
||||||
|
input: "status:wat service",
|
||||||
|
wantTerms: []string{"status:wat", "service"},
|
||||||
|
wantMethods: nil,
|
||||||
|
wantStatuses: nil,
|
||||||
|
wantClasses: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
q := parseRequestSearchQuery(tt.input)
|
||||||
|
|
||||||
|
if len(q.terms) != len(tt.wantTerms) {
|
||||||
|
t.Fatalf("terms len = %d, want %d", len(q.terms), len(tt.wantTerms))
|
||||||
|
}
|
||||||
|
for i := range tt.wantTerms {
|
||||||
|
if q.terms[i] != tt.wantTerms[i] {
|
||||||
|
t.Fatalf("terms[%d] = %q, want %q", i, q.terms[i], tt.wantTerms[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(q.methods) != len(tt.wantMethods) {
|
||||||
|
t.Fatalf("methods len = %d, want %d", len(q.methods), len(tt.wantMethods))
|
||||||
|
}
|
||||||
|
for i := range tt.wantMethods {
|
||||||
|
if q.methods[i] != tt.wantMethods[i] {
|
||||||
|
t.Fatalf("methods[%d] = %q, want %q", i, q.methods[i], tt.wantMethods[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, status := range tt.wantStatuses {
|
||||||
|
if _, ok := q.statuses[status]; !ok {
|
||||||
|
t.Fatalf("expected status %d to exist", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, class := range tt.wantClasses {
|
||||||
|
if _, ok := q.statusHuns[class]; !ok {
|
||||||
|
t.Fatalf("expected status class %dxx to exist", class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestMatchesQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := model.Request{
|
||||||
|
Method: "POST",
|
||||||
|
Host: "api.example.com",
|
||||||
|
URL: "/v1/login",
|
||||||
|
RawURL: "https://api.example.com/v1/login",
|
||||||
|
Status: 502,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "free text host", query: "api.example.com", want: true},
|
||||||
|
{name: "free text path", query: "/v1/login", want: true},
|
||||||
|
{name: "method match", query: "method:post", want: true},
|
||||||
|
{name: "method mismatch", query: "method:get", want: false},
|
||||||
|
{name: "status class match", query: "status:5xx", want: true},
|
||||||
|
{name: "status exact mismatch", query: "status:200", want: false},
|
||||||
|
{name: "combined and match", query: "api method:post status:5xx", want: true},
|
||||||
|
{name: "combined and mismatch", query: "api method:post status:2xx", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
q := parseRequestSearchQuery(tt.query)
|
||||||
|
got := requestMatchesQuery(req, q)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("requestMatchesQuery(%q) = %v, want %v", tt.query, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectedRequestUsesFilteredResults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := NewModel(make(chan model.Event), Controls{})
|
||||||
|
m.requests = []model.Request{
|
||||||
|
{ID: uuid.New(), Method: "GET", Host: "a.test", URL: "/a", Status: 200},
|
||||||
|
{ID: uuid.New(), Method: "POST", Host: "b.test", URL: "/b", Status: 500},
|
||||||
|
{ID: uuid.New(), Method: "GET", Host: "c.test", URL: "/c", Status: 201},
|
||||||
|
}
|
||||||
|
m.searchQuery = "status:5xx"
|
||||||
|
|
||||||
|
req, ok := m.selectedRequest()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("selectedRequest should succeed with filtered results")
|
||||||
|
}
|
||||||
|
if req.Host != "b.test" {
|
||||||
|
t.Fatalf("selected request host = %q, want %q", req.Host, "b.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.requestCursor = 9
|
||||||
|
m.clampRequestCursor()
|
||||||
|
if m.requestCursor != 0 {
|
||||||
|
t.Fatalf("requestCursor = %d, want 0 for single filtered row", m.requestCursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilteredRequestIndices_EmptyQueryReturnsAll(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := NewModel(make(chan model.Event), Controls{})
|
||||||
|
m.requests = []model.Request{
|
||||||
|
{ID: uuid.New(), Host: "a", URL: "/a"},
|
||||||
|
{ID: uuid.New(), Host: "b", URL: "/b"},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.searchQuery = ""
|
||||||
|
idx := m.filteredRequestIndices()
|
||||||
|
if len(idx) != 2 {
|
||||||
|
t.Fatalf("filteredRequestIndices len = %d, want 2", len(idx))
|
||||||
|
}
|
||||||
|
if idx[0] != 0 || idx[1] != 1 {
|
||||||
|
t.Fatalf("filtered indices = %#v, want [0 1]", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchEscClearsAndResetsSelectionState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := NewModel(make(chan model.Event), Controls{})
|
||||||
|
m.showSearch = true
|
||||||
|
m.searchQuery = "status:5xx"
|
||||||
|
m.requestCursor = 3
|
||||||
|
m.requestScroll = 2
|
||||||
|
m.detailsScroll = 4
|
||||||
|
|
||||||
|
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
got := next.(Model)
|
||||||
|
|
||||||
|
if got.showSearch {
|
||||||
|
t.Fatal("esc should close search mode")
|
||||||
|
}
|
||||||
|
if got.searchQuery != "" {
|
||||||
|
t.Fatal("esc should clear search query")
|
||||||
|
}
|
||||||
|
if got.requestCursor != 0 || got.requestScroll != 0 || got.detailsScroll != 0 {
|
||||||
|
t.Fatalf("scroll/cursor not reset: cursor=%d reqScroll=%d detScroll=%d", got.requestCursor, got.requestScroll, got.detailsScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchPaneUsesThemedBackgroundFill(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := NewModel(make(chan model.Event), Controls{})
|
||||||
|
line := m.renderSearchPane(24, 1)[0]
|
||||||
|
if lipgloss.Width(line) != 24 {
|
||||||
|
t.Fatalf("search line width = %d, want 24", lipgloss.Width(line))
|
||||||
|
}
|
||||||
|
if line == " " {
|
||||||
|
t.Fatal("search pane should render styled content, not raw spaces")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchPaneShowsQueryText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := NewModel(make(chan model.Event), Controls{})
|
||||||
|
m.searchQuery = "method:get status:2xx api"
|
||||||
|
line := m.renderSearchPane(64, 1)[0]
|
||||||
|
plain := ansi.Strip(line)
|
||||||
|
if !strings.Contains(plain, "method:get") || !strings.Contains(plain, "status:2xx") || !strings.Contains(plain, "api") {
|
||||||
|
t.Fatalf("search pane missing query text, got %q", plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,8 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@ -25,6 +27,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
// TODO: Abstract the keymaps
|
// TODO: Abstract the keymaps
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
if m.showSearch {
|
||||||
|
if m.handleSearchKey(msg) {
|
||||||
|
m.clampPaneScrolls()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "q":
|
case "ctrl+c", "q":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@ -66,6 +75,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.clampPaneScrolls()
|
m.clampPaneScrolls()
|
||||||
case "esc":
|
case "esc":
|
||||||
m.showSearch = false
|
m.showSearch = false
|
||||||
|
m.searchQuery = ""
|
||||||
m.clampPaneScrolls()
|
m.clampPaneScrolls()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@ -163,7 +173,8 @@ func (m *Model) moveRequestCursor(delta int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) clampRequestCursor() {
|
func (m *Model) clampRequestCursor() {
|
||||||
if len(m.requests) == 0 {
|
total := m.visibleRequestCount()
|
||||||
|
if total == 0 {
|
||||||
m.requestCursor = 0
|
m.requestCursor = 0
|
||||||
m.requestScroll = 0
|
m.requestScroll = 0
|
||||||
return
|
return
|
||||||
@ -173,8 +184,8 @@ func (m *Model) clampRequestCursor() {
|
|||||||
m.requestCursor = 0
|
m.requestCursor = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.requestCursor >= len(m.requests) {
|
if m.requestCursor >= total {
|
||||||
m.requestCursor = len(m.requests) - 1
|
m.requestCursor = total - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,7 +196,7 @@ func (m *Model) ensureRequestCursorVisible() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
maxScroll := max(0, len(m.requests)-viewHeight)
|
maxScroll := max(0, m.visibleRequestCount()-viewHeight)
|
||||||
if m.requestScroll < 0 {
|
if m.requestScroll < 0 {
|
||||||
m.requestScroll = 0
|
m.requestScroll = 0
|
||||||
}
|
}
|
||||||
@ -208,6 +219,154 @@ func (m *Model) ensureRequestCursorVisible() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleSearchKey(msg tea.KeyMsg) bool {
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
m.showSearch = false
|
||||||
|
m.searchQuery = ""
|
||||||
|
m.requestCursor = 0
|
||||||
|
m.requestScroll = 0
|
||||||
|
m.detailsScroll = 0
|
||||||
|
return true
|
||||||
|
case tea.KeyBackspace, tea.KeyCtrlH:
|
||||||
|
if len(m.searchQuery) > 0 {
|
||||||
|
m.searchQuery = m.searchQuery[:len(m.searchQuery)-1]
|
||||||
|
}
|
||||||
|
m.requestCursor = 0
|
||||||
|
m.requestScroll = 0
|
||||||
|
m.detailsScroll = 0
|
||||||
|
return true
|
||||||
|
case tea.KeyRunes:
|
||||||
|
if len(msg.Runes) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m.searchQuery += string(msg.Runes)
|
||||||
|
m.requestCursor = 0
|
||||||
|
m.requestScroll = 0
|
||||||
|
m.detailsScroll = 0
|
||||||
|
return true
|
||||||
|
case tea.KeySpace:
|
||||||
|
m.searchQuery += " "
|
||||||
|
m.requestCursor = 0
|
||||||
|
m.requestScroll = 0
|
||||||
|
m.detailsScroll = 0
|
||||||
|
return true
|
||||||
|
case tea.KeyEnter:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) visibleRequestCount() int {
|
||||||
|
return len(m.filteredRequestIndices())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) filteredRequestIndices() []int {
|
||||||
|
indices := make([]int, 0, len(m.requests))
|
||||||
|
query := parseRequestSearchQuery(m.searchQuery)
|
||||||
|
|
||||||
|
for i := range m.requests {
|
||||||
|
if requestMatchesQuery(m.requests[i], query) {
|
||||||
|
indices = append(indices, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return indices
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestSearchQuery struct {
|
||||||
|
terms []string
|
||||||
|
methods []string
|
||||||
|
statuses map[int]struct{}
|
||||||
|
statusHuns map[int]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRequestSearchQuery(input string) requestSearchQuery {
|
||||||
|
q := requestSearchQuery{
|
||||||
|
statuses: make(map[int]struct{}),
|
||||||
|
statusHuns: make(map[int]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, token := range strings.Fields(strings.ToLower(strings.TrimSpace(input))) {
|
||||||
|
if value, ok := strings.CutPrefix(token, "method:"); ok {
|
||||||
|
if value != "" {
|
||||||
|
q.methods = append(q.methods, value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := strings.CutPrefix(token, "status:"); ok {
|
||||||
|
if status, ok := parseStatusToken(value); ok {
|
||||||
|
if status >= 1000 {
|
||||||
|
q.statusHuns[status/1000] = struct{}{}
|
||||||
|
} else {
|
||||||
|
q.statuses[status] = struct{}{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
q.terms = append(q.terms, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStatusToken(value string) (int, bool) {
|
||||||
|
if len(value) == 3 && strings.HasSuffix(value, "xx") {
|
||||||
|
h := int(value[0] - '0')
|
||||||
|
if h >= 1 && h <= 5 {
|
||||||
|
return h * 1000, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := strconv.Atoi(value)
|
||||||
|
if err != nil || code < 100 || code > 599 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return code, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestMatchesQuery(req model.Request, query requestSearchQuery) bool {
|
||||||
|
if len(query.methods) > 0 {
|
||||||
|
method := strings.ToLower(req.Method)
|
||||||
|
matched := false
|
||||||
|
for _, want := range query.methods {
|
||||||
|
if method == want {
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query.statuses) > 0 || len(query.statusHuns) > 0 {
|
||||||
|
status := req.Status
|
||||||
|
_, exact := query.statuses[status]
|
||||||
|
_, class := query.statusHuns[status/100]
|
||||||
|
if !exact && !class {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query.terms) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
haystack := strings.ToLower(req.Host + " " + req.URL + " " + req.RawURL)
|
||||||
|
for _, term := range query.terms {
|
||||||
|
if !strings.Contains(haystack, term) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) moveDetailsTab(delta int) {
|
func (m *Model) moveDetailsTab(delta int) {
|
||||||
count := len(detailsTabNames)
|
count := len(detailsTabNames)
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"termtap.dev/internal/model"
|
"termtap.dev/internal/model"
|
||||||
)
|
)
|
||||||
@ -184,8 +185,8 @@ func TestPaneRenderersAndStatusBar(t *testing.T) {
|
|||||||
t.Fatalf("search pane len = %d, want 3", len(search))
|
t.Fatalf("search pane len = %d, want 3", len(search))
|
||||||
}
|
}
|
||||||
for i, line := range search {
|
for i, line := range search {
|
||||||
if len(line) != 20 {
|
if lipgloss.Width(line) != 20 {
|
||||||
t.Fatalf("search pane line %d len = %d, want %d", i, len(line), 20)
|
t.Fatalf("search pane line %d width = %d, want %d", i, lipgloss.Width(line), 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,6 +218,42 @@ func TestPaneRenderersAndStatusBar(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestSearchFiltering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := NewModel(make(chan model.Event), Controls{})
|
||||||
|
m.requests = []model.Request{
|
||||||
|
{ID: uuid.New(), Method: "GET", Host: "api.example.com", URL: "/v1/users", Status: 200},
|
||||||
|
{ID: uuid.New(), Method: "POST", Host: "api.example.com", URL: "/v1/login", Status: 401},
|
||||||
|
{ID: uuid.New(), Method: "GET", Host: "cdn.example.com", URL: "/asset.js", Status: 304},
|
||||||
|
{ID: uuid.New(), Method: "DELETE", Host: "api.example.com", URL: "/v1/users/42", Status: 500},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.searchQuery = "api.example.com method:get status:2xx"
|
||||||
|
idx := m.filteredRequestIndices()
|
||||||
|
if len(idx) != 1 {
|
||||||
|
t.Fatalf("filteredRequestIndices len = %d, want 1", len(idx))
|
||||||
|
}
|
||||||
|
if got := m.requests[idx[0]].URL; got != "/v1/users" {
|
||||||
|
t.Fatalf("filtered request URL = %q, want %q", got, "/v1/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.searchQuery = "status:5xx method:delete"
|
||||||
|
idx = m.filteredRequestIndices()
|
||||||
|
if len(idx) != 1 {
|
||||||
|
t.Fatalf("filteredRequestIndices len = %d, want 1", len(idx))
|
||||||
|
}
|
||||||
|
if got := m.requests[idx[0]].Status; got != 500 {
|
||||||
|
t.Fatalf("filtered request status = %d, want 500", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.searchQuery = "status:404"
|
||||||
|
idx = m.filteredRequestIndices()
|
||||||
|
if len(idx) != 0 {
|
||||||
|
t.Fatalf("filteredRequestIndices len = %d, want 0", len(idx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderEventsPane_ErrorAndPIDBranches(t *testing.T) {
|
func TestRenderEventsPane_ErrorAndPIDBranches(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user