mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 01:32:33 +02:00
Init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ko_fi: anotherhadi
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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".
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
```txt
|
||||||
|
)
|
||||||
|
(
|
||||||
|
)
|
||||||
|
.-.,--^--. _
|
||||||
|
\\| `---' |//
|
||||||
|
\| /
|
||||||
|
_\_______/_
|
||||||
|
```
|
||||||
|
|
||||||
|
# Spilltea Documentation
|
||||||
|
|
||||||
|
- **Version**: `{{.Cfg.Version}}`
|
||||||
|
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
||||||
|
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
result/
|
||||||
@@ -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:"
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img alt="logo" src="./.github/assets/logo.png" width="120px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
# 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) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/anotherhadi/spilltea">github</a> |
|
||||||
|
<a href="https://gitlab.com/anotherhadi_mirror/spilltea">gitlab (mirror)</a> |
|
||||||
|
<a href="https://git.hadi.icu/anotherhadi/spilltea">gitea (mirror)</a>
|
||||||
|
</div
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
|
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
||||||
|
flag "github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
flagConfig = flag.StringP("config", "c", "", "path to config file")
|
||||||
|
flagHost = flag.String("host", "", "proxy host (overrides config)")
|
||||||
|
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
|
||||||
|
flagVersion = flag.BoolP("version", "v", false, "print version")
|
||||||
|
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *flagVersion {
|
||||||
|
fmt.Println(version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) {
|
||||||
|
fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
|
||||||
|
if *flagConfig != "" {
|
||||||
|
cfgPath = *flagConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Load(cfgPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
config.Global.Version = version
|
||||||
|
|
||||||
|
if *flagHost != "" {
|
||||||
|
config.Global.App.Host = *flagHost
|
||||||
|
}
|
||||||
|
if *flagPort != 0 {
|
||||||
|
config.Global.App.Port = *flagPort
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
|
||||||
|
// Check if the proxy port is available before starting the UI.
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "proxy: cannot bind to %s: %v\n", addr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
ln.Close()
|
||||||
|
|
||||||
|
style.Init(config.Global)
|
||||||
|
icons.Init(config.Global)
|
||||||
|
keys.Init(config.Global)
|
||||||
|
|
||||||
|
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
||||||
|
|
||||||
|
// Resolve project: either from --project flag or by running the home UI.
|
||||||
|
var project *homeUI.Project
|
||||||
|
if *flagProject != "" {
|
||||||
|
p, err := homeUI.OpenProject(projectDir, *flagProject)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "project: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
project = p
|
||||||
|
} else {
|
||||||
|
finalModel, err := tea.NewProgram(homeUI.New(projectDir)).Run()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
project = finalModel.(homeUI.Model).Selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// User quit the home screen without selecting a project.
|
||||||
|
if project == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
broker := intercept.NewBroker()
|
||||||
|
m := appUI.New(broker, project.Name, project.Path)
|
||||||
|
if _, err := tea.NewProgram(m).Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package spilltea
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed .github/docs
|
||||||
|
var DocsFS embed.FS
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1777954456,
|
||||||
|
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
|
|
||||||
|
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";};
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}: let
|
||||||
|
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
|
||||||
|
forAllSystems = f:
|
||||||
|
nixpkgs.lib.genAttrs supportedSystems
|
||||||
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
|
||||||
|
pname = "spilltea";
|
||||||
|
version = "0.0.1";
|
||||||
|
|
||||||
|
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||||
|
in {
|
||||||
|
packages = forAllSystems (system: pkgs: let
|
||||||
|
pkg = pkgs.buildGoModule {
|
||||||
|
inherit pname version ldflags;
|
||||||
|
|
||||||
|
src = ./.;
|
||||||
|
outputs = ["out"];
|
||||||
|
|
||||||
|
vendorHash = "sha256-u+u38Qr5Dugk5eFmxTK4vKUEv2SlXcfY6ZFlu1cPqVk=";
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
|
homepage = "https://github.com/anotherhadi/spilltea";
|
||||||
|
platforms = platforms.unix;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
"${pname}" = pkg;
|
||||||
|
default = pkg;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
module github.com/anotherhadi/spilltea
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
charm.land/bubbles/v2 v2.1.0
|
||||||
|
charm.land/bubbletea/v2 v2.0.6
|
||||||
|
charm.land/glamour/v2 v2.0.0
|
||||||
|
charm.land/lipgloss/v2 v2.0.3
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7
|
||||||
|
github.com/lqqyt2423/go-mitmproxy v1.8.11
|
||||||
|
github.com/sirupsen/logrus v1.8.1
|
||||||
|
github.com/spf13/pflag v1.0.10
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
|
golang.org/x/net v0.39.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.50.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.8 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
|
github.com/satori/go.uuid v1.2.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
|
modernc.org/libc v1.72.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||||
|
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||||
|
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
|
||||||
|
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
|
||||||
|
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
|
||||||
|
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
|
||||||
|
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||||
|
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||||
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||||
|
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||||
|
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lqqyt2423/go-mitmproxy v1.8.11 h1:Au/qwhXSlKKCkDxPVa6aSfCJeoxoH6I+7zm0Rhwzz24=
|
||||||
|
github.com/lqqyt2423/go-mitmproxy v1.8.11/go.mod h1:dSGnI17tVZ8dtYu9vnaIz7kxVwJNFH0CoNQwEQlTpxE=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
|
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||||
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
|
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||||
|
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
|
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
|
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||||
|
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||||
|
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||||
|
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||||
|
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||||
|
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type Colors struct {
|
||||||
|
Base00 string `mapstructure:"base00"`
|
||||||
|
Base01 string `mapstructure:"base01"`
|
||||||
|
Base02 string `mapstructure:"base02"`
|
||||||
|
Base03 string `mapstructure:"base03"`
|
||||||
|
Base04 string `mapstructure:"base04"`
|
||||||
|
Base05 string `mapstructure:"base05"`
|
||||||
|
Base06 string `mapstructure:"base06"`
|
||||||
|
Base07 string `mapstructure:"base07"`
|
||||||
|
Base08 string `mapstructure:"base08"`
|
||||||
|
Base09 string `mapstructure:"base09"`
|
||||||
|
Base0A string `mapstructure:"base0a"`
|
||||||
|
Base0B string `mapstructure:"base0b"`
|
||||||
|
Base0C string `mapstructure:"base0c"`
|
||||||
|
Base0D string `mapstructure:"base0d"`
|
||||||
|
Base0E string `mapstructure:"base0e"`
|
||||||
|
Base0F string `mapstructure:"base0f"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed default_config.yaml
|
||||||
|
var defaultConfig []byte
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Version string `mapstructure:"-"`
|
||||||
|
|
||||||
|
App struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
CertDir string `mapstructure:"cert_dir"`
|
||||||
|
ProjectDir string `mapstructure:"project_dir"`
|
||||||
|
PluginsDir string `mapstructure:"plugins_dir"`
|
||||||
|
} `mapstructure:"app"`
|
||||||
|
|
||||||
|
TUI struct {
|
||||||
|
Colors Colors `mapstructure:"colors"`
|
||||||
|
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
|
||||||
|
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
|
||||||
|
PrettyPrintBody bool `mapstructure:"pretty_print_body"`
|
||||||
|
} `mapstructure:"tui"`
|
||||||
|
|
||||||
|
Intercept struct {
|
||||||
|
DefaultAutoForward bool `mapstructure:"default_auto_forward"`
|
||||||
|
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
||||||
|
} `mapstructure:"intercept"`
|
||||||
|
|
||||||
|
Replay struct {
|
||||||
|
SwitchToPageOnSend bool `mapstructure:"switch_to_page_on_send"`
|
||||||
|
} `mapstructure:"replay"`
|
||||||
|
|
||||||
|
History struct {
|
||||||
|
SkipDuplicates bool `mapstructure:"skip_duplicates"`
|
||||||
|
} `mapstructure:"history"`
|
||||||
|
|
||||||
|
Keybindings Keybindings `mapstructure:"keybindings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var Global *Config
|
||||||
|
|
||||||
|
func Load(path string) error {
|
||||||
|
var defaults map[string]any
|
||||||
|
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
||||||
|
return fmt.Errorf("default config: %w", err)
|
||||||
|
}
|
||||||
|
for k, v := range flatten("", defaults) {
|
||||||
|
viper.SetDefault(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
viper.SetConfigType("yaml")
|
||||||
|
viper.SetConfigFile(path)
|
||||||
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Global = &Config{}
|
||||||
|
return viper.Unmarshal(Global)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExpandPath(p string) string {
|
||||||
|
if strings.HasPrefix(p, "~/") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return filepath.Join(home, p[2:])
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatten(prefix string, m map[string]any) map[string]any {
|
||||||
|
out := make(map[string]any)
|
||||||
|
for k, v := range m {
|
||||||
|
key := k
|
||||||
|
if prefix != "" {
|
||||||
|
key = prefix + "." + k
|
||||||
|
}
|
||||||
|
if nested, ok := v.(map[string]any); ok {
|
||||||
|
for nk, nv := range flatten(key, nested) {
|
||||||
|
out[nk] = nv
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
app:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8080
|
||||||
|
cert_dir: ~/.local/share/spilltea
|
||||||
|
project_dir: ~/.local/share/spilltea
|
||||||
|
plugins_dir: ~/.config/spilltea/plugins
|
||||||
|
|
||||||
|
intercept:
|
||||||
|
default_auto_forward: false
|
||||||
|
default_capture_response: false
|
||||||
|
|
||||||
|
replay:
|
||||||
|
switch_to_page_on_send: false
|
||||||
|
|
||||||
|
history:
|
||||||
|
skip_duplicates: false # if true, skip saving entries with the same method, host, path and body
|
||||||
|
|
||||||
|
tui:
|
||||||
|
use_nerdfont_icons: true
|
||||||
|
default_sidebar_state: "expanded" # hidden, collapsed, expanded
|
||||||
|
pretty_print_body: true # auto-indent JSON and HTML response bodies
|
||||||
|
colors:
|
||||||
|
base00: "110F12" # Default Background
|
||||||
|
base01: "1C1920" # Lighter Background (status bars, line numbers)
|
||||||
|
base02: "1D1A26" # Selection Background
|
||||||
|
base03: "514D63" # Comments, Invisibles, faint text
|
||||||
|
base04: "8E8AA0" # Dark Foreground (status bars)
|
||||||
|
base05: "C2BED6" # Default Foreground, Caret, Delimiters
|
||||||
|
base06: "D8D5EA" # Light Foreground (rarely used)
|
||||||
|
base07: "EAE7F7" # Light Background (rarely used)
|
||||||
|
base08: "E07080" # Red: errors, diff deleted
|
||||||
|
base09: "D49070" # Orange: integers, constants, warnings
|
||||||
|
base0a: "C4B060" # Yellow: classes, search highlight
|
||||||
|
base0b: "80B880" # Green: strings, diff inserted, success
|
||||||
|
base0c: "70B8C0" # Cyan: support, regex, escape chars
|
||||||
|
base0d: "9E97F8" # Blue/Accent: functions, headings, primary
|
||||||
|
base0e: "C090E8" # Purple: keywords, storage
|
||||||
|
base0f: "D080A0" # Pink: deprecated, embedded language tags
|
||||||
|
|
||||||
|
keybindings:
|
||||||
|
global:
|
||||||
|
quit: "q,ctrl+c"
|
||||||
|
open_logs: "ctrl+g"
|
||||||
|
toggle_sidebar: "ctrl+b"
|
||||||
|
help: "?"
|
||||||
|
up: "up,k"
|
||||||
|
down: "down,j"
|
||||||
|
left: "left,h"
|
||||||
|
right: "right,l"
|
||||||
|
cycle_focus: "tab"
|
||||||
|
copy_request: "ctrl+y"
|
||||||
|
send_to_replay: "ctrl+r"
|
||||||
|
scroll_up: "pgup"
|
||||||
|
scroll_down: "pgdown"
|
||||||
|
send_to_diff: "ctrl+d"
|
||||||
|
|
||||||
|
intercept:
|
||||||
|
forward: "f"
|
||||||
|
forward_all: "F"
|
||||||
|
drop: "d"
|
||||||
|
drop_all: "D"
|
||||||
|
auto_forward: "a"
|
||||||
|
capture_response: "r"
|
||||||
|
undo_edits: "ctrl+z"
|
||||||
|
edit: "e,enter"
|
||||||
|
edit_external: "E"
|
||||||
|
|
||||||
|
history:
|
||||||
|
delete_entry: "x"
|
||||||
|
delete_all: "X"
|
||||||
|
sql_query: ":"
|
||||||
|
filter: "/"
|
||||||
|
|
||||||
|
home:
|
||||||
|
open: "enter,l"
|
||||||
|
delete: "x"
|
||||||
|
filter: "/"
|
||||||
|
|
||||||
|
replay:
|
||||||
|
send: "enter,s"
|
||||||
|
edit: "e"
|
||||||
|
edit_external: "E"
|
||||||
|
undo_edits: "R"
|
||||||
|
delete_entry: "x"
|
||||||
|
delete_all: "X"
|
||||||
|
|
||||||
|
diff:
|
||||||
|
clear: "c"
|
||||||
|
|
||||||
|
findings:
|
||||||
|
dismiss: "x"
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
toggle: "space"
|
||||||
|
edit_config: "e,enter"
|
||||||
|
filter: "/"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type GlobalKeys struct {
|
||||||
|
Quit string `mapstructure:"quit"`
|
||||||
|
OpenLogs string `mapstructure:"open_logs"`
|
||||||
|
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
||||||
|
Help string `mapstructure:"help"`
|
||||||
|
Up string `mapstructure:"up"`
|
||||||
|
Down string `mapstructure:"down"`
|
||||||
|
Left string `mapstructure:"left"`
|
||||||
|
Right string `mapstructure:"right"`
|
||||||
|
CycleFocus string `mapstructure:"cycle_focus"`
|
||||||
|
CopyRequest string `mapstructure:"copy_request"`
|
||||||
|
SendToReplay string `mapstructure:"send_to_replay"`
|
||||||
|
ScrollUp string `mapstructure:"scroll_up"`
|
||||||
|
ScrollDown string `mapstructure:"scroll_down"`
|
||||||
|
SendToDiff string `mapstructure:"send_to_diff"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterceptKeys struct {
|
||||||
|
Forward string `mapstructure:"forward"`
|
||||||
|
ForwardAll string `mapstructure:"forward_all"`
|
||||||
|
Drop string `mapstructure:"drop"`
|
||||||
|
DropAll string `mapstructure:"drop_all"`
|
||||||
|
AutoForward string `mapstructure:"auto_forward"`
|
||||||
|
CaptureResponse string `mapstructure:"capture_response"`
|
||||||
|
UndoEdits string `mapstructure:"undo_edits"`
|
||||||
|
Edit string `mapstructure:"edit"`
|
||||||
|
EditExternal string `mapstructure:"edit_external"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryKeys struct {
|
||||||
|
DeleteEntry string `mapstructure:"delete_entry"`
|
||||||
|
DeleteAll string `mapstructure:"delete_all"`
|
||||||
|
Filter string `mapstructure:"filter"`
|
||||||
|
SqlQuery string `mapstructure:"sql_query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeKeys struct {
|
||||||
|
Open string `mapstructure:"open"`
|
||||||
|
Delete string `mapstructure:"delete"`
|
||||||
|
Filter string `mapstructure:"filter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplayKeys struct {
|
||||||
|
Send string `mapstructure:"send"`
|
||||||
|
Edit string `mapstructure:"edit"`
|
||||||
|
EditExt string `mapstructure:"edit_external"`
|
||||||
|
UndoEdits string `mapstructure:"undo_edits"`
|
||||||
|
Delete string `mapstructure:"delete_entry"`
|
||||||
|
DeleteAll string `mapstructure:"delete_all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiffKeys struct {
|
||||||
|
Clear string `mapstructure:"clear"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindingsKeys struct {
|
||||||
|
Dismiss string `mapstructure:"dismiss"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginsKeys struct {
|
||||||
|
Toggle string `mapstructure:"toggle"`
|
||||||
|
EditConfig string `mapstructure:"edit_config"`
|
||||||
|
Filter string `mapstructure:"filter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Keybindings struct {
|
||||||
|
Global GlobalKeys `mapstructure:"global"`
|
||||||
|
Intercept InterceptKeys `mapstructure:"intercept"`
|
||||||
|
Home HomeKeys `mapstructure:"home"`
|
||||||
|
History HistoryKeys `mapstructure:"history"`
|
||||||
|
Replay ReplayKeys `mapstructure:"replay"`
|
||||||
|
Diff DiffKeys `mapstructure:"diff"`
|
||||||
|
Findings FindingsKeys `mapstructure:"findings"`
|
||||||
|
Plugins PluginsKeys `mapstructure:"plugins"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
conn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d := &DB{conn: conn}
|
||||||
|
if err := d.migrate(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) migrate() error {
|
||||||
|
_, err := d.conn.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
status_code INTEGER NOT NULL,
|
||||||
|
request_raw TEXT NOT NULL,
|
||||||
|
response_raw TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS scope (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('whitelist','blacklist')),
|
||||||
|
pattern TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS replay_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
scheme TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
original_raw TEXT NOT NULL,
|
||||||
|
request_raw TEXT NOT NULL,
|
||||||
|
response_raw TEXT NOT NULL,
|
||||||
|
status_code INTEGER NOT NULL,
|
||||||
|
error_msg TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
config_text TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS findings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plugin_name TEXT NOT NULL,
|
||||||
|
dedup_key TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
|
dismissed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
UNIQUE(plugin_name, dedup_key)
|
||||||
|
);
|
||||||
|
INSERT INTO scope (kind, pattern)
|
||||||
|
SELECT 'blacklist', '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM scope);
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) Close() error {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountEntriesAt opens the database at path read-only, counts entries, and
|
||||||
|
// closes it immediately. Safe to call on files not yet opened by the app.
|
||||||
|
func CountEntriesAt(path string) int {
|
||||||
|
conn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
var n int
|
||||||
|
conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ID int64
|
||||||
|
Timestamp time.Time
|
||||||
|
Method string
|
||||||
|
Host string
|
||||||
|
Path string
|
||||||
|
StatusCode int
|
||||||
|
RequestRaw string
|
||||||
|
ResponseRaw string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasDuplicate returns true if an entry with the same method, host, path and
|
||||||
|
// request body already exists. Used to implement skip_duplicates filtering.
|
||||||
|
func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) {
|
||||||
|
rows, err := d.conn.Query(
|
||||||
|
`SELECT request_raw FROM entries WHERE method = ? AND host = ? AND path = ?`,
|
||||||
|
method, host, path,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var raw string
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(raw, "\n\n", 2)
|
||||||
|
entryBody := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
entryBody = parts[1]
|
||||||
|
}
|
||||||
|
if entryBody == body {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) InsertEntry(e Entry) (Entry, error) {
|
||||||
|
res, err := d.conn.Exec(
|
||||||
|
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
e.Timestamp.UTC().Format(time.RFC3339),
|
||||||
|
e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
e.ID, _ = res.LastInsertId()
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||||
|
var entries []Entry
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entry
|
||||||
|
var ts string
|
||||||
|
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListEntries() ([]Entry, error) {
|
||||||
|
rows, err := d.conn.Query(
|
||||||
|
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw
|
||||||
|
FROM entries ORDER BY id DESC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanEntries(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
||||||
|
like := "%" + term + "%"
|
||||||
|
rows, err := d.conn.Query(
|
||||||
|
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw
|
||||||
|
FROM entries
|
||||||
|
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
|
||||||
|
ORDER BY id DESC`,
|
||||||
|
like, like, like, like, like,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanEntries(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEntries executes a user-supplied query against the entries table.
|
||||||
|
// If the query does not start with SELECT, it is treated as a WHERE expression
|
||||||
|
// and wrapped automatically (e.g. "status_code = 404" becomes a full SELECT).
|
||||||
|
func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
|
||||||
|
q := strings.TrimSpace(rawSQL)
|
||||||
|
if !strings.HasPrefix(strings.ToUpper(q), "SELECT") {
|
||||||
|
q = "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw FROM entries WHERE " + q
|
||||||
|
} else if strings.ContainsAny(strings.ToUpper(q), "INSERTDELETEUPDATEDROP") {
|
||||||
|
return nil, fmt.Errorf("only SELECT queries are allowed")
|
||||||
|
}
|
||||||
|
rows, err := d.conn.Query(q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanEntries(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteEntry(id int64) error {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteAllEntries() error {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM entries`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
var findingTimeFormats = []string{time.RFC3339, "2006-01-02 15:04:05"}
|
||||||
|
|
||||||
|
type Finding struct {
|
||||||
|
ID int64
|
||||||
|
PluginName string
|
||||||
|
DedupKey string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Severity string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
|
||||||
|
// not already exist. Returns true when the row was actually inserted.
|
||||||
|
func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
||||||
|
res, err := d.conn.Exec(
|
||||||
|
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
||||||
|
f.PluginName, f.DedupKey, f.Title, f.Description, f.Severity,
|
||||||
|
f.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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> ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<!DOCTYPE " + n.Data + ">\n")
|
||||||
|
case html.CommentNode:
|
||||||
|
w.WriteString(indent + "<!--" + n.Data + "-->\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] == "<!--" {
|
||||||
|
end := strings.Index(s[i:], "-->")
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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" +
|
||||||
|
" _\\_______/_ ",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user