termtap/internal/tui/update.go
Hayden Hargreaves 8c399b6754 feat: search functionality.
Bit rushed, but its on the site. Going to add a real larger prompt about
it.
2026-05-01 14:51:00 -07:00

563 lines
11 KiB
Go

package tui
import (
"fmt"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"termtap.dev/internal/model"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.clampPaneScrolls()
return m, nil
case TickMsg:
m.now = msg.Now
if m.hasPendingRequests() {
return m, tickCmd()
}
return m, nil
// 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
case "j", "down":
m.moveFocusedVertical(1)
case "k", "up":
m.moveFocusedVertical(-1)
case "tab":
m.moveDetailsTab(1)
case "shift+tab", "backtab":
m.moveDetailsTab(-1)
case "1":
m.setFocusedPane(focusPaneRequests)
case "2":
m.setFocusedPane(focusPaneDetails)
case "3":
m.setFocusedPane(focusPaneEvents)
case "4":
m.setFocusedPane(focusPaneStd)
case tea.KeyCtrlR.String():
if m.restarting {
return m, nil
}
if m.controls.Restart == nil {
return m, nil
}
m.restarting = true
return m, restartCmd(m.controls.Restart)
case "e":
m.showEvents = !m.showEvents
m.ensureFocusedPaneVisible()
m.clampPaneScrolls()
case "o":
m.showStd = !m.showStd
m.ensureFocusedPaneVisible()
m.clampPaneScrolls()
case "/":
m.showSearch = true
m.clampPaneScrolls()
case "esc":
m.showSearch = false
m.searchQuery = ""
m.clampPaneScrolls()
}
return m, nil
case ErrMsg:
m.pushEvent(model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn,
Body: fmt.Sprintf("tui event stream closed: %v", msg.err),
})
return m, nil
case RestartResultMsg:
m.restarting = false
if msg.err != nil {
m.pushEvent(model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn,
Body: fmt.Sprintf("failed to restart process: %v", msg.err),
})
}
return m, nil
case EventMsg:
m.pushEvent(msg.value)
m.applyMessage(msg.value)
m.clampPaneScrolls()
if m.hasPendingRequests() {
return m, tea.Batch(waitForEvent(m.channel), tickCmd())
}
return m, waitForEvent(m.channel)
}
return m, nil
}
func (m *Model) pushEvent(msg model.Event) {
m.events = append(m.events, msg)
if len(m.events) > maxEvents {
m.events = m.events[len(m.events)-maxEvents:]
}
}
func (m *Model) applyMessage(msg model.Event) {
switch msg.Type {
case model.EventTypeRequestStarted:
m.createRequest(msg.Request)
case model.EventTypeRequestFinished, model.EventTypeRequestFailed:
m.updateRequest(msg.Request)
}
}
func (m *Model) createRequest(req model.Request) {
if req.Method == "CONNECT" {
return
}
if len(m.requests) > 0 && m.requestCursor > 0 {
m.requestCursor++
}
m.requests = append(m.requests, req)
// If we passed the max, delete the first one
// Maybe we should notify the user?
if len(m.requests) > maxRequests {
m.requests = m.requests[1:]
}
m.clampRequestCursor()
}
func (m *Model) updateRequest(req model.Request) {
// Traverse backward, since the newest one is at the end, and its likely we will be
// updated a new request.
for i := len(m.requests) - 1; i >= 0; i-- {
if m.requests[i].ID == req.ID {
m.requests[i] = req
break
}
}
}
func (m *Model) moveRequestCursor(delta int) {
if len(m.requests) == 0 {
m.requestCursor = 0
m.requestScroll = 0
return
}
m.requestCursor += delta
m.clampRequestCursor()
m.ensureRequestCursorVisible()
m.detailsScroll = 0
}
func (m *Model) clampRequestCursor() {
total := m.visibleRequestCount()
if total == 0 {
m.requestCursor = 0
m.requestScroll = 0
return
}
if m.requestCursor < 0 {
m.requestCursor = 0
}
if m.requestCursor >= total {
m.requestCursor = total - 1
}
}
func (m *Model) ensureRequestCursorVisible() {
viewHeight := m.requestBodyHeight()
if viewHeight <= 0 {
m.requestScroll = 0
return
}
maxScroll := max(0, m.visibleRequestCount()-viewHeight)
if m.requestScroll < 0 {
m.requestScroll = 0
}
if m.requestScroll > maxScroll {
m.requestScroll = maxScroll
}
if m.requestCursor < m.requestScroll {
m.requestScroll = m.requestCursor
}
if m.requestCursor >= m.requestScroll+viewHeight {
m.requestScroll = m.requestCursor - viewHeight + 1
}
if m.requestScroll < 0 {
m.requestScroll = 0
}
if m.requestScroll > maxScroll {
m.requestScroll = maxScroll
}
}
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 {
m.detailsTab = 0
m.detailsScroll = 0
return
}
m.detailsTab = (m.detailsTab + delta) % count
if m.detailsTab < 0 {
m.detailsTab += count
}
m.detailsScroll = 0
m.clampPaneScrolls()
}
func (m *Model) moveFocusedVertical(delta int) {
switch m.focusedPane {
case focusPaneRequests:
m.moveRequestCursor(delta)
case focusPaneDetails:
m.detailsScroll += delta
case focusPaneEvents:
if delta > 0 {
m.eventsScroll = max(0, m.eventsScroll-delta)
} else {
m.eventsScroll += -delta
}
case focusPaneStd:
if delta > 0 {
m.stdScroll = max(0, m.stdScroll-delta)
} else {
m.stdScroll += -delta
}
}
m.clampPaneScrolls()
}
func (m *Model) setFocusedPane(pane int) {
if !m.canFocusPane(pane) {
return
}
m.focusedPane = pane
}
func (m *Model) canFocusPane(pane int) bool {
switch pane {
case focusPaneRequests, focusPaneDetails:
return true
case focusPaneEvents:
return m.showEvents
case focusPaneStd:
return m.showStd
default:
return false
}
}
func (m *Model) ensureFocusedPaneVisible() {
if m.canFocusPane(m.focusedPane) {
return
}
if m.canFocusPane(focusPaneDetails) {
m.focusedPane = focusPaneDetails
return
}
m.focusedPane = focusPaneRequests
}
func (m *Model) clampPaneScrolls() {
if m.requestScroll < 0 {
m.requestScroll = 0
}
if m.detailsScroll < 0 {
m.detailsScroll = 0
}
if m.eventsScroll < 0 {
m.eventsScroll = 0
}
if m.stdScroll < 0 {
m.stdScroll = 0
}
m.ensureRequestCursorVisible()
maxDetails := m.maxDetailsScroll()
if m.detailsScroll > maxDetails {
m.detailsScroll = maxDetails
}
maxEvents := m.maxEventsScroll()
if m.eventsScroll > maxEvents {
m.eventsScroll = maxEvents
}
maxStd := m.maxStdScroll()
if m.stdScroll > maxStd {
m.stdScroll = maxStd
}
}
func (m Model) panelHeights() (requestHeight, detailsHeight, eventsHeight, stdHeight int) {
const constHeightOffset = 1
requestHeight = max(0, m.height-constHeightOffset)
detailsHeight = max(0, m.height-constHeightOffset)
eventsHeight = max(0, int(float64(m.height)*0.2))
stdHeight = max(0, int(float64(m.height)*0.2))
if m.showSearch {
requestHeight = max(0, requestHeight-1)
detailsHeight = max(0, detailsHeight-1)
}
if m.showEvents {
requestHeight = max(0, requestHeight-eventsHeight)
detailsHeight = max(0, detailsHeight-eventsHeight)
}
if m.showStd {
requestHeight = max(0, requestHeight-stdHeight)
detailsHeight = max(0, detailsHeight-stdHeight)
}
return requestHeight, detailsHeight, eventsHeight, stdHeight
}
func (m Model) requestBodyHeight() int {
requestHeight, _, _, _ := m.panelHeights()
return max(0, requestHeight-2)
}
func (m Model) detailsBodyHeight() int {
_, detailsHeight, _, _ := m.panelHeights()
return max(0, detailsHeight-2)
}
func (m Model) detailsPaneWidth() int {
requestWidth := max(0, int(float64(m.width)*0.5))
return max(0, m.width-requestWidth)
}
func (m Model) maxDetailsScroll() int {
bodyHeight := m.detailsBodyHeight()
if bodyHeight <= 0 {
return 0
}
total := m.detailsContentLineCount(m.detailsPaneWidth())
return max(0, total-bodyHeight)
}
func (m Model) maxEventsScroll() int {
if !m.showEvents {
return 0
}
_, _, eventsHeight, _ := m.panelHeights()
bodyHeight := max(0, eventsHeight-1)
if bodyHeight <= 0 {
return 0
}
return max(0, m.eventCount()-bodyHeight)
}
func (m Model) maxStdScroll() int {
if !m.showStd {
return 0
}
_, _, _, stdHeight := m.panelHeights()
bodyHeight := max(0, stdHeight-1)
if bodyHeight <= 0 {
return 0
}
return max(0, m.stdLogCount()-bodyHeight)
}
func (m Model) hasPendingRequests() bool {
// Traverse backward to be a bit more efficient, the most recent requests are more
// like to be pending.
for i := len(m.requests) - 1; i >= 0; i-- {
if m.requests[i].Pending {
return true
}
}
return false
}