Initial commit
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
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, ", "))
|
||||
}
|
||||
Reference in New Issue
Block a user