Merge pull request 'feature/search' (#1) from feature/search into master

Reviewed-on: #1
This commit is contained in:
Hayden Hargreaves 2026-05-01 15:11:24 -07:00
commit d372043dbd
7 changed files with 732 additions and 31 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 {

366
internal/tui/search_test.go Normal file
View File

@ -0,0 +1,366 @@
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,
},
{
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 {
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 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()
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,9 +2,13 @@ package tui
import ( import (
"fmt" "fmt"
"strconv"
"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"
) )
@ -25,6 +29,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 +77,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
@ -123,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)
@ -135,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()
} }
@ -163,7 +179,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 +190,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 +202,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 +225,195 @@ 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 {
_, size := utf8.DecodeLastRuneInString(m.searchQuery)
m.searchQuery = m.searchQuery[:len(m.searchQuery)-size]
}
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
}
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
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{}),
}
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)
continue
}
}
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 {
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()

View File

@ -8,6 +8,7 @@ const contents = [
{ id: 'examples', label: 'examples' }, { id: 'examples', label: 'examples' },
{ id: 'https-certs', label: 'https & certs' }, { id: 'https-certs', label: 'https & certs' },
{ id: 'keymaps', label: 'keymaps' }, { id: 'keymaps', label: 'keymaps' },
{ id: 'search', label: 'search' },
{ id: 'privacy', label: 'privacy' }, { id: 'privacy', label: 'privacy' },
] ]
@ -298,7 +299,52 @@ export function DocsPage() {
</p> </p>
</DocsSection> </DocsSection>
<DocsSection id="privacy" number="07" title="Privacy"> <DocsSection id="search" number="07" title="Search">
<p>
Press <code className="px-0 text-slate-200">/</code> to open search. Filtering is live as you type and
applies to the Requests pane.
</p>
<p className="text-slate-500">Search supports free text and simple field filters.</p>
<div className="overflow-x-auto rounded-md border border-slate-800 bg-slate-900">
<table className="w-full min-w-[32rem] text-sm text-slate-300">
<thead className="border-b border-slate-800 text-xs uppercase tracking-widest text-slate-500">
<tr>
<th className="px-4 py-3 text-left font-medium">Pattern</th>
<th className="px-4 py-3 text-left font-medium">Meaning</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-800/60">
<td className="px-4 py-3 font-mono text-emerald-400">users api.example.com</td>
<td className="px-4 py-3">Free text; matches host and URL path (AND between terms)</td>
</tr>
<tr className="border-b border-slate-800/60">
<td className="px-4 py-3 font-mono text-emerald-400">method:get</td>
<td className="px-4 py-3">Method equals GET (case-insensitive)</td>
</tr>
<tr className="border-b border-slate-800/60">
<td className="px-4 py-3 font-mono text-emerald-400">status:404</td>
<td className="px-4 py-3">Exact response status code</td>
</tr>
<tr className="border-b border-slate-800/60">
<td className="px-4 py-3 font-mono text-emerald-400">status:5xx</td>
<td className="px-4 py-3">Status class match (1xx-5xx)</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-emerald-400">method: get status: 5xx login</td>
<td className="px-4 py-3">Mixed filters + terms, including spaced filter syntax</td>
</tr>
</tbody>
</table>
</div>
<p className="text-sm text-slate-500">
Tip: press <span className="text-slate-300">esc</span> to close search and clear the active query.
</p>
</DocsSection>
<DocsSection id="privacy" number="08" title="Privacy">
<p>Termtap is a local-only tool. It does not phone home, collect telemetry, or send any data anywhere.</p> <p>Termtap is a local-only tool. It does not phone home, collect telemetry, or send any data anywhere.</p>
<ul className="space-y-3"> <ul className="space-y-3">
<li> <li>