From e8e64eff1223d7ec83ac4c8ee1a598a6f2616661 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Tue, 12 May 2026 19:12:29 +0200 Subject: [PATCH] Init Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- .github/CONTRIBUTING.md | 10 + .github/FUNDING.yml | 1 + .github/assets/banner.png | Bin 0 -> 27576 bytes .github/assets/demo.tape | 0 .github/assets/logo.png | Bin 0 -> 4117 bytes .github/docs/certificate.md | 14 + .github/docs/history.md | 25 ++ .github/docs/main.md | 15 + .github/docs/plugins.md | 127 ++++++ .github/docs/proxy.md | 9 + .github/docs/scopes.md | 19 + .github/plugins_example/inject_header.lua | 26 ++ .github/plugins_example/ip_whitelist.lua | 48 ++ .github/plugins_example/secret_finder.lua | 35 ++ .github/workflows/release.yml | 28 ++ .gitignore | 3 + .goreleaser.yaml | 31 ++ LICENSE | 21 + README.md | 84 ++++ cmd/spilltea/main.go | 105 +++++ docs.go | 6 + flake.lock | 27 ++ flake.nix | 41 ++ go.mod | 70 +++ go.sum | 189 ++++++++ internal/config/colors.go | 20 + internal/config/config.go | 101 +++++ internal/config/default_config.yaml | 96 ++++ internal/config/keybindings.go | 77 ++++ internal/db/db.go | 97 ++++ internal/db/entries.go | 131 ++++++ internal/db/findings.go | 63 +++ internal/db/plugins.go | 35 ++ internal/db/replay.go | 76 ++++ internal/db/scope.go | 45 ++ internal/icons/icons.go | 51 +++ internal/intercept/broker.go | 222 ++++++++++ internal/intercept/cmd.go | 18 + internal/intercept/format.go | 61 +++ internal/keys/diff.go | 20 + internal/keys/findings.go | 20 + internal/keys/global.go | 54 +++ internal/keys/history.go | 26 ++ internal/keys/home.go | 24 + internal/keys/intercept.go | 41 ++ internal/keys/keys.go | 72 +++ internal/keys/plugins.go | 24 + internal/keys/replay.go | 30 ++ internal/plugins/cmd.go | 15 + internal/plugins/lua.go | 206 +++++++++ internal/plugins/manager.go | 346 +++++++++++++++ internal/plugins/types.go | 66 +++ internal/proxy/proxy.go | 128 ++++++ internal/style/border.go | 43 ++ internal/style/components.go | 84 ++++ internal/style/glamour.go | 236 ++++++++++ internal/style/highlight.go | 333 ++++++++++++++ internal/style/style.go | 100 +++++ internal/ui/app/model.go | 137 ++++++ internal/ui/app/pages.go | 146 +++++++ internal/ui/app/sidebar.go | 88 ++++ internal/ui/app/update.go | 238 ++++++++++ internal/ui/app/view.go | 49 +++ internal/ui/components/copyas/formats.go | 200 +++++++++ internal/ui/components/copyas/model.go | 117 +++++ internal/ui/components/copyas/update.go | 30 ++ internal/ui/components/copyas/view.go | 93 ++++ internal/ui/components/notifications/model.go | 155 +++++++ internal/ui/components/teapot/teapot.go | 72 +++ internal/ui/diff/model.go | 264 +++++++++++ internal/ui/diff/update.go | 143 ++++++ internal/ui/diff/view.go | 94 ++++ internal/ui/docs/model.go | 37 ++ internal/ui/docs/update.go | 50 +++ internal/ui/docs/view.go | 52 +++ internal/ui/findings/model.go | 156 +++++++ internal/ui/findings/update.go | 86 ++++ internal/ui/findings/view.go | 112 +++++ internal/ui/history/model.go | 149 +++++++ internal/ui/history/search.go | 48 ++ internal/ui/history/update.go | 303 +++++++++++++ internal/ui/history/view.go | 150 +++++++ internal/ui/home/model.go | 339 ++++++++++++++ internal/ui/home/update.go | 180 ++++++++ internal/ui/home/view.go | 101 +++++ internal/ui/intercept/helpers.go | 384 ++++++++++++++++ internal/ui/intercept/keymap.go | 34 ++ internal/ui/intercept/model.go | 118 +++++ internal/ui/intercept/update.go | 296 +++++++++++++ internal/ui/intercept/view.go | 220 ++++++++++ internal/ui/plugins/model.go | 180 ++++++++ internal/ui/plugins/update.go | 130 ++++++ internal/ui/plugins/view.go | 150 +++++++ internal/ui/replay/model.go | 175 ++++++++ internal/ui/replay/update.go | 413 ++++++++++++++++++ internal/ui/replay/view.go | 137 ++++++ internal/ui/scope/model.go | 150 +++++++ internal/ui/scope/update.go | 70 +++ internal/ui/scope/view.go | 84 ++++ internal/util/editor.go | 38 ++ internal/util/util.go | 18 + 101 files changed, 10081 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/FUNDING.yml create mode 100644 .github/assets/banner.png create mode 100644 .github/assets/demo.tape create mode 100644 .github/assets/logo.png create mode 100644 .github/docs/certificate.md create mode 100644 .github/docs/history.md create mode 100644 .github/docs/main.md create mode 100644 .github/docs/plugins.md create mode 100644 .github/docs/proxy.md create mode 100644 .github/docs/scopes.md create mode 100644 .github/plugins_example/inject_header.lua create mode 100644 .github/plugins_example/ip_whitelist.lua create mode 100644 .github/plugins_example/secret_finder.lua create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/spilltea/main.go create mode 100644 docs.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/colors.go create mode 100644 internal/config/config.go create mode 100644 internal/config/default_config.yaml create mode 100644 internal/config/keybindings.go create mode 100644 internal/db/db.go create mode 100644 internal/db/entries.go create mode 100644 internal/db/findings.go create mode 100644 internal/db/plugins.go create mode 100644 internal/db/replay.go create mode 100644 internal/db/scope.go create mode 100644 internal/icons/icons.go create mode 100644 internal/intercept/broker.go create mode 100644 internal/intercept/cmd.go create mode 100644 internal/intercept/format.go create mode 100644 internal/keys/diff.go create mode 100644 internal/keys/findings.go create mode 100644 internal/keys/global.go create mode 100644 internal/keys/history.go create mode 100644 internal/keys/home.go create mode 100644 internal/keys/intercept.go create mode 100644 internal/keys/keys.go create mode 100644 internal/keys/plugins.go create mode 100644 internal/keys/replay.go create mode 100644 internal/plugins/cmd.go create mode 100644 internal/plugins/lua.go create mode 100644 internal/plugins/manager.go create mode 100644 internal/plugins/types.go create mode 100644 internal/proxy/proxy.go create mode 100644 internal/style/border.go create mode 100644 internal/style/components.go create mode 100644 internal/style/glamour.go create mode 100644 internal/style/highlight.go create mode 100644 internal/style/style.go create mode 100644 internal/ui/app/model.go create mode 100644 internal/ui/app/pages.go create mode 100644 internal/ui/app/sidebar.go create mode 100644 internal/ui/app/update.go create mode 100644 internal/ui/app/view.go create mode 100644 internal/ui/components/copyas/formats.go create mode 100644 internal/ui/components/copyas/model.go create mode 100644 internal/ui/components/copyas/update.go create mode 100644 internal/ui/components/copyas/view.go create mode 100644 internal/ui/components/notifications/model.go create mode 100644 internal/ui/components/teapot/teapot.go create mode 100644 internal/ui/diff/model.go create mode 100644 internal/ui/diff/update.go create mode 100644 internal/ui/diff/view.go create mode 100644 internal/ui/docs/model.go create mode 100644 internal/ui/docs/update.go create mode 100644 internal/ui/docs/view.go create mode 100644 internal/ui/findings/model.go create mode 100644 internal/ui/findings/update.go create mode 100644 internal/ui/findings/view.go create mode 100644 internal/ui/history/model.go create mode 100644 internal/ui/history/search.go create mode 100644 internal/ui/history/update.go create mode 100644 internal/ui/history/view.go create mode 100644 internal/ui/home/model.go create mode 100644 internal/ui/home/update.go create mode 100644 internal/ui/home/view.go create mode 100644 internal/ui/intercept/helpers.go create mode 100644 internal/ui/intercept/keymap.go create mode 100644 internal/ui/intercept/model.go create mode 100644 internal/ui/intercept/update.go create mode 100644 internal/ui/intercept/view.go create mode 100644 internal/ui/plugins/model.go create mode 100644 internal/ui/plugins/update.go create mode 100644 internal/ui/plugins/view.go create mode 100644 internal/ui/replay/model.go create mode 100644 internal/ui/replay/update.go create mode 100644 internal/ui/replay/view.go create mode 100644 internal/ui/scope/model.go create mode 100644 internal/ui/scope/update.go create mode 100644 internal/ui/scope/view.go create mode 100644 internal/util/editor.go create mode 100644 internal/util/util.go diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7b8ae86 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Everybody is invited and welcome to contribute. There is a lot to do... Check the issues! + +The process is straight-forward. + +- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1) +- Fork this git repository +- Write your changes (bug fixes, new features, ...). +- Create a Pull Request against the main branch. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b1c5749 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: anotherhadi diff --git a/.github/assets/banner.png b/.github/assets/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..8d2285c3e8336d66e1e97a04d3643c250d2d6430 GIT binary patch literal 27576 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 literal 0 HcmV?d00001 diff --git a/.github/assets/demo.tape b/.github/assets/demo.tape new file mode 100644 index 0000000..e69de29 diff --git a/.github/assets/logo.png b/.github/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..35a0210a0d319ecc87c94d0d2a1a4d814ef796c8 GIT binary patch literal 4117 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 literal 0 HcmV?d00001 diff --git a/.github/docs/certificate.md b/.github/docs/certificate.md new file mode 100644 index 0000000..459a1da --- /dev/null +++ b/.github/docs/certificate.md @@ -0,0 +1,14 @@ +## CA Certificate Installation + +1. Copy your **CA Certificate** located in `{{.Cfg.App.CertDir}}` +2. Install your certificate: + - On Chrome: + - Open your Chrome settings, search for "Certificates" and click on "Security". + - In the security settings page, scroll down and click on "Manage certificates". + - Select the "Authorities" tab and click on "Import tab and click on "Import". + - Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`. + - On Firefox: + - Open your Firefox settings, search for "Certificates" and click on "View Certificates". + - Select the "Authorities" tab and click on "Import". + - Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`. + - When prompted, click the "Trust this CA to identify websites" checkbox, then click on "OK". diff --git a/.github/docs/history.md b/.github/docs/history.md new file mode 100644 index 0000000..3004831 --- /dev/null +++ b/.github/docs/history.md @@ -0,0 +1,25 @@ +## History Search + +The History page has a built-in search bar with two modes: + +**Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies. + +**SQL mode**: press `:` to open it, then `Enter` to run. You can write either a WHERE expression or a full SELECT query against the `entries` table. + +WHERE expression (the `SELECT` is added automatically): + +```sql +status_code = 404 +``` + +```sql +host LIKE '%.api.%' AND method = 'POST' +``` + +Full SELECT query: + +```sql +SELECT * FROM entries WHERE response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20 +``` + +The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`. diff --git a/.github/docs/main.md b/.github/docs/main.md new file mode 100644 index 0000000..fcd3e5b --- /dev/null +++ b/.github/docs/main.md @@ -0,0 +1,15 @@ +```txt + ) + ( + ) + .-.,--^--. _ + \\| `---' |// + \| / + _\_______/_ +``` + +# Spilltea Documentation + +- **Version**: `{{.Cfg.Version}}` +- **Repository**: `https://github.com/anotherhadi/spilltea` +- **Sponsor this project**: `https://ko-fi.com/anotherhadi` diff --git a/.github/docs/plugins.md b/.github/docs/plugins.md new file mode 100644 index 0000000..b0ae181 --- /dev/null +++ b/.github/docs/plugins.md @@ -0,0 +1,127 @@ +# Plugins + +Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic. + +## Where to place plugins + +Put `.lua` files in the directory configured by `plugins_dir` in your config file (default: `~/.config/spilltea/plugins`). + +Each file is loaded as a separate plugin at startup. The plugin list is shown on the **Plugins** page. + +## Plugin structure + +Every plugin must declare a `Plugin` table and implement the hooks it wants to use. + +```lua +Plugin = { + name = "My Plugin", + + -- Declare which hooks you use and whether they are synchronous. + on_start = { sync = true }, + on_request = { sync = true }, + on_response = { sync = false }, + on_history_entry = {}, + on_quit = {}, +} +``` + +### Hook reference + +| Hook | When called | Sync/async | Return value | +| ------------------------- | --------------------------- | ------------ | ------------------- | +| `on_start(config_text)` | Once at startup | always sync | ignored | +| `on_quit()` | When the app exits | always sync | ignored | +| `on_request(req)` | Every request | declared | `"drop"`, `"forward"`, or `nil` (sync only) | +| `on_response(req, res)` | Every response | declared | `"drop"`, `"forward"`, or `nil` (sync only) | +| `on_history_entry(entry)` | After a flow is saved to DB | always async | ignored | + +## Request and response objects + +### `req` (request) + +| Field / method | Type | Description | +| ----------------------------- | ------ | ----------------------------------- | +| `req.method` | string | HTTP method | +| `req.url` | string | Full URL | +| `req.host` | string | Host | +| `req.path` | string | Path | +| `req.headers["Name"]` | string | Request header value | +| `req:get_body()` | string | Raw request body (loaded on demand) | +| `req:set_header(name, value)` | - | Set a request header | +| `req:set_body(body)` | - | Replace the request body | + +### `res` (response) + +| Field / method | Type | Description | +| ----------------------------- | ------ | ------------------------- | +| `res.status_code` | number | HTTP status code | +| `res.headers["Name"]` | string | Response header value | +| `res:get_body()` | string | Raw response body | +| `res:set_header(name, value)` | - | Set a response header | +| `res:set_body(body)` | - | Replace the response body | + +### `entry` (history entry) + +| Field | Type | +| -------------------- | ---------------------------- | +| `entry.id` | number | +| `entry.method` | string | +| `entry.host` | string | +| `entry.path` | string | +| `entry.status_code` | number | +| `entry.timestamp` | string (YYYY-MM-DD HH:MM:SS) | +| `entry.request_raw` | string | +| `entry.response_raw` | string | + +## Utility functions + +```lua +-- Log a message to logs.log (prefixed with the plugin name) +log("message") + +-- Send a notification bubble in the TUI +notif("Title", "Body text") + +-- Create a finding (shown on the Findings page, persisted in DB) +create_finding({ + title = "API Key Found", + description = "Markdown description of the finding...", + key = "stable-unique-id", -- used for deduplication; defaults to title + severity = "high", -- info | low | medium | high | critical +}) + +-- Check if a URL matches the current scope (whitelist/blacklist) +local ok = is_in_scope("https://example.com/api/v1") + +-- Quit the app (useful for startup checks that fail) +quit("reason message") +``` + +### Finding deduplication + +A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again. + +## Configuration + +Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_start(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.). + +## Sync vs async + +- **`sync = true`**: spilltea waits for the hook to return before continuing. For `on_request`/`on_response` this blocks the proxy goroutine; the hook can return one of the values below. +- **`sync = false`** (or omitted for supported hooks): the hook runs in a background goroutine. Return values are ignored. Use this for analysis and findings. + +### Return values for `on_request` and `on_response` (sync only) + +| Return value | Effect | +| ------------ | ------ | +| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. | +| `"forward"` | The flow is forwarded immediately without going through the intercept panel. | +| `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. | + +The `sync` declaration is only meaningful for `on_request` and `on_response`. The other hooks have fixed behaviour: + +- `on_start` is **always synchronous**: plugins are initialised one by one before the first request is accepted. +- `on_quit` is **always synchronous**: the app waits for all `on_quit` hooks before exiting. +- `on_history_entry` is **always asynchronous**. + +> A sync `on_request` or `on_response` hook that hangs will block traffic for that flow. There is no automatic timeout. diff --git a/.github/docs/proxy.md b/.github/docs/proxy.md new file mode 100644 index 0000000..2a5caa1 --- /dev/null +++ b/.github/docs/proxy.md @@ -0,0 +1,9 @@ +## Configuring your browser's proxy settings + +We recommend installing **FoxyProxy** to manage your browser's proxies. +You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions) + +1. Open FoxyProxy's options, then click on `Add New Proxy` button. +2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save". +3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button. +4. You're all set! You can now use Spilltea. diff --git a/.github/docs/scopes.md b/.github/docs/scopes.md new file mode 100644 index 0000000..3bb1515 --- /dev/null +++ b/.github/docs/scopes.md @@ -0,0 +1,19 @@ +## Scopes + +Scopes let you control which requests Spilltea intercepts. Patterns are Go regular expressions matched against `host/path` (e.g. `api.example.com/v1/users`). + +- **Whitelist**: if non-empty, only matching requests are intercepted. +- **Blacklist**: matching requests are always ignored, even if whitelisted. + +When both lists are set, a request must pass the whitelist _and_ not be in the blacklist. + +### Examples + +| Pattern | Matches | +| -------------------------- | ----------------------------------- | +| `example\.com` | any request to `example.com` | +| `^api\.example\.com` | only the `api` subdomain | +| `example\.com/api/v2` | a specific path prefix | +| `\.(js\|css\|png\|woff2?)` | static assets (useful in blacklist) | +| `googleapis\.com` | all Google API traffic | +| `/graphql$` | any host with a `/graphql` endpoint | diff --git a/.github/plugins_example/inject_header.lua b/.github/plugins_example/inject_header.lua new file mode 100644 index 0000000..87dd739 --- /dev/null +++ b/.github/plugins_example/inject_header.lua @@ -0,0 +1,26 @@ +-- Inject a custom header into every request. +-- Config format (one per line): Header-Name: value + +Plugin = { + name = "Inject Header", + on_request = { sync = true }, +} + +local headers = {} + +function on_start(config_text) + for line in config_text:gmatch("[^\n]+") do + local name, value = line:match("^([^:]+):%s*(.+)$") + if name and value then + table.insert(headers, { name = name, value = value }) + end + end + log("loaded " .. #headers .. " header(s)") +end + +function on_request(req) + for _, h in ipairs(headers) do + req:set_header(h.name, h.value) + end + return "forward" +end diff --git a/.github/plugins_example/ip_whitelist.lua b/.github/plugins_example/ip_whitelist.lua new file mode 100644 index 0000000..0aa8c8f --- /dev/null +++ b/.github/plugins_example/ip_whitelist.lua @@ -0,0 +1,48 @@ +-- Check that the proxy's outbound IP is in the whitelist before starting. +-- Config: one allowed IP per line. Leave empty to disable the check. + +Plugin = { + name = "IP Whitelist", + on_start = {}, +} + +function on_start(config_text) + local allowed = {} + for line in config_text:gmatch("[^\n]+") do + local ip = line:match("^%s*(.-)%s*$") + if ip ~= "" then + table.insert(allowed, ip) + end + end + + if #allowed == 0 then + log("no IPs configured, skipping check") + return + end + + -- Fetch the current outbound IP via a public API. + local ok, result = pcall(function() + local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null") + if not handle then return nil end + local ip = handle:read("*a") + handle:close() + return ip and ip:match("^%s*(.-)%s*$") or nil + end) + + if not ok or not result or result == "" then + log("could not determine outbound IP, skipping check") + return + end + + log("outbound IP: " .. result) + + for _, ip in ipairs(allowed) do + if result == ip then + log("IP " .. result .. " is whitelisted") + return + end + end + + notif("IP Whitelist", "Outbound IP " .. result .. " is NOT in the whitelist!") + quit("outbound IP " .. result .. " not whitelisted") +end diff --git a/.github/plugins_example/secret_finder.lua b/.github/plugins_example/secret_finder.lua new file mode 100644 index 0000000..1437de3 --- /dev/null +++ b/.github/plugins_example/secret_finder.lua @@ -0,0 +1,35 @@ +-- Scan response bodies for common API key / secret patterns. +-- Runs asynchronously so it never delays traffic. + +Plugin = { + name = "Secret Finder", + on_response = { sync = false }, +} + +local PATTERNS = { + { pattern = "AIza[0-9A-Za-z%-_]{35}", label = "Google API Key" }, + { pattern = "AKIA[0-9A-Z]{16}", label = "AWS Access Key" }, + { pattern = "sk%-[a-zA-Z0-9]{20,}", label = "OpenAI API Key" }, + { pattern = "ghp_[a-zA-Z0-9]{36}", label = "GitHub Personal Token" }, + { pattern = "Bearer%s+[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+", + label = "JWT Bearer Token" }, +} + +function on_response(req, res) + local body = res:get_body() + if body == "" then return end + + for _, p in ipairs(PATTERNS) do + if body:find(p.pattern) then + local key = p.label .. ":" .. req.host + create_finding({ + title = p.label .. " in response", + description = "**Host:** `" .. req.host .. "`\n\n" .. + "**Path:** `" .. req.path .. "`\n\n" .. + "Pattern `" .. p.pattern .. "` matched in the response body.", + key = key, + severity = "high", + }) + end + end +end diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8cf8fc1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5c89e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.claude/ +CLAUDE.md +result/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..8defbe2 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,31 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - binary: spilltea + goos: + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ce7478 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hadi + +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..8c5cf3b --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +

