514 lines
14 KiB
Go
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, ", "))
|
|
}
|