Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+10
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
ko_fi: anotherhadi
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+14
View File
@@ -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".
+25
View File
@@ -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`.
+15
View File
@@ -0,0 +1,15 @@
```txt
)
(
)
.-.,--^--. _
\\| `---' |//
\| /
_\_______/_
```
# Spilltea Documentation
- **Version**: `{{.Cfg.Version}}`
- **Repository**: `https://github.com/anotherhadi/spilltea`
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
+127
View File
@@ -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.
+9
View File
@@ -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.
+19
View File
@@ -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 |
+26
View File
@@ -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
+48
View File
@@ -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
+35
View File
@@ -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
+28
View File
@@ -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 }}
+3
View File
@@ -0,0 +1,3 @@
.claude/
CLAUDE.md
result/
+31
View File
@@ -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:"
+21
View File
@@ -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.
+84
View File
@@ -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 Version](https://img.shields.io/github/go-mod/go-version/anotherhadi/spilltea)](go.mod)
[![Release](https://img.shields.io/github/v/release/anotherhadi/spilltea)](https://github.com/anotherhadi/spilltea/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/anotherhadi/spilltea)](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
## What is Spilltea?
Spilltea is a **terminal-native HTTP(S) interception proxy**. It sits between your browser and the internet, letting you inspect, modify, and replay traffic without ever leaving your terminal.
It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, keyboard-driven tool that gets out of your way.
## Features
- **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding.
- **HTTP History**: Every request that passes through the proxy is stored. Browse, search and filter your full session history.
- **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration
- **Scopes**: Keep your history clean by white/blacklisting domains or specific paths.
- **HTTPS Support** (using go-mitmproxy under the hood)
- Built-in Integrations:
- **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly.
- **cURL / HTTPie**: Copy any request as a curl or httpie command to your clipboard.
- **Markdown Export**: Export any request and its response as a clean Markdown snippet, ready to drop into a report.
## Project Management
Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
On startup, you choose:
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
- **Existing project**: pick from a list of previous projects
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
## Plugin System
Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code.
For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md).
## Configuration
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
## Deployment
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
If you need to run spilltea on a remote machine (e.g., a VPS or pivot host), use SSH port forwarding:
```sh
ssh -L 8080:127.0.0.1:8080 user@remote-host
```
Then point your browser at `127.0.0.1:8080` as usual.
## Tech Stack
| Component | Library |
| ------------------ | --------------------------------------------------------- |
| TUI | [bubbletea](https://github.com/charmbracelet/bubbletea) |
| Styles | [lipgloss](https://github.com/charmbracelet/lipgloss) |
| Proxy / MITM / TLS | [go-mitmproxy](https://github.com/lqqyt2423/go-mitmproxy) |
| Storage | [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) |
| Config | [viper](https://github.com/spf13/viper) |
| Plugins | [gopher-lua](https://github.com/yuin/gopher-lua) |
---
<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
+105
View File
@@ -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)
}
}
+6
View File
@@ -0,0 +1,6 @@
package spilltea
import "embed"
//go:embed .github/docs
var DocsFS embed.FS
Generated
+27
View File
@@ -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
}
+41
View File
@@ -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;
});
};
}
+70
View File
@@ -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
)
+189
View File
@@ -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=
+20
View File
@@ -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"`
}
+101
View File
@@ -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
}
+96
View File
@@ -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: "/"
+77
View File
@@ -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"`
}
+97
View File
@@ -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
}
+131
View File
@@ -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
}
+63
View File
@@ -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
}
+35
View File
@@ -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()
}
+76
View File
@@ -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
}
+45
View File
@@ -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()
}
+51
View File
@@ -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{}
}
}
+222
View File
@@ -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
}
+18
View File
@@ -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}
}
}
+61
View File
@@ -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()
}
+20
View File
@@ -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}
}
+20
View File
@@ -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}
}
+54
View File
@@ -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,
}
}
+26
View File
@@ -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}
}
+24
View File
@@ -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}
}
+41
View File
@@ -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,
}
}
+72
View File
@@ -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
}
+24
View File
@@ -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}
}
+30
View File
@@ -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}
}
+15
View File
@@ -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}
}
}
+206
View File
@@ -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
}
+346
View File
@@ -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)
}
}
+66
View File
@@ -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
}
+128
View File
@@ -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"),
}
}
+43
View File
@@ -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)
}
+84
View File
@@ -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
}
+236
View File
@@ -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> ",
},
}
}
+333
View File
@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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()
}
+100
View File
@@ -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)
}
}
+137
View File
@@ -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 },
)
}
+146
View File
@@ -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) },
},
}
+88
View File
@@ -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)
}
+238
View File
@@ -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)
}
+49
View File
@@ -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")
}
+200
View File
@@ -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()
}
+117
View File
@@ -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
}
+30
View File
@@ -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
}
+93
View File
@@ -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")
}
+72
View File
@@ -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" +
" _\\_______/_ ",
}
}
+264
View File
@@ -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)
}
+143
View File
@@ -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
}
+94
View File
@@ -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()
}
+37
View File
@@ -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
}
+50
View File
@@ -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()
}
+52
View File
@@ -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)
}
+156
View File
@@ -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()}
}
+86
View File
@@ -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())
}
+112
View File
@@ -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()
}
+149
View File
@@ -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)
}
+48
View File
@@ -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}
}
}
+303
View File
@@ -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))
}
+150
View File
@@ -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()
}
+339
View File
@@ -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)
}
+180
View File
@@ -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()
}
}
}
}
+101
View File
@@ -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)
}
+384
View File
@@ -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()))
}
+34
View File
@@ -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)
}
+118
View File
@@ -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()
}
+296
View File
@@ -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...)
}
+220
View File
@@ -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()
}
+180
View File
@@ -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()}
}
+130
View File
@@ -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
}
+150
View File
@@ -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()
}
+175
View File
@@ -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)
}
+413
View File
@@ -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
}
+137
View File
@@ -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)
}
+150
View File
@@ -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()}
}
+70
View File
@@ -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)
}
+84
View File
@@ -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
}
+38
View File
@@ -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