Files
Mester Gábor 2dd6519168 Initial commit
2026-03-19 07:12:03 +01:00

514 lines
14 KiB
Go

package tui
import (
"bytes"
"fmt"
"strings"
"time"
"greq/internal/api"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/charmbracelet/lipgloss"
)
// testResultsBadge renders a compact badge for test results shown in the stats line.
func testResultsBadge(results []api.TestResult) string {
if len(results) == 0 {
return ""
}
var passed, failed int
for _, r := range results {
if r.Pass {
passed++
} else {
failed++
}
}
if failed == 0 {
return lipgloss.NewStyle().Foreground(colorGreen).Bold(true).
Render(fmt.Sprintf(" ✓ %d tests", passed))
}
return lipgloss.NewStyle().Foreground(colorRed).Bold(true).
Render(fmt.Sprintf(" ✗ %d/%d failed", failed, passed+failed))
}
// ---------------------------------------------------------------------------
// Main View
// ---------------------------------------------------------------------------
func (m Model) View() string {
switch m.state {
case stateWizard:
return m.wizardView()
case stateCurlImport:
return m.curlImportView()
case stateEditorPicker:
return m.editorPickerView()
}
return m.listView()
}
func (m Model) listView() string {
header := m.renderHeader()
footer := m.renderFooter()
body := m.renderBody()
base := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
switch {
case m.showDiff:
return m.overlayPanel(base, " DIFF ", m.renderDiffTitle(), m.vpDiff.View())
case m.showHistory:
return m.overlayPanel(base, " HISTORY ",
fmt.Sprintf(" %d requests ", len(m.history)),
m.vpHistory.View())
}
return base
}
// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------
func (m Model) renderHeader() string {
// Accent colour changes with environment
envName := "default"
if m.activeEnv < len(m.envList) {
envName = m.envList[m.activeEnv].Name
}
accent := envColorFor(envName)
accentHeader := headerStyle.Copy().Background(accent)
left := accentHeader.Render(" GREQ ")
// Environment selector: [1:default] 2:local 3:staging …
var envParts []string
for idx, e := range m.envList {
label := fmt.Sprintf("%d:%s", idx+1, e.Name)
if idx == m.activeEnv {
envParts = append(envParts, accentHeader.Copy().
Foreground(lipgloss.Color("#FFFFFF")).Bold(true).
Render("["+label+"]"))
} else {
envParts = append(envParts, subHeaderStyle.Render(label))
}
}
envBar := ""
if len(envParts) > 0 {
envBar = subHeaderStyle.Render(" ") + strings.Join(envParts, subHeaderStyle.Render(" "))
}
var tokenInfo string
if api.SavedToken != "" {
tokenInfo = subHeaderStyle.Copy().Foreground(colorGreen).Render(" ⚡ token ")
} else {
tokenInfo = subHeaderStyle.Copy().Foreground(colorGray).Render(" ○ no token ")
}
usedW := lipgloss.Width(left) + lipgloss.Width(envBar) + lipgloss.Width(tokenInfo)
gap := m.width - usedW
if gap < 0 {
gap = 0
}
fill := subHeaderStyle.Render(strings.Repeat(" ", gap))
return lipgloss.JoinHorizontal(lipgloss.Top, left, envBar, fill, tokenInfo)
}
// ---------------------------------------------------------------------------
// Body (two-panel layout)
// ---------------------------------------------------------------------------
func (m Model) renderBody() string {
headerH := 1
footerH := 1
bodyH := m.height - headerH - footerH
if bodyH < 2 {
bodyH = 2
}
rightTotal := m.width - listWidth
if rightTotal < 4 {
rightTotal = 4
}
// Left panel
var lStyle lipgloss.Style
if m.vpFocus {
lStyle = panelStyle
} else {
lStyle = activePanelStyle
}
leftPanel := lStyle.Width(listWidth - 2).Height(bodyH - 2).Render(m.list.View())
// Right panel
var rStyle lipgloss.Style
if m.vpFocus {
rStyle = activePanelStyle
} else {
rStyle = panelStyle
}
divLine := dimItemStyle.Render(strings.Repeat("─", rightTotal-2))
var rightContent string
switch {
case m.stressLoading:
rightContent = hintStyle.Render(fmt.Sprintf(" ⚡ Running stress test (%d requests)…", api.StressCount))
case m.loading:
rightContent = hintStyle.Render(" Sending request…")
case m.stressResult != nil:
rightContent = lipgloss.JoinVertical(lipgloss.Left,
m.renderRequestInfo(),
divLine,
m.vpResp.View(),
)
case m.response != nil || m.err != nil:
rightContent = lipgloss.JoinVertical(lipgloss.Left,
m.renderRequestInfo(),
divLine,
m.vpResp.View(),
divLine,
m.renderStatsLine(),
)
default:
rightContent = m.emptyState()
}
rightPanel := rStyle.Width(rightTotal - 2).Height(bodyH - 2).Render(rightContent)
return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
}
// renderRequestInfo shows "→ METHOD url" above the viewport.
func (m Model) renderRequestInfo() string {
if m.lastRequest == nil {
return dimItemStyle.Render(" —")
}
arrow := dimItemStyle.Render(" →")
method := methodStyle(m.lastRequest.Method).Render(fmt.Sprintf(" %-6s ", m.lastRequest.Method))
url := dimItemStyle.Render(m.lastRequest.URL)
return arrow + method + url
}
// renderStatsLine shows status / duration / size / content-type / test badge.
func (m Model) renderStatsLine() string {
if len(m.history) == 0 {
return ""
}
last := m.history[len(m.history)-1]
rightTotal := m.width - listWidth
if rightTotal < 4 {
rightTotal = 4
}
bar := renderStatsBar(last, rightTotal-4)
if badge := testResultsBadge(m.testResults); badge != "" {
bar += badge
}
return bar
}
func (m Model) emptyState() string {
return hintStyle.Render("\n Select a request and press Enter to run it.\n\n" +
" n new request i import cURL\n" +
" e edit in $EDITOR c copy as cURL\n" +
" d diff vs baseline S save baseline\n" +
" h history r reload\n" +
" s stress test / filter\n" +
" 1-9 switch env Tab focus response panel")
}
// renderStressResult formats the load test statistics for display in the response viewport.
func renderStressResult(r api.StressResult) string {
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(colorPurple)
labelStyle2 := lipgloss.NewStyle().Foreground(colorGray).Width(14)
valStyle := lipgloss.NewStyle().Bold(true).Foreground(colorFg)
okStyle := lipgloss.NewStyle().Bold(true).Foreground(colorGreen)
errStyle := lipgloss.NewStyle().Bold(true).Foreground(colorRed)
var sb strings.Builder
sb.WriteString("\n")
sb.WriteString(titleStyle.Render(" ⚡ LOAD TEST RESULTS") + "\n\n")
row := func(label, value string, highlight lipgloss.Style) {
sb.WriteString(" " + labelStyle2.Render(label) + highlight.Render(value) + "\n")
}
row("Requests", fmt.Sprintf("%d", r.Total), valStyle)
row("Concurrency", fmt.Sprintf("%d workers", api.StressConcurrency), valStyle)
sb.WriteString("\n")
row("Min", r.MinDur.Round(time.Millisecond).String(), okStyle)
row("Avg", r.AvgDur.Round(time.Millisecond).String(), valStyle)
row("Max", r.MaxDur.Round(time.Millisecond).String(), valStyle)
sb.WriteString("\n")
errPct := fmt.Sprintf("%d (%.1f%%)", r.Errors, r.ErrRate())
if r.Errors == 0 {
row("Errors", "0 (0.0%)", okStyle)
} else {
row("Errors", errPct, errStyle)
}
return sb.String()
}
// ---------------------------------------------------------------------------
// Footer
// ---------------------------------------------------------------------------
func (m Model) renderFooter() string {
keys := " Enter:run s:stress c:copy curl i:import curl d:diff S:snapshot h:history n:new e:edit r:reload Tab:focus q:quit"
switch {
case m.showDiff:
keys = " ↑↓:scroll diff d/Esc:close S:update baseline q:quit"
case m.showHistory:
keys = " ↑↓:scroll h/Esc:close q:quit"
case m.vpFocus:
keys = " ↑↓:scroll Tab:focus list d:diff h:history q:quit"
}
var status string
if m.statusMsg != "" {
if strings.HasPrefix(m.statusMsg, "Error") {
status = statusErrStyle.Render(" " + m.statusMsg)
} else {
status = statusOKStyle.Render(" " + m.statusMsg)
}
}
keysStr := footerStyle.Render(keys)
gap := m.width - lipgloss.Width(keysStr) - lipgloss.Width(status)
if gap < 0 {
gap = 0
}
line := lipgloss.NewStyle().
Foreground(colorBorder).
Render(strings.Repeat("─", m.width))
row := lipgloss.JoinHorizontal(lipgloss.Top,
keysStr,
footerStyle.Render(strings.Repeat(" ", gap)),
status,
)
return lipgloss.JoinVertical(lipgloss.Left, line, row)
}
// ---------------------------------------------------------------------------
// Wizard View
// ---------------------------------------------------------------------------
func (m Model) wizardView() string {
var b strings.Builder
b.WriteString(headerStyle.Render(" NEW REQUEST ") + "\n\n")
for i, def := range wizardDefs {
label := labelStyle.Render(def.label + ":")
b.WriteString(label + "\n")
inp := m.wizardInputs[i].View()
b.WriteString(inp + "\n")
if i < len(wizardDefs)-1 {
b.WriteString("\n")
}
}
b.WriteString("\n" + hintStyle.Render("Tab / Shift+Tab: next / prev field Enter: advance or save Esc: cancel"))
boxW := m.width - 8
if boxW < 40 {
boxW = 40
}
box := wizardBoxStyle.Width(boxW).Render(b.String())
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}
// ---------------------------------------------------------------------------
// JSON syntax highlighting via Chroma
// ---------------------------------------------------------------------------
// highlightJSON uses Chroma to apply terminal256 colour codes to a JSON string.
// If highlighting fails for any reason, the raw string is returned unchanged.
func highlightJSON(src string) string {
if strings.TrimSpace(src) == "" {
return src
}
lexer := lexers.Get("json")
if lexer == nil {
return src
}
style := styles.Get("monokai")
if style == nil {
style = styles.Fallback
}
formatter := formatters.Get("terminal256")
if formatter == nil {
return src
}
iterator, err := lexer.Tokenise(nil, src)
if err != nil {
return src
}
var buf bytes.Buffer
if err := formatter.Format(&buf, style, iterator); err != nil {
return src
}
result := buf.String()
if result == "" {
return src
}
return result
}
// ---------------------------------------------------------------------------
// Overlays (history, diff)
// ---------------------------------------------------------------------------
// overlayPanel renders a titled modal over the base view.
func (m Model) overlayPanel(base, title, subtitle, content string) string {
ovW := m.width - 6
if ovW < 20 {
ovW = 20
}
ovH := m.height - 6
if ovH < 3 {
ovH = 3
}
hdr := headerStyle.Render(title) +
subHeaderStyle.Render(subtitle) +
hintStyle.Render(" ↑↓ scroll Esc close")
inner := lipgloss.JoinVertical(lipgloss.Left, hdr, content)
box := activePanelStyle.Width(ovW).Height(ovH).Render(inner)
overlay := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
baseLines := strings.Split(base, "\n")
overLines := strings.Split(overlay, "\n")
for i, ol := range overLines {
if i < len(baseLines) {
baseLines[i] = ol
}
}
return strings.Join(baseLines, "\n")
}
func (m Model) renderDiffTitle() string {
if m.lastRequest == nil || !snapshotExists(m.lastRequest.Name) {
return " no baseline"
}
lines := computeDiff("", m.diffText) // already computed; show summary from text
_ = lines
// Extract +N -M from diffText lines
var added, removed int
for _, l := range strings.Split(m.diffText, "\n") {
if strings.HasPrefix(l, "\x1b") { // ANSI line — check underlying char
// simplified: count rendered prefix chars
}
if len(l) > 0 && l[0] == '+' {
added++
}
if len(l) > 0 && l[0] == '-' {
removed++
}
}
if added == 0 && removed == 0 {
return " identical"
}
return fmt.Sprintf(" +%d -%d vs baseline", added, removed)
}
// ---------------------------------------------------------------------------
// cURL import view
// ---------------------------------------------------------------------------
func (m Model) curlImportView() string {
label := labelStyle.Render("Paste cURL command (Chrome DevTools → Copy as cURL):")
hint := hintStyle.Render("Enter: parse & open wizard Esc: cancel")
content := lipgloss.JoinVertical(lipgloss.Left,
headerStyle.Render(" IMPORT cURL "),
"",
label,
m.curlInput.View(),
"",
hint,
)
boxW := m.width - 8
if boxW < 50 {
boxW = 50
}
box := wizardBoxStyle.Width(boxW).Render(content)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}
// ---------------------------------------------------------------------------
// Editor picker view
// ---------------------------------------------------------------------------
func (m Model) editorPickerView() string {
var b strings.Builder
b.WriteString(headerStyle.Render(" SELECT DEFAULT EDITOR ") + "\n\n")
hint := hintStyle.Render("No editor configured. Choose one — it will be saved to .greq/config.yaml")
b.WriteString(hint + "\n\n")
for i, ed := range m.pickerEditors {
cursor := " "
var line string
if i == m.pickerCursor {
cursor = selectedItemStyle.Render("▶ ")
line = selectedItemStyle.Render(ed.label)
if len(ed.args) > 0 {
line += dimItemStyle.Render(" (" + ed.bin + " " + strings.Join(ed.args, " ") + ")")
} else {
line += dimItemStyle.Render(" (" + ed.bin + ")")
}
} else {
line = lipgloss.NewStyle().Foreground(colorFg).Render(ed.label)
line += dimItemStyle.Render(" (" + ed.bin + ")")
}
b.WriteString(cursor + line + "\n")
}
b.WriteString("\n" + hintStyle.Render("↑↓ / j k: navigate Enter: select & save Esc: cancel"))
// Also show manual config hint
b.WriteString("\n\n" + dimItemStyle.Render("Or set manually: echo 'editor: nvim' >> .greq/config.yaml"))
boxW := m.width - 8
if boxW < 50 {
boxW = 50
}
box := wizardBoxStyle.Width(boxW).Render(b.String())
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}
// renderUnresolved warns the user about any {{placeholder}} left in a string
// after variable resolution. Used in the footer/status area.
func renderUnresolved(s string) string {
var unresolved []string
parts := strings.Split(s, "{{")
for _, p := range parts[1:] {
end := strings.Index(p, "}}")
if end != -1 {
unresolved = append(unresolved, "{{"+p[:end]+"}}")
}
}
if len(unresolved) == 0 {
return ""
}
return fmt.Sprintf("unresolved: %s", strings.Join(unresolved, ", "))
}