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
|
maxRequests = 256
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
focusPaneRequests = iota
|
||||||
|
focusPaneDetails
|
||||||
|
focusPaneEvents
|
||||||
|
focusPaneStd
|
||||||
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
channel <-chan model.Event
|
channel <-chan model.Event
|
||||||
controls Controls
|
controls Controls
|
||||||
|
|
||||||
events []model.Event
|
events []model.Event
|
||||||
requests []model.Request
|
requests []model.Request
|
||||||
|
requestCursor int
|
||||||
|
requestScroll int
|
||||||
|
detailsTab int
|
||||||
|
detailsScroll int
|
||||||
|
eventsScroll int
|
||||||
|
stdScroll int
|
||||||
|
focusedPane int
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@ -43,6 +57,13 @@ func NewModel(ch <-chan model.Event, controls Controls) Model {
|
|||||||
controls: controls,
|
controls: controls,
|
||||||
events: make([]model.Event, 0, maxEvents),
|
events: make([]model.Event, 0, maxEvents),
|
||||||
requests: make([]model.Request, 0, maxRequests),
|
requests: make([]model.Request, 0, maxRequests),
|
||||||
|
requestCursor: 0,
|
||||||
|
requestScroll: 0,
|
||||||
|
detailsTab: detailsTabOverview,
|
||||||
|
detailsScroll: 0,
|
||||||
|
eventsScroll: 0,
|
||||||
|
stdScroll: 0,
|
||||||
|
focusedPane: focusPaneRequests,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
showEvents: false,
|
showEvents: false,
|
||||||
|
|||||||
@ -1,16 +1,31 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
"termtap.dev/internal/model"
|
"termtap.dev/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: LOTS OF THIS SUCKS BUT IT WORKS
|
// 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 {
|
func (m Model) renderStatusBar(w int) string {
|
||||||
var errCount int
|
var errCount int
|
||||||
var msSum int64
|
var msSum int64
|
||||||
@ -23,7 +38,15 @@ func (m Model) renderStatusBar(w int) string {
|
|||||||
|
|
||||||
avg := int(msSum) / max(1, len(m.requests))
|
avg := int(msSum) / max(1, len(m.requests))
|
||||||
left := fmt.Sprintf(" tap %3d reqs | %d err | avg %dms", len(m.requests), errCount, avg)
|
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)
|
spaceSize := max(w-(len(left)+len(right)), 0)
|
||||||
space := strings.Repeat(" ", spaceSize)
|
space := strings.Repeat(" ", spaceSize)
|
||||||
@ -31,6 +54,91 @@ func (m Model) renderStatusBar(w int) string {
|
|||||||
return m.theme.Header.Render(left + space + right)
|
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
|
// TODO: Implement
|
||||||
func (m Model) renderSearchPane(w, h int) []string {
|
func (m Model) renderSearchPane(w, h int) []string {
|
||||||
lines := make([]string, h)
|
lines := make([]string, h)
|
||||||
@ -43,39 +151,79 @@ func (m Model) renderSearchPane(w, h int) []string {
|
|||||||
func (m Model) renderRequestPane(w, h int) []string {
|
func (m Model) renderRequestPane(w, h int) []string {
|
||||||
var lines []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
|
// Render header
|
||||||
headerLeft := fmt.Sprintf(" %-7s %-24s %s", "METHOD", "HOST", "PATH")
|
headerLeft := fmt.Sprintf(" %-7s %-24s %s", "METHOD", "HOST", "PATH")
|
||||||
headerRight := fmt.Sprintf("%4s %8s ", "CODE", "TIME")
|
headerRight := fmt.Sprintf("%4s %8s ", "CODE", "TIME")
|
||||||
headerSpace := strings.Repeat(" ", max(0, w-len(headerLeft+headerRight)))
|
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)
|
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]
|
req := m.requests[i]
|
||||||
|
duration := req.Duration
|
||||||
// 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),
|
|
||||||
)
|
|
||||||
if req.Pending && !req.StartTime.IsZero() {
|
if req.Pending && !req.StartTime.IsZero() {
|
||||||
right = fmt.Sprintf(
|
duration = time.Since(req.StartTime)
|
||||||
"%4s %8s ",
|
|
||||||
"",
|
|
||||||
formatDuration(time.Since(req.StartTime)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
space := strings.Repeat(" ", max(0, w-len(left+right)))
|
|
||||||
|
|
||||||
line := left + space + right
|
statusStyle := lipgloss.NewStyle().Foreground(green).Background(background).Bold(true)
|
||||||
lines = append(lines, line)
|
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
|
// Cleanup
|
||||||
@ -92,16 +240,396 @@ func (m Model) renderRequestPane(w, h int) []string {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement
|
|
||||||
func (m Model) renderDetailsPane(w, h int) []string {
|
func (m Model) renderDetailsPane(w, h int) []string {
|
||||||
lines := make([]string, h)
|
if h <= 0 {
|
||||||
for y := range lines {
|
return nil
|
||||||
lines[y] = m.theme.Text.Render(strings.Repeat(" ", w))
|
}
|
||||||
|
|
||||||
|
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
|
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: This can be done better
|
||||||
// TODO: Should h be max or defined?
|
// TODO: Should h be max or defined?
|
||||||
func (m Model) renderEventsPane(w, h int) []string {
|
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)
|
left := fmt.Sprintf("[3] EVENT LOG - %d EVENTS", len(events))
|
||||||
|
|
||||||
if displayCount < len(events) {
|
|
||||||
events = events[len(events)-displayCount:]
|
|
||||||
}
|
|
||||||
|
|
||||||
left := fmt.Sprintf("EVENT LOG - %d EVENTS", len(events))
|
|
||||||
right := "E: TOGGLE"
|
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}
|
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 (
|
var (
|
||||||
eTime string = m.theme.TextMuted.Render(event.Time.Format("15:04:05") + " ")
|
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))
|
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
|
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?
|
// TODO: Should h be max or defined?
|
||||||
func (m Model) renderStdPane(w, h int) []string {
|
func (m Model) renderStdPane(w, h int) []string {
|
||||||
// Only the stdout or stderr logs
|
// Only the stdout or stderr logs
|
||||||
@ -176,18 +726,30 @@ func (m Model) renderStdPane(w, h int) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
displayCount := max(h-1, 0)
|
left := fmt.Sprintf("[4] STDOUT/STDERR LOG - %d LINES", len(logs))
|
||||||
|
|
||||||
if displayCount < len(logs) {
|
|
||||||
logs = logs[len(logs)-displayCount:]
|
|
||||||
}
|
|
||||||
|
|
||||||
left := fmt.Sprintf("STDOUT/STDERR LOG - %d LINES", len(logs))
|
|
||||||
right := "O: TOGGLE"
|
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}
|
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 (
|
var (
|
||||||
tag string
|
tag string
|
||||||
body string
|
body string
|
||||||
@ -225,3 +787,13 @@ func (m Model) renderStdPane(w, h int) []string {
|
|||||||
|
|
||||||
return 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
|
||||||
|
}
|
||||||
|
|||||||
@ -4,13 +4,13 @@ import "strings"
|
|||||||
|
|
||||||
func (m Model) renderAppPane() string {
|
func (m Model) renderAppPane() string {
|
||||||
// Constant height offset
|
// Constant height offset
|
||||||
constHeightOffset := 1
|
constHeightOffset := 2
|
||||||
|
|
||||||
var (
|
var (
|
||||||
searchW int = max(0, m.width)
|
searchW int = max(0, m.width)
|
||||||
searchH int = 1
|
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)
|
detW int = max(0, m.width-reqW)
|
||||||
|
|
||||||
reqH int = max(0, m.height-constHeightOffset)
|
reqH int = max(0, m.height-constHeightOffset)
|
||||||
@ -68,6 +68,9 @@ func (m Model) renderAppPane() string {
|
|||||||
screen = append(screen, stdPane...)
|
screen = append(screen, stdPane...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusBottom := m.renderBottomStatusBar(m.width)
|
||||||
|
screen = append(screen, statusBottom)
|
||||||
|
|
||||||
if len(screen) != m.height {
|
if len(screen) != m.height {
|
||||||
return "height of screen does not match terminal height"
|
return "height of screen does not match terminal height"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,12 +11,15 @@ type Theme struct {
|
|||||||
|
|
||||||
Header lipgloss.Style
|
Header lipgloss.Style
|
||||||
EventHeader lipgloss.Style
|
EventHeader lipgloss.Style
|
||||||
|
EventPaneHeader lipgloss.Style
|
||||||
StdHeader lipgloss.Style
|
StdHeader lipgloss.Style
|
||||||
|
|
||||||
Text lipgloss.Style
|
Text lipgloss.Style
|
||||||
TextMuted lipgloss.Style
|
TextMuted lipgloss.Style
|
||||||
TextError lipgloss.Style
|
TextError lipgloss.Style
|
||||||
TextMutedError lipgloss.Style
|
TextMutedError lipgloss.Style
|
||||||
|
RequestSelected lipgloss.Style
|
||||||
|
HeaderKey lipgloss.Style
|
||||||
|
|
||||||
EventDefault lipgloss.Style
|
EventDefault lipgloss.Style
|
||||||
EventSession lipgloss.Style
|
EventSession lipgloss.Style
|
||||||
@ -56,6 +59,10 @@ func newTheme() Theme {
|
|||||||
Bold(true).
|
Bold(true).
|
||||||
Foreground(background).
|
Foreground(background).
|
||||||
Background(blue),
|
Background(blue),
|
||||||
|
EventPaneHeader: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(background).
|
||||||
|
Background(green),
|
||||||
StdHeader: lipgloss.NewStyle().
|
StdHeader: lipgloss.NewStyle().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Foreground(background).
|
Foreground(background).
|
||||||
@ -73,6 +80,13 @@ func newTheme() Theme {
|
|||||||
TextMutedError: lipgloss.NewStyle().
|
TextMutedError: lipgloss.NewStyle().
|
||||||
Foreground(textMuted).
|
Foreground(textMuted).
|
||||||
Background(backgroundError),
|
Background(backgroundError),
|
||||||
|
RequestSelected: lipgloss.NewStyle().
|
||||||
|
Foreground(background).
|
||||||
|
Background(blue).
|
||||||
|
Bold(true),
|
||||||
|
HeaderKey: lipgloss.NewStyle().
|
||||||
|
Foreground(cyan).
|
||||||
|
Background(background),
|
||||||
|
|
||||||
EventDefault: lipgloss.NewStyle().
|
EventDefault: lipgloss.NewStyle().
|
||||||
Foreground(text).
|
Foreground(text).
|
||||||
|
|||||||
@ -13,6 +13,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
|
m.clampPaneScrolls()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case TickMsg:
|
case TickMsg:
|
||||||
@ -27,6 +28,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "q":
|
case "ctrl+c", "q":
|
||||||
return m, tea.Quit
|
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():
|
case tea.KeyCtrlR.String():
|
||||||
if m.restarting {
|
if m.restarting {
|
||||||
return m, nil
|
return m, nil
|
||||||
@ -38,12 +55,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, restartCmd(m.controls.Restart)
|
return m, restartCmd(m.controls.Restart)
|
||||||
case "e":
|
case "e":
|
||||||
m.showEvents = !m.showEvents
|
m.showEvents = !m.showEvents
|
||||||
|
m.ensureFocusedPaneVisible()
|
||||||
|
m.clampPaneScrolls()
|
||||||
case "o":
|
case "o":
|
||||||
m.showStd = !m.showStd
|
m.showStd = !m.showStd
|
||||||
|
m.ensureFocusedPaneVisible()
|
||||||
|
m.clampPaneScrolls()
|
||||||
case "/":
|
case "/":
|
||||||
m.showSearch = true
|
m.showSearch = true
|
||||||
|
m.clampPaneScrolls()
|
||||||
case "esc":
|
case "esc":
|
||||||
m.showSearch = false
|
m.showSearch = false
|
||||||
|
m.clampPaneScrolls()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@ -69,6 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case EventMsg:
|
case EventMsg:
|
||||||
m.pushEvent(msg.value)
|
m.pushEvent(msg.value)
|
||||||
m.applyMessage(msg.value)
|
m.applyMessage(msg.value)
|
||||||
|
m.clampPaneScrolls()
|
||||||
if m.hasPendingRequests() {
|
if m.hasPendingRequests() {
|
||||||
return m, tea.Batch(waitForEvent(m.channel), tickCmd())
|
return m, tea.Batch(waitForEvent(m.channel), tickCmd())
|
||||||
}
|
}
|
||||||
@ -99,6 +123,10 @@ func (m *Model) createRequest(req model.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(m.requests) > 0 && m.requestCursor > 0 {
|
||||||
|
m.requestCursor++
|
||||||
|
}
|
||||||
|
|
||||||
m.requests = append(m.requests, req)
|
m.requests = append(m.requests, req)
|
||||||
|
|
||||||
// If we passed the max, delete the first one
|
// 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 {
|
if len(m.requests) > maxRequests {
|
||||||
m.requests = m.requests[1:]
|
m.requests = m.requests[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.clampRequestCursor()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) updateRequest(req model.Request) {
|
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 {
|
func (m Model) hasPendingRequests() bool {
|
||||||
// Traverse backward to be a bit more efficient, the most recent requests are more
|
// Traverse backward to be a bit more efficient, the most recent requests are more
|
||||||
// like to be pending.
|
// like to be pending.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user