feat: mainly just UI things.

I did not give a full review to the panes.go file. Its just too big and
likely pretty simple.
This commit is contained in:
Hayden Hargreaves 2026-04-21 22:09:03 -07:00
parent f87d4fc040
commit d47e1d578c
5 changed files with 950 additions and 68 deletions

View File

@ -14,12 +14,26 @@ const (
maxRequests = 256
)
const (
focusPaneRequests = iota
focusPaneDetails
focusPaneEvents
focusPaneStd
)
type Model struct {
channel <-chan model.Event
controls Controls
events []model.Event
requests []model.Request
requestCursor int
requestScroll int
detailsTab int
detailsScroll int
eventsScroll int
stdScroll int
focusedPane int
width int
height int
@ -43,6 +57,13 @@ func NewModel(ch <-chan model.Event, controls Controls) Model {
controls: controls,
events: make([]model.Event, 0, maxEvents),
requests: make([]model.Request, 0, maxRequests),
requestCursor: 0,
requestScroll: 0,
detailsTab: detailsTabOverview,
detailsScroll: 0,
eventsScroll: 0,
stdScroll: 0,
focusedPane: focusPaneRequests,
width: 0,
height: 0,
showEvents: false,

View File

@ -1,16 +1,31 @@
package tui
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"termtap.dev/internal/model"
)
// TODO: LOTS OF THIS SUCKS BUT IT WORKS
const (
detailsTabOverview = iota
detailsTabRequest
detailsTabResponse
detailsTabHeaders
)
var detailsTabNames = []string{"Overview", "Request", "Response", "Headers"}
func (m Model) renderStatusBar(w int) string {
var errCount int
var msSum int64
@ -23,7 +38,15 @@ func (m Model) renderStatusBar(w int) string {
avg := int(msSum) / max(1, len(m.requests))
left := fmt.Sprintf(" tap %3d reqs | %d err | avg %dms", len(m.requests), errCount, avg)
right := "j/k nav / search tab panel e events o output ^r restart q quit "
logState := "logs off"
if m.showEvents && m.showStd {
logState = "events+output"
} else if m.showEvents {
logState = "events"
} else if m.showStd {
logState = "output"
}
right := " " + logState + " "
spaceSize := max(w-(len(left)+len(right)), 0)
space := strings.Repeat(" ", spaceSize)
@ -31,6 +54,91 @@ func (m Model) renderStatusBar(w int) string {
return m.theme.Header.Render(left + space + right)
}
func (m Model) renderBottomStatusBar(w int) string {
if w <= 0 {
return ""
}
modeLabel := "REQ"
modeColor := blue
switch m.focusedPane {
case focusPaneDetails:
modeLabel = "DETAIL"
modeColor = blue
case focusPaneEvents:
modeLabel = "EVENT"
modeColor = green
case focusPaneStd:
modeLabel = "OUT"
modeColor = orange
}
modeStyle := lipgloss.NewStyle().Foreground(background).Background(modeColor).Bold(true)
left := modeStyle.Render(" " + modeLabel + " ")
if m.restarting {
left += m.theme.Text.Render(" ") + m.theme.EventWarn.Render(" RESTARTING ")
}
right := m.theme.TextMuted.Render(" " + m.bottomStatusRight() + " ")
if lipgloss.Width(right) >= w {
return clampRendered(right, w)
}
maxLeft := max(0, w-lipgloss.Width(right)-1)
left = clampRendered(left, maxLeft)
spaceSize := max(1, w-lipgloss.Width(left)-lipgloss.Width(right))
space := m.theme.Text.Render(strings.Repeat(" ", spaceSize))
return clampRendered(left+space+right, w)
}
func (m Model) bottomStatusRight() string {
selected, total := m.requestSelectionStats()
switch m.focusedPane {
case focusPaneRequests:
return fmt.Sprintf("req %d/%d", selected, total)
case focusPaneDetails:
tab := "overview"
if m.detailsTab >= 0 && m.detailsTab < len(detailsTabNames) {
tab = strings.ToLower(detailsTabNames[m.detailsTab])
}
linesTotal := m.detailsContentLineCount(m.detailsPaneWidth())
linesPos := 0
if linesTotal > 0 {
linesPos = min(linesTotal, m.detailsScroll+1)
}
return fmt.Sprintf("req %d/%d | tab %s | %d/%d", selected, total, tab, linesPos, linesTotal)
case focusPaneEvents:
count := m.eventCount()
if m.eventsScroll == 0 {
return fmt.Sprintf("events %d | LIVE", count)
}
return fmt.Sprintf("events %d | PAUSED", count)
case focusPaneStd:
count := m.stdLogCount()
if m.stdScroll == 0 {
return fmt.Sprintf("lines %d | LIVE", count)
}
return fmt.Sprintf("lines %d | PAUSED", count)
}
return ""
}
func (m Model) requestSelectionStats() (selected int, total int) {
total = len(m.requests)
if total == 0 {
return 0, 0
}
selected = min(total, max(1, m.requestCursor+1))
return selected, total
}
// TODO: Implement
func (m Model) renderSearchPane(w, h int) []string {
lines := make([]string, h)
@ -43,39 +151,79 @@ func (m Model) renderSearchPane(w, h int) []string {
func (m Model) renderRequestPane(w, h int) []string {
var lines []string
titleStyle := m.theme.TextMuted
if m.focusedPane == focusPaneRequests {
titleStyle = m.theme.EventHeader
}
title := titleStyle.Render(padToWidth("[1] REQUESTS", w))
lines = append(lines, title)
// Render header
headerLeft := fmt.Sprintf(" %-7s %-24s %s", "METHOD", "HOST", "PATH")
headerRight := fmt.Sprintf("%4s %8s ", "CODE", "TIME")
headerSpace := strings.Repeat(" ", max(0, w-len(headerLeft+headerRight)))
header := headerLeft + headerSpace + headerRight
header := m.theme.TextMuted.Render(headerLeft + headerSpace + headerRight)
lines = append(lines, header)
for i := len(m.requests) - 1; i >= 0; i-- {
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]
// Some formatting magic here maybe
left := fmt.Sprintf(
" %-7s %-24s %s",
strings.ToUpper(req.Method),
truncate(req.Host, 24),
req.URL,
)
right := fmt.Sprintf(
"%4d %8s ",
req.Status,
formatDuration(req.Duration),
)
duration := req.Duration
if req.Pending && !req.StartTime.IsZero() {
right = fmt.Sprintf(
"%4s %8s ",
"",
formatDuration(time.Since(req.StartTime)),
)
duration = time.Since(req.StartTime)
}
space := strings.Repeat(" ", max(0, w-len(left+right)))
line := left + space + right
lines = append(lines, line)
statusStyle := lipgloss.NewStyle().Foreground(green).Background(background).Bold(true)
if req.Failed || req.Status >= 500 {
statusStyle = lipgloss.NewStyle().Foreground(red).Background(background).Bold(true)
} else if req.Status >= 400 {
statusStyle = lipgloss.NewStyle().Foreground(orange).Background(background).Bold(true)
}
latencyStyle := m.theme.Text
if duration >= 2*time.Second {
latencyStyle = lipgloss.NewStyle().Foreground(orange).Background(background).Bold(true)
}
methodCol := statusStyle.Render(fmt.Sprintf("%-7s", truncate(strings.ToUpper(req.Method), 7)))
hostCol := m.theme.Text.Render(fmt.Sprintf("%-24s", truncate(req.Host, 24)))
pathCol := m.theme.Text.Render(req.URL)
statusText := ""
if !req.Pending && req.Status > 0 {
statusText = fmt.Sprintf("%d", req.Status)
}
statusCol := statusStyle.Render(fmt.Sprintf("%4s", statusText))
timeCol := latencyStyle.Render(fmt.Sprintf("%8s", formatDuration(duration)))
sep := m.theme.Text.Render(" ")
left := sep + methodCol + sep + hostCol + sep + pathCol
right := statusCol + sep + timeCol + sep
space := strings.Repeat(" ", max(0, w-lipgloss.Width(left+right)))
line := left + m.theme.Text.Render(space) + right
line = clampRendered(line, w)
if row == m.requestCursor {
line = m.theme.RequestSelected.Render(ansi.Strip(line))
}
bodyLines = append(bodyLines, line)
}
bodyHeight := max(0, h-len(lines))
scroll := m.requestScroll
maxScroll := max(0, len(bodyLines)-bodyHeight)
if scroll < 0 {
scroll = 0
}
if scroll > maxScroll {
scroll = maxScroll
}
if bodyHeight > 0 {
end := min(len(bodyLines), scroll+bodyHeight)
if scroll < end {
lines = append(lines, bodyLines[scroll:end]...)
}
}
// Cleanup
@ -92,16 +240,396 @@ func (m Model) renderRequestPane(w, h int) []string {
return lines
}
// TODO: Implement
func (m Model) renderDetailsPane(w, h int) []string {
lines := make([]string, h)
for y := range lines {
lines[y] = m.theme.Text.Render(strings.Repeat(" ", w))
if h <= 0 {
return nil
}
formatLine := func(content string) string {
line := truncate(content, w)
if len(line) < w {
line += strings.Repeat(" ", w-len(line))
}
return m.theme.Text.Render(line)
}
formatMutedLine := func(content string) string {
line := truncate(content, w)
if len(line) < w {
line += strings.Repeat(" ", w-len(line))
}
return m.theme.TextMuted.Render(line)
}
formatMutedItalicLine := func(content string) string {
line := truncate(content, w)
if len(line) < w {
line += strings.Repeat(" ", w-len(line))
}
return lipgloss.NewStyle().
Foreground(textMuted).
Background(background).
Italic(true).
Render(line)
}
renderTabRow := func() string {
if w <= 0 {
return ""
}
activeTabStyle := lipgloss.NewStyle().
Foreground(background).
Background(blue).
Bold(true)
var parts []string
for i, name := range detailsTabNames {
label := " " + name + " "
if i == m.detailsTab {
label = " [" + name + "] "
parts = append(parts, activeTabStyle.Render(label))
continue
}
parts = append(parts, m.theme.TextMuted.Render(label))
}
sep := m.theme.Text.Render(" ")
line := strings.Join(parts, sep)
if lipgloss.Width(line) < w {
pad := strings.Repeat(" ", w-lipgloss.Width(line))
line += m.theme.Text.Render(pad)
}
return clampRendered(line, w)
}
lines := make([]string, 0, h)
detailsTitleStyle := m.theme.TextMuted
if m.focusedPane == focusPaneDetails {
detailsTitleStyle = m.theme.EventHeader
}
lines = append(lines, detailsTitleStyle.Render(padToWidth("[2] DETAIL", w)))
if len(lines) >= h {
return lines[:h]
}
lines = append(lines, renderTabRow())
if len(lines) >= h {
return lines[:h]
}
contentLines := m.detailsContentLines(w, formatLine, formatMutedLine, formatMutedItalicLine)
contentHeight := max(0, h-len(lines))
scroll := m.detailsScroll
maxScroll := max(0, len(contentLines)-contentHeight)
if scroll < 0 {
scroll = 0
}
if scroll > maxScroll {
scroll = maxScroll
}
if contentHeight > 0 {
end := min(len(contentLines), scroll+contentHeight)
if scroll < end {
lines = append(lines, contentLines[scroll:end]...)
}
}
for len(lines) < h {
lines = append(lines, formatLine(""))
}
if len(lines) > h {
return lines[:h]
}
return lines
}
func padToWidth(s string, w int) string {
if w <= 0 {
return ""
}
s = truncate(s, w)
if len(s) < w {
s += strings.Repeat(" ", w-len(s))
}
return s
}
func (m Model) detailsContentLines(
w int,
formatLine func(string) string,
formatMutedLine func(string) string,
formatMutedItalicLine func(string) string,
) []string {
selectedReq, ok := m.selectedRequest()
if !ok {
return []string{formatLine(" No requests yet. Use j/k once requests arrive.")}
}
contentLines := make([]string, 0, 64)
switch m.detailsTab {
case detailsTabOverview:
duration := selectedReq.Duration
if selectedReq.Pending && !selectedReq.StartTime.IsZero() {
duration = time.Since(selectedReq.StartTime)
}
renderKVLine := func(key, value string, valueStyle lipgloss.Style) string {
keyPart := m.theme.TextMuted.Render(fmt.Sprintf(" %-8s", key))
valuePart := valueStyle.Render(value)
sep := m.theme.Text.Render(" ")
line := keyPart + sep + valuePart
if lipgloss.Width(line) < w {
line += m.theme.Text.Render(strings.Repeat(" ", w-lipgloss.Width(line)))
}
return clampRendered(line, w)
}
statusText := "-"
statusStyle := m.theme.TextMuted
if selectedReq.Status > 0 {
statusText = fmt.Sprintf("%d", selectedReq.Status)
statusStyle = m.theme.EventSuccess
if selectedReq.Status >= 400 {
statusStyle = m.theme.EventWarn
}
if selectedReq.Status >= 500 {
statusStyle = m.theme.EventError
}
}
timeText := "-"
if !selectedReq.StartTime.IsZero() {
timeText = selectedReq.StartTime.Format("3:04:05 PM")
}
contentLines = append(contentLines, formatLine(""))
contentLines = append(contentLines, renderKVLine("Method", strings.ToUpper(selectedReq.Method), m.theme.Text))
contentLines = append(contentLines, renderKVLine("URL", selectedReq.RawURL, m.theme.TextMuted))
queryText := selectedReq.QueryString
if queryText == "" {
queryText = "-"
}
contentLines = append(contentLines, renderKVLine("Query", queryText, m.theme.TextMuted))
contentLines = append(contentLines, renderKVLine("Status", statusText, statusStyle))
contentLines = append(contentLines, renderKVLine("Latency", formatDuration(duration), m.theme.Text))
contentLines = append(contentLines, renderKVLine("Time", timeText, m.theme.Text))
contentLines = append(contentLines, formatLine(""))
contentLines = append(contentLines, formatMutedLine(" Timing"))
barValue := formatDuration(duration)
barPrefix := " "
barSuffix := " " + barValue
maxBarWidth := max(1, w/2)
barWidth := min(maxBarWidth, max(0, w-len(barPrefix)-len(barSuffix)))
if barWidth == 0 {
contentLines = append(contentLines, formatLine(" "+barValue))
break
}
const maxDurationForScale = 2 * time.Second
ratio := float64(duration) / float64(maxDurationForScale)
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
filled := int(ratio * float64(barWidth))
if duration > 0 && filled == 0 {
filled = 1
}
if filled > barWidth {
filled = barWidth
}
empty := max(0, barWidth-filled)
filledPart := lipgloss.NewStyle().Foreground(blue).Render(strings.Repeat("█", filled))
emptyPart := lipgloss.NewStyle().Foreground(cyan).Render(strings.Repeat("░", empty))
barLine := barPrefix + filledPart + emptyPart + m.theme.TextMuted.Render(barSuffix)
contentLines = append(contentLines, clampRendered(barLine, w))
case detailsTabRequest:
contentLines = append(contentLines, formatLine(""))
contentLines = append(contentLines, formatMutedLine(" -- Request Body --"))
contentLines = append(contentLines, formatLine(""))
if len(selectedReq.RequestData) == 0 {
contentLines = append(contentLines, formatMutedItalicLine(" empty"))
break
}
for _, line := range formatBodyLines(prettyBody(selectedReq.RequestData, selectedReq.RequestHeaders), w) {
contentLines = append(contentLines, formatLine(" "+line))
}
case detailsTabResponse:
contentLines = append(contentLines, formatLine(""))
contentLines = append(contentLines, formatMutedLine(" -- Response Body --"))
contentLines = append(contentLines, formatLine(""))
if len(selectedReq.ResponseData) == 0 {
contentLines = append(contentLines, formatMutedItalicLine(" empty"))
break
}
for _, line := range formatBodyLines(prettyBody(selectedReq.ResponseData, selectedReq.ResponseHeaders), w) {
contentLines = append(contentLines, formatLine(" "+line))
}
case detailsTabHeaders:
contentLines = append(contentLines, formatLine(""))
renderHeaderLine := func(key, value string) string {
left := m.theme.HeaderKey.Render(" " + key + ": ")
right := m.theme.TextMuted.Render(value)
line := left + right
if lipgloss.Width(line) < w {
line += m.theme.Text.Render(strings.Repeat(" ", w-lipgloss.Width(line)))
}
return clampRendered(line, w)
}
appendHeaders := func(title string, headers map[string][]string) {
contentLines = append(contentLines, formatMutedLine(" -- "+title+" --"))
contentLines = append(contentLines, formatLine(""))
if len(headers) == 0 {
contentLines = append(contentLines, formatMutedItalicLine(" empty"))
contentLines = append(contentLines, formatLine(""))
return
}
keys := make([]string, 0, len(headers))
for key := range headers {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
contentLines = append(contentLines, renderHeaderLine(key, strings.Join(headers[key], ", ")))
}
contentLines = append(contentLines, formatLine(""))
}
appendHeaders("Request Headers", selectedReq.RequestHeaders)
appendHeaders("Response Headers", selectedReq.ResponseHeaders)
default:
contentLines = append(contentLines, formatLine(" Unknown details tab"))
}
return contentLines
}
func (m Model) detailsContentLineCount(w int) int {
plain := func(s string) string { return s }
return len(m.detailsContentLines(w, plain, plain, plain))
}
func (m Model) selectedRequest() (model.Request, bool) {
if len(m.requests) == 0 {
return model.Request{}, false
}
cursor := m.requestCursor
if cursor < 0 {
cursor = 0
}
if cursor >= len(m.requests) {
cursor = len(m.requests) - 1
}
idx := len(m.requests) - cursor - 1
if idx < 0 || idx >= len(m.requests) {
return model.Request{}, false
}
return m.requests[idx], true
}
func formatBodyLines(body []byte, width int) []string {
if len(body) == 0 {
return []string{"(empty)"}
}
text := string(body)
if !utf8.Valid(body) {
previewSize := min(len(body), 64)
hexPreview := hex.EncodeToString(body[:previewSize])
suffix := ""
if previewSize < len(body) {
suffix = "..."
}
text = fmt.Sprintf("(binary payload, %d bytes, hex preview: %s%s)", len(body), hexPreview, suffix)
}
src := strings.ReplaceAll(text, "\t", " ")
rawLines := strings.Split(src, "\n")
if width <= 4 {
return rawLines
}
maxWidth := max(1, width-2)
out := make([]string, 0, len(rawLines))
for _, line := range rawLines {
if line == "" {
out = append(out, "")
continue
}
for len(line) > maxWidth {
out = append(out, line[:maxWidth])
line = line[maxWidth:]
}
out = append(out, line)
}
return out
}
func prettyBody(body []byte, headers map[string][]string) []byte {
if len(body) == 0 {
return body
}
if !looksLikeJSON(body, headers) {
return body
}
var out bytes.Buffer
if err := json.Indent(&out, body, "", " "); err != nil {
return body
}
return out.Bytes()
}
func looksLikeJSON(body []byte, headers map[string][]string) bool {
if json.Valid(body) {
return true
}
for key, values := range headers {
if !strings.EqualFold(key, "Content-Type") {
continue
}
for _, value := range values {
contentType := strings.ToLower(value)
if strings.Contains(contentType, "application/json") || strings.Contains(contentType, "+json") {
return true
}
}
}
return false
}
// TODO: This can be done better
// TODO: Should h be max or defined?
func (m Model) renderEventsPane(w, h int) []string {
@ -114,18 +642,30 @@ func (m Model) renderEventsPane(w, h int) []string {
}
}
displayCount := max(h-1, 0)
if displayCount < len(events) {
events = events[len(events)-displayCount:]
}
left := fmt.Sprintf("EVENT LOG - %d EVENTS", len(events))
left := fmt.Sprintf("[3] EVENT LOG - %d EVENTS", len(events))
right := "E: TOGGLE"
status := m.theme.EventHeader.Render(left + strings.Repeat(" ", w-len(left+right)) + right)
headerStyle := m.theme.TextMuted
if m.focusedPane == focusPaneEvents {
headerStyle = m.theme.EventPaneHeader
}
status := headerStyle.Render(left + strings.Repeat(" ", max(0, w-len(left+right))) + right)
lines := []string{status}
for _, event := range events {
bodyHeight := max(0, h-1)
maxScroll := max(0, len(events)-bodyHeight)
scroll := m.eventsScroll
if scroll < 0 {
scroll = 0
}
if scroll > maxScroll {
scroll = maxScroll
}
start := max(0, len(events)-bodyHeight-scroll)
end := min(len(events), start+bodyHeight)
visible := events[start:end]
for _, event := range visible {
var (
eTime string = m.theme.TextMuted.Render(event.Time.Format("15:04:05") + " ")
eType string = getEventColor(m.theme, event.Type).Render(fmt.Sprintf("%-17s ", event.Type))
@ -165,6 +705,16 @@ func (m Model) renderEventsPane(w, h int) []string {
return lines
}
func (m Model) eventCount() int {
count := 0
for _, ev := range m.events {
if ev.Type != model.EventTypeProcessStderr && ev.Type != model.EventTypeProcessStdout {
count++
}
}
return count
}
// TODO: Should h be max or defined?
func (m Model) renderStdPane(w, h int) []string {
// Only the stdout or stderr logs
@ -176,18 +726,30 @@ func (m Model) renderStdPane(w, h int) []string {
}
}
displayCount := max(h-1, 0)
if displayCount < len(logs) {
logs = logs[len(logs)-displayCount:]
}
left := fmt.Sprintf("STDOUT/STDERR LOG - %d LINES", len(logs))
left := fmt.Sprintf("[4] STDOUT/STDERR LOG - %d LINES", len(logs))
right := "O: TOGGLE"
status := m.theme.StdHeader.Render(left + strings.Repeat(" ", w-len(left+right)) + right)
headerStyle := m.theme.TextMuted
if m.focusedPane == focusPaneStd {
headerStyle = m.theme.StdHeader
}
status := headerStyle.Render(left + strings.Repeat(" ", max(0, w-len(left+right))) + right)
lines := []string{status}
for _, log := range logs {
bodyHeight := max(0, h-1)
maxScroll := max(0, len(logs)-bodyHeight)
scroll := m.stdScroll
if scroll < 0 {
scroll = 0
}
if scroll > maxScroll {
scroll = maxScroll
}
start := max(0, len(logs)-bodyHeight-scroll)
end := min(len(logs), start+bodyHeight)
visible := logs[start:end]
for _, log := range visible {
var (
tag string
body string
@ -225,3 +787,13 @@ func (m Model) renderStdPane(w, h int) []string {
return lines
}
func (m Model) stdLogCount() int {
count := 0
for _, ev := range m.events {
if ev.Type == model.EventTypeProcessStderr || ev.Type == model.EventTypeProcessStdout {
count++
}
}
return count
}

View File

@ -4,13 +4,13 @@ import "strings"
func (m Model) renderAppPane() string {
// Constant height offset
constHeightOffset := 1
constHeightOffset := 2
var (
searchW int = max(0, m.width)
searchH int = 1
reqW int = max(0, int(float64(m.width)*0.55))
reqW int = max(0, int(float64(m.width)*0.5))
detW int = max(0, m.width-reqW)
reqH int = max(0, m.height-constHeightOffset)
@ -68,6 +68,9 @@ func (m Model) renderAppPane() string {
screen = append(screen, stdPane...)
}
statusBottom := m.renderBottomStatusBar(m.width)
screen = append(screen, statusBottom)
if len(screen) != m.height {
return "height of screen does not match terminal height"
}

View File

@ -11,12 +11,15 @@ type Theme struct {
Header lipgloss.Style
EventHeader lipgloss.Style
EventPaneHeader lipgloss.Style
StdHeader lipgloss.Style
Text lipgloss.Style
TextMuted lipgloss.Style
TextError lipgloss.Style
TextMutedError lipgloss.Style
RequestSelected lipgloss.Style
HeaderKey lipgloss.Style
EventDefault lipgloss.Style
EventSession lipgloss.Style
@ -56,6 +59,10 @@ func newTheme() Theme {
Bold(true).
Foreground(background).
Background(blue),
EventPaneHeader: lipgloss.NewStyle().
Bold(true).
Foreground(background).
Background(green),
StdHeader: lipgloss.NewStyle().
Bold(true).
Foreground(background).
@ -73,6 +80,13 @@ func newTheme() Theme {
TextMutedError: lipgloss.NewStyle().
Foreground(textMuted).
Background(backgroundError),
RequestSelected: lipgloss.NewStyle().
Foreground(background).
Background(blue).
Bold(true),
HeaderKey: lipgloss.NewStyle().
Foreground(cyan).
Background(background),
EventDefault: lipgloss.NewStyle().
Foreground(text).

View File

@ -13,6 +13,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.clampPaneScrolls()
return m, nil
case TickMsg:
@ -27,6 +28,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@ -38,12 +55,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.clampPaneScrolls()
}
return m, nil
@ -69,6 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case EventMsg:
m.pushEvent(msg.value)
m.applyMessage(msg.value)
m.clampPaneScrolls()
if m.hasPendingRequests() {
return m, tea.Batch(waitForEvent(m.channel), tickCmd())
}
@ -99,6 +123,10 @@ func (m *Model) createRequest(req model.Request) {
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
@ -106,6 +134,8 @@ func (m *Model) createRequest(req model.Request) {
if len(m.requests) > maxRequests {
m.requests = m.requests[1:]
}
m.clampRequestCursor()
}
func (m *Model) updateRequest(req model.Request) {
@ -119,6 +149,248 @@ func (m *Model) updateRequest(req model.Request) {
}
}
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() {
if len(m.requests) == 0 {
m.requestCursor = 0
m.requestScroll = 0
return
}
if m.requestCursor < 0 {
m.requestCursor = 0
}
if m.requestCursor >= len(m.requests) {
m.requestCursor = len(m.requests) - 1
}
}
func (m *Model) ensureRequestCursorVisible() {
viewHeight := m.requestBodyHeight()
if viewHeight <= 0 {
m.requestScroll = 0
return
}
maxScroll := max(0, len(m.requests)-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) 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.