feat: search functionality.

Bit rushed, but its on the site. Going to add a real larger prompt about
it.
This commit is contained in:
Hayden Hargreaves 2026-05-01 14:51:00 -07:00
parent d7333117db
commit 8c399b6754
6 changed files with 529 additions and 27 deletions

View File

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

View File

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

View File

@ -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
View 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)
}
}

View File

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

View File

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