818 lines
21 KiB
Go
818 lines
21 KiB
Go
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
|
|
for _, req := range m.requests {
|
|
if req.Failed || (req.Status >= 400 && req.Status < 600) {
|
|
errCount++
|
|
}
|
|
msSum += req.Duration.Milliseconds()
|
|
}
|
|
|
|
avg := int(msSum) / max(1, len(m.requests))
|
|
left := fmt.Sprintf(" tap %3d reqs | %d err | avg %dms", len(m.requests), errCount, avg)
|
|
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)
|
|
|
|
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 = m.visibleRequestCount()
|
|
if total == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
selected = min(total, max(1, m.requestCursor+1))
|
|
return selected, total
|
|
}
|
|
|
|
func (m Model) renderSearchPane(w, h int) []string {
|
|
if h <= 0 {
|
|
return nil
|
|
}
|
|
|
|
left := m.theme.TextMuted.Render(" / SEARCH ")
|
|
hint := m.theme.TextMuted.Render(" host/path method:get status:5xx ")
|
|
if strings.TrimSpace(m.searchQuery) != "" {
|
|
hint = m.theme.Text.Render(" " + m.searchQuery + " ")
|
|
}
|
|
|
|
line := left + hint
|
|
line = clampRendered(line, w)
|
|
if lipgloss.Width(line) < w {
|
|
line += m.theme.Text.Render(strings.Repeat(" ", w-lipgloss.Width(line)))
|
|
}
|
|
|
|
lines := make([]string, h)
|
|
lines[0] = line
|
|
for y := 1; y < h; y++ {
|
|
lines[y] = m.theme.Text.Render(strings.Repeat(" ", w))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
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 := m.theme.TextMuted.Render(headerLeft + headerSpace + headerRight)
|
|
lines = append(lines, header)
|
|
|
|
visible := m.filteredRequestIndices()
|
|
bodyLines := make([]string, 0, len(visible))
|
|
for i, row := len(visible)-1, 0; i >= 0; i, row = i-1, row+1 {
|
|
req := m.requests[visible[i]]
|
|
duration := req.Duration
|
|
if req.Pending && !req.StartTime.IsZero() {
|
|
duration = time.Since(req.StartTime)
|
|
}
|
|
|
|
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
|
|
if len(lines) < h {
|
|
for i := len(lines); i < h; i++ {
|
|
lines = append(lines, strings.Repeat(" ", w))
|
|
}
|
|
}
|
|
|
|
if len(lines) > h {
|
|
lines = lines[:h]
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
func (m Model) renderDetailsPane(w, h int) []string {
|
|
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) {
|
|
visible := m.filteredRequestIndices()
|
|
if len(visible) == 0 {
|
|
return model.Request{}, false
|
|
}
|
|
|
|
cursor := m.requestCursor
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if cursor >= len(visible) {
|
|
cursor = len(visible) - 1
|
|
}
|
|
|
|
idx := len(visible) - cursor - 1
|
|
if idx < 0 || idx >= len(visible) {
|
|
return model.Request{}, false
|
|
}
|
|
|
|
return m.requests[visible[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 {
|
|
// Remove the stdout or stderr logs
|
|
var events []model.Event
|
|
for _, ev := range m.events {
|
|
if ev.Type != model.EventTypeProcessStderr &&
|
|
ev.Type != model.EventTypeProcessStdout {
|
|
events = append(events, ev)
|
|
}
|
|
}
|
|
|
|
left := fmt.Sprintf("[3] EVENT LOG - %d EVENTS", len(events))
|
|
right := "E: TOGGLE"
|
|
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}
|
|
|
|
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))
|
|
|
|
avail int = max(0, w-lipgloss.Width(eTime+eType))
|
|
body string = clampRendered(m.theme.Text.Render(event.Body), avail)
|
|
)
|
|
|
|
if event.Type == model.EventTypeRequestFailed || event.Type == model.EventTypeFatal {
|
|
body = clampRendered(m.theme.TextError.Render(event.Body), avail)
|
|
eTime = m.theme.TextMutedError.Render(event.Time.Format("15:04:05") + " ")
|
|
}
|
|
|
|
line := eTime + eType + body
|
|
if event.PID > 0 {
|
|
pid := m.theme.TextMuted.Render(fmt.Sprintf("%d ", event.PID))
|
|
|
|
avail = max(0, w-lipgloss.Width(eTime+eType+pid))
|
|
body = clampRendered(m.theme.Text.Render(event.Body), avail)
|
|
line = eTime + eType + pid + body
|
|
}
|
|
|
|
if event.Type == model.EventTypeRequestFailed || event.Type == model.EventTypeFatal {
|
|
line += m.theme.TextError.Render(strings.Repeat(" ", w-lipgloss.Width(line)))
|
|
}
|
|
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
// Cleanup
|
|
if len(lines) < h {
|
|
for i := len(lines); i < h; i++ {
|
|
lines = append(lines, "")
|
|
}
|
|
}
|
|
|
|
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
|
|
var logs []model.Event
|
|
for _, ev := range m.events {
|
|
if ev.Type == model.EventTypeProcessStderr ||
|
|
ev.Type == model.EventTypeProcessStdout {
|
|
logs = append(logs, ev)
|
|
}
|
|
}
|
|
|
|
left := fmt.Sprintf("[4] STDOUT/STDERR LOG - %d LINES", len(logs))
|
|
right := "O: TOGGLE"
|
|
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}
|
|
|
|
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
|
|
timePart string
|
|
)
|
|
if log.Type == model.EventTypeProcessStderr {
|
|
tag = m.theme.TextError.Render("ERR ")
|
|
timePart = m.theme.TextMutedError.Render(log.Time.Format("15:04:05") + " ")
|
|
|
|
prefix := timePart + tag
|
|
avail := max(0, w-lipgloss.Width(prefix))
|
|
body = clampRendered(m.theme.TextError.Render(log.Body), avail)
|
|
|
|
pad := max(0, avail-lipgloss.Width(body))
|
|
body += m.theme.TextMutedError.Render(strings.Repeat(" ", pad))
|
|
}
|
|
if log.Type == model.EventTypeProcessStdout {
|
|
tag = m.theme.TextMuted.Render("OUT ")
|
|
timePart = m.theme.TextMuted.Render(log.Time.Format("15:04:05") + " ")
|
|
|
|
prefix := timePart + tag
|
|
avail := max(0, w-lipgloss.Width(prefix))
|
|
body = clampRendered(m.theme.Text.Render(log.Body), avail)
|
|
}
|
|
line := clampRendered(timePart+tag+body, w)
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
// Cleanup
|
|
if len(lines) < h {
|
|
for i := len(lines); i < h; i++ {
|
|
lines = append(lines, "")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|