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
events []model.Event
requests []model.Request
requestCursor int
requestScroll int
detailsTab int
detailsScroll int
eventsScroll int
stdScroll int
focusedPane int
width int
height int
@ -39,17 +53,24 @@ type Controls struct {
func NewModel(ch <-chan model.Event, controls Controls) Model {
return Model{
channel: ch,
controls: controls,
events: make([]model.Event, 0, maxEvents),
requests: make([]model.Request, 0, maxRequests),
width: 0,
height: 0,
showEvents: false,
showStd: false,
showSearch: false,
restarting: false,
theme: newTheme(),
channel: ch,
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,
showStd: false,
showSearch: false,
restarting: false,
theme: newTheme(),
}
}

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

@ -9,14 +9,17 @@ import (
type Theme struct {
Background lipgloss.Style
Header lipgloss.Style
EventHeader lipgloss.Style
StdHeader lipgloss.Style
Header lipgloss.Style
EventHeader lipgloss.Style
EventPaneHeader lipgloss.Style
StdHeader lipgloss.Style
Text lipgloss.Style
TextMuted lipgloss.Style
TextError lipgloss.Style
TextMutedError 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.