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, ", ")) }