From 8c399b6754642f311a04173cfc711e4d94faff88 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 1 May 2026 14:51:00 -0700 Subject: [PATCH 1/3] feat: search functionality. Bit rushed, but its on the site. Going to add a real larger prompt about it. --- internal/tui/model.go | 12 +- internal/tui/model_update_test.go | 32 ++- internal/tui/panes.go | 44 +++- internal/tui/search_test.go | 260 ++++++++++++++++++++ internal/tui/update.go | 167 ++++++++++++- internal/tui/view_split_panes_style_test.go | 41 ++- 6 files changed, 529 insertions(+), 27 deletions(-) create mode 100644 internal/tui/search_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index 60e1e54..dd6fa8d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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(), } diff --git a/internal/tui/model_update_test.go b/internal/tui/model_update_test.go index 75d52cc..d778f00 100644 --- a/internal/tui/model_update_test.go +++ b/internal/tui/model_update_test.go @@ -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 { diff --git a/internal/tui/panes.go b/internal/tui/panes.go index f88e0c5..3ef70b5 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -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 { diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go new file mode 100644 index 0000000..47839cf --- /dev/null +++ b/internal/tui/search_test.go @@ -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) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 3784163..8670eb0 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -2,6 +2,8 @@ package tui import ( "fmt" + "strconv" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -25,6 +27,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 +75,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 @@ -163,7 +173,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 +184,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 +196,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 +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) { count := len(detailsTabNames) if count == 0 { diff --git a/internal/tui/view_split_panes_style_test.go b/internal/tui/view_split_panes_style_test.go index 86a3934..7391d4d 100644 --- a/internal/tui/view_split_panes_style_test.go +++ b/internal/tui/view_split_panes_style_test.go @@ -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() -- 2.47.2 From 699a551549f65338381adbc92cec24a949c1cb5e Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 1 May 2026 15:00:33 -0700 Subject: [PATCH 2/3] fix: resolved some search related issues --- internal/tui/search_test.go | 106 ++++++++++++++++++++++++++++++++++++ internal/tui/update.go | 57 +++++++++++++++++-- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go index 47839cf..eedab32 100644 --- a/internal/tui/search_test.go +++ b/internal/tui/search_test.go @@ -88,6 +88,14 @@ func TestParseRequestSearchQuery_Table(t *testing.T) { 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 { @@ -234,6 +242,104 @@ func TestSearchEscClearsAndResetsSelectionState(t *testing.T) { } } +func TestSearchBackspaceHandlesUnicodeRune(t *testing.T) { + t.Parallel() + + m := NewModel(make(chan model.Event), Controls{}) + m.showSearch = true + m.searchQuery = "a\u00e9" + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + got := next.(Model) + if got.searchQuery != "a" { + t.Fatalf("searchQuery after unicode backspace = %q, want %q", got.searchQuery, "a") + } +} + +func TestCreateRequestKeepsSelectionInFilteredView(t *testing.T) { + t.Parallel() + + selectedID := uuid.New() + m := NewModel(make(chan model.Event), Controls{}) + m.requests = []model.Request{ + {ID: uuid.New(), Method: "GET", Host: "api", URL: "/ok", Status: 200}, + {ID: selectedID, Method: "GET", Host: "api", URL: "/err", Status: 500}, + } + m.searchQuery = "status:5xx" + m.requestCursor = 0 + + before, ok := m.selectedRequest() + if !ok || before.ID != selectedID { + t.Fatal("expected selected filtered request before createRequest") + } + + m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new-ok", Status: 201}) + after, ok := m.selectedRequest() + if !ok || after.ID != selectedID { + t.Fatal("selection should stay on same request when new request does not match filter") + } + if m.requestCursor != 0 { + t.Fatalf("requestCursor = %d, want 0 with single visible row", m.requestCursor) + } + + m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new-err", Status: 502}) + afterMatch, ok := m.selectedRequest() + if !ok || afterMatch.ID != selectedID { + t.Fatal("selection should stay on same request when matching request arrives") + } + if m.requestCursor != 1 { + t.Fatalf("requestCursor = %d, want 1 to keep selected row anchored", m.requestCursor) + } +} + +func TestCreateRequestWhenSelectedEntryEvictedByTrim(t *testing.T) { + t.Parallel() + + m := NewModel(make(chan model.Event), Controls{}) + m.searchQuery = "status:5xx" + + for i := 0; i < maxRequests; i++ { + status := 200 + if i%2 == 0 { + status = 500 + } + m.requests = append(m.requests, model.Request{ + ID: uuid.New(), + Method: "GET", + Host: "api", + URL: "/r", + Status: status, + }) + } + + visible := m.filteredRequestIndices() + if len(visible) == 0 { + t.Fatal("expected non-empty visible filtered set") + } + + // Pick the oldest visible row so it gets evicted first. + m.requestCursor = len(visible) - 1 + selectedBefore, ok := m.selectedRequest() + if !ok { + t.Fatal("expected selected request before trim") + } + + for { + m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new", Status: 201}) + selectedAfter, ok := m.selectedRequest() + if !ok { + t.Fatal("expected selection after trim") + } + if selectedAfter.ID != selectedBefore.ID { + break + } + } + + if m.requestCursor < 0 || m.requestCursor >= m.visibleRequestCount() { + t.Fatalf("requestCursor out of bounds after eviction: %d of %d", m.requestCursor, m.visibleRequestCount()) + } +} + func TestSearchPaneUsesThemedBackgroundFill(t *testing.T) { t.Parallel() diff --git a/internal/tui/update.go b/internal/tui/update.go index 8670eb0..da51da7 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -5,8 +5,10 @@ import ( "strconv" "strings" "time" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" + "github.com/google/uuid" "termtap.dev/internal/model" ) @@ -133,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) @@ -145,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() } @@ -230,7 +236,8 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) bool { return true case tea.KeyBackspace, tea.KeyCtrlH: if len(m.searchQuery) > 0 { - m.searchQuery = m.searchQuery[:len(m.searchQuery)-1] + _, size := utf8.DecodeLastRuneInString(m.searchQuery) + m.searchQuery = m.searchQuery[:len(m.searchQuery)-size] } m.requestCursor = 0 m.requestScroll = 0 @@ -275,6 +282,17 @@ func (m Model) filteredRequestIndices() []int { 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 @@ -288,7 +306,20 @@ func parseRequestSearchQuery(input string) requestSearchQuery { statusHuns: make(map[int]struct{}), } - for _, token := range strings.Fields(strings.ToLower(strings.TrimSpace(input))) { + tokens := strings.Fields(strings.ToLower(strings.TrimSpace(input))) + for i := 0; i < len(tokens); i++ { + token := tokens[i] + + if token == "method:" { + if i+1 < len(tokens) && tokens[i+1] != "" { + q.methods = append(q.methods, tokens[i+1]) + i++ + continue + } + q.terms = append(q.terms, token) + continue + } + if value, ok := strings.CutPrefix(token, "method:"); ok { if value != "" { q.methods = append(q.methods, value) @@ -296,6 +327,22 @@ func parseRequestSearchQuery(input string) requestSearchQuery { } } + if token == "status:" { + if i+1 < len(tokens) { + if status, ok := parseStatusToken(tokens[i+1]); ok { + if status >= 1000 { + q.statusHuns[status/1000] = struct{}{} + } else { + q.statuses[status] = struct{}{} + } + i++ + continue + } + } + q.terms = append(q.terms, token) + continue + } + if value, ok := strings.CutPrefix(token, "status:"); ok { if status, ok := parseStatusToken(value); ok { if status >= 1000 { -- 2.47.2 From a1667dbf46df30a27c3edf693d967c3fe7bd8cc5 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 1 May 2026 15:06:35 -0700 Subject: [PATCH 3/3] feat: added section to web app --- web/src/pages/DocsPage.tsx | 48 +++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx index 65c745e..57e86ba 100644 --- a/web/src/pages/DocsPage.tsx +++ b/web/src/pages/DocsPage.tsx @@ -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() {

- + +

+ Press / to open search. Filtering is live as you type and + applies to the Requests pane. +

+

Search supports free text and simple field filters.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PatternMeaning
users api.example.comFree text; matches host and URL path (AND between terms)
method:getMethod equals GET (case-insensitive)
status:404Exact response status code
status:5xxStatus class match (1xx-5xx)
method: get status: 5xx loginMixed filters + terms, including spaced filter syntax
+
+ +

+ Tip: press esc to close search and clear the active query. +

+
+ +

Termtap is a local-only tool. It does not phone home, collect telemetry, or send any data anywhere.

  • -- 2.47.2