internal/tui/view/list/list.go

// Package list is the scrollable left-hand pane that lists captured
// requests. Selection state and filtering are owned here; the detail
// pane simply observes App.selected().
//
// mercemay.top/src/httptap/
package list

import (
	"strings"

	tea "github.com/charmbracelet/bubbletea"

	"mercemay.top/httptap/internal/parser"
	"mercemay.top/httptap/internal/tui/theme"
)

// Model is the list state.
type Model struct {
	pal     theme.Palette
	items   []parser.Message
	filter  Matcher
	cursor  int
	top     int
	width   int
	height  int
	visible []int // indexes into items after filtering
}

// Matcher is a predicate used to filter rows; nil allows everything.
type Matcher func(parser.Message) bool

// New creates an empty list.
func New(pal theme.Palette) Model {
	return Model{pal: pal}
}

// Append adds a message to the end of the list and rebuilds the filter
// projection if a filter is active.
func (m *Model) Append(msg parser.Message) {
	m.items = append(m.items, msg)
	m.rebuild()
}

// SetFilter installs a new matcher and rebuilds the visible projection.
// If expr is empty the filter is cleared.
func (m *Model) SetFilter(expr string) {
	if strings.TrimSpace(expr) == "" {
		m.filter = nil
	} else {
		m.filter = CompileMatcher(expr)
	}
	m.rebuild()
}

// rebuild refreshes the visible index slice using the current filter.
func (m *Model) rebuild() {
	m.visible = m.visible[:0]
	for i, it := range m.items {
		if m.filter == nil || m.filter(it) {
			m.visible = append(m.visible, i)
		}
	}
	if m.cursor >= len(m.visible) {
		m.cursor = len(m.visible) - 1
	}
	if m.cursor < 0 {
		m.cursor = 0
	}
}

// SetSize is called by App.relayout.
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }

// Height exposes the current height for app tests.
func (m Model) Height() int { return m.height }

// Len reports the current number of visible rows.
func (m Model) Len() int { return len(m.visible) }

// Selected returns the message under the cursor, if any.
func (m Model) Selected() (parser.Message, bool) {
	if len(m.visible) == 0 {
		return parser.Message{}, false
	}
	return m.items[m.visible[m.cursor]], true
}

// Update handles navigation keys.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
	if k, ok := msg.(tea.KeyMsg); ok {
		switch k.String() {
		case "up", "k":
			if m.cursor > 0 {
				m.cursor--
			}
		case "down", "j":
			if m.cursor+1 < len(m.visible) {
				m.cursor++
			}
		case "g", "home":
			m.cursor = 0
		case "G", "end":
			m.cursor = len(m.visible) - 1
			if m.cursor < 0 {
				m.cursor = 0
			}
		}
		// Keep cursor visible.
		if m.cursor < m.top {
			m.top = m.cursor
		}
		if m.cursor >= m.top+m.height-1 {
			m.top = m.cursor - m.height + 2
		}
	}
	return m, nil
}

// View renders a slice of visible rows via render.go (item.go).
func (m Model) View() string {
	if len(m.visible) == 0 {
		return m.pal.List.Width(m.width).Render("(no requests)")
	}
	return m.pal.List.Width(m.width).Render(m.renderRows())
}