feature/search #1

Merged
azpect merged 3 commits from feature/search into master 2026-05-01 15:11:24 -07:00
7 changed files with 732 additions and 31 deletions

View File

@ -38,11 +38,12 @@ type Model struct {
width int
height int
theme Theme
showEvents bool
showStd bool
showSearch bool
restarting bool
theme Theme
showEvents bool
showStd bool
showSearch bool
searchQuery string
restarting bool
now time.Time
}
@ -69,6 +70,7 @@ func NewModel(ch <-chan model.Event, controls Controls) Model {
showEvents: false,
showStd: false,
showSearch: false,
searchQuery: "",
restarting: false,
theme: newTheme(),
}

View File

@ -29,6 +29,9 @@ func TestNewModelDefaults(t *testing.T) {
if m.showEvents || m.showStd || m.showSearch || m.restarting {
t.Fatal("toggle flags should initialize false")
}
if m.searchQuery != "" {
t.Fatal("search query should initialize empty")
}
}
func TestInitBatchesEventAndTick(t *testing.T) {
@ -188,10 +191,33 @@ func TestUpdate(t *testing.T) {
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 {
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")})
if cmd6 != nil {
@ -310,7 +336,7 @@ func TestModelHelpers(t *testing.T) {
t.Run("pushEvent trims to maxEvents", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
for i := 0; i < maxEvents+5; i++ {
for range maxEvents + 5 {
m.pushEvent(model.Event{Body: "x"})
}
if len(m.events) != maxEvents {
@ -326,7 +352,7 @@ func TestModelHelpers(t *testing.T) {
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})
}
if len(m.requests) != maxRequests {

View File

@ -130,7 +130,7 @@ func (m Model) bottomStatusRight() string {
}
func (m Model) requestSelectionStats() (selected int, total int) {
total = len(m.requests)
total = m.visibleRequestCount()
if total == 0 {
return 0, 0
}
@ -139,11 +139,27 @@ func (m Model) requestSelectionStats() (selected int, total int) {
return selected, total
}
// TODO: Implement
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)
for y := range lines {
lines[y] = strings.Repeat(" ", w)
lines[0] = line
for y := 1; y < h; y++ {
lines[y] = m.theme.Text.Render(strings.Repeat(" ", w))
}
return lines
}
@ -165,9 +181,10 @@ func (m Model) renderRequestPane(w, h int) []string {
header := m.theme.TextMuted.Render(headerLeft + headerSpace + headerRight)
lines = append(lines, header)
bodyLines := make([]string, 0, len(m.requests))
for i, row := len(m.requests)-1, 0; i >= 0; i, row = i-1, row+1 {
req := m.requests[i]
visible := m.filteredRequestIndices()
bodyLines := make([]string, 0, len(visible))
for i, row := len(visible)-1, 0; i >= 0; i, row = i-1, row+1 {
req := m.requests[visible[i]]
duration := req.Duration
if req.Pending && !req.StartTime.IsZero() {
duration = time.Since(req.StartTime)
@ -532,7 +549,8 @@ func (m Model) detailsContentLineCount(w int) int {
}
func (m Model) selectedRequest() (model.Request, bool) {
if len(m.requests) == 0 {
visible := m.filteredRequestIndices()
if len(visible) == 0 {
return model.Request{}, false
}
@ -540,16 +558,16 @@ func (m Model) selectedRequest() (model.Request, bool) {
if cursor < 0 {
cursor = 0
}
if cursor >= len(m.requests) {
cursor = len(m.requests) - 1
if cursor >= len(visible) {
cursor = len(visible) - 1
}
idx := len(m.requests) - cursor - 1
if idx < 0 || idx >= len(m.requests) {
idx := len(visible) - cursor - 1
if idx < 0 || idx >= len(visible) {
return model.Request{}, false
}
return m.requests[idx], true
return m.requests[visible[idx]], true
}
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 (
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/uuid"
"termtap.dev/internal/model"
)
@ -25,6 +29,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// TODO: Abstract the keymaps
case tea.KeyMsg:
if m.showSearch {
if m.handleSearchKey(msg) {
m.clampPaneScrolls()
return m, nil
}
}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
@ -66,6 +77,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.clampPaneScrolls()
case "esc":
m.showSearch = false
m.searchQuery = ""
m.clampPaneScrolls()
}
return m, nil
@ -123,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)
@ -135,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()
}
@ -163,7 +179,8 @@ func (m *Model) moveRequestCursor(delta int) {
}
func (m *Model) clampRequestCursor() {
if len(m.requests) == 0 {
total := m.visibleRequestCount()
if total == 0 {
m.requestCursor = 0
m.requestScroll = 0
return
@ -173,8 +190,8 @@ func (m *Model) clampRequestCursor() {
m.requestCursor = 0
}
if m.requestCursor >= len(m.requests) {
m.requestCursor = len(m.requests) - 1
if m.requestCursor >= total {
m.requestCursor = total - 1
}
}
@ -185,7 +202,7 @@ func (m *Model) ensureRequestCursorVisible() {
return
}
maxScroll := max(0, len(m.requests)-viewHeight)
maxScroll := max(0, m.visibleRequestCount()-viewHeight)
if 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) {
count := len(detailsTabNames)
if count == 0 {

View File

@ -5,6 +5,7 @@ import (
"testing"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"termtap.dev/internal/model"
)
@ -184,8 +185,8 @@ func TestPaneRenderersAndStatusBar(t *testing.T) {
t.Fatalf("search pane len = %d, want 3", len(search))
}
for i, line := range search {
if len(line) != 20 {
t.Fatalf("search pane line %d len = %d, want %d", i, len(line), 20)
if lipgloss.Width(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) {
t.Parallel()

View File

@ -8,6 +8,7 @@ const contents = [
{ id: 'examples', label: 'examples' },
{ id: 'https-certs', label: 'https & certs' },
{ id: 'keymaps', label: 'keymaps' },
{ id: 'search', label: 'search' },
{ id: 'privacy', label: 'privacy' },
]
@ -298,7 +299,52 @@ export function DocsPage() {
</p>
</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>
<ul className="space-y-3">
<li>