Initial commit
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// itemDelegate is a custom list delegate that renders both folderItems and
|
||||
// requestItems in a single-row style with method colour coding.
|
||||
type itemDelegate struct{}
|
||||
|
||||
func (d itemDelegate) Height() int { return 1 }
|
||||
func (d itemDelegate) Spacing() int { return 0 }
|
||||
|
||||
func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
||||
|
||||
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, itm list.Item) {
|
||||
isSelected := index == m.Index()
|
||||
maxW := m.Width()
|
||||
if maxW <= 0 {
|
||||
maxW = 30
|
||||
}
|
||||
|
||||
switch i := itm.(type) {
|
||||
case folderItem:
|
||||
line := folderItemStyle.Render(" ▸ " + strings.ToUpper(i.name))
|
||||
fmt.Fprint(w, line)
|
||||
|
||||
case requestItem:
|
||||
method := fmt.Sprintf("%-6s", i.request.Method)
|
||||
styledMethod := methodStyle(i.request.Method).Render(method)
|
||||
|
||||
name := i.request.Name
|
||||
// Truncate to avoid overflowing the panel
|
||||
nameMax := maxW - 10
|
||||
if nameMax < 1 {
|
||||
nameMax = 1
|
||||
}
|
||||
if len(name) > nameMax {
|
||||
name = name[:nameMax-1] + "…"
|
||||
}
|
||||
|
||||
indent := " "
|
||||
if i.folder != "" {
|
||||
indent = " "
|
||||
}
|
||||
|
||||
var styledName string
|
||||
if isSelected {
|
||||
styledName = selectedItemStyle.Render(name)
|
||||
cursor := lipgloss.NewStyle().Foreground(colorPurple).Render("▶")
|
||||
fmt.Fprintf(w, "%s%s %s %s", indent, cursor, styledMethod, styledName)
|
||||
} else {
|
||||
styledName = lipgloss.NewStyle().Foreground(colorFg).Render(name)
|
||||
fmt.Fprintf(w, "%s %s %s", indent, styledMethod, styledName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"greq/internal/storage"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type diffKind int
|
||||
|
||||
const (
|
||||
diffEqual diffKind = iota
|
||||
diffAdded // present in new, absent in old (+)
|
||||
diffRemoved // present in old, absent in new (-)
|
||||
)
|
||||
|
||||
type diffLine struct {
|
||||
kind diffKind
|
||||
content string
|
||||
}
|
||||
|
||||
// computeDiff performs a line-level LCS diff between oldStr and newStr.
|
||||
// Lines are capped at 500 each to keep the algorithm O(n²) bounded.
|
||||
func computeDiff(oldStr, newStr string) []diffLine {
|
||||
a := strings.Split(oldStr, "\n")
|
||||
b := strings.Split(newStr, "\n")
|
||||
if len(a) > 500 {
|
||||
a = a[:500]
|
||||
}
|
||||
if len(b) > 500 {
|
||||
b = b[:500]
|
||||
}
|
||||
return lcsBacktrack(a, b, lcsTable(a, b))
|
||||
}
|
||||
|
||||
func lcsTable(a, b []string) [][]int {
|
||||
m, n := len(a), len(b)
|
||||
dp := make([][]int, m+1)
|
||||
for i := range dp {
|
||||
dp[i] = make([]int, n+1)
|
||||
}
|
||||
for i := 1; i <= m; i++ {
|
||||
for j := 1; j <= n; j++ {
|
||||
if a[i-1] == b[j-1] {
|
||||
dp[i][j] = dp[i-1][j-1] + 1
|
||||
} else if dp[i-1][j] >= dp[i][j-1] {
|
||||
dp[i][j] = dp[i-1][j]
|
||||
} else {
|
||||
dp[i][j] = dp[i][j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return dp
|
||||
}
|
||||
|
||||
func lcsBacktrack(a, b []string, dp [][]int) []diffLine {
|
||||
var result []diffLine
|
||||
i, j := len(a), len(b)
|
||||
for i > 0 || j > 0 {
|
||||
switch {
|
||||
case i > 0 && j > 0 && a[i-1] == b[j-1]:
|
||||
result = append(result, diffLine{diffEqual, a[i-1]})
|
||||
i--
|
||||
j--
|
||||
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
||||
result = append(result, diffLine{diffAdded, b[j-1]})
|
||||
j--
|
||||
default:
|
||||
result = append(result, diffLine{diffRemoved, a[i-1]})
|
||||
i--
|
||||
}
|
||||
}
|
||||
// Reverse
|
||||
for l, r := 0, len(result)-1; l < r; l, r = l+1, r-1 {
|
||||
result[l], result[r] = result[r], result[l]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// renderDiff formats diff lines with ANSI colours for the viewport.
|
||||
// Equal lines are dimmed; added lines are green (+); removed lines are red (-).
|
||||
func renderDiff(lines []diffLine, width int) string {
|
||||
if len(lines) == 0 {
|
||||
return hintStyle.Render(" No differences found.")
|
||||
}
|
||||
|
||||
addStyle := lipgloss.NewStyle().Foreground(colorGreen)
|
||||
remStyle := lipgloss.NewStyle().Foreground(colorRed)
|
||||
eqStyle := lipgloss.NewStyle().Foreground(colorGray)
|
||||
|
||||
maxLine := width - 3
|
||||
if maxLine < 1 {
|
||||
maxLine = 1
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, l := range lines {
|
||||
content := l.content
|
||||
if len(content) > maxLine {
|
||||
content = content[:maxLine-1] + "…"
|
||||
}
|
||||
switch l.kind {
|
||||
case diffAdded:
|
||||
sb.WriteString(addStyle.Render("+ "+content) + "\n")
|
||||
case diffRemoved:
|
||||
sb.WriteString(remStyle.Render("- "+content) + "\n")
|
||||
case diffEqual:
|
||||
sb.WriteString(eqStyle.Render(" "+content) + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// diffSummary returns a compact "+N -M" or "identical" summary.
|
||||
func diffSummary(lines []diffLine) string {
|
||||
var added, removed int
|
||||
for _, l := range lines {
|
||||
switch l.kind {
|
||||
case diffAdded:
|
||||
added++
|
||||
case diffRemoved:
|
||||
removed++
|
||||
}
|
||||
}
|
||||
if added == 0 && removed == 0 {
|
||||
return "identical"
|
||||
}
|
||||
var parts []string
|
||||
if added > 0 {
|
||||
parts = append(parts, fmt.Sprintf("+%d", added))
|
||||
}
|
||||
if removed > 0 {
|
||||
parts = append(parts, fmt.Sprintf("-%d", removed))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot storage (.greq/snapshots/<slug>.json)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func snapshotPath(name string) string {
|
||||
slug := strings.ToLower(strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, name))
|
||||
return filepath.Join(storage.RootDir, "snapshots", slug+".json")
|
||||
}
|
||||
|
||||
func saveSnapshot(name, body string) error {
|
||||
p := snapshotPath(name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, []byte(body), 0o644)
|
||||
}
|
||||
|
||||
func loadSnapshot(name string) (string, error) {
|
||||
data, err := os.ReadFile(snapshotPath(name))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func snapshotExists(name string) bool {
|
||||
_, err := os.Stat(snapshotPath(name))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// candidateEditors is the ordered list shown in the picker.
|
||||
// Only editors actually found on PATH are offered.
|
||||
var candidateEditors = []struct {
|
||||
bin string // executable name
|
||||
label string // human-readable label
|
||||
}{
|
||||
{"nvim", "Neovim"},
|
||||
{"vim", "Vim"},
|
||||
{"vi", "Vi"},
|
||||
{"nano", "Nano"},
|
||||
{"micro", "Micro"},
|
||||
{"hx", "Helix"},
|
||||
{"emacs", "Emacs (terminal)"},
|
||||
{"code", "VS Code (code --wait)"},
|
||||
{"gedit", "Gedit"},
|
||||
{"kate", "Kate"},
|
||||
}
|
||||
|
||||
type editorChoice struct {
|
||||
bin string
|
||||
label string
|
||||
// For VS Code we need the --wait flag so the terminal blocks until closed.
|
||||
args []string
|
||||
}
|
||||
|
||||
// availableEditors returns only the editors found on the current PATH.
|
||||
func availableEditors() []editorChoice {
|
||||
var found []editorChoice
|
||||
for _, c := range candidateEditors {
|
||||
if path, err := exec.LookPath(c.bin); err == nil {
|
||||
choice := editorChoice{bin: path, label: c.label}
|
||||
if c.bin == "code" {
|
||||
choice.args = []string{"--wait"}
|
||||
}
|
||||
found = append(found, choice)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// editorCommand returns (binary, extraArgs) for a saved editor string.
|
||||
// e.g. "code --wait" → ("code", ["--wait"])
|
||||
func editorCommand(saved string) (string, []string) {
|
||||
if saved == "" {
|
||||
return "", nil
|
||||
}
|
||||
// Handle "code --wait" style strings stored in config
|
||||
parts := splitArgs(saved)
|
||||
if len(parts) == 1 {
|
||||
return parts[0], nil
|
||||
}
|
||||
return parts[0], parts[1:]
|
||||
}
|
||||
|
||||
func splitArgs(s string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, r := range s {
|
||||
if r == ' ' {
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
cur = ""
|
||||
}
|
||||
} else {
|
||||
cur += string(r)
|
||||
}
|
||||
}
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const maxHistory = 100
|
||||
|
||||
// HistoryEntry records one completed HTTP transaction.
|
||||
type HistoryEntry struct {
|
||||
At time.Time
|
||||
Name string
|
||||
Method string
|
||||
URL string
|
||||
StatusCode int
|
||||
Status string
|
||||
Duration time.Duration
|
||||
Size int
|
||||
ContentType string
|
||||
IsError bool
|
||||
ErrMsg string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func fmtDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Millisecond:
|
||||
return "< 1ms"
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
default:
|
||||
m := int(d.Minutes())
|
||||
s := int(d.Seconds()) % 60
|
||||
return fmt.Sprintf("%dm%ds", m, s)
|
||||
}
|
||||
}
|
||||
|
||||
func fmtSize(n int) string {
|
||||
switch {
|
||||
case n < 1024:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
case n < 1024*1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
func durationStyle(d time.Duration) lipgloss.Style {
|
||||
switch {
|
||||
case d < 200*time.Millisecond:
|
||||
return lipgloss.NewStyle().Foreground(colorGreen)
|
||||
case d < time.Second:
|
||||
return lipgloss.NewStyle().Foreground(colorYellow)
|
||||
default:
|
||||
return lipgloss.NewStyle().Foreground(colorRed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats bar (rendered inside the response panel, below the viewport)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderStatsBar returns a single line summarising the last response.
|
||||
func renderStatsBar(e HistoryEntry, width int) string {
|
||||
if e.IsError {
|
||||
return statusErrStyle.Render(" ✗ " + e.ErrMsg)
|
||||
}
|
||||
|
||||
status := statusCodeStyle(e.StatusCode).Render(e.Status)
|
||||
dur := durationStyle(e.Duration).Render("⚡ " + fmtDuration(e.Duration))
|
||||
size := lipgloss.NewStyle().Foreground(colorFg).Render("◎ " + fmtSize(e.Size))
|
||||
|
||||
sep := dimItemStyle.Render(" │ ")
|
||||
bar := " " + status + sep + dur + sep + size
|
||||
|
||||
// Append content-type if it fits
|
||||
if e.ContentType != "" {
|
||||
candidate := bar + sep + dimItemStyle.Render(e.ContentType)
|
||||
if lipgloss.Width(candidate) <= width-2 {
|
||||
bar = candidate
|
||||
}
|
||||
}
|
||||
return bar
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History overlay table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderHistoryTable builds the full text content for the history viewport.
|
||||
func renderHistoryTable(history []HistoryEntry, vpWidth int) string {
|
||||
if len(history) == 0 {
|
||||
return hintStyle.Render("\n No requests yet in this session.")
|
||||
}
|
||||
|
||||
// Column widths
|
||||
const (
|
||||
colTime = 8 // HH:MM:SS
|
||||
colMethod = 7 // DELETE + space
|
||||
colStatus = 10 // "200 OK" etc.
|
||||
colDur = 9 // "1.2s" etc.
|
||||
colSize = 8 // "4.2 KB"
|
||||
colSep = 2 // " "
|
||||
)
|
||||
fixedW := colTime + colMethod + colStatus + colDur + colSize + colSep*5
|
||||
nameW := vpWidth - fixedW - 2
|
||||
if nameW < 10 {
|
||||
nameW = 10
|
||||
}
|
||||
|
||||
header := buildHistoryRow(
|
||||
dimItemStyle,
|
||||
"Time", "Method", "Status", "Duration", "Size", "Name",
|
||||
colTime, colMethod, colStatus, colDur, colSize, nameW,
|
||||
)
|
||||
divider := dimItemStyle.Render(strings.Repeat("─", vpWidth-2))
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(header + "\n")
|
||||
sb.WriteString(divider + "\n")
|
||||
|
||||
// Most recent first
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
e := history[i]
|
||||
|
||||
timeStr := e.At.Format("15:04:05")
|
||||
method := e.Method
|
||||
status := e.Status
|
||||
dur := fmtDuration(e.Duration)
|
||||
size := fmtSize(e.Size)
|
||||
name := e.Name
|
||||
if len(name) > nameW {
|
||||
name = name[:nameW-1] + "…"
|
||||
}
|
||||
|
||||
var rowStyle lipgloss.Style
|
||||
if e.IsError {
|
||||
rowStyle = lipgloss.NewStyle().Foreground(colorRed)
|
||||
} else {
|
||||
rowStyle = lipgloss.NewStyle().Foreground(colorFg)
|
||||
}
|
||||
|
||||
methodStr := methodStyle(method).Render(fmt.Sprintf("%-*s", colMethod, method))
|
||||
statusStr := statusCodeStyle(e.StatusCode).Render(fmt.Sprintf("%-*s", colStatus, status))
|
||||
durStr := durationStyle(e.Duration).Render(fmt.Sprintf("%-*s", colDur, dur))
|
||||
|
||||
row := rowStyle.Render(timeStr+" ") +
|
||||
methodStr + " " +
|
||||
statusStr + " " +
|
||||
durStr + " " +
|
||||
rowStyle.Render(fmt.Sprintf("%-*s", colSize, size)+" "+name)
|
||||
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildHistoryRow(s lipgloss.Style, t, method, status, dur, size, name string,
|
||||
wT, wM, wSt, wD, wSz, wN int) string {
|
||||
return s.Render(
|
||||
fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s",
|
||||
wT, t,
|
||||
wM, method,
|
||||
wSt, status,
|
||||
wD, dur,
|
||||
wSz, size,
|
||||
wN, name,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"greq/internal/api"
|
||||
"greq/internal/storage"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// stateCurlImport is shown when the user presses 'i' to import a curl command.
|
||||
// After parsing, it transitions to stateWizard with pre-filled inputs.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List item types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// folderItem acts as a visual section header; it is not executable.
|
||||
type folderItem struct{ name string }
|
||||
|
||||
func (f folderItem) Title() string { return "" }
|
||||
func (f folderItem) Description() string { return "" }
|
||||
func (f folderItem) FilterValue() string { return "" }
|
||||
|
||||
// requestItem wraps a parsed request for display in the list.
|
||||
type requestItem struct {
|
||||
request storage.RequestFile
|
||||
path string
|
||||
folder string
|
||||
}
|
||||
|
||||
func (r requestItem) Title() string { return r.request.Name }
|
||||
func (r requestItem) Description() string { return r.request.URL }
|
||||
func (r requestItem) FilterValue() string {
|
||||
return r.request.Name + " " + r.request.URL + " " + r.folder
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wizard field definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var wizardDefs = []struct{ label, placeholder string }{
|
||||
{"Name", "e.g. Login"},
|
||||
{"Method", "GET / POST / PUT / DELETE / PATCH"},
|
||||
{"URL", "https://api.example.com/endpoint"},
|
||||
{"Folder", "(optional) e.g. auth"},
|
||||
{"Headers", "(optional) Key:Value, Key2:Value2"},
|
||||
{"Body (JSON)", `(optional) {"key": "value"}`},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type viewState int
|
||||
|
||||
const (
|
||||
stateList viewState = iota
|
||||
stateWizard // new-request form
|
||||
stateCurlImport // single textinput to paste a curl command
|
||||
stateEditorPicker // choose default editor when none is configured
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type responseMsg struct {
|
||||
req storage.RequestFile
|
||||
resp *api.Response
|
||||
testResults []api.TestResult
|
||||
}
|
||||
type errMsg struct {
|
||||
req storage.RequestFile
|
||||
err error
|
||||
}
|
||||
type stressResultMsg struct {
|
||||
req storage.RequestFile
|
||||
result api.StressResult
|
||||
}
|
||||
type editorDoneMsg struct{}
|
||||
type itemsReloadedMsg struct{ items []list.Item }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Model is the root Bubble Tea model.
|
||||
type Model struct {
|
||||
state viewState
|
||||
width int
|
||||
height int
|
||||
|
||||
list list.Model
|
||||
envs map[string]string
|
||||
envList []storage.EnvInfo
|
||||
activeEnv int // index into envList
|
||||
|
||||
vpResp viewport.Model
|
||||
vpFocus bool // true → keyboard goes to response viewport
|
||||
respText string // current content of the response viewport
|
||||
|
||||
response *api.Response
|
||||
lastRequest *storage.RequestFile
|
||||
testResults []api.TestResult
|
||||
err error
|
||||
loading bool
|
||||
statusMsg string
|
||||
|
||||
// stress test
|
||||
stressLoading bool
|
||||
stressResult *api.StressResult
|
||||
|
||||
// history
|
||||
history []HistoryEntry
|
||||
showHistory bool
|
||||
vpHistory viewport.Model
|
||||
|
||||
// diff overlay
|
||||
showDiff bool
|
||||
diffText string
|
||||
vpDiff viewport.Model
|
||||
|
||||
// curl import
|
||||
curlInput textinput.Model
|
||||
|
||||
// editor picker
|
||||
cfg storage.Config
|
||||
pickerEditors []editorChoice // available editors on PATH
|
||||
pickerCursor int // currently highlighted row
|
||||
pickerTargetPath string // file to open after editor is chosen
|
||||
|
||||
// wizard
|
||||
wizardInputs []textinput.Model
|
||||
wizardFocus int
|
||||
}
|
||||
|
||||
// New constructs the initial Model, loading env and requests from disk.
|
||||
func New() Model {
|
||||
envs, _ := storage.LoadEnv()
|
||||
envList, _ := storage.LoadEnvList()
|
||||
cfg, _ := storage.LoadConfig()
|
||||
items, _ := buildListItems()
|
||||
|
||||
l := list.New(items, itemDelegate{}, 0, 0)
|
||||
l.Title = "Requests"
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
l.Styles.Title = headerStyle
|
||||
|
||||
return Model{
|
||||
envs: envs,
|
||||
envList: envList,
|
||||
cfg: cfg,
|
||||
list: l,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildListItems loads requests from disk, sorts them by folder then name,
|
||||
// and interleaves folderItem headers between groups.
|
||||
func buildListItems() ([]list.Item, error) {
|
||||
requests, err := storage.LoadRequests()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(requests, func(i, j int) bool {
|
||||
a, b := requests[i], requests[j]
|
||||
if a.Folder != b.Folder {
|
||||
if a.Folder == "" {
|
||||
return true
|
||||
}
|
||||
if b.Folder == "" {
|
||||
return false
|
||||
}
|
||||
return a.Folder < b.Folder
|
||||
}
|
||||
return a.Request.Name < b.Request.Name
|
||||
})
|
||||
|
||||
var items []list.Item
|
||||
lastFolder := "\x00" // sentinel that never equals a real folder
|
||||
|
||||
for _, r := range requests {
|
||||
if r.Folder != lastFolder {
|
||||
lastFolder = r.Folder
|
||||
if r.Folder != "" {
|
||||
items = append(items, folderItem{name: r.Folder})
|
||||
}
|
||||
}
|
||||
items = append(items, requestItem{
|
||||
request: r.Request,
|
||||
path: r.Path,
|
||||
folder: r.Folder,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// newWizardInputs returns a fresh slice of textinputs for the "new request" form.
|
||||
func newWizardInputs() []textinput.Model {
|
||||
inputs := make([]textinput.Model, len(wizardDefs))
|
||||
for i, def := range wizardDefs {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = def.placeholder
|
||||
ti.CharLimit = 512
|
||||
if i == 0 {
|
||||
ti.Focus()
|
||||
}
|
||||
inputs[i] = ti
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Palette (Tokyo Night inspired)
|
||||
var (
|
||||
colorPurple = lipgloss.Color("#7C71FF")
|
||||
colorGreen = lipgloss.Color("#9ECE6A")
|
||||
colorYellow = lipgloss.Color("#E0AF68")
|
||||
colorRed = lipgloss.Color("#F7768E")
|
||||
colorBlue = lipgloss.Color("#7AA2F7")
|
||||
colorCyan = lipgloss.Color("#73DACA")
|
||||
colorGray = lipgloss.Color("#565F89")
|
||||
colorBorder = lipgloss.Color("#414868")
|
||||
colorFg = lipgloss.Color("#C0CAF5")
|
||||
)
|
||||
|
||||
var (
|
||||
headerStyle = lipgloss.NewStyle().
|
||||
Background(colorPurple).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true).
|
||||
Padding(0, 2)
|
||||
|
||||
subHeaderStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#414868")).
|
||||
Foreground(colorFg).
|
||||
Padding(0, 2)
|
||||
|
||||
footerStyle = lipgloss.NewStyle().
|
||||
Foreground(colorGray)
|
||||
|
||||
panelStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder)
|
||||
|
||||
activePanelStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorPurple)
|
||||
|
||||
folderItemStyle = lipgloss.NewStyle().
|
||||
Foreground(colorPurple).
|
||||
Bold(true)
|
||||
|
||||
selectedItemStyle = lipgloss.NewStyle().
|
||||
Foreground(colorPurple).
|
||||
Bold(true)
|
||||
|
||||
dimItemStyle = lipgloss.NewStyle().
|
||||
Foreground(colorGray)
|
||||
|
||||
statusOKStyle = lipgloss.NewStyle().
|
||||
Foreground(colorGreen).
|
||||
Bold(true)
|
||||
|
||||
statusErrStyle = lipgloss.NewStyle().
|
||||
Foreground(colorRed).
|
||||
Bold(true)
|
||||
|
||||
labelStyle = lipgloss.NewStyle().
|
||||
Foreground(colorGray)
|
||||
|
||||
wizardBoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorPurple).
|
||||
Padding(1, 2)
|
||||
|
||||
hintStyle = lipgloss.NewStyle().
|
||||
Foreground(colorGray).
|
||||
Italic(true)
|
||||
)
|
||||
|
||||
// envColorFor maps an environment name to its header accent colour.
|
||||
// prod/production → red (danger), staging → yellow, local/dev → green, else → purple.
|
||||
func envColorFor(name string) lipgloss.Color {
|
||||
switch strings.ToLower(name) {
|
||||
case "prod", "production", "live", "master":
|
||||
return colorRed
|
||||
case "staging", "stage", "uat", "preprod":
|
||||
return colorYellow
|
||||
case "local", "dev", "development", "test":
|
||||
return colorGreen
|
||||
default:
|
||||
return colorPurple
|
||||
}
|
||||
}
|
||||
|
||||
// methodStyle returns a style coloured by HTTP verb.
|
||||
func methodStyle(method string) lipgloss.Style {
|
||||
switch method {
|
||||
case "GET":
|
||||
return lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
|
||||
case "POST":
|
||||
return lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
|
||||
case "PUT":
|
||||
return lipgloss.NewStyle().Foreground(colorBlue).Bold(true)
|
||||
case "DELETE":
|
||||
return lipgloss.NewStyle().Foreground(colorRed).Bold(true)
|
||||
case "PATCH":
|
||||
return lipgloss.NewStyle().Foreground(colorCyan).Bold(true)
|
||||
default:
|
||||
return lipgloss.NewStyle().Foreground(colorGray)
|
||||
}
|
||||
}
|
||||
|
||||
// statusCodeStyle colours HTTP status codes (2xx green, 3xx blue, 4xx/5xx red).
|
||||
func statusCodeStyle(code int) lipgloss.Style {
|
||||
switch {
|
||||
case code >= 200 && code < 300:
|
||||
return lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
|
||||
case code >= 300 && code < 400:
|
||||
return lipgloss.NewStyle().Foreground(colorBlue).Bold(true)
|
||||
case code >= 400:
|
||||
return lipgloss.NewStyle().Foreground(colorRed).Bold(true)
|
||||
default:
|
||||
return lipgloss.NewStyle().Foreground(colorGray)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"greq/internal/api"
|
||||
"greq/internal/storage"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Window resize is handled regardless of state.
|
||||
if sz, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width, m.height = sz.Width, sz.Height
|
||||
m = m.applySize()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateWizard:
|
||||
return m.updateWizard(msg)
|
||||
case stateCurlImport:
|
||||
return m.updateCurlImport(msg)
|
||||
case stateEditorPicker:
|
||||
return m.updateEditorPicker(msg)
|
||||
default:
|
||||
return m.updateList(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List / main state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Let the list handle its own filter-mode keys first.
|
||||
if m.list.FilterState() == list.Filtering {
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
|
||||
case "n":
|
||||
m.state = stateWizard
|
||||
m.wizardInputs = newWizardInputs()
|
||||
m.wizardFocus = 0
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
|
||||
case "i": // import from cURL
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Paste curl command…"
|
||||
ti.CharLimit = 4096
|
||||
ti.Width = m.width - 12
|
||||
ti.Focus()
|
||||
m.curlInput = ti
|
||||
m.state = stateCurlImport
|
||||
return m, nil
|
||||
|
||||
case "c": // copy selected request as cURL
|
||||
if i, ok := m.list.SelectedItem().(requestItem); ok {
|
||||
curl := api.ExportCURL(i.request, m.envs)
|
||||
if err := copyToClipboard(curl); err != nil {
|
||||
// Clipboard tool not available — show the command in the
|
||||
// response panel so the user can manually select & copy it.
|
||||
m.respText = curl
|
||||
m.vpResp.SetContent(curl)
|
||||
m.vpResp.GotoTop()
|
||||
m.statusMsg = clipboardInstallHint()
|
||||
} else {
|
||||
m.statusMsg = "Copied cURL to clipboard ✓"
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "d": // diff current response vs snapshot
|
||||
if m.response != nil && m.lastRequest != nil {
|
||||
name := m.lastRequest.Name
|
||||
if !snapshotExists(name) {
|
||||
m.statusMsg = "No baseline — press S to save current as baseline"
|
||||
} else {
|
||||
old, err := loadSnapshot(name)
|
||||
if err != nil {
|
||||
m.statusMsg = "Snapshot error: " + err.Error()
|
||||
} else {
|
||||
lines := computeDiff(old, m.response.Body)
|
||||
m.diffText = renderDiff(lines, m.vpDiff.Width)
|
||||
m.vpDiff.SetContent(m.diffText)
|
||||
m.vpDiff.GotoTop()
|
||||
m.showDiff = !m.showDiff
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m.statusMsg = "Run a request first"
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "S": // save snapshot baseline (shift+s)
|
||||
if m.response != nil && m.lastRequest != nil {
|
||||
if err := saveSnapshot(m.lastRequest.Name, m.response.Body); err != nil {
|
||||
m.statusMsg = "Snapshot error: " + err.Error()
|
||||
} else {
|
||||
m.statusMsg = "Baseline saved ✓"
|
||||
}
|
||||
} else {
|
||||
m.statusMsg = "Nothing to save"
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
|
||||
idx := int(msg.String()[0]-'0') - 1
|
||||
if idx < len(m.envList) {
|
||||
envs, err := storage.LoadEnvFromFile(m.envList[idx].Path)
|
||||
if err != nil {
|
||||
m.statusMsg = "Env load error: " + err.Error()
|
||||
} else {
|
||||
m.activeEnv = idx
|
||||
m.envs = envs
|
||||
m.statusMsg = "Env: " + m.envList[idx].Name
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "e":
|
||||
if i, ok := m.list.SelectedItem().(requestItem); ok {
|
||||
return m.openInEditor(i.path)
|
||||
}
|
||||
|
||||
case "r":
|
||||
m.statusMsg = "Reloading…"
|
||||
return m, reloadCmd(&m)
|
||||
|
||||
case "h":
|
||||
m.showHistory = !m.showHistory
|
||||
if m.showHistory {
|
||||
content := renderHistoryTable(m.history, m.vpHistory.Width)
|
||||
m.vpHistory.SetContent(content)
|
||||
m.vpHistory.GotoTop()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "tab":
|
||||
if !m.showHistory {
|
||||
m.vpFocus = !m.vpFocus
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "esc":
|
||||
if m.showDiff {
|
||||
m.showDiff = false
|
||||
return m, nil
|
||||
}
|
||||
if m.showHistory {
|
||||
m.showHistory = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "s": // stress test
|
||||
if i, ok := m.list.SelectedItem().(requestItem); ok {
|
||||
m.stressLoading = true
|
||||
m.stressResult = nil
|
||||
m.response = nil
|
||||
m.lastRequest = nil
|
||||
m.err = nil
|
||||
m.respText = ""
|
||||
m.vpResp.SetContent("")
|
||||
m.statusMsg = fmt.Sprintf("⚡ Running stress test (%d requests)…", api.StressCount)
|
||||
return m, doStressCmd(i.request, m.envs)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
if i, ok := m.list.SelectedItem().(requestItem); ok {
|
||||
m.loading = true
|
||||
m.statusMsg = "Sending…"
|
||||
m.respText = ""
|
||||
m.response = nil
|
||||
m.lastRequest = nil
|
||||
m.err = nil
|
||||
m.stressResult = nil
|
||||
m.vpResp.SetContent("")
|
||||
return m, doRequestCmd(i.request, m.envs)
|
||||
}
|
||||
// folderItem selected → do nothing
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case responseMsg:
|
||||
m.loading = false
|
||||
m.response = msg.resp
|
||||
m.lastRequest = &msg.req
|
||||
m.testResults = msg.testResults
|
||||
m.err = nil
|
||||
|
||||
highlighted := highlightJSON(msg.resp.Body)
|
||||
m.respText = highlighted
|
||||
m.vpResp.SetContent(highlighted)
|
||||
m.vpResp.GotoTop()
|
||||
|
||||
ct := msg.resp.Headers.Get("Content-Type")
|
||||
if idx := strings.Index(ct, ";"); idx != -1 {
|
||||
ct = strings.TrimSpace(ct[:idx])
|
||||
}
|
||||
m.history = appendHistory(m.history, HistoryEntry{
|
||||
At: timeNow(),
|
||||
Name: msg.req.Name,
|
||||
Method: msg.req.Method,
|
||||
URL: api.ResolveVariables(msg.req.URL, m.envs),
|
||||
StatusCode: msg.resp.StatusCode,
|
||||
Status: msg.resp.Status,
|
||||
Duration: msg.resp.Duration,
|
||||
Size: msg.resp.Size,
|
||||
ContentType: ct,
|
||||
})
|
||||
|
||||
switch {
|
||||
case api.SavedToken != "":
|
||||
m.statusMsg = "Token captured ✓"
|
||||
case testSummary(m.testResults) != "":
|
||||
m.statusMsg = testSummary(m.testResults)
|
||||
default:
|
||||
m.statusMsg = ""
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case errMsg:
|
||||
m.loading = false
|
||||
m.err = msg.err
|
||||
m.lastRequest = &msg.req
|
||||
m.statusMsg = "Error: " + msg.err.Error()
|
||||
errText := "Error:\n\n" + msg.err.Error()
|
||||
m.vpResp.SetContent(errText)
|
||||
m.vpResp.GotoTop()
|
||||
|
||||
m.history = appendHistory(m.history, HistoryEntry{
|
||||
At: timeNow(),
|
||||
Name: msg.req.Name,
|
||||
Method: msg.req.Method,
|
||||
URL: api.ResolveVariables(msg.req.URL, m.envs),
|
||||
IsError: true,
|
||||
ErrMsg: msg.err.Error(),
|
||||
})
|
||||
return m, nil
|
||||
|
||||
case stressResultMsg:
|
||||
m.stressLoading = false
|
||||
m.stressResult = &msg.result
|
||||
m.lastRequest = &msg.req
|
||||
text := renderStressResult(msg.result)
|
||||
m.respText = text
|
||||
m.vpResp.SetContent(text)
|
||||
m.vpResp.GotoTop()
|
||||
m.statusMsg = fmt.Sprintf("⚡ Done: %d requests, %.1f%% errors", msg.result.Total, msg.result.ErrRate())
|
||||
return m, nil
|
||||
|
||||
case editorDoneMsg:
|
||||
m.statusMsg = "Reloading after edit…"
|
||||
return m, reloadCmd(&m)
|
||||
|
||||
case itemsReloadedMsg:
|
||||
m.list.SetItems(msg.items)
|
||||
envs, _ := storage.LoadEnv()
|
||||
m.envs = envs
|
||||
if m.statusMsg == "Reloading…" || m.statusMsg == "Reloading after edit…" {
|
||||
m.statusMsg = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Route keyboard/mouse to the focused panel.
|
||||
var cmd tea.Cmd
|
||||
switch {
|
||||
case m.showDiff:
|
||||
m.vpDiff, cmd = m.vpDiff.Update(msg)
|
||||
case m.showHistory:
|
||||
m.vpHistory, cmd = m.vpHistory.Update(msg)
|
||||
case m.vpFocus:
|
||||
m.vpResp, cmd = m.vpResp.Update(msg)
|
||||
default:
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wizard state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m Model) updateWizard(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
|
||||
case "tab", "shift+tab":
|
||||
m.wizardInputs[m.wizardFocus].Blur()
|
||||
if msg.String() == "tab" {
|
||||
m.wizardFocus = (m.wizardFocus + 1) % len(m.wizardInputs)
|
||||
} else {
|
||||
m.wizardFocus = (m.wizardFocus - 1 + len(m.wizardInputs)) % len(m.wizardInputs)
|
||||
}
|
||||
m.wizardInputs[m.wizardFocus].Focus()
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
// On the last field → save and return; otherwise advance.
|
||||
if m.wizardFocus < len(m.wizardInputs)-1 {
|
||||
m.wizardInputs[m.wizardFocus].Blur()
|
||||
m.wizardFocus++
|
||||
m.wizardInputs[m.wizardFocus].Focus()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Save
|
||||
rf, folder := wizardToRequest(m.wizardInputs)
|
||||
if _, err := storage.SaveRequest(rf, folder); err != nil {
|
||||
m.statusMsg = "Save failed: " + err.Error()
|
||||
} else {
|
||||
m.statusMsg = "Saved " + rf.Name
|
||||
}
|
||||
m.state = stateList
|
||||
return m, reloadCmd(&m)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward key events to the focused input.
|
||||
var cmd tea.Cmd
|
||||
m.wizardInputs[m.wizardFocus], cmd = m.wizardInputs[m.wizardFocus].Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func doRequestCmd(rf storage.RequestFile, envs map[string]string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// 1. Run pre-script (shell) to get extra headers
|
||||
extraHeaders, err := api.RunPreScript(rf.PreScript, rf, envs)
|
||||
if err != nil {
|
||||
return errMsg{req: rf, err: err}
|
||||
}
|
||||
// Merge extra headers into a copy of the request
|
||||
if len(extraHeaders) > 0 {
|
||||
merged := make(map[string]string)
|
||||
for k, v := range rf.Headers {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range extraHeaders {
|
||||
merged[k] = v
|
||||
}
|
||||
rf = rf // make a local copy
|
||||
rf.Headers = merged
|
||||
}
|
||||
|
||||
// 2. Execute the HTTP request
|
||||
resp, err := api.Do(rf, envs)
|
||||
if err != nil {
|
||||
return errMsg{req: rf, err: err}
|
||||
}
|
||||
|
||||
// 3. Run test script (Lua)
|
||||
testResults, _ := api.RunTestScript(rf.TestScript, resp)
|
||||
|
||||
return responseMsg{req: rf, resp: resp, testResults: testResults}
|
||||
}
|
||||
}
|
||||
|
||||
func doStressCmd(rf storage.RequestFile, envs map[string]string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
result := api.RunStress(rf, envs)
|
||||
return stressResultMsg{req: rf, result: result}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadCmd(m *Model) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, _ := buildListItems()
|
||||
return itemsReloadedMsg{items}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sizing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const listWidth = 36 // total width of the left panel including borders
|
||||
|
||||
// statsBarLines is the number of lines reserved at the bottom of the response
|
||||
// panel for the request-info line + divider + stats bar.
|
||||
const statsBarLines = 3
|
||||
|
||||
func (m Model) applySize() Model {
|
||||
headerH := 1
|
||||
footerH := 2 // divider + key hints
|
||||
bodyH := m.height - headerH - footerH
|
||||
if bodyH < 4 {
|
||||
bodyH = 4
|
||||
}
|
||||
|
||||
leftContent := listWidth - 2 // subtract border chars
|
||||
rightTotal := m.width - listWidth
|
||||
if rightTotal < 4 {
|
||||
rightTotal = 4
|
||||
}
|
||||
rightContent := rightTotal - 2
|
||||
|
||||
m.list.SetSize(leftContent, bodyH-2)
|
||||
|
||||
// Response viewport: leave room for request-info + divider + stats bar
|
||||
vpH := bodyH - 2 - statsBarLines
|
||||
if vpH < 1 {
|
||||
vpH = 1
|
||||
}
|
||||
m.vpResp = viewport.New(rightContent, vpH)
|
||||
m.vpResp.SetContent(m.respText)
|
||||
|
||||
// History viewport
|
||||
ovH := m.height - 8
|
||||
if ovH < 3 {
|
||||
ovH = 3
|
||||
}
|
||||
ovW := m.width - 6
|
||||
if ovW < 20 {
|
||||
ovW = 20
|
||||
}
|
||||
m.vpHistory = viewport.New(ovW-4, ovH)
|
||||
m.vpHistory.SetContent(renderHistoryTable(m.history, ovW-4))
|
||||
|
||||
// Diff viewport (same size as history overlay)
|
||||
m.vpDiff = viewport.New(ovW-4, ovH)
|
||||
m.vpDiff.SetContent(m.diffText)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wizard helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func appendHistory(history []HistoryEntry, e HistoryEntry) []HistoryEntry {
|
||||
history = append(history, e)
|
||||
if len(history) > maxHistory {
|
||||
history = history[len(history)-maxHistory:]
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
// timeNow is a variable so tests can override it.
|
||||
var timeNow = func() time.Time { return time.Now() }
|
||||
|
||||
// testSummary returns a short status string from test results, or "".
|
||||
func testSummary(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 fmt.Sprintf("Tests: %d passed ✓", passed)
|
||||
}
|
||||
return fmt.Sprintf("Tests: %d failed, %d passed", failed, passed)
|
||||
}
|
||||
|
||||
// copyToClipboard writes text to the system clipboard.
|
||||
func copyToClipboard(text string) error {
|
||||
return clipboard.WriteAll(text)
|
||||
}
|
||||
|
||||
// clipboardInstallHint returns a short install suggestion based on the detected
|
||||
// desktop session (Wayland vs X11) or falls back to a generic message.
|
||||
func clipboardInstallHint() string {
|
||||
session := strings.ToLower(os.Getenv("XDG_SESSION_TYPE"))
|
||||
switch session {
|
||||
case "wayland":
|
||||
return "Clipboard unavailable — install wl-clipboard: sudo apt install wl-clipboard"
|
||||
case "x11", "x":
|
||||
return "Clipboard unavailable — install xclip: sudo apt install xclip"
|
||||
default:
|
||||
// Could be macOS (pbcopy built-in, shouldn't fail), or unknown.
|
||||
// Show generic hint; the curl is displayed in the panel.
|
||||
return "Clipboard unavailable — curl shown in panel (install xclip / wl-clipboard)"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor resolution + picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// openInEditor resolves the editor to use and either launches it immediately
|
||||
// or transitions to stateEditorPicker when no editor is configured/available.
|
||||
func (m Model) openInEditor(path string) (tea.Model, tea.Cmd) {
|
||||
// Priority: 1) .greq/config.yaml 2) $EDITOR 3) picker
|
||||
editorStr := m.cfg.Editor
|
||||
if editorStr == "" {
|
||||
editorStr = os.Getenv("EDITOR")
|
||||
}
|
||||
|
||||
if editorStr != "" {
|
||||
bin, args := editorCommand(editorStr)
|
||||
cmdArgs := append(args, path)
|
||||
c := exec.Command(bin, cmdArgs...)
|
||||
return m, tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
return editorDoneMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
// No editor configured — show picker
|
||||
editors := availableEditors()
|
||||
if len(editors) == 0 {
|
||||
m.statusMsg = "No editor found. Set EDITOR or add 'editor:' to .greq/config.yaml"
|
||||
return m, nil
|
||||
}
|
||||
m.state = stateEditorPicker
|
||||
m.pickerEditors = editors
|
||||
m.pickerCursor = 0
|
||||
m.pickerTargetPath = path
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateEditorPicker(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc", "ctrl+c":
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
|
||||
case "up", "k":
|
||||
if m.pickerCursor > 0 {
|
||||
m.pickerCursor--
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
if m.pickerCursor < len(m.pickerEditors)-1 {
|
||||
m.pickerCursor++
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter", " ":
|
||||
chosen := m.pickerEditors[m.pickerCursor]
|
||||
|
||||
// Ask whether to save as default (second Enter press would be too
|
||||
// complex; we always save — the user can edit config.yaml to change it)
|
||||
m.cfg.Editor = chosen.bin
|
||||
if len(chosen.args) > 0 {
|
||||
m.cfg.Editor = chosen.bin + " " + strings.Join(chosen.args, " ")
|
||||
}
|
||||
_ = storage.SaveConfig(m.cfg)
|
||||
m.statusMsg = "Default editor saved: " + m.cfg.Editor
|
||||
m.state = stateList
|
||||
|
||||
cmdArgs := append(chosen.args, m.pickerTargetPath)
|
||||
c := exec.Command(chosen.bin, cmdArgs...)
|
||||
return m, tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
return editorDoneMsg{}
|
||||
})
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cURL import state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m Model) updateCurlImport(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
case "enter":
|
||||
raw := m.curlInput.Value()
|
||||
rf, err := api.ImportCURL(raw)
|
||||
if err != nil {
|
||||
m.statusMsg = "cURL parse error: " + err.Error()
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
}
|
||||
// Pre-fill wizard inputs with parsed values
|
||||
m.state = stateWizard
|
||||
m.wizardInputs = newWizardInputs()
|
||||
m.wizardInputs[0].SetValue(rf.Name)
|
||||
m.wizardInputs[1].SetValue(rf.Method)
|
||||
m.wizardInputs[2].SetValue(rf.URL)
|
||||
if len(rf.Headers) > 0 {
|
||||
var hparts []string
|
||||
for k, v := range rf.Headers {
|
||||
hparts = append(hparts, k+":"+v)
|
||||
}
|
||||
m.wizardInputs[4].SetValue(strings.Join(hparts, ", "))
|
||||
}
|
||||
m.wizardInputs[5].SetValue(rf.Body)
|
||||
m.wizardFocus = 0
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.curlInput, cmd = m.curlInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wizard helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// wizardToRequest converts the wizard textinputs into a RequestFile and folder.
|
||||
func wizardToRequest(inputs []textinput.Model) (storage.RequestFile, string) {
|
||||
get := func(i int) string {
|
||||
if i < len(inputs) {
|
||||
return strings.TrimSpace(inputs[i].Value())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
method := strings.ToUpper(get(1))
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
rf := storage.RequestFile{
|
||||
Name: get(0),
|
||||
Method: method,
|
||||
URL: get(2),
|
||||
Body: get(5),
|
||||
}
|
||||
|
||||
// Parse headers: "Key:Value, Key2:Value2"
|
||||
if rawHeaders := get(4); rawHeaders != "" {
|
||||
rf.Headers = make(map[string]string)
|
||||
for _, pair := range strings.Split(rawHeaders, ",") {
|
||||
parts := strings.SplitN(strings.TrimSpace(pair), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
rf.Headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
folder := get(3)
|
||||
return rf, folder
|
||||
}
|
||||
@@ -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