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
+10
View File
@@ -0,0 +1,10 @@
.greq
# Built binaries
greq
greq.exe
dist/
# Go build cache
*.test
*.out
+124
View File
@@ -0,0 +1,124 @@
stages:
- build
- release
variables:
APP_NAME: greq
GO_VERSION: "1.23"
# ── Build binaries for all platforms ──────────────────────────────────────────
.build_template: &build_template
stage: build
image: golang:${GO_VERSION}
before_script:
- go version
- go mod download
only:
- tags
build:linux-amd64:
<<: *build_template
script:
- GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o dist/${APP_NAME}-linux-amd64 .
artifacts:
paths:
- dist/${APP_NAME}-linux-amd64
expire_in: 1 hour
build:linux-arm64:
<<: *build_template
script:
- GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o dist/${APP_NAME}-linux-arm64 .
artifacts:
paths:
- dist/${APP_NAME}-linux-arm64
expire_in: 1 hour
build:darwin-amd64:
<<: *build_template
script:
- GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o dist/${APP_NAME}-darwin-amd64 .
artifacts:
paths:
- dist/${APP_NAME}-darwin-amd64
expire_in: 1 hour
build:darwin-arm64:
<<: *build_template
script:
- GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o dist/${APP_NAME}-darwin-arm64 .
artifacts:
paths:
- dist/${APP_NAME}-darwin-arm64
expire_in: 1 hour
build:windows-amd64:
<<: *build_template
script:
- GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o dist/${APP_NAME}-windows-amd64.exe .
artifacts:
paths:
- dist/${APP_NAME}-windows-amd64.exe
expire_in: 1 hour
# ── Create GitLab Release with all binaries ───────────────────────────────────
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
needs:
- job: build:linux-amd64
artifacts: true
- job: build:linux-arm64
artifacts: true
- job: build:darwin-amd64
artifacts: true
- job: build:darwin-arm64
artifacts: true
- job: build:windows-amd64
artifacts: true
script:
- echo "Creating release ${CI_COMMIT_TAG}"
release:
tag_name: ${CI_COMMIT_TAG}
name: "greq ${CI_COMMIT_TAG}"
description: |
## greq ${CI_COMMIT_TAG}
A terminal-based HTTP client with a TUI interface.
### Download
| Platform | Architecture | File |
|----------------|--------------|-----------------------------------------|
| Linux | x86_64 | `greq-linux-amd64` |
| Linux | ARM64 | `greq-linux-arm64` |
| macOS | x86_64 | `greq-darwin-amd64` |
| macOS | Apple Silicon| `greq-darwin-arm64` |
| Windows | x86_64 | `greq-windows-amd64.exe` |
### Install (Linux/macOS)
```bash
chmod +x greq-*
sudo mv greq-* /usr/local/bin/greq
```
assets:
links:
- name: "greq-linux-amd64"
url: "${CI_PROJECT_URL}/-/jobs/artifacts/${CI_COMMIT_TAG}/raw/dist/greq-linux-amd64?job=build:linux-amd64"
link_type: package
- name: "greq-linux-arm64"
url: "${CI_PROJECT_URL}/-/jobs/artifacts/${CI_COMMIT_TAG}/raw/dist/greq-linux-arm64?job=build:linux-arm64"
link_type: package
- name: "greq-darwin-amd64"
url: "${CI_PROJECT_URL}/-/jobs/artifacts/${CI_COMMIT_TAG}/raw/dist/greq-darwin-amd64?job=build:darwin-amd64"
link_type: package
- name: "greq-darwin-arm64"
url: "${CI_PROJECT_URL}/-/jobs/artifacts/${CI_COMMIT_TAG}/raw/dist/greq-darwin-arm64?job=build:darwin-arm64"
link_type: package
- name: "greq-windows-amd64.exe"
url: "${CI_PROJECT_URL}/-/jobs/artifacts/${CI_COMMIT_TAG}/raw/dist/greq-windows-amd64.exe?job=build:windows-amd64"
link_type: package
only:
- tags
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mester Gábor
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+341
View File
@@ -0,0 +1,341 @@
# greq
**greq** — a git-native, TUI-based API client for the terminal. Like Postman, but it lives in your `.greq/` folder, is version-controllable, and never opens a browser.
```
╭──────────────────────────╮╭──────────────────────────────────────────────────╮
│ GREQ [1:default] ││ → POST https://api.example.com/auth/login │
├──────────────────────────┤│──────────────────────────────────────────────────│
│ ▶ GET Health Check ││ { │
│ ▸ AUTH ││ "token": "eyJhbGciOiJIUzI1NiJ9...", │
│ ▶ POST Login ││ "user_id": 42 │
│ GET Refresh ││ } │
│ ▸ USERS ││ │
│ GET List Users ││──────────────────────────────────────────────────│
│ GET Get User ││ ● 200 OK │ ⚡ 213ms │ ◎ 1.2 KB │ app/json │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Enter:run s:stress c:curl i:import d:diff S:snapshot h:history n:new e:edit
```
---
## Installation
```bash
git clone <repo>
cd greq
go build -o greq .
./greq # looks for .greq/ in the current directory
```
> **Dependencies:** Go 1.21+. For clipboard support on Linux you may need `xclip` (X11) or `wl-clipboard` (Wayland).
---
## Project structure
```
greq/
├── main.go # entry point
├── internal/
│ ├── api/
│ │ ├── client.go # HTTP client, variable resolution, token extraction
│ │ ├── chain.go # request chaining ({{last_FIELD}})
│ │ ├── curl.go # cURL import / export
│ │ ├── lua_test_runner.go # Lua test script runner (gopher-lua)
│ │ ├── script.go # pre_script shell execution
│ │ └── stress.go # load test runner (100×, 10 workers)
│ ├── storage/
│ │ ├── types.go # RequestFile, RequestItem structs
│ │ ├── loader.go # YAML loading, env list
│ │ ├── writer.go # YAML saving
│ │ └── config.go # .greq/config.yaml (e.g. editor setting)
│ └── tui/
│ ├── model.go # Bubble Tea Model, states, item types
│ ├── update.go # Update logic, key handlers, commands
│ ├── view.go # View rendering (header, panels, overlays)
│ ├── styles.go # Lipgloss styles, colours, env colour
│ ├── delegate.go # Custom list delegate (folder headers)
│ ├── history.go # HistoryEntry, stats bar, history table
│ ├── diff.go # LCS line diff, snapshot save/load
│ └── editor_picker.go # Available editor detection
└── .greq/ # project-level data (can be committed to git!)
├── config.yaml # user settings
├── env.yaml # default environment
├── env.local.yaml # optional: local env
├── env.staging.yaml # optional: staging env
├── env.prod.yaml # optional: prod env
├── requests/
│ ├── health.yaml # root-level request
│ ├── auth/
│ │ └── login.yaml # auth folder
│ └── users/
│ ├── list.yaml
│ └── get_user.yaml
└── snapshots/ # response baselines (d/S keys)
└── login.json
```
---
## .greq/ folder layout
### `env.yaml` — variables
```yaml
base_url: https://api.example.com
user_id: "42"
api_key: secret123
```
Reference them in any request with `{{base_url}}`, `{{user_id}}`, etc.
### Request YAML schema
```yaml
name: Login # display name in the list
method: POST # GET / POST / PUT / DELETE / PATCH
url: "{{base_url}}/auth/login" # variables allowed
headers:
Content-Type: application/json
X-Api-Key: "{{api_key}}"
body: |
{
"username": "admin",
"password": "secret"
}
# Shell script — runs BEFORE the request (to generate extra headers)
pre_script: |
TS=$(date +%s)
echo "X-Timestamp: $TS"
# Lua script — runs AFTER the response (for assertions)
test_script: |
assert_status(200)
assert_eq(json_body.success, true, "login successful")
if json_body.token then pass("token present") else fail("no token") end
```
### `config.yaml` — settings
```yaml
editor: nvim # default editor for the 'e' key
```
---
## Keybindings
### Main view
| Key | Action |
|---|---|
| `Enter` | Run the selected request |
| `s` | Load test (100 requests, Min/Max/Avg/error rate) |
| `e` | Edit request in `$EDITOR` (or configured editor) |
| `n` | New request wizard |
| `i` | Import a cURL command |
| `c` | Copy selected request as cURL |
| `d` | Diff: current response vs. saved baseline |
| `S` | Save current response as baseline |
| `h` | History overlay (all requests in the session) |
| `r` | Reload request list and env |
| `/` | Fuzzy search across requests |
| `Tab` | Toggle focus: list ↔ response panel |
| `1``9` | Switch environment (env.local.yaml, env.staging.yaml, etc.) |
| `q` / `Ctrl+C` | Quit |
### Response panel (after Tab)
| Key | Action |
|---|---|
| `↑` / `↓` | Scroll |
| `PgUp` / `PgDn` | Page scroll |
| `Tab` | Return to list |
### Overlays (History, Diff)
| Key | Action |
|---|---|
| `↑` / `↓` | Scroll |
| `Esc` / `h` / `d` | Close |
---
## Features
### Variables and Request Chaining
Values from `env.yaml` can be used as `{{key}}` placeholders in URLs, headers, and bodies.
If a response contains JSON, every top-level field is automatically available in the next request as `{{last_FIELD}}`:
```
Login response: { "id": 42, "token": "abc" }
→ {{last_id}} = "42"
→ {{last_token}} = "abc"
Next request URL: {{base_url}}/users/{{last_id}}
```
The `token`, `access_token`, and `jwt` fields are also automatically injected as `Authorization: Bearer …` headers in subsequent requests.
### Lua test scripts
`test_script` runs in Lua after the response arrives.
**Available variables:**
| Variable | Type | Description |
|---|---|---|
| `status` | number | HTTP status code (e.g. `200`) |
| `status_text` | string | Full status string (e.g. `"200 OK"`) |
| `body` | string | Raw response body |
| `json_body` | table / nil | Parsed JSON, or `nil` |
| `headers` | table | Response headers (lowercase keys) |
| `duration_ms` | number | Response time in milliseconds |
| `size` | number | Body size in bytes |
**Helper functions:**
```lua
pass("message") -- green assertion
fail("message") -- red assertion
assert_eq(a, b, "message") -- fail if a ~= b
assert_status(200) -- fail if status does not match
json_decode("...") -- JSON string → Lua table
```
**Example:**
```lua
assert_status(200)
if json_body == nil then
fail("response is not JSON")
return
end
assert_eq(json_body.role, "admin", "admin permission")
if duration_ms > 500 then
fail("too slow: " .. duration_ms .. "ms")
else
pass("response time OK: " .. duration_ms .. "ms")
end
```
Results appear in the stats bar badge: `✓ 3 tests` or `✗ 1/3 failed`.
### Shell pre-script
`pre_script` runs **before** the request, in a shell. Lines printed to stdout matching `Key: Value` are injected as extra request headers.
```yaml
pre_script: |
TS=$(date +%s)
SIG=$(echo -n "POST${TS}" | openssl dgst -sha256 -hmac "$HMAC_SECRET" -binary | base64)
echo "X-Timestamp: $TS"
echo "X-Signature: $SIG"
```
If the script exits with a non-zero code, the request is aborted.
### cURL Import / Export
**Export (`c`):** Copies the selected request as a ready-to-use `curl` command to the clipboard (variables resolved). If no clipboard tool is available, the command is displayed in the right panel.
**Import (`i`):** Paste a `curl` command (e.g. from Chrome DevTools → Copy as cURL) and greq parses it automatically, opening the wizard pre-filled.
### Load Testing ⚡
Press `s` on any selected request and greq fires it **100 times** concurrently (10 workers at a time).
The response panel shows the aggregated stats:
```
⚡ LOAD TEST RESULTS
Requests 100
Concurrency 10 workers
Min 43ms
Avg 97ms
Max 312ms
Errors 0 (0.0%)
```
If any requests failed, the `Errors` line is highlighted in red. Press `Enter` afterwards to go back to normal request mode.
### Response Diff
```
S → saves the current response as a baseline (.greq/snapshots/)
d → shows what changed since the baseline (red/green per line)
```
Useful when hitting the same endpoint at different times and wanting to see exactly what changed in the response.
### Environments (`1``9`)
Multiple env files can coexist:
```
.greq/env.yaml → 1 (default, purple header)
.greq/env.local.yaml → 2 (green header)
.greq/env.staging.yaml → 3 (yellow header)
.greq/env.prod.yaml → 4 (RED header — warning)
```
The header colour instantly signals which environment is active, so you don't accidentally delete production data.
---
## Quick start
```bash
# 1. Create the .greq folder at your project root
mkdir -p .greq/requests/auth
# 2. Write an env file
cat > .greq/env.yaml << EOF
base_url: https://api.example.com
EOF
# 3. Create a request
cat > .greq/requests/auth/login.yaml << EOF
name: Login
method: POST
url: "{{base_url}}/auth/login"
headers:
Content-Type: application/json
body: |
{"username": "admin", "password": "secret"}
test_script: |
assert_status(200)
if json_body.token then pass("token OK") else fail("no token") end
EOF
# 4. Run it
./greq
```
`greq` always looks for the `.greq/` folder in the **current directory**, so each git repo can have its own request set.
---
## Dependencies
| Package | Purpose |
|---|---|
| `charmbracelet/bubbletea` | TUI framework |
| `charmbracelet/bubbles` | List, textinput, viewport components |
| `charmbracelet/lipgloss` | Styles, colours, layout |
| `alecthomas/chroma/v2` | JSON syntax highlighting |
| `yuin/gopher-lua` | Lua interpreter for test scripts |
| `atotto/clipboard` | Clipboard support |
| `gopkg.in/yaml.v3` | YAML read/write |
+38
View File
@@ -0,0 +1,38 @@
module greq
go 1.26.1
require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+76
View File
@@ -0,0 +1,76 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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, ", "))
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"fmt"
"os"
"greq/internal/tui"
tea "github.com/charmbracelet/bubbletea"
)
// version is set at build time via -ldflags "-X main.version=..."
var version = "dev"
func main() {
p := tea.NewProgram(tui.New(), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "greq: %v\n", err)
os.Exit(1)
}
}