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:
parent
f87d4fc040
commit
d47e1d578c
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user