internal/tui/model.go

// Package tui renders the stream of captured HTTP exchanges as a
// bubbletea program. See mercemay.top/src/httptap/ for the bigger
// picture.
package tui

import (
	"fmt"
	"strings"
	"time"

	"github.com/atotto/clipboard"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"

	"mercemay.top/src/httptap/internal/parser"
	"mercemay.top/src/httptap/internal/tracer"
)

// Options tweaks the TUI without forcing us to thread flags through the
// whole package.
type Options struct {
	NoColor bool
}

type row struct {
	seq     int
	pid     uint32
	when    time.Time
	method  string
	target  string
	status  int
	dur     time.Duration
	reqSize int
	resSize int
}

// Model is the bubbletea model.
type Model struct {
	rows     []row
	cursor   int
	expanded bool
	filter   string
	filterOn bool
	err      error

	events <-chan tracer.Event
	errs   <-chan error

	styles styles
	width  int
	height int
}

type styles struct {
	header, row, selected, status2xx, status4xx, status5xx, dim lipgloss.Style
}

func mkStyles(noColor bool) styles {
	if noColor {
		return styles{}
	}
	return styles{
		header:    lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("238")),
		row:       lipgloss.NewStyle(),
		selected:  lipgloss.NewStyle().Background(lipgloss.Color("237")),
		status2xx: lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
		status4xx: lipgloss.NewStyle().Foreground(lipgloss.Color("11")),
		status5xx: lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
		dim:       lipgloss.NewStyle().Foreground(lipgloss.Color("244")),
	}
}

// NewModel builds a model from the tracer channels.
func NewModel(events <-chan tracer.Event, errs <-chan error, opts Options) Model {
	return Model{events: events, errs: errs, styles: mkStyles(opts.NoColor)}
}

type eventMsg tracer.Event
type tickMsg time.Time
type errMsg error

// Init starts pumping events into the bubbletea runtime.
func (m Model) Init() tea.Cmd {
	return tea.Batch(m.waitEvent(), m.waitErr(), tick())
}

func (m Model) waitEvent() tea.Cmd {
	return func() tea.Msg {
		ev, ok := <-m.events
		if !ok {
			return nil
		}
		return eventMsg(ev)
	}
}

func (m Model) waitErr() tea.Cmd {
	return func() tea.Msg {
		err, ok := <-m.errs
		if !ok {
			return nil
		}
		return errMsg(err)
	}
}

func tick() tea.Cmd {
	return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}

// Update handles keyboard, window resize, and incoming events.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
		return m, nil

	case tea.KeyMsg:
		return m.handleKey(msg)

	case eventMsg:
		m.ingest(tracer.Event(msg))
		return m, m.waitEvent()

	case tickMsg:
		return m, tick()

	case errMsg:
		m.err = error(msg)
		return m, nil
	}
	return m, nil
}

func (m *Model) ingest(ev tracer.Event) {
	if ev.Direction == tracer.DirWrite {
		req, ok := parser.ParseRequest(ev.Data)
		if !ok {
			return
		}
		m.rows = append(m.rows, row{
			seq:     len(m.rows) + 1,
			pid:     ev.PID,
			when:    time.Now(),
			method:  req.Method,
			target:  req.Target,
			reqSize: len(ev.Data),
		})
		return
	}

	if res, ok := parser.ParseResponse(ev.Data); ok && len(m.rows) > 0 {
		// crude pairing: attach to last unclosed row for same pid
		for i := len(m.rows) - 1; i >= 0; i-- {
			if m.rows[i].pid == ev.PID && m.rows[i].status == 0 {
				m.rows[i].status = res.Status
				m.rows[i].dur = time.Since(m.rows[i].when)
				m.rows[i].resSize = len(ev.Data)
				break
			}
		}
	}
}

func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
	switch msg.String() {
	case "q", "ctrl+c":
		return m, tea.Quit
	case "j", "down":
		if m.cursor < len(m.rows)-1 {
			m.cursor++
		}
	case "k", "up":
		if m.cursor > 0 {
			m.cursor--
		}
	case "g":
		m.cursor = 0
	case "G":
		m.cursor = len(m.rows) - 1
	case "enter":
		m.expanded = !m.expanded
	case "y":
		if m.cursor < len(m.rows) {
			_ = clipboard.WriteAll(asCurl(m.rows[m.cursor]))
		}
	}
	return m, nil
}

// View renders the model to a string.
func (m Model) View() string {
	if m.err != nil {
		return fmt.Sprintf("tracer error: %s\n", m.err)
	}
	var b strings.Builder
	b.WriteString(m.styles.header.Render(fmt.Sprintf(" httptap  %d exchanges ", len(m.rows))))
	b.WriteString("\n")
	for i, r := range m.rows {
		line := fmt.Sprintf(" %4d  %-6s %-40s  %3d  %8s  %dB",
			r.seq, r.method, truncate(r.target, 40), r.status, r.dur.Round(time.Millisecond), r.reqSize+r.resSize)
		line = m.paint(r.status, line)
		if i == m.cursor {
			line = m.styles.selected.Render(line)
		}
		b.WriteString(line)
		b.WriteString("\n")
	}
	b.WriteString(m.styles.dim.Render("?: help  q: quit  /: filter  enter: expand  y: yank curl"))
	return b.String()
}

func (m Model) paint(status int, s string) string {
	switch {
	case status >= 500:
		return m.styles.status5xx.Render(s)
	case status >= 400:
		return m.styles.status4xx.Render(s)
	case status >= 200:
		return m.styles.status2xx.Render(s)
	}
	return s
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n-1] + "..."
}

func asCurl(r row) string {
	return fmt.Sprintf("curl -X %s %q", r.method, r.target)
}