commit e8e64eff1223d7ec83ac4c8ee1a598a6f2616661 Author: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Tue May 12 19:12:29 2026 +0200 Init Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> 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 0000000..8d2285c Binary files /dev/null and b/.github/assets/banner.png differ 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 0000000..35a0210 Binary files /dev/null and b/.github/assets/logo.png differ 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) | + +--- + +
+ github | + gitlab (mirror) | + gitea (mirror) +
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" +}