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 @@
+
+

+
+
+
+
+# Spilltea
+
+> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
+> Think Burp Suite or Caido, but entirely in your terminal.
+
+[](go.mod)
+[](https://github.com/anotherhadi/spilltea/releases)
+[](LICENSE)
+[](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
+
+## What is Spilltea?
+
+Spilltea is a **terminal-native HTTP(S) interception proxy**. It sits between your browser and the internet, letting you inspect, modify, and replay traffic without ever leaving your terminal.
+
+It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, keyboard-driven tool that gets out of your way.
+
+## Features
+
+- **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding.
+- **HTTP History**: Every request that passes through the proxy is stored. Browse, search and filter your full session history.
+- **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration
+- **Scopes**: Keep your history clean by white/blacklisting domains or specific paths.
+- **HTTPS Support** (using go-mitmproxy under the hood)
+- Built-in Integrations:
+ - **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly.
+ - **cURL / HTTPie**: Copy any request as a curl or httpie command to your clipboard.
+ - **Markdown Export**: Export any request and its response as a clean Markdown snippet, ready to drop into a report.
+
+## Project Management
+
+Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
+
+On startup, you choose:
+
+- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
+- **Existing project**: pick from a list of previous projects
+- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
+
+## Plugin System
+
+Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code.
+For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md).
+
+## Configuration
+
+Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
+Check the default configuration with all the options [here](./internal/config/default_config.yaml)
+
+## Deployment
+
+spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
+
+If you need to run spilltea on a remote machine (e.g., a VPS or pivot host), use SSH port forwarding:
+
+```sh
+ssh -L 8080:127.0.0.1:8080 user@remote-host
+```
+
+Then point your browser at `127.0.0.1:8080` as usual.
+
+## Tech Stack
+
+| Component | Library |
+| ------------------ | --------------------------------------------------------- |
+| TUI | [bubbletea](https://github.com/charmbracelet/bubbletea) |
+| Styles | [lipgloss](https://github.com/charmbracelet/lipgloss) |
+| Proxy / MITM / TLS | [go-mitmproxy](https://github.com/lqqyt2423/go-mitmproxy) |
+| Storage | [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) |
+| Config | [viper](https://github.com/spf13/viper) |
+| Plugins | [gopher-lua](https://github.com/yuin/gopher-lua) |
+
+---
+
+ 0, nil
+}
+
+func (d *DB) LoadFindings() ([]Finding, error) {
+ rows, err := d.conn.Query(
+ `SELECT id, plugin_name, dedup_key, title, description, severity, created_at
+ FROM findings WHERE dismissed = 0 ORDER BY id DESC`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Finding
+ for rows.Next() {
+ var f Finding
+ var ts string
+ if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil {
+ return nil, err
+ }
+ for _, layout := range findingTimeFormats {
+ if t, err := time.Parse(layout, ts); err == nil {
+ f.CreatedAt = t
+ break
+ }
+ }
+ out = append(out, f)
+ }
+ return out, rows.Err()
+}
+
+func (d *DB) DismissFinding(id int64) error {
+ _, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
+ return err
+}
diff --git a/internal/db/plugins.go b/internal/db/plugins.go
new file mode 100644
index 0000000..2d1c013
--- /dev/null
+++ b/internal/db/plugins.go
@@ -0,0 +1,35 @@
+package db
+
+type PluginState struct {
+ Name string
+ Enabled bool
+ ConfigText string
+}
+
+func (d *DB) SavePluginState(name string, enabled bool, configText string) error {
+ _, err := d.conn.Exec(
+ `INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?)
+ ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`,
+ name, enabled, configText,
+ )
+ return err
+}
+
+func (d *DB) LoadPluginStates() ([]PluginState, error) {
+ rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []PluginState
+ for rows.Next() {
+ var s PluginState
+ var enabled int
+ if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil {
+ return nil, err
+ }
+ s.Enabled = enabled != 0
+ out = append(out, s)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/db/replay.go b/internal/db/replay.go
new file mode 100644
index 0000000..ad25f16
--- /dev/null
+++ b/internal/db/replay.go
@@ -0,0 +1,76 @@
+package db
+
+import (
+ "time"
+)
+
+type ReplayEntry struct {
+ ID int64
+ Timestamp time.Time
+ Scheme string
+ Host string
+ Path string
+ Method string
+ OriginalRaw string
+ RequestRaw string
+ ResponseRaw string
+ StatusCode int
+ ErrorMsg string
+}
+
+func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
+ res, err := d.conn.Exec(
+ `INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ e.Timestamp.UTC().Format(time.RFC3339),
+ e.Scheme, e.Host, e.Path, e.Method,
+ e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
+ e.StatusCode, e.ErrorMsg,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
+ _, err := d.conn.Exec(
+ `UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`,
+ e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID,
+ )
+ return err
+}
+
+func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
+ rows, err := d.conn.Query(
+ `SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg
+ FROM replay_entries ORDER BY id ASC`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var entries []ReplayEntry
+ for rows.Next() {
+ var e ReplayEntry
+ var ts string
+ if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method,
+ &e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil {
+ return nil, err
+ }
+ e.Timestamp, _ = time.Parse(time.RFC3339, ts)
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+func (d *DB) DeleteReplayEntry(id int64) error {
+ _, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
+ return err
+}
+
+func (d *DB) DeleteAllReplayEntries() error {
+ _, err := d.conn.Exec(`DELETE FROM replay_entries`)
+ return err
+}
diff --git a/internal/db/scope.go b/internal/db/scope.go
new file mode 100644
index 0000000..9b3d731
--- /dev/null
+++ b/internal/db/scope.go
@@ -0,0 +1,45 @@
+package db
+
+func (d *DB) SaveScope(whitelist, blacklist []string) error {
+ tx, err := d.conn.Begin()
+ if err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM scope`); err != nil {
+ tx.Rollback()
+ return err
+ }
+ for _, p := range whitelist {
+ if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('whitelist', ?)`, p); err != nil {
+ tx.Rollback()
+ return err
+ }
+ }
+ for _, p := range blacklist {
+ if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('blacklist', ?)`, p); err != nil {
+ tx.Rollback()
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (d *DB) LoadScope() (whitelist, blacklist []string, err error) {
+ rows, err := d.conn.Query(`SELECT kind, pattern FROM scope`)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var kind, pattern string
+ if err := rows.Scan(&kind, &pattern); err != nil {
+ return nil, nil, err
+ }
+ if kind == "whitelist" {
+ whitelist = append(whitelist, pattern)
+ } else {
+ blacklist = append(blacklist, pattern)
+ }
+ }
+ return whitelist, blacklist, rows.Err()
+}
diff --git a/internal/icons/icons.go b/internal/icons/icons.go
new file mode 100644
index 0000000..e81201f
--- /dev/null
+++ b/internal/icons/icons.go
@@ -0,0 +1,51 @@
+package icons
+
+import "github.com/anotherhadi/spilltea/internal/config"
+
+type Icons struct {
+ Forward string
+ Drop string
+ Edit string
+ Intercept string
+ History string
+ Replay string
+ Diff string
+ Request string
+ Response string
+ Plugin string
+ Findings string
+ Scope string
+ Detail string
+ Docs string
+ New string
+ Temp string
+ Project string
+}
+
+var I *Icons
+
+func Init(cfg *config.Config) {
+ if cfg.TUI.UseNerdfontIcons {
+ I = &Icons{
+ Forward: " ",
+ Drop: " ",
+ Edit: " ",
+ Intercept: " ",
+ History: " ",
+ Replay: " ",
+ Diff: " ",
+ Request: " ",
+ Response: " ",
+ Plugin: " ",
+ Findings: " ",
+ Scope: " ",
+ Detail: " ",
+ Docs: " ",
+ New: " ",
+ Temp: " ",
+ Project: " ",
+ }
+ } else {
+ I = &Icons{}
+ }
+}
diff --git a/internal/intercept/broker.go b/internal/intercept/broker.go
new file mode 100644
index 0000000..58ac045
--- /dev/null
+++ b/internal/intercept/broker.go
@@ -0,0 +1,222 @@
+package intercept
+
+import (
+ "regexp"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/anotherhadi/spilltea/internal/config"
+ "github.com/anotherhadi/spilltea/internal/db"
+ "github.com/lqqyt2423/go-mitmproxy/proxy"
+)
+
+type Decision int
+
+const (
+ Forward Decision = iota // forward without showing in intercept
+ Drop // drop the flow
+ Intercept // pass to the TUI for user decision
+)
+
+type PendingRequest struct {
+ Flow *proxy.Flow
+ decision chan Decision
+ ArrivedAt time.Time
+}
+
+type PendingResponse struct {
+ Flow *proxy.Flow
+ decision chan Decision
+ ArrivedAt time.Time
+}
+
+type Broker struct {
+ Incoming chan *PendingRequest
+ IncomingResponse chan *PendingResponse
+ captureResponse atomic.Bool
+
+ dbMu sync.RWMutex
+ database *db.DB
+ droppedFlows sync.Map // *proxy.Flow → struct{}
+ outOfScope sync.Map // *proxy.Flow → struct{}
+
+ scopeMu sync.RWMutex
+ whitelist []*regexp.Regexp
+ blacklist []*regexp.Regexp
+
+ onNewEntry func(db.Entry)
+}
+
+func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
+ b.onNewEntry = cb
+}
+
+// IsInScope reports whether the given target string (host+path) matches the
+// current scope rules. Used by the plugin API.
+func (b *Broker) IsInScope(target string) bool {
+ b.scopeMu.RLock()
+ wl := b.whitelist
+ bl := b.blacklist
+ b.scopeMu.RUnlock()
+ return scopeMatches(wl, bl, target)
+}
+
+func NewBroker() *Broker {
+ return &Broker{
+ Incoming: make(chan *PendingRequest, 64),
+ IncomingResponse: make(chan *PendingResponse, 64),
+ }
+}
+
+func (b *Broker) SetCaptureResponse(v bool) {
+ b.captureResponse.Store(v)
+}
+
+// SetScope compiles and stores whitelist/blacklist regex patterns.
+// Invalid patterns are silently skipped.
+func (b *Broker) SetScope(whitelist, blacklist []string) {
+ wl := compilePatterns(whitelist)
+ bl := compilePatterns(blacklist)
+ b.scopeMu.Lock()
+ b.whitelist = wl
+ b.blacklist = bl
+ b.scopeMu.Unlock()
+}
+
+func compilePatterns(patterns []string) []*regexp.Regexp {
+ out := make([]*regexp.Regexp, 0, len(patterns))
+ for _, p := range patterns {
+ if r, err := regexp.Compile(p); err == nil {
+ out = append(out, r)
+ }
+ }
+ return out
+}
+
+func (b *Broker) matchesScope(f *proxy.Flow) bool {
+ target := f.Request.URL.Host + f.Request.URL.Path
+ b.scopeMu.RLock()
+ wl := b.whitelist
+ bl := b.blacklist
+ b.scopeMu.RUnlock()
+ return scopeMatches(wl, bl, target)
+}
+
+func scopeMatches(wl, bl []*regexp.Regexp, target string) bool {
+ if len(wl) > 0 {
+ matched := false
+ for _, r := range wl {
+ if r.MatchString(target) {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ return false
+ }
+ }
+ for _, r := range bl {
+ if r.MatchString(target) {
+ return false
+ }
+ }
+ return true
+}
+
+func (b *Broker) SetDB(d *db.DB) {
+ b.dbMu.Lock()
+ b.database = d
+ b.dbMu.Unlock()
+}
+
+// Hold is called from the proxy addon: it blocks until a decision is made in the TUI.
+func (b *Broker) Hold(f *proxy.Flow) Decision {
+ if !b.matchesScope(f) {
+ b.outOfScope.Store(f, struct{}{})
+ return Forward
+ }
+ p := &PendingRequest{
+ Flow: f,
+ decision: make(chan Decision, 1),
+ ArrivedAt: time.Now(),
+ }
+ b.Incoming <- p
+ d := <-p.decision
+ if d == Drop {
+ b.droppedFlows.Store(f, struct{}{})
+ }
+ return d
+}
+
+// HoldResponse is called from the proxy addon after receiving the response headers, but before reading the body.
+func (b *Broker) HoldResponse(f *proxy.Flow) Decision {
+ if _, oos := b.outOfScope.Load(f); oos {
+ return Forward
+ }
+ if !b.captureResponse.Load() {
+ return Forward
+ }
+ p := &PendingResponse{
+ Flow: f,
+ decision: make(chan Decision, 1),
+ ArrivedAt: time.Now(),
+ }
+ b.IncomingResponse <- p
+ return <-p.decision
+}
+
+// SaveEntry persists the completed flow to the history DB.
+// It must be called after HoldResponse and before modifying f.Response.
+// Flows that were dropped at the request phase are silently skipped.
+func (b *Broker) SaveEntry(f *proxy.Flow) {
+ b.dbMu.RLock()
+ d := b.database
+ b.dbMu.RUnlock()
+ if d == nil {
+ return
+ }
+ if _, oos := b.outOfScope.LoadAndDelete(f); oos {
+ return
+ }
+ if _, dropped := b.droppedFlows.LoadAndDelete(f); dropped {
+ return
+ }
+ status := 0
+ if f.Response != nil {
+ status = f.Response.StatusCode
+ }
+ r := f.Request
+ path := r.URL.Path
+ if path == "" {
+ path = "/"
+ }
+ if config.Global.History.SkipDuplicates {
+ body := string(r.Body)
+ if dup, _ := d.HasDuplicate(r.Method, r.URL.Host, path, body); dup {
+ return
+ }
+ }
+ entry, err := d.InsertEntry(db.Entry{
+ Timestamp: time.Now(),
+ Method: r.Method,
+ Host: r.URL.Host,
+ Path: path,
+ StatusCode: status,
+ RequestRaw: FormatRawRequest(f),
+ ResponseRaw: FormatRawResponse(f),
+ })
+ if err == nil {
+ if cb := b.onNewEntry; cb != nil {
+ go cb(entry)
+ }
+ }
+}
+
+func (b *Broker) Decide(p *PendingRequest, d Decision) {
+ p.decision <- d
+}
+
+func (b *Broker) DecideResponse(p *PendingResponse, d Decision) {
+ p.decision <- d
+}
diff --git a/internal/intercept/cmd.go b/internal/intercept/cmd.go
new file mode 100644
index 0000000..bd95cf0
--- /dev/null
+++ b/internal/intercept/cmd.go
@@ -0,0 +1,18 @@
+package intercept
+
+import tea "charm.land/bubbletea/v2"
+
+type RequestArrivedMsg struct{ Req *PendingRequest }
+type ResponseArrivedMsg struct{ Resp *PendingResponse }
+
+func WaitForRequest(b *Broker) tea.Cmd {
+ return func() tea.Msg {
+ return RequestArrivedMsg{Req: <-b.Incoming}
+ }
+}
+
+func WaitForResponse(b *Broker) tea.Cmd {
+ return func() tea.Msg {
+ return ResponseArrivedMsg{Resp: <-b.IncomingResponse}
+ }
+}
diff --git a/internal/intercept/format.go b/internal/intercept/format.go
new file mode 100644
index 0000000..d1cb8bb
--- /dev/null
+++ b/internal/intercept/format.go
@@ -0,0 +1,61 @@
+package intercept
+
+import (
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/lqqyt2423/go-mitmproxy/proxy"
+)
+
+// FormatRawRequest serialises a flow's request to a raw HTTP string.
+func FormatRawRequest(f *proxy.Flow) string {
+ r := f.Request
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
+ keys := make([]string, 0, len(r.Header))
+ for k := range r.Header {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ for _, v := range r.Header[k] {
+ fmt.Fprintf(&sb, "%s: %s\n", k, v)
+ }
+ }
+ sb.WriteString("\n")
+ if len(r.Body) > 0 {
+ sb.Write(r.Body)
+ }
+ return sb.String()
+}
+
+// FormatRawResponse serialises a flow's response to a raw HTTP string.
+func FormatRawResponse(f *proxy.Flow) string {
+ r := f.Response
+ if r == nil {
+ return "(no response)"
+ }
+ var sb strings.Builder
+ proto := f.Request.Proto
+ if proto == "" {
+ proto = "HTTP/1.1"
+ }
+ fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
+ keys := make([]string, 0, len(r.Header))
+ for k := range r.Header {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ for _, v := range r.Header[k] {
+ fmt.Fprintf(&sb, "%s: %s\n", k, v)
+ }
+ }
+ sb.WriteString("\n")
+ if len(r.Body) > 0 {
+ sb.Write(r.Body)
+ }
+ return sb.String()
+}
diff --git a/internal/keys/diff.go b/internal/keys/diff.go
new file mode 100644
index 0000000..19e0450
--- /dev/null
+++ b/internal/keys/diff.go
@@ -0,0 +1,20 @@
+package keys
+
+import (
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type DiffKeyMap struct {
+ Clear key.Binding
+}
+
+func newDiffKeyMap(cfg config.DiffKeys) DiffKeyMap {
+ return DiffKeyMap{
+ Clear: binding(cfg.Clear, "clear"),
+ }
+}
+
+func (d DiffKeyMap) Bindings() []key.Binding {
+ return []key.Binding{d.Clear}
+}
diff --git a/internal/keys/findings.go b/internal/keys/findings.go
new file mode 100644
index 0000000..b769667
--- /dev/null
+++ b/internal/keys/findings.go
@@ -0,0 +1,20 @@
+package keys
+
+import (
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type FindingsKeyMap struct {
+ Dismiss key.Binding
+}
+
+func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
+ return FindingsKeyMap{
+ Dismiss: binding(cfg.Dismiss, "dismiss"),
+ }
+}
+
+func (f FindingsKeyMap) Bindings() []key.Binding {
+ return []key.Binding{f.Dismiss}
+}
diff --git a/internal/keys/global.go b/internal/keys/global.go
new file mode 100644
index 0000000..a20b96c
--- /dev/null
+++ b/internal/keys/global.go
@@ -0,0 +1,54 @@
+package keys
+
+import (
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type GlobalKeyMap struct {
+ Quit key.Binding
+ OpenLogs key.Binding
+ ToggleSidebar key.Binding
+ Help key.Binding
+ Up key.Binding
+ Down key.Binding
+ Left key.Binding
+ Right key.Binding
+ CycleFocus key.Binding
+ CopyRequest key.Binding
+ Escape key.Binding
+ SendToReplay key.Binding
+ ScrollUp key.Binding
+ ScrollDown key.Binding
+ SendToDiff key.Binding
+}
+
+func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
+ return GlobalKeyMap{
+ Quit: binding(cfg.Quit, "quit"),
+ OpenLogs: binding(cfg.OpenLogs, "open logs"),
+ ToggleSidebar: binding(cfg.ToggleSidebar, "toggle sidebar"),
+ Help: binding(cfg.Help, "help"),
+ Up: binding(cfg.Up, "up"),
+ Down: binding(cfg.Down, "down"),
+ Left: binding(cfg.Left, "scroll left"),
+ Right: binding(cfg.Right, "scroll right"),
+ CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
+ CopyRequest: binding(cfg.CopyRequest, "copy as..."),
+ Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
+ SendToReplay: binding(cfg.SendToReplay, "send to replay"),
+ ScrollUp: binding(cfg.ScrollUp, "scroll up"),
+ ScrollDown: binding(cfg.ScrollDown, "scroll down"),
+ SendToDiff: binding(cfg.SendToDiff, "send to diff"),
+ }
+}
+
+func (g GlobalKeyMap) Bindings() []key.Binding {
+ return []key.Binding{
+ g.Up, g.Down, g.Left, g.Right, g.CycleFocus,
+ g.Quit, g.Escape, g.Help,
+ g.OpenLogs, g.ToggleSidebar, g.CopyRequest,
+ g.SendToReplay, g.SendToDiff,
+ g.ScrollUp, g.ScrollDown,
+ }
+}
diff --git a/internal/keys/history.go b/internal/keys/history.go
new file mode 100644
index 0000000..3426701
--- /dev/null
+++ b/internal/keys/history.go
@@ -0,0 +1,26 @@
+package keys
+
+import (
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type HistoryKeyMap struct {
+ DeleteEntry key.Binding
+ DeleteAll key.Binding
+ Filter key.Binding
+ SqlQuery key.Binding
+}
+
+func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
+ return HistoryKeyMap{
+ DeleteEntry: binding(cfg.DeleteEntry, "delete entry"),
+ DeleteAll: binding(cfg.DeleteAll, "delete all"),
+ Filter: binding(cfg.Filter, "filter"),
+ SqlQuery: binding(cfg.SqlQuery, "sql query"),
+ }
+}
+
+func (h HistoryKeyMap) Bindings() []key.Binding {
+ return []key.Binding{h.DeleteEntry, h.DeleteAll}
+}
diff --git a/internal/keys/home.go b/internal/keys/home.go
new file mode 100644
index 0000000..6a2329c
--- /dev/null
+++ b/internal/keys/home.go
@@ -0,0 +1,24 @@
+package keys
+
+import (
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type HomeKeyMap struct {
+ Open key.Binding
+ Delete key.Binding
+ Filter key.Binding
+}
+
+func newHomeKeyMap(cfg config.HomeKeys) HomeKeyMap {
+ return HomeKeyMap{
+ Open: binding(cfg.Open, "open"),
+ Delete: binding(cfg.Delete, "delete project"),
+ Filter: binding(cfg.Filter, "filter"),
+ }
+}
+
+func (h HomeKeyMap) Bindings() []key.Binding {
+ return []key.Binding{h.Open, h.Delete, h.Filter}
+}
diff --git a/internal/keys/intercept.go b/internal/keys/intercept.go
new file mode 100644
index 0000000..84f3aba
--- /dev/null
+++ b/internal/keys/intercept.go
@@ -0,0 +1,41 @@
+package keys
+
+import (
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type InterceptKeyMap struct {
+ Forward key.Binding
+ ForwardAll key.Binding
+ Drop key.Binding
+ DropAll key.Binding
+ AutoForward key.Binding
+ CaptureResponse key.Binding
+ UndoEdits key.Binding
+ Edit key.Binding
+ EditExternal key.Binding
+}
+
+func newInterceptKeyMap(cfg config.InterceptKeys) InterceptKeyMap {
+ return InterceptKeyMap{
+ Forward: binding(cfg.Forward, "forward"),
+ ForwardAll: binding(cfg.ForwardAll, "forward all"),
+ Drop: binding(cfg.Drop, "drop"),
+ DropAll: binding(cfg.DropAll, "drop all"),
+ AutoForward: binding(cfg.AutoForward, "auto forward"),
+ CaptureResponse: binding(cfg.CaptureResponse, "capture response"),
+ UndoEdits: binding(cfg.UndoEdits, "undo edits"),
+ Edit: binding(cfg.Edit, "edit"),
+ EditExternal: binding(cfg.EditExternal, "edit in $EDITOR"),
+ }
+}
+
+func (ic InterceptKeyMap) Bindings() []key.Binding {
+ return []key.Binding{
+ ic.Forward, ic.ForwardAll,
+ ic.Drop, ic.DropAll,
+ ic.Edit, ic.EditExternal, ic.UndoEdits,
+ ic.AutoForward, ic.CaptureResponse,
+ }
+}
diff --git a/internal/keys/keys.go b/internal/keys/keys.go
new file mode 100644
index 0000000..849445c
--- /dev/null
+++ b/internal/keys/keys.go
@@ -0,0 +1,72 @@
+package keys
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type KeyMap struct {
+ Global GlobalKeyMap
+ Intercept InterceptKeyMap
+ Home HomeKeyMap
+ History HistoryKeyMap
+ Replay ReplayKeyMap
+ Diff DiffKeyMap
+ Findings FindingsKeyMap
+ Plugins PluginsKeyMap
+}
+
+var Keys *KeyMap
+
+func Init(cfg *config.Config) {
+ kb := cfg.Keybindings
+ Keys = &KeyMap{
+ Global: newGlobalKeyMap(kb.Global),
+ Intercept: newInterceptKeyMap(kb.Intercept),
+ Home: newHomeKeyMap(kb.Home),
+ History: newHistoryKeyMap(kb.History),
+ Replay: newReplayKeyMap(kb.Replay),
+ Diff: newDiffKeyMap(kb.Diff),
+ Findings: newFindingsKeyMap(kb.Findings),
+ Plugins: newPluginsKeyMap(kb.Plugins),
+ }
+}
+
+func parseKeys(s string) []string {
+ parts := strings.Split(s, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if k := strings.TrimSpace(p); k != "" {
+ out = append(out, k)
+ }
+ }
+ return out
+}
+
+func binding(s, help string) key.Binding {
+ keys := parseKeys(s)
+ display := strings.Join(keys, "/")
+ return key.NewBinding(key.WithKeys(keys...), key.WithHelp(display, help))
+}
+
+// ChunkByWidth splits bindings into columns sized to fit the terminal width.
+func ChunkByWidth(bindings []key.Binding, termWidth int) [][]key.Binding {
+ cols := termWidth / 26
+ if cols < 2 {
+ cols = 2
+ } else if cols > 7 {
+ cols = 7
+ }
+ perCol := (len(bindings) + cols - 1) / cols
+ var out [][]key.Binding
+ for i := 0; i < len(bindings); i += perCol {
+ end := i + perCol
+ if end > len(bindings) {
+ end = len(bindings)
+ }
+ out = append(out, bindings[i:end])
+ }
+ return out
+}
diff --git a/internal/keys/plugins.go b/internal/keys/plugins.go
new file mode 100644
index 0000000..2d09f70
--- /dev/null
+++ b/internal/keys/plugins.go
@@ -0,0 +1,24 @@
+package keys
+
+import (
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type PluginsKeyMap struct {
+ Toggle key.Binding
+ EditConfig key.Binding
+ Filter key.Binding
+}
+
+func newPluginsKeyMap(cfg config.PluginsKeys) PluginsKeyMap {
+ return PluginsKeyMap{
+ Toggle: binding(cfg.Toggle, "toggle"),
+ EditConfig: binding(cfg.EditConfig, "edit config"),
+ Filter: binding(cfg.Filter, "filter"),
+ }
+}
+
+func (p PluginsKeyMap) Bindings() []key.Binding {
+ return []key.Binding{p.Toggle, p.EditConfig, p.Filter}
+}
diff --git a/internal/keys/replay.go b/internal/keys/replay.go
new file mode 100644
index 0000000..7643eb8
--- /dev/null
+++ b/internal/keys/replay.go
@@ -0,0 +1,30 @@
+package keys
+
+import (
+ "charm.land/bubbles/v2/key"
+ "github.com/anotherhadi/spilltea/internal/config"
+)
+
+type ReplayKeyMap struct {
+ Send key.Binding
+ Edit key.Binding
+ EditExt key.Binding
+ UndoEdits key.Binding
+ Delete key.Binding
+ DeleteAll key.Binding
+}
+
+func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
+ return ReplayKeyMap{
+ Send: binding(cfg.Send, "send"),
+ Edit: binding(cfg.Edit, "edit"),
+ EditExt: binding(cfg.EditExt, "edit in $EDITOR"),
+ UndoEdits: binding(cfg.UndoEdits, "undo edits"),
+ Delete: binding(cfg.Delete, "delete"),
+ DeleteAll: binding(cfg.DeleteAll, "delete all"),
+ }
+}
+
+func (r ReplayKeyMap) Bindings() []key.Binding {
+ return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll}
+}
diff --git a/internal/plugins/cmd.go b/internal/plugins/cmd.go
new file mode 100644
index 0000000..9b266e5
--- /dev/null
+++ b/internal/plugins/cmd.go
@@ -0,0 +1,15 @@
+package plugins
+
+import tea "charm.land/bubbletea/v2"
+
+func WaitForNotif(mgr *Manager) tea.Cmd {
+ return func() tea.Msg {
+ return <-mgr.Notifs
+ }
+}
+
+func WaitForQuit(mgr *Manager) tea.Cmd {
+ return func() tea.Msg {
+ return PluginQuitMsg{Reason: <-mgr.Quit}
+ }
+}
diff --git a/internal/plugins/lua.go b/internal/plugins/lua.go
new file mode 100644
index 0000000..e838a56
--- /dev/null
+++ b/internal/plugins/lua.go
@@ -0,0 +1,206 @@
+package plugins
+
+import (
+ "log"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/anotherhadi/spilltea/internal/db"
+ goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
+ lua "github.com/yuin/gopher-lua"
+)
+
+func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
+ L := lua.NewState()
+ registerUtilities(L, mgr, p)
+ return L
+}
+
+func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
+ L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
+ msg := L.CheckString(1)
+ log.Printf("[plugin:%s] %s", p.Name, msg)
+ return 0
+ }))
+
+ L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int {
+ title := L.CheckString(1)
+ body := L.CheckString(2)
+ select {
+ case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body}:
+ default:
+ }
+ return 0
+ }))
+
+ L.SetGlobal("create_finding", L.NewFunction(func(L *lua.LState) int {
+ t := L.CheckTable(1)
+ title := luaTableString(t, "title")
+ desc := luaTableString(t, "description")
+ key := luaTableString(t, "key")
+ severity := luaTableString(t, "severity")
+ if severity == "" {
+ severity = "info"
+ }
+ if key == "" {
+ key = title
+ }
+ if mgr.db == nil {
+ return 0
+ }
+ inserted, err := mgr.db.UpsertFinding(db.Finding{
+ PluginName: p.Name,
+ DedupKey: key,
+ Title: title,
+ Description: desc,
+ Severity: severity,
+ CreatedAt: time.Now(),
+ })
+ if err != nil {
+ log.Printf("[plugin:%s] create_finding error: %v", p.Name, err)
+ return 0
+ }
+ _ = inserted
+ return 0
+ }))
+
+ L.SetGlobal("is_in_scope", L.NewFunction(func(L *lua.LState) int {
+ raw := L.CheckString(1)
+ if mgr.broker == nil {
+ L.Push(lua.LTrue)
+ return 1
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ L.Push(lua.LFalse)
+ return 1
+ }
+ path := u.Path
+ if path == "" {
+ path = "/"
+ }
+ L.Push(lua.LBool(mgr.broker.IsInScope(u.Host + path)))
+ return 1
+ }))
+
+ L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
+ reason := L.OptString(1, "plugin requested quit")
+ select {
+ case mgr.Quit <- reason:
+ default:
+ }
+ return 0
+ }))
+}
+
+func luaTableString(t *lua.LTable, key string) string {
+ v := t.RawGetString(key)
+ if s, ok := v.(lua.LString); ok {
+ return string(s)
+ }
+ return ""
+}
+
+func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
+ t := L.NewTable()
+ r := f.Request
+ L.SetField(t, "method", lua.LString(r.Method))
+ L.SetField(t, "url", lua.LString(r.URL.String()))
+ L.SetField(t, "host", lua.LString(r.URL.Host))
+ L.SetField(t, "path", lua.LString(r.URL.Path))
+
+ headers := L.NewTable()
+ for k, vals := range r.Header {
+ L.SetField(headers, k, lua.LString(strings.Join(vals, ", ")))
+ }
+ L.SetField(t, "headers", headers)
+
+ L.SetField(t, "get_body", L.NewFunction(func(L *lua.LState) int {
+ L.Push(lua.LString(string(r.Body)))
+ return 1
+ }))
+
+ L.SetField(t, "set_header", L.NewFunction(func(L *lua.LState) int {
+ name := L.CheckString(2)
+ value := L.CheckString(3)
+ r.Header.Set(name, value)
+ return 0
+ }))
+
+ L.SetField(t, "set_body", L.NewFunction(func(L *lua.LState) int {
+ body := L.CheckString(2)
+ r.Body = []byte(body)
+ return 0
+ }))
+
+ return t
+}
+
+func pushResponse(L *lua.LState, f *goproxy.Flow) *lua.LTable {
+ t := L.NewTable()
+ if f.Response == nil {
+ return t
+ }
+ resp := f.Response
+ L.SetField(t, "status_code", lua.LNumber(resp.StatusCode))
+
+ headers := L.NewTable()
+ for k, vals := range resp.Header {
+ L.SetField(headers, k, lua.LString(strings.Join(vals, ", ")))
+ }
+ L.SetField(t, "headers", headers)
+
+ L.SetField(t, "get_body", L.NewFunction(func(L *lua.LState) int {
+ L.Push(lua.LString(string(resp.Body)))
+ return 1
+ }))
+
+ L.SetField(t, "set_header", L.NewFunction(func(L *lua.LState) int {
+ name := L.CheckString(2)
+ value := L.CheckString(3)
+ resp.Header.Set(name, value)
+ return 0
+ }))
+
+ L.SetField(t, "set_body", L.NewFunction(func(L *lua.LState) int {
+ body := L.CheckString(2)
+ resp.Body = []byte(body)
+ return 0
+ }))
+
+ return t
+}
+
+func pushEntry(L *lua.LState, e db.Entry) *lua.LTable {
+ t := L.NewTable()
+ L.SetField(t, "id", lua.LNumber(e.ID))
+ L.SetField(t, "method", lua.LString(e.Method))
+ L.SetField(t, "host", lua.LString(e.Host))
+ L.SetField(t, "path", lua.LString(e.Path))
+ L.SetField(t, "status_code", lua.LNumber(e.StatusCode))
+ L.SetField(t, "timestamp", lua.LString(e.Timestamp.Format("2006-01-02 15:04:05")))
+ L.SetField(t, "request_raw", lua.LString(e.RequestRaw))
+ L.SetField(t, "response_raw", lua.LString(e.ResponseRaw))
+ return t
+}
+
+func callHook(p *Plugin, hookName string, args ...lua.LValue) (string, error) {
+ fn := p.L.GetGlobal(hookName)
+ if fn == lua.LNil {
+ return "", nil
+ }
+ if err := p.L.CallByParam(lua.P{
+ Fn: fn,
+ NRet: 1,
+ Protect: true,
+ }, args...); err != nil {
+ return "", err
+ }
+ ret := p.L.Get(-1)
+ p.L.Pop(1)
+ if s, ok := ret.(lua.LString); ok {
+ return string(s), nil
+ }
+ return "", nil
+}
diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go
new file mode 100644
index 0000000..63542f9
--- /dev/null
+++ b/internal/plugins/manager.go
@@ -0,0 +1,346 @@
+package plugins
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/anotherhadi/spilltea/internal/db"
+ "github.com/anotherhadi/spilltea/internal/intercept"
+ goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
+ lua "github.com/yuin/gopher-lua"
+)
+
+type Manager struct {
+ mu sync.RWMutex
+ plugins []*Plugin
+
+ db *db.DB
+ broker *intercept.Broker
+
+ Notifs chan PluginNotifMsg
+ Quit chan string
+}
+
+func NewManager(broker *intercept.Broker) *Manager {
+ mgr := &Manager{
+ broker: broker,
+ Notifs: make(chan PluginNotifMsg, 64),
+ Quit: make(chan string, 4),
+ }
+ if broker != nil {
+ broker.SetOnNewEntry(mgr.RunOnHistoryEntry)
+ }
+ return mgr
+}
+
+func (m *Manager) SetDB(d *db.DB) {
+ m.db = d
+}
+
+func (m *Manager) LoadFromDir(dir string) error {
+ entries, err := os.ReadDir(dir)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+
+ var states map[string]db.PluginState
+ if m.db != nil {
+ list, err := m.db.LoadPluginStates()
+ if err == nil {
+ states = make(map[string]db.PluginState, len(list))
+ for _, s := range list {
+ states[s.Name] = s
+ }
+ }
+ }
+
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
+ continue
+ }
+ path := filepath.Join(dir, e.Name())
+ p, err := m.loadPlugin(path)
+ if err != nil {
+ log.Printf("plugin load error %s: %v", path, err)
+ continue
+ }
+ if s, ok := states[p.Name]; ok {
+ p.Enabled = s.Enabled
+ p.ConfigText = s.ConfigText
+ }
+ m.mu.Lock()
+ m.plugins = append(m.plugins, p)
+ m.mu.Unlock()
+ }
+ return nil
+}
+
+func (m *Manager) loadPlugin(path string) (*Plugin, error) {
+ p := &Plugin{
+ FilePath: path,
+ Enabled: true,
+ hooks: make(map[string]HookConfig),
+ }
+ p.L = newLuaState(m, p)
+ if err := p.L.DoFile(path); err != nil {
+ p.L.Close()
+ return nil, err
+ }
+
+ pluginTable, ok := p.L.GetGlobal("Plugin").(*lua.LTable)
+ if !ok {
+ p.L.Close()
+ return nil, fmt.Errorf("missing Plugin table")
+ }
+
+ if s, ok := pluginTable.RawGetString("name").(lua.LString); ok {
+ p.Name = string(s)
+ }
+ if p.Name == "" {
+ p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
+ }
+
+ // Defaults when not overridden by the Plugin table.
+ hookDefaults := map[string]bool{
+ "on_start": true, // always sync
+ "on_request": false, // async
+ "on_response": false, // async
+ "on_quit": true, // always sync
+ "on_history_entry": false, // always async
+ }
+ for hookName, defaultSync := range hookDefaults {
+ // Plugin table entry overrides the default (except on_start/on_quit/on_history_entry which are fixed).
+ if hookName != "on_start" && hookName != "on_quit" && hookName != "on_history_entry" {
+ if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
+ p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
+ continue
+ }
+ }
+ // Auto-detect: register the hook if the function exists as a global.
+ if p.L.GetGlobal(hookName) != lua.LNil {
+ p.hooks[hookName] = HookConfig{Sync: defaultSync}
+ }
+ }
+
+ return p, nil
+}
+
+func (m *Manager) GetPlugins() []*Plugin {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ out := make([]*Plugin, len(m.plugins))
+ copy(out, m.plugins)
+ return out
+}
+
+func (m *Manager) TogglePlugin(name string) {
+ m.mu.RLock()
+ var found *Plugin
+ for _, p := range m.plugins {
+ if p.Name == name {
+ found = p
+ break
+ }
+ }
+ m.mu.RUnlock()
+ if found == nil {
+ return
+ }
+ found.mu.Lock()
+ found.Enabled = !found.Enabled
+ enabled := found.Enabled
+ configText := found.ConfigText
+ found.mu.Unlock()
+ if m.db != nil {
+ _ = m.db.SavePluginState(name, enabled, configText)
+ }
+}
+
+func (m *Manager) SaveConfig(name, configText string) {
+ m.mu.RLock()
+ var found *Plugin
+ for _, p := range m.plugins {
+ if p.Name == name {
+ found = p
+ break
+ }
+ }
+ m.mu.RUnlock()
+ if found == nil {
+ return
+ }
+ found.mu.Lock()
+ found.ConfigText = configText
+ enabled := found.Enabled
+ hc, hasOnStart := found.hooks["on_start"]
+ found.mu.Unlock()
+ if m.db != nil {
+ _ = m.db.SavePluginState(name, enabled, configText)
+ }
+ if !hasOnStart {
+ return
+ }
+ // Re-run on_start so the plugin can re-parse the new config.
+ if hc.Sync {
+ found.mu.Lock()
+ if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil {
+ log.Printf("plugin %s on_start (config reload): %v", name, err)
+ }
+ found.mu.Unlock()
+ } else {
+ go func() {
+ found.mu.Lock()
+ if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil {
+ log.Printf("plugin %s on_start (config reload): %v", name, err)
+ }
+ found.mu.Unlock()
+ }()
+ }
+}
+
+func (m *Manager) RunOnStart() {
+ for _, p := range m.GetPlugins() {
+ if !p.Enabled {
+ continue
+ }
+ if _, ok := p.hooks["on_start"]; !ok {
+ continue
+ }
+ p.mu.Lock()
+ if _, err := callHook(p, "on_start", lua.LString(p.ConfigText)); err != nil {
+ log.Printf("plugin %s on_start: %v", p.Name, err)
+ }
+ p.mu.Unlock()
+ }
+}
+
+func (m *Manager) RunOnQuit() {
+ for _, p := range m.GetPlugins() {
+ if !p.Enabled {
+ continue
+ }
+ if _, ok := p.hooks["on_quit"]; !ok {
+ continue
+ }
+ p.mu.Lock()
+ if _, err := callHook(p, "on_quit"); err != nil {
+ log.Printf("plugin %s on_quit: %v", p.Name, err)
+ }
+ p.mu.Unlock()
+ }
+}
+
+func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
+ for _, p := range m.GetPlugins() {
+ if !p.Enabled {
+ continue
+ }
+ hc, ok := p.hooks["on_request"]
+ if !ok || !hc.Sync {
+ continue
+ }
+ p.mu.Lock()
+ result, err := callHook(p, "on_request", pushRequest(p.L, f))
+ p.mu.Unlock()
+ if err != nil {
+ log.Printf("plugin %s on_request: %v", p.Name, err)
+ continue
+ }
+ switch result {
+ case "drop":
+ return intercept.Drop
+ case "forward":
+ return intercept.Forward
+ }
+ }
+ return intercept.Intercept
+}
+
+func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
+ for _, p := range m.GetPlugins() {
+ if !p.Enabled {
+ continue
+ }
+ hc, ok := p.hooks["on_request"]
+ if !ok || hc.Sync {
+ continue
+ }
+ go func(p *Plugin) {
+ p.mu.Lock()
+ if _, err := callHook(p, "on_request", pushRequest(p.L, f)); err != nil {
+ log.Printf("plugin %s on_request: %v", p.Name, err)
+ }
+ p.mu.Unlock()
+ }(p)
+ }
+}
+
+func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
+ for _, p := range m.GetPlugins() {
+ if !p.Enabled {
+ continue
+ }
+ hc, ok := p.hooks["on_response"]
+ if !ok || !hc.Sync {
+ continue
+ }
+ p.mu.Lock()
+ result, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f))
+ p.mu.Unlock()
+ if err != nil {
+ log.Printf("plugin %s on_response: %v", p.Name, err)
+ continue
+ }
+ switch result {
+ case "drop":
+ return intercept.Drop
+ case "forward":
+ return intercept.Forward
+ }
+ }
+ return intercept.Intercept
+}
+
+func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
+ for _, p := range m.GetPlugins() {
+ if !p.Enabled {
+ continue
+ }
+ hc, ok := p.hooks["on_response"]
+ if !ok || hc.Sync {
+ continue
+ }
+ go func(p *Plugin) {
+ p.mu.Lock()
+ if _, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f)); err != nil {
+ log.Printf("plugin %s on_response: %v", p.Name, err)
+ }
+ p.mu.Unlock()
+ }(p)
+ }
+}
+
+func (m *Manager) RunOnHistoryEntry(e db.Entry) {
+ for _, p := range m.GetPlugins() {
+ if !p.Enabled {
+ continue
+ }
+ if _, ok := p.hooks["on_history_entry"]; !ok {
+ continue
+ }
+ go func(p *Plugin) {
+ p.mu.Lock()
+ if _, err := callHook(p, "on_history_entry", pushEntry(p.L, e)); err != nil {
+ log.Printf("plugin %s on_history_entry: %v", p.Name, err)
+ }
+ p.mu.Unlock()
+ }(p)
+ }
+}
diff --git a/internal/plugins/types.go b/internal/plugins/types.go
new file mode 100644
index 0000000..a74c521
--- /dev/null
+++ b/internal/plugins/types.go
@@ -0,0 +1,66 @@
+package plugins
+
+import (
+ "sync"
+
+ lua "github.com/yuin/gopher-lua"
+)
+
+type HookConfig struct {
+ Sync bool
+}
+
+type Plugin struct {
+ Name string
+ FilePath string
+ Enabled bool
+ ConfigText string
+
+ L *lua.LState
+ mu sync.Mutex
+ hooks map[string]HookConfig
+}
+
+func (p *Plugin) HookNames() []string {
+ out := make([]string, 0, len(p.hooks))
+ for name := range p.hooks {
+ out = append(out, name)
+ }
+ return out
+}
+
+func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
+ hc, ok := p.hooks[name]
+ return hc, ok
+}
+
+type Info struct {
+ Name string
+ FilePath string
+ Enabled bool
+ ConfigText string
+ Hooks map[string]HookConfig
+}
+
+func (p *Plugin) Info() Info {
+ hooks := make(map[string]HookConfig, len(p.hooks))
+ for k, v := range p.hooks {
+ hooks[k] = v
+ }
+ return Info{
+ Name: p.Name,
+ FilePath: p.FilePath,
+ Enabled: p.Enabled,
+ ConfigText: p.ConfigText,
+ Hooks: hooks,
+ }
+}
+
+type PluginNotifMsg struct {
+ Title string
+ Body string
+}
+
+type PluginQuitMsg struct {
+ Reason string
+}
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
new file mode 100644
index 0000000..353ee52
--- /dev/null
+++ b/internal/proxy/proxy.go
@@ -0,0 +1,128 @@
+package proxy
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/anotherhadi/spilltea/internal/config"
+ "github.com/anotherhadi/spilltea/internal/intercept"
+ "github.com/anotherhadi/spilltea/internal/plugins"
+ goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
+)
+
+type ErrMsg struct{ Err error }
+
+func StartCmd(broker *intercept.Broker, mgr *plugins.Manager) tea.Cmd {
+ return func() tea.Msg {
+ if err := Start(broker, mgr); err != nil {
+ return ErrMsg{Err: err}
+ }
+ return ErrMsg{}
+ }
+}
+
+type interceptAddon struct {
+ goproxy.BaseAddon
+ broker *intercept.Broker
+ plugins *plugins.Manager
+}
+
+// ClientConnected disables upstream cert fetching so the upstream TCP/TLS
+// connection is established only after Hold() returns, not during CONNECT.
+// Without this, the upstream connection sits idle while the TUI holds the
+// request, and the server closes it (keep-alive timeout) → unexpected EOF.
+func (a *interceptAddon) ClientConnected(clientConn *goproxy.ClientConn) {
+ clientConn.UpstreamCert = false
+}
+
+func (a *interceptAddon) Request(f *goproxy.Flow) {
+ if a.plugins != nil {
+ switch a.plugins.RunSyncOnRequest(f) {
+ case intercept.Drop:
+ f.Response = dropResponse()
+ go a.plugins.RunAsyncOnRequest(f)
+ return
+ case intercept.Forward:
+ go a.plugins.RunAsyncOnRequest(f)
+ return
+ }
+ }
+
+ if a.broker.Hold(f) == intercept.Drop {
+ f.Response = dropResponse()
+ }
+
+ if a.plugins != nil {
+ go a.plugins.RunAsyncOnRequest(f)
+ }
+}
+
+func (a *interceptAddon) Response(f *goproxy.Flow) {
+ if f.Response != nil {
+ if len(f.Response.Body) == 0 && f.Response.BodyReader != nil {
+ body, _ := io.ReadAll(f.Response.BodyReader)
+ f.Response.Body = body
+ f.Response.BodyReader = nil
+ }
+ f.Response.ReplaceToDecodedBody()
+ }
+
+ if a.plugins != nil {
+ switch a.plugins.RunSyncOnResponse(f) {
+ case intercept.Drop:
+ a.broker.SaveEntry(f)
+ f.Response = dropResponse()
+ go a.plugins.RunAsyncOnResponse(f)
+ return
+ case intercept.Forward:
+ a.broker.SaveEntry(f)
+ go a.plugins.RunAsyncOnResponse(f)
+ return
+ }
+ }
+
+ decision := a.broker.HoldResponse(f)
+ a.broker.SaveEntry(f)
+ if decision == intercept.Drop {
+ f.Response = dropResponse()
+ }
+
+ if a.plugins != nil {
+ go a.plugins.RunAsyncOnResponse(f)
+ }
+}
+
+func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
+ cfg := config.Global.App
+ addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
+ caPath := config.ExpandPath(cfg.CertDir)
+
+ if err := os.MkdirAll(caPath, 0o700); err != nil {
+ return fmt.Errorf("ca dir: %w", err)
+ }
+
+ opts := &goproxy.Options{
+ Addr: addr,
+ StreamLargeBodies: 1024 * 1024 * 5,
+ CaRootPath: caPath,
+ }
+
+ p, err := goproxy.NewProxy(opts)
+ if err != nil {
+ return err
+ }
+
+ p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
+ return p.Start()
+}
+
+func dropResponse() *goproxy.Response {
+ return &goproxy.Response{
+ StatusCode: 502,
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ Body: []byte("Dropped by spilltea"),
+ }
+}
diff --git a/internal/style/border.go b/internal/style/border.go
new file mode 100644
index 0000000..196821a
--- /dev/null
+++ b/internal/style/border.go
@@ -0,0 +1,43 @@
+package style
+
+import (
+ "strings"
+
+ "charm.land/lipgloss/v2"
+)
+
+// PanelContentH returns the usable inner content height for a panel rendered by
+// RenderWithTitle. It subtracts the two border lines (top + bottom) from the
+// total panel height.
+func PanelContentH(totalH int) int {
+ h := totalH - 2
+ if h < 0 {
+ return 0
+ }
+ return h
+}
+
+// RenderWithTitle renders a lipgloss bordered box with a title embedded in the
+// top border, matching the border's own foreground color. height is the total
+// desired output height (including both border lines).
+func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
+ boxH := height - 1
+ if contentH := boxH - 1; contentH > 0 {
+ lines := strings.Split(content, "\n")
+ if len(lines) > contentH {
+ content = strings.Join(lines[:contentH], "\n")
+ }
+ }
+ box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
+
+ boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
+ label := " " + title + " "
+ fillW := boxWidth - lipgloss.Width(label) - 2
+ if fillW < 0 {
+ fillW = 0
+ }
+ topLine := "╭" + label + strings.Repeat("─", fillW) + "╮"
+ topLine = lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()).Render(topLine)
+
+ return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
+}
diff --git a/internal/style/components.go b/internal/style/components.go
new file mode 100644
index 0000000..9c52643
--- /dev/null
+++ b/internal/style/components.go
@@ -0,0 +1,84 @@
+package style
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/paginator"
+ "charm.land/bubbles/v2/textarea"
+ "charm.land/bubbles/v2/viewport"
+ "charm.land/lipgloss/v2"
+)
+
+func NewViewport() viewport.Model {
+ vp := viewport.New()
+ vp.MouseWheelEnabled = false
+ return vp
+}
+
+func NewPaginator() paginator.Model {
+ p := paginator.New()
+ p.Type = paginator.Dots
+ p.ActiveDot = S.PagerDotActive
+ p.InactiveDot = S.PagerDotInactive
+ return p
+}
+
+func NewTextarea(showLineNumbers bool) textarea.Model {
+ ta := textarea.New()
+ ta.Prompt = ""
+ ta.ShowLineNumbers = showLineNumbers
+ ta.CharLimit = 0
+ ts := ta.Styles()
+ ts.Focused.Base = lipgloss.NewStyle()
+ ts.Blurred.Base = lipgloss.NewStyle()
+ ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
+ ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
+ ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
+ ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
+ ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
+ ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg)
+ ta.SetStyles(ts)
+ return ta
+}
+
+// SeverityStyle returns a bold lipgloss style coloured by finding severity level.
+func SeverityStyle(sev string) lipgloss.Style {
+ base := lipgloss.NewStyle().Bold(true)
+ switch sev {
+ case "critical":
+ return base.Foreground(S.Error)
+ case "high":
+ return base.Foreground(S.Warning)
+ case "medium":
+ return base.Foreground(S.Primary)
+ case "low":
+ return base.Foreground(S.Success)
+ default:
+ return base.Foreground(S.Subtle)
+ }
+}
+
+// StatusStyle returns a bold lipgloss style coloured by HTTP status code.
+func StatusStyle(code, width int) lipgloss.Style {
+ base := lipgloss.NewStyle().Bold(true).Width(width)
+ switch {
+ case code >= 500:
+ return base.Foreground(S.Error)
+ case code >= 400:
+ return base.Foreground(S.Warning)
+ case code >= 300:
+ return base.Foreground(S.Primary)
+ default:
+ return base.Foreground(S.Success)
+ }
+}
+
+// SplitH splits totalHeight into top and bottom sections, accounting for the
+// status bar height.
+func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
+ statusH := strings.Count(statusBar, "\n") + 1
+ available := totalHeight - statusH
+ top = int(float64(available) * ratio)
+ bottom = available - top
+ return
+}
diff --git a/internal/style/glamour.go b/internal/style/glamour.go
new file mode 100644
index 0000000..0c07dcb
--- /dev/null
+++ b/internal/style/glamour.go
@@ -0,0 +1,236 @@
+package style
+
+import (
+ "github.com/anotherhadi/spilltea/internal/config"
+
+ "charm.land/glamour/v2/ansi"
+)
+
+func GlamourStyleConfig(cfg *config.Config) ansi.StyleConfig {
+ c := cfg.TUI.Colors
+
+ str := func(s string) *string { return &s }
+ hex := func(base string) *string { return str("#" + base) }
+ boolPtr := func(b bool) *bool { return &b }
+ uintPtr := func(u uint) *uint { return &u }
+
+ return ansi.StyleConfig{
+ Document: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockPrefix: "\n",
+ BlockSuffix: "\n",
+ Color: hex(c.Base05),
+ },
+ Margin: uintPtr(2),
+ },
+ BlockQuote: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: hex(c.Base03),
+ Italic: boolPtr(true),
+ },
+ Indent: uintPtr(1),
+ IndentToken: str("│ "),
+ },
+ List: ansi.StyleList{
+ LevelIndent: 2,
+ },
+ Heading: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockSuffix: "\n",
+ Color: hex(c.Base0D),
+ Bold: boolPtr(true),
+ },
+ },
+ H1: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: " ",
+ Suffix: " ",
+ Color: hex(c.Base07),
+ BackgroundColor: hex(c.Base0D),
+ Bold: boolPtr(true),
+ },
+ },
+ H2: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "## ",
+ Color: hex(c.Base0D),
+ Bold: boolPtr(true),
+ },
+ },
+ H3: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "### ",
+ Color: hex(c.Base0C),
+ },
+ },
+ H4: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "#### ",
+ Color: hex(c.Base0B),
+ },
+ },
+ H5: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "##### ",
+ Color: hex(c.Base09),
+ },
+ },
+ H6: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "###### ",
+ Color: hex(c.Base08),
+ Bold: boolPtr(false),
+ },
+ },
+ Strikethrough: ansi.StylePrimitive{
+ CrossedOut: boolPtr(true),
+ },
+ Emph: ansi.StylePrimitive{
+ Italic: boolPtr(true),
+ },
+ Strong: ansi.StylePrimitive{
+ Bold: boolPtr(true),
+ },
+ HorizontalRule: ansi.StylePrimitive{
+ Color: hex(c.Base03),
+ Format: "\n--------\n",
+ },
+ Item: ansi.StylePrimitive{
+ BlockPrefix: "• ",
+ },
+ Enumeration: ansi.StylePrimitive{
+ BlockPrefix: ". ",
+ },
+ Task: ansi.StyleTask{
+ Ticked: "[✓] ",
+ Unticked: "[ ] ",
+ },
+ Link: ansi.StylePrimitive{
+ Color: hex(c.Base0C),
+ Underline: boolPtr(true),
+ },
+ LinkText: ansi.StylePrimitive{
+ Color: hex(c.Base0D),
+ Bold: boolPtr(true),
+ },
+ Image: ansi.StylePrimitive{
+ Color: hex(c.Base0C),
+ Underline: boolPtr(true),
+ },
+ ImageText: ansi.StylePrimitive{
+ Color: hex(c.Base04),
+ Format: "Image: {{.text}} ->",
+ },
+ Code: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: " ",
+ Suffix: " ",
+ Color: hex(c.Base0B),
+ BackgroundColor: hex(c.Base01),
+ },
+ },
+ CodeBlock: ansi.StyleCodeBlock{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: hex(c.Base04),
+ },
+ Margin: uintPtr(2),
+ },
+ Chroma: &ansi.Chroma{
+ Text: ansi.StylePrimitive{
+ Color: hex(c.Base05),
+ },
+ Error: ansi.StylePrimitive{
+ Color: hex(c.Base07),
+ BackgroundColor: hex(c.Base08),
+ },
+ Comment: ansi.StylePrimitive{
+ Color: hex(c.Base03),
+ Italic: boolPtr(true),
+ },
+ CommentPreproc: ansi.StylePrimitive{
+ Color: hex(c.Base09),
+ },
+ Keyword: ansi.StylePrimitive{
+ Color: hex(c.Base0E),
+ },
+ KeywordReserved: ansi.StylePrimitive{
+ Color: hex(c.Base0E),
+ },
+ KeywordNamespace: ansi.StylePrimitive{
+ Color: hex(c.Base0D),
+ },
+ KeywordType: ansi.StylePrimitive{
+ Color: hex(c.Base0A),
+ },
+ Operator: ansi.StylePrimitive{
+ Color: hex(c.Base05),
+ },
+ Punctuation: ansi.StylePrimitive{
+ Color: hex(c.Base05),
+ },
+ Name: ansi.StylePrimitive{
+ Color: hex(c.Base05),
+ },
+ NameBuiltin: ansi.StylePrimitive{
+ Color: hex(c.Base0D),
+ },
+ NameTag: ansi.StylePrimitive{
+ Color: hex(c.Base08),
+ },
+ NameAttribute: ansi.StylePrimitive{
+ Color: hex(c.Base0A),
+ },
+ NameClass: ansi.StylePrimitive{
+ Color: hex(c.Base0A),
+ Bold: boolPtr(true),
+ Underline: boolPtr(true),
+ },
+ NameConstant: ansi.StylePrimitive{
+ Color: hex(c.Base09),
+ },
+ NameDecorator: ansi.StylePrimitive{
+ Color: hex(c.Base0C),
+ },
+ NameFunction: ansi.StylePrimitive{
+ Color: hex(c.Base0D),
+ },
+ LiteralNumber: ansi.StylePrimitive{
+ Color: hex(c.Base09),
+ },
+ LiteralString: ansi.StylePrimitive{
+ Color: hex(c.Base0B),
+ },
+ LiteralStringEscape: ansi.StylePrimitive{
+ Color: hex(c.Base0C),
+ },
+ GenericDeleted: ansi.StylePrimitive{
+ Color: hex(c.Base08),
+ },
+ GenericEmph: ansi.StylePrimitive{
+ Italic: boolPtr(true),
+ },
+ GenericInserted: ansi.StylePrimitive{
+ Color: hex(c.Base0B),
+ },
+ GenericStrong: ansi.StylePrimitive{
+ Bold: boolPtr(true),
+ },
+ GenericSubheading: ansi.StylePrimitive{
+ Color: hex(c.Base04),
+ },
+ Background: ansi.StylePrimitive{
+ BackgroundColor: hex(c.Base01),
+ },
+ },
+ },
+ Table: ansi.StyleTable{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{},
+ },
+ },
+ DefinitionDescription: ansi.StylePrimitive{
+ BlockPrefix: "\n> ",
+ },
+ }
+}
diff --git a/internal/style/highlight.go b/internal/style/highlight.go
new file mode 100644
index 0000000..04f5586
--- /dev/null
+++ b/internal/style/highlight.go
@@ -0,0 +1,333 @@
+package style
+
+import (
+ "bytes"
+ "encoding/json"
+ "image/color"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/anotherhadi/spilltea/internal/config"
+ "golang.org/x/net/html"
+)
+
+func Paint(c color.Color, s string) string {
+ return lipgloss.NewStyle().Foreground(c).Render(s)
+}
+
+// HighlightHTTP highlights a full raw HTTP message (headers + body).
+func HighlightHTTP(raw string) string {
+ raw = strings.ReplaceAll(raw, "\r\n", "\n")
+ raw = strings.ReplaceAll(raw, "\r", "\n")
+ idx := strings.Index(raw, "\n\n")
+ if idx == -1 {
+ return highlightHeaders(raw)
+ }
+ headers := raw[:idx+2]
+ body := raw[idx+2:]
+ result := highlightHeaders(headers)
+ if body == "" {
+ return result
+ }
+ pretty := config.Global != nil && config.Global.TUI.PrettyPrintBody
+ switch detectBodyType(headers) {
+ case "json":
+ if pretty {
+ body = prettyJSON(body)
+ }
+ result += highlightJSON(body)
+ case "html":
+ if pretty {
+ body = prettyHTML(body)
+ }
+ result += highlightHTML(body)
+ default:
+ result += body
+ }
+ return result
+}
+
+func detectBodyType(headers string) string {
+ for _, line := range strings.Split(headers, "\n") {
+ lower := strings.ToLower(line)
+ if !strings.HasPrefix(lower, "content-type:") {
+ continue
+ }
+ ct := strings.ToLower(strings.TrimSpace(line[len("content-type:"):]))
+ switch {
+ case strings.Contains(ct, "json"):
+ return "json"
+ case strings.Contains(ct, "html"):
+ return "html"
+ }
+ break
+ }
+ return ""
+}
+
+func highlightHeaders(raw string) string {
+ var out strings.Builder
+ lines := strings.Split(raw, "\n")
+ for i, line := range lines {
+ trimmed := strings.TrimRight(line, "\r")
+ if i == 0 {
+ out.WriteString(highlightStatusLine(trimmed))
+ } else if trimmed == "" {
+ out.WriteString(line)
+ } else if idx := strings.Index(trimmed, ": "); idx != -1 {
+ out.WriteString(Paint(S.Subtle, trimmed[:idx+2]))
+ out.WriteString(Paint(S.Text, trimmed[idx+2:]))
+ } else {
+ out.WriteString(line)
+ }
+ if i < len(lines)-1 {
+ out.WriteByte('\n')
+ }
+ }
+ return out.String()
+}
+
+func highlightStatusLine(line string) string {
+ parts := strings.SplitN(line, " ", 3)
+ if len(parts) < 2 {
+ return line
+ }
+ switch parts[0] {
+ case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
+ result := S.Method(parts[0]).Width(0).Render(parts[0]) + " "
+ result += Paint(S.Primary, parts[1])
+ if len(parts) == 3 {
+ result += " " + Paint(S.Subtle, parts[2])
+ }
+ return result
+ }
+ result := Paint(S.Subtle, parts[0]) + " "
+ result += Paint(S.Warning, parts[1])
+ if len(parts) == 3 {
+ result += " " + Paint(S.MutedFg, parts[2])
+ }
+ return result
+}
+
+func highlightJSON(s string) string {
+ var out strings.Builder
+ i, n := 0, len(s)
+ for i < n {
+ ch := s[i]
+ switch {
+ case ch == '"':
+ j := i + 1
+ for j < n {
+ if s[j] == '\\' {
+ j += 2
+ continue
+ }
+ if s[j] == '"' {
+ j++
+ break
+ }
+ j++
+ }
+ str := s[i:j]
+ k := j
+ for k < n && (s[k] == ' ' || s[k] == '\t') {
+ k++
+ }
+ if k < n && s[k] == ':' {
+ out.WriteString(Paint(S.Primary, str))
+ } else {
+ out.WriteString(Paint(S.Success, str))
+ }
+ i = j
+ case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
+ j := i
+ if s[j] == '-' {
+ j++
+ }
+ for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') {
+ j++
+ }
+ out.WriteString(Paint(S.Warning, s[i:j]))
+ i = j
+ case i+4 <= n && s[i:i+4] == "true":
+ out.WriteString(Paint(S.Error, "true"))
+ i += 4
+ case i+5 <= n && s[i:i+5] == "false":
+ out.WriteString(Paint(S.Error, "false"))
+ i += 5
+ case i+4 <= n && s[i:i+4] == "null":
+ out.WriteString(Paint(S.Error, "null"))
+ i += 4
+ case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
+ out.WriteString(Paint(S.Subtle, string(ch)))
+ i++
+ default:
+ out.WriteByte(ch)
+ i++
+ }
+ }
+ return out.String()
+}
+
+func prettyJSON(s string) string {
+ var buf bytes.Buffer
+ if err := json.Indent(&buf, []byte(strings.TrimSpace(s)), "", " "); err != nil {
+ return s
+ }
+ return buf.String()
+}
+
+var voidHTMLElements = map[string]bool{
+ "area": true, "base": true, "br": true, "col": true, "embed": true,
+ "hr": true, "img": true, "input": true, "link": true, "meta": true,
+ "param": true, "source": true, "track": true, "wbr": true,
+}
+
+func prettyHTML(s string) string {
+ doc, err := html.Parse(strings.NewReader(s))
+ if err != nil {
+ return s
+ }
+ var buf strings.Builder
+ walkHTMLNode(&buf, doc, 0)
+ return strings.TrimRight(buf.String(), "\n")
+}
+
+func walkHTMLNode(w *strings.Builder, n *html.Node, depth int) {
+ indent := strings.Repeat(" ", depth)
+ switch n.Type {
+ case html.DocumentNode:
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walkHTMLNode(w, c, depth)
+ }
+ case html.DoctypeNode:
+ w.WriteString("\n")
+ case html.CommentNode:
+ w.WriteString(indent + "\n")
+ case html.TextNode:
+ text := strings.TrimSpace(n.Data)
+ if text != "" {
+ w.WriteString(indent + text + "\n")
+ }
+ case html.ElementNode:
+ tag := buildHTMLOpenTag(n)
+ if voidHTMLElements[n.Data] {
+ w.WriteString(indent + tag + "\n")
+ return
+ }
+ w.WriteString(indent + tag + "\n")
+ if n.Data == "script" || n.Data == "style" {
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if c.Type == html.TextNode {
+ text := strings.TrimSpace(c.Data)
+ if text != "" {
+ for _, line := range strings.Split(text, "\n") {
+ w.WriteString(indent + " " + line + "\n")
+ }
+ }
+ }
+ }
+ } else {
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walkHTMLNode(w, c, depth+1)
+ }
+ }
+ w.WriteString(indent + "" + n.Data + ">\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"
+}