// 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)
}