+ logo +
+ +
+ +# Spilltea + +> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. +> Think Burp Suite or Caido, but entirely in your terminal. + +[![Go Version](https://img.shields.io/github/go-mod/go-version/anotherhadi/spilltea)](go.mod) +[![Release](https://img.shields.io/github/v/release/anotherhadi/spilltea)](https://github.com/anotherhadi/spilltea/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Go Report Card](https://goreportcard.com/badge/github.com/anotherhadi/spilltea)](https://goreportcard.com/report/github.com/anotherhadi/spilltea) + +## What is Spilltea? + +Spilltea is a **terminal-native HTTP(S) interception proxy**. It sits between your browser and the internet, letting you inspect, modify, and replay traffic without ever leaving your terminal. + +It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, keyboard-driven tool that gets out of your way. + +## Features + +- **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding. +- **HTTP History**: Every request that passes through the proxy is stored. Browse, search and filter your full session history. +- **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration +- **Scopes**: Keep your history clean by white/blacklisting domains or specific paths. +- **HTTPS Support** (using go-mitmproxy under the hood) +- Built-in Integrations: + - **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly. + - **cURL / HTTPie**: Copy any request as a curl or httpie command to your clipboard. + - **Markdown Export**: Export any request and its response as a clean Markdown snippet, ready to drop into a report. + +## Project Management + +Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files. + +On startup, you choose: + +- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default +- **Existing project**: pick from a list of previous projects +- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot! + +## Plugin System + +Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code. +For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md). + +## Configuration + +Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`. +Check the default configuration with all the options [here](./internal/config/default_config.yaml) + +## Deployment + +spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component. + +If you need to run spilltea on a remote machine (e.g., a VPS or pivot host), use SSH port forwarding: + +```sh +ssh -L 8080:127.0.0.1:8080 user@remote-host +``` + +Then point your browser at `127.0.0.1:8080` as usual. + +## Tech Stack + +| Component | Library | +| ------------------ | --------------------------------------------------------- | +| TUI | [bubbletea](https://github.com/charmbracelet/bubbletea) | +| Styles | [lipgloss](https://github.com/charmbracelet/lipgloss) | +| Proxy / MITM / TLS | [go-mitmproxy](https://github.com/lqqyt2423/go-mitmproxy) | +| Storage | [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | +| Config | [viper](https://github.com/spf13/viper) | +| Plugins | [gopher-lua](https://github.com/yuin/gopher-lua) | + +--- + + 0, nil +} + +func (d *DB) LoadFindings() ([]Finding, error) { + rows, err := d.conn.Query( + `SELECT id, plugin_name, dedup_key, title, description, severity, created_at + FROM findings WHERE dismissed = 0 ORDER BY id DESC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Finding + for rows.Next() { + var f Finding + var ts string + if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil { + return nil, err + } + for _, layout := range findingTimeFormats { + if t, err := time.Parse(layout, ts); err == nil { + f.CreatedAt = t + break + } + } + out = append(out, f) + } + return out, rows.Err() +} + +func (d *DB) DismissFinding(id int64) error { + _, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id) + return err +} diff --git a/internal/db/plugins.go b/internal/db/plugins.go new file mode 100644 index 0000000..2d1c013 --- /dev/null +++ b/internal/db/plugins.go @@ -0,0 +1,35 @@ +package db + +type PluginState struct { + Name string + Enabled bool + ConfigText string +} + +func (d *DB) SavePluginState(name string, enabled bool, configText string) error { + _, err := d.conn.Exec( + `INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`, + name, enabled, configText, + ) + return err +} + +func (d *DB) LoadPluginStates() ([]PluginState, error) { + rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []PluginState + for rows.Next() { + var s PluginState + var enabled int + if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil { + return nil, err + } + s.Enabled = enabled != 0 + out = append(out, s) + } + return out, rows.Err() +} diff --git a/internal/db/replay.go b/internal/db/replay.go new file mode 100644 index 0000000..ad25f16 --- /dev/null +++ b/internal/db/replay.go @@ -0,0 +1,76 @@ +package db + +import ( + "time" +) + +type ReplayEntry struct { + ID int64 + Timestamp time.Time + Scheme string + Host string + Path string + Method string + OriginalRaw string + RequestRaw string + ResponseRaw string + StatusCode int + ErrorMsg string +} + +func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) { + res, err := d.conn.Exec( + `INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.Timestamp.UTC().Format(time.RFC3339), + e.Scheme, e.Host, e.Path, e.Method, + e.OriginalRaw, e.RequestRaw, e.ResponseRaw, + e.StatusCode, e.ErrorMsg, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (d *DB) UpdateReplayEntry(e ReplayEntry) error { + _, err := d.conn.Exec( + `UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`, + e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID, + ) + return err +} + +func (d *DB) ListReplayEntries() ([]ReplayEntry, error) { + rows, err := d.conn.Query( + `SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg + FROM replay_entries ORDER BY id ASC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []ReplayEntry + for rows.Next() { + var e ReplayEntry + var ts string + if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method, + &e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil { + return nil, err + } + e.Timestamp, _ = time.Parse(time.RFC3339, ts) + entries = append(entries, e) + } + return entries, rows.Err() +} + +func (d *DB) DeleteReplayEntry(id int64) error { + _, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id) + return err +} + +func (d *DB) DeleteAllReplayEntries() error { + _, err := d.conn.Exec(`DELETE FROM replay_entries`) + return err +} diff --git a/internal/db/scope.go b/internal/db/scope.go new file mode 100644 index 0000000..9b3d731 --- /dev/null +++ b/internal/db/scope.go @@ -0,0 +1,45 @@ +package db + +func (d *DB) SaveScope(whitelist, blacklist []string) error { + tx, err := d.conn.Begin() + if err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM scope`); err != nil { + tx.Rollback() + return err + } + for _, p := range whitelist { + if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('whitelist', ?)`, p); err != nil { + tx.Rollback() + return err + } + } + for _, p := range blacklist { + if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('blacklist', ?)`, p); err != nil { + tx.Rollback() + return err + } + } + return tx.Commit() +} + +func (d *DB) LoadScope() (whitelist, blacklist []string, err error) { + rows, err := d.conn.Query(`SELECT kind, pattern FROM scope`) + if err != nil { + return nil, nil, err + } + defer rows.Close() + for rows.Next() { + var kind, pattern string + if err := rows.Scan(&kind, &pattern); err != nil { + return nil, nil, err + } + if kind == "whitelist" { + whitelist = append(whitelist, pattern) + } else { + blacklist = append(blacklist, pattern) + } + } + return whitelist, blacklist, rows.Err() +} diff --git a/internal/icons/icons.go b/internal/icons/icons.go new file mode 100644 index 0000000..e81201f --- /dev/null +++ b/internal/icons/icons.go @@ -0,0 +1,51 @@ +package icons + +import "github.com/anotherhadi/spilltea/internal/config" + +type Icons struct { + Forward string + Drop string + Edit string + Intercept string + History string + Replay string + Diff string + Request string + Response string + Plugin string + Findings string + Scope string + Detail string + Docs string + New string + Temp string + Project string +} + +var I *Icons + +func Init(cfg *config.Config) { + if cfg.TUI.UseNerdfontIcons { + I = &Icons{ + Forward: "󰁔 ", + Drop: "󰆴 ", + Edit: "󰏫 ", + Intercept: " ", + History: "󰋚 ", + Replay: "󰑙 ", + Diff: "󰕛 ", + Request: "󰜷 ", + Response: "󰜮 ", + Plugin: " ", + Findings: "󱎸 ", + Scope: "󰓾 ", + Detail: "󰱼 ", + Docs: " ", + New: "󰐕 ", + Temp: "󰙨 ", + Project: "󰉋 ", + } + } else { + I = &Icons{} + } +} diff --git a/internal/intercept/broker.go b/internal/intercept/broker.go new file mode 100644 index 0000000..58ac045 --- /dev/null +++ b/internal/intercept/broker.go @@ -0,0 +1,222 @@ +package intercept + +import ( + "regexp" + "sync" + "sync/atomic" + "time" + + "github.com/anotherhadi/spilltea/internal/config" + "github.com/anotherhadi/spilltea/internal/db" + "github.com/lqqyt2423/go-mitmproxy/proxy" +) + +type Decision int + +const ( + Forward Decision = iota // forward without showing in intercept + Drop // drop the flow + Intercept // pass to the TUI for user decision +) + +type PendingRequest struct { + Flow *proxy.Flow + decision chan Decision + ArrivedAt time.Time +} + +type PendingResponse struct { + Flow *proxy.Flow + decision chan Decision + ArrivedAt time.Time +} + +type Broker struct { + Incoming chan *PendingRequest + IncomingResponse chan *PendingResponse + captureResponse atomic.Bool + + dbMu sync.RWMutex + database *db.DB + droppedFlows sync.Map // *proxy.Flow → struct{} + outOfScope sync.Map // *proxy.Flow → struct{} + + scopeMu sync.RWMutex + whitelist []*regexp.Regexp + blacklist []*regexp.Regexp + + onNewEntry func(db.Entry) +} + +func (b *Broker) SetOnNewEntry(cb func(db.Entry)) { + b.onNewEntry = cb +} + +// IsInScope reports whether the given target string (host+path) matches the +// current scope rules. Used by the plugin API. +func (b *Broker) IsInScope(target string) bool { + b.scopeMu.RLock() + wl := b.whitelist + bl := b.blacklist + b.scopeMu.RUnlock() + return scopeMatches(wl, bl, target) +} + +func NewBroker() *Broker { + return &Broker{ + Incoming: make(chan *PendingRequest, 64), + IncomingResponse: make(chan *PendingResponse, 64), + } +} + +func (b *Broker) SetCaptureResponse(v bool) { + b.captureResponse.Store(v) +} + +// SetScope compiles and stores whitelist/blacklist regex patterns. +// Invalid patterns are silently skipped. +func (b *Broker) SetScope(whitelist, blacklist []string) { + wl := compilePatterns(whitelist) + bl := compilePatterns(blacklist) + b.scopeMu.Lock() + b.whitelist = wl + b.blacklist = bl + b.scopeMu.Unlock() +} + +func compilePatterns(patterns []string) []*regexp.Regexp { + out := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + if r, err := regexp.Compile(p); err == nil { + out = append(out, r) + } + } + return out +} + +func (b *Broker) matchesScope(f *proxy.Flow) bool { + target := f.Request.URL.Host + f.Request.URL.Path + b.scopeMu.RLock() + wl := b.whitelist + bl := b.blacklist + b.scopeMu.RUnlock() + return scopeMatches(wl, bl, target) +} + +func scopeMatches(wl, bl []*regexp.Regexp, target string) bool { + if len(wl) > 0 { + matched := false + for _, r := range wl { + if r.MatchString(target) { + matched = true + break + } + } + if !matched { + return false + } + } + for _, r := range bl { + if r.MatchString(target) { + return false + } + } + return true +} + +func (b *Broker) SetDB(d *db.DB) { + b.dbMu.Lock() + b.database = d + b.dbMu.Unlock() +} + +// Hold is called from the proxy addon: it blocks until a decision is made in the TUI. +func (b *Broker) Hold(f *proxy.Flow) Decision { + if !b.matchesScope(f) { + b.outOfScope.Store(f, struct{}{}) + return Forward + } + p := &PendingRequest{ + Flow: f, + decision: make(chan Decision, 1), + ArrivedAt: time.Now(), + } + b.Incoming <- p + d := <-p.decision + if d == Drop { + b.droppedFlows.Store(f, struct{}{}) + } + return d +} + +// HoldResponse is called from the proxy addon after receiving the response headers, but before reading the body. +func (b *Broker) HoldResponse(f *proxy.Flow) Decision { + if _, oos := b.outOfScope.Load(f); oos { + return Forward + } + if !b.captureResponse.Load() { + return Forward + } + p := &PendingResponse{ + Flow: f, + decision: make(chan Decision, 1), + ArrivedAt: time.Now(), + } + b.IncomingResponse <- p + return <-p.decision +} + +// SaveEntry persists the completed flow to the history DB. +// It must be called after HoldResponse and before modifying f.Response. +// Flows that were dropped at the request phase are silently skipped. +func (b *Broker) SaveEntry(f *proxy.Flow) { + b.dbMu.RLock() + d := b.database + b.dbMu.RUnlock() + if d == nil { + return + } + if _, oos := b.outOfScope.LoadAndDelete(f); oos { + return + } + if _, dropped := b.droppedFlows.LoadAndDelete(f); dropped { + return + } + status := 0 + if f.Response != nil { + status = f.Response.StatusCode + } + r := f.Request + path := r.URL.Path + if path == "" { + path = "/" + } + if config.Global.History.SkipDuplicates { + body := string(r.Body) + if dup, _ := d.HasDuplicate(r.Method, r.URL.Host, path, body); dup { + return + } + } + entry, err := d.InsertEntry(db.Entry{ + Timestamp: time.Now(), + Method: r.Method, + Host: r.URL.Host, + Path: path, + StatusCode: status, + RequestRaw: FormatRawRequest(f), + ResponseRaw: FormatRawResponse(f), + }) + if err == nil { + if cb := b.onNewEntry; cb != nil { + go cb(entry) + } + } +} + +func (b *Broker) Decide(p *PendingRequest, d Decision) { + p.decision <- d +} + +func (b *Broker) DecideResponse(p *PendingResponse, d Decision) { + p.decision <- d +} diff --git a/internal/intercept/cmd.go b/internal/intercept/cmd.go new file mode 100644 index 0000000..bd95cf0 --- /dev/null +++ b/internal/intercept/cmd.go @@ -0,0 +1,18 @@ +package intercept + +import tea "charm.land/bubbletea/v2" + +type RequestArrivedMsg struct{ Req *PendingRequest } +type ResponseArrivedMsg struct{ Resp *PendingResponse } + +func WaitForRequest(b *Broker) tea.Cmd { + return func() tea.Msg { + return RequestArrivedMsg{Req: <-b.Incoming} + } +} + +func WaitForResponse(b *Broker) tea.Cmd { + return func() tea.Msg { + return ResponseArrivedMsg{Resp: <-b.IncomingResponse} + } +} diff --git a/internal/intercept/format.go b/internal/intercept/format.go new file mode 100644 index 0000000..d1cb8bb --- /dev/null +++ b/internal/intercept/format.go @@ -0,0 +1,61 @@ +package intercept + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "github.com/lqqyt2423/go-mitmproxy/proxy" +) + +// FormatRawRequest serialises a flow's request to a raw HTTP string. +func FormatRawRequest(f *proxy.Flow) string { + r := f.Request + var sb strings.Builder + fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto) + keys := make([]string, 0, len(r.Header)) + for k := range r.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + for _, v := range r.Header[k] { + fmt.Fprintf(&sb, "%s: %s\n", k, v) + } + } + sb.WriteString("\n") + if len(r.Body) > 0 { + sb.Write(r.Body) + } + return sb.String() +} + +// FormatRawResponse serialises a flow's response to a raw HTTP string. +func FormatRawResponse(f *proxy.Flow) string { + r := f.Response + if r == nil { + return "(no response)" + } + var sb strings.Builder + proto := f.Request.Proto + if proto == "" { + proto = "HTTP/1.1" + } + fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode)) + keys := make([]string, 0, len(r.Header)) + for k := range r.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + for _, v := range r.Header[k] { + fmt.Fprintf(&sb, "%s: %s\n", k, v) + } + } + sb.WriteString("\n") + if len(r.Body) > 0 { + sb.Write(r.Body) + } + return sb.String() +} diff --git a/internal/keys/diff.go b/internal/keys/diff.go new file mode 100644 index 0000000..19e0450 --- /dev/null +++ b/internal/keys/diff.go @@ -0,0 +1,20 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type DiffKeyMap struct { + Clear key.Binding +} + +func newDiffKeyMap(cfg config.DiffKeys) DiffKeyMap { + return DiffKeyMap{ + Clear: binding(cfg.Clear, "clear"), + } +} + +func (d DiffKeyMap) Bindings() []key.Binding { + return []key.Binding{d.Clear} +} diff --git a/internal/keys/findings.go b/internal/keys/findings.go new file mode 100644 index 0000000..b769667 --- /dev/null +++ b/internal/keys/findings.go @@ -0,0 +1,20 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type FindingsKeyMap struct { + Dismiss key.Binding +} + +func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap { + return FindingsKeyMap{ + Dismiss: binding(cfg.Dismiss, "dismiss"), + } +} + +func (f FindingsKeyMap) Bindings() []key.Binding { + return []key.Binding{f.Dismiss} +} diff --git a/internal/keys/global.go b/internal/keys/global.go new file mode 100644 index 0000000..a20b96c --- /dev/null +++ b/internal/keys/global.go @@ -0,0 +1,54 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type GlobalKeyMap struct { + Quit key.Binding + OpenLogs key.Binding + ToggleSidebar key.Binding + Help key.Binding + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + CycleFocus key.Binding + CopyRequest key.Binding + Escape key.Binding + SendToReplay key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + SendToDiff key.Binding +} + +func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap { + return GlobalKeyMap{ + Quit: binding(cfg.Quit, "quit"), + OpenLogs: binding(cfg.OpenLogs, "open logs"), + ToggleSidebar: binding(cfg.ToggleSidebar, "toggle sidebar"), + Help: binding(cfg.Help, "help"), + Up: binding(cfg.Up, "up"), + Down: binding(cfg.Down, "down"), + Left: binding(cfg.Left, "scroll left"), + Right: binding(cfg.Right, "scroll right"), + CycleFocus: binding(cfg.CycleFocus, "cycle focus"), + CopyRequest: binding(cfg.CopyRequest, "copy as..."), + Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + SendToReplay: binding(cfg.SendToReplay, "send to replay"), + ScrollUp: binding(cfg.ScrollUp, "scroll up"), + ScrollDown: binding(cfg.ScrollDown, "scroll down"), + SendToDiff: binding(cfg.SendToDiff, "send to diff"), + } +} + +func (g GlobalKeyMap) Bindings() []key.Binding { + return []key.Binding{ + g.Up, g.Down, g.Left, g.Right, g.CycleFocus, + g.Quit, g.Escape, g.Help, + g.OpenLogs, g.ToggleSidebar, g.CopyRequest, + g.SendToReplay, g.SendToDiff, + g.ScrollUp, g.ScrollDown, + } +} diff --git a/internal/keys/history.go b/internal/keys/history.go new file mode 100644 index 0000000..3426701 --- /dev/null +++ b/internal/keys/history.go @@ -0,0 +1,26 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type HistoryKeyMap struct { + DeleteEntry key.Binding + DeleteAll key.Binding + Filter key.Binding + SqlQuery key.Binding +} + +func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap { + return HistoryKeyMap{ + DeleteEntry: binding(cfg.DeleteEntry, "delete entry"), + DeleteAll: binding(cfg.DeleteAll, "delete all"), + Filter: binding(cfg.Filter, "filter"), + SqlQuery: binding(cfg.SqlQuery, "sql query"), + } +} + +func (h HistoryKeyMap) Bindings() []key.Binding { + return []key.Binding{h.DeleteEntry, h.DeleteAll} +} diff --git a/internal/keys/home.go b/internal/keys/home.go new file mode 100644 index 0000000..6a2329c --- /dev/null +++ b/internal/keys/home.go @@ -0,0 +1,24 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type HomeKeyMap struct { + Open key.Binding + Delete key.Binding + Filter key.Binding +} + +func newHomeKeyMap(cfg config.HomeKeys) HomeKeyMap { + return HomeKeyMap{ + Open: binding(cfg.Open, "open"), + Delete: binding(cfg.Delete, "delete project"), + Filter: binding(cfg.Filter, "filter"), + } +} + +func (h HomeKeyMap) Bindings() []key.Binding { + return []key.Binding{h.Open, h.Delete, h.Filter} +} diff --git a/internal/keys/intercept.go b/internal/keys/intercept.go new file mode 100644 index 0000000..84f3aba --- /dev/null +++ b/internal/keys/intercept.go @@ -0,0 +1,41 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type InterceptKeyMap struct { + Forward key.Binding + ForwardAll key.Binding + Drop key.Binding + DropAll key.Binding + AutoForward key.Binding + CaptureResponse key.Binding + UndoEdits key.Binding + Edit key.Binding + EditExternal key.Binding +} + +func newInterceptKeyMap(cfg config.InterceptKeys) InterceptKeyMap { + return InterceptKeyMap{ + Forward: binding(cfg.Forward, "forward"), + ForwardAll: binding(cfg.ForwardAll, "forward all"), + Drop: binding(cfg.Drop, "drop"), + DropAll: binding(cfg.DropAll, "drop all"), + AutoForward: binding(cfg.AutoForward, "auto forward"), + CaptureResponse: binding(cfg.CaptureResponse, "capture response"), + UndoEdits: binding(cfg.UndoEdits, "undo edits"), + Edit: binding(cfg.Edit, "edit"), + EditExternal: binding(cfg.EditExternal, "edit in $EDITOR"), + } +} + +func (ic InterceptKeyMap) Bindings() []key.Binding { + return []key.Binding{ + ic.Forward, ic.ForwardAll, + ic.Drop, ic.DropAll, + ic.Edit, ic.EditExternal, ic.UndoEdits, + ic.AutoForward, ic.CaptureResponse, + } +} diff --git a/internal/keys/keys.go b/internal/keys/keys.go new file mode 100644 index 0000000..849445c --- /dev/null +++ b/internal/keys/keys.go @@ -0,0 +1,72 @@ +package keys + +import ( + "strings" + + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type KeyMap struct { + Global GlobalKeyMap + Intercept InterceptKeyMap + Home HomeKeyMap + History HistoryKeyMap + Replay ReplayKeyMap + Diff DiffKeyMap + Findings FindingsKeyMap + Plugins PluginsKeyMap +} + +var Keys *KeyMap + +func Init(cfg *config.Config) { + kb := cfg.Keybindings + Keys = &KeyMap{ + Global: newGlobalKeyMap(kb.Global), + Intercept: newInterceptKeyMap(kb.Intercept), + Home: newHomeKeyMap(kb.Home), + History: newHistoryKeyMap(kb.History), + Replay: newReplayKeyMap(kb.Replay), + Diff: newDiffKeyMap(kb.Diff), + Findings: newFindingsKeyMap(kb.Findings), + Plugins: newPluginsKeyMap(kb.Plugins), + } +} + +func parseKeys(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if k := strings.TrimSpace(p); k != "" { + out = append(out, k) + } + } + return out +} + +func binding(s, help string) key.Binding { + keys := parseKeys(s) + display := strings.Join(keys, "/") + return key.NewBinding(key.WithKeys(keys...), key.WithHelp(display, help)) +} + +// ChunkByWidth splits bindings into columns sized to fit the terminal width. +func ChunkByWidth(bindings []key.Binding, termWidth int) [][]key.Binding { + cols := termWidth / 26 + if cols < 2 { + cols = 2 + } else if cols > 7 { + cols = 7 + } + perCol := (len(bindings) + cols - 1) / cols + var out [][]key.Binding + for i := 0; i < len(bindings); i += perCol { + end := i + perCol + if end > len(bindings) { + end = len(bindings) + } + out = append(out, bindings[i:end]) + } + return out +} diff --git a/internal/keys/plugins.go b/internal/keys/plugins.go new file mode 100644 index 0000000..2d09f70 --- /dev/null +++ b/internal/keys/plugins.go @@ -0,0 +1,24 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type PluginsKeyMap struct { + Toggle key.Binding + EditConfig key.Binding + Filter key.Binding +} + +func newPluginsKeyMap(cfg config.PluginsKeys) PluginsKeyMap { + return PluginsKeyMap{ + Toggle: binding(cfg.Toggle, "toggle"), + EditConfig: binding(cfg.EditConfig, "edit config"), + Filter: binding(cfg.Filter, "filter"), + } +} + +func (p PluginsKeyMap) Bindings() []key.Binding { + return []key.Binding{p.Toggle, p.EditConfig, p.Filter} +} diff --git a/internal/keys/replay.go b/internal/keys/replay.go new file mode 100644 index 0000000..7643eb8 --- /dev/null +++ b/internal/keys/replay.go @@ -0,0 +1,30 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type ReplayKeyMap struct { + Send key.Binding + Edit key.Binding + EditExt key.Binding + UndoEdits key.Binding + Delete key.Binding + DeleteAll key.Binding +} + +func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap { + return ReplayKeyMap{ + Send: binding(cfg.Send, "send"), + Edit: binding(cfg.Edit, "edit"), + EditExt: binding(cfg.EditExt, "edit in $EDITOR"), + UndoEdits: binding(cfg.UndoEdits, "undo edits"), + Delete: binding(cfg.Delete, "delete"), + DeleteAll: binding(cfg.DeleteAll, "delete all"), + } +} + +func (r ReplayKeyMap) Bindings() []key.Binding { + return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll} +} diff --git a/internal/plugins/cmd.go b/internal/plugins/cmd.go new file mode 100644 index 0000000..9b266e5 --- /dev/null +++ b/internal/plugins/cmd.go @@ -0,0 +1,15 @@ +package plugins + +import tea "charm.land/bubbletea/v2" + +func WaitForNotif(mgr *Manager) tea.Cmd { + return func() tea.Msg { + return <-mgr.Notifs + } +} + +func WaitForQuit(mgr *Manager) tea.Cmd { + return func() tea.Msg { + return PluginQuitMsg{Reason: <-mgr.Quit} + } +} diff --git a/internal/plugins/lua.go b/internal/plugins/lua.go new file mode 100644 index 0000000..e838a56 --- /dev/null +++ b/internal/plugins/lua.go @@ -0,0 +1,206 @@ +package plugins + +import ( + "log" + "net/url" + "strings" + "time" + + "github.com/anotherhadi/spilltea/internal/db" + goproxy "github.com/lqqyt2423/go-mitmproxy/proxy" + lua "github.com/yuin/gopher-lua" +) + +func newLuaState(mgr *Manager, p *Plugin) *lua.LState { + L := lua.NewState() + registerUtilities(L, mgr, p) + return L +} + +func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) { + L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int { + msg := L.CheckString(1) + log.Printf("[plugin:%s] %s", p.Name, msg) + return 0 + })) + + L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int { + title := L.CheckString(1) + body := L.CheckString(2) + select { + case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body}: + default: + } + return 0 + })) + + L.SetGlobal("create_finding", L.NewFunction(func(L *lua.LState) int { + t := L.CheckTable(1) + title := luaTableString(t, "title") + desc := luaTableString(t, "description") + key := luaTableString(t, "key") + severity := luaTableString(t, "severity") + if severity == "" { + severity = "info" + } + if key == "" { + key = title + } + if mgr.db == nil { + return 0 + } + inserted, err := mgr.db.UpsertFinding(db.Finding{ + PluginName: p.Name, + DedupKey: key, + Title: title, + Description: desc, + Severity: severity, + CreatedAt: time.Now(), + }) + if err != nil { + log.Printf("[plugin:%s] create_finding error: %v", p.Name, err) + return 0 + } + _ = inserted + return 0 + })) + + L.SetGlobal("is_in_scope", L.NewFunction(func(L *lua.LState) int { + raw := L.CheckString(1) + if mgr.broker == nil { + L.Push(lua.LTrue) + return 1 + } + u, err := url.Parse(raw) + if err != nil { + L.Push(lua.LFalse) + return 1 + } + path := u.Path + if path == "" { + path = "/" + } + L.Push(lua.LBool(mgr.broker.IsInScope(u.Host + path))) + return 1 + })) + + L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { + reason := L.OptString(1, "plugin requested quit") + select { + case mgr.Quit <- reason: + default: + } + return 0 + })) +} + +func luaTableString(t *lua.LTable, key string) string { + v := t.RawGetString(key) + if s, ok := v.(lua.LString); ok { + return string(s) + } + return "" +} + +func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable { + t := L.NewTable() + r := f.Request + L.SetField(t, "method", lua.LString(r.Method)) + L.SetField(t, "url", lua.LString(r.URL.String())) + L.SetField(t, "host", lua.LString(r.URL.Host)) + L.SetField(t, "path", lua.LString(r.URL.Path)) + + headers := L.NewTable() + for k, vals := range r.Header { + L.SetField(headers, k, lua.LString(strings.Join(vals, ", "))) + } + L.SetField(t, "headers", headers) + + L.SetField(t, "get_body", L.NewFunction(func(L *lua.LState) int { + L.Push(lua.LString(string(r.Body))) + return 1 + })) + + L.SetField(t, "set_header", L.NewFunction(func(L *lua.LState) int { + name := L.CheckString(2) + value := L.CheckString(3) + r.Header.Set(name, value) + return 0 + })) + + L.SetField(t, "set_body", L.NewFunction(func(L *lua.LState) int { + body := L.CheckString(2) + r.Body = []byte(body) + return 0 + })) + + return t +} + +func pushResponse(L *lua.LState, f *goproxy.Flow) *lua.LTable { + t := L.NewTable() + if f.Response == nil { + return t + } + resp := f.Response + L.SetField(t, "status_code", lua.LNumber(resp.StatusCode)) + + headers := L.NewTable() + for k, vals := range resp.Header { + L.SetField(headers, k, lua.LString(strings.Join(vals, ", "))) + } + L.SetField(t, "headers", headers) + + L.SetField(t, "get_body", L.NewFunction(func(L *lua.LState) int { + L.Push(lua.LString(string(resp.Body))) + return 1 + })) + + L.SetField(t, "set_header", L.NewFunction(func(L *lua.LState) int { + name := L.CheckString(2) + value := L.CheckString(3) + resp.Header.Set(name, value) + return 0 + })) + + L.SetField(t, "set_body", L.NewFunction(func(L *lua.LState) int { + body := L.CheckString(2) + resp.Body = []byte(body) + return 0 + })) + + return t +} + +func pushEntry(L *lua.LState, e db.Entry) *lua.LTable { + t := L.NewTable() + L.SetField(t, "id", lua.LNumber(e.ID)) + L.SetField(t, "method", lua.LString(e.Method)) + L.SetField(t, "host", lua.LString(e.Host)) + L.SetField(t, "path", lua.LString(e.Path)) + L.SetField(t, "status_code", lua.LNumber(e.StatusCode)) + L.SetField(t, "timestamp", lua.LString(e.Timestamp.Format("2006-01-02 15:04:05"))) + L.SetField(t, "request_raw", lua.LString(e.RequestRaw)) + L.SetField(t, "response_raw", lua.LString(e.ResponseRaw)) + return t +} + +func callHook(p *Plugin, hookName string, args ...lua.LValue) (string, error) { + fn := p.L.GetGlobal(hookName) + if fn == lua.LNil { + return "", nil + } + if err := p.L.CallByParam(lua.P{ + Fn: fn, + NRet: 1, + Protect: true, + }, args...); err != nil { + return "", err + } + ret := p.L.Get(-1) + p.L.Pop(1) + if s, ok := ret.(lua.LString); ok { + return string(s), nil + } + return "", nil +} diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go new file mode 100644 index 0000000..63542f9 --- /dev/null +++ b/internal/plugins/manager.go @@ -0,0 +1,346 @@ +package plugins + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/anotherhadi/spilltea/internal/db" + "github.com/anotherhadi/spilltea/internal/intercept" + goproxy "github.com/lqqyt2423/go-mitmproxy/proxy" + lua "github.com/yuin/gopher-lua" +) + +type Manager struct { + mu sync.RWMutex + plugins []*Plugin + + db *db.DB + broker *intercept.Broker + + Notifs chan PluginNotifMsg + Quit chan string +} + +func NewManager(broker *intercept.Broker) *Manager { + mgr := &Manager{ + broker: broker, + Notifs: make(chan PluginNotifMsg, 64), + Quit: make(chan string, 4), + } + if broker != nil { + broker.SetOnNewEntry(mgr.RunOnHistoryEntry) + } + return mgr +} + +func (m *Manager) SetDB(d *db.DB) { + m.db = d +} + +func (m *Manager) LoadFromDir(dir string) error { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + + var states map[string]db.PluginState + if m.db != nil { + list, err := m.db.LoadPluginStates() + if err == nil { + states = make(map[string]db.PluginState, len(list)) + for _, s := range list { + states[s.Name] = s + } + } + } + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") { + continue + } + path := filepath.Join(dir, e.Name()) + p, err := m.loadPlugin(path) + if err != nil { + log.Printf("plugin load error %s: %v", path, err) + continue + } + if s, ok := states[p.Name]; ok { + p.Enabled = s.Enabled + p.ConfigText = s.ConfigText + } + m.mu.Lock() + m.plugins = append(m.plugins, p) + m.mu.Unlock() + } + return nil +} + +func (m *Manager) loadPlugin(path string) (*Plugin, error) { + p := &Plugin{ + FilePath: path, + Enabled: true, + hooks: make(map[string]HookConfig), + } + p.L = newLuaState(m, p) + if err := p.L.DoFile(path); err != nil { + p.L.Close() + return nil, err + } + + pluginTable, ok := p.L.GetGlobal("Plugin").(*lua.LTable) + if !ok { + p.L.Close() + return nil, fmt.Errorf("missing Plugin table") + } + + if s, ok := pluginTable.RawGetString("name").(lua.LString); ok { + p.Name = string(s) + } + if p.Name == "" { + p.Name = strings.TrimSuffix(filepath.Base(path), ".lua") + } + + // Defaults when not overridden by the Plugin table. + hookDefaults := map[string]bool{ + "on_start": true, // always sync + "on_request": false, // async + "on_response": false, // async + "on_quit": true, // always sync + "on_history_entry": false, // always async + } + for hookName, defaultSync := range hookDefaults { + // Plugin table entry overrides the default (except on_start/on_quit/on_history_entry which are fixed). + if hookName != "on_start" && hookName != "on_quit" && hookName != "on_history_entry" { + if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { + p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue} + continue + } + } + // Auto-detect: register the hook if the function exists as a global. + if p.L.GetGlobal(hookName) != lua.LNil { + p.hooks[hookName] = HookConfig{Sync: defaultSync} + } + } + + return p, nil +} + +func (m *Manager) GetPlugins() []*Plugin { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]*Plugin, len(m.plugins)) + copy(out, m.plugins) + return out +} + +func (m *Manager) TogglePlugin(name string) { + m.mu.RLock() + var found *Plugin + for _, p := range m.plugins { + if p.Name == name { + found = p + break + } + } + m.mu.RUnlock() + if found == nil { + return + } + found.mu.Lock() + found.Enabled = !found.Enabled + enabled := found.Enabled + configText := found.ConfigText + found.mu.Unlock() + if m.db != nil { + _ = m.db.SavePluginState(name, enabled, configText) + } +} + +func (m *Manager) SaveConfig(name, configText string) { + m.mu.RLock() + var found *Plugin + for _, p := range m.plugins { + if p.Name == name { + found = p + break + } + } + m.mu.RUnlock() + if found == nil { + return + } + found.mu.Lock() + found.ConfigText = configText + enabled := found.Enabled + hc, hasOnStart := found.hooks["on_start"] + found.mu.Unlock() + if m.db != nil { + _ = m.db.SavePluginState(name, enabled, configText) + } + if !hasOnStart { + return + } + // Re-run on_start so the plugin can re-parse the new config. + if hc.Sync { + found.mu.Lock() + if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil { + log.Printf("plugin %s on_start (config reload): %v", name, err) + } + found.mu.Unlock() + } else { + go func() { + found.mu.Lock() + if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil { + log.Printf("plugin %s on_start (config reload): %v", name, err) + } + found.mu.Unlock() + }() + } +} + +func (m *Manager) RunOnStart() { + for _, p := range m.GetPlugins() { + if !p.Enabled { + continue + } + if _, ok := p.hooks["on_start"]; !ok { + continue + } + p.mu.Lock() + if _, err := callHook(p, "on_start", lua.LString(p.ConfigText)); err != nil { + log.Printf("plugin %s on_start: %v", p.Name, err) + } + p.mu.Unlock() + } +} + +func (m *Manager) RunOnQuit() { + for _, p := range m.GetPlugins() { + if !p.Enabled { + continue + } + if _, ok := p.hooks["on_quit"]; !ok { + continue + } + p.mu.Lock() + if _, err := callHook(p, "on_quit"); err != nil { + log.Printf("plugin %s on_quit: %v", p.Name, err) + } + p.mu.Unlock() + } +} + +func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision { + for _, p := range m.GetPlugins() { + if !p.Enabled { + continue + } + hc, ok := p.hooks["on_request"] + if !ok || !hc.Sync { + continue + } + p.mu.Lock() + result, err := callHook(p, "on_request", pushRequest(p.L, f)) + p.mu.Unlock() + if err != nil { + log.Printf("plugin %s on_request: %v", p.Name, err) + continue + } + switch result { + case "drop": + return intercept.Drop + case "forward": + return intercept.Forward + } + } + return intercept.Intercept +} + +func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) { + for _, p := range m.GetPlugins() { + if !p.Enabled { + continue + } + hc, ok := p.hooks["on_request"] + if !ok || hc.Sync { + continue + } + go func(p *Plugin) { + p.mu.Lock() + if _, err := callHook(p, "on_request", pushRequest(p.L, f)); err != nil { + log.Printf("plugin %s on_request: %v", p.Name, err) + } + p.mu.Unlock() + }(p) + } +} + +func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision { + for _, p := range m.GetPlugins() { + if !p.Enabled { + continue + } + hc, ok := p.hooks["on_response"] + if !ok || !hc.Sync { + continue + } + p.mu.Lock() + result, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f)) + p.mu.Unlock() + if err != nil { + log.Printf("plugin %s on_response: %v", p.Name, err) + continue + } + switch result { + case "drop": + return intercept.Drop + case "forward": + return intercept.Forward + } + } + return intercept.Intercept +} + +func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) { + for _, p := range m.GetPlugins() { + if !p.Enabled { + continue + } + hc, ok := p.hooks["on_response"] + if !ok || hc.Sync { + continue + } + go func(p *Plugin) { + p.mu.Lock() + if _, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f)); err != nil { + log.Printf("plugin %s on_response: %v", p.Name, err) + } + p.mu.Unlock() + }(p) + } +} + +func (m *Manager) RunOnHistoryEntry(e db.Entry) { + for _, p := range m.GetPlugins() { + if !p.Enabled { + continue + } + if _, ok := p.hooks["on_history_entry"]; !ok { + continue + } + go func(p *Plugin) { + p.mu.Lock() + if _, err := callHook(p, "on_history_entry", pushEntry(p.L, e)); err != nil { + log.Printf("plugin %s on_history_entry: %v", p.Name, err) + } + p.mu.Unlock() + }(p) + } +} diff --git a/internal/plugins/types.go b/internal/plugins/types.go new file mode 100644 index 0000000..a74c521 --- /dev/null +++ b/internal/plugins/types.go @@ -0,0 +1,66 @@ +package plugins + +import ( + "sync" + + lua "github.com/yuin/gopher-lua" +) + +type HookConfig struct { + Sync bool +} + +type Plugin struct { + Name string + FilePath string + Enabled bool + ConfigText string + + L *lua.LState + mu sync.Mutex + hooks map[string]HookConfig +} + +func (p *Plugin) HookNames() []string { + out := make([]string, 0, len(p.hooks)) + for name := range p.hooks { + out = append(out, name) + } + return out +} + +func (p *Plugin) HookConfig(name string) (HookConfig, bool) { + hc, ok := p.hooks[name] + return hc, ok +} + +type Info struct { + Name string + FilePath string + Enabled bool + ConfigText string + Hooks map[string]HookConfig +} + +func (p *Plugin) Info() Info { + hooks := make(map[string]HookConfig, len(p.hooks)) + for k, v := range p.hooks { + hooks[k] = v + } + return Info{ + Name: p.Name, + FilePath: p.FilePath, + Enabled: p.Enabled, + ConfigText: p.ConfigText, + Hooks: hooks, + } +} + +type PluginNotifMsg struct { + Title string + Body string +} + +type PluginQuitMsg struct { + Reason string +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..353ee52 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,128 @@ +package proxy + +import ( + "fmt" + "io" + "net/http" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/config" + "github.com/anotherhadi/spilltea/internal/intercept" + "github.com/anotherhadi/spilltea/internal/plugins" + goproxy "github.com/lqqyt2423/go-mitmproxy/proxy" +) + +type ErrMsg struct{ Err error } + +func StartCmd(broker *intercept.Broker, mgr *plugins.Manager) tea.Cmd { + return func() tea.Msg { + if err := Start(broker, mgr); err != nil { + return ErrMsg{Err: err} + } + return ErrMsg{} + } +} + +type interceptAddon struct { + goproxy.BaseAddon + broker *intercept.Broker + plugins *plugins.Manager +} + +// ClientConnected disables upstream cert fetching so the upstream TCP/TLS +// connection is established only after Hold() returns, not during CONNECT. +// Without this, the upstream connection sits idle while the TUI holds the +// request, and the server closes it (keep-alive timeout) → unexpected EOF. +func (a *interceptAddon) ClientConnected(clientConn *goproxy.ClientConn) { + clientConn.UpstreamCert = false +} + +func (a *interceptAddon) Request(f *goproxy.Flow) { + if a.plugins != nil { + switch a.plugins.RunSyncOnRequest(f) { + case intercept.Drop: + f.Response = dropResponse() + go a.plugins.RunAsyncOnRequest(f) + return + case intercept.Forward: + go a.plugins.RunAsyncOnRequest(f) + return + } + } + + if a.broker.Hold(f) == intercept.Drop { + f.Response = dropResponse() + } + + if a.plugins != nil { + go a.plugins.RunAsyncOnRequest(f) + } +} + +func (a *interceptAddon) Response(f *goproxy.Flow) { + if f.Response != nil { + if len(f.Response.Body) == 0 && f.Response.BodyReader != nil { + body, _ := io.ReadAll(f.Response.BodyReader) + f.Response.Body = body + f.Response.BodyReader = nil + } + f.Response.ReplaceToDecodedBody() + } + + if a.plugins != nil { + switch a.plugins.RunSyncOnResponse(f) { + case intercept.Drop: + a.broker.SaveEntry(f) + f.Response = dropResponse() + go a.plugins.RunAsyncOnResponse(f) + return + case intercept.Forward: + a.broker.SaveEntry(f) + go a.plugins.RunAsyncOnResponse(f) + return + } + } + + decision := a.broker.HoldResponse(f) + a.broker.SaveEntry(f) + if decision == intercept.Drop { + f.Response = dropResponse() + } + + if a.plugins != nil { + go a.plugins.RunAsyncOnResponse(f) + } +} + +func Start(broker *intercept.Broker, mgr *plugins.Manager) error { + cfg := config.Global.App + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + caPath := config.ExpandPath(cfg.CertDir) + + if err := os.MkdirAll(caPath, 0o700); err != nil { + return fmt.Errorf("ca dir: %w", err) + } + + opts := &goproxy.Options{ + Addr: addr, + StreamLargeBodies: 1024 * 1024 * 5, + CaRootPath: caPath, + } + + p, err := goproxy.NewProxy(opts) + if err != nil { + return err + } + + p.AddAddon(&interceptAddon{broker: broker, plugins: mgr}) + return p.Start() +} + +func dropResponse() *goproxy.Response { + return &goproxy.Response{ + StatusCode: 502, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: []byte("Dropped by spilltea"), + } +} diff --git a/internal/style/border.go b/internal/style/border.go new file mode 100644 index 0000000..196821a --- /dev/null +++ b/internal/style/border.go @@ -0,0 +1,43 @@ +package style + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// PanelContentH returns the usable inner content height for a panel rendered by +// RenderWithTitle. It subtracts the two border lines (top + bottom) from the +// total panel height. +func PanelContentH(totalH int) int { + h := totalH - 2 + if h < 0 { + return 0 + } + return h +} + +// RenderWithTitle renders a lipgloss bordered box with a title embedded in the +// top border, matching the border's own foreground color. height is the total +// desired output height (including both border lines). +func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string { + boxH := height - 1 + if contentH := boxH - 1; contentH > 0 { + lines := strings.Split(content, "\n") + if len(lines) > contentH { + content = strings.Join(lines[:contentH], "\n") + } + } + box := border.BorderTop(false).Width(width).Height(boxH).Render(content) + + boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0]) + label := " " + title + " " + fillW := boxWidth - lipgloss.Width(label) - 2 + if fillW < 0 { + fillW = 0 + } + topLine := "╭" + label + strings.Repeat("─", fillW) + "╮" + topLine = lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()).Render(topLine) + + return lipgloss.JoinVertical(lipgloss.Left, topLine, box) +} diff --git a/internal/style/components.go b/internal/style/components.go new file mode 100644 index 0000000..9c52643 --- /dev/null +++ b/internal/style/components.go @@ -0,0 +1,84 @@ +package style + +import ( + "strings" + + "charm.land/bubbles/v2/paginator" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + "charm.land/lipgloss/v2" +) + +func NewViewport() viewport.Model { + vp := viewport.New() + vp.MouseWheelEnabled = false + return vp +} + +func NewPaginator() paginator.Model { + p := paginator.New() + p.Type = paginator.Dots + p.ActiveDot = S.PagerDotActive + p.InactiveDot = S.PagerDotInactive + return p +} + +func NewTextarea(showLineNumbers bool) textarea.Model { + ta := textarea.New() + ta.Prompt = "" + ta.ShowLineNumbers = showLineNumbers + ta.CharLimit = 0 + ts := ta.Styles() + ts.Focused.Base = lipgloss.NewStyle() + ts.Blurred.Base = lipgloss.NewStyle() + ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text) + ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) + ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) + ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) + ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) + ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg) + ta.SetStyles(ts) + return ta +} + +// SeverityStyle returns a bold lipgloss style coloured by finding severity level. +func SeverityStyle(sev string) lipgloss.Style { + base := lipgloss.NewStyle().Bold(true) + switch sev { + case "critical": + return base.Foreground(S.Error) + case "high": + return base.Foreground(S.Warning) + case "medium": + return base.Foreground(S.Primary) + case "low": + return base.Foreground(S.Success) + default: + return base.Foreground(S.Subtle) + } +} + +// StatusStyle returns a bold lipgloss style coloured by HTTP status code. +func StatusStyle(code, width int) lipgloss.Style { + base := lipgloss.NewStyle().Bold(true).Width(width) + switch { + case code >= 500: + return base.Foreground(S.Error) + case code >= 400: + return base.Foreground(S.Warning) + case code >= 300: + return base.Foreground(S.Primary) + default: + return base.Foreground(S.Success) + } +} + +// SplitH splits totalHeight into top and bottom sections, accounting for the +// status bar height. +func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) { + statusH := strings.Count(statusBar, "\n") + 1 + available := totalHeight - statusH + top = int(float64(available) * ratio) + bottom = available - top + return +} diff --git a/internal/style/glamour.go b/internal/style/glamour.go new file mode 100644 index 0000000..0c07dcb --- /dev/null +++ b/internal/style/glamour.go @@ -0,0 +1,236 @@ +package style + +import ( + "github.com/anotherhadi/spilltea/internal/config" + + "charm.land/glamour/v2/ansi" +) + +func GlamourStyleConfig(cfg *config.Config) ansi.StyleConfig { + c := cfg.TUI.Colors + + str := func(s string) *string { return &s } + hex := func(base string) *string { return str("#" + base) } + boolPtr := func(b bool) *bool { return &b } + uintPtr := func(u uint) *uint { return &u } + + return ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockPrefix: "\n", + BlockSuffix: "\n", + Color: hex(c.Base05), + }, + Margin: uintPtr(2), + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: hex(c.Base03), + Italic: boolPtr(true), + }, + Indent: uintPtr(1), + IndentToken: str("│ "), + }, + List: ansi.StyleList{ + LevelIndent: 2, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockSuffix: "\n", + Color: hex(c.Base0D), + Bold: boolPtr(true), + }, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: hex(c.Base07), + BackgroundColor: hex(c.Base0D), + Bold: boolPtr(true), + }, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + Color: hex(c.Base0D), + Bold: boolPtr(true), + }, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + Color: hex(c.Base0C), + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + Color: hex(c.Base0B), + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + Color: hex(c.Base09), + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: hex(c.Base08), + Bold: boolPtr(false), + }, + }, + Strikethrough: ansi.StylePrimitive{ + CrossedOut: boolPtr(true), + }, + Emph: ansi.StylePrimitive{ + Italic: boolPtr(true), + }, + Strong: ansi.StylePrimitive{ + Bold: boolPtr(true), + }, + HorizontalRule: ansi.StylePrimitive{ + Color: hex(c.Base03), + Format: "\n--------\n", + }, + Item: ansi.StylePrimitive{ + BlockPrefix: "• ", + }, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + }, + Task: ansi.StyleTask{ + Ticked: "[✓] ", + Unticked: "[ ] ", + }, + Link: ansi.StylePrimitive{ + Color: hex(c.Base0C), + Underline: boolPtr(true), + }, + LinkText: ansi.StylePrimitive{ + Color: hex(c.Base0D), + Bold: boolPtr(true), + }, + Image: ansi.StylePrimitive{ + Color: hex(c.Base0C), + Underline: boolPtr(true), + }, + ImageText: ansi.StylePrimitive{ + Color: hex(c.Base04), + Format: "Image: {{.text}} ->", + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: hex(c.Base0B), + BackgroundColor: hex(c.Base01), + }, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: hex(c.Base04), + }, + Margin: uintPtr(2), + }, + Chroma: &ansi.Chroma{ + Text: ansi.StylePrimitive{ + Color: hex(c.Base05), + }, + Error: ansi.StylePrimitive{ + Color: hex(c.Base07), + BackgroundColor: hex(c.Base08), + }, + Comment: ansi.StylePrimitive{ + Color: hex(c.Base03), + Italic: boolPtr(true), + }, + CommentPreproc: ansi.StylePrimitive{ + Color: hex(c.Base09), + }, + Keyword: ansi.StylePrimitive{ + Color: hex(c.Base0E), + }, + KeywordReserved: ansi.StylePrimitive{ + Color: hex(c.Base0E), + }, + KeywordNamespace: ansi.StylePrimitive{ + Color: hex(c.Base0D), + }, + KeywordType: ansi.StylePrimitive{ + Color: hex(c.Base0A), + }, + Operator: ansi.StylePrimitive{ + Color: hex(c.Base05), + }, + Punctuation: ansi.StylePrimitive{ + Color: hex(c.Base05), + }, + Name: ansi.StylePrimitive{ + Color: hex(c.Base05), + }, + NameBuiltin: ansi.StylePrimitive{ + Color: hex(c.Base0D), + }, + NameTag: ansi.StylePrimitive{ + Color: hex(c.Base08), + }, + NameAttribute: ansi.StylePrimitive{ + Color: hex(c.Base0A), + }, + NameClass: ansi.StylePrimitive{ + Color: hex(c.Base0A), + Bold: boolPtr(true), + Underline: boolPtr(true), + }, + NameConstant: ansi.StylePrimitive{ + Color: hex(c.Base09), + }, + NameDecorator: ansi.StylePrimitive{ + Color: hex(c.Base0C), + }, + NameFunction: ansi.StylePrimitive{ + Color: hex(c.Base0D), + }, + LiteralNumber: ansi.StylePrimitive{ + Color: hex(c.Base09), + }, + LiteralString: ansi.StylePrimitive{ + Color: hex(c.Base0B), + }, + LiteralStringEscape: ansi.StylePrimitive{ + Color: hex(c.Base0C), + }, + GenericDeleted: ansi.StylePrimitive{ + Color: hex(c.Base08), + }, + GenericEmph: ansi.StylePrimitive{ + Italic: boolPtr(true), + }, + GenericInserted: ansi.StylePrimitive{ + Color: hex(c.Base0B), + }, + GenericStrong: ansi.StylePrimitive{ + Bold: boolPtr(true), + }, + GenericSubheading: ansi.StylePrimitive{ + Color: hex(c.Base04), + }, + Background: ansi.StylePrimitive{ + BackgroundColor: hex(c.Base01), + }, + }, + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + }, + DefinitionDescription: ansi.StylePrimitive{ + BlockPrefix: "\n> ", + }, + } +} diff --git a/internal/style/highlight.go b/internal/style/highlight.go new file mode 100644 index 0000000..04f5586 --- /dev/null +++ b/internal/style/highlight.go @@ -0,0 +1,333 @@ +package style + +import ( + "bytes" + "encoding/json" + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/config" + "golang.org/x/net/html" +) + +func Paint(c color.Color, s string) string { + return lipgloss.NewStyle().Foreground(c).Render(s) +} + +// HighlightHTTP highlights a full raw HTTP message (headers + body). +func HighlightHTTP(raw string) string { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + idx := strings.Index(raw, "\n\n") + if idx == -1 { + return highlightHeaders(raw) + } + headers := raw[:idx+2] + body := raw[idx+2:] + result := highlightHeaders(headers) + if body == "" { + return result + } + pretty := config.Global != nil && config.Global.TUI.PrettyPrintBody + switch detectBodyType(headers) { + case "json": + if pretty { + body = prettyJSON(body) + } + result += highlightJSON(body) + case "html": + if pretty { + body = prettyHTML(body) + } + result += highlightHTML(body) + default: + result += body + } + return result +} + +func detectBodyType(headers string) string { + for _, line := range strings.Split(headers, "\n") { + lower := strings.ToLower(line) + if !strings.HasPrefix(lower, "content-type:") { + continue + } + ct := strings.ToLower(strings.TrimSpace(line[len("content-type:"):])) + switch { + case strings.Contains(ct, "json"): + return "json" + case strings.Contains(ct, "html"): + return "html" + } + break + } + return "" +} + +func highlightHeaders(raw string) string { + var out strings.Builder + lines := strings.Split(raw, "\n") + for i, line := range lines { + trimmed := strings.TrimRight(line, "\r") + if i == 0 { + out.WriteString(highlightStatusLine(trimmed)) + } else if trimmed == "" { + out.WriteString(line) + } else if idx := strings.Index(trimmed, ": "); idx != -1 { + out.WriteString(Paint(S.Subtle, trimmed[:idx+2])) + out.WriteString(Paint(S.Text, trimmed[idx+2:])) + } else { + out.WriteString(line) + } + if i < len(lines)-1 { + out.WriteByte('\n') + } + } + return out.String() +} + +func highlightStatusLine(line string) string { + parts := strings.SplitN(line, " ", 3) + if len(parts) < 2 { + return line + } + switch parts[0] { + case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE": + result := S.Method(parts[0]).Width(0).Render(parts[0]) + " " + result += Paint(S.Primary, parts[1]) + if len(parts) == 3 { + result += " " + Paint(S.Subtle, parts[2]) + } + return result + } + result := Paint(S.Subtle, parts[0]) + " " + result += Paint(S.Warning, parts[1]) + if len(parts) == 3 { + result += " " + Paint(S.MutedFg, parts[2]) + } + return result +} + +func highlightJSON(s string) string { + var out strings.Builder + i, n := 0, len(s) + for i < n { + ch := s[i] + switch { + case ch == '"': + j := i + 1 + for j < n { + if s[j] == '\\' { + j += 2 + continue + } + if s[j] == '"' { + j++ + break + } + j++ + } + str := s[i:j] + k := j + for k < n && (s[k] == ' ' || s[k] == '\t') { + k++ + } + if k < n && s[k] == ':' { + out.WriteString(Paint(S.Primary, str)) + } else { + out.WriteString(Paint(S.Success, str)) + } + i = j + case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'): + j := i + if s[j] == '-' { + j++ + } + for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') { + j++ + } + out.WriteString(Paint(S.Warning, s[i:j])) + i = j + case i+4 <= n && s[i:i+4] == "true": + out.WriteString(Paint(S.Error, "true")) + i += 4 + case i+5 <= n && s[i:i+5] == "false": + out.WriteString(Paint(S.Error, "false")) + i += 5 + case i+4 <= n && s[i:i+4] == "null": + out.WriteString(Paint(S.Error, "null")) + i += 4 + case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',': + out.WriteString(Paint(S.Subtle, string(ch))) + i++ + default: + out.WriteByte(ch) + i++ + } + } + return out.String() +} + +func prettyJSON(s string) string { + var buf bytes.Buffer + if err := json.Indent(&buf, []byte(strings.TrimSpace(s)), "", " "); err != nil { + return s + } + return buf.String() +} + +var voidHTMLElements = map[string]bool{ + "area": true, "base": true, "br": true, "col": true, "embed": true, + "hr": true, "img": true, "input": true, "link": true, "meta": true, + "param": true, "source": true, "track": true, "wbr": true, +} + +func prettyHTML(s string) string { + doc, err := html.Parse(strings.NewReader(s)) + if err != nil { + return s + } + var buf strings.Builder + walkHTMLNode(&buf, doc, 0) + return strings.TrimRight(buf.String(), "\n") +} + +func walkHTMLNode(w *strings.Builder, n *html.Node, depth int) { + indent := strings.Repeat(" ", depth) + switch n.Type { + case html.DocumentNode: + for c := n.FirstChild; c != nil; c = c.NextSibling { + walkHTMLNode(w, c, depth) + } + case html.DoctypeNode: + w.WriteString("\n") + case html.CommentNode: + w.WriteString(indent + "\n") + case html.TextNode: + text := strings.TrimSpace(n.Data) + if text != "" { + w.WriteString(indent + text + "\n") + } + case html.ElementNode: + tag := buildHTMLOpenTag(n) + if voidHTMLElements[n.Data] { + w.WriteString(indent + tag + "\n") + return + } + w.WriteString(indent + tag + "\n") + if n.Data == "script" || n.Data == "style" { + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.TextNode { + text := strings.TrimSpace(c.Data) + if text != "" { + for _, line := range strings.Split(text, "\n") { + w.WriteString(indent + " " + line + "\n") + } + } + } + } + } else { + for c := n.FirstChild; c != nil; c = c.NextSibling { + walkHTMLNode(w, c, depth+1) + } + } + w.WriteString(indent + "\n") + } +} + +func buildHTMLOpenTag(n *html.Node) string { + var sb strings.Builder + sb.WriteString("<" + n.Data) + for _, attr := range n.Attr { + sb.WriteString(" ") + if attr.Namespace != "" { + sb.WriteString(attr.Namespace + ":") + } + sb.WriteString(attr.Key + `="` + escapeHTMLAttr(attr.Val) + `"`) + } + sb.WriteString(">") + return sb.String() +} + +func escapeHTMLAttr(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, `"`, """) + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +func highlightHTML(s string) string { + var out strings.Builder + i, n := 0, len(s) + for i < n { + if i+4 <= n && s[i:i+4] == "") + if end == -1 { + out.WriteString(Paint(S.Subtle, s[i:])) + break + } + end = i + end + 3 + out.WriteString(Paint(S.Subtle, s[i:end])) + i = end + continue + } + if s[i] != '<' { + out.WriteByte(s[i]) + i++ + continue + } + out.WriteString(Paint(S.Subtle, "<")) + i++ + if i < n && (s[i] == '/' || s[i] == '!') { + out.WriteString(Paint(S.Subtle, string(s[i]))) + i++ + } + j := i + for j < n && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' && s[j] != '\r' { + j++ + } + if j > i { + out.WriteString(Paint(S.Primary, s[i:j])) + i = j + } + for i < n && s[i] != '>' { + ch := s[i] + switch { + case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r': + out.WriteByte(ch) + i++ + case ch == '/': + out.WriteString(Paint(S.Subtle, "/")) + i++ + case ch == '=': + out.WriteString(Paint(S.Subtle, "=")) + i++ + case ch == '"' || ch == '\'': + q := ch + j = i + 1 + for j < n && s[j] != q { + j++ + } + if j < n { + j++ + } + out.WriteString(Paint(S.Success, s[i:j])) + i = j + default: + j = i + for j < n && s[j] != '=' && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' { + j++ + } + out.WriteString(Paint(S.Warning, s[i:j])) + i = j + } + } + if i < n && s[i] == '>' { + out.WriteString(Paint(S.Subtle, ">")) + i++ + } + } + return out.String() +} diff --git a/internal/style/style.go b/internal/style/style.go new file mode 100644 index 0000000..66c86ae --- /dev/null +++ b/internal/style/style.go @@ -0,0 +1,100 @@ +package style + +import ( + "image/color" + + "charm.land/bubbles/v2/help" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/config" +) + +type Styles struct { + Primary color.Color + Success color.Color + Error color.Color + Warning color.Color + SubtleBg color.Color + Selection color.Color + Text color.Color + MutedFg color.Color + Subtle color.Color + + Bold lipgloss.Style + Faint lipgloss.Style + + Panel lipgloss.Style + PanelFocused lipgloss.Style + + PagerDotActive string + PagerDotInactive string +} + +var S *Styles + +func Init(cfg *config.Config) { + c := cfg.TUI.Colors + + subtleBg := lipgloss.Color("#" + c.Base01) // Lighter Background (status bars) + selection := lipgloss.Color("#" + c.Base02) // Selection Background + subtle := lipgloss.Color("#" + c.Base03) // Faint text, borders + mutedFg := lipgloss.Color("#" + c.Base04) // Muted foreground + text := lipgloss.Color("#" + c.Base05) // Default Foreground + errCol := lipgloss.Color("#" + c.Base08) // Red: errors + warning := lipgloss.Color("#" + c.Base09) // Orange: warnings + success := lipgloss.Color("#" + c.Base0B) // Green: success + primary := lipgloss.Color("#" + c.Base0D) // Accent: primary + + S = &Styles{ + Primary: primary, + Success: success, + Error: errCol, + Warning: warning, + SubtleBg: subtleBg, + Selection: selection, + MutedFg: mutedFg, + Text: text, + Subtle: subtle, + + Bold: lipgloss.NewStyle().Bold(true), + Faint: lipgloss.NewStyle().Foreground(subtle).Faint(true), + + Panel: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(subtle), + + PanelFocused: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primary), + + PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(), + PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(), + } +} + +func NewHelp() help.Model { + h := help.New() + h.Styles.ShortKey = lipgloss.NewStyle().Foreground(S.Primary) + h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(S.MutedFg) + h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(S.Subtle) + h.Styles.FullKey = lipgloss.NewStyle().Foreground(S.Primary) + h.Styles.FullDesc = lipgloss.NewStyle().Foreground(S.MutedFg) + h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(S.Subtle) + h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(S.Subtle) + return h +} + +func (s *Styles) Method(method string) lipgloss.Style { + base := lipgloss.NewStyle().Bold(true).Width(7) + switch method { + case "GET": + return base.Foreground(s.Success) + case "POST": + return base.Foreground(s.Warning) + case "PUT", "PATCH": + return base.Foreground(s.Primary) + case "DELETE": + return base.Foreground(s.Error) + default: + return base.Foreground(s.Text) + } +} diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go new file mode 100644 index 0000000..1536bfa --- /dev/null +++ b/internal/ui/app/model.go @@ -0,0 +1,137 @@ +package app + +import ( + "log" + "os" + "path/filepath" + "strconv" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/config" + "github.com/anotherhadi/spilltea/internal/db" + "github.com/anotherhadi/spilltea/internal/intercept" + "github.com/anotherhadi/spilltea/internal/plugins" + proxyPkg "github.com/anotherhadi/spilltea/internal/proxy" + copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas" + notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" + diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" + docsUI "github.com/anotherhadi/spilltea/internal/ui/docs" + findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings" + historyUI "github.com/anotherhadi/spilltea/internal/ui/history" + interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" + pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins" + replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" + scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope" + "github.com/sirupsen/logrus" +) + +const tickInterval = 2 * time.Second + +type tickMsg struct{} + +func tickCmd() tea.Cmd { + return func() tea.Msg { + time.Sleep(tickInterval) + return tickMsg{} + } +} + +var sidebarEntries = pageRegistry + +var pageShortcuts = func() map[string]page { + m := make(map[string]page, len(sidebarEntries)) + for i, e := range sidebarEntries { + m[strconv.Itoa(i+1)] = e.id + } + return m +}() + +type Model struct { + broker *intercept.Broker + page page + projectName string + projectPath string + database *db.DB + logFile *os.File + pluginManager *plugins.Manager + + width int + height int + sidebarState sidebarState + intercept interceptUI.Model + history historyUI.Model + replay replayUI.Model + diff diffUI.Model + docs docsUI.Model + scope scopeUI.Model + pluginsPage pluginsUI.Model + findingsPage findingsUI.Model + copyAs copyasUI.Model + notifications notificationsUI.Model +} + +func New(broker *intercept.Broker, name, path string) Model { + cfg := config.Global + mgr := plugins.NewManager(broker) + + m := Model{ + broker: broker, + page: pageIntercept, + projectName: name, + projectPath: path, + pluginManager: mgr, + intercept: interceptUI.New(broker), + history: historyUI.New(), + replay: replayUI.New(), + diff: diffUI.New(), + docs: docsUI.New(), + scope: scopeUI.New(name, path), + pluginsPage: pluginsUI.New(mgr), + findingsPage: findingsUI.New(), + copyAs: copyasUI.New(), + notifications: notificationsUI.New(), + sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), + } + + if d, err := db.Open(path); err == nil { + m.database = d + broker.SetDB(d) + m.history.SetDB(d) + m.replay.SetDB(d) + m.findingsPage.SetDB(d) + mgr.SetDB(d) + if wl, bl, err := d.LoadScope(); err == nil { + broker.SetScope(wl, bl) + m.scope.SetScope(wl, bl) + } + } + + pluginsDir := config.ExpandPath(cfg.App.PluginsDir) + if err := mgr.LoadFromDir(pluginsDir); err != nil { + log.Printf("plugins: %v", err) + } + m.pluginsPage.Refresh() + + if lf, err := os.OpenFile(filepath.Join(filepath.Dir(path), "logs.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil { + m.logFile = lf + log.SetOutput(lf) + logrus.SetOutput(lf) + } + + return m +} + +func (m Model) Init() tea.Cmd { + mgr := m.pluginManager + return tea.Batch( + intercept.WaitForRequest(m.broker), + intercept.WaitForResponse(m.broker), + tickCmd(), + proxyPkg.StartCmd(m.broker, mgr), + plugins.WaitForNotif(mgr), + plugins.WaitForQuit(mgr), + findingsUI.RefreshCmd(m.database), + func() tea.Msg { mgr.RunOnStart(); return nil }, + ) +} diff --git a/internal/ui/app/pages.go b/internal/ui/app/pages.go new file mode 100644 index 0000000..93baa3d --- /dev/null +++ b/internal/ui/app/pages.go @@ -0,0 +1,146 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/icons" + diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" + docsUI "github.com/anotherhadi/spilltea/internal/ui/docs" + findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings" + historyUI "github.com/anotherhadi/spilltea/internal/ui/history" + interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" + pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins" + replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" + scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope" +) + +type page string + +const ( + pageIntercept page = "Intercept" + pageHistory page = "History" + pageReplay page = "Replay" + pageDiff page = "Diff" + pageScopes page = "Scopes" + pagePlugins page = "Plugins" + pageFindings page = "Findings" + pageDocs page = "Docs" +) + +// pageEntry describes a page and all its integration hooks. +type pageEntry struct { + id page + icon func() string + + // render returns the page's view content. nil = show "empty". + render func(m *Model) string + // update is called when this page is active. nil = no-op. + update func(m *Model, msg tea.Msg) tea.Cmd + // isEditing reports whether the page is in text-editing mode. + isEditing func(m *Model) bool + // resize propagates a new (w, h) to the page model. + resize func(m *Model, w, h int) +} + +var pageRegistry = []pageEntry{ + { + id: pageIntercept, + icon: func() string { return icons.I.Intercept }, + + render: func(m *Model) string { return m.intercept.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.intercept.Update(msg) + m.intercept = updated.(interceptUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.intercept.IsEditing() }, + resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) }, + }, + { + id: pageHistory, + icon: func() string { return icons.I.History }, + + render: func(m *Model) string { return m.history.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.history.Update(msg) + m.history = updated.(historyUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.history.IsEditing() }, + resize: func(m *Model, w, h int) { m.history.SetSize(w, h) }, + }, + { + id: pageReplay, + icon: func() string { return icons.I.Replay }, + + render: func(m *Model) string { return m.replay.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.replay.Update(msg) + m.replay = updated.(replayUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.replay.IsEditing() }, + resize: func(m *Model, w, h int) { m.replay.SetSize(w, h) }, + }, + { + id: pageDiff, + icon: func() string { return icons.I.Diff }, + + render: func(m *Model) string { return m.diff.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.diff.Update(msg) + m.diff = updated.(diffUI.Model) + return cmd + }, + resize: func(m *Model, w, h int) { m.diff.SetSize(w, h) }, + }, + { + id: pageScopes, + icon: func() string { return icons.I.Scope }, + + render: func(m *Model) string { return m.scope.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.scope.Update(msg) + m.scope = updated.(scopeUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.scope.IsEditing() }, + resize: func(m *Model, w, h int) { m.scope.SetSize(w, h) }, + }, + { + id: pagePlugins, + icon: func() string { return icons.I.Plugin }, + + render: func(m *Model) string { return m.pluginsPage.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.pluginsPage.Update(msg) + m.pluginsPage = updated.(pluginsUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.pluginsPage.IsEditing() }, + resize: func(m *Model, w, h int) { m.pluginsPage.SetSize(w, h) }, + }, + { + id: pageFindings, + icon: func() string { return icons.I.Findings }, + + render: func(m *Model) string { return m.findingsPage.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.findingsPage.Update(msg) + m.findingsPage = updated.(findingsUI.Model) + return cmd + }, + resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) }, + }, + { + id: pageDocs, + icon: func() string { return icons.I.Docs }, + + render: func(m *Model) string { return m.docs.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.docs.Update(msg) + m.docs = updated.(docsUI.Model) + return cmd + }, + resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) }, + }, +} diff --git a/internal/ui/app/sidebar.go b/internal/ui/app/sidebar.go new file mode 100644 index 0000000..ca1a978 --- /dev/null +++ b/internal/ui/app/sidebar.go @@ -0,0 +1,88 @@ +package app + +import ( + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/style" +) + +type sidebarState string + +const ( + sidebarHidden sidebarState = "hidden" + sidebarCollapsed sidebarState = "collapsed" + sidebarExpanded sidebarState = "expanded" +) + +func (m *Model) cycleSidebarState() { + switch m.sidebarState { + case sidebarHidden: + m.sidebarState = sidebarCollapsed + case sidebarCollapsed: + m.sidebarState = sidebarExpanded + default: + m.sidebarState = sidebarHidden + } +} + +func (m Model) getSidebarWidth() int { + switch m.sidebarState { + case sidebarHidden: + return 0 + case sidebarCollapsed: + return 8 + default: + return 18 + } +} + +func (m *Model) renderSidebar() string { + if m.sidebarState == sidebarHidden { + return "" + } + s := style.S + // content width inside bordered panel + inner := m.getSidebarWidth() - 2 + + titleText := "SPILLTEA" + if m.sidebarState == sidebarCollapsed { + titleText = "SPLT" + } + title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText) + divider := strings.Repeat("─", inner) + + badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true) + badgeNormal := lipgloss.NewStyle().Foreground(s.Subtle) + textSelected := lipgloss.NewStyle().Foreground(s.Primary) + textNormal := lipgloss.NewStyle().Foreground(s.Text) + lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1) + + var items strings.Builder + for i, entry := range sidebarEntries { + selected := entry.id == m.page + badgeStyle, textStyle := badgeNormal, textNormal + if selected { + badgeStyle, textStyle = badgeSelected, textSelected + } + icon := "" + if entry.icon != nil { + icon = entry.icon() + } + label := " " + icon + if m.sidebarState != sidebarCollapsed { + label += string(entry.id) + } + line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label)) + items.WriteString(line + "\n") + } + + body := lipgloss.JoinVertical(lipgloss.Left, + title, + lipgloss.NewStyle().Foreground(s.Subtle).Render(divider), + items.String(), + ) + + return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body) +} diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go new file mode 100644 index 0000000..ca60aee --- /dev/null +++ b/internal/ui/app/update.go @@ -0,0 +1,238 @@ +package app + +import ( + "log" + "os" + "os/exec" + "path/filepath" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/config" + "github.com/anotherhadi/spilltea/internal/intercept" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/plugins" + proxyPkg "github.com/anotherhadi/spilltea/internal/proxy" + copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas" + notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" + diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" + findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings" + historyUI "github.com/anotherhadi/spilltea/internal/ui/history" + interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" + replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" + scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Broker messages must always re-register their watchers + switch msg := msg.(type) { + case notificationsUI.NotificationMsg: + var cmd tea.Cmd + m.notifications, cmd = m.notifications.Update(msg) + return m, cmd + case notificationsUI.DismissMsg: + var cmd tea.Cmd + m.notifications, cmd = m.notifications.Update(msg) + return m, cmd + case intercept.RequestArrivedMsg: + updated, cmd := m.intercept.Update(msg) + m.intercept = updated.(interceptUI.Model) + return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker)) + case intercept.ResponseArrivedMsg: + updated, cmd := m.intercept.Update(msg) + m.intercept = updated.(interceptUI.Model) + return m, tea.Batch(cmd, intercept.WaitForResponse(m.broker)) + + case plugins.PluginNotifMsg: + cmd := plugins.WaitForNotif(m.pluginManager) + notifCmd := func() tea.Msg { + return notificationsUI.NotificationMsg{ + Title: msg.Title, + Body: msg.Body, + Kind: notificationsUI.KindInfo, + } + } + return m, tea.Batch(cmd, notifCmd) + + case plugins.PluginQuitMsg: + log.Printf("plugin quit: %s", msg.Reason) + m.pluginManager.RunOnQuit() + return m, tea.Quit + } + + if m.copyAs.IsOpen() { + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.width = ws.Width + m.height = ws.Height + m.copyAs.SetSize(ws.Width, ws.Height) + m.resizeChildren() + return m, nil + } + var cmd tea.Cmd + m.copyAs, cmd = m.copyAs.Update(msg) + return m, cmd + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeChildren() + + case scopeUI.ScopeChangedMsg: + m.broker.SetScope(msg.Whitelist, msg.Blacklist) + if m.database != nil { + if err := m.database.SaveScope(msg.Whitelist, msg.Blacklist); err != nil { + log.Printf("failed to persist scope: %v", err) + } + } + return m, nil + + case proxyPkg.ErrMsg: + if msg.Err != nil { + log.Printf("proxy error: %v", msg.Err) + } + return m, nil + + case tickMsg: + var cmds []tea.Cmd + cmds = append(cmds, tickCmd()) + if m.page == pageHistory { + cmds = append(cmds, m.history.RefreshCmd()) + } + cmds = append(cmds, findingsUI.RefreshCmd(m.database)) + return m, tea.Batch(cmds...) + + case findingsUI.FindingsLoadedMsg: + updated, cmd := m.findingsPage.Update(msg) + m.findingsPage = updated.(findingsUI.Model) + return m, cmd + + case replayUI.SendToReplayMsg: + updated, cmd := m.replay.Update(msg) + m.replay = updated.(replayUI.Model) + if config.Global.Replay.SwitchToPageOnSend { + m.page = pageReplay + m.resizeChildren() + } else { + return m, tea.Batch(cmd, func() tea.Msg { + return notificationsUI.NotificationMsg{ + Title: "Replay", + Body: "Request queued in replay", + Kind: notificationsUI.KindInfo, + } + }) + } + return m, cmd + + case diffUI.SendToDiffMsg: + updated, cmd := m.diff.Update(msg) + m.diff = updated.(diffUI.Model) + return m, cmd + + case diffUI.DiffReadyMsg: + m.page = pageDiff + m.resizeChildren() + return m, nil + + case historyUI.EntriesLoadedMsg: + updated, cmd := m.history.Update(msg) + m.history = updated.(historyUI.Model) + return m, cmd + + case tea.KeyPressMsg: + // ctrl+c always quits, even when a textarea is focused. + if msg.String() == "ctrl+c" { + m.pluginManager.RunOnQuit() + return m, tea.Quit + } + if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() { + m.pluginManager.RunOnQuit() + return m, tea.Quit + } + + if key.Matches(msg, keys.Keys.Global.OpenLogs) { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log") + return m, tea.ExecProcess(exec.Command(editor, logPath), nil) + } + + if !m.activeIsEditing() { + switch { + case key.Matches(msg, keys.Keys.Global.CopyRequest): + if m.page == pageDiff { + if raw := m.diff.CurrentRaw(); raw != "" { + m.copyAs.SetSize(m.width, m.height) + m.copyAs.Open(copyasUI.OpenMsg{ + RawRequest: raw, + Scheme: "https", + }) + } + } else if m.page == pageIntercept { + if raw := m.intercept.CurrentRaw(); raw != "" { + m.copyAs.SetSize(m.width, m.height) + m.copyAs.Open(copyasUI.OpenMsg{ + RawRequest: raw, + Scheme: m.intercept.CurrentScheme(), + }) + } + } + return m, nil + + case key.Matches(msg, keys.Keys.Global.ToggleSidebar): + m.cycleSidebarState() + m.resizeChildren() + + default: + if p, ok := pageShortcuts[msg.String()]; ok { + prev := m.page + m.page = p + if p == pageHistory && prev != pageHistory { + return m, m.history.RefreshCmd() + } + if p == pageFindings { + return m, findingsUI.RefreshCmd(m.database) + } + } + } + } + } + + var cmd tea.Cmd + m, cmd = m.updateActivePage(msg) + return m, cmd +} + +func (m Model) activeIsEditing() bool { + for _, e := range pageRegistry { + if e.id == m.page && e.isEditing != nil { + return e.isEditing(&m) + } + } + return false +} + +func (m Model) updateActivePage(msg tea.Msg) (Model, tea.Cmd) { + for _, e := range pageRegistry { + if e.id == m.page && e.update != nil { + cmd := e.update(&m, msg) + return m, cmd + } + } + return m, nil +} + +func (m *Model) resizeChildren() { + sidebarW := m.getSidebarWidth() + h := m.height + for _, e := range pageRegistry { + if e.resize == nil { + continue + } + e.resize(m, m.width-sidebarW, h) + } + m.notifications.SetSize(m.width, m.height) +} diff --git a/internal/ui/app/view.go b/internal/ui/app/view.go new file mode 100644 index 0000000..f8fa9e2 --- /dev/null +++ b/internal/ui/app/view.go @@ -0,0 +1,49 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/style" +) + +func (m Model) View() tea.View { + if m.width == 0 { + v := tea.NewView("") + v.AltScreen = true + return v + } + + normal := m.renderNormal() + + if m.copyAs.IsOpen() { + v := tea.NewView(m.copyAs.View(normal)) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v + } + + rendered := normal + if m.notifications.HasNotifications() { + rendered = m.notifications.View(normal) + } + + v := tea.NewView(rendered) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} + +func (m Model) renderNormal() string { + sidebar := m.renderSidebar() + content := m.renderActivePage() + return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content) +} + +func (m *Model) renderActivePage() string { + for _, e := range pageRegistry { + if e.id == m.page && e.render != nil { + return e.render(m) + } + } + return style.S.Faint.Render("Work in progress") +} diff --git a/internal/ui/components/copyas/formats.go b/internal/ui/components/copyas/formats.go new file mode 100644 index 0000000..490996c --- /dev/null +++ b/internal/ui/components/copyas/formats.go @@ -0,0 +1,200 @@ +package copyas + +import ( + "fmt" + "strings" +) + +type header struct{ key, value string } + +type parsedRequest struct { + method string + path string + host string + scheme string + headers []header + body string +} + +func parseRaw(raw, scheme string) parsedRequest { + lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") + pr := parsedRequest{scheme: scheme} + if len(lines) == 0 { + return pr + } + + parts := strings.SplitN(lines[0], " ", 3) + if len(parts) >= 1 { + pr.method = strings.TrimSpace(parts[0]) + } + if len(parts) >= 2 { + pr.path = strings.TrimSpace(parts[1]) + } + + i := 1 + for i < len(lines) { + line := strings.TrimRight(lines[i], "\r") + if line == "" { + i++ + break + } + if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 { + k := strings.TrimSpace(kv[0]) + v := strings.TrimSpace(kv[1]) + pr.headers = append(pr.headers, header{k, v}) + if strings.EqualFold(k, "host") { + pr.host = v + } + } + i++ + } + + if i < len(lines) { + pr.body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n") + } + return pr +} + +func (pr parsedRequest) fullURL() string { + scheme := pr.scheme + if scheme == "" { + scheme = "https" + } + return scheme + "://" + pr.host + pr.path +} + +func formatAs(id, raw, scheme string) string { + pr := parseRaw(raw, scheme) + switch id { + case "curl": + return toCurl(pr) + case "python": + return toPython(pr) + case "go": + return toGo(pr) + case "ffuf": + return toFFUF(pr) + case "markdown": + return toMarkdown(pr) + } + return raw +} + +func toMarkdown(pr parsedRequest) string { + var sb strings.Builder + fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL()) + sb.WriteString("```\n") + sb.WriteString(pr.method + " " + pr.path + " HTTP/1.1\n") + for _, h := range pr.headers { + sb.WriteString(fmt.Sprintf("%s: %s\n", h.key, h.value)) + } + if pr.body != "" { + sb.WriteString("\n" + pr.body) + } + sb.WriteString("\n```") + return sb.String() +} + +func toCurl(pr parsedRequest) string { + var sb strings.Builder + fmt.Fprintf(&sb, "curl -X %s '%s'", pr.method, pr.fullURL()) + for _, h := range pr.headers { + if strings.EqualFold(h.key, "content-length") { + continue + } + fmt.Fprintf(&sb, " \\\n -H '%s: %s'", h.key, h.value) + } + if pr.body != "" { + body := strings.ReplaceAll(pr.body, "'", "'\\''") + fmt.Fprintf(&sb, " \\\n --data '%s'", body) + } + return sb.String() +} + +func toPython(pr parsedRequest) string { + var sb strings.Builder + sb.WriteString("import requests\n\n") + fmt.Fprintf(&sb, "url = %q\n", pr.fullURL()) + + sb.WriteString("headers = {\n") + for _, h := range pr.headers { + if strings.EqualFold(h.key, "content-length") { + continue + } + fmt.Fprintf(&sb, " %q: %q,\n", h.key, h.value) + } + sb.WriteString("}\n") + + method := strings.ToLower(pr.method) + if pr.body != "" { + fmt.Fprintf(&sb, "data = %q\n\n", pr.body) + fmt.Fprintf(&sb, "response = requests.%s(url, headers=headers, data=data)\n", method) + } else { + fmt.Fprintf(&sb, "\nresponse = requests.%s(url, headers=headers)\n", method) + } + sb.WriteString("print(response.status_code)\n") + sb.WriteString("print(response.text)\n") + return sb.String() +} + +func toGo(pr parsedRequest) string { + var sb strings.Builder + sb.WriteString("package main\n\nimport (\n") + if pr.body != "" { + sb.WriteString("\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n") + } else { + sb.WriteString("\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n") + } + sb.WriteString("func main() {\n") + + if pr.body != "" { + fmt.Fprintf(&sb, "\tbody := strings.NewReader(%q)\n", pr.body) + fmt.Fprintf(&sb, "\treq, err := http.NewRequest(%q, %q, body)\n", pr.method, pr.fullURL()) + } else { + fmt.Fprintf(&sb, "\treq, err := http.NewRequest(%q, %q, nil)\n", pr.method, pr.fullURL()) + } + sb.WriteString("\tif err != nil { panic(err) }\n") + + for _, h := range pr.headers { + if strings.EqualFold(h.key, "host") || strings.EqualFold(h.key, "content-length") { + continue + } + fmt.Fprintf(&sb, "\treq.Header.Set(%q, %q)\n", h.key, h.value) + } + + sb.WriteString("\n\tclient := &http.Client{}\n") + sb.WriteString("\tresp, err := client.Do(req)\n") + sb.WriteString("\tif err != nil { panic(err) }\n") + sb.WriteString("\tdefer resp.Body.Close()\n") + sb.WriteString("\tbody2, _ := io.ReadAll(resp.Body)\n") + sb.WriteString("\tfmt.Printf(\"Status: %d\\n\", resp.StatusCode)\n") + sb.WriteString("\tfmt.Println(string(body2))\n") + sb.WriteString("}\n") + return sb.String() +} + +func toFFUF(pr parsedRequest) string { + // Place FUZZ in the path: replace query string or append ?FUZZ + fuzzURL := pr.scheme + "://" + pr.host + if idx := strings.Index(pr.path, "?"); idx != -1 { + fuzzURL += pr.path[:idx] + "?FUZZ" + } else { + fuzzURL += pr.path + "?FUZZ" + } + + var sb strings.Builder + fmt.Fprintf(&sb, "ffuf -u '%s'", fuzzURL) + sb.WriteString(" \\\n -w wordlist.txt") + fmt.Fprintf(&sb, " \\\n -X %s", pr.method) + for _, h := range pr.headers { + if strings.EqualFold(h.key, "content-length") { + continue + } + fmt.Fprintf(&sb, " \\\n -H '%s: %s'", h.key, h.value) + } + if pr.body != "" { + body := strings.ReplaceAll(pr.body, "'", "'\\''") + fmt.Fprintf(&sb, " \\\n -d '%s'", body) + } + return sb.String() +} diff --git a/internal/ui/components/copyas/model.go b/internal/ui/components/copyas/model.go new file mode 100644 index 0000000..583942a --- /dev/null +++ b/internal/ui/components/copyas/model.go @@ -0,0 +1,117 @@ +package copyas + +import ( + "encoding/base64" + "fmt" + "os" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/style" +) + +const popupInnerW = 46 + +// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard. +// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…). +func writeClipboard(text string) { + encoded := base64.StdEncoding.EncodeToString([]byte(text)) + fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded) +} + +type OpenMsg struct { + RawRequest string + Scheme string +} + +type formatItem struct { + id string + title string + desc string +} + +func (f formatItem) Title() string { return f.title } +func (f formatItem) Description() string { return f.desc } +func (f formatItem) FilterValue() string { return f.title } + +var allFormats = []list.Item{ + formatItem{"curl", "cURL", "command line HTTP request"}, + formatItem{"python", "Python", "requests library"}, + formatItem{"go", "Go", "net/http package"}, + formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"}, + formatItem{"markdown", "Markdown", "formatted for documentation"}, +} + +type Model struct { + open bool + list list.Model + rawRequest string + scheme string + width int + height int +} + +func New() Model { + s := style.S + + delegate := list.NewDefaultDelegate() + delegate.SetSpacing(0) + delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2) + delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2) + delegate.Styles.SelectedTitle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(s.Primary). + Foreground(s.Primary).Bold(true).PaddingLeft(1) + delegate.Styles.SelectedDesc = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(s.Primary). + Foreground(s.MutedFg).PaddingLeft(1) + + l := list.New(allFormats, delegate, popupInnerW, 8) + l.SetShowTitle(false) + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.SetFilteringEnabled(true) + l.KeyMap.Quit.SetEnabled(false) + l.KeyMap.ForceQuit.SetEnabled(false) + l.KeyMap.ShowFullHelp.SetEnabled(false) + l.KeyMap.CloseFullHelp.SetEnabled(false) + + return Model{list: l} +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) IsOpen() bool { return m.open } + +func (m *Model) Open(msg OpenMsg) { + m.rawRequest = msg.RawRequest + m.scheme = msg.Scheme + m.open = true + m.list.ResetFilter() + m.list.Select(0) + m.list.SetSize(popupInnerW, m.listHeight()) +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.list.SetSize(popupInnerW, m.listHeight()) +} + +func (m Model) popupHeight() int { + h := 14 + if m.height > 0 && m.height-4 < h { + h = m.height - 4 + } + if h < 6 { + h = 6 + } + return h +} + +// listHeight = panel content area - hint line (1) +func (m Model) listHeight() int { + return style.PanelContentH(m.popupHeight()) - 1 +} diff --git a/internal/ui/components/copyas/update.go b/internal/ui/components/copyas/update.go new file mode 100644 index 0000000..05b6d14 --- /dev/null +++ b/internal/ui/components/copyas/update.go @@ -0,0 +1,30 @@ +package copyas + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/keys" +) + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if kp, ok := msg.(tea.KeyPressMsg); ok { + switch { + case kp.String() == "enter": + if item, ok := m.list.SelectedItem().(formatItem); ok { + writeClipboard(formatAs(item.id, m.rawRequest, m.scheme)) + } + m.open = false + return m, nil + case key.Matches(kp, keys.Keys.Global.Escape): + if m.list.SettingFilter() { + break + } + m.open = false + return m, nil + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} diff --git a/internal/ui/components/copyas/view.go b/internal/ui/components/copyas/view.go new file mode 100644 index 0000000..702dd48 --- /dev/null +++ b/internal/ui/components/copyas/view.go @@ -0,0 +1,93 @@ +package copyas + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/style" + "github.com/charmbracelet/x/ansi" +) + +func (m *Model) View(background string) string { + s := style.S + + hint := lipgloss.NewStyle().Foreground(s.Subtle). + Render(" enter: copy • /: filter • esc: cancel") + + inner := lipgloss.JoinVertical(lipgloss.Left, + m.list.View(), + hint, + ) + + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(s.Primary) + + popupH := m.popupHeight() + popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH) + + return overlayCenter(background, popup, m.width, m.height) +} + +func overlayCenter(bg, popup string, w, h int) string { + s := style.S + + stripped := ansi.Strip(bg) + rawLines := strings.Split(stripped, "\n") + bgRunes := make([][]rune, h) + for y := 0; y < h; y++ { + var line []rune + if y < len(rawLines) { + line = []rune(rawLines[y]) + } + if len(line) > w { + line = line[:w] + } + for len(line) < w { + line = append(line, ' ') + } + bgRunes[y] = line + } + + popupLines := strings.Split(popup, "\n") + popupH := len(popupLines) + popupW := 0 + for _, l := range popupLines { + if vw := lipgloss.Width(l); vw > popupW { + popupW = vw + } + } + + startY := (h - popupH) / 2 + startX := (w - popupW) / 2 + if startY < 0 { + startY = 0 + } + if startX < 0 { + startX = 0 + } + + dim := lipgloss.NewStyle().Foreground(s.Subtle).Faint(true) + + result := make([]string, h) + for y := 0; y < h; y++ { + popupY := y - startY + if popupY >= 0 && popupY < popupH { + leftEnd := startX + if leftEnd > len(bgRunes[y]) { + leftEnd = len(bgRunes[y]) + } + prefix := dim.Render(string(bgRunes[y][:leftEnd])) + rightStart := startX + popupW + suffix := "" + if rightStart < len(bgRunes[y]) { + suffix = dim.Render(string(bgRunes[y][rightStart:])) + } + result[y] = prefix + popupLines[popupY] + suffix + } else { + result[y] = dim.Render(string(bgRunes[y])) + } + } + + return strings.Join(result, "\n") +} diff --git a/internal/ui/components/notifications/model.go b/internal/ui/components/notifications/model.go new file mode 100644 index 0000000..cf73da8 --- /dev/null +++ b/internal/ui/components/notifications/model.go @@ -0,0 +1,155 @@ +package notifications + +import ( + "image/color" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/style" + "github.com/charmbracelet/x/ansi" +) + +type Kind string + +const ( + KindInfo Kind = "info" + KindSuccess Kind = "success" + KindWarning Kind = "warning" + KindError Kind = "error" +) + +type NotificationMsg struct { + Title string + Body string + Kind Kind +} + +type DismissMsg struct{ ID int } + +type notification struct { + id int + title string + body string + kind Kind +} + +type Model struct { + queue []notification + nextID int + width int + height int +} + +func New() Model { return Model{} } + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h +} + +func (m Model) HasNotifications() bool { + return len(m.queue) > 0 +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case NotificationMsg: + n := notification{id: m.nextID, title: msg.Title, body: msg.Body, kind: msg.Kind} + m.nextID++ + m.queue = append(m.queue, n) + return m, tea.Tick(4*time.Second, func(time.Time) tea.Msg { return DismissMsg{ID: n.id} }) + case DismissMsg: + for i, n := range m.queue { + if n.id == msg.ID { + m.queue = append(m.queue[:i], m.queue[i+1:]...) + break + } + } + } + return m, nil +} + +func (m Model) View(background string) string { + if len(m.queue) == 0 { + return background + } + + s := style.S + const popupW = 34 + + var popups []string + start := len(m.queue) - 3 + if start < 0 { + start = 0 + } + for i := start; i < len(m.queue); i++ { + n := m.queue[i] + var accent color.Color + switch n.kind { + case KindSuccess: + accent = s.Success + case KindWarning: + accent = s.Warning + case KindError: + accent = s.Error + default: + accent = s.Primary + } + + titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title) + bodyStr := lipgloss.NewStyle().Foreground(s.Text).Width(popupW).Render(n.body) + + inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr) + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accent). + Padding(0, 1). + Render(inner) + popups = append(popups, box) + } + + popup := strings.Join(popups, "\n") + return overlayTopRight(background, popup, m.width, m.height) +} + +func overlayTopRight(bg, popup string, w, h int) string { + bgLines := strings.Split(bg, "\n") + + popupLines := strings.Split(popup, "\n") + popupH := len(popupLines) + popupW := 0 + for _, l := range popupLines { + if vw := lipgloss.Width(l); vw > popupW { + popupW = vw + } + } + + const marginTop = 1 + const marginRight = 2 + startY := marginTop + startX := w - popupW - marginRight + if startX < 0 { + startX = 0 + } + + result := make([]string, h) + for y := 0; y < h; y++ { + bgLine := "" + if y < len(bgLines) { + bgLine = bgLines[y] + } + + popupY := y - startY + if popupY >= 0 && popupY < popupH { + prefix := ansi.Truncate(bgLine, startX, "") + suffix := ansi.TruncateLeft(bgLine, startX+popupW, "") + result[y] = prefix + popupLines[popupY] + suffix + } else { + result[y] = bgLine + } + } + + return strings.Join(result, "\n") +} diff --git a/internal/ui/components/teapot/teapot.go b/internal/ui/components/teapot/teapot.go new file mode 100644 index 0000000..8361fef --- /dev/null +++ b/internal/ui/components/teapot/teapot.go @@ -0,0 +1,72 @@ +package teapot + +import "strings" + +// FrameLines returns the number of visual lines in a teapot frame. +func FrameLines() int { + frames := TeapotFrames() + if len(frames) == 0 { + return 0 + } + return strings.Count(frames[0], "\n") + 1 +} + +func Teapot() string { + return "" + + " ) \n" + + " ( \n" + + " ) \n" + + " .-.,--^--. _ \n" + + " \\\\| `---' |//\n" + + " \\| / \n" + + " _\\_______/_ " +} + +func TeapotFrames() []string { + return []string{ + "" + + " ) \n" + + " ( \n" + + " ) \n" + + " .-.,--^--. _ \n" + + " \\\\| `---' |//\n" + + " \\| / \n" + + " _\\_______/_ ", + + "" + + " ) \n" + + " ( \n" + + " ) \n" + + " .-.,--^--. _ \n" + + " \\\\| `---' |//\n" + + " \\| / \n" + + " _\\_______/_ ", + + "" + + " ) \n" + + " ( \n" + + " ) \n" + + " .-.,--^--. _ \n" + + " \\\\| `---' |//\n" + + " \\| / \n" + + " _\\_______/_ ", + + "" + + " \n" + + " ( \n" + + " ) \n" + + " .-.,--^--. _ \n" + + " \\\\| `---' |//\n" + + " \\| / \n" + + " _\\_______/_ ", + + "" + + " \n" + + " (( \n" + + " ) \n" + + " .-.,--^--. _ \n" + + " \\\\| `---' |//\n" + + " \\| / \n" + + " _\\_______/_ ", + } +} diff --git a/internal/ui/diff/model.go b/internal/ui/diff/model.go new file mode 100644 index 0000000..4b2e54a --- /dev/null +++ b/internal/ui/diff/model.go @@ -0,0 +1,264 @@ +package diff + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" +) + +type slot struct { + label string + raw string +} + +type focusedSlot int + +const ( + bothSlots focusedSlot = iota + leftSlot + rightSlot +) + +func (f focusedSlot) next() focusedSlot { + return (f + 1) % 3 +} + +type lineKind int + +const ( + lineUnchanged lineKind = iota + lineAdded + lineRemoved +) + +type diffLine struct { + text string + kind lineKind +} + +type Model struct { + left slot + right slot + focus focusedSlot + + leftLines []diffLine + rightLines []diffLine + + leftViewport viewport.Model + rightViewport viewport.Model + help help.Model + + width int + height int +} + +func New() Model { + return Model{ + leftViewport: style.NewViewport(), + rightViewport: style.NewViewport(), + help: style.NewHelp(), + } +} + +func (m Model) Init() tea.Cmd { return nil } + +// CurrentRaw returns the raw content of the focused slot (left when both are focused). +func (m Model) CurrentRaw() string { + if m.focus == rightSlot { + return m.right.raw + } + return m.left.raw +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.recalcSizes() +} + +func (m *Model) recalcSizes() { + m.help.SetWidth(m.width - 2) + + statusH := strings.Count(m.renderStatusBar(), "\n") + 1 + panelH := m.height - statusH + if panelH < 0 { + panelH = 0 + } + + leftW := m.width / 2 + rightW := m.width - leftW + + leftInner := leftW - 2 + rightInner := rightW - 2 + if leftInner < 0 { + leftInner = 0 + } + if rightInner < 0 { + rightInner = 0 + } + + viewportH := style.PanelContentH(panelH) + + m.leftViewport.SetWidth(leftInner) + m.leftViewport.SetHeight(viewportH) + m.rightViewport.SetWidth(rightInner) + m.rightViewport.SetHeight(viewportH) + + m.refreshViewports() +} + +func (m *Model) computeDiff() { + if m.left.raw == "" || m.right.raw == "" { + m.leftLines = nil + m.rightLines = nil + return + } + leftNorm := normRaw(m.left.raw) + rightNorm := normRaw(m.right.raw) + leftPlain := strings.Split(leftNorm, "\n") + rightPlain := strings.Split(rightNorm, "\n") + leftHL := hlLines(leftNorm) + rightHL := hlLines(rightNorm) + m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL) +} + +func normRaw(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return strings.TrimRight(s, "\n") +} + +func hlLines(raw string) []string { + s := strings.TrimRight(style.HighlightHTTP(raw), "\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +func (m *Model) refreshViewports() { + s := style.S + + if m.left.raw == "" { + placeholder := lipgloss.Place( + m.leftViewport.Width(), m.leftViewport.Height(), + lipgloss.Center, lipgloss.Center, + s.Faint.Render(" <(^_^)>\nsend two entries here to compare"), + ) + m.leftViewport.SetContent(placeholder) + m.rightViewport.SetContent("") + return + } + + if m.right.raw == "" { + m.leftViewport.SetContent(style.HighlightHTTP(normRaw(m.left.raw))) + placeholder := lipgloss.Place( + m.rightViewport.Width(), m.rightViewport.Height(), + lipgloss.Center, lipgloss.Center, + s.Faint.Render(" (・3・)\nwaiting for second entry…"), + ) + m.rightViewport.SetContent(placeholder) + return + } + + m.leftViewport.SetContent(renderLeftLines(m.leftLines)) + m.rightViewport.SetContent(renderRightLines(m.rightLines)) +} + +func (m *Model) scroll(delta int) { + offset := m.leftViewport.YOffset() + delta + m.leftViewport.SetYOffset(offset) + m.rightViewport.SetYOffset(offset) +} + +func (m *Model) scrollH(delta int) { + offset := m.leftViewport.XOffset() + delta + m.leftViewport.SetXOffset(offset) + m.rightViewport.SetXOffset(offset) +} + +func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) { + hlA := func(i int) string { + if i < len(aHL) { + return aHL[i] + } + return a[i] + } + hlB := func(j int) string { + if j < len(bHL) { + return bHL[j] + } + return b[j] + } + + n, m := len(a), len(b) + + dp := make([][]int, n+1) + for i := range dp { + dp[i] = make([]int, m+1) + } + for i := 1; i <= n; i++ { + for j := 1; j <= m; 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] + } + } + } + + left = make([]diffLine, 0, n+m) + right = make([]diffLine, 0, n+m) + i, j := n, m + for i > 0 || j > 0 { + switch { + case i > 0 && j > 0 && a[i-1] == b[j-1]: + left = append(left, diffLine{text: hlA(i-1), kind: lineUnchanged}) + right = append(right, diffLine{text: hlB(j-1), kind: lineUnchanged}) + i-- + j-- + case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]): + left = append(left, diffLine{kind: lineAdded}) + right = append(right, diffLine{text: hlB(j-1), kind: lineAdded}) + j-- + default: + left = append(left, diffLine{text: hlA(i-1), kind: lineRemoved}) + right = append(right, diffLine{kind: lineRemoved}) + i-- + } + } + + for lo, hi := 0, len(left)-1; lo < hi; lo, hi = lo+1, hi-1 { + left[lo], left[hi] = left[hi], left[lo] + right[lo], right[hi] = right[hi], right[lo] + } + return left, right +} + +func diffBindings() []key.Binding { + g := keys.Keys.Global + return []key.Binding{ + g.Up, g.Down, g.ScrollUp, g.ScrollDown, + g.CycleFocus, keys.Keys.Diff.Clear, + } +} + +type diffKeyMap struct{ width int } + +func (diffKeyMap) ShortHelp() []key.Binding { + g := keys.Keys.Global + return []key.Binding{g.Up, g.Down, g.CycleFocus, keys.Keys.Diff.Clear, g.Help} +} + +func (m diffKeyMap) FullHelp() [][]key.Binding { + all := append(diffBindings(), keys.Keys.Global.Bindings()...) + return keys.ChunkByWidth(all, m.width) +} diff --git a/internal/ui/diff/update.go b/internal/ui/diff/update.go new file mode 100644 index 0000000..82186ba --- /dev/null +++ b/internal/ui/diff/update.go @@ -0,0 +1,143 @@ +package diff + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/keys" + notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" +) + +// SendToDiffMsg carries a raw HTTP request or response to the diff page. +type SendToDiffMsg struct { + Label string + Raw string +} + +// DiffReadyMsg is emitted when both slots are filled and the diff is ready to view. +type DiffReadyMsg struct{} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case SendToDiffMsg: + if m.left.raw == "" { + m.left = slot{label: msg.Label, raw: msg.Raw} + m.refreshViewports() + return m, func() tea.Msg { + return notificationsUI.NotificationMsg{ + Title: "Entry selected", + Body: "Select a second entry to compare", + Kind: notificationsUI.KindInfo, + } + } + } else if m.right.raw == "" { + m.right = slot{label: msg.Label, raw: msg.Raw} + m.computeDiff() + m.focus = bothSlots + m.leftViewport.SetYOffset(0) + m.rightViewport.SetYOffset(0) + m.leftViewport.SetXOffset(0) + m.rightViewport.SetXOffset(0) + m.refreshViewports() + return m, func() tea.Msg { return DiffReadyMsg{} } + } else { + // Both full: reset and start new comparison + m.left = slot{label: msg.Label, raw: msg.Raw} + m.right = slot{} + m.leftLines = nil + m.rightLines = nil + m.focus = bothSlots + m.leftViewport.SetYOffset(0) + m.rightViewport.SetYOffset(0) + m.leftViewport.SetXOffset(0) + m.rightViewport.SetXOffset(0) + m.refreshViewports() + return m, func() tea.Msg { + return notificationsUI.NotificationMsg{ + Title: "Entry replaced", + Body: "Select a second entry to compare", + Kind: notificationsUI.KindInfo, + } + } + } + + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelUp: + if msg.Mod.Contains(tea.ModShift) { + m.scrollH(-6) + } else { + m.scroll(-1) + } + case tea.MouseWheelDown: + if msg.Mod.Contains(tea.ModShift) { + m.scrollH(6) + } else { + m.scroll(1) + } + case tea.MouseWheelLeft: + m.scrollH(-6) + case tea.MouseWheelRight: + m.scrollH(6) + } + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, keys.Keys.Global.CycleFocus): + m.focus = m.focus.next() + + case key.Matches(msg, keys.Keys.Global.Up): + m.scroll(-1) + case key.Matches(msg, keys.Keys.Global.Down): + m.scroll(1) + case key.Matches(msg, keys.Keys.Global.ScrollUp): + step := m.leftViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.scroll(-step) + case key.Matches(msg, keys.Keys.Global.ScrollDown): + step := m.leftViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.scroll(step) + + case key.Matches(msg, keys.Keys.Global.Left): + m.scrollH(-6) + case key.Matches(msg, keys.Keys.Global.Right): + m.scrollH(6) + + case key.Matches(msg, keys.Keys.Diff.Clear): + switch m.focus { + case leftSlot: + m.left = m.right + m.right = slot{} + m.leftLines = nil + m.rightLines = nil + m.focus = bothSlots + case rightSlot: + m.right = slot{} + m.leftLines = nil + m.rightLines = nil + m.focus = bothSlots + default: + m.left = slot{} + m.right = slot{} + m.leftLines = nil + m.rightLines = nil + m.focus = bothSlots + } + m.leftViewport.SetYOffset(0) + m.rightViewport.SetYOffset(0) + m.leftViewport.SetXOffset(0) + m.rightViewport.SetXOffset(0) + m.refreshViewports() + + case key.Matches(msg, keys.Keys.Global.Help): + m.help.ShowAll = !m.help.ShowAll + m.recalcSizes() + } + } + + return m, nil +} diff --git a/internal/ui/diff/view.go b/internal/ui/diff/view.go new file mode 100644 index 0000000..d5663f3 --- /dev/null +++ b/internal/ui/diff/view.go @@ -0,0 +1,94 @@ +package diff + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/style" +) + +func (m Model) View() tea.View { + if m.width == 0 { + return tea.NewView("Loading...") + } + + statusH := strings.Count(m.renderStatusBar(), "\n") + 1 + panelH := m.height - statusH + + content := lipgloss.JoinVertical(lipgloss.Left, + m.renderPanels(panelH), + m.renderStatusBar(), + ) + return tea.NewView(content) +} + +func (m *Model) renderPanels(panelH int) string { + s := style.S + + leftW := m.width / 2 + rightW := m.width - leftW + + leftTitle := icons.I.Diff + "First" + if m.left.label != "" { + leftTitle = icons.I.Diff + "First: " + m.left.label + } + rightTitle := icons.I.Diff + "Second" + if m.right.label != "" { + rightTitle = icons.I.Diff + "Second: " + m.right.label + } + + leftBorder := s.Panel + rightBorder := s.Panel + switch m.focus { + case bothSlots: + leftBorder = s.PanelFocused + rightBorder = s.PanelFocused + case leftSlot: + leftBorder = s.PanelFocused + case rightSlot: + rightBorder = s.PanelFocused + } + + left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH) + right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH) + + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} + +func (m *Model) renderStatusBar() string { + return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(diffKeyMap{width: m.width})) +} + +func renderLeftLines(lines []diffLine) string { + s := style.S + var sb strings.Builder + for _, l := range lines { + switch l.kind { + case lineRemoved: + sb.WriteString(style.Paint(s.Error, "- ") + l.text + "\n") + case lineAdded: + sb.WriteString("\n") + default: + sb.WriteString(" " + l.text + "\n") + } + } + return sb.String() +} + +func renderRightLines(lines []diffLine) string { + s := style.S + var sb strings.Builder + for _, l := range lines { + switch l.kind { + case lineAdded: + sb.WriteString(style.Paint(s.Success, "+ ") + l.text + "\n") + case lineRemoved: + sb.WriteString("\n") + default: + sb.WriteString(" " + l.text + "\n") + } + } + return sb.String() +} diff --git a/internal/ui/docs/model.go b/internal/ui/docs/model.go new file mode 100644 index 0000000..fad410d --- /dev/null +++ b/internal/ui/docs/model.go @@ -0,0 +1,37 @@ +package docs + +import ( + "strings" + + spilltea "github.com/anotherhadi/spilltea" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" +) + +func readDoc(name string) string { + b, _ := spilltea.DocsFS.ReadFile(".github/docs/" + name) + return string(b) +} + +var contentMarkdown = strings.Join([]string{ + readDoc("main.md"), + readDoc("proxy.md"), + readDoc("certificate.md"), + readDoc("history.md"), + readDoc("scopes.md"), +}, "\n") + +type Model struct { + viewport viewport.Model +} + +func New() Model { + return Model{ + viewport: viewport.New(), + } +} + +func (e Model) Init() tea.Cmd { + return nil +} diff --git a/internal/ui/docs/update.go b/internal/ui/docs/update.go new file mode 100644 index 0000000..ba04ca4 --- /dev/null +++ b/internal/ui/docs/update.go @@ -0,0 +1,50 @@ +package docs + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/keys" +) + +func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + g := keys.Keys.Global + switch msg := msg.(type) { + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelUp: + e.viewport.SetYOffset(e.viewport.YOffset() - 1) + case tea.MouseWheelDown: + e.viewport.SetYOffset(e.viewport.YOffset() + 1) + } + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, g.Up): + e.viewport.SetYOffset(e.viewport.YOffset() - 1) + case key.Matches(msg, g.Down): + e.viewport.SetYOffset(e.viewport.YOffset() + 1) + case key.Matches(msg, g.ScrollUp): + step := e.viewport.Height() / 2 + if step < 1 { + step = 1 + } + e.viewport.SetYOffset(e.viewport.YOffset() - step) + case key.Matches(msg, g.ScrollDown): + step := e.viewport.Height() / 2 + if step < 1 { + step = 1 + } + e.viewport.SetYOffset(e.viewport.YOffset() + step) + } + } + return e, nil +} + +func (m *Model) SetSize(w, h int) { + frameW := windowStyle().GetHorizontalFrameSize() + frameH := windowStyle().GetVerticalFrameSize() + + m.viewport.SetWidth(w - frameW) + m.viewport.SetHeight(h - frameH) + m.renderMarkdown() +} diff --git a/internal/ui/docs/view.go b/internal/ui/docs/view.go new file mode 100644 index 0000000..d1f2e76 --- /dev/null +++ b/internal/ui/docs/view.go @@ -0,0 +1,52 @@ +package docs + +import ( + "bytes" + _ "embed" + "text/template" + + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/config" + "github.com/anotherhadi/spilltea/internal/style" +) + +func windowStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(style.S.Subtle). + Padding(0, 0) +} + +func (e Model) View() tea.View { + return tea.NewView(windowStyle().Render(e.viewport.View())) +} + +func (m *Model) renderMarkdown() { + cfg := config.Global + data := struct { + Cfg *config.Config + }{ + Cfg: cfg, + } + + tmpl, err := template.New("info").Parse(contentMarkdown) + if err != nil { + return + } + + var processed bytes.Buffer + if err := tmpl.Execute(&processed, data); err != nil { + return + } + + width := m.viewport.Width() - 2 + renderer, _ := glamour.NewTermRenderer( + glamour.WithStyles(style.GlamourStyleConfig(cfg)), + glamour.WithWordWrap(width), + ) + + str, _ := renderer.Render(processed.String()) + m.viewport.SetContent(str) +} diff --git a/internal/ui/findings/model.go b/internal/ui/findings/model.go new file mode 100644 index 0000000..4f0498b --- /dev/null +++ b/internal/ui/findings/model.go @@ -0,0 +1,156 @@ +package findings + +import ( + "bytes" + "text/template" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/paginator" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/config" + "github.com/anotherhadi/spilltea/internal/db" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" +) + +type Model struct { + database *db.DB + findings []db.Finding + cursor int + + listViewport viewport.Model + bodyViewport viewport.Model + pager paginator.Model + help help.Model + + width int + height int +} + +func New() Model { + return Model{ + listViewport: style.NewViewport(), + bodyViewport: style.NewViewport(), + pager: style.NewPaginator(), + help: style.NewHelp(), + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m *Model) SetDB(d *db.DB) { + m.database = d +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.recalcSizes() +} + +func (m *Model) recalcSizes() { + if m.width == 0 { + return + } + m.help.SetWidth(m.width - 2) + inner := m.width - 2 + + listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35) + + listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row + if listVH < 0 { + listVH = 0 + } + m.listViewport.SetWidth(inner) + m.listViewport.SetHeight(listVH) + m.pager.PerPage = listVH + if m.pager.PerPage < 1 { + m.pager.PerPage = 1 + } + + bodyVH := style.PanelContentH(bodyH) + m.bodyViewport.SetWidth(inner) + m.bodyViewport.SetHeight(bodyVH) + + m.refreshListViewport() + m.refreshBody() +} + +func (m *Model) renderStatusBar() string { + return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(findingsKeyMap{})) +} + +// RefreshCmd loads findings from the database. +func RefreshCmd(d *db.DB) tea.Cmd { + return func() tea.Msg { + if d == nil { + return FindingsLoadedMsg{} + } + list, err := d.LoadFindings() + if err != nil { + return FindingsLoadedMsg{Err: err} + } + return FindingsLoadedMsg{Findings: list} + } +} + +type FindingsLoadedMsg struct { + Findings []db.Finding + Err error +} + +func (m *Model) refreshBody() { + if len(m.findings) == 0 { + m.bodyViewport.SetContent("") + return + } + f := m.findings[m.cursor] + rendered := renderMarkdown(f.Description, m.bodyViewport.Width()) + m.bodyViewport.SetContent(rendered) + m.bodyViewport.GotoTop() +} + +func renderMarkdown(src string, width int) string { + if src == "" { + return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description") + } + tmpl, err := template.New("").Parse(src) + if err != nil { + return src + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, nil); err != nil { + return src + } + if width < 10 { + width = 80 + } + r, err := glamour.NewTermRenderer( + glamour.WithStyles(style.GlamourStyleConfig(config.Global)), + glamour.WithWordWrap(width), + ) + if err != nil { + return buf.String() + } + out, err := r.Render(buf.String()) + if err != nil { + return buf.String() + } + return out +} + +type findingsKeyMap struct{} + +func (findingsKeyMap) ShortHelp() []key.Binding { + g := keys.Keys.Global + f := keys.Keys.Findings + return []key.Binding{g.Up, g.Down, f.Dismiss} +} + +func (findingsKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{findingsKeyMap{}.ShortHelp()} +} diff --git a/internal/ui/findings/update.go b/internal/ui/findings/update.go new file mode 100644 index 0000000..f20920c --- /dev/null +++ b/internal/ui/findings/update.go @@ -0,0 +1,86 @@ +package findings + +import ( + "log" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/keys" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case FindingsLoadedMsg: + if msg.Err != nil { + log.Printf("findings load error: %v", msg.Err) + return m, nil + } + m.findings = msg.Findings + if m.cursor >= len(m.findings) { + m.cursor = max(0, len(m.findings)-1) + } + m.pager.SetTotalPages(len(m.findings)) + m.refreshListViewport() + m.refreshBody() + return m, nil + + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelUp: + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1) + case tea.MouseWheelDown: + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1) + } + return m, nil + + case tea.KeyPressMsg: + g := keys.Keys.Global + f := keys.Keys.Findings + + switch { + case key.Matches(msg, g.Up): + if m.cursor > 0 { + m.cursor-- + if m.cursor < m.pager.Page*m.pager.PerPage { + m.pager.PrevPage() + } + m.refreshListViewport() + m.refreshBody() + } + case key.Matches(msg, g.Down): + if m.cursor < len(m.findings)-1 { + m.cursor++ + if m.cursor >= (m.pager.Page+1)*m.pager.PerPage { + m.pager.NextPage() + } + m.refreshListViewport() + m.refreshBody() + } + case key.Matches(msg, f.Dismiss): + if len(m.findings) > 0 && m.database != nil { + if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil { + log.Printf("dismiss finding: %v", err) + return m, nil + } + return m, RefreshCmd(m.database) + } + case key.Matches(msg, g.ScrollUp): + step := m.bodyViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step) + case key.Matches(msg, g.ScrollDown): + step := m.bodyViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step) + } + } + return m, nil +} + +func (m *Model) refreshListViewport() { + m.listViewport.SetContent(m.renderList()) +} diff --git a/internal/ui/findings/view.go b/internal/ui/findings/view.go new file mode 100644 index 0000000..df06dbb --- /dev/null +++ b/internal/ui/findings/view.go @@ -0,0 +1,112 @@ +package findings + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/style" + "github.com/anotherhadi/spilltea/internal/util" +) + +func (m Model) View() tea.View { + if m.width == 0 { + return tea.NewView("Loading...") + } + + listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35) + + content := lipgloss.JoinVertical(lipgloss.Left, + m.renderListPanel(m.width, listH), + m.renderBodyPanel(bodyH), + m.renderStatusBar(), + ) + return tea.NewView(content) +} + +func (m *Model) renderListPanel(w, h int) string { + s := style.S + dots := s.Faint.Render(m.pager.View()) + inner := lipgloss.JoinVertical(lipgloss.Left, + m.listViewport.View(), + lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), + ) + return style.RenderWithTitle(s.PanelFocused, icons.I.Findings+"Findings", inner, w, h) +} + +func (m *Model) renderBodyPanel(h int) string { + s := style.S + title := "Description" + if len(m.findings) > 0 { + title = m.findings[m.cursor].Title + } + return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h) +} + +func (m *Model) renderList() string { + s := style.S + if len(m.findings) == 0 { + return lipgloss.Place( + m.listViewport.Width(), m.listViewport.Height(), + lipgloss.Center, lipgloss.Center, + s.Faint.Render(" (҂◡_◡) ᕤ\nno findings"), + ) + } + + start, end := m.pager.GetSliceBounds(len(m.findings)) + if start < 0 { + start = 0 + } + if end < start { + end = start + } + + var sb strings.Builder + for i, f := range m.findings[start:end] { + globalIdx := start + i + selected := globalIdx == m.cursor + + sevStyle := style.SeverityStyle(f.Severity) + sevLabel := sevStyle.Width(8).Render(f.Severity) + ts := f.CreatedAt.Format("15:04:05") + + w := m.listViewport.Width() + const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1 + titleW := w - fixedW + if titleW < 0 { + titleW = 0 + } + + pluginStr := s.Faint.Width(8).Render(util.Truncate(f.PluginName, 8)) + + var line string + if selected { + bg := lipgloss.NewStyle().Background(s.Selection) + line = lipgloss.JoinHorizontal(lipgloss.Top, + bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"), + sevStyle.Background(s.Selection).Width(8).Render(f.Severity), + bg.Width(1).Render(""), + bg.Foreground(s.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)), + bg.Width(1).Render(""), + bg.Foreground(s.Subtle).Width(10).Render(ts), + bg.Width(1).Render(""), + bg.Bold(true).Width(titleW).Render(f.Title), + ) + } else { + line = lipgloss.JoinHorizontal(lipgloss.Top, + " ", + sevLabel, + " ", + pluginStr, + " ", + s.Faint.Width(10).Render(ts), + " ", + s.Bold.Render(f.Title), + ) + } + sb.WriteString(fmt.Sprintf("%s\n", line)) + } + return sb.String() +} diff --git a/internal/ui/history/model.go b/internal/ui/history/model.go new file mode 100644 index 0000000..8b6252c --- /dev/null +++ b/internal/ui/history/model.go @@ -0,0 +1,149 @@ +package history + +import ( + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/paginator" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/db" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" +) + +type panel int + +const ( + panelRequest panel = iota + panelResponse +) + +type Model struct { + database *db.DB + entries []db.Entry + cursor int + focusedPanel panel + + listViewport viewport.Model + bodyViewport viewport.Model + pager paginator.Model + help help.Model + + searchInput textinput.Model + searchKind searchKind + searchAccepted bool + searchErr string + + width int + height int +} + +func New() Model { + ti := textinput.New() + ti.Prompt = "" + return Model{ + listViewport: style.NewViewport(), + bodyViewport: style.NewViewport(), + pager: style.NewPaginator(), + help: style.NewHelp(), + searchInput: ti, + } +} + +func (m Model) IsEditing() bool { + return m.searchKind != searchKindOff && !m.searchAccepted +} + +// RefreshCmd returns the appropriate load command given the current search state. +// The app model should call this instead of LoadEntriesCmd directly so that +// background refreshes re-run the active search rather than resetting it. +func (m Model) RefreshCmd() tea.Cmd { + switch m.searchKind { + case searchKindFulltext: + return SearchCmd(m.database, m.searchInput.Value()) + case searchKindSQL: + return nil + default: + return LoadEntriesCmd(m.database) + } +} + +func (m *Model) clearSearch() tea.Cmd { + m.searchKind = searchKindOff + m.searchAccepted = false + m.searchErr = "" + m.searchInput.SetValue("") + m.searchInput.Blur() + m.recalcSizes() + return LoadEntriesCmd(m.database) +} + +func (m *Model) acceptSearch() { + m.searchAccepted = true + m.searchInput.Blur() + m.recalcSizes() +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m *Model) SetDB(d *db.DB) { + m.database = d +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.recalcSizes() +} + +func (m *Model) recalcSizes() { + m.help.SetWidth(m.width - 2) + // 2 (padding) + 2 (prefix char + space) + m.searchInput.SetWidth(m.width - 4) + + listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35) + + inner := m.width - 2 + if inner < 0 { + inner = 0 + } + + listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row + if listVH < 0 { + listVH = 0 + } + m.listViewport.SetWidth(inner) + m.listViewport.SetHeight(listVH) + m.pager.PerPage = listVH + if m.pager.PerPage < 1 { + m.pager.PerPage = 1 + } + + bodyVH := style.PanelContentH(bodyH) + m.bodyViewport.SetWidth(inner) + m.bodyViewport.SetHeight(bodyVH) + + m.refreshListViewport() + m.refreshBody() +} + +type historyKeyMap struct{ width int } + +func (historyKeyMap) ShortHelp() []key.Binding { + h := keys.Keys.History + g := keys.Keys.Global + return []key.Binding{ + g.Up, g.Down, g.CycleFocus, + h.DeleteEntry, h.DeleteAll, + h.Filter, h.SqlQuery, + g.Help, + } +} + +func (m historyKeyMap) FullHelp() [][]key.Binding { + h := keys.Keys.History + all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery} + all = append(all, keys.Keys.Global.Bindings()...) + return keys.ChunkByWidth(all, m.width) +} diff --git a/internal/ui/history/search.go b/internal/ui/history/search.go new file mode 100644 index 0000000..e10604a --- /dev/null +++ b/internal/ui/history/search.go @@ -0,0 +1,48 @@ +package history + +import ( + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/db" +) + +type searchKind int + +const ( + searchKindOff searchKind = iota + searchKindFulltext + searchKindSQL +) + +type SearchResultMsg struct { + Entries []db.Entry +} + +type SearchErrMsg struct { + Err error +} + +func SearchCmd(database *db.DB, term string) tea.Cmd { + return func() tea.Msg { + if database == nil { + return SearchResultMsg{} + } + entries, err := database.SearchEntries(term) + if err != nil { + return SearchErrMsg{Err: err} + } + return SearchResultMsg{Entries: entries} + } +} + +func SQLCmd(database *db.DB, query string) tea.Cmd { + return func() tea.Msg { + if database == nil { + return SearchResultMsg{} + } + entries, err := database.QueryEntries(query) + if err != nil { + return SearchErrMsg{Err: err} + } + return SearchResultMsg{Entries: entries} + } +} diff --git a/internal/ui/history/update.go b/internal/ui/history/update.go new file mode 100644 index 0000000..6b2febc --- /dev/null +++ b/internal/ui/history/update.go @@ -0,0 +1,303 @@ +package history + +import ( + "fmt" + "net/http" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/db" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" + diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" + replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" + "github.com/anotherhadi/spilltea/internal/util" +) + +type EntriesLoadedMsg struct { + Entries []db.Entry +} + +func LoadEntriesCmd(database *db.DB) tea.Cmd { + return func() tea.Msg { + if database == nil { + return EntriesLoadedMsg{} + } + entries, _ := database.ListEntries() + return EntriesLoadedMsg{Entries: entries} + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case EntriesLoadedMsg: + // Ignore background reloads while a search is active (but not during a mode switch reset). + if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") { + return m, nil + } + prevCursor := m.cursor + m.entries = msg.Entries + if m.cursor >= len(m.entries) { + m.cursor = len(m.entries) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + m.pager.SetTotalPages(len(m.entries)) + m.refreshListViewport() + m.refreshBody() + if m.cursor != prevCursor { + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) + } + + case SearchResultMsg: + m.entries = msg.Entries + m.cursor = 0 + m.searchErr = "" + m.pager.SetTotalPages(len(m.entries)) + m.refreshListViewport() + m.refreshBody() + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) + if m.searchKind == searchKindSQL { + m.acceptSearch() + } + + case SearchErrMsg: + m.searchErr = msg.Err.Error() + m.entries = nil + m.pager.SetTotalPages(0) + m.refreshListViewport() + m.refreshBody() + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) + + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelUp: + if msg.Mod.Contains(tea.ModShift) { + m.bodyViewport.ScrollLeft(6) + } else { + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1) + } + case tea.MouseWheelDown: + if msg.Mod.Contains(tea.ModShift) { + m.bodyViewport.ScrollRight(6) + } else { + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1) + } + case tea.MouseWheelLeft: + m.bodyViewport.ScrollLeft(6) + case tea.MouseWheelRight: + m.bodyViewport.ScrollRight(6) + } + + case tea.KeyPressMsg: + h := keys.Keys.History + g := keys.Keys.Global + + if m.searchKind != searchKindOff && !m.searchAccepted { + // Actively typing: only search navigation + accept/cancel. + switch { + case key.Matches(msg, g.Escape): + return m, m.clearSearch() + + case msg.String() == "enter": + if m.searchKind == searchKindSQL { + return m, SQLCmd(m.database, m.searchInput.Value()) + } + m.acceptSearch() + + case key.Matches(msg, g.Up): + if m.cursor > 0 { + m.cursor-- + m.refreshListViewport() + m.refreshBody() + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) + } + + case key.Matches(msg, g.Down): + if m.cursor < len(m.entries)-1 { + m.cursor++ + m.refreshListViewport() + m.refreshBody() + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) + } + + default: + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + if m.searchKind == searchKindFulltext { + return m, tea.Batch(cmd, SearchCmd(m.database, m.searchInput.Value())) + } + return m, cmd + } + return m, nil + } + + if m.searchKind != searchKindOff && m.searchAccepted { + // Filter accepted: Escape clears, all other shortcuts fall through. + if key.Matches(msg, g.Escape) { + return m, m.clearSearch() + } + } + + switch { + case key.Matches(msg, keys.Keys.History.Filter): + prev := m.searchKind + m.searchKind = searchKindFulltext + m.searchAccepted = false + m.searchInput.Placeholder = "filter requests..." + m.searchErr = "" + m.searchInput.Focus() + m.recalcSizes() + if prev != searchKindFulltext { + m.searchInput.SetValue("") + return m, LoadEntriesCmd(m.database) + } + + case key.Matches(msg, keys.Keys.History.SqlQuery): + prev := m.searchKind + m.searchKind = searchKindSQL + m.searchAccepted = false + m.searchInput.Placeholder = "status_code = 200 AND host LIKE '%.api.%'" + m.searchErr = "" + m.searchInput.Focus() + m.recalcSizes() + if prev != searchKindSQL { + m.searchInput.SetValue("") + return m, LoadEntriesCmd(m.database) + } + + case key.Matches(msg, g.Up): + if m.cursor > 0 { + m.cursor-- + m.refreshListViewport() + m.refreshBody() + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) + } + + case key.Matches(msg, g.Down): + if m.cursor < len(m.entries)-1 { + m.cursor++ + m.refreshListViewport() + m.refreshBody() + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) + } + + case key.Matches(msg, g.CycleFocus): + if m.focusedPanel == panelRequest { + m.focusedPanel = panelResponse + } else { + m.focusedPanel = panelRequest + } + m.refreshBody() + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) + + case key.Matches(msg, g.SendToReplay): + if len(m.entries) > 0 { + e := m.entries[m.cursor] + scheme := util.InferScheme(e.Host) + return m, func() tea.Msg { + return replayUI.SendToReplayMsg{ + Scheme: scheme, + Host: e.Host, + RequestRaw: e.RequestRaw, + } + } + } + + case key.Matches(msg, g.SendToDiff): + if len(m.entries) > 0 { + e := m.entries[m.cursor] + var raw, label string + if m.focusedPanel == panelResponse { + raw = e.ResponseRaw + label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode)) + } else { + raw = e.RequestRaw + label = e.Method + " " + e.Host + e.Path + } + return m, func() tea.Msg { + return diffUI.SendToDiffMsg{Label: label, Raw: raw} + } + } + + case key.Matches(msg, h.DeleteEntry): + if len(m.entries) > 0 { + id := m.entries[m.cursor].ID + if m.database != nil { + m.database.DeleteEntry(id) + } + return m, LoadEntriesCmd(m.database) + } + + case key.Matches(msg, h.DeleteAll): + if m.database != nil { + if m.searchKind != searchKindOff { + for _, e := range m.entries { + m.database.DeleteEntry(e.ID) + } + } else { + m.database.DeleteAllEntries() + } + } + return m, m.clearSearch() + + case key.Matches(msg, g.ScrollUp): + step := m.bodyViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step) + + case key.Matches(msg, g.ScrollDown): + step := m.bodyViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step) + + case key.Matches(msg, g.Left): + m.bodyViewport.ScrollLeft(6) + + case key.Matches(msg, g.Right): + m.bodyViewport.ScrollRight(6) + + case key.Matches(msg, keys.Keys.Global.Help): + m.help.ShowAll = !m.help.ShowAll + m.recalcSizes() + } + } + + return m, nil +} + +func (m *Model) refreshListViewport() { + if m.pager.PerPage > 0 { + m.pager.Page = m.cursor / m.pager.PerPage + m.pager.SetTotalPages(len(m.entries)) + } + m.listViewport.SetContent(m.renderList()) +} + +func (m *Model) refreshBody() { + if len(m.entries) == 0 { + m.bodyViewport.SetContent("") + return + } + e := m.entries[m.cursor] + var raw string + if m.focusedPanel == panelResponse { + raw = e.ResponseRaw + } else { + raw = e.RequestRaw + } + m.bodyViewport.SetContent(style.HighlightHTTP(raw)) +} diff --git a/internal/ui/history/view.go b/internal/ui/history/view.go new file mode 100644 index 0000000..5c826d7 --- /dev/null +++ b/internal/ui/history/view.go @@ -0,0 +1,150 @@ +package history + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" +) + +func (m Model) View() tea.View { + if m.width == 0 { + return tea.NewView("Loading...") + } + + listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35) + + content := lipgloss.JoinVertical(lipgloss.Left, + m.renderListPanel(m.width, listH), + m.renderBodyPanel(bodyH), + m.renderStatusBar(), + ) + return tea.NewView(content) +} + +func (m *Model) renderListPanel(w, h int) string { + s := style.S + dots := s.Faint.Render(m.pager.View()) + inner := lipgloss.JoinVertical(lipgloss.Left, + m.listViewport.View(), + lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), + ) + return style.RenderWithTitle(s.PanelFocused, icons.I.History+"History", inner, w, h) +} + +func (m *Model) renderBodyPanel(h int) string { + s := style.S + title := icons.I.Request + "Request" + if m.focusedPanel == panelResponse { + title = icons.I.Response + "Response" + } + return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h) +} + +func (m *Model) renderStatusBar() string { + s := style.S + pad := lipgloss.NewStyle().Padding(0, 1) + escKey := keys.Keys.Global.Escape.Help().Key + switch m.searchKind { + case searchKindFulltext: + filterKey := keys.Keys.History.Filter.Help().Key + if m.searchAccepted { + accent := lipgloss.NewStyle().Foreground(s.Primary) + filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear")) + return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width}))) + } + return pad.Render(s.Faint.Render(filterKey) + " " + m.searchInput.View()) + case searchKindSQL: + sqlKey := keys.Keys.History.SqlQuery.Help().Key + if m.searchAccepted { + accent := lipgloss.NewStyle().Foreground(s.Primary) + filterLine := pad.Render(accent.Render(sqlKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear")) + return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width}))) + } + return pad.Render(s.Faint.Render(sqlKey) + " " + m.searchInput.View()) + default: + return pad.Render(m.help.View(historyKeyMap{width: m.width})) + } +} + +func (m *Model) renderList() string { + s := style.S + if m.searchErr != "" { + return lipgloss.Place( + m.listViewport.Width(), m.listViewport.Height(), + lipgloss.Center, lipgloss.Center, + lipgloss.NewStyle().Foreground(s.Error).Render(m.searchErr), + ) + } + if len(m.entries) == 0 { + msg := " (⌐■_■)\nno history yet" + if m.searchKind != searchKindOff { + msg = "ʕノ•ᴥ•ʔノ ︵ ┻━┻\n no results" + } + return lipgloss.Place( + m.listViewport.Width(), m.listViewport.Height(), + lipgloss.Center, lipgloss.Center, + s.Faint.Render(msg), + ) + } + + start, end := m.pager.GetSliceBounds(len(m.entries)) + if start < 0 { + start = 0 + } + if end < start { + end = start + } + + var sb strings.Builder + for i, e := range m.entries[start:end] { + globalIdx := start + i + selected := globalIdx == m.cursor + + selBg := s.Selection + w := m.listViewport.Width() + + statusStr := fmt.Sprintf("%3d", e.StatusCode) + const fixedW = 2 + 7 + 1 + 3 + 1 + 10 + 1 + hostPathW := w - fixedW + if hostPathW < 0 { + hostPathW = 0 + } + + ts := e.Timestamp.Format("15:04:05") + statusSt := style.StatusStyle(e.StatusCode, 3) + + var line string + if selected { + bg := lipgloss.NewStyle().Background(selBg) + line = lipgloss.JoinHorizontal(lipgloss.Top, + bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"), + s.Method(e.Method).Background(selBg).Render(e.Method), + bg.Width(1).Render(""), + statusSt.Background(selBg).Render(statusStr), + bg.Width(1).Render(""), + bg.Foreground(s.Subtle).Width(10).Render(ts), + bg.Width(1).Render(""), + bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path), + ) + } else { + line = lipgloss.JoinHorizontal(lipgloss.Top, + " ", + s.Method(e.Method).Render(e.Method), + " ", + statusSt.Render(statusStr), + " ", + s.Faint.Width(10).Render(ts), + " ", + s.Bold.Render(e.Host), + s.Faint.Render(e.Path), + ) + } + sb.WriteString(line + "\n") + } + return sb.String() +} diff --git a/internal/ui/home/model.go b/internal/ui/home/model.go new file mode 100644 index 0000000..fded1ae --- /dev/null +++ b/internal/ui/home/model.go @@ -0,0 +1,339 @@ +package home + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/db" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" + "github.com/anotherhadi/spilltea/internal/ui/components/teapot" +) + +type itemKind int + +const ( + kindNew itemKind = iota + kindTemp + kindExisting +) + +type listItem struct { + kind itemKind + name string + path string + count int + modTime time.Time +} + +func (i listItem) icon() string { + ic := icons.I + switch i.kind { + case kindNew: + return ic.New + case kindTemp: + return ic.Temp + default: + return ic.Project + } +} + +func (i listItem) title() string { + switch i.kind { + case kindNew: + return "New Project" + case kindTemp: + return "Temporary Session" + default: + return i.name + } +} + +func (i listItem) description() string { + switch i.kind { + case kindNew: + return "create and name a new project" + case kindTemp: + return "isolated session, deleted on exit" + default: + date := i.modTime.Format("Jan 2, 2006") + if i.count == 1 { + return fmt.Sprintf("1 request · %s", date) + } + return fmt.Sprintf("%d requests · %s", i.count, date) + } +} + +// FilterValue contains only the text (no icon) so fuzzy match indices map +// directly onto title() and don't need an offset to account for icon width. +func (i listItem) FilterValue() string { return i.title() } + +type homeDelegate struct { + normalTitle lipgloss.Style + normalDesc lipgloss.Style + selectedTitle lipgloss.Style + selectedDesc lipgloss.Style + filterMatch lipgloss.Style +} + +func newHomeDelegate() homeDelegate { + s := style.S + leftBorder := lipgloss.Border{Left: "│"} + return homeDelegate{ + normalTitle: lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(4), + normalDesc: lipgloss.NewStyle().Foreground(s.Subtle).Faint(true).PaddingLeft(4), + selectedTitle: lipgloss.NewStyle(). + Border(leftBorder, false, false, false, true). + BorderForeground(s.Primary). + Foreground(s.Primary).Bold(true).PaddingLeft(3), + selectedDesc: lipgloss.NewStyle(). + Border(leftBorder, false, false, false, true). + BorderForeground(s.Primary). + Foreground(s.MutedFg).PaddingLeft(3), + filterMatch: lipgloss.NewStyle().Underline(true), + } +} + +func (d homeDelegate) Height() int { return 2 } +func (d homeDelegate) Spacing() int { return 1 } +func (d homeDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } + +func (d homeDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + li := item.(listItem) + selected := index == m.Index() + + // Apply match highlighting only to the title text + // separately so its width never shifts the highlight indices. + titleText := li.title() + if m.IsFiltered() { + if matches := m.MatchesForItem(index); len(matches) > 0 { + base := lipgloss.NewStyle() + titleText = lipgloss.StyleRunes(titleText, matches, d.filterMatch.Inherit(base), base) + } + } + + full := li.icon() + titleText + var titleLine, descLine string + if selected { + titleLine = d.selectedTitle.Render(full) + descLine = d.selectedDesc.Render(li.description()) + } else { + titleLine = d.normalTitle.Render(full) + descLine = d.normalDesc.Render(li.description()) + } + fmt.Fprintf(w, "%s\n%s", titleLine, descLine) +} + +type Project struct { + Name string + Path string + Count int + ModTime time.Time +} + +type inputMode int + +const ( + modeSelect inputMode = iota + modeNaming +) + +const ( + baseHeaderLines = 1 + 1 + 1 + 2 + teapotMinH = 28 // minimum terminal height to show the teapot + maxInnerW = 80 // max content width inside the padding box + maxInnerH = 50 // max content height inside the padding box +) + +type Model struct { + mode inputMode + list list.Model + projectDir string + nameInput textinput.Model + selected *Project + width int + height int + teapotFrame int +} + +// Selected returns the project chosen by the user, or nil if the program was +// quit without making a selection. +func (m Model) Selected() *Project { return m.selected } + +func New(projectDir string) Model { + projects := loadProjects(projectDir) + + l := list.New(buildItems(projects), newHomeDelegate(), 0, 0) + l.SetShowTitle(false) + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.SetFilteringEnabled(true) + l.KeyMap.Quit.SetEnabled(false) + l.KeyMap.ForceQuit.SetEnabled(false) + l.KeyMap.ShowFullHelp.SetEnabled(false) + l.KeyMap.CloseFullHelp.SetEnabled(false) + + ti := textinput.New() + ti.Placeholder = "my-project" + ti.CharLimit = 64 + ti.SetWidth(inputPanelMaxW - 2 - 4) + + return Model{ + projectDir: projectDir, + list: l, + nameInput: ti, + } +} + +func (m Model) Init() tea.Cmd { return teapotTick() } + +func (m Model) innerW() int { + w := m.width - 2 + if w > maxInnerW { + w = maxInnerW + } + if w < 0 { + return 0 + } + return w +} + +func (m Model) innerH() int { + h := m.height - 2 + if h > maxInnerH { + h = maxInnerH + } + if h < 0 { + return 0 + } + return h +} + +func (m Model) headerHeight() int { + if m.height > teapotMinH { + // teapot block replaces 1 \n (else branch) with frame \n's + \n\n + // net addition = FrameLines() (= frame_internal_\n + \n\n - else_\n) + return baseHeaderLines + teapot.FrameLines() + } + return baseHeaderLines +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + lw := m.listWidth() + lh := m.innerH() - m.headerHeight() - 1 + if lh < 0 { + lh = 0 + } + m.list.SetSize(lw, lh) + m.nameInput.SetWidth(inputPanelInnerW(m.innerW())) +} + +func (m Model) IsEditing() bool { return m.mode == modeNaming } + +func (m Model) listWidth() int { + return m.innerW() +} + +func inputPanelInnerW(termW int) int { + panelW := inputPanelMaxW + if termW < panelW+4 { + panelW = termW - 4 + } + if panelW < 10 { + panelW = 10 + } + return panelW - 2 - 4 // border (2) + padding (2×2) +} + +func loadProjects(projectDir string) []Project { + entries, err := os.ReadDir(projectDir) + if err != nil { + return nil + } + var projects []Project + for _, e := range entries { + if !e.IsDir() { + continue + } + dbPath := filepath.Join(projectDir, e.Name(), "data.db") + info, err := os.Stat(dbPath) + if err != nil { + continue + } + projects = append(projects, Project{ + Name: e.Name(), + Path: dbPath, + Count: db.CountEntriesAt(dbPath), + ModTime: info.ModTime(), + }) + } + sort.Slice(projects, func(i, j int) bool { + return projects[i].ModTime.After(projects[j].ModTime) + }) + return projects +} + +func buildItems(projects []Project) []list.Item { + items := []list.Item{ + listItem{kind: kindNew}, + listItem{kind: kindTemp}, + } + for _, p := range projects { + items = append(items, listItem{ + kind: kindExisting, + name: p.Name, + path: p.Path, + count: p.Count, + modTime: p.ModTime, + }) + } + return items +} + +func (m Model) renderHelpLine() string { + s := style.S + k := keys.Keys.Home + fs := m.list.FilterState() + + kStyle := lipgloss.NewStyle().Foreground(s.MutedFg).Inline(true) + dStyle := s.Faint.Inline(true) + + sep := s.Faint.Inline(true).Render(" • ") + item := func(keyStr, desc string) string { + return kStyle.Render(keyStr) + " " + dStyle.Render(desc) + } + binding := func(b key.Binding) string { + return item(b.Help().Key, b.Help().Desc) + } + + var parts []string + if fs == list.Filtering { + parts = append(parts, item("enter", "apply filter")) + parts = append(parts, item("esc", "cancel")) + } else { + parts = append(parts, item("↑/↓", "navigate")) + if fs == list.FilterApplied { + parts = append(parts, item("esc", "clear filter")) + } else { + parts = append(parts, binding(k.Filter)) + } + parts = append(parts, binding(k.Open)) + parts = append(parts, binding(k.Delete)) + parts = append(parts, item("q", "quit")) + } + + return strings.Join(parts, sep) +} diff --git a/internal/ui/home/update.go b/internal/ui/home/update.go new file mode 100644 index 0000000..9498425 --- /dev/null +++ b/internal/ui/home/update.go @@ -0,0 +1,180 @@ +package home + +import ( + crypto "crypto/rand" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/ui/components/teapot" +) + +type teapotTickMsg struct{} + +func teapotTick() tea.Cmd { + return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return teapotTickMsg{} + }) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.SetSize(ws.Width, ws.Height) + return m, nil + } + + if _, ok := msg.(teapotTickMsg); ok { + frames := teapot.TeapotFrames() + m.teapotFrame = (m.teapotFrame + 1) % len(frames) + return m, teapotTick() + } + + if m.mode == modeNaming { + if kp, ok := msg.(tea.KeyPressMsg); ok { + return m.updateNaming(kp) + } + return m, nil + } + + if kp, ok := msg.(tea.KeyPressMsg); ok { + if !m.list.SettingFilter() { + if key.Matches(kp, keys.Keys.Global.Quit) { + return m, tea.Quit + } + if key.Matches(kp, keys.Keys.Home.Open) { + return m.handleSelection() + } + if key.Matches(kp, keys.Keys.Home.Delete) { + return m.deleteSelected() + } + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m Model) handleSelection() (tea.Model, tea.Cmd) { + item, ok := m.list.SelectedItem().(listItem) + if !ok { + return m, nil + } + switch item.kind { + case kindNew: + m.mode = modeNaming + m.nameInput.SetValue("") + return m, m.nameInput.Focus() + case kindTemp: + dir := tempDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return m, nil + } + initProjectFiles(dir) + m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")} + return m, tea.Quit + default: + m.selected = &Project{Name: item.name, Path: item.path} + return m, tea.Quit + } +} + +func (m Model) deleteSelected() (tea.Model, tea.Cmd) { + item, ok := m.list.SelectedItem().(listItem) + if !ok || item.kind != kindExisting { + return m, nil + } + dir := filepath.Dir(item.path) // parent dir of data.db + os.RemoveAll(dir) + idx := m.list.GlobalIndex() + m.list.RemoveItem(idx) + if idx > 0 && idx >= len(m.list.Items()) { + m.list.Select(idx - 1) + } + return m, nil +} + +func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Keys.Global.Escape): + m.mode = modeSelect + m.nameInput.Blur() + return m, nil + case msg.String() == "enter": + name := m.nameInput.Value() + if name == "" { + return m, nil + } + m.mode = modeSelect + m.nameInput.Blur() + dir := filepath.Join(m.projectDir, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + return m, nil + } + initProjectFiles(dir) + m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")} + return m, tea.Quit + default: + var cmd tea.Cmd + m.nameInput, cmd = m.nameInput.Update(msg) + m.nameInput.SetValue(sanitizeName(m.nameInput.Value())) + return m, cmd + } +} + +func sanitizeName(s string) string { + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } + } + return b.String() +} + +func IsValidProjectName(s string) bool { + if s == "tmp" { + return true + } + return s != "" && s == sanitizeName(s) +} + +func OpenProject(projectDir, name string) (*Project, error) { + if name == "tmp" { + dir := tempDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + initProjectFiles(dir) + return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil + } + dir := filepath.Join(projectDir, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + initProjectFiles(dir) + return &Project{Name: name, Path: filepath.Join(dir, "data.db")}, nil +} + +func tempDir() string { + b := make([]byte, 4) + _, _ = crypto.Read(b) + return filepath.Join(os.TempDir(), "spilltea", fmt.Sprintf("%08x", b)) +} + +func initProjectFiles(dir string) { + for _, name := range []string{"data.db", "logs.log"} { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); os.IsNotExist(err) { + f, err := os.Create(p) + if err == nil { + f.Close() + } + } + } +} diff --git a/internal/ui/home/view.go b/internal/ui/home/view.go new file mode 100644 index 0000000..12f4227 --- /dev/null +++ b/internal/ui/home/view.go @@ -0,0 +1,101 @@ +package home + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/style" + "github.com/anotherhadi/spilltea/internal/ui/components/teapot" +) + +const inputPanelMaxW = 44 + +func (m Model) View() tea.View { + s := style.S + iw := m.innerW() + + var sb strings.Builder + sb.WriteString("\n") + if m.height > teapotMinH { + frames := teapot.TeapotFrames() + frame := lipgloss.NewStyle().Foreground(s.Primary).Render(frames[m.teapotFrame]) + sb.WriteString(center(iw, frame)) + sb.WriteString("\n\n") + } else { + sb.WriteString("\n") + } + sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(s.Primary).Render("SPILLTEA"))) + sb.WriteString("\n") + sb.WriteString(center(iw, s.Faint.Render("choose a project to get started"))) + sb.WriteString("\n\n") + + if m.mode == modeNaming { + sb.WriteString(m.renderNamingPanel()) + } else { + lw := m.listWidth() + leftPad := (iw - lw) / 2 + sb.WriteString(padLeft(m.list.View(), leftPad)) + sb.WriteString("\n") + sb.WriteString(center(iw, m.renderHelpLine())) + } + + box := lipgloss.NewStyle().Padding(1, 1).Render(sb.String()) + content := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) + + v := tea.NewView(content) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} + +func (m Model) renderNamingPanel() string { + s := style.S + iw := m.innerW() + + panelW := inputPanelMaxW + if iw < panelW+4 { + panelW = iw - 4 + } + if panelW < 10 { + panelW = 10 + } + innerW := inputPanelInnerW(iw) + inputLine := lipgloss.NewStyle().Width(innerW).Render(m.nameInput.View()) + + label := lipgloss.NewStyle().Foreground(s.MutedFg).Render("Project name") + panel := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(s.Primary). + Padding(1, 2). + Width(panelW). + Render(label + "\n" + inputLine) + + hint := s.Faint.Render("[enter] confirm [esc] cancel") + + var sb strings.Builder + sb.WriteString(center(iw, panel)) + sb.WriteString("\n") + sb.WriteString(center(iw, hint)) + sb.WriteString("\n") + return sb.String() +} + +// padLeft prepends n spaces to every non-empty line. +func padLeft(content string, n int) string { + if n <= 0 { + return content + } + pad := strings.Repeat(" ", n) + lines := strings.Split(content, "\n") + for i, l := range lines { + if l != "" { + lines[i] = pad + l + } + } + return strings.Join(lines, "\n") +} + +func center(width int, s string) string { + return lipgloss.PlaceHorizontal(width, lipgloss.Center, s) +} diff --git a/internal/ui/intercept/helpers.go b/internal/ui/intercept/helpers.go new file mode 100644 index 0000000..75cd898 --- /dev/null +++ b/internal/ui/intercept/helpers.go @@ -0,0 +1,384 @@ +package intercept + +import ( + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/anotherhadi/spilltea/internal/intercept" + "github.com/anotherhadi/spilltea/internal/style" +) + +func formatRawRequest(req *intercept.PendingRequest) string { + r := req.Flow.Request + var sb strings.Builder + + fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto) + + keys := make([]string, 0, len(r.Header)) + for k := range r.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + for _, v := range r.Header[k] { + fmt.Fprintf(&sb, "%s: %s\n", k, v) + } + } + + sb.WriteString("\n") + if len(r.Body) > 0 { + sb.Write(r.Body) + } + return sb.String() +} + +func formatRawResponse(resp *intercept.PendingResponse) string { + r := resp.Flow.Response + if r == nil { + return "(no response)" + } + var sb strings.Builder + + proto := resp.Flow.Request.Proto + if proto == "" { + proto = "HTTP/1.1" + } + fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode)) + + keys := make([]string, 0, len(r.Header)) + for k := range r.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + for _, v := range r.Header[k] { + fmt.Fprintf(&sb, "%s: %s\n", k, v) + } + } + + sb.WriteString("\n") + if len(r.Body) > 0 { + sb.Write(r.Body) + } + return sb.String() +} + +func parseRawRequest(content string, req *intercept.PendingRequest) { + r := req.Flow.Request + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + if len(lines) == 0 { + return + } + + parts := strings.SplitN(lines[0], " ", 3) + if len(parts) >= 1 { + r.Method = strings.TrimSpace(parts[0]) + } + if len(parts) >= 2 { + if u, err := url.ParseRequestURI(strings.TrimSpace(parts[1])); err == nil { + r.URL.Path = u.Path + r.URL.RawQuery = u.RawQuery + } + } + if len(parts) >= 3 { + r.Proto = strings.TrimSpace(parts[2]) + } + + r.Header = make(http.Header) + i := 1 + for i < len(lines) { + line := strings.TrimRight(lines[i], "\r") + if line == "" { + i++ + break + } + if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 { + r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])) + } + i++ + } + + if i < len(lines) { + body := strings.Join(lines[i:], "\n") + body = strings.TrimRight(body, "\n") + if body != "" { + r.Body = []byte(body) + } else { + r.Body = nil + } + } +} + +func parseRawResponse(content string, resp *intercept.PendingResponse) { + r := resp.Flow.Response + if r == nil { + return + } + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + if len(lines) == 0 { + return + } + + parts := strings.SplitN(lines[0], " ", 3) + if len(parts) >= 2 { + if code, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { + r.StatusCode = code + } + } + + r.Header = make(http.Header) + i := 1 + for i < len(lines) { + line := strings.TrimRight(lines[i], "\r") + if line == "" { + i++ + break + } + if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 { + r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])) + } + i++ + } + + if i < len(lines) { + body := strings.Join(lines[i:], "\n") + body = strings.TrimRight(body, "\n") + if body != "" { + r.Body = []byte(body) + } else { + r.Body = nil + } + } + r.Header.Set("Content-Length", strconv.Itoa(len(r.Body))) +} + +func (m *Model) currentLabel() string { + if m.captureResponse && m.focusedPanel == panelResponses { + if len(m.responseQueue) == 0 { + return "" + } + resp := m.responseQueue[m.responseCursor] + code := 0 + if resp.Flow.Response != nil { + code = resp.Flow.Response.StatusCode + } + return fmt.Sprintf("%d %s %s", code, http.StatusText(code), resp.Flow.Request.URL.RequestURI()) + } + if len(m.queue) == 0 { + return "" + } + req := m.queue[m.cursor] + return req.Flow.Request.Method + " " + req.Flow.Request.URL.RequestURI() +} + +func (m *Model) removeFromQueue(index int) { + m.queue = append(m.queue[:index], m.queue[index+1:]...) + if m.cursor >= len(m.queue) && m.cursor > 0 { + m.cursor-- + } + m.refreshListViewport() + m.refreshBody() +} + +func (m *Model) removeFromResponseQueue(index int) { + m.responseQueue = append(m.responseQueue[:index], m.responseQueue[index+1:]...) + if m.responseCursor >= len(m.responseQueue) && m.responseCursor > 0 { + m.responseCursor-- + } + m.refreshResponseListViewport() + m.refreshBody() +} + +func (m *Model) applyAndDecide(d intercept.Decision) { + if len(m.queue) == 0 { + return + } + req := m.queue[m.cursor] + if d == intercept.Forward { + if edited, ok := m.pendingEdits[req]; ok { + parseRawRequest(edited, req) + } + } + delete(m.pendingEdits, req) + m.broker.Decide(req, d) + m.removeFromQueue(m.cursor) +} + +func (m *Model) applyAndDecideResponse(d intercept.Decision) { + if len(m.responseQueue) == 0 { + return + } + resp := m.responseQueue[m.responseCursor] + if d == intercept.Forward { + if edited, ok := m.pendingResponseEdits[resp]; ok { + parseRawResponse(edited, resp) + } + } + delete(m.pendingResponseEdits, resp) + m.broker.DecideResponse(resp, d) + m.removeFromResponseQueue(m.responseCursor) +} + +func (m *Model) listHalfWidths() (leftW, rightW int) { + leftW = m.width / 2 + rightW = m.width - leftW + return +} + +func (m *Model) recalcSizes() { + m.help.SetWidth(m.width - 2) + + listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35) + + bodyInner := m.width - 2 + if bodyInner < 0 { + bodyInner = 0 + } + bodyVH := style.PanelContentH(bodyH) + + m.textarea.SetWidth(bodyInner) + m.textarea.SetHeight(bodyVH) + m.bodyViewport.SetWidth(bodyInner) + m.bodyViewport.SetHeight(bodyVH) + + listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row + if listVH < 0 { + listVH = 0 + } + + if m.captureResponse { + leftW, rightW := m.listHalfWidths() + leftInner := leftW - 2 + rightInner := rightW - 2 + if leftInner < 0 { + leftInner = 0 + } + if rightInner < 0 { + rightInner = 0 + } + + m.listViewport.SetWidth(leftInner) + m.listViewport.SetHeight(listVH) + m.pager.PerPage = listVH + if m.pager.PerPage < 1 { + m.pager.PerPage = 1 + } + + m.responseViewport.SetWidth(rightInner) + m.responseViewport.SetHeight(listVH) + m.responsePager.PerPage = listVH + if m.responsePager.PerPage < 1 { + m.responsePager.PerPage = 1 + } + } else { + listInner := m.width - 2 + if listInner < 0 { + listInner = 0 + } + + m.listViewport.SetWidth(listInner) + m.listViewport.SetHeight(listVH) + m.pager.PerPage = listVH + if m.pager.PerPage < 1 { + m.pager.PerPage = 1 + } + } + + m.refreshListViewport() + m.refreshResponseListViewport() + m.refreshBody() +} + +func (m *Model) refreshListViewport() { + if m.pager.PerPage > 0 { + m.pager.Page = m.cursor / m.pager.PerPage + m.pager.SetTotalPages(len(m.queue)) + } + m.listViewport.SetContent(m.renderList()) +} + +func (m *Model) refreshResponseListViewport() { + if m.responsePager.PerPage > 0 { + m.responsePager.Page = m.responseCursor / m.responsePager.PerPage + m.responsePager.SetTotalPages(len(m.responseQueue)) + } + m.responseViewport.SetContent(m.renderResponseList()) +} + +// saveCurrentEdit must only be called when exiting edit mode. +func (m *Model) saveCurrentEdit() { + if m.captureResponse && m.focusedPanel == panelResponses { + if len(m.responseQueue) > 0 { + m.pendingResponseEdits[m.responseQueue[m.responseCursor]] = m.textarea.Value() + } + } else { + if len(m.queue) > 0 { + m.pendingEdits[m.queue[m.cursor]] = m.textarea.Value() + } + } +} + +const maxInlineEditBytes = 32 * 1024 + +func (m *Model) loadIntoTextarea() { + if m.captureResponse && m.focusedPanel == panelResponses { + if len(m.responseQueue) == 0 { + return + } + resp := m.responseQueue[m.responseCursor] + if edited, ok := m.pendingResponseEdits[resp]; ok { + m.textarea.SetValue(edited) + } else { + m.textarea.SetValue(formatRawResponse(resp)) + } + } else { + if len(m.queue) == 0 { + return + } + req := m.queue[m.cursor] + if edited, ok := m.pendingEdits[req]; ok { + m.textarea.SetValue(edited) + } else { + m.textarea.SetValue(formatRawRequest(req)) + } + } +} + +// refreshBody does not touch the textarea - it is only loaded when entering edit mode. +func (m *Model) refreshBody() { + var raw string + if m.captureResponse && m.focusedPanel == panelResponses { + if len(m.responseQueue) == 0 { + m.bodyViewport.SetContent("") + return + } + resp := m.responseQueue[m.responseCursor] + if edited, ok := m.pendingResponseEdits[resp]; ok { + raw = edited + } else { + raw = formatRawResponse(resp) + } + } else { + if len(m.queue) == 0 { + m.bodyViewport.SetContent("") + return + } + req := m.queue[m.cursor] + if edited, ok := m.pendingEdits[req]; ok { + raw = edited + } else { + raw = formatRawRequest(req) + } + } + m.bodyViewport.SetContent(style.HighlightHTTP(raw)) + m.bodyViewport.SetYOffset(0) + m.bodyViewport.SetXOffset(0) +} + +func (m *Model) refreshBodyViewport() { + m.bodyViewport.SetContent(style.HighlightHTTP(m.textarea.Value())) +} diff --git a/internal/ui/intercept/keymap.go b/internal/ui/intercept/keymap.go new file mode 100644 index 0000000..ca7513d --- /dev/null +++ b/internal/ui/intercept/keymap.go @@ -0,0 +1,34 @@ +package intercept + +import ( + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" +) + +func newHelp() help.Model { return style.NewHelp() } + +type interceptKeyMap struct{ width int } + +func iconBinding(b key.Binding, icon string) key.Binding { + h := b.Help() + return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(h.Key, icon+h.Desc)) +} + +func (interceptKeyMap) ShortHelp() []key.Binding { + ic := keys.Keys.Intercept + i := icons.I + return []key.Binding{ + iconBinding(ic.Forward, i.Forward), + iconBinding(ic.Drop, i.Drop), + iconBinding(ic.Edit, i.Edit), + keys.Keys.Global.Help, + } +} + +func (m interceptKeyMap) FullHelp() [][]key.Binding { + all := append(keys.Keys.Intercept.Bindings(), keys.Keys.Global.Bindings()...) + return keys.ChunkByWidth(all, m.width) +} diff --git a/internal/ui/intercept/model.go b/internal/ui/intercept/model.go new file mode 100644 index 0000000..9af952d --- /dev/null +++ b/internal/ui/intercept/model.go @@ -0,0 +1,118 @@ +package intercept + +import ( + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/paginator" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/config" + "github.com/anotherhadi/spilltea/internal/intercept" + "github.com/anotherhadi/spilltea/internal/style" +) + +type panel int + +const ( + panelRequests panel = iota + panelResponses +) + +type Model struct { + broker *intercept.Broker + queue []*intercept.PendingRequest + cursor int + + captureResponse bool + focusedPanel panel + responseQueue []*intercept.PendingResponse + responseCursor int + + editing bool + autoForward bool + pendingEdits map[*intercept.PendingRequest]string + pendingResponseEdits map[*intercept.PendingResponse]string + + listViewport viewport.Model + responseViewport viewport.Model + bodyViewport viewport.Model + textarea textarea.Model + pager paginator.Model + responsePager paginator.Model + help help.Model + + width int + height int +} + +func New(broker *intercept.Broker) Model { + cfg := config.Global + ta := style.NewTextarea(false) + ta.Blur() + + lv := style.NewViewport() + rv := style.NewViewport() + bv := style.NewViewport() + p := style.NewPaginator() + rp := style.NewPaginator() + + broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse) + + return Model{ + broker: broker, + autoForward: cfg.Intercept.DefaultAutoForward, + captureResponse: cfg.Intercept.DefaultCaptureResponse, + listViewport: lv, + responseViewport: rv, + bodyViewport: bv, + textarea: ta, + pager: p, + responsePager: rp, + help: newHelp(), + pendingEdits: make(map[*intercept.PendingRequest]string), + pendingResponseEdits: make(map[*intercept.PendingResponse]string), + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) IsEditing() bool { return m.editing } + +func (m Model) CurrentScheme() string { + if len(m.queue) == 0 { + return "https" + } + scheme := m.queue[m.cursor].Flow.Request.URL.Scheme + if scheme == "" { + return "https" + } + return scheme +} + +func (m Model) CurrentRaw() string { + if m.captureResponse && m.focusedPanel == panelResponses { + if len(m.responseQueue) == 0 { + return "" + } + resp := m.responseQueue[m.responseCursor] + if edited, ok := m.pendingResponseEdits[resp]; ok { + return edited + } + return formatRawResponse(resp) + } + if len(m.queue) == 0 { + return "" + } + req := m.queue[m.cursor] + if edited, ok := m.pendingEdits[req]; ok { + return edited + } + return formatRawRequest(req) +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.recalcSizes() +} + diff --git a/internal/ui/intercept/update.go b/internal/ui/intercept/update.go new file mode 100644 index 0000000..40796e0 --- /dev/null +++ b/internal/ui/intercept/update.go @@ -0,0 +1,296 @@ +package intercept + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/intercept" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/util" + diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" + replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case intercept.RequestArrivedMsg: + if m.autoForward { + m.broker.Decide(msg.Req, intercept.Forward) + break + } + wasEmpty := len(m.queue) == 0 + m.queue = append(m.queue, msg.Req) + m.refreshListViewport() + if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) { + m.refreshBody() + } + + case intercept.ResponseArrivedMsg: + wasEmpty := len(m.responseQueue) == 0 + m.responseQueue = append(m.responseQueue, msg.Resp) + m.refreshResponseListViewport() + if wasEmpty && m.captureResponse && m.focusedPanel == panelResponses { + m.refreshBody() + } + + case util.EditorFinishedMsg: + if msg.Err == nil && msg.Content != "" { + m.textarea.SetValue(msg.Content) + m.refreshBodyViewport() + } + + case tea.MouseWheelMsg: + if !m.editing { + switch msg.Button { + case tea.MouseWheelUp: + if msg.Mod.Contains(tea.ModShift) { + m.bodyViewport.ScrollLeft(6) + } else { + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1) + } + case tea.MouseWheelDown: + if msg.Mod.Contains(tea.ModShift) { + m.bodyViewport.ScrollRight(6) + } else { + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1) + } + case tea.MouseWheelLeft: + m.bodyViewport.ScrollLeft(6) + case tea.MouseWheelRight: + m.bodyViewport.ScrollRight(6) + } + } + + case tea.KeyPressMsg: + if m.editing { + return m.updateEditMode(msg, &cmds) + } + return m.updateNormalMode(msg, &cmds) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) { + onResponses := m.captureResponse && m.focusedPanel == panelResponses + + switch { + case key.Matches(msg, keys.Keys.Global.Up): + if onResponses { + if m.responseCursor > 0 { + m.responseCursor-- + m.refreshResponseListViewport() + m.refreshBody() + } + } else { + if m.cursor > 0 { + m.cursor-- + m.refreshListViewport() + m.refreshBody() + } + } + + case key.Matches(msg, keys.Keys.Global.Down): + if onResponses { + if m.responseCursor < len(m.responseQueue)-1 { + m.responseCursor++ + m.refreshResponseListViewport() + m.refreshBody() + } + } else { + if m.cursor < len(m.queue)-1 { + m.cursor++ + m.refreshListViewport() + m.refreshBody() + } + } + + case key.Matches(msg, keys.Keys.Global.CycleFocus): + if m.captureResponse { + if m.focusedPanel == panelRequests { + m.focusedPanel = panelResponses + } else { + m.focusedPanel = panelRequests + } + m.refreshBody() + } + + case key.Matches(msg, keys.Keys.Global.ScrollUp): + step := m.bodyViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step) + + case key.Matches(msg, keys.Keys.Global.ScrollDown): + step := m.bodyViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step) + + case key.Matches(msg, keys.Keys.Global.Left): + m.bodyViewport.ScrollLeft(6) + + case key.Matches(msg, keys.Keys.Global.Right): + m.bodyViewport.ScrollRight(6) + + case key.Matches(msg, keys.Keys.Global.Quit): + return m, tea.Quit + + case key.Matches(msg, keys.Keys.Intercept.UndoEdits): + if onResponses { + if len(m.responseQueue) > 0 { + delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor]) + m.refreshBody() + } + } else { + if len(m.queue) > 0 { + delete(m.pendingEdits, m.queue[m.cursor]) + m.refreshBody() + } + } + + case key.Matches(msg, keys.Keys.Intercept.AutoForward): + m.autoForward = !m.autoForward + if m.autoForward { + for len(m.queue) > 0 { + m.applyAndDecide(intercept.Forward) + } + } + + case key.Matches(msg, keys.Keys.Intercept.CaptureResponse): + m.captureResponse = !m.captureResponse + m.broker.SetCaptureResponse(m.captureResponse) + if !m.captureResponse { + for len(m.responseQueue) > 0 { + m.broker.DecideResponse(m.responseQueue[0], intercept.Forward) + m.responseQueue = m.responseQueue[1:] + } + m.responseCursor = 0 + m.focusedPanel = panelRequests + } + m.recalcSizes() + + case key.Matches(msg, keys.Keys.Global.Help): + m.help.ShowAll = !m.help.ShowAll + m.recalcSizes() + + case key.Matches(msg, keys.Keys.Intercept.Forward): + if onResponses { + m.applyAndDecideResponse(intercept.Forward) + } else { + m.applyAndDecide(intercept.Forward) + } + + case key.Matches(msg, keys.Keys.Intercept.ForwardAll): + if onResponses { + for len(m.responseQueue) > 0 { + m.applyAndDecideResponse(intercept.Forward) + } + } else { + for len(m.queue) > 0 { + m.applyAndDecide(intercept.Forward) + } + } + + case key.Matches(msg, keys.Keys.Intercept.Drop): + if onResponses { + m.applyAndDecideResponse(intercept.Drop) + } else { + m.applyAndDecide(intercept.Drop) + } + + case key.Matches(msg, keys.Keys.Intercept.DropAll): + if onResponses { + for len(m.responseQueue) > 0 { + m.applyAndDecideResponse(intercept.Drop) + } + } else { + for len(m.queue) > 0 { + m.applyAndDecide(intercept.Drop) + } + } + + case key.Matches(msg, keys.Keys.Intercept.Edit): + hasItem := (!onResponses && len(m.queue) > 0) || (onResponses && len(m.responseQueue) > 0) + if hasItem { + raw := m.CurrentRaw() + if len(raw) > maxInlineEditBytes { + return m, util.OpenExternalEditor(raw) + } + m.loadIntoTextarea() + m.editing = true + m.textarea.Focus() + } + + case key.Matches(msg, keys.Keys.Intercept.EditExternal): + if !onResponses && len(m.queue) > 0 { + return m, util.OpenExternalEditor(formatRawRequest(m.queue[m.cursor])) + } + if onResponses && len(m.responseQueue) > 0 { + return m, util.OpenExternalEditor(formatRawResponse(m.responseQueue[m.responseCursor])) + } + + case key.Matches(msg, keys.Keys.Global.SendToReplay): + if !onResponses && len(m.queue) > 0 { + req := m.queue[m.cursor] + raw := m.CurrentRaw() + scheme := req.Flow.Request.URL.Scheme + if scheme == "" { + scheme = "https" + } + return m, func() tea.Msg { + return replayUI.SendToReplayMsg{ + Scheme: scheme, + Host: req.Flow.Request.URL.Host, + RequestRaw: raw, + } + } + } + + case key.Matches(msg, keys.Keys.Global.SendToDiff): + raw := m.CurrentRaw() + if raw != "" { + label := m.currentLabel() + return m, func() tea.Msg { + return diffUI.SendToDiffMsg{Label: label, Raw: raw} + } + } + } + + return m, tea.Batch(*cmds...) +} + +func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) { + onResponses := m.captureResponse && m.focusedPanel == panelResponses + + switch { + case key.Matches(msg, keys.Keys.Global.Escape): + m.saveCurrentEdit() + m.editing = false + m.textarea.Blur() + m.refreshBodyViewport() + + case key.Matches(msg, keys.Keys.Intercept.UndoEdits): + if onResponses { + if len(m.responseQueue) > 0 { + delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor]) + m.textarea.SetValue(formatRawResponse(m.responseQueue[m.responseCursor])) + } + } else { + if len(m.queue) > 0 { + delete(m.pendingEdits, m.queue[m.cursor]) + m.textarea.SetValue(formatRawRequest(m.queue[m.cursor])) + } + } + + default: + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + *cmds = append(*cmds, cmd) + } + + return m, tea.Batch(*cmds...) +} diff --git a/internal/ui/intercept/view.go b/internal/ui/intercept/view.go new file mode 100644 index 0000000..b114a15 --- /dev/null +++ b/internal/ui/intercept/view.go @@ -0,0 +1,220 @@ +package intercept + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/style" +) + +func (m Model) View() tea.View { + if m.width == 0 { + return tea.NewView("Loading...") + } + + listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35) + + var listRow string + if m.captureResponse { + leftW, rightW := m.listHalfWidths() + listRow = lipgloss.JoinHorizontal(lipgloss.Top, + m.renderListPanel(leftW, listH), + m.renderResponseListPanel(rightW, listH), + ) + } else { + listRow = m.renderListPanel(m.width, listH) + } + + content := lipgloss.JoinVertical(lipgloss.Left, + listRow, + m.renderBodyPanel(bodyH), + m.renderStatusBar(), + ) + return tea.NewView(content) +} + +func (m *Model) renderListPanel(w, h int) string { + s := style.S + + focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests) + border := s.Panel + if focused { + border = s.PanelFocused + } + + dots := s.Faint.Render(m.pager.View()) + inner := lipgloss.JoinVertical(lipgloss.Left, + m.listViewport.View(), + lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), + ) + + title := icons.I.Request + "Requests" + if m.autoForward { + title += " [auto forward]" + } + return style.RenderWithTitle(border, title, inner, w, h) +} + +func (m *Model) renderResponseListPanel(w, h int) string { + s := style.S + + focused := !m.editing && m.focusedPanel == panelResponses + border := s.Panel + if focused { + border = s.PanelFocused + } + + dots := s.Faint.Render(m.responsePager.View()) + inner := lipgloss.JoinVertical(lipgloss.Left, + m.responseViewport.View(), + lipgloss.PlaceHorizontal(m.responseViewport.Width(), lipgloss.Center, dots), + ) + + return style.RenderWithTitle(border, icons.I.Response+"Responses", inner, w, h) +} + +func (m *Model) renderBodyPanel(h int) string { + s := style.S + + var body string + if m.editing { + body = m.textarea.View() + } else { + body = m.bodyViewport.View() + } + + border := s.Panel + if m.editing { + border = s.PanelFocused + } + + title := icons.I.Detail + "Details" + return style.RenderWithTitle(border, title, body, m.width, h) +} + +func (m *Model) renderStatusBar() string { + return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(interceptKeyMap{width: m.width})) +} + +func (m *Model) renderList() string { + if len(m.queue) == 0 { + return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (。◕‿‿◕。)\nwaiting for a request")) + } + + s := style.S + start, end := m.pager.GetSliceBounds(len(m.queue)) + if start < 0 { + start = 0 + } + if end < start { + end = start + } + + var sb strings.Builder + for i, req := range m.queue[start:end] { + globalIdx := start + i + r := req.Flow.Request + path := r.URL.Path + if path == "" { + path = "/" + } + + selected := globalIdx == m.cursor + selBg := s.Selection + + w := m.listViewport.Width() + const fixedW = 2 + 7 + 2 + hostPathW := w - fixedW + if hostPathW < 0 { + hostPathW = 0 + } + + var line string + if selected { + bg := lipgloss.NewStyle().Background(selBg) + line = lipgloss.JoinHorizontal(lipgloss.Top, + bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"), + s.Method(r.Method).Background(selBg).Render(r.Method), + bg.Width(2).Render(""), + bg.Bold(true).Width(hostPathW).Render(r.URL.Host+path), + ) + } else { + line = lipgloss.JoinHorizontal(lipgloss.Top, + " ", + s.Method(r.Method).Render(r.Method), + s.Faint.Render(" "), + s.Bold.Render(r.URL.Host), + s.Faint.Render(path), + ) + } + sb.WriteString(line + "\n") + } + return sb.String() +} + +func (m *Model) renderResponseList() string { + if len(m.responseQueue) == 0 { + return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (҂◡_◡)\nno response yet")) + } + + s := style.S + start, end := m.responsePager.GetSliceBounds(len(m.responseQueue)) + if start < 0 { + start = 0 + } + if end < start { + end = start + } + + var sb strings.Builder + for i, resp := range m.responseQueue[start:end] { + globalIdx := start + i + f := resp.Flow + path := f.Request.URL.Path + if path == "" { + path = "/" + } + + code := 0 + if f.Response != nil { + code = f.Response.StatusCode + } + statusStr := fmt.Sprintf("%d", code) + + selected := globalIdx == m.responseCursor + selBg := s.Selection + + statusSt := style.StatusStyle(code, 7) + + w := m.responseViewport.Width() + const fixedW = 2 + 7 + 2 + hostPathW := w - fixedW + if hostPathW < 0 { + hostPathW = 0 + } + + var line string + if selected { + bg := lipgloss.NewStyle().Background(selBg) + line = lipgloss.JoinHorizontal(lipgloss.Top, + bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"), + statusSt.Background(selBg).Render(statusStr), + bg.Width(2).Render(""), + bg.Bold(true).Width(hostPathW).Render(f.Request.URL.Host+path), + ) + } else { + line = lipgloss.JoinHorizontal(lipgloss.Top, + " ", + statusSt.Render(statusStr), + s.Faint.Render(" "), + s.Bold.Render(f.Request.URL.Host), + s.Faint.Render(path), + ) + } + sb.WriteString(line + "\n") + } + return sb.String() +} diff --git a/internal/ui/plugins/model.go b/internal/ui/plugins/model.go new file mode 100644 index 0000000..14c7ea9 --- /dev/null +++ b/internal/ui/plugins/model.go @@ -0,0 +1,180 @@ +package plugins + +import ( + "os" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/paginator" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/plugins" + "github.com/anotherhadi/spilltea/internal/style" +) + +type Model struct { + manager *plugins.Manager + items []plugins.Info + cursor int + editing bool + filter string + filtered []plugins.Info + + listViewport viewport.Model + textarea textarea.Model + filterInput textinput.Model + filtering bool + pager paginator.Model + help help.Model + + width int + height int +} + +func New(mgr *plugins.Manager) Model { + ta := style.NewTextarea(false) + ta.Placeholder = "plugin configuration..." + ta.Blur() + + fi := textinput.New() + fi.Prompt = "" + + return Model{ + manager: mgr, + listViewport: style.NewViewport(), + textarea: ta, + filterInput: fi, + pager: style.NewPaginator(), + help: style.NewHelp(), + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) IsEditing() bool { return m.editing || m.filtering } + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.recalcSizes() +} + +func (m *Model) recalcSizes() { + if m.width == 0 { + return + } + m.help.SetWidth(m.width - 2) + + listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4) + + inner := m.width - 2 + if inner < 0 { + inner = 0 + } + + listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row + if listVH < 0 { + listVH = 0 + } + m.listViewport.SetWidth(inner) + m.listViewport.SetHeight(listVH) + m.pager.PerPage = listVH + if m.pager.PerPage < 1 { + m.pager.PerPage = 1 + } + + m.filterInput.SetWidth(inner - 2) + m.textarea.SetWidth(max(1, inner-2)) + m.textarea.SetHeight(max(3, detailH-6)) + + m.refreshListViewport() +} + +// Refresh reloads the plugin list from the manager. +func (m *Model) Refresh() { + if m.manager == nil { + return + } + pl := m.manager.GetPlugins() + m.items = make([]plugins.Info, len(pl)) + for i, p := range pl { + m.items[i] = p.Info() + } + m.applyFilter() +} + +func (m *Model) applyFilter() { + if m.filter == "" { + m.filtered = m.items + } else { + f := strings.ToLower(m.filter) + filtered := make([]plugins.Info, 0, len(m.items)) + for _, p := range m.items { + if strings.Contains(strings.ToLower(p.Name), f) { + filtered = append(filtered, p) + } + } + m.filtered = filtered + } + m.pager.SetTotalPages(len(m.filtered)) + if m.cursor >= len(m.filtered) { + m.cursor = max(0, len(m.filtered)-1) + } + m.refreshListViewport() + m.syncTextarea() +} + +func (m *Model) selected() (plugins.Info, bool) { + if len(m.filtered) == 0 { + return plugins.Info{}, false + } + return m.filtered[m.cursor], true +} + +func (m *Model) syncTextarea() { + if m.editing { + return + } + info, ok := m.selected() + if !ok { + m.textarea.SetValue("") + return + } + m.textarea.SetValue(info.ConfigText) +} + +func (m *Model) refreshListViewport() { + if m.pager.PerPage > 0 { + m.pager.Page = m.cursor / m.pager.PerPage + m.pager.SetTotalPages(len(m.filtered)) + } + m.listViewport.SetContent(m.renderList()) +} + +func shortenPath(p string) string { + home := os.Getenv("HOME") + if home != "" && strings.HasPrefix(p, home) { + return "~" + p[len(home):] + } + return p +} + +type pluginsKeyMap struct{ editing bool } + +func (k pluginsKeyMap) ShortHelp() []key.Binding { + pk := keys.Keys.Plugins + g := keys.Keys.Global + if k.editing { + esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit")) + return []key.Binding{esc} + } + return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter} +} + +func (k pluginsKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} diff --git a/internal/ui/plugins/update.go b/internal/ui/plugins/update.go new file mode 100644 index 0000000..749534d --- /dev/null +++ b/internal/ui/plugins/update.go @@ -0,0 +1,130 @@ +package plugins + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/keys" +) + +// PluginsChangedMsg is sent when the plugin list should be refreshed. +type PluginsChangedMsg struct{} + +// RefreshCmd returns a command that triggers a list refresh. +func RefreshCmd() tea.Cmd { + return func() tea.Msg { return PluginsChangedMsg{} } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case PluginsChangedMsg: + m.Refresh() + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + pk := keys.Keys.Plugins + g := keys.Keys.Global + + // Filtering mode: esc clears+closes, enter just closes, rest goes to filterInput. + if m.filtering { + switch { + case key.Matches(msg, g.Escape): + m.filtering = false + m.filter = "" + m.filterInput.SetValue("") + m.filterInput.Blur() + m.applyFilter() + m.recalcSizes() + case msg.String() == "enter": + m.filtering = false + m.filterInput.Blur() + m.recalcSizes() + default: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + m.filter = m.filterInput.Value() + m.applyFilter() + return m, cmd + } + return m, nil + } + + // Editing mode: only esc exits, everything else goes to textarea. + if m.editing { + if key.Matches(msg, g.Escape) { + m.editing = false + m.textarea.Blur() + if info, ok := m.selected(); ok && m.manager != nil { + val := m.textarea.Value() + m.manager.SaveConfig(info.Name, val) + // Update cached info. + m.filtered[m.cursor].ConfigText = val + for i := range m.items { + if m.items[i].Name == info.Name { + m.items[i].ConfigText = val + break + } + } + } + return m, nil + } + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + + switch { + case key.Matches(msg, g.Escape): + if m.filter != "" { + m.filter = "" + m.filterInput.SetValue("") + m.applyFilter() + } + + case key.Matches(msg, pk.Filter): + m.filtering = true + m.filterInput.Focus() + m.recalcSizes() + + case key.Matches(msg, g.Up): + if m.cursor > 0 { + m.cursor-- + m.refreshListViewport() + m.syncTextarea() + } + + case key.Matches(msg, g.Down): + if m.cursor < len(m.filtered)-1 { + m.cursor++ + m.refreshListViewport() + m.syncTextarea() + } + + case key.Matches(msg, pk.Toggle): + if info, ok := m.selected(); ok && m.manager != nil { + m.manager.TogglePlugin(info.Name) + m.filtered[m.cursor].Enabled = !info.Enabled + for i := range m.items { + if m.items[i].Name == info.Name { + m.items[i].Enabled = !info.Enabled + break + } + } + m.refreshListViewport() + } + + case key.Matches(msg, pk.EditConfig): + if _, ok := m.selected(); ok { + m.editing = true + m.textarea.Focus() + } + + case key.Matches(msg, g.Help): + m.help.ShowAll = !m.help.ShowAll + m.recalcSizes() + } + } + + return m, nil +} diff --git a/internal/ui/plugins/view.go b/internal/ui/plugins/view.go new file mode 100644 index 0000000..c867d24 --- /dev/null +++ b/internal/ui/plugins/view.go @@ -0,0 +1,150 @@ +package plugins + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" +) + +func (m Model) View() tea.View { + if m.width == 0 || m.manager == nil { + return tea.NewView(style.S.Faint.Render("\nno plugins loaded")) + } + + listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4) + + content := lipgloss.JoinVertical(lipgloss.Left, + m.renderListPanel(m.width, listH), + m.renderDetailPanel(detailH), + m.renderStatusBar(), + ) + return tea.NewView(content) +} + +func (m *Model) renderListPanel(w, h int) string { + s := style.S + dots := s.Faint.Render(m.pager.View()) + inner := lipgloss.JoinVertical(lipgloss.Left, + m.listViewport.View(), + lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), + ) + return style.RenderWithTitle(s.PanelFocused, icons.I.Plugin+"Plugins", inner, w, h) +} + +func (m *Model) renderDetailPanel(h int) string { + s := style.S + info, ok := m.selected() + if !ok { + return style.RenderWithTitle(s.Panel, "Config", "", m.width, h) + } + + var sb strings.Builder + + statusSt := lipgloss.NewStyle().Foreground(s.Error) + if info.Enabled { + statusSt = lipgloss.NewStyle().Foreground(s.Success) + } + status := "disabled" + if info.Enabled { + status = "enabled" + } + sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n") + sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n") + + if m.editing { + escKey := keys.Keys.Global.Escape.Help().Key + sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):")) + } else { + editKey := keys.Keys.Plugins.EditConfig.Help().Key + sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):")) + } + + inner := lipgloss.JoinVertical(lipgloss.Left, + lipgloss.NewStyle().Padding(0, 1).Render(sb.String()), + lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()), + ) + return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h) +} + +func (m *Model) renderStatusBar() string { + s := style.S + pad := lipgloss.NewStyle().Padding(0, 1) + filterKey := keys.Keys.Plugins.Filter.Help().Key + if m.filtering { + return pad.Render(s.Faint.Render(filterKey) + " " + m.filterInput.View()) + } + if m.filter != "" { + escKey := keys.Keys.Global.Escape.Help().Key + accent := lipgloss.NewStyle().Foreground(s.Primary) + filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear")) + return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))) + } + return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})) +} + +func (m *Model) renderList() string { + s := style.S + if len(m.filtered) == 0 { + msg := " (ง •̀_•́)ง\nno plugins" + if m.filter != "" { + msg = " = _ =\nno results" + } + return lipgloss.Place( + m.listViewport.Width(), m.listViewport.Height(), + lipgloss.Center, lipgloss.Center, + s.Faint.Render(msg), + ) + } + + start, end := m.pager.GetSliceBounds(len(m.filtered)) + if start < 0 { + start = 0 + } + if end < start { + end = start + } + + var sb strings.Builder + for i, p := range m.filtered[start:end] { + globalIdx := start + i + selected := globalIdx == m.cursor + + enabledSt := lipgloss.NewStyle().Foreground(s.Error) + enabledStr := "off" + if p.Enabled { + enabledSt = lipgloss.NewStyle().Foreground(s.Success) + enabledStr = "on " + } + + w := m.listViewport.Width() + const fixedW = 2 + 3 + 1 + nameW := w - fixedW + if nameW < 0 { + nameW = 0 + } + + var line string + if selected { + bg := lipgloss.NewStyle().Background(s.Selection) + line = lipgloss.JoinHorizontal(lipgloss.Top, + bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"), + enabledSt.Background(s.Selection).Width(3).Render(enabledStr), + bg.Width(1).Render(""), + bg.Bold(true).Width(nameW).Render(p.Name), + ) + } else { + line = lipgloss.JoinHorizontal(lipgloss.Top, + " ", + enabledSt.Width(3).Render(enabledStr), + " ", + s.Bold.Render(p.Name), + ) + } + sb.WriteString(line + "\n") + } + return sb.String() +} diff --git a/internal/ui/replay/model.go b/internal/ui/replay/model.go new file mode 100644 index 0000000..895364d --- /dev/null +++ b/internal/ui/replay/model.go @@ -0,0 +1,175 @@ +package replay + +import ( + "fmt" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/paginator" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/db" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" +) + +type SendToReplayMsg struct { + Scheme string + Host string + RequestRaw string +} + +type Entry struct { + DBID int64 + Scheme string + Host string + Path string + Method string + OriginalRaw string + RequestRaw string // current (possibly edited) request + ResponseRaw string // filled after send + StatusCode int // 0 = not sent yet + Sending bool + Err error +} + +type Model struct { + entries []Entry + cursor int + editing bool + database *db.DB + + listViewport viewport.Model + requestViewport viewport.Model + responseViewport viewport.Model + textarea textarea.Model + pager paginator.Model + help help.Model + + width int + height int +} + +func New() Model { + ta := style.NewTextarea(false) + ta.Blur() + return Model{ + listViewport: style.NewViewport(), + requestViewport: style.NewViewport(), + responseViewport: style.NewViewport(), + textarea: ta, + pager: style.NewPaginator(), + help: style.NewHelp(), + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) IsEditing() bool { return m.editing } + +func (m *Model) SetDB(d *db.DB) { + m.database = d + if d == nil { + return + } + entries, err := d.ListReplayEntries() + if err != nil { + return + } + for _, dbe := range entries { + m.entries = append(m.entries, entryFromDB(dbe)) + } + m.pager.SetTotalPages(len(m.entries)) + if len(m.entries) > 0 { + m.cursor = len(m.entries) - 1 + } + m.refreshListViewport() + m.refreshBody() +} + +func entryFromDB(dbe db.ReplayEntry) Entry { + var err error + if dbe.ErrorMsg != "" { + err = fmt.Errorf("%s", dbe.ErrorMsg) + } + return Entry{ + DBID: dbe.ID, + Scheme: dbe.Scheme, + Host: dbe.Host, + Path: dbe.Path, + Method: dbe.Method, + OriginalRaw: dbe.OriginalRaw, + RequestRaw: dbe.RequestRaw, + ResponseRaw: dbe.ResponseRaw, + StatusCode: dbe.StatusCode, + Err: err, + } +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.recalcSizes() +} + +func (m *Model) recalcSizes() { + m.help.SetWidth(m.width - 2) + + listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35) + + listInner := m.width - 2 + if listInner < 0 { + listInner = 0 + } + listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row + if listVH < 0 { + listVH = 0 + } + m.listViewport.SetWidth(listInner) + m.listViewport.SetHeight(listVH) + m.pager.PerPage = listVH + if m.pager.PerPage < 1 { + m.pager.PerPage = 1 + } + + leftW, rightW := m.bodyHalfWidths() + leftInner := leftW - 2 + rightInner := rightW - 2 + if leftInner < 0 { + leftInner = 0 + } + if rightInner < 0 { + rightInner = 0 + } + bodyVH := style.PanelContentH(bodyH) + + m.requestViewport.SetWidth(leftInner) + m.requestViewport.SetHeight(bodyVH) + m.responseViewport.SetWidth(rightInner) + m.responseViewport.SetHeight(bodyVH) + m.textarea.SetWidth(leftInner) + m.textarea.SetHeight(bodyVH) + + m.refreshListViewport() + m.refreshBody() +} + +func (m *Model) bodyHalfWidths() (left, right int) { + left = m.width / 2 + right = m.width - left + return +} + +type replayKeyMap struct{ width int } + +func (replayKeyMap) ShortHelp() []key.Binding { + g := keys.Keys.Global + r := keys.Keys.Replay + return []key.Binding{g.Up, g.Down, r.Send, r.Edit, g.Help} +} + +func (m replayKeyMap) FullHelp() [][]key.Binding { + all := append(keys.Keys.Replay.Bindings(), keys.Keys.Global.Bindings()...) + return keys.ChunkByWidth(all, m.width) +} diff --git a/internal/ui/replay/update.go b/internal/ui/replay/update.go new file mode 100644 index 0000000..e36776b --- /dev/null +++ b/internal/ui/replay/update.go @@ -0,0 +1,413 @@ +package replay + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/db" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" + "github.com/anotherhadi/spilltea/internal/util" +) + +type sentMsg struct { + index int + responseRaw string + statusCode int + err error +} + +func sendCmd(entry Entry, index int) tea.Cmd { + return func() tea.Msg { + raw, code, err := doSend(entry) + return sentMsg{index: index, responseRaw: raw, statusCode: code, err: err} + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case SendToReplayMsg: + entry := entryFromMsg(msg) + if m.database != nil { + id, err := m.database.InsertReplayEntry(entryToDB(entry)) + if err == nil { + entry.DBID = id + } + } + m.entries = append(m.entries, entry) + m.cursor = len(m.entries) - 1 + m.pager.SetTotalPages(len(m.entries)) + m.refreshListViewport() + m.refreshBody() + + case sentMsg: + if msg.index >= 0 && msg.index < len(m.entries) { + e := &m.entries[msg.index] + e.Sending = false + e.StatusCode = msg.statusCode + e.ResponseRaw = msg.responseRaw + if msg.err != nil { + e.Err = msg.err + e.ResponseRaw = "Error: " + msg.err.Error() + } + if m.database != nil && e.DBID != 0 { + m.database.UpdateReplayEntry(entryToDB(*e)) + } + } + m.refreshListViewport() + m.refreshBody() + + case util.EditorFinishedMsg: + if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 { + m.entries[m.cursor].RequestRaw = msg.Content + m.refreshBody() + } + + case tea.MouseWheelMsg: + if !m.editing { + switch msg.Button { + case tea.MouseWheelUp: + if msg.Mod.Contains(tea.ModShift) { + m.requestViewport.ScrollLeft(6) + m.responseViewport.ScrollLeft(6) + } else { + m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1) + } + case tea.MouseWheelDown: + if msg.Mod.Contains(tea.ModShift) { + m.requestViewport.ScrollRight(6) + m.responseViewport.ScrollRight(6) + } else { + m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1) + } + case tea.MouseWheelLeft: + m.requestViewport.ScrollLeft(6) + m.responseViewport.ScrollLeft(6) + case tea.MouseWheelRight: + m.requestViewport.ScrollRight(6) + m.responseViewport.ScrollRight(6) + } + } + + case tea.KeyPressMsg: + if m.editing { + return m.updateEditMode(msg) + } + return m.updateNormalMode(msg) + } + + return m, nil +} + +func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + g := keys.Keys.Global + r := keys.Keys.Replay + switch { + case key.Matches(msg, g.Up): + if m.cursor > 0 { + m.cursor-- + m.refreshListViewport() + m.refreshBody() + } + + case key.Matches(msg, g.Down): + if m.cursor < len(m.entries)-1 { + m.cursor++ + m.refreshListViewport() + m.refreshBody() + } + + case key.Matches(msg, r.Send): + if len(m.entries) > 0 && !m.entries[m.cursor].Sending { + m.entries[m.cursor].Sending = true + m.entries[m.cursor].ResponseRaw = "" + m.entries[m.cursor].Err = nil + m.refreshListViewport() + m.refreshBody() + return m, sendCmd(m.entries[m.cursor], m.cursor) + } + + case key.Matches(msg, r.Edit): + if len(m.entries) > 0 { + m.textarea.SetValue(m.entries[m.cursor].RequestRaw) + m.editing = true + m.textarea.Focus() + } + + case key.Matches(msg, r.EditExt): + if len(m.entries) > 0 { + return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw) + } + + case key.Matches(msg, r.UndoEdits): + if len(m.entries) > 0 { + m.entries[m.cursor].RequestRaw = m.entries[m.cursor].OriginalRaw + m.refreshBody() + } + + case key.Matches(msg, g.ScrollUp): + step := m.responseViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step) + + case key.Matches(msg, g.ScrollDown): + step := m.responseViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step) + + case key.Matches(msg, g.Left): + m.requestViewport.ScrollLeft(6) + m.responseViewport.ScrollLeft(6) + + case key.Matches(msg, g.Right): + m.requestViewport.ScrollRight(6) + m.responseViewport.ScrollRight(6) + + case key.Matches(msg, r.Delete): + if len(m.entries) > 0 { + e := m.entries[m.cursor] + if m.database != nil && e.DBID != 0 { + m.database.DeleteReplayEntry(e.DBID) + } + m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...) + if m.cursor >= len(m.entries) && m.cursor > 0 { + m.cursor-- + } + m.pager.SetTotalPages(len(m.entries)) + m.refreshListViewport() + m.refreshBody() + } + + case key.Matches(msg, r.DeleteAll): + if m.database != nil { + m.database.DeleteAllReplayEntries() + } + m.entries = nil + m.cursor = 0 + m.pager.SetTotalPages(0) + m.refreshListViewport() + m.refreshBody() + + case key.Matches(msg, g.Help): + m.help.ShowAll = !m.help.ShowAll + m.recalcSizes() + } + + return m, nil +} + +func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Keys.Global.Escape): + if len(m.entries) > 0 { + m.entries[m.cursor].RequestRaw = m.textarea.Value() + } + m.editing = false + m.textarea.Blur() + m.refreshBody() + + default: + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m *Model) refreshListViewport() { + if m.pager.PerPage > 0 { + m.pager.Page = m.cursor / m.pager.PerPage + m.pager.SetTotalPages(len(m.entries)) + } + m.listViewport.SetContent(m.renderList()) +} + +func (m *Model) refreshBody() { + if len(m.entries) == 0 { + m.requestViewport.SetContent("") + m.responseViewport.SetContent("") + return + } + e := m.entries[m.cursor] + m.requestViewport.SetContent(style.HighlightHTTP(e.RequestRaw)) + m.requestViewport.SetYOffset(0) + m.requestViewport.SetXOffset(0) + + if e.Sending { + m.responseViewport.SetContent(style.HighlightHTTP("Sending...")) + } else if e.ResponseRaw != "" { + m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw)) + } else { + m.responseViewport.SetContent("") + } + m.responseViewport.SetYOffset(0) + m.responseViewport.SetXOffset(0) +} + +func doSend(entry Entry) (responseRaw string, statusCode int, err error) { + lines := strings.Split(strings.ReplaceAll(entry.RequestRaw, "\r\n", "\n"), "\n") + if len(lines) == 0 { + return "", 0, fmt.Errorf("empty request") + } + + parts := strings.SplitN(lines[0], " ", 3) + if len(parts) < 2 { + return "", 0, fmt.Errorf("invalid request line") + } + method := strings.TrimSpace(parts[0]) + path := strings.TrimSpace(parts[1]) + + headers := make(http.Header) + host := entry.Host + i := 1 + for i < len(lines) { + line := strings.TrimRight(lines[i], "\r") + if line == "" { + i++ + break + } + if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 { + k := strings.TrimSpace(kv[0]) + v := strings.TrimSpace(kv[1]) + if strings.ToLower(k) == "host" { + host = v + } else { + headers.Add(k, v) + } + } + i++ + } + + var bodyBytes []byte + if i < len(lines) { + b := strings.Join(lines[i:], "\n") + b = strings.TrimRight(b, "\n") + bodyBytes = []byte(b) + } + + scheme := entry.Scheme + if scheme == "" { + scheme = "https" + } + urlStr := scheme + "://" + host + path + + var bodyReader io.Reader + if len(bodyBytes) > 0 { + bodyReader = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequest(method, urlStr, bodyReader) + if err != nil { + return "", 0, err + } + req.Header = headers + + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + }, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + var sb strings.Builder + fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)) + sortedKeys := make([]string, 0, len(resp.Header)) + for k := range resp.Header { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + for _, k := range sortedKeys { + for _, v := range resp.Header[k] { + fmt.Fprintf(&sb, "%s: %s\n", k, v) + } + } + sb.WriteString("\n") + sb.Write(respBody) + + return sb.String(), resp.StatusCode, nil +} + +func entryToDB(e Entry) db.ReplayEntry { + errMsg := "" + if e.Err != nil { + errMsg = e.Err.Error() + } + return db.ReplayEntry{ + ID: e.DBID, + Timestamp: time.Now(), + Scheme: e.Scheme, + Host: e.Host, + Path: e.Path, + Method: e.Method, + OriginalRaw: e.OriginalRaw, + RequestRaw: e.RequestRaw, + ResponseRaw: e.ResponseRaw, + StatusCode: e.StatusCode, + ErrorMsg: errMsg, + } +} + +func entryFromMsg(msg SendToReplayMsg) Entry { + method, host, path := parseFirstLine(msg.RequestRaw, msg.Host) + scheme := msg.Scheme + if scheme == "" { + scheme = util.InferScheme(host) + } + return Entry{ + Scheme: scheme, + Host: host, + Path: path, + Method: method, + OriginalRaw: msg.RequestRaw, + RequestRaw: msg.RequestRaw, + } +} + +func parseFirstLine(raw, fallbackHost string) (method, host, path string) { + host = fallbackHost + path = "/" + lines := strings.SplitN(raw, "\n", 2) + if len(lines) == 0 { + return + } + parts := strings.Fields(lines[0]) + if len(parts) >= 1 { + method = parts[0] + } + if len(parts) >= 2 { + path = parts[1] + } + if len(lines) > 1 { + for _, line := range strings.Split(lines[1], "\n") { + if strings.HasPrefix(strings.ToLower(line), "host:") { + host = strings.TrimSpace(line[5:]) + break + } + } + } + return +} diff --git a/internal/ui/replay/view.go b/internal/ui/replay/view.go new file mode 100644 index 0000000..ff24d1b --- /dev/null +++ b/internal/ui/replay/view.go @@ -0,0 +1,137 @@ +package replay + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/style" +) + +func (m Model) View() tea.View { + if m.width == 0 { + return tea.NewView("Loading...") + } + + listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35) + leftW, rightW := m.bodyHalfWidths() + + bodyRow := lipgloss.JoinHorizontal(lipgloss.Top, + m.renderRequestPanel(leftW, bodyH), + m.renderResponsePanel(rightW, bodyH), + ) + + content := lipgloss.JoinVertical(lipgloss.Left, + m.renderListPanel(m.width, listH), + bodyRow, + m.renderStatusBar(), + ) + return tea.NewView(content) +} + +func (m *Model) renderListPanel(w, h int) string { + s := style.S + dots := s.Faint.Render(m.pager.View()) + inner := lipgloss.JoinVertical(lipgloss.Left, + m.listViewport.View(), + lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), + ) + return style.RenderWithTitle(s.PanelFocused, icons.I.Replay+"Replay", inner, w, h) +} + +func (m *Model) renderRequestPanel(w, h int) string { + s := style.S + var body string + border := s.Panel + if m.editing { + body = m.textarea.View() + border = s.PanelFocused + } else { + body = m.requestViewport.View() + } + return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h) +} + +func (m *Model) renderResponsePanel(w, h int) string { + s := style.S + return style.RenderWithTitle(s.Panel, icons.I.Response+"Response", m.responseViewport.View(), w, h) +} + +func (m *Model) renderStatusBar() string { + return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(replayKeyMap{width: m.width})) +} + +func (m *Model) renderList() string { + if len(m.entries) == 0 { + return lipgloss.Place( + m.listViewport.Width(), m.listViewport.Height(), + lipgloss.Center, lipgloss.Center, + style.S.Faint.Render(" (╥﹏╥)\nsend a request from History or Intercept"), + ) + } + + s := style.S + start, end := m.pager.GetSliceBounds(len(m.entries)) + if start < 0 { + start = 0 + } + if end < start { + end = start + } + + var sb strings.Builder + for i, e := range m.entries[start:end] { + globalIdx := start + i + selected := globalIdx == m.cursor + selBg := s.Selection + + w := m.listViewport.Width() + const fixedW = 2 + 7 + 1 + 3 + 1 + hostPathW := w - fixedW + if hostPathW < 0 { + hostPathW = 0 + } + + statusStr, statusSt := entryStatus(e) + + var line string + if selected { + bg := lipgloss.NewStyle().Background(selBg) + line = lipgloss.JoinHorizontal(lipgloss.Top, + bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"), + s.Method(e.Method).Background(selBg).Render(e.Method), + bg.Width(1).Render(""), + statusSt.Background(selBg).Render(statusStr), + bg.Width(1).Render(""), + bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path), + ) + } else { + line = lipgloss.JoinHorizontal(lipgloss.Top, + " ", + s.Method(e.Method).Render(e.Method), + " ", + statusSt.Render(statusStr), + " ", + s.Bold.Render(e.Host), + s.Faint.Render(e.Path), + ) + } + sb.WriteString(line + "\n") + } + return sb.String() +} + +func entryStatus(e Entry) (string, lipgloss.Style) { + base := lipgloss.NewStyle().Bold(true).Width(3) + switch { + case e.Sending: + return "···", base.Foreground(style.S.Subtle) + case e.Err != nil: + return "ERR", base.Foreground(style.S.Error) + case e.StatusCode == 0: + return "---", base.Foreground(style.S.Subtle) + } + return fmt.Sprintf("%3d", e.StatusCode), style.StatusStyle(e.StatusCode, 3) +} diff --git a/internal/ui/scope/model.go b/internal/ui/scope/model.go new file mode 100644 index 0000000..1e09a4c --- /dev/null +++ b/internal/ui/scope/model.go @@ -0,0 +1,150 @@ +package scope + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/keys" + "github.com/anotherhadi/spilltea/internal/style" +) + +const ( + fieldNone = -1 + fieldWhitelist = 0 + fieldBlacklist = 1 +) + +const ( + minTaH = 3 + maxTaH = 12 + fixedH = 8 // (blank + label + desc + blank) x2 +) + +type ScopeChangedMsg struct { + Whitelist []string + Blacklist []string +} + +type Model struct { + focusIdx int + + wlTextarea textarea.Model + blTextarea textarea.Model + + innerH int + width int + height int + + help help.Model +} + +func New(name, path string) Model { + wl := style.NewTextarea(true) + wl.Placeholder = "one pattern per line..." + + bl := style.NewTextarea(true) + bl.Placeholder = "one pattern per line..." + bl.Blur() + + return Model{ + focusIdx: fieldNone, + wlTextarea: wl, + blTextarea: bl, + help: style.NewHelp(), + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m *Model) SetScope(whitelist, blacklist []string) { + m.wlTextarea.SetValue(strings.Join(whitelist, "\n")) + m.blTextarea.SetValue(strings.Join(blacklist, "\n")) +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.syncLayout() +} + +func (m *Model) syncLayout() { + if m.width == 0 { + return + } + m.help.SetWidth(m.width - 2) + + statusH := strings.Count(m.renderStatusBar(), "\n") + 1 + panelH := m.height - statusH + m.innerH = max(1, style.PanelContentH(panelH)) + + taH := (m.innerH - fixedH) / 2 + if taH < minTaH { + taH = minTaH + } + if taH > maxTaH { + taH = maxTaH + } + // width - 2 (panel border) - 1 (leading space in view) - 3 (right margin + cursor) + taW := max(1, m.width-6) + m.wlTextarea.SetWidth(taW) + m.wlTextarea.SetHeight(taH) + m.blTextarea.SetWidth(taW) + m.blTextarea.SetHeight(taH) +} + +func (m Model) IsEditing() bool { + return m.focusIdx == fieldWhitelist || m.focusIdx == fieldBlacklist +} + +func (m *Model) scopeChangedCmd() tea.Cmd { + wl := parseLines(m.wlTextarea.Value()) + bl := parseLines(m.blTextarea.Value()) + return func() tea.Msg { + return ScopeChangedMsg{Whitelist: wl, Blacklist: bl} + } +} + +func parseLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + if t := strings.TrimSpace(line); t != "" { + out = append(out, t) + } + } + return out +} + +func (m Model) renderStatusBar() string { + return lipgloss.NewStyle().Padding(0, 1).Render( + m.help.View(formKeyMap{focusIdx: m.focusIdx}), + ) +} + +type formKeyMap struct { + focusIdx int +} + +func (k formKeyMap) ShortHelp() []key.Binding { + cycle := keys.Keys.Global.CycleFocus + hlp := keys.Keys.Global.Help + + switch k.focusIdx { + case fieldWhitelist, fieldBlacklist: + esc := keys.Keys.Global.Escape + escBinding := key.NewBinding(key.WithKeys(esc.Keys()...), key.WithHelp(esc.Help().Key, "unfocus")) + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line")), + escBinding, + cycle, + } + } + return []key.Binding{cycle, hlp} +} + +func (k formKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} diff --git a/internal/ui/scope/update.go b/internal/ui/scope/update.go new file mode 100644 index 0000000..9ba98ea --- /dev/null +++ b/internal/ui/scope/update.go @@ -0,0 +1,70 @@ +package scope + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/spilltea/internal/keys" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + kp, isKey := msg.(tea.KeyPressMsg) + if !isKey { + return m, nil + } + + if key.Matches(kp, keys.Keys.Global.CycleFocus) { + return m.cycleFocus() + } + + if key.Matches(kp, keys.Keys.Global.Help) && !m.IsEditing() { + m.help.ShowAll = !m.help.ShowAll + return m, nil + } + + switch m.focusIdx { + case fieldWhitelist: + if key.Matches(kp, keys.Keys.Global.Escape) { + return m.blurAll() + } + var cmd tea.Cmd + m.wlTextarea, cmd = m.wlTextarea.Update(kp) + return m, cmd + + case fieldBlacklist: + if key.Matches(kp, keys.Keys.Global.Escape) { + return m.blurAll() + } + var cmd tea.Cmd + m.blTextarea, cmd = m.blTextarea.Update(kp) + return m, cmd + } + + return m, nil +} + +func (m Model) blurAll() (tea.Model, tea.Cmd) { + m.wlTextarea.Blur() + m.blTextarea.Blur() + m.focusIdx = fieldNone + m.syncLayout() + return m, m.scopeChangedCmd() +} + +func (m Model) cycleFocus() (tea.Model, tea.Cmd) { + scopeCmd := m.scopeChangedCmd() + + var focusCmd tea.Cmd + switch m.focusIdx { + case fieldNone, fieldBlacklist: + m.blTextarea.Blur() + m.focusIdx = fieldWhitelist + focusCmd = m.wlTextarea.Focus() + case fieldWhitelist: + m.wlTextarea.Blur() + m.focusIdx = fieldBlacklist + focusCmd = m.blTextarea.Focus() + } + + m.syncLayout() + return m, tea.Batch(focusCmd, scopeCmd) +} diff --git a/internal/ui/scope/view.go b/internal/ui/scope/view.go new file mode 100644 index 0000000..0d25194 --- /dev/null +++ b/internal/ui/scope/view.go @@ -0,0 +1,84 @@ +package scope + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/icons" + "github.com/anotherhadi/spilltea/internal/style" +) + +func (m Model) View() tea.View { + if m.width == 0 { + return tea.NewView("") + } + + s := style.S + + statusBar := m.renderStatusBar() + statusH := strings.Count(statusBar, "\n") + 1 + panelH := m.height - statusH + innerH := max(1, style.PanelContentH(panelH)) + + taH := (innerH - fixedH) / 2 + if taH < minTaH { + taH = minTaH + } + if taH > maxTaH { + taH = maxTaH + } + + var lines []string + add := func(l string) { lines = append(lines, l) } + + add("") + add(fieldLabel("Whitelist", m.focusIdx == fieldWhitelist)) + add(" " + s.Faint.Render("If non-empty, only matching requests are intercepted.")) + add("") + wlContentLines := strings.Count(m.wlTextarea.Value(), "\n") + 1 + for _, l := range taLines(m.wlTextarea.View(), taH, wlContentLines) { + add(" " + l) + } + + add("") + add(fieldLabel("Blacklist", m.focusIdx == fieldBlacklist)) + add(" " + s.Faint.Render("Matching requests are always excluded from history.")) + add("") + blContentLines := strings.Count(m.blTextarea.Value(), "\n") + 1 + for _, l := range taLines(m.blTextarea.View(), taH, blContentLines) { + add(" " + l) + } + + for len(lines) < innerH { + lines = append(lines, "") + } + content := strings.Join(lines[:innerH], "\n") + + panel := style.RenderWithTitle(s.PanelFocused, icons.I.Scope+"Scopes", content, m.width, panelH) + return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, panel, statusBar)) +} + +func fieldLabel(name string, focused bool) string { + s := style.S + c := s.MutedFg + if focused { + c = s.Primary + } + return " " + lipgloss.NewStyle().Foreground(c).Bold(focused).Render(name) +} + +func taLines(view string, h int, contentLines int) []string { + raw := strings.Split(strings.TrimRight(view, "\n"), "\n") + tilde := style.S.Faint.Render("~") + for len(raw) < h { + raw = append(raw, tilde) + } + if len(raw) > h { + raw = raw[:h] + } + for i := contentLines; i < len(raw); i++ { + raw[i] = tilde + } + return raw +} diff --git a/internal/util/editor.go b/internal/util/editor.go new file mode 100644 index 0000000..152cca8 --- /dev/null +++ b/internal/util/editor.go @@ -0,0 +1,38 @@ +package util + +import ( + "os" + "os/exec" + + tea "charm.land/bubbletea/v2" +) + +type EditorFinishedMsg struct { + Content string + Err error +} + +func OpenExternalEditor(content string) tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + f, err := os.CreateTemp("", "spilltea-*.http") + if err != nil { + return nil + } + tmpPath := f.Name() + _, _ = f.WriteString(content) + f.Close() + return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg { + defer os.Remove(tmpPath) + if err != nil { + return EditorFinishedMsg{Err: err} + } + data, readErr := os.ReadFile(tmpPath) + if readErr != nil { + return EditorFinishedMsg{Err: readErr} + } + return EditorFinishedMsg{Content: string(data)} + }) +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..30f1c09 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,18 @@ +package util + +import "strings" + +func Truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} + +// InferScheme returns "http" for port 80, "https" otherwise. +func InferScheme(host string) string { + if strings.HasSuffix(host, ":80") { + return "http" + } + return "https" +}