commit 2dd6519168e69b0abba576a1d27145cb45093849 Author: Mester Gábor Date: Thu Mar 19 07:12:03 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ce007d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.greq + +# Built binaries +greq +greq.exe +dist/ + +# Go build cache +*.test +*.out diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..655ea3c --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..312042d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5df311 --- /dev/null +++ b/README.md @@ -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 +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 | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a93e6fb --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..93e8cb8 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/chain.go b/internal/api/chain.go new file mode 100644 index 0000000..779e9b2 --- /dev/null +++ b/internal/api/chain.go @@ -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) + } + } +} diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..feeefb2 --- /dev/null +++ b/internal/api/client.go @@ -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 + } + } + } +} diff --git a/internal/api/curl.go b/internal/api/curl.go new file mode 100644 index 0000000..05077a0 --- /dev/null +++ b/internal/api/curl.go @@ -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, "'", "'\\''") + "'" +} diff --git a/internal/api/lua_test_runner.go b/internal/api/lua_test_runner.go new file mode 100644 index 0000000..af4eaa1 --- /dev/null +++ b/internal/api/lua_test_runner.go @@ -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)) + } +} diff --git a/internal/api/script.go b/internal/api/script.go new file mode 100644 index 0000000..6d5161e --- /dev/null +++ b/internal/api/script.go @@ -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 +} diff --git a/internal/api/stress.go b/internal/api/stress.go new file mode 100644 index 0000000..129098a --- /dev/null +++ b/internal/api/stress.go @@ -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 (0–100). +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, + } +} diff --git a/internal/storage/config.go b/internal/storage/config.go new file mode 100644 index 0000000..c68a6a5 --- /dev/null +++ b/internal/storage/config.go @@ -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) +} diff --git a/internal/storage/loader.go b/internal/storage/loader.go new file mode 100644 index 0000000..ae1c4e8 --- /dev/null +++ b/internal/storage/loader.go @@ -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 +} diff --git a/internal/storage/types.go b/internal/storage/types.go new file mode 100644 index 0000000..24b9fe0 --- /dev/null +++ b/internal/storage/types.go @@ -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 +} diff --git a/internal/storage/writer.go b/internal/storage/writer.go new file mode 100644 index 0000000..47fff07 --- /dev/null +++ b/internal/storage/writer.go @@ -0,0 +1,36 @@ +package storage + +import ( + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// SaveRequest writes rf to .greq/requests//.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) +} diff --git a/internal/tui/delegate.go b/internal/tui/delegate.go new file mode 100644 index 0000000..c1c2e3d --- /dev/null +++ b/internal/tui/delegate.go @@ -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) + } + } +} diff --git a/internal/tui/diff.go b/internal/tui/diff.go new file mode 100644 index 0000000..6a6cceb --- /dev/null +++ b/internal/tui/diff.go @@ -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/.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 +} diff --git a/internal/tui/editor_picker.go b/internal/tui/editor_picker.go new file mode 100644 index 0000000..58dc592 --- /dev/null +++ b/internal/tui/editor_picker.go @@ -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 +} diff --git a/internal/tui/history.go b/internal/tui/history.go new file mode 100644 index 0000000..0e83ac9 --- /dev/null +++ b/internal/tui/history.go @@ -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, + ), + ) +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..77bbd37 --- /dev/null +++ b/internal/tui/model.go @@ -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 +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..d9dbcd6 --- /dev/null +++ b/internal/tui/styles.go @@ -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) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..5784dba --- /dev/null +++ b/internal/tui/update.go @@ -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 +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..6296fbb --- /dev/null +++ b/internal/tui/view.go @@ -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, ", ")) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bcdcb01 --- /dev/null +++ b/main.go @@ -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) + } +}