Initial commit

This commit is contained in:
Mester Gábor
2026-03-19 07:12:03 +01:00
commit 2dd6519168
25 changed files with 3645 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"encoding/json"
"fmt"
)
// ChainedVars holds auto-extracted fields from the most-recent response.
// Keys are formatted as "last_FIELD" (top-level) or "last_PARENT_FIELD"
// (one level of nesting). Use them in any request as {{last_id}}, etc.
//
// The map is replaced entirely on each successful response so stale values
// from a previous call do not bleed into unrelated requests.
var ChainedVars = make(map[string]string)
// ExtractChained parses body as a JSON object and populates ChainedVars.
// Non-JSON bodies are silently ignored.
func ExtractChained(body []byte) {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return
}
next := make(map[string]string)
flattenJSON(next, "last", data, 0)
ChainedVars = next
}
// flattenJSON recursively walks obj up to maxDepth levels deep.
func flattenJSON(dst map[string]string, prefix string, obj map[string]interface{}, depth int) {
if depth > 1 {
return
}
for k, v := range obj {
key := prefix + "_" + k
switch val := v.(type) {
case string:
dst[key] = val
case float64:
if val == float64(int64(val)) {
dst[key] = fmt.Sprintf("%d", int64(val))
} else {
dst[key] = fmt.Sprintf("%g", val)
}
case bool:
if val {
dst[key] = "true"
} else {
dst[key] = "false"
}
case map[string]interface{}:
flattenJSON(dst, key, val, depth+1)
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"greq/internal/storage"
)
// SavedToken is the most-recently captured bearer token.
// It is populated automatically from any response that contains a
// top-level "token", "access_token", "accessToken", "jwt", or "id_token" field.
var SavedToken string
// Response holds the parsed result of an HTTP call.
type Response struct {
StatusCode int
Status string
Body string // pretty-printed if JSON, raw otherwise
Headers http.Header
IsJSON bool
Duration time.Duration
Size int // raw body bytes
}
// ResolveVariables replaces every {{key}} placeholder in input.
//
// Resolution order:
// 1. Values from env.yaml (envs map)
// 2. The captured bearer token ({{token}} and {{access_token}})
//
// Missing keys are left as-is (e.g. "{{unknown}}" stays in the string)
// so the caller can detect unresolved variables by scanning for "{{".
// ResolveVariables replaces {{key}} placeholders in the following order:
// 1. env.yaml values (envs)
// 2. Auto-chained vars from the last response (ChainedVars, e.g. {{last_id}})
// 3. Captured bearer token ({{token}}, {{access_token}})
//
// Unknown keys are left as-is so callers can detect them by scanning for "{{".
func ResolveVariables(input string, envs map[string]string) string {
for k, v := range envs {
input = strings.ReplaceAll(input, "{{"+k+"}}", v)
}
for k, v := range ChainedVars {
input = strings.ReplaceAll(input, "{{"+k+"}}", v)
}
if SavedToken != "" {
input = strings.ReplaceAll(input, "{{token}}", SavedToken)
input = strings.ReplaceAll(input, "{{access_token}}", SavedToken)
}
return input
}
// Do executes the request described by rf with variable substitution applied.
func Do(rf storage.RequestFile, envs map[string]string) (*Response, error) {
finalURL := ResolveVariables(rf.URL, envs)
finalBody := ResolveVariables(rf.Body, envs)
req, err := http.NewRequest(rf.Method, finalURL, bytes.NewBufferString(finalBody))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
// Headers from request file (variables resolved)
for k, v := range rf.Headers {
req.Header.Set(k, ResolveVariables(v, envs))
}
// Auto-inject token when present and header not already set
if SavedToken != "" && req.Header.Get("Authorization") == "" {
req.Header.Set("Authorization", "Bearer "+SavedToken)
}
// Default Content-Type for non-empty bodies
if finalBody != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 15 * time.Second}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
elapsed := time.Since(start)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
// Extract token and chain variables from the response body
extractToken(raw)
ExtractChained(raw)
// Pretty-print JSON bodies
body := string(raw)
isJSON := false
var pretty bytes.Buffer
if json.Indent(&pretty, raw, "", " ") == nil && pretty.Len() > 0 {
body = pretty.String()
isJSON = true
}
return &Response{
StatusCode: resp.StatusCode,
Status: resp.Status,
Body: body,
Headers: resp.Header,
IsJSON: isJSON,
Duration: elapsed,
Size: len(raw),
}, nil
}
// extractToken scans the top level of a JSON object for common token field names
// and stores the first match in SavedToken.
func extractToken(body []byte) {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return
}
for _, key := range []string{"token", "access_token", "accessToken", "jwt", "id_token"} {
if v, ok := data[key]; ok {
if s, ok := v.(string); ok && s != "" {
SavedToken = s
return
}
}
}
}
+218
View File
@@ -0,0 +1,218 @@
package api
import (
"encoding/base64"
"fmt"
"net/url"
"sort"
"strings"
"unicode"
"greq/internal/storage"
)
// ImportCURL parses a curl command string into a RequestFile.
// Handles -X, -H/--header, -d/--data/--data-raw, -u/--user, -b/--cookie.
// Unknown placeholder variables ({{…}}) are left as-is.
func ImportCURL(raw string) (storage.RequestFile, error) {
tokens := tokenizeCURL(raw)
if len(tokens) == 0 {
return storage.RequestFile{}, fmt.Errorf("empty input")
}
if !strings.EqualFold(tokens[0], "curl") {
return storage.RequestFile{}, fmt.Errorf("does not start with 'curl'")
}
rf := storage.RequestFile{
Method: "GET",
Headers: make(map[string]string),
}
i := 1
for i < len(tokens) {
tok := tokens[i]
switch tok {
case "-X", "--request":
i++
if i < len(tokens) {
rf.Method = strings.ToUpper(tokens[i])
}
case "-H", "--header":
i++
if i < len(tokens) {
if k, v, ok := splitHeader(tokens[i]); ok {
rf.Headers[k] = v
}
}
case "-d", "--data", "--data-raw", "--data-ascii", "--data-urlencode":
i++
if i < len(tokens) {
if rf.Body != "" {
rf.Body += "&"
}
rf.Body += tokens[i]
if rf.Method == "GET" {
rf.Method = "POST"
}
}
case "--data-binary":
i++
if i < len(tokens) && !strings.HasPrefix(tokens[i], "@") {
rf.Body = tokens[i]
if rf.Method == "GET" {
rf.Method = "POST"
}
}
case "-u", "--user":
i++
if i < len(tokens) {
enc := base64.StdEncoding.EncodeToString([]byte(tokens[i]))
rf.Headers["Authorization"] = "Basic " + enc
}
case "-b", "--cookie":
i++
if i < len(tokens) {
rf.Headers["Cookie"] = tokens[i]
}
case "-A", "--user-agent":
i++
if i < len(tokens) {
rf.Headers["User-Agent"] = tokens[i]
}
// Flags to silently ignore (no value)
case "--compressed", "--silent", "-s", "--insecure", "-k",
"-L", "--location", "-v", "--verbose", "-i", "--include",
"--no-keepalive", "--http2", "--http1.1", "--http1.0":
// Short flags with values that we don't handle — skip value
default:
if !strings.HasPrefix(tok, "-") {
if rf.URL == "" {
rf.URL = tok
}
} else if len(tok) == 2 {
// Unknown short flag: skip its value
i++
}
// Unknown long flags: skip
}
i++
}
if rf.URL == "" {
return rf, fmt.Errorf("no URL found in curl command")
}
if len(rf.Headers) == 0 {
rf.Headers = nil
}
rf.Name = inferName(rf.Method, rf.URL)
return rf, nil
}
// ExportCURL builds a shell-safe curl command from a RequestFile.
// Variables are resolved before embedding in the command string.
func ExportCURL(rf storage.RequestFile, envs map[string]string) string {
var parts []string
parts = append(parts, "curl")
parts = append(parts, shellQuote(ResolveVariables(rf.URL, envs)))
if rf.Method != "GET" || rf.Body != "" {
parts = append(parts, "-X", rf.Method)
}
// Deterministic header order
keys := make([]string, 0, len(rf.Headers))
for k := range rf.Headers {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := ResolveVariables(rf.Headers[k], envs)
parts = append(parts, "-H", shellQuote(k+": "+v))
}
if rf.Body != "" {
parts = append(parts, "--data-raw", shellQuote(ResolveVariables(rf.Body, envs)))
}
return strings.Join(parts, " \\\n ")
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
func tokenizeCURL(s string) []string {
// Join backslash-newline continuations
s = strings.ReplaceAll(s, "\\\n", " ")
s = strings.ReplaceAll(s, "\\\r\n", " ")
s = strings.TrimSpace(s)
var tokens []string
var cur strings.Builder
inSingle := false
inDouble := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\'' && !inDouble:
inSingle = !inSingle
case c == '"' && !inSingle:
inDouble = !inDouble
case c == '\\' && !inSingle:
i++
if i < len(s) {
cur.WriteByte(s[i])
}
case (c == ' ' || c == '\t' || c == '\n') && !inSingle && !inDouble:
if cur.Len() > 0 {
tokens = append(tokens, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
tokens = append(tokens, cur.String())
}
return tokens
}
func splitHeader(s string) (key, val string, ok bool) {
idx := strings.Index(s, ": ")
if idx == -1 {
idx = strings.Index(s, ":")
if idx == -1 {
return "", "", false
}
}
return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+1:]), true
}
func inferName(method, rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.Path == "" || u.Path == "/" {
return method + " Request"
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
last := parts[len(parts)-1]
if last == "" || last == "{" {
return method + " Request"
}
// Title-case the last path segment
words := strings.FieldsFunc(strings.ReplaceAll(last, "_", " "), func(r rune) bool {
return r == '-' || unicode.IsSpace(r)
})
for i, w := range words {
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(words, " ")
}
// shellQuote wraps s in single quotes, escaping any embedded single quotes.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
+167
View File
@@ -0,0 +1,167 @@
package api
import (
"encoding/json"
"fmt"
"strings"
lua "github.com/yuin/gopher-lua"
)
// TestResult holds the outcome of a single test assertion.
type TestResult struct {
Pass bool
Message string
}
// RunLuaTestScript executes a Lua script against a Response.
//
// Globals available in the script:
//
// status (number) HTTP status code, e.g. 200
// status_text (string) Full status string, e.g. "200 OK"
// body (string) Raw response body
// headers (table) Response headers, lower-cased keys
// json_body (table) Parsed JSON body, or nil if not JSON
// duration_ms (number) Round-trip duration in milliseconds
// size (number) Response body size in bytes
//
// Helper functions:
//
// pass(message) Record a passing assertion
// fail(message) Record a failing assertion (does NOT abort the script)
// assert_eq(a, b, msg) fail(msg) if a ~= b, else pass(msg)
// assert_status(code) assert_eq(status, code, "status == "..code)
// json_decode(str) Parse a JSON string → Lua table
func RunLuaTestScript(script string, resp *Response) ([]TestResult, error) {
if strings.TrimSpace(script) == "" {
return nil, nil
}
L := lua.NewState(lua.Options{SkipOpenLibs: false})
defer L.Close()
var results []TestResult
// Inject pass / fail helpers
L.SetGlobal("pass", L.NewFunction(func(L *lua.LState) int {
msg := L.OptString(1, "")
results = append(results, TestResult{Pass: true, Message: msg})
return 0
}))
L.SetGlobal("fail", L.NewFunction(func(L *lua.LState) int {
msg := L.OptString(1, "")
results = append(results, TestResult{Pass: false, Message: msg})
return 0
}))
L.SetGlobal("assert_eq", L.NewFunction(func(L *lua.LState) int {
a := L.Get(1)
b := L.Get(2)
msg := L.OptString(3, fmt.Sprintf("%v == %v", a, b))
if lua.LVCanConvToString(a) && lua.LVCanConvToString(b) &&
lua.LVAsString(a) == lua.LVAsString(b) {
results = append(results, TestResult{Pass: true, Message: msg})
} else {
results = append(results, TestResult{
Pass: false,
Message: fmt.Sprintf("%s (got %v, expected %v)", msg, a, b),
})
}
return 0
}))
L.SetGlobal("assert_status", L.NewFunction(func(L *lua.LState) int {
expected := L.CheckInt(1)
msg := fmt.Sprintf("status == %d", expected)
if resp.StatusCode == expected {
results = append(results, TestResult{Pass: true, Message: msg})
} else {
results = append(results, TestResult{
Pass: false,
Message: fmt.Sprintf("%s (got %d)", msg, resp.StatusCode),
})
}
return 0
}))
// json_decode helper
L.SetGlobal("json_decode", L.NewFunction(func(L *lua.LState) int {
s := L.CheckString(1)
var v interface{}
if err := json.Unmarshal([]byte(s), &v); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(goToLua(L, v))
return 1
}))
// Set response globals
L.SetGlobal("status", lua.LNumber(resp.StatusCode))
L.SetGlobal("status_text", lua.LString(resp.Status))
L.SetGlobal("body", lua.LString(resp.Body))
L.SetGlobal("duration_ms", lua.LNumber(resp.Duration.Milliseconds()))
L.SetGlobal("size", lua.LNumber(resp.Size))
// headers table (lower-cased keys)
headersTbl := L.NewTable()
for k, vals := range resp.Headers {
if len(vals) > 0 {
L.SetField(headersTbl, strings.ToLower(k), lua.LString(vals[0]))
}
}
L.SetGlobal("headers", headersTbl)
// json_body: parse resp.Body if JSON
var jsonBody interface{}
if resp.IsJSON {
if err := json.Unmarshal([]byte(resp.Body), &jsonBody); err == nil {
L.SetGlobal("json_body", goToLua(L, jsonBody))
} else {
L.SetGlobal("json_body", lua.LNil)
}
} else {
L.SetGlobal("json_body", lua.LNil)
}
if err := L.DoString(script); err != nil {
// Include any results collected before the error
errResult := TestResult{
Pass: false,
Message: "Lua error: " + err.Error(),
}
results = append(results, errResult)
return results, nil // return results, not the error, so UI can display them
}
return results, nil
}
// goToLua converts a Go value (from json.Unmarshal) into a lua.LValue.
func goToLua(L *lua.LState, v interface{}) lua.LValue {
if v == nil {
return lua.LNil
}
switch val := v.(type) {
case bool:
return lua.LBool(val)
case float64:
return lua.LNumber(val)
case string:
return lua.LString(val)
case []interface{}:
tbl := L.NewTable()
for i, item := range val {
tbl.RawSetInt(i+1, goToLua(L, item))
}
return tbl
case map[string]interface{}:
tbl := L.NewTable()
for k, item := range val {
L.SetField(tbl, k, goToLua(L, item))
}
return tbl
default:
return lua.LString(fmt.Sprintf("%v", val))
}
}
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"greq/internal/storage"
)
// RunPreScript executes rf.PreScript before sending the request.
// The script receives REQUEST_URL, REQUEST_METHOD, REQUEST_BODY as env vars.
// Lines of stdout matching "Key: Value" are returned as extra headers to merge
// into the request. A non-zero exit is treated as an error (request is aborted).
func RunPreScript(script string, rf storage.RequestFile, envs map[string]string) (map[string]string, error) {
if strings.TrimSpace(script) == "" {
return nil, nil
}
out, err := runScript(script, map[string]string{
"REQUEST_URL": ResolveVariables(rf.URL, envs),
"REQUEST_METHOD": rf.Method,
"REQUEST_BODY": rf.Body,
})
if err != nil {
return nil, fmt.Errorf("pre_script failed: %w\nOutput: %s", err, out)
}
headers := make(map[string]string)
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if k, v, ok := splitScriptHeader(line); ok {
headers[k] = v
}
}
return headers, nil
}
// RunTestScript executes the test_script field of a request using the Lua runner.
// See RunLuaTestScript for available globals and helper functions.
func RunTestScript(script string, resp *Response) ([]TestResult, error) {
return RunLuaTestScript(script, resp)
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
func runScript(script string, vars map[string]string) (string, error) {
f, err := os.CreateTemp("", "greq-script-*.sh")
if err != nil {
return "", err
}
defer os.Remove(f.Name())
if _, err := f.WriteString("#!/bin/sh\n" + script + "\n"); err != nil {
f.Close()
return "", err
}
f.Close()
if err := os.Chmod(f.Name(), 0o700); err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", f.Name())
// Start with the current process env, then overlay our custom vars
// so the script has access to PATH, HOME, etc.
cmd.Env = os.Environ()
for k, v := range vars {
cmd.Env = append(cmd.Env, k+"="+v)
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err = cmd.Run()
return out.String(), err
}
func splitScriptHeader(s string) (key, val string, ok bool) {
idx := strings.Index(s, ": ")
if idx < 1 {
return "", "", false
}
k := strings.TrimSpace(s[:idx])
v := strings.TrimSpace(s[idx+2:])
// Reject lines that look like script output, not headers
if strings.ContainsAny(k, " \t=/") {
return "", "", false
}
return k, v, true
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"sync"
"time"
"greq/internal/storage"
)
// StressCount is the number of requests fired in a single stress run.
const StressCount = 100
// StressConcurrency is the number of parallel workers.
const StressConcurrency = 10
// StressResult holds the aggregated statistics of a load test.
type StressResult struct {
Total int
Errors int
MinDur time.Duration
MaxDur time.Duration
AvgDur time.Duration
}
// ErrRate returns the error percentage (0100).
func (r StressResult) ErrRate() float64 {
if r.Total == 0 {
return 0
}
return float64(r.Errors) / float64(r.Total) * 100
}
// RunStress fires StressCount HTTP requests against rf with StressConcurrency
// parallel workers and returns aggregated timing statistics.
func RunStress(rf storage.RequestFile, envs map[string]string) StressResult {
type singleResult struct {
dur time.Duration
err bool
}
results := make([]singleResult, StressCount)
sem := make(chan struct{}, StressConcurrency)
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < StressCount; i++ {
wg.Add(1)
sem <- struct{}{}
go func(idx int) {
defer wg.Done()
defer func() { <-sem }()
start := time.Now()
_, err := Do(rf, envs)
dur := time.Since(start)
mu.Lock()
results[idx] = singleResult{dur: dur, err: err != nil}
mu.Unlock()
}(i)
}
wg.Wait()
var errCount int
var sumDur, minDur, maxDur time.Duration
for _, r := range results {
if r.err {
errCount++
}
sumDur += r.dur
if minDur == 0 || r.dur < minDur {
minDur = r.dur
}
if r.dur > maxDur {
maxDur = r.dur
}
}
avgDur := time.Duration(int64(sumDur) / int64(StressCount))
return StressResult{
Total: StressCount,
Errors: errCount,
MinDur: minDur,
MaxDur: maxDur,
AvgDur: avgDur,
}
}
+38
View File
@@ -0,0 +1,38 @@
package storage
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Config holds persistent user preferences stored in .greq/config.yaml.
type Config struct {
Editor string `yaml:"editor,omitempty"` // e.g. "nvim", "nano", "code --wait"
}
var configPath = filepath.Join(RootDir, "config.yaml")
// LoadConfig reads .greq/config.yaml. Missing file returns an empty Config.
func LoadConfig() (Config, error) {
var cfg Config
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
return cfg, yaml.Unmarshal(data, &cfg)
}
// SaveConfig writes cfg to .greq/config.yaml.
func SaveConfig(cfg Config) error {
_ = os.MkdirAll(RootDir, 0o755)
data, err := yaml.Marshal(&cfg)
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0o644)
}
+119
View File
@@ -0,0 +1,119 @@
package storage
import (
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// EnvInfo describes a single environment file under .greq/.
type EnvInfo struct {
Name string // "default", "local", "staging", "prod", …
Path string // absolute or relative path to the yaml file
}
// LoadEnvFromFile reads any env yaml file into a flat key/value map.
func LoadEnvFromFile(path string) (map[string]string, error) {
envs := make(map[string]string)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return envs, nil
}
return envs, err
}
return envs, yaml.Unmarshal(data, &envs)
}
// LoadEnvList scans .greq/ for files matching env*.yaml and returns them
// sorted with "default" first, then alphabetically.
func LoadEnvList() ([]EnvInfo, error) {
matches, err := filepath.Glob(filepath.Join(RootDir, "env*.yaml"))
if err != nil {
return nil, err
}
var list []EnvInfo
for _, p := range matches {
base := filepath.Base(p)
var name string
if base == "env.yaml" {
name = "default"
} else {
// env.NAME.yaml → NAME
name = strings.TrimSuffix(strings.TrimPrefix(base, "env."), ".yaml")
}
list = append(list, EnvInfo{Name: name, Path: p})
}
sort.Slice(list, func(i, j int) bool {
if list[i].Name == "default" {
return true
}
if list[j].Name == "default" {
return false
}
return list[i].Name < list[j].Name
})
return list, nil
}
const RootDir = ".greq"
// LoadEnv reads .greq/env.yaml into a flat key/value map.
// Missing file is not an error an empty map is returned instead.
func LoadEnv() (map[string]string, error) {
return LoadEnvFromFile(filepath.Join(RootDir, "env.yaml"))
}
// LoadRequests walks .greq/requests/ and parses every .yaml/.yml file.
// The first path segment below requests/ is used as the Folder name.
func LoadRequests() ([]RequestItem, error) {
var items []RequestItem
root := filepath.Join(RootDir, "requests")
_ = os.MkdirAll(root, 0o755)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".yaml" && ext != ".yml" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil // skip unreadable files silently
}
var rf RequestFile
if err := yaml.Unmarshal(data, &rf); err != nil {
return nil
}
if rf.Name == "" {
rf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if rf.Method == "" {
rf.Method = "GET"
}
rel, _ := filepath.Rel(root, path)
folder := ""
if parts := strings.Split(rel, string(filepath.Separator)); len(parts) > 1 {
folder = parts[0]
}
items = append(items, RequestItem{
Request: rf,
Path: path,
Folder: folder,
})
return nil
})
return items, err
}
+19
View File
@@ -0,0 +1,19 @@
package storage
// RequestFile is the YAML schema for a single request file.
type RequestFile struct {
Name string `yaml:"name"`
Method string `yaml:"method"`
URL string `yaml:"url"`
Headers map[string]string `yaml:"headers,omitempty"`
Body string `yaml:"body,omitempty"`
PreScript string `yaml:"pre_script,omitempty"` // shell, runs before request
TestScript string `yaml:"test_script,omitempty"` // Lua, runs after response
}
// RequestItem pairs a parsed RequestFile with its filesystem metadata.
type RequestItem struct {
Request RequestFile
Path string
Folder string
}
+36
View File
@@ -0,0 +1,36 @@
package storage
import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// SaveRequest writes rf to .greq/requests/<folder>/<slug>.yaml.
// folder may be empty (places the file directly in requests/).
func SaveRequest(rf RequestFile, folder string) (string, error) {
dir := filepath.Join(RootDir, "requests")
if folder != "" {
dir = filepath.Join(dir, folder)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
slug := strings.ToLower(strings.ReplaceAll(rf.Name, " ", "_"))
slug = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, slug)
path := filepath.Join(dir, slug+".yaml")
data, err := yaml.Marshal(&rf)
if err != nil {
return "", err
}
return path, os.WriteFile(path, data, 0o644)
}
+63
View File
@@ -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)
}
}
}
+176
View File
@@ -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
}
+78
View File
@@ -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
}
+181
View File
@@ -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,
),
)
}
+224
View File
@@ -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
}
+122
View File
@@ -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)
}
}
+677
View File
@@ -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
}
+513
View File
@@ -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, ", "))
